1use std::collections::VecDeque;
4
5use flatland_protocol::{
6 ChatChannel, ChatClarity, ChatMessage, EntityId, ItemStack, TradePanel,
7};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum AudioCue {
12 TradeOffer,
13 Whisper,
14 NearbySpeech,
15 TradeOpened,
16 TradeDeclined,
17}
18
19#[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
29pub fn self_chat_rgb() -> (u8, u8, u8) {
31 (110, 220, 195)
32}
33
34pub 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#[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 pub system: bool,
72 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum ChatThreadKind {
114 Nearby,
116 Whisper { peer: EntityId },
118 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#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct PendingTradeRequest {
156 pub from_entity: EntityId,
157 pub from_name: String,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct LastWhisperPeer {
163 pub entity_id: EntityId,
164 pub label: String,
165 pub channel: ChatChannel,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum ChatSlashCommand {
172 Help,
173 Nearby {
174 message: Option<String>,
175 },
176 Reply {
178 message: Option<String>,
179 },
180 Whisper {
182 name: Option<String>,
183 message: Option<String>,
184 },
185}
186
187pub 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 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, }
233}
234
235pub 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 pub input_focused: bool,
249 pub buffer: String,
250 pub thread: ChatThreadKind,
251 pub peer_label: String,
252 pub log_hidden: bool,
253 pub pending_trade: Option<PendingTradeRequest>,
255 pub last_whisper_peer: Option<LastWhisperPeer>,
257 pub picking_stone: bool,
259 pub stone_pick_index: usize,
260 pub speaking_bubbles: Vec<SpeakingBubble>,
262 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 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 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 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 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 if matches!(self.thread, ChatThreadKind::Whisper { .. }) {
414 self.thread = ChatThreadKind::Nearby;
415 self.peer_label.clear();
416 }
417 self.buffer.clear();
418 }
419
420 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 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 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 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 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 pub quantity: u32,
522 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 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 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}