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/// Last private chat peer — used by `/r` and bare `/whisper`.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct LastWhisperPeer {
163    pub entity_id: EntityId,
164    pub label: String,
165    /// `Whisper` (range) or `WhisperStone` (long-range).
166    pub channel: ChatChannel,
167}
168
169/// Slash commands typed in the chat buffer (leading `/`).
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum ChatSlashCommand {
172    Help,
173    Nearby {
174        message: Option<String>,
175    },
176    /// Reply to [`SocialChatState::last_whisper_peer`].
177    Reply {
178        message: Option<String>,
179    },
180    /// Whisper a named player (or last peer when `name` is None).
181    Whisper {
182        name: Option<String>,
183        message: Option<String>,
184    },
185}
186
187/// Parse a leading `/command` from chat input. Returns `None` for normal speech.
188pub fn parse_chat_slash(text: &str) -> Option<ChatSlashCommand> {
189    let trimmed = text.trim();
190    if !trimmed.starts_with('/') {
191        return None;
192    }
193    let rest = trimmed[1..].trim_start();
194    if rest.is_empty() {
195        return Some(ChatSlashCommand::Help);
196    }
197    let (cmd, args) = match rest.split_once(char::is_whitespace) {
198        Some((c, a)) => (c, a.trim()),
199        None => (rest, ""),
200    };
201    let cmd_l = cmd.to_ascii_lowercase();
202    let message = if args.is_empty() {
203        None
204    } else {
205        Some(args.to_string())
206    };
207    match cmd_l.as_str() {
208        "help" | "h" | "?" => Some(ChatSlashCommand::Help),
209        "nearby" | "n" | "say" | "s" => Some(ChatSlashCommand::Nearby { message }),
210        "reply" | "r" => Some(ChatSlashCommand::Reply { message }),
211        "whisper" | "w" => {
212            // `/w` → last peer; `/w Name` → mode; `/w Name hi there` → send
213            if args.is_empty() {
214                return Some(ChatSlashCommand::Whisper {
215                    name: None,
216                    message: None,
217                });
218            }
219            let (name, msg) = match args.split_once(char::is_whitespace) {
220                Some((n, m)) => {
221                    let m = m.trim();
222                    (n.to_string(), if m.is_empty() { None } else { Some(m.to_string()) })
223                }
224                None => (args.to_string(), None),
225            };
226            Some(ChatSlashCommand::Whisper {
227                name: Some(name),
228                message: msg,
229            })
230        }
231        _ => None, // unknown `/foo` — fall through as normal text? Better treat as help hint.
232    }
233}
234
235/// True when `parse_chat_slash` would consume the line (including unknown `/cmd`).
236pub fn is_chat_slash_line(text: &str) -> bool {
237    text.trim_start().starts_with('/')
238}
239
240pub fn chat_slash_help_text() -> &'static str {
241    "Chat commands: /nearby [/n] · /whisper Name [/w] · /reply [/r] · /help — optional message after the name"
242}
243
244#[derive(Debug, Clone)]
245pub struct SocialChatState {
246    pub log: Vec<ChatLogEntry>,
247    /// Keyboard focus on the chat input (sidebar) — not a popup.
248    pub input_focused: bool,
249    pub buffer: String,
250    pub thread: ChatThreadKind,
251    pub peer_label: String,
252    pub log_hidden: bool,
253    /// Peer asked to trade — show inline accept in CHAT.
254    pub pending_trade: Option<PendingTradeRequest>,
255    /// Last private message peer (for `/r` / bare `/whisper`).
256    pub last_whisper_peer: Option<LastWhisperPeer>,
257    /// Picking a whisper-stone contact inside the chat column (`g`).
258    pub picking_stone: bool,
259    pub stone_pick_index: usize,
260    /// Active "…" bubbles above speakers.
261    pub speaking_bubbles: Vec<SpeakingBubble>,
262    /// Soft tones for the gfx client to drain/play.
263    pub audio_cues: VecDeque<AudioCue>,
264}
265
266impl Default for SocialChatState {
267    fn default() -> Self {
268        Self {
269            log: Vec::new(),
270            input_focused: false,
271            buffer: String::new(),
272            thread: ChatThreadKind::Nearby,
273            peer_label: String::new(),
274            log_hidden: false,
275            pending_trade: None,
276            last_whisper_peer: None,
277            picking_stone: false,
278            stone_pick_index: 0,
279            speaking_bubbles: Vec::new(),
280            audio_cues: VecDeque::new(),
281        }
282    }
283}
284
285impl SocialChatState {
286    pub const MAX_LOG: usize = 200;
287    pub const MAX_BUF: usize = 200;
288
289    pub fn push(&mut self, entry: ChatLogEntry) {
290        self.log.push(entry);
291        if self.log.len() > Self::MAX_LOG {
292            let drop = self.log.len() - Self::MAX_LOG;
293            self.log.drain(0..drop);
294        }
295    }
296
297    pub fn push_system(&mut self, text: impl Into<String>) {
298        self.push(ChatLogEntry::system_line(text));
299    }
300
301    pub fn push_cue(&mut self, cue: AudioCue) {
302        self.audio_cues.push_back(cue);
303        while self.audio_cues.len() > 8 {
304            self.audio_cues.pop_front();
305        }
306    }
307
308    pub fn drain_audio_cues(&mut self) -> Vec<AudioCue> {
309        self.audio_cues.drain(..).collect()
310    }
311
312    /// Record a speech bubble + optional tone for an inbound chat line.
313    pub fn note_speech(
314        &mut self,
315        msg: &ChatMessage,
316        self_id: EntityId,
317        now_ms: u64,
318    ) {
319        let rgb = if msg.from_entity == self_id {
320            self_chat_rgb()
321        } else {
322            speaker_chat_rgb(msg.from_entity)
323        };
324        match msg.channel {
325            ChatChannel::Nearby | ChatChannel::Direct => {
326                self.speaking_bubbles
327                    .retain(|b| b.entity_id != msg.from_entity && b.until_ms > now_ms);
328                self.speaking_bubbles.push(SpeakingBubble {
329                    entity_id: msg.from_entity,
330                    until_ms: now_ms.saturating_add(SPEECH_BUBBLE_MS),
331                    rgb,
332                });
333                if msg.from_entity != self_id {
334                    self.push_cue(AudioCue::NearbySpeech);
335                }
336            }
337            ChatChannel::Whisper | ChatChannel::WhisperStone => {
338                if msg.from_entity != self_id {
339                    self.push_cue(AudioCue::Whisper);
340                    self.remember_whisper_peer(msg.from_entity, &msg.from_name, msg.channel);
341                }
342            }
343        }
344    }
345
346    pub fn remember_whisper_peer(
347        &mut self,
348        entity_id: EntityId,
349        label: &str,
350        channel: ChatChannel,
351    ) {
352        if !matches!(
353            channel,
354            ChatChannel::Whisper | ChatChannel::WhisperStone
355        ) {
356            return;
357        }
358        self.last_whisper_peer = Some(LastWhisperPeer {
359            entity_id,
360            label: label.to_string(),
361            channel,
362        });
363    }
364
365    /// Switch to whisper/stone mode without the long system banner (slash commands).
366    pub fn set_whisper_thread(&mut self, peer: EntityId, label: &str, stone: bool) {
367        self.picking_stone = false;
368        self.peer_label = label.to_string();
369        self.thread = if stone {
370            ChatThreadKind::Stone { peer }
371        } else {
372            ChatThreadKind::Whisper { peer }
373        };
374        self.input_focused = true;
375        self.remember_whisper_peer(
376            peer,
377            label,
378            if stone {
379                ChatChannel::WhisperStone
380            } else {
381                ChatChannel::Whisper
382            },
383        );
384    }
385
386    pub fn prune_bubbles(&mut self, now_ms: u64) {
387        self.speaking_bubbles.retain(|b| b.until_ms > now_ms);
388    }
389
390    /// Focus chat for nearby speech (`t`).
391    pub fn focus_nearby(&mut self) {
392        self.picking_stone = false;
393        self.thread = ChatThreadKind::Nearby;
394        self.peer_label.clear();
395        self.input_focused = true;
396    }
397
398    /// Focus chat whispering a nearby player (`f` → Whisper).
399    pub fn focus_whisper(&mut self, peer: EntityId, label: &str) {
400        self.set_whisper_thread(peer, label, false);
401        self.push_system(format!("Whispering {label} — type and Enter · Esc cancels · /nearby"));
402    }
403
404    pub fn focus_stone(&mut self, peer: EntityId, label: &str) {
405        self.set_whisper_thread(peer, label, true);
406        self.push_system(format!("Stone link to {label} — type and Enter · /nearby"));
407    }
408
409    pub fn unfocus(&mut self) {
410        self.input_focused = false;
411        self.picking_stone = false;
412        // Keep whisper/stone thread until they switch; Esc from whisper returns to nearby.
413        if matches!(self.thread, ChatThreadKind::Whisper { .. }) {
414            self.thread = ChatThreadKind::Nearby;
415            self.peer_label.clear();
416        }
417        self.buffer.clear();
418    }
419
420    /// Drop directed whisper when the peer is out of interact range (or gone).
421    /// Returns true if whisper mode was cancelled.
422    pub fn cancel_whisper_out_of_range(&mut self) -> bool {
423        let ChatThreadKind::Whisper { .. } = self.thread else {
424            return false;
425        };
426        let label = if self.peer_label.is_empty() {
427            "peer".to_string()
428        } else {
429            self.peer_label.clone()
430        };
431        self.thread = ChatThreadKind::Nearby;
432        self.peer_label.clear();
433        self.input_focused = false;
434        self.picking_stone = false;
435        self.buffer.clear();
436        self.push_system(format!("Whisper with {label} ended — out of range"));
437        true
438    }
439
440    pub fn prompt_prefix(&self) -> &'static str {
441        match self.thread {
442            ChatThreadKind::Nearby => "say",
443            ChatThreadKind::Whisper { .. } => "whisper",
444            ChatThreadKind::Stone { .. } => "stone",
445        }
446    }
447
448    /// Legacy alias used by older call sites.
449    pub fn composer_open(&self) -> bool {
450        self.input_focused || self.picking_stone
451    }
452
453    pub fn open_nearby(&mut self) {
454        self.focus_nearby();
455    }
456
457    pub fn open_direct(&mut self, peer: EntityId, label: &str, whisper: bool) {
458        if whisper {
459            self.focus_whisper(peer, label);
460        } else {
461            // Speak aloud = nearby (no directed speak UI).
462            self.focus_nearby();
463            self.push_system(format!("Nearby speech — {label} can hear if close"));
464        }
465    }
466
467    pub fn open_stone(&mut self, peer: EntityId, label: &str) {
468        self.focus_stone(peer, label);
469    }
470
471    pub fn close_composer(&mut self) {
472        self.unfocus();
473    }
474
475    pub fn toggle_mode_speak_whisper(&mut self) {
476        // Tab no longer toggles speak↔whisper; nearby is always loud.
477        // If whispering, Tab returns to nearby.
478        if matches!(self.thread, ChatThreadKind::Whisper { .. } | ChatThreadKind::Stone { .. }) {
479            self.thread = ChatThreadKind::Nearby;
480            self.peer_label.clear();
481            self.push_system("Switched to Nearby speech");
482        }
483    }
484}
485
486#[derive(Debug, Clone, Default)]
487pub struct PlayerVerbState {
488    pub open: bool,
489    pub target_entity: Option<EntityId>,
490    pub target_label: String,
491    pub index: usize,
492}
493
494impl PlayerVerbState {
495    /// Speak is ambient (chat section); `f` is only private whisper + trade.
496    pub fn options() -> &'static [&'static str] {
497        &["Whisper", "Trade"]
498    }
499
500    pub fn open_for(&mut self, entity: EntityId, label: &str) {
501        self.open = true;
502        self.target_entity = Some(entity);
503        self.target_label = label.to_string();
504        self.index = 0;
505    }
506
507    pub fn close(&mut self) {
508        self.open = false;
509        self.target_entity = None;
510        self.target_label.clear();
511        self.index = 0;
512    }
513}
514
515#[derive(Debug, Clone)]
516pub struct TradeQtyEntry {
517    pub item_instance_id: uuid::Uuid,
518    pub label: String,
519    pub max_qty: u32,
520    /// Current offer quantity (1..=max_qty).
521    pub quantity: u32,
522    /// Digits typed this session (empty → showing quantity from [ ] / a).
523    pub typed: String,
524}
525
526#[derive(Debug, Clone, Default)]
527pub struct TradeUiState {
528    pub panel: Option<TradePanel>,
529    pub select_index: usize,
530    pub picking_inventory: bool,
531    pub inventory_index: usize,
532    /// Presenting a stack — choose how many (like storage).
533    pub qty_entry: Option<TradeQtyEntry>,
534}
535
536impl TradeUiState {
537    pub fn open(&mut self, panel: TradePanel) {
538        self.panel = Some(panel);
539        self.select_index = 0;
540        self.picking_inventory = false;
541        self.qty_entry = None;
542    }
543
544    pub fn close(&mut self) {
545        self.panel = None;
546        self.picking_inventory = false;
547        self.qty_entry = None;
548    }
549
550    pub fn apply(&mut self, panel: TradePanel) {
551        self.panel = Some(panel);
552    }
553
554    pub fn begin_qty_entry(
555        &mut self,
556        item_instance_id: uuid::Uuid,
557        label: String,
558        max_qty: u32,
559    ) {
560        let max_qty = max_qty.max(1);
561        self.qty_entry = Some(TradeQtyEntry {
562            item_instance_id,
563            label,
564            max_qty,
565            quantity: max_qty,
566            typed: String::new(),
567        });
568        self.picking_inventory = false;
569    }
570
571    pub fn adjust_qty(&mut self, delta: i32) {
572        let Some(entry) = self.qty_entry.as_mut() else {
573            return;
574        };
575        entry.typed.clear();
576        let next = (entry.quantity as i32 + delta).clamp(1, entry.max_qty as i32);
577        entry.quantity = next as u32;
578    }
579
580    pub fn set_qty_all(&mut self) {
581        if let Some(entry) = self.qty_entry.as_mut() {
582            entry.typed.clear();
583            entry.quantity = entry.max_qty;
584        }
585    }
586
587    pub fn append_qty_digit(&mut self, c: char) {
588        let Some(entry) = self.qty_entry.as_mut() else {
589            return;
590        };
591        if !c.is_ascii_digit() || entry.typed.len() >= 8 {
592            return;
593        }
594        entry.typed.push(c);
595        let parsed = entry.typed.parse::<u32>().unwrap_or(1);
596        entry.quantity = parsed.clamp(1, entry.max_qty);
597    }
598
599    pub fn qty_backspace(&mut self) {
600        let Some(entry) = self.qty_entry.as_mut() else {
601            return;
602        };
603        if !entry.typed.is_empty() {
604            entry.typed.pop();
605            entry.quantity = if entry.typed.is_empty() {
606                1
607            } else {
608                entry
609                    .typed
610                    .parse::<u32>()
611                    .unwrap_or(1)
612                    .clamp(1, entry.max_qty)
613            };
614            return;
615        }
616        entry.quantity = (entry.quantity / 10).max(1);
617    }
618
619    /// Intent quantity: `None` means entire stack.
620    pub fn present_quantity(&self) -> Option<u32> {
621        let entry = self.qty_entry.as_ref()?;
622        if entry.quantity >= entry.max_qty {
623            None
624        } else {
625            Some(entry.quantity)
626        }
627    }
628}
629
630#[derive(Debug, Clone, Default)]
631pub struct WhisperPouchUi {
632    pub open: bool,
633    pub index: usize,
634}
635
636#[derive(Debug, Clone)]
637pub struct WhisperContact {
638    pub instance_id: uuid::Uuid,
639    pub peer_label: String,
640    pub peer_character_id: Option<uuid::Uuid>,
641    pub pair_id: Option<String>,
642    pub blank: bool,
643}
644
645pub fn contacts_from_stacks(stacks: &[ItemStack]) -> Vec<WhisperContact> {
646    stacks
647        .iter()
648        .filter(|s| s.template_id == "whisper_stone")
649        .map(|s| {
650            let pair_id = s.props.get("whisper_pair_id").cloned();
651            let peer_label = s.props.get("peer_label").cloned().unwrap_or_else(|| {
652                if pair_id.is_some() {
653                    "Unknown".into()
654                } else {
655                    "Blank stone".into()
656                }
657            });
658            let peer_character_id = s
659                .props
660                .get("peer_character_id")
661                .and_then(|v| uuid::Uuid::parse_str(v).ok());
662            WhisperContact {
663                instance_id: s.item_instance_id.unwrap_or_default(),
664                peer_label,
665                peer_character_id,
666                pair_id,
667                blank: !s.props.contains_key("whisper_pair_id"),
668            }
669        })
670        .collect()
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    #[test]
678    fn cancel_whisper_out_of_range_clears_thread() {
679        let mut chat = SocialChatState::default();
680        chat.focus_whisper(42, "Ada");
681        assert!(matches!(
682            chat.thread,
683            ChatThreadKind::Whisper { peer: 42 }
684        ));
685        assert!(chat.input_focused);
686        assert!(chat.cancel_whisper_out_of_range());
687        assert_eq!(chat.thread, ChatThreadKind::Nearby);
688        assert!(!chat.input_focused);
689        assert!(chat.buffer.is_empty());
690        assert!(chat
691            .log
692            .last()
693            .is_some_and(|e| e.system && e.text.contains("out of range")));
694    }
695
696    #[test]
697    fn cancel_whisper_noop_when_nearby_or_stone() {
698        let mut chat = SocialChatState::default();
699        chat.focus_nearby();
700        assert!(!chat.cancel_whisper_out_of_range());
701        chat.focus_stone(7, "Bob");
702        assert!(!chat.cancel_whisper_out_of_range());
703        assert!(matches!(chat.thread, ChatThreadKind::Stone { peer: 7 }));
704    }
705
706    #[test]
707    fn parse_chat_slash_commands() {
708        assert_eq!(
709            parse_chat_slash("/help"),
710            Some(ChatSlashCommand::Help)
711        );
712        assert_eq!(
713            parse_chat_slash("/nearby hello"),
714            Some(ChatSlashCommand::Nearby {
715                message: Some("hello".into())
716            })
717        );
718        assert_eq!(
719            parse_chat_slash("/n"),
720            Some(ChatSlashCommand::Nearby { message: None })
721        );
722        assert_eq!(
723            parse_chat_slash("/r thanks"),
724            Some(ChatSlashCommand::Reply {
725                message: Some("thanks".into())
726            })
727        );
728        assert_eq!(
729            parse_chat_slash("/whisper"),
730            Some(ChatSlashCommand::Whisper {
731                name: None,
732                message: None
733            })
734        );
735        assert_eq!(
736            parse_chat_slash("/w Ada"),
737            Some(ChatSlashCommand::Whisper {
738                name: Some("Ada".into()),
739                message: None
740            })
741        );
742        assert_eq!(
743            parse_chat_slash("/w Ada hi there"),
744            Some(ChatSlashCommand::Whisper {
745                name: Some("Ada".into()),
746                message: Some("hi there".into())
747            })
748        );
749        assert_eq!(parse_chat_slash("hello"), None);
750        assert_eq!(parse_chat_slash("/unknown"), None);
751        assert!(is_chat_slash_line("  /w Ada"));
752        assert!(!is_chat_slash_line("w Ada"));
753    }
754
755    #[test]
756    fn note_speech_records_last_whisper_peer() {
757        let mut chat = SocialChatState::default();
758        let msg = ChatMessage {
759            channel: ChatChannel::Whisper,
760            from_entity: 9,
761            from_name: "Mira".into(),
762            to_entity: Some(1),
763            text: "psst".into(),
764            tick: 1,
765            clarity: ChatClarity::Clear,
766        };
767        chat.note_speech(&msg, 1, 1000);
768        let peer = chat.last_whisper_peer.expect("peer");
769        assert_eq!(peer.entity_id, 9);
770        assert_eq!(peer.label, "Mira");
771        assert_eq!(peer.channel, ChatChannel::Whisper);
772    }
773}