1use std::collections::{HashMap, VecDeque};
4
5use flatland_protocol::{ChatChannel, ChatClarity, ChatMessage, EntityId, ItemStack, TradePanel};
6
7#[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 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#[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
175pub fn self_chat_rgb() -> (u8, u8, u8) {
177 (110, 220, 195)
178}
179
180pub 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#[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 pub system: bool,
218 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum ChatThreadKind {
260 Nearby,
262 Whisper { peer: EntityId },
264 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#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct PendingTradeRequest {
302 pub from_entity: EntityId,
303 pub from_name: String,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct LastWhisperPeer {
309 pub entity_id: EntityId,
310 pub label: String,
311 pub channel: ChatChannel,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq)]
317pub enum ChatSlashCommand {
318 Help,
319 Nearby {
320 message: Option<String>,
321 },
322 Reply {
324 message: Option<String>,
325 },
326 Whisper {
328 name: Option<String>,
329 message: Option<String>,
330 },
331}
332
333pub 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 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, }
386}
387
388pub 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 pub input_focused: bool,
402 pub buffer: String,
403 pub thread: ChatThreadKind,
404 pub peer_label: String,
405 pub log_hidden: bool,
406 pub pending_trade: Option<PendingTradeRequest>,
408 pub last_whisper_peer: Option<LastWhisperPeer>,
410 pub picking_stone: bool,
412 pub stone_pick_index: usize,
413 pub speaking_bubbles: Vec<SpeakingBubble>,
415 pub audio_cues: VecDeque<AudioCue>,
417 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 pub(crate) audio_ui_sig: u64,
428 pub(crate) audio_ui_bootstrapped: bool,
429 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 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 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 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 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 if matches!(self.thread, ChatThreadKind::Whisper { .. }) {
606 self.thread = ChatThreadKind::Nearby;
607 self.peer_label.clear();
608 }
609 self.buffer.clear();
610 }
611
612 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 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 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 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 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 pub quantity: u32,
717 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 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 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}