Skip to main content

flatland_client_lib/
social.rs

1//! Client-side multiplayer chat + trade UI state.
2
3use std::collections::VecDeque;
4
5use flatland_protocol::{
6    ChatChannel, ChatClarity, ChatMessage, EntityId, ItemStack, TradePanel,
7};
8
9/// Soft UI tones (synthesized; .wav hooks come later).
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum AudioCue {
12    TradeOffer,
13    Whisper,
14    NearbySpeech,
15    TradeOpened,
16    TradeDeclined,
17}
18
19/// Comic "…" bubble above a speaking player (Nearby / Direct only).
20#[derive(Debug, Clone, PartialEq)]
21pub struct SpeakingBubble {
22    pub entity_id: EntityId,
23    pub until_ms: u64,
24    pub rgb: (u8, u8, u8),
25}
26
27pub const SPEECH_BUBBLE_MS: u64 = 2800;
28
29/// Local player's chat color (mint) — distinct from hashed peer colors.
30pub fn self_chat_rgb() -> (u8, u8, u8) {
31    (110, 220, 195)
32}
33
34/// Stable pastel from entity id (same color in CHAT log and world bubble).
35pub fn speaker_chat_rgb(entity_id: EntityId) -> (u8, u8, u8) {
36    let h = (entity_id.wrapping_mul(2654435761) % 360) as f32;
37    hsl_to_rgb(h, 0.58, 0.62)
38}
39
40fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
41    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
42    let hp = h / 60.0;
43    let x = c * (1.0 - ((hp % 2.0) - 1.0).abs());
44    let (r1, g1, b1) = match hp as i32 {
45        0 => (c, x, 0.0),
46        1 => (x, c, 0.0),
47        2 => (0.0, c, x),
48        3 => (0.0, x, c),
49        4 => (x, 0.0, c),
50        _ => (c, 0.0, x),
51    };
52    let m = l - c / 2.0;
53    (
54        ((r1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
55        ((g1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
56        ((b1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
57    )
58}
59
60/// One line in the always-visible chat log / active thread.
61#[derive(Debug, Clone, PartialEq)]
62pub struct ChatLogEntry {
63    pub channel: ChatChannel,
64    pub from_entity: EntityId,
65    pub from_name: String,
66    pub to_entity: Option<EntityId>,
67    pub text: String,
68    pub tick: u64,
69    pub clarity: ChatClarity,
70    /// System / trade-request lines (not player speech).
71    pub system: bool,
72    /// Stable speaker color for this line (matches world bubble).
73    pub rgb: (u8, u8, u8),
74}
75
76impl ChatLogEntry {
77    pub fn from_message(msg: ChatMessage, self_id: EntityId) -> Self {
78        let rgb = if msg.from_entity == self_id {
79            self_chat_rgb()
80        } else {
81            speaker_chat_rgb(msg.from_entity)
82        };
83        Self {
84            channel: msg.channel,
85            from_entity: msg.from_entity,
86            from_name: msg.from_name,
87            to_entity: msg.to_entity,
88            text: msg.text,
89            tick: msg.tick,
90            clarity: msg.clarity,
91            system: false,
92            rgb,
93        }
94    }
95
96    pub fn system_line(text: impl Into<String>) -> Self {
97        Self {
98            channel: ChatChannel::Nearby,
99            from_entity: 0,
100            from_name: "system".into(),
101            to_entity: None,
102            text: text.into(),
103            tick: 0,
104            clarity: ChatClarity::Clear,
105            system: true,
106            rgb: (140, 145, 155),
107        }
108    }
109}
110
111/// Where chat input will send.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum ChatThreadKind {
114    /// Loud nearby speech (default).
115    Nearby,
116    /// Private directed whisper (range-gated on send).
117    Whisper { peer: EntityId },
118    /// Long-range paired whisper stone.
119    Stone { peer: EntityId },
120}
121
122impl ChatThreadKind {
123    pub fn channel(self) -> ChatChannel {
124        match self {
125            Self::Nearby => ChatChannel::Nearby,
126            Self::Whisper { .. } => ChatChannel::Whisper,
127            Self::Stone { .. } => ChatChannel::WhisperStone,
128        }
129    }
130
131    pub fn to_entity(self) -> Option<EntityId> {
132        match self {
133            Self::Nearby => None,
134            Self::Whisper { peer } | Self::Stone { peer } => Some(peer),
135        }
136    }
137
138    pub fn mode_label(self, peer_name: &str) -> String {
139        match self {
140            Self::Nearby => "Nearby".into(),
141            Self::Whisper { .. } => format!("Whisper → {peer_name}"),
142            Self::Stone { .. } => format!("Stone → {peer_name}"),
143        }
144    }
145}
146
147impl Default for ChatThreadKind {
148    fn default() -> Self {
149        Self::Nearby
150    }
151}
152
153/// Inbound trade request waiting for Y/N in the chat column.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct PendingTradeRequest {
156    pub from_entity: EntityId,
157    pub from_name: String,
158}
159
160#[derive(Debug, Clone)]
161pub struct SocialChatState {
162    pub log: Vec<ChatLogEntry>,
163    /// Keyboard focus on the chat input (sidebar) — not a popup.
164    pub input_focused: bool,
165    pub buffer: String,
166    pub thread: ChatThreadKind,
167    pub peer_label: String,
168    pub log_hidden: bool,
169    /// Peer asked to trade — show inline accept in CHAT.
170    pub pending_trade: Option<PendingTradeRequest>,
171    /// Picking a whisper-stone contact inside the chat column (`g`).
172    pub picking_stone: bool,
173    pub stone_pick_index: usize,
174    /// Active "…" bubbles above speakers.
175    pub speaking_bubbles: Vec<SpeakingBubble>,
176    /// Soft tones for the gfx client to drain/play.
177    pub audio_cues: VecDeque<AudioCue>,
178}
179
180impl Default for SocialChatState {
181    fn default() -> Self {
182        Self {
183            log: Vec::new(),
184            input_focused: false,
185            buffer: String::new(),
186            thread: ChatThreadKind::Nearby,
187            peer_label: String::new(),
188            log_hidden: false,
189            pending_trade: None,
190            picking_stone: false,
191            stone_pick_index: 0,
192            speaking_bubbles: Vec::new(),
193            audio_cues: VecDeque::new(),
194        }
195    }
196}
197
198impl SocialChatState {
199    pub const MAX_LOG: usize = 200;
200    pub const MAX_BUF: usize = 200;
201
202    pub fn push(&mut self, entry: ChatLogEntry) {
203        self.log.push(entry);
204        if self.log.len() > Self::MAX_LOG {
205            let drop = self.log.len() - Self::MAX_LOG;
206            self.log.drain(0..drop);
207        }
208    }
209
210    pub fn push_system(&mut self, text: impl Into<String>) {
211        self.push(ChatLogEntry::system_line(text));
212    }
213
214    pub fn push_cue(&mut self, cue: AudioCue) {
215        self.audio_cues.push_back(cue);
216        while self.audio_cues.len() > 8 {
217            self.audio_cues.pop_front();
218        }
219    }
220
221    pub fn drain_audio_cues(&mut self) -> Vec<AudioCue> {
222        self.audio_cues.drain(..).collect()
223    }
224
225    /// Record a speech bubble + optional tone for an inbound chat line.
226    pub fn note_speech(
227        &mut self,
228        msg: &ChatMessage,
229        self_id: EntityId,
230        now_ms: u64,
231    ) {
232        let rgb = if msg.from_entity == self_id {
233            self_chat_rgb()
234        } else {
235            speaker_chat_rgb(msg.from_entity)
236        };
237        match msg.channel {
238            ChatChannel::Nearby | ChatChannel::Direct => {
239                self.speaking_bubbles
240                    .retain(|b| b.entity_id != msg.from_entity && b.until_ms > now_ms);
241                self.speaking_bubbles.push(SpeakingBubble {
242                    entity_id: msg.from_entity,
243                    until_ms: now_ms.saturating_add(SPEECH_BUBBLE_MS),
244                    rgb,
245                });
246                if msg.from_entity != self_id {
247                    self.push_cue(AudioCue::NearbySpeech);
248                }
249            }
250            ChatChannel::Whisper | ChatChannel::WhisperStone => {
251                if msg.from_entity != self_id {
252                    self.push_cue(AudioCue::Whisper);
253                }
254            }
255        }
256    }
257
258    pub fn prune_bubbles(&mut self, now_ms: u64) {
259        self.speaking_bubbles.retain(|b| b.until_ms > now_ms);
260    }
261
262    /// Focus chat for nearby speech (`t`).
263    pub fn focus_nearby(&mut self) {
264        self.picking_stone = false;
265        self.thread = ChatThreadKind::Nearby;
266        self.peer_label.clear();
267        self.input_focused = true;
268    }
269
270    /// Focus chat whispering a nearby player (`f` → Whisper).
271    pub fn focus_whisper(&mut self, peer: EntityId, label: &str) {
272        self.picking_stone = false;
273        self.thread = ChatThreadKind::Whisper { peer };
274        self.peer_label = label.to_string();
275        self.input_focused = true;
276        self.push_system(format!("Whispering {label} — type and Enter · Esc cancels"));
277    }
278
279    pub fn focus_stone(&mut self, peer: EntityId, label: &str) {
280        self.picking_stone = false;
281        self.thread = ChatThreadKind::Stone { peer };
282        self.peer_label = label.to_string();
283        self.input_focused = true;
284        self.push_system(format!("Stone link to {label} — type and Enter"));
285    }
286
287    pub fn unfocus(&mut self) {
288        self.input_focused = false;
289        self.picking_stone = false;
290        // Keep whisper/stone thread until they switch; Esc from whisper returns to nearby.
291        if matches!(self.thread, ChatThreadKind::Whisper { .. }) {
292            self.thread = ChatThreadKind::Nearby;
293            self.peer_label.clear();
294        }
295        self.buffer.clear();
296    }
297
298    /// Drop directed whisper when the peer is out of interact range (or gone).
299    /// Returns true if whisper mode was cancelled.
300    pub fn cancel_whisper_out_of_range(&mut self) -> bool {
301        let ChatThreadKind::Whisper { .. } = self.thread else {
302            return false;
303        };
304        let label = if self.peer_label.is_empty() {
305            "peer".to_string()
306        } else {
307            self.peer_label.clone()
308        };
309        self.thread = ChatThreadKind::Nearby;
310        self.peer_label.clear();
311        self.input_focused = false;
312        self.picking_stone = false;
313        self.buffer.clear();
314        self.push_system(format!("Whisper with {label} ended — out of range"));
315        true
316    }
317
318    pub fn prompt_prefix(&self) -> &'static str {
319        match self.thread {
320            ChatThreadKind::Nearby => "say",
321            ChatThreadKind::Whisper { .. } => "whisper",
322            ChatThreadKind::Stone { .. } => "stone",
323        }
324    }
325
326    /// Legacy alias used by older call sites.
327    pub fn composer_open(&self) -> bool {
328        self.input_focused || self.picking_stone
329    }
330
331    pub fn open_nearby(&mut self) {
332        self.focus_nearby();
333    }
334
335    pub fn open_direct(&mut self, peer: EntityId, label: &str, whisper: bool) {
336        if whisper {
337            self.focus_whisper(peer, label);
338        } else {
339            // Speak aloud = nearby (no directed speak UI).
340            self.focus_nearby();
341            self.push_system(format!("Nearby speech — {label} can hear if close"));
342        }
343    }
344
345    pub fn open_stone(&mut self, peer: EntityId, label: &str) {
346        self.focus_stone(peer, label);
347    }
348
349    pub fn close_composer(&mut self) {
350        self.unfocus();
351    }
352
353    pub fn toggle_mode_speak_whisper(&mut self) {
354        // Tab no longer toggles speak↔whisper; nearby is always loud.
355        // If whispering, Tab returns to nearby.
356        if matches!(self.thread, ChatThreadKind::Whisper { .. } | ChatThreadKind::Stone { .. }) {
357            self.thread = ChatThreadKind::Nearby;
358            self.peer_label.clear();
359            self.push_system("Switched to Nearby speech");
360        }
361    }
362}
363
364#[derive(Debug, Clone, Default)]
365pub struct PlayerVerbState {
366    pub open: bool,
367    pub target_entity: Option<EntityId>,
368    pub target_label: String,
369    pub index: usize,
370}
371
372impl PlayerVerbState {
373    /// Speak is ambient (chat section); `f` is only private whisper + trade.
374    pub fn options() -> &'static [&'static str] {
375        &["Whisper", "Trade"]
376    }
377
378    pub fn open_for(&mut self, entity: EntityId, label: &str) {
379        self.open = true;
380        self.target_entity = Some(entity);
381        self.target_label = label.to_string();
382        self.index = 0;
383    }
384
385    pub fn close(&mut self) {
386        self.open = false;
387        self.target_entity = None;
388        self.target_label.clear();
389        self.index = 0;
390    }
391}
392
393#[derive(Debug, Clone)]
394pub struct TradeQtyEntry {
395    pub item_instance_id: uuid::Uuid,
396    pub label: String,
397    pub max_qty: u32,
398    /// Current offer quantity (1..=max_qty).
399    pub quantity: u32,
400    /// Digits typed this session (empty → showing quantity from [ ] / a).
401    pub typed: String,
402}
403
404#[derive(Debug, Clone, Default)]
405pub struct TradeUiState {
406    pub panel: Option<TradePanel>,
407    pub select_index: usize,
408    pub picking_inventory: bool,
409    pub inventory_index: usize,
410    /// Presenting a stack — choose how many (like storage).
411    pub qty_entry: Option<TradeQtyEntry>,
412}
413
414impl TradeUiState {
415    pub fn open(&mut self, panel: TradePanel) {
416        self.panel = Some(panel);
417        self.select_index = 0;
418        self.picking_inventory = false;
419        self.qty_entry = None;
420    }
421
422    pub fn close(&mut self) {
423        self.panel = None;
424        self.picking_inventory = false;
425        self.qty_entry = None;
426    }
427
428    pub fn apply(&mut self, panel: TradePanel) {
429        self.panel = Some(panel);
430    }
431
432    pub fn begin_qty_entry(
433        &mut self,
434        item_instance_id: uuid::Uuid,
435        label: String,
436        max_qty: u32,
437    ) {
438        let max_qty = max_qty.max(1);
439        self.qty_entry = Some(TradeQtyEntry {
440            item_instance_id,
441            label,
442            max_qty,
443            quantity: max_qty,
444            typed: String::new(),
445        });
446        self.picking_inventory = false;
447    }
448
449    pub fn adjust_qty(&mut self, delta: i32) {
450        let Some(entry) = self.qty_entry.as_mut() else {
451            return;
452        };
453        entry.typed.clear();
454        let next = (entry.quantity as i32 + delta).clamp(1, entry.max_qty as i32);
455        entry.quantity = next as u32;
456    }
457
458    pub fn set_qty_all(&mut self) {
459        if let Some(entry) = self.qty_entry.as_mut() {
460            entry.typed.clear();
461            entry.quantity = entry.max_qty;
462        }
463    }
464
465    pub fn append_qty_digit(&mut self, c: char) {
466        let Some(entry) = self.qty_entry.as_mut() else {
467            return;
468        };
469        if !c.is_ascii_digit() || entry.typed.len() >= 8 {
470            return;
471        }
472        entry.typed.push(c);
473        let parsed = entry.typed.parse::<u32>().unwrap_or(1);
474        entry.quantity = parsed.clamp(1, entry.max_qty);
475    }
476
477    pub fn qty_backspace(&mut self) {
478        let Some(entry) = self.qty_entry.as_mut() else {
479            return;
480        };
481        if !entry.typed.is_empty() {
482            entry.typed.pop();
483            entry.quantity = if entry.typed.is_empty() {
484                1
485            } else {
486                entry
487                    .typed
488                    .parse::<u32>()
489                    .unwrap_or(1)
490                    .clamp(1, entry.max_qty)
491            };
492            return;
493        }
494        entry.quantity = (entry.quantity / 10).max(1);
495    }
496
497    /// Intent quantity: `None` means entire stack.
498    pub fn present_quantity(&self) -> Option<u32> {
499        let entry = self.qty_entry.as_ref()?;
500        if entry.quantity >= entry.max_qty {
501            None
502        } else {
503            Some(entry.quantity)
504        }
505    }
506}
507
508#[derive(Debug, Clone, Default)]
509pub struct WhisperPouchUi {
510    pub open: bool,
511    pub index: usize,
512}
513
514#[derive(Debug, Clone)]
515pub struct WhisperContact {
516    pub instance_id: uuid::Uuid,
517    pub peer_label: String,
518    pub peer_character_id: Option<uuid::Uuid>,
519    pub pair_id: Option<String>,
520    pub blank: bool,
521}
522
523pub fn contacts_from_stacks(stacks: &[ItemStack]) -> Vec<WhisperContact> {
524    stacks
525        .iter()
526        .filter(|s| s.template_id == "whisper_stone")
527        .map(|s| {
528            let pair_id = s.props.get("whisper_pair_id").cloned();
529            let peer_label = s.props.get("peer_label").cloned().unwrap_or_else(|| {
530                if pair_id.is_some() {
531                    "Unknown".into()
532                } else {
533                    "Blank stone".into()
534                }
535            });
536            let peer_character_id = s
537                .props
538                .get("peer_character_id")
539                .and_then(|v| uuid::Uuid::parse_str(v).ok());
540            WhisperContact {
541                instance_id: s.item_instance_id.unwrap_or_default(),
542                peer_label,
543                peer_character_id,
544                pair_id,
545                blank: !s.props.contains_key("whisper_pair_id"),
546            }
547        })
548        .collect()
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn cancel_whisper_out_of_range_clears_thread() {
557        let mut chat = SocialChatState::default();
558        chat.focus_whisper(42, "Ada");
559        assert!(matches!(
560            chat.thread,
561            ChatThreadKind::Whisper { peer: 42 }
562        ));
563        assert!(chat.input_focused);
564        assert!(chat.cancel_whisper_out_of_range());
565        assert_eq!(chat.thread, ChatThreadKind::Nearby);
566        assert!(!chat.input_focused);
567        assert!(chat.buffer.is_empty());
568        assert!(chat
569            .log
570            .last()
571            .is_some_and(|e| e.system && e.text.contains("out of range")));
572    }
573
574    #[test]
575    fn cancel_whisper_noop_when_nearby_or_stone() {
576        let mut chat = SocialChatState::default();
577        chat.focus_nearby();
578        assert!(!chat.cancel_whisper_out_of_range());
579        chat.focus_stone(7, "Bob");
580        assert!(!chat.cancel_whisper_out_of_range());
581        assert!(matches!(chat.thread, ChatThreadKind::Stone { peer: 7 }));
582    }
583}