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)]
161pub struct SocialChatState {
162 pub log: Vec<ChatLogEntry>,
163 pub input_focused: bool,
165 pub buffer: String,
166 pub thread: ChatThreadKind,
167 pub peer_label: String,
168 pub log_hidden: bool,
169 pub pending_trade: Option<PendingTradeRequest>,
171 pub picking_stone: bool,
173 pub stone_pick_index: usize,
174 pub speaking_bubbles: Vec<SpeakingBubble>,
176 pub audio_cues: VecDeque<AudioCue>,
178}
179
180impl Default for SocialChatState {
181 fn default() -> Self {
182 Self {
183 log: Vec::new(),
184 input_focused: false,
185 buffer: String::new(),
186 thread: ChatThreadKind::Nearby,
187 peer_label: String::new(),
188 log_hidden: false,
189 pending_trade: None,
190 picking_stone: false,
191 stone_pick_index: 0,
192 speaking_bubbles: Vec::new(),
193 audio_cues: VecDeque::new(),
194 }
195 }
196}
197
198impl SocialChatState {
199 pub const MAX_LOG: usize = 200;
200 pub const MAX_BUF: usize = 200;
201
202 pub fn push(&mut self, entry: ChatLogEntry) {
203 self.log.push(entry);
204 if self.log.len() > Self::MAX_LOG {
205 let drop = self.log.len() - Self::MAX_LOG;
206 self.log.drain(0..drop);
207 }
208 }
209
210 pub fn push_system(&mut self, text: impl Into<String>) {
211 self.push(ChatLogEntry::system_line(text));
212 }
213
214 pub fn push_cue(&mut self, cue: AudioCue) {
215 self.audio_cues.push_back(cue);
216 while self.audio_cues.len() > 8 {
217 self.audio_cues.pop_front();
218 }
219 }
220
221 pub fn drain_audio_cues(&mut self) -> Vec<AudioCue> {
222 self.audio_cues.drain(..).collect()
223 }
224
225 pub fn note_speech(
227 &mut self,
228 msg: &ChatMessage,
229 self_id: EntityId,
230 now_ms: u64,
231 ) {
232 let rgb = if msg.from_entity == self_id {
233 self_chat_rgb()
234 } else {
235 speaker_chat_rgb(msg.from_entity)
236 };
237 match msg.channel {
238 ChatChannel::Nearby | ChatChannel::Direct => {
239 self.speaking_bubbles
240 .retain(|b| b.entity_id != msg.from_entity && b.until_ms > now_ms);
241 self.speaking_bubbles.push(SpeakingBubble {
242 entity_id: msg.from_entity,
243 until_ms: now_ms.saturating_add(SPEECH_BUBBLE_MS),
244 rgb,
245 });
246 if msg.from_entity != self_id {
247 self.push_cue(AudioCue::NearbySpeech);
248 }
249 }
250 ChatChannel::Whisper | ChatChannel::WhisperStone => {
251 if msg.from_entity != self_id {
252 self.push_cue(AudioCue::Whisper);
253 }
254 }
255 }
256 }
257
258 pub fn prune_bubbles(&mut self, now_ms: u64) {
259 self.speaking_bubbles.retain(|b| b.until_ms > now_ms);
260 }
261
262 pub fn focus_nearby(&mut self) {
264 self.picking_stone = false;
265 self.thread = ChatThreadKind::Nearby;
266 self.peer_label.clear();
267 self.input_focused = true;
268 }
269
270 pub fn focus_whisper(&mut self, peer: EntityId, label: &str) {
272 self.picking_stone = false;
273 self.thread = ChatThreadKind::Whisper { peer };
274 self.peer_label = label.to_string();
275 self.input_focused = true;
276 self.push_system(format!("Whispering {label} — type and Enter · Esc cancels"));
277 }
278
279 pub fn focus_stone(&mut self, peer: EntityId, label: &str) {
280 self.picking_stone = false;
281 self.thread = ChatThreadKind::Stone { peer };
282 self.peer_label = label.to_string();
283 self.input_focused = true;
284 self.push_system(format!("Stone link to {label} — type and Enter"));
285 }
286
287 pub fn unfocus(&mut self) {
288 self.input_focused = false;
289 self.picking_stone = false;
290 if matches!(self.thread, ChatThreadKind::Whisper { .. }) {
292 self.thread = ChatThreadKind::Nearby;
293 self.peer_label.clear();
294 }
295 self.buffer.clear();
296 }
297
298 pub fn cancel_whisper_out_of_range(&mut self) -> bool {
301 let ChatThreadKind::Whisper { .. } = self.thread else {
302 return false;
303 };
304 let label = if self.peer_label.is_empty() {
305 "peer".to_string()
306 } else {
307 self.peer_label.clone()
308 };
309 self.thread = ChatThreadKind::Nearby;
310 self.peer_label.clear();
311 self.input_focused = false;
312 self.picking_stone = false;
313 self.buffer.clear();
314 self.push_system(format!("Whisper with {label} ended — out of range"));
315 true
316 }
317
318 pub fn prompt_prefix(&self) -> &'static str {
319 match self.thread {
320 ChatThreadKind::Nearby => "say",
321 ChatThreadKind::Whisper { .. } => "whisper",
322 ChatThreadKind::Stone { .. } => "stone",
323 }
324 }
325
326 pub fn composer_open(&self) -> bool {
328 self.input_focused || self.picking_stone
329 }
330
331 pub fn open_nearby(&mut self) {
332 self.focus_nearby();
333 }
334
335 pub fn open_direct(&mut self, peer: EntityId, label: &str, whisper: bool) {
336 if whisper {
337 self.focus_whisper(peer, label);
338 } else {
339 self.focus_nearby();
341 self.push_system(format!("Nearby speech — {label} can hear if close"));
342 }
343 }
344
345 pub fn open_stone(&mut self, peer: EntityId, label: &str) {
346 self.focus_stone(peer, label);
347 }
348
349 pub fn close_composer(&mut self) {
350 self.unfocus();
351 }
352
353 pub fn toggle_mode_speak_whisper(&mut self) {
354 if matches!(self.thread, ChatThreadKind::Whisper { .. } | ChatThreadKind::Stone { .. }) {
357 self.thread = ChatThreadKind::Nearby;
358 self.peer_label.clear();
359 self.push_system("Switched to Nearby speech");
360 }
361 }
362}
363
364#[derive(Debug, Clone, Default)]
365pub struct PlayerVerbState {
366 pub open: bool,
367 pub target_entity: Option<EntityId>,
368 pub target_label: String,
369 pub index: usize,
370}
371
372impl PlayerVerbState {
373 pub fn options() -> &'static [&'static str] {
375 &["Whisper", "Trade"]
376 }
377
378 pub fn open_for(&mut self, entity: EntityId, label: &str) {
379 self.open = true;
380 self.target_entity = Some(entity);
381 self.target_label = label.to_string();
382 self.index = 0;
383 }
384
385 pub fn close(&mut self) {
386 self.open = false;
387 self.target_entity = None;
388 self.target_label.clear();
389 self.index = 0;
390 }
391}
392
393#[derive(Debug, Clone)]
394pub struct TradeQtyEntry {
395 pub item_instance_id: uuid::Uuid,
396 pub label: String,
397 pub max_qty: u32,
398 pub quantity: u32,
400 pub typed: String,
402}
403
404#[derive(Debug, Clone, Default)]
405pub struct TradeUiState {
406 pub panel: Option<TradePanel>,
407 pub select_index: usize,
408 pub picking_inventory: bool,
409 pub inventory_index: usize,
410 pub qty_entry: Option<TradeQtyEntry>,
412}
413
414impl TradeUiState {
415 pub fn open(&mut self, panel: TradePanel) {
416 self.panel = Some(panel);
417 self.select_index = 0;
418 self.picking_inventory = false;
419 self.qty_entry = None;
420 }
421
422 pub fn close(&mut self) {
423 self.panel = None;
424 self.picking_inventory = false;
425 self.qty_entry = None;
426 }
427
428 pub fn apply(&mut self, panel: TradePanel) {
429 self.panel = Some(panel);
430 }
431
432 pub fn begin_qty_entry(
433 &mut self,
434 item_instance_id: uuid::Uuid,
435 label: String,
436 max_qty: u32,
437 ) {
438 let max_qty = max_qty.max(1);
439 self.qty_entry = Some(TradeQtyEntry {
440 item_instance_id,
441 label,
442 max_qty,
443 quantity: max_qty,
444 typed: String::new(),
445 });
446 self.picking_inventory = false;
447 }
448
449 pub fn adjust_qty(&mut self, delta: i32) {
450 let Some(entry) = self.qty_entry.as_mut() else {
451 return;
452 };
453 entry.typed.clear();
454 let next = (entry.quantity as i32 + delta).clamp(1, entry.max_qty as i32);
455 entry.quantity = next as u32;
456 }
457
458 pub fn set_qty_all(&mut self) {
459 if let Some(entry) = self.qty_entry.as_mut() {
460 entry.typed.clear();
461 entry.quantity = entry.max_qty;
462 }
463 }
464
465 pub fn append_qty_digit(&mut self, c: char) {
466 let Some(entry) = self.qty_entry.as_mut() else {
467 return;
468 };
469 if !c.is_ascii_digit() || entry.typed.len() >= 8 {
470 return;
471 }
472 entry.typed.push(c);
473 let parsed = entry.typed.parse::<u32>().unwrap_or(1);
474 entry.quantity = parsed.clamp(1, entry.max_qty);
475 }
476
477 pub fn qty_backspace(&mut self) {
478 let Some(entry) = self.qty_entry.as_mut() else {
479 return;
480 };
481 if !entry.typed.is_empty() {
482 entry.typed.pop();
483 entry.quantity = if entry.typed.is_empty() {
484 1
485 } else {
486 entry
487 .typed
488 .parse::<u32>()
489 .unwrap_or(1)
490 .clamp(1, entry.max_qty)
491 };
492 return;
493 }
494 entry.quantity = (entry.quantity / 10).max(1);
495 }
496
497 pub fn present_quantity(&self) -> Option<u32> {
499 let entry = self.qty_entry.as_ref()?;
500 if entry.quantity >= entry.max_qty {
501 None
502 } else {
503 Some(entry.quantity)
504 }
505 }
506}
507
508#[derive(Debug, Clone, Default)]
509pub struct WhisperPouchUi {
510 pub open: bool,
511 pub index: usize,
512}
513
514#[derive(Debug, Clone)]
515pub struct WhisperContact {
516 pub instance_id: uuid::Uuid,
517 pub peer_label: String,
518 pub peer_character_id: Option<uuid::Uuid>,
519 pub pair_id: Option<String>,
520 pub blank: bool,
521}
522
523pub fn contacts_from_stacks(stacks: &[ItemStack]) -> Vec<WhisperContact> {
524 stacks
525 .iter()
526 .filter(|s| s.template_id == "whisper_stone")
527 .map(|s| {
528 let pair_id = s.props.get("whisper_pair_id").cloned();
529 let peer_label = s.props.get("peer_label").cloned().unwrap_or_else(|| {
530 if pair_id.is_some() {
531 "Unknown".into()
532 } else {
533 "Blank stone".into()
534 }
535 });
536 let peer_character_id = s
537 .props
538 .get("peer_character_id")
539 .and_then(|v| uuid::Uuid::parse_str(v).ok());
540 WhisperContact {
541 instance_id: s.item_instance_id.unwrap_or_default(),
542 peer_label,
543 peer_character_id,
544 pair_id,
545 blank: !s.props.contains_key("whisper_pair_id"),
546 }
547 })
548 .collect()
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[test]
556 fn cancel_whisper_out_of_range_clears_thread() {
557 let mut chat = SocialChatState::default();
558 chat.focus_whisper(42, "Ada");
559 assert!(matches!(
560 chat.thread,
561 ChatThreadKind::Whisper { peer: 42 }
562 ));
563 assert!(chat.input_focused);
564 assert!(chat.cancel_whisper_out_of_range());
565 assert_eq!(chat.thread, ChatThreadKind::Nearby);
566 assert!(!chat.input_focused);
567 assert!(chat.buffer.is_empty());
568 assert!(chat
569 .log
570 .last()
571 .is_some_and(|e| e.system && e.text.contains("out of range")));
572 }
573
574 #[test]
575 fn cancel_whisper_noop_when_nearby_or_stone() {
576 let mut chat = SocialChatState::default();
577 chat.focus_nearby();
578 assert!(!chat.cancel_whisper_out_of_range());
579 chat.focus_stone(7, "Bob");
580 assert!(!chat.cancel_whisper_out_of_range());
581 assert!(matches!(chat.thread, ChatThreadKind::Stone { peer: 7 }));
582 }
583}