Skip to main content

flatland_client_lib/
social.rs

1//! Client-side multiplayer chat + trade UI state.
2
3use std::collections::{HashMap, VecDeque};
4
5use flatland_protocol::{ChatChannel, ChatClarity, ChatMessage, EntityId, ItemStack, TradePanel};
6
7/// Short client SFX cues (gfx loads ElevenLabs MP3s from `assets/audio/sfx/`).
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub enum AudioCue {
10    TradeOffer,
11    Whisper,
12    NearbySpeech,
13    ChatDirect,
14    ChatOutgoing,
15    TradeOpened,
16    TradeDeclined,
17    UiClick,
18    UiOpen,
19    UiClose,
20    UiError,
21    DoorOpen,
22    DoorClose,
23    DoorLock,
24    DoorUnlock,
25    BuildingEnter,
26    BuildingExit,
27    NpcGreet,
28    ShopOpen,
29    ChestOpen,
30    ChestLock,
31    ChestUnlock,
32    CombatTelegraphStart,
33    CombatTelegraphImpact,
34    CombatDodge,
35    CombatBlock,
36    CombatHitLight,
37    CombatHitHeavy,
38    CombatAoeWarn,
39    AbilityCastSelf,
40    PlayerDeath,
41    LootPickup,
42    QuestUpdate,
43    LevelUp,
44}
45
46impl AudioCue {
47    pub const ALL: [AudioCue; 34] = [
48        AudioCue::TradeOffer,
49        AudioCue::Whisper,
50        AudioCue::NearbySpeech,
51        AudioCue::ChatDirect,
52        AudioCue::ChatOutgoing,
53        AudioCue::TradeOpened,
54        AudioCue::TradeDeclined,
55        AudioCue::UiClick,
56        AudioCue::UiOpen,
57        AudioCue::UiClose,
58        AudioCue::UiError,
59        AudioCue::DoorOpen,
60        AudioCue::DoorClose,
61        AudioCue::DoorLock,
62        AudioCue::DoorUnlock,
63        AudioCue::BuildingEnter,
64        AudioCue::BuildingExit,
65        AudioCue::NpcGreet,
66        AudioCue::ShopOpen,
67        AudioCue::ChestOpen,
68        AudioCue::ChestLock,
69        AudioCue::ChestUnlock,
70        AudioCue::CombatTelegraphStart,
71        AudioCue::CombatTelegraphImpact,
72        AudioCue::CombatDodge,
73        AudioCue::CombatBlock,
74        AudioCue::CombatHitLight,
75        AudioCue::CombatHitHeavy,
76        AudioCue::CombatAoeWarn,
77        AudioCue::AbilityCastSelf,
78        AudioCue::PlayerDeath,
79        AudioCue::LootPickup,
80        AudioCue::QuestUpdate,
81        AudioCue::LevelUp,
82    ];
83
84    pub const fn index(self) -> usize {
85        match self {
86            AudioCue::TradeOffer => 0,
87            AudioCue::Whisper => 1,
88            AudioCue::NearbySpeech => 2,
89            AudioCue::ChatDirect => 3,
90            AudioCue::ChatOutgoing => 4,
91            AudioCue::TradeOpened => 5,
92            AudioCue::TradeDeclined => 6,
93            AudioCue::UiClick => 7,
94            AudioCue::UiOpen => 8,
95            AudioCue::UiClose => 9,
96            AudioCue::UiError => 10,
97            AudioCue::DoorOpen => 11,
98            AudioCue::DoorClose => 12,
99            AudioCue::DoorLock => 13,
100            AudioCue::DoorUnlock => 14,
101            AudioCue::BuildingEnter => 15,
102            AudioCue::BuildingExit => 16,
103            AudioCue::NpcGreet => 17,
104            AudioCue::ShopOpen => 18,
105            AudioCue::ChestOpen => 19,
106            AudioCue::ChestLock => 20,
107            AudioCue::ChestUnlock => 21,
108            AudioCue::CombatTelegraphStart => 22,
109            AudioCue::CombatTelegraphImpact => 23,
110            AudioCue::CombatDodge => 24,
111            AudioCue::CombatBlock => 25,
112            AudioCue::CombatHitLight => 26,
113            AudioCue::CombatHitHeavy => 27,
114            AudioCue::CombatAoeWarn => 28,
115            AudioCue::AbilityCastSelf => 29,
116            AudioCue::PlayerDeath => 30,
117            AudioCue::LootPickup => 31,
118            AudioCue::QuestUpdate => 32,
119            AudioCue::LevelUp => 33,
120        }
121    }
122
123    /// Filename under `assets/audio/sfx/` / synced `audio/sfx/`.
124    /// WAV (PCM) only — macroquad/quad-snd does not decode MP3.
125    pub const fn filename(self) -> &'static str {
126        match self {
127            AudioCue::TradeOffer => "trade_offer.wav",
128            AudioCue::Whisper => "chat_whisper.wav",
129            AudioCue::NearbySpeech => "chat_nearby.wav",
130            AudioCue::ChatDirect => "chat_direct.wav",
131            AudioCue::ChatOutgoing => "chat_outgoing.wav",
132            AudioCue::TradeOpened => "trade_open.wav",
133            AudioCue::TradeDeclined => "trade_decline.wav",
134            AudioCue::UiClick => "ui_click.wav",
135            AudioCue::UiOpen => "ui_open.wav",
136            AudioCue::UiClose => "ui_close.wav",
137            AudioCue::UiError => "ui_error.wav",
138            AudioCue::DoorOpen => "door_open.wav",
139            AudioCue::DoorClose => "door_close.wav",
140            AudioCue::DoorLock => "door_lock.wav",
141            AudioCue::DoorUnlock => "door_unlock.wav",
142            AudioCue::BuildingEnter => "building_enter.wav",
143            AudioCue::BuildingExit => "building_exit.wav",
144            AudioCue::NpcGreet => "npc_greet.wav",
145            AudioCue::ShopOpen => "shop_open.wav",
146            AudioCue::ChestOpen => "chest_open.wav",
147            AudioCue::ChestLock => "chest_lock.wav",
148            AudioCue::ChestUnlock => "chest_unlock.wav",
149            AudioCue::CombatTelegraphStart => "combat_telegraph_start.wav",
150            AudioCue::CombatTelegraphImpact => "combat_telegraph_impact.wav",
151            AudioCue::CombatDodge => "combat_dodge.wav",
152            AudioCue::CombatBlock => "combat_block.wav",
153            AudioCue::CombatHitLight => "combat_hit_light.wav",
154            AudioCue::CombatHitHeavy => "combat_hit_heavy.wav",
155            AudioCue::CombatAoeWarn => "combat_aoe_warn.wav",
156            AudioCue::AbilityCastSelf => "ability_cast_self.wav",
157            AudioCue::PlayerDeath => "player_death.wav",
158            AudioCue::LootPickup => "loot_pickup.wav",
159            AudioCue::QuestUpdate => "quest_update.wav",
160            AudioCue::LevelUp => "level_up.wav",
161        }
162    }
163}
164
165/// Comic "…" bubble above a speaking player (Nearby / Direct only).
166#[derive(Debug, Clone, PartialEq)]
167pub struct SpeakingBubble {
168    pub entity_id: EntityId,
169    pub until_ms: u64,
170    pub rgb: (u8, u8, u8),
171}
172
173pub const SPEECH_BUBBLE_MS: u64 = 2800;
174
175/// Local player's chat color (mint) — distinct from hashed peer colors.
176pub fn self_chat_rgb() -> (u8, u8, u8) {
177    (110, 220, 195)
178}
179
180/// Stable pastel from entity id (same color in CHAT log and world bubble).
181pub fn speaker_chat_rgb(entity_id: EntityId) -> (u8, u8, u8) {
182    let h = (entity_id.wrapping_mul(2654435761) % 360) as f32;
183    hsl_to_rgb(h, 0.58, 0.62)
184}
185
186fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
187    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
188    let hp = h / 60.0;
189    let x = c * (1.0 - ((hp % 2.0) - 1.0).abs());
190    let (r1, g1, b1) = match hp as i32 {
191        0 => (c, x, 0.0),
192        1 => (x, c, 0.0),
193        2 => (0.0, c, x),
194        3 => (0.0, x, c),
195        4 => (x, 0.0, c),
196        _ => (c, 0.0, x),
197    };
198    let m = l - c / 2.0;
199    (
200        ((r1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
201        ((g1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
202        ((b1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
203    )
204}
205
206/// One line in the always-visible chat log / active thread.
207#[derive(Debug, Clone, PartialEq)]
208pub struct ChatLogEntry {
209    pub channel: ChatChannel,
210    pub from_entity: EntityId,
211    pub from_name: String,
212    pub to_entity: Option<EntityId>,
213    pub text: String,
214    pub tick: u64,
215    pub clarity: ChatClarity,
216    /// System / trade-request lines (not player speech).
217    pub system: bool,
218    /// Stable speaker color for this line (matches world bubble).
219    pub rgb: (u8, u8, u8),
220}
221
222impl ChatLogEntry {
223    pub fn from_message(msg: ChatMessage, self_id: EntityId) -> Self {
224        let rgb = if msg.from_entity == self_id {
225            self_chat_rgb()
226        } else {
227            speaker_chat_rgb(msg.from_entity)
228        };
229        Self {
230            channel: msg.channel,
231            from_entity: msg.from_entity,
232            from_name: msg.from_name,
233            to_entity: msg.to_entity,
234            text: msg.text,
235            tick: msg.tick,
236            clarity: msg.clarity,
237            system: false,
238            rgb,
239        }
240    }
241
242    pub fn system_line(text: impl Into<String>) -> Self {
243        Self {
244            channel: ChatChannel::Nearby,
245            from_entity: 0,
246            from_name: "system".into(),
247            to_entity: None,
248            text: text.into(),
249            tick: 0,
250            clarity: ChatClarity::Clear,
251            system: true,
252            rgb: (140, 145, 155),
253        }
254    }
255}
256
257/// Where chat input will send.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum ChatThreadKind {
260    /// Loud nearby speech (default).
261    Nearby,
262    /// Private directed whisper (range-gated on send).
263    Whisper { peer: EntityId },
264    /// Long-range paired whisper stone.
265    Stone { peer: EntityId },
266}
267
268impl ChatThreadKind {
269    pub fn channel(self) -> ChatChannel {
270        match self {
271            Self::Nearby => ChatChannel::Nearby,
272            Self::Whisper { .. } => ChatChannel::Whisper,
273            Self::Stone { .. } => ChatChannel::WhisperStone,
274        }
275    }
276
277    pub fn to_entity(self) -> Option<EntityId> {
278        match self {
279            Self::Nearby => None,
280            Self::Whisper { peer } | Self::Stone { peer } => Some(peer),
281        }
282    }
283
284    pub fn mode_label(self, peer_name: &str) -> String {
285        match self {
286            Self::Nearby => "Nearby".into(),
287            Self::Whisper { .. } => format!("Whisper → {peer_name}"),
288            Self::Stone { .. } => format!("Stone → {peer_name}"),
289        }
290    }
291}
292
293impl Default for ChatThreadKind {
294    fn default() -> Self {
295        Self::Nearby
296    }
297}
298
299/// Inbound trade request waiting for Y/N in the chat column.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct PendingTradeRequest {
302    pub from_entity: EntityId,
303    pub from_name: String,
304}
305
306/// Last private chat peer — used by `/r` and bare `/whisper`.
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct LastWhisperPeer {
309    pub entity_id: EntityId,
310    pub label: String,
311    /// `Whisper` (range) or `WhisperStone` (long-range).
312    pub channel: ChatChannel,
313}
314
315/// Slash commands typed in the chat buffer (leading `/`).
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub enum ChatSlashCommand {
318    Help,
319    Nearby {
320        message: Option<String>,
321    },
322    /// Reply to [`SocialChatState::last_whisper_peer`].
323    Reply {
324        message: Option<String>,
325    },
326    /// Whisper a named player (or last peer when `name` is None).
327    Whisper {
328        name: Option<String>,
329        message: Option<String>,
330    },
331}
332
333/// Parse a leading `/command` from chat input. Returns `None` for normal speech.
334pub fn parse_chat_slash(text: &str) -> Option<ChatSlashCommand> {
335    let trimmed = text.trim();
336    if !trimmed.starts_with('/') {
337        return None;
338    }
339    let rest = trimmed[1..].trim_start();
340    if rest.is_empty() {
341        return Some(ChatSlashCommand::Help);
342    }
343    let (cmd, args) = match rest.split_once(char::is_whitespace) {
344        Some((c, a)) => (c, a.trim()),
345        None => (rest, ""),
346    };
347    let cmd_l = cmd.to_ascii_lowercase();
348    let message = if args.is_empty() {
349        None
350    } else {
351        Some(args.to_string())
352    };
353    match cmd_l.as_str() {
354        "help" | "h" | "?" => Some(ChatSlashCommand::Help),
355        "nearby" | "n" | "say" | "s" => Some(ChatSlashCommand::Nearby { message }),
356        "reply" | "r" => Some(ChatSlashCommand::Reply { message }),
357        "whisper" | "w" => {
358            // `/w` → last peer; `/w Name` → mode; `/w Name hi there` → send
359            if args.is_empty() {
360                return Some(ChatSlashCommand::Whisper {
361                    name: None,
362                    message: None,
363                });
364            }
365            let (name, msg) = match args.split_once(char::is_whitespace) {
366                Some((n, m)) => {
367                    let m = m.trim();
368                    (
369                        n.to_string(),
370                        if m.is_empty() {
371                            None
372                        } else {
373                            Some(m.to_string())
374                        },
375                    )
376                }
377                None => (args.to_string(), None),
378            };
379            Some(ChatSlashCommand::Whisper {
380                name: Some(name),
381                message: msg,
382            })
383        }
384        _ => None, // unknown `/foo` — fall through as normal text? Better treat as help hint.
385    }
386}
387
388/// True when `parse_chat_slash` would consume the line (including unknown `/cmd`).
389pub fn is_chat_slash_line(text: &str) -> bool {
390    text.trim_start().starts_with('/')
391}
392
393pub fn chat_slash_help_text() -> &'static str {
394    "Chat commands: /nearby [/n] · /whisper Name [/w] · /reply [/r] · /help — optional message after the name"
395}
396
397#[derive(Debug, Clone)]
398pub struct SocialChatState {
399    pub log: Vec<ChatLogEntry>,
400    /// Keyboard focus on the chat input (sidebar) — not a popup.
401    pub input_focused: bool,
402    pub buffer: String,
403    pub thread: ChatThreadKind,
404    pub peer_label: String,
405    pub log_hidden: bool,
406    /// Peer asked to trade — show inline accept in CHAT.
407    pub pending_trade: Option<PendingTradeRequest>,
408    /// Last private message peer (for `/r` / bare `/whisper`).
409    pub last_whisper_peer: Option<LastWhisperPeer>,
410    /// Picking a whisper-stone contact inside the chat column (`g`).
411    pub picking_stone: bool,
412    pub stone_pick_index: usize,
413    /// Active "…" bubbles above speakers.
414    pub speaking_bubbles: Vec<SpeakingBubble>,
415    /// Soft tones for the gfx client to drain/play.
416    pub audio_cues: VecDeque<AudioCue>,
417    /// Edge detectors for gameplay SFX (not networked).
418    pub(crate) audio_had_target_telegraph: bool,
419    pub(crate) audio_was_alive: bool,
420    pub(crate) audio_was_casting: bool,
421    pub(crate) audio_was_in_aoe: bool,
422    pub(crate) audio_seen_fx_ids: Vec<u64>,
423    pub(crate) audio_quest_sig: u64,
424    pub(crate) audio_char_level: u16,
425    pub(crate) audio_bootstrapped: bool,
426    /// HUD overlay open/close edge detector.
427    pub(crate) audio_ui_sig: u64,
428    pub(crate) audio_ui_bootstrapped: bool,
429    /// Building interior transitions.
430    pub(crate) audio_inside_building: Option<String>,
431    pub(crate) audio_world_bootstrapped: bool,
432    pub(crate) audio_door_open: HashMap<String, bool>,
433    pub(crate) audio_door_locked: HashMap<String, bool>,
434    pub(crate) audio_chest_locked: HashMap<String, bool>,
435}
436
437impl Default for SocialChatState {
438    fn default() -> Self {
439        Self {
440            log: Vec::new(),
441            input_focused: false,
442            buffer: String::new(),
443            thread: ChatThreadKind::Nearby,
444            peer_label: String::new(),
445            log_hidden: false,
446            pending_trade: None,
447            last_whisper_peer: None,
448            picking_stone: false,
449            stone_pick_index: 0,
450            speaking_bubbles: Vec::new(),
451            audio_cues: VecDeque::new(),
452            audio_had_target_telegraph: false,
453            audio_was_alive: true,
454            audio_was_casting: false,
455            audio_was_in_aoe: false,
456            audio_seen_fx_ids: Vec::new(),
457            audio_quest_sig: 0,
458            audio_char_level: 0,
459            audio_bootstrapped: false,
460            audio_ui_sig: 0,
461            audio_ui_bootstrapped: false,
462            audio_inside_building: None,
463            audio_world_bootstrapped: false,
464            audio_door_open: HashMap::new(),
465            audio_door_locked: HashMap::new(),
466            audio_chest_locked: HashMap::new(),
467        }
468    }
469}
470
471impl SocialChatState {
472    pub const MAX_LOG: usize = 200;
473    pub const MAX_BUF: usize = 200;
474
475    pub fn push(&mut self, entry: ChatLogEntry) {
476        self.log.push(entry);
477        if self.log.len() > Self::MAX_LOG {
478            let drop = self.log.len() - Self::MAX_LOG;
479            self.log.drain(0..drop);
480        }
481    }
482
483    pub fn push_system(&mut self, text: impl Into<String>) {
484        self.push(ChatLogEntry::system_line(text));
485    }
486
487    pub fn push_cue(&mut self, cue: AudioCue) {
488        self.audio_cues.push_back(cue);
489        while self.audio_cues.len() > 16 {
490            self.audio_cues.pop_front();
491        }
492    }
493
494    pub fn drain_audio_cues(&mut self) -> Vec<AudioCue> {
495        self.audio_cues.drain(..).collect()
496    }
497
498    /// Record a speech bubble + optional tone for an inbound chat line.
499    pub fn note_speech(&mut self, msg: &ChatMessage, self_id: EntityId, now_ms: u64) {
500        let rgb = if msg.from_entity == self_id {
501            self_chat_rgb()
502        } else {
503            speaker_chat_rgb(msg.from_entity)
504        };
505        match msg.channel {
506            ChatChannel::Nearby => {
507                self.speaking_bubbles
508                    .retain(|b| b.entity_id != msg.from_entity && b.until_ms > now_ms);
509                self.speaking_bubbles.push(SpeakingBubble {
510                    entity_id: msg.from_entity,
511                    until_ms: now_ms.saturating_add(SPEECH_BUBBLE_MS),
512                    rgb,
513                });
514                if msg.from_entity != self_id {
515                    self.push_cue(AudioCue::NearbySpeech);
516                }
517            }
518            ChatChannel::Direct => {
519                self.speaking_bubbles
520                    .retain(|b| b.entity_id != msg.from_entity && b.until_ms > now_ms);
521                self.speaking_bubbles.push(SpeakingBubble {
522                    entity_id: msg.from_entity,
523                    until_ms: now_ms.saturating_add(SPEECH_BUBBLE_MS),
524                    rgb,
525                });
526                if msg.from_entity != self_id {
527                    self.push_cue(AudioCue::ChatDirect);
528                }
529            }
530            ChatChannel::Whisper | ChatChannel::WhisperStone => {
531                if msg.from_entity != self_id {
532                    self.push_cue(AudioCue::Whisper);
533                    self.remember_whisper_peer(msg.from_entity, &msg.from_name, msg.channel);
534                }
535            }
536        }
537    }
538
539    pub fn remember_whisper_peer(
540        &mut self,
541        entity_id: EntityId,
542        label: &str,
543        channel: ChatChannel,
544    ) {
545        if !matches!(channel, ChatChannel::Whisper | ChatChannel::WhisperStone) {
546            return;
547        }
548        self.last_whisper_peer = Some(LastWhisperPeer {
549            entity_id,
550            label: label.to_string(),
551            channel,
552        });
553    }
554
555    /// Switch to whisper/stone mode without the long system banner (slash commands).
556    pub fn set_whisper_thread(&mut self, peer: EntityId, label: &str, stone: bool) {
557        self.picking_stone = false;
558        self.peer_label = label.to_string();
559        self.thread = if stone {
560            ChatThreadKind::Stone { peer }
561        } else {
562            ChatThreadKind::Whisper { peer }
563        };
564        self.input_focused = true;
565        self.remember_whisper_peer(
566            peer,
567            label,
568            if stone {
569                ChatChannel::WhisperStone
570            } else {
571                ChatChannel::Whisper
572            },
573        );
574    }
575
576    pub fn prune_bubbles(&mut self, now_ms: u64) {
577        self.speaking_bubbles.retain(|b| b.until_ms > now_ms);
578    }
579
580    /// Focus chat for nearby speech (`t`).
581    pub fn focus_nearby(&mut self) {
582        self.picking_stone = false;
583        self.thread = ChatThreadKind::Nearby;
584        self.peer_label.clear();
585        self.input_focused = true;
586    }
587
588    /// Focus chat whispering a nearby player (`f` → Whisper).
589    pub fn focus_whisper(&mut self, peer: EntityId, label: &str) {
590        self.set_whisper_thread(peer, label, false);
591        self.push_system(format!(
592            "Whispering {label} — type and Enter · Esc cancels · /nearby"
593        ));
594    }
595
596    pub fn focus_stone(&mut self, peer: EntityId, label: &str) {
597        self.set_whisper_thread(peer, label, true);
598        self.push_system(format!("Stone link to {label} — type and Enter · /nearby"));
599    }
600
601    pub fn unfocus(&mut self) {
602        self.input_focused = false;
603        self.picking_stone = false;
604        // Keep whisper/stone thread until they switch; Esc from whisper returns to nearby.
605        if matches!(self.thread, ChatThreadKind::Whisper { .. }) {
606            self.thread = ChatThreadKind::Nearby;
607            self.peer_label.clear();
608        }
609        self.buffer.clear();
610    }
611
612    /// Drop directed whisper when the peer is out of interact range (or gone).
613    /// Returns true if whisper mode was cancelled.
614    pub fn cancel_whisper_out_of_range(&mut self) -> bool {
615        let ChatThreadKind::Whisper { .. } = self.thread else {
616            return false;
617        };
618        let label = if self.peer_label.is_empty() {
619            "peer".to_string()
620        } else {
621            self.peer_label.clone()
622        };
623        self.thread = ChatThreadKind::Nearby;
624        self.peer_label.clear();
625        self.input_focused = false;
626        self.picking_stone = false;
627        self.buffer.clear();
628        self.push_system(format!("Whisper with {label} ended — out of range"));
629        true
630    }
631
632    pub fn prompt_prefix(&self) -> &'static str {
633        match self.thread {
634            ChatThreadKind::Nearby => "say",
635            ChatThreadKind::Whisper { .. } => "whisper",
636            ChatThreadKind::Stone { .. } => "stone",
637        }
638    }
639
640    /// Legacy alias used by older call sites.
641    pub fn composer_open(&self) -> bool {
642        self.input_focused || self.picking_stone
643    }
644
645    pub fn open_nearby(&mut self) {
646        self.focus_nearby();
647    }
648
649    pub fn open_direct(&mut self, peer: EntityId, label: &str, whisper: bool) {
650        if whisper {
651            self.focus_whisper(peer, label);
652        } else {
653            // Speak aloud = nearby (no directed speak UI).
654            self.focus_nearby();
655            self.push_system(format!("Nearby speech — {label} can hear if close"));
656        }
657    }
658
659    pub fn open_stone(&mut self, peer: EntityId, label: &str) {
660        self.focus_stone(peer, label);
661    }
662
663    pub fn close_composer(&mut self) {
664        self.unfocus();
665    }
666
667    pub fn toggle_mode_speak_whisper(&mut self) {
668        // Tab no longer toggles speak↔whisper; nearby is always loud.
669        // If whispering, Tab returns to nearby.
670        if matches!(
671            self.thread,
672            ChatThreadKind::Whisper { .. } | ChatThreadKind::Stone { .. }
673        ) {
674            self.thread = ChatThreadKind::Nearby;
675            self.peer_label.clear();
676            self.push_system("Switched to Nearby speech");
677        }
678    }
679}
680
681#[derive(Debug, Clone, Default)]
682pub struct PlayerVerbState {
683    pub open: bool,
684    pub target_entity: Option<EntityId>,
685    pub target_label: String,
686    pub index: usize,
687}
688
689impl PlayerVerbState {
690    /// Speak is ambient (chat section); `f` is only private whisper + trade.
691    pub fn options() -> &'static [&'static str] {
692        &["Whisper", "Trade"]
693    }
694
695    pub fn open_for(&mut self, entity: EntityId, label: &str) {
696        self.open = true;
697        self.target_entity = Some(entity);
698        self.target_label = label.to_string();
699        self.index = 0;
700    }
701
702    pub fn close(&mut self) {
703        self.open = false;
704        self.target_entity = None;
705        self.target_label.clear();
706        self.index = 0;
707    }
708}
709
710#[derive(Debug, Clone)]
711pub struct TradeQtyEntry {
712    pub item_instance_id: uuid::Uuid,
713    pub label: String,
714    pub max_qty: u32,
715    /// Current offer quantity (1..=max_qty).
716    pub quantity: u32,
717    /// Digits typed this session (empty → showing quantity from [ ] / a).
718    pub typed: String,
719}
720
721#[derive(Debug, Clone, Default)]
722pub struct TradeUiState {
723    pub panel: Option<TradePanel>,
724    pub select_index: usize,
725    pub picking_inventory: bool,
726    pub inventory_index: usize,
727    /// Presenting a stack — choose how many (like storage).
728    pub qty_entry: Option<TradeQtyEntry>,
729}
730
731impl TradeUiState {
732    pub fn open(&mut self, panel: TradePanel) {
733        self.panel = Some(panel);
734        self.select_index = 0;
735        self.picking_inventory = false;
736        self.qty_entry = None;
737    }
738
739    pub fn close(&mut self) {
740        self.panel = None;
741        self.picking_inventory = false;
742        self.qty_entry = None;
743    }
744
745    pub fn apply(&mut self, panel: TradePanel) {
746        self.panel = Some(panel);
747    }
748
749    pub fn begin_qty_entry(&mut self, item_instance_id: uuid::Uuid, label: String, max_qty: u32) {
750        let max_qty = max_qty.max(1);
751        self.qty_entry = Some(TradeQtyEntry {
752            item_instance_id,
753            label,
754            max_qty,
755            quantity: max_qty,
756            typed: String::new(),
757        });
758        self.picking_inventory = false;
759    }
760
761    pub fn adjust_qty(&mut self, delta: i32) {
762        let Some(entry) = self.qty_entry.as_mut() else {
763            return;
764        };
765        entry.typed.clear();
766        let next = (entry.quantity as i32 + delta).clamp(1, entry.max_qty as i32);
767        entry.quantity = next as u32;
768    }
769
770    pub fn set_qty_all(&mut self) {
771        if let Some(entry) = self.qty_entry.as_mut() {
772            entry.typed.clear();
773            entry.quantity = entry.max_qty;
774        }
775    }
776
777    pub fn set_qty_min(&mut self) {
778        if let Some(entry) = self.qty_entry.as_mut() {
779            entry.typed.clear();
780            entry.quantity = 1;
781        }
782    }
783
784    pub fn append_qty_digit(&mut self, c: char) {
785        let Some(entry) = self.qty_entry.as_mut() else {
786            return;
787        };
788        if !c.is_ascii_digit() || entry.typed.len() >= 8 {
789            return;
790        }
791        entry.typed.push(c);
792        let parsed = entry.typed.parse::<u32>().unwrap_or(1);
793        entry.quantity = parsed.clamp(1, entry.max_qty);
794    }
795
796    pub fn qty_backspace(&mut self) {
797        let Some(entry) = self.qty_entry.as_mut() else {
798            return;
799        };
800        if !entry.typed.is_empty() {
801            entry.typed.pop();
802            entry.quantity = if entry.typed.is_empty() {
803                1
804            } else {
805                entry
806                    .typed
807                    .parse::<u32>()
808                    .unwrap_or(1)
809                    .clamp(1, entry.max_qty)
810            };
811            return;
812        }
813        entry.quantity = (entry.quantity / 10).max(1);
814    }
815
816    /// Intent quantity: `None` means entire stack.
817    pub fn present_quantity(&self) -> Option<u32> {
818        let entry = self.qty_entry.as_ref()?;
819        if entry.quantity >= entry.max_qty {
820            None
821        } else {
822            Some(entry.quantity)
823        }
824    }
825}
826
827#[derive(Debug, Clone, Default)]
828pub struct WhisperPouchUi {
829    pub open: bool,
830    pub index: usize,
831}
832
833#[derive(Debug, Clone)]
834pub struct WhisperContact {
835    pub instance_id: uuid::Uuid,
836    pub peer_label: String,
837    pub peer_character_id: Option<uuid::Uuid>,
838    pub pair_id: Option<String>,
839    pub blank: bool,
840}
841
842pub fn contacts_from_stacks(stacks: &[ItemStack]) -> Vec<WhisperContact> {
843    stacks
844        .iter()
845        .filter(|s| s.template_id == "whisper_stone")
846        .map(|s| {
847            let pair_id = s.props.get("whisper_pair_id").cloned();
848            let peer_label = s.props.get("peer_label").cloned().unwrap_or_else(|| {
849                if pair_id.is_some() {
850                    "Unknown".into()
851                } else {
852                    "Blank stone".into()
853                }
854            });
855            let peer_character_id = s
856                .props
857                .get("peer_character_id")
858                .and_then(|v| uuid::Uuid::parse_str(v).ok());
859            WhisperContact {
860                instance_id: s.item_instance_id.unwrap_or_default(),
861                peer_label,
862                peer_character_id,
863                pair_id,
864                blank: !s.props.contains_key("whisper_pair_id"),
865            }
866        })
867        .collect()
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    #[test]
875    fn cancel_whisper_out_of_range_clears_thread() {
876        let mut chat = SocialChatState::default();
877        chat.focus_whisper(42, "Ada");
878        assert!(matches!(chat.thread, ChatThreadKind::Whisper { peer: 42 }));
879        assert!(chat.input_focused);
880        assert!(chat.cancel_whisper_out_of_range());
881        assert_eq!(chat.thread, ChatThreadKind::Nearby);
882        assert!(!chat.input_focused);
883        assert!(chat.buffer.is_empty());
884        assert!(chat
885            .log
886            .last()
887            .is_some_and(|e| e.system && e.text.contains("out of range")));
888    }
889
890    #[test]
891    fn cancel_whisper_noop_when_nearby_or_stone() {
892        let mut chat = SocialChatState::default();
893        chat.focus_nearby();
894        assert!(!chat.cancel_whisper_out_of_range());
895        chat.focus_stone(7, "Bob");
896        assert!(!chat.cancel_whisper_out_of_range());
897        assert!(matches!(chat.thread, ChatThreadKind::Stone { peer: 7 }));
898    }
899
900    #[test]
901    fn parse_chat_slash_commands() {
902        assert_eq!(parse_chat_slash("/help"), Some(ChatSlashCommand::Help));
903        assert_eq!(
904            parse_chat_slash("/nearby hello"),
905            Some(ChatSlashCommand::Nearby {
906                message: Some("hello".into())
907            })
908        );
909        assert_eq!(
910            parse_chat_slash("/n"),
911            Some(ChatSlashCommand::Nearby { message: None })
912        );
913        assert_eq!(
914            parse_chat_slash("/r thanks"),
915            Some(ChatSlashCommand::Reply {
916                message: Some("thanks".into())
917            })
918        );
919        assert_eq!(
920            parse_chat_slash("/whisper"),
921            Some(ChatSlashCommand::Whisper {
922                name: None,
923                message: None
924            })
925        );
926        assert_eq!(
927            parse_chat_slash("/w Ada"),
928            Some(ChatSlashCommand::Whisper {
929                name: Some("Ada".into()),
930                message: None
931            })
932        );
933        assert_eq!(
934            parse_chat_slash("/w Ada hi there"),
935            Some(ChatSlashCommand::Whisper {
936                name: Some("Ada".into()),
937                message: Some("hi there".into())
938            })
939        );
940        assert_eq!(parse_chat_slash("hello"), None);
941        assert_eq!(parse_chat_slash("/unknown"), None);
942        assert!(is_chat_slash_line("  /w Ada"));
943        assert!(!is_chat_slash_line("w Ada"));
944    }
945
946    #[test]
947    fn note_speech_records_last_whisper_peer() {
948        let mut chat = SocialChatState::default();
949        let msg = ChatMessage {
950            channel: ChatChannel::Whisper,
951            from_entity: 9,
952            from_name: "Mira".into(),
953            to_entity: Some(1),
954            text: "psst".into(),
955            tick: 1,
956            clarity: ChatClarity::Clear,
957        };
958        chat.note_speech(&msg, 1, 1000);
959        let peer = chat.last_whisper_peer.expect("peer");
960        assert_eq!(peer.entity_id, 9);
961        assert_eq!(peer.label, "Mira");
962        assert_eq!(peer.channel, ChatChannel::Whisper);
963    }
964}