use std::collections::VecDeque;
use flatland_protocol::{
ChatChannel, ChatClarity, ChatMessage, EntityId, ItemStack, TradePanel,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioCue {
TradeOffer,
Whisper,
NearbySpeech,
TradeOpened,
TradeDeclined,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SpeakingBubble {
pub entity_id: EntityId,
pub until_ms: u64,
pub rgb: (u8, u8, u8),
}
pub const SPEECH_BUBBLE_MS: u64 = 2800;
pub fn self_chat_rgb() -> (u8, u8, u8) {
(110, 220, 195)
}
pub fn speaker_chat_rgb(entity_id: EntityId) -> (u8, u8, u8) {
let h = (entity_id.wrapping_mul(2654435761) % 360) as f32;
hsl_to_rgb(h, 0.58, 0.62)
}
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = h / 60.0;
let x = c * (1.0 - ((hp % 2.0) - 1.0).abs());
let (r1, g1, b1) = match hp as i32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
(
((r1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
((g1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
((b1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
)
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChatLogEntry {
pub channel: ChatChannel,
pub from_entity: EntityId,
pub from_name: String,
pub to_entity: Option<EntityId>,
pub text: String,
pub tick: u64,
pub clarity: ChatClarity,
pub system: bool,
pub rgb: (u8, u8, u8),
}
impl ChatLogEntry {
pub fn from_message(msg: ChatMessage, self_id: EntityId) -> Self {
let rgb = if msg.from_entity == self_id {
self_chat_rgb()
} else {
speaker_chat_rgb(msg.from_entity)
};
Self {
channel: msg.channel,
from_entity: msg.from_entity,
from_name: msg.from_name,
to_entity: msg.to_entity,
text: msg.text,
tick: msg.tick,
clarity: msg.clarity,
system: false,
rgb,
}
}
pub fn system_line(text: impl Into<String>) -> Self {
Self {
channel: ChatChannel::Nearby,
from_entity: 0,
from_name: "system".into(),
to_entity: None,
text: text.into(),
tick: 0,
clarity: ChatClarity::Clear,
system: true,
rgb: (140, 145, 155),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatThreadKind {
Nearby,
Whisper { peer: EntityId },
Stone { peer: EntityId },
}
impl ChatThreadKind {
pub fn channel(self) -> ChatChannel {
match self {
Self::Nearby => ChatChannel::Nearby,
Self::Whisper { .. } => ChatChannel::Whisper,
Self::Stone { .. } => ChatChannel::WhisperStone,
}
}
pub fn to_entity(self) -> Option<EntityId> {
match self {
Self::Nearby => None,
Self::Whisper { peer } | Self::Stone { peer } => Some(peer),
}
}
pub fn mode_label(self, peer_name: &str) -> String {
match self {
Self::Nearby => "Nearby".into(),
Self::Whisper { .. } => format!("Whisper → {peer_name}"),
Self::Stone { .. } => format!("Stone → {peer_name}"),
}
}
}
impl Default for ChatThreadKind {
fn default() -> Self {
Self::Nearby
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingTradeRequest {
pub from_entity: EntityId,
pub from_name: String,
}
#[derive(Debug, Clone)]
pub struct SocialChatState {
pub log: Vec<ChatLogEntry>,
pub input_focused: bool,
pub buffer: String,
pub thread: ChatThreadKind,
pub peer_label: String,
pub log_hidden: bool,
pub pending_trade: Option<PendingTradeRequest>,
pub picking_stone: bool,
pub stone_pick_index: usize,
pub speaking_bubbles: Vec<SpeakingBubble>,
pub audio_cues: VecDeque<AudioCue>,
}
impl Default for SocialChatState {
fn default() -> Self {
Self {
log: Vec::new(),
input_focused: false,
buffer: String::new(),
thread: ChatThreadKind::Nearby,
peer_label: String::new(),
log_hidden: false,
pending_trade: None,
picking_stone: false,
stone_pick_index: 0,
speaking_bubbles: Vec::new(),
audio_cues: VecDeque::new(),
}
}
}
impl SocialChatState {
pub const MAX_LOG: usize = 200;
pub const MAX_BUF: usize = 200;
pub fn push(&mut self, entry: ChatLogEntry) {
self.log.push(entry);
if self.log.len() > Self::MAX_LOG {
let drop = self.log.len() - Self::MAX_LOG;
self.log.drain(0..drop);
}
}
pub fn push_system(&mut self, text: impl Into<String>) {
self.push(ChatLogEntry::system_line(text));
}
pub fn push_cue(&mut self, cue: AudioCue) {
self.audio_cues.push_back(cue);
while self.audio_cues.len() > 8 {
self.audio_cues.pop_front();
}
}
pub fn drain_audio_cues(&mut self) -> Vec<AudioCue> {
self.audio_cues.drain(..).collect()
}
pub fn note_speech(
&mut self,
msg: &ChatMessage,
self_id: EntityId,
now_ms: u64,
) {
let rgb = if msg.from_entity == self_id {
self_chat_rgb()
} else {
speaker_chat_rgb(msg.from_entity)
};
match msg.channel {
ChatChannel::Nearby | ChatChannel::Direct => {
self.speaking_bubbles
.retain(|b| b.entity_id != msg.from_entity && b.until_ms > now_ms);
self.speaking_bubbles.push(SpeakingBubble {
entity_id: msg.from_entity,
until_ms: now_ms.saturating_add(SPEECH_BUBBLE_MS),
rgb,
});
if msg.from_entity != self_id {
self.push_cue(AudioCue::NearbySpeech);
}
}
ChatChannel::Whisper | ChatChannel::WhisperStone => {
if msg.from_entity != self_id {
self.push_cue(AudioCue::Whisper);
}
}
}
}
pub fn prune_bubbles(&mut self, now_ms: u64) {
self.speaking_bubbles.retain(|b| b.until_ms > now_ms);
}
pub fn focus_nearby(&mut self) {
self.picking_stone = false;
self.thread = ChatThreadKind::Nearby;
self.peer_label.clear();
self.input_focused = true;
}
pub fn focus_whisper(&mut self, peer: EntityId, label: &str) {
self.picking_stone = false;
self.thread = ChatThreadKind::Whisper { peer };
self.peer_label = label.to_string();
self.input_focused = true;
self.push_system(format!("Whispering {label} — type and Enter · Esc cancels"));
}
pub fn focus_stone(&mut self, peer: EntityId, label: &str) {
self.picking_stone = false;
self.thread = ChatThreadKind::Stone { peer };
self.peer_label = label.to_string();
self.input_focused = true;
self.push_system(format!("Stone link to {label} — type and Enter"));
}
pub fn unfocus(&mut self) {
self.input_focused = false;
self.picking_stone = false;
if matches!(self.thread, ChatThreadKind::Whisper { .. }) {
self.thread = ChatThreadKind::Nearby;
self.peer_label.clear();
}
self.buffer.clear();
}
pub fn cancel_whisper_out_of_range(&mut self) -> bool {
let ChatThreadKind::Whisper { .. } = self.thread else {
return false;
};
let label = if self.peer_label.is_empty() {
"peer".to_string()
} else {
self.peer_label.clone()
};
self.thread = ChatThreadKind::Nearby;
self.peer_label.clear();
self.input_focused = false;
self.picking_stone = false;
self.buffer.clear();
self.push_system(format!("Whisper with {label} ended — out of range"));
true
}
pub fn prompt_prefix(&self) -> &'static str {
match self.thread {
ChatThreadKind::Nearby => "say",
ChatThreadKind::Whisper { .. } => "whisper",
ChatThreadKind::Stone { .. } => "stone",
}
}
pub fn composer_open(&self) -> bool {
self.input_focused || self.picking_stone
}
pub fn open_nearby(&mut self) {
self.focus_nearby();
}
pub fn open_direct(&mut self, peer: EntityId, label: &str, whisper: bool) {
if whisper {
self.focus_whisper(peer, label);
} else {
self.focus_nearby();
self.push_system(format!("Nearby speech — {label} can hear if close"));
}
}
pub fn open_stone(&mut self, peer: EntityId, label: &str) {
self.focus_stone(peer, label);
}
pub fn close_composer(&mut self) {
self.unfocus();
}
pub fn toggle_mode_speak_whisper(&mut self) {
if matches!(self.thread, ChatThreadKind::Whisper { .. } | ChatThreadKind::Stone { .. }) {
self.thread = ChatThreadKind::Nearby;
self.peer_label.clear();
self.push_system("Switched to Nearby speech");
}
}
}
#[derive(Debug, Clone, Default)]
pub struct PlayerVerbState {
pub open: bool,
pub target_entity: Option<EntityId>,
pub target_label: String,
pub index: usize,
}
impl PlayerVerbState {
pub fn options() -> &'static [&'static str] {
&["Whisper", "Trade"]
}
pub fn open_for(&mut self, entity: EntityId, label: &str) {
self.open = true;
self.target_entity = Some(entity);
self.target_label = label.to_string();
self.index = 0;
}
pub fn close(&mut self) {
self.open = false;
self.target_entity = None;
self.target_label.clear();
self.index = 0;
}
}
#[derive(Debug, Clone)]
pub struct TradeQtyEntry {
pub item_instance_id: uuid::Uuid,
pub label: String,
pub max_qty: u32,
pub quantity: u32,
pub typed: String,
}
#[derive(Debug, Clone, Default)]
pub struct TradeUiState {
pub panel: Option<TradePanel>,
pub select_index: usize,
pub picking_inventory: bool,
pub inventory_index: usize,
pub qty_entry: Option<TradeQtyEntry>,
}
impl TradeUiState {
pub fn open(&mut self, panel: TradePanel) {
self.panel = Some(panel);
self.select_index = 0;
self.picking_inventory = false;
self.qty_entry = None;
}
pub fn close(&mut self) {
self.panel = None;
self.picking_inventory = false;
self.qty_entry = None;
}
pub fn apply(&mut self, panel: TradePanel) {
self.panel = Some(panel);
}
pub fn begin_qty_entry(
&mut self,
item_instance_id: uuid::Uuid,
label: String,
max_qty: u32,
) {
let max_qty = max_qty.max(1);
self.qty_entry = Some(TradeQtyEntry {
item_instance_id,
label,
max_qty,
quantity: max_qty,
typed: String::new(),
});
self.picking_inventory = false;
}
pub fn adjust_qty(&mut self, delta: i32) {
let Some(entry) = self.qty_entry.as_mut() else {
return;
};
entry.typed.clear();
let next = (entry.quantity as i32 + delta).clamp(1, entry.max_qty as i32);
entry.quantity = next as u32;
}
pub fn set_qty_all(&mut self) {
if let Some(entry) = self.qty_entry.as_mut() {
entry.typed.clear();
entry.quantity = entry.max_qty;
}
}
pub fn append_qty_digit(&mut self, c: char) {
let Some(entry) = self.qty_entry.as_mut() else {
return;
};
if !c.is_ascii_digit() || entry.typed.len() >= 8 {
return;
}
entry.typed.push(c);
let parsed = entry.typed.parse::<u32>().unwrap_or(1);
entry.quantity = parsed.clamp(1, entry.max_qty);
}
pub fn qty_backspace(&mut self) {
let Some(entry) = self.qty_entry.as_mut() else {
return;
};
if !entry.typed.is_empty() {
entry.typed.pop();
entry.quantity = if entry.typed.is_empty() {
1
} else {
entry
.typed
.parse::<u32>()
.unwrap_or(1)
.clamp(1, entry.max_qty)
};
return;
}
entry.quantity = (entry.quantity / 10).max(1);
}
pub fn present_quantity(&self) -> Option<u32> {
let entry = self.qty_entry.as_ref()?;
if entry.quantity >= entry.max_qty {
None
} else {
Some(entry.quantity)
}
}
}
#[derive(Debug, Clone, Default)]
pub struct WhisperPouchUi {
pub open: bool,
pub index: usize,
}
#[derive(Debug, Clone)]
pub struct WhisperContact {
pub instance_id: uuid::Uuid,
pub peer_label: String,
pub peer_character_id: Option<uuid::Uuid>,
pub pair_id: Option<String>,
pub blank: bool,
}
pub fn contacts_from_stacks(stacks: &[ItemStack]) -> Vec<WhisperContact> {
stacks
.iter()
.filter(|s| s.template_id == "whisper_stone")
.map(|s| {
let pair_id = s.props.get("whisper_pair_id").cloned();
let peer_label = s.props.get("peer_label").cloned().unwrap_or_else(|| {
if pair_id.is_some() {
"Unknown".into()
} else {
"Blank stone".into()
}
});
let peer_character_id = s
.props
.get("peer_character_id")
.and_then(|v| uuid::Uuid::parse_str(v).ok());
WhisperContact {
instance_id: s.item_instance_id.unwrap_or_default(),
peer_label,
peer_character_id,
pair_id,
blank: !s.props.contains_key("whisper_pair_id"),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cancel_whisper_out_of_range_clears_thread() {
let mut chat = SocialChatState::default();
chat.focus_whisper(42, "Ada");
assert!(matches!(
chat.thread,
ChatThreadKind::Whisper { peer: 42 }
));
assert!(chat.input_focused);
assert!(chat.cancel_whisper_out_of_range());
assert_eq!(chat.thread, ChatThreadKind::Nearby);
assert!(!chat.input_focused);
assert!(chat.buffer.is_empty());
assert!(chat
.log
.last()
.is_some_and(|e| e.system && e.text.contains("out of range")));
}
#[test]
fn cancel_whisper_noop_when_nearby_or_stone() {
let mut chat = SocialChatState::default();
chat.focus_nearby();
assert!(!chat.cancel_whisper_out_of_range());
chat.focus_stone(7, "Bob");
assert!(!chat.cancel_whisper_out_of_range());
assert!(matches!(chat.thread, ChatThreadKind::Stone { peer: 7 }));
}
}