use std::collections::{HashMap, VecDeque};
use flatland_protocol::{ChatChannel, ChatClarity, ChatMessage, EntityId, ItemStack, TradePanel};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AudioCue {
TradeOffer,
Whisper,
NearbySpeech,
ChatDirect,
ChatOutgoing,
TradeOpened,
TradeDeclined,
UiClick,
UiOpen,
UiClose,
UiError,
DoorOpen,
DoorClose,
DoorLock,
DoorUnlock,
BuildingEnter,
BuildingExit,
NpcGreet,
ShopOpen,
ChestOpen,
ChestLock,
ChestUnlock,
CombatTelegraphStart,
CombatTelegraphImpact,
CombatDodge,
CombatBlock,
CombatHitLight,
CombatHitHeavy,
CombatAoeWarn,
AbilityCastSelf,
PlayerDeath,
LootPickup,
QuestUpdate,
LevelUp,
}
impl AudioCue {
pub const ALL: [AudioCue; 34] = [
AudioCue::TradeOffer,
AudioCue::Whisper,
AudioCue::NearbySpeech,
AudioCue::ChatDirect,
AudioCue::ChatOutgoing,
AudioCue::TradeOpened,
AudioCue::TradeDeclined,
AudioCue::UiClick,
AudioCue::UiOpen,
AudioCue::UiClose,
AudioCue::UiError,
AudioCue::DoorOpen,
AudioCue::DoorClose,
AudioCue::DoorLock,
AudioCue::DoorUnlock,
AudioCue::BuildingEnter,
AudioCue::BuildingExit,
AudioCue::NpcGreet,
AudioCue::ShopOpen,
AudioCue::ChestOpen,
AudioCue::ChestLock,
AudioCue::ChestUnlock,
AudioCue::CombatTelegraphStart,
AudioCue::CombatTelegraphImpact,
AudioCue::CombatDodge,
AudioCue::CombatBlock,
AudioCue::CombatHitLight,
AudioCue::CombatHitHeavy,
AudioCue::CombatAoeWarn,
AudioCue::AbilityCastSelf,
AudioCue::PlayerDeath,
AudioCue::LootPickup,
AudioCue::QuestUpdate,
AudioCue::LevelUp,
];
pub const fn index(self) -> usize {
match self {
AudioCue::TradeOffer => 0,
AudioCue::Whisper => 1,
AudioCue::NearbySpeech => 2,
AudioCue::ChatDirect => 3,
AudioCue::ChatOutgoing => 4,
AudioCue::TradeOpened => 5,
AudioCue::TradeDeclined => 6,
AudioCue::UiClick => 7,
AudioCue::UiOpen => 8,
AudioCue::UiClose => 9,
AudioCue::UiError => 10,
AudioCue::DoorOpen => 11,
AudioCue::DoorClose => 12,
AudioCue::DoorLock => 13,
AudioCue::DoorUnlock => 14,
AudioCue::BuildingEnter => 15,
AudioCue::BuildingExit => 16,
AudioCue::NpcGreet => 17,
AudioCue::ShopOpen => 18,
AudioCue::ChestOpen => 19,
AudioCue::ChestLock => 20,
AudioCue::ChestUnlock => 21,
AudioCue::CombatTelegraphStart => 22,
AudioCue::CombatTelegraphImpact => 23,
AudioCue::CombatDodge => 24,
AudioCue::CombatBlock => 25,
AudioCue::CombatHitLight => 26,
AudioCue::CombatHitHeavy => 27,
AudioCue::CombatAoeWarn => 28,
AudioCue::AbilityCastSelf => 29,
AudioCue::PlayerDeath => 30,
AudioCue::LootPickup => 31,
AudioCue::QuestUpdate => 32,
AudioCue::LevelUp => 33,
}
}
pub const fn filename(self) -> &'static str {
match self {
AudioCue::TradeOffer => "trade_offer.wav",
AudioCue::Whisper => "chat_whisper.wav",
AudioCue::NearbySpeech => "chat_nearby.wav",
AudioCue::ChatDirect => "chat_direct.wav",
AudioCue::ChatOutgoing => "chat_outgoing.wav",
AudioCue::TradeOpened => "trade_open.wav",
AudioCue::TradeDeclined => "trade_decline.wav",
AudioCue::UiClick => "ui_click.wav",
AudioCue::UiOpen => "ui_open.wav",
AudioCue::UiClose => "ui_close.wav",
AudioCue::UiError => "ui_error.wav",
AudioCue::DoorOpen => "door_open.wav",
AudioCue::DoorClose => "door_close.wav",
AudioCue::DoorLock => "door_lock.wav",
AudioCue::DoorUnlock => "door_unlock.wav",
AudioCue::BuildingEnter => "building_enter.wav",
AudioCue::BuildingExit => "building_exit.wav",
AudioCue::NpcGreet => "npc_greet.wav",
AudioCue::ShopOpen => "shop_open.wav",
AudioCue::ChestOpen => "chest_open.wav",
AudioCue::ChestLock => "chest_lock.wav",
AudioCue::ChestUnlock => "chest_unlock.wav",
AudioCue::CombatTelegraphStart => "combat_telegraph_start.wav",
AudioCue::CombatTelegraphImpact => "combat_telegraph_impact.wav",
AudioCue::CombatDodge => "combat_dodge.wav",
AudioCue::CombatBlock => "combat_block.wav",
AudioCue::CombatHitLight => "combat_hit_light.wav",
AudioCue::CombatHitHeavy => "combat_hit_heavy.wav",
AudioCue::CombatAoeWarn => "combat_aoe_warn.wav",
AudioCue::AbilityCastSelf => "ability_cast_self.wav",
AudioCue::PlayerDeath => "player_death.wav",
AudioCue::LootPickup => "loot_pickup.wav",
AudioCue::QuestUpdate => "quest_update.wav",
AudioCue::LevelUp => "level_up.wav",
}
}
}
#[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, PartialEq, Eq)]
pub struct LastWhisperPeer {
pub entity_id: EntityId,
pub label: String,
pub channel: ChatChannel,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChatSlashCommand {
Help,
Nearby {
message: Option<String>,
},
Reply {
message: Option<String>,
},
Whisper {
name: Option<String>,
message: Option<String>,
},
}
pub fn parse_chat_slash(text: &str) -> Option<ChatSlashCommand> {
let trimmed = text.trim();
if !trimmed.starts_with('/') {
return None;
}
let rest = trimmed[1..].trim_start();
if rest.is_empty() {
return Some(ChatSlashCommand::Help);
}
let (cmd, args) = match rest.split_once(char::is_whitespace) {
Some((c, a)) => (c, a.trim()),
None => (rest, ""),
};
let cmd_l = cmd.to_ascii_lowercase();
let message = if args.is_empty() {
None
} else {
Some(args.to_string())
};
match cmd_l.as_str() {
"help" | "h" | "?" => Some(ChatSlashCommand::Help),
"nearby" | "n" | "say" | "s" => Some(ChatSlashCommand::Nearby { message }),
"reply" | "r" => Some(ChatSlashCommand::Reply { message }),
"whisper" | "w" => {
if args.is_empty() {
return Some(ChatSlashCommand::Whisper {
name: None,
message: None,
});
}
let (name, msg) = match args.split_once(char::is_whitespace) {
Some((n, m)) => {
let m = m.trim();
(
n.to_string(),
if m.is_empty() {
None
} else {
Some(m.to_string())
},
)
}
None => (args.to_string(), None),
};
Some(ChatSlashCommand::Whisper {
name: Some(name),
message: msg,
})
}
_ => None, }
}
pub fn is_chat_slash_line(text: &str) -> bool {
text.trim_start().starts_with('/')
}
pub fn chat_slash_help_text() -> &'static str {
"Chat commands: /nearby [/n] · /whisper Name [/w] · /reply [/r] · /help — optional message after the name"
}
#[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 last_whisper_peer: Option<LastWhisperPeer>,
pub picking_stone: bool,
pub stone_pick_index: usize,
pub speaking_bubbles: Vec<SpeakingBubble>,
pub audio_cues: VecDeque<AudioCue>,
pub(crate) audio_had_target_telegraph: bool,
pub(crate) audio_was_alive: bool,
pub(crate) audio_was_casting: bool,
pub(crate) audio_was_in_aoe: bool,
pub(crate) audio_seen_fx_ids: Vec<u64>,
pub(crate) audio_quest_sig: u64,
pub(crate) audio_char_level: u16,
pub(crate) audio_bootstrapped: bool,
pub(crate) audio_ui_sig: u64,
pub(crate) audio_ui_bootstrapped: bool,
pub(crate) audio_inside_building: Option<String>,
pub(crate) audio_world_bootstrapped: bool,
pub(crate) audio_door_open: HashMap<String, bool>,
pub(crate) audio_door_locked: HashMap<String, bool>,
pub(crate) audio_chest_locked: HashMap<String, bool>,
}
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,
last_whisper_peer: None,
picking_stone: false,
stone_pick_index: 0,
speaking_bubbles: Vec::new(),
audio_cues: VecDeque::new(),
audio_had_target_telegraph: false,
audio_was_alive: true,
audio_was_casting: false,
audio_was_in_aoe: false,
audio_seen_fx_ids: Vec::new(),
audio_quest_sig: 0,
audio_char_level: 0,
audio_bootstrapped: false,
audio_ui_sig: 0,
audio_ui_bootstrapped: false,
audio_inside_building: None,
audio_world_bootstrapped: false,
audio_door_open: HashMap::new(),
audio_door_locked: HashMap::new(),
audio_chest_locked: HashMap::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() > 16 {
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 => {
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::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::ChatDirect);
}
}
ChatChannel::Whisper | ChatChannel::WhisperStone => {
if msg.from_entity != self_id {
self.push_cue(AudioCue::Whisper);
self.remember_whisper_peer(msg.from_entity, &msg.from_name, msg.channel);
}
}
}
}
pub fn remember_whisper_peer(
&mut self,
entity_id: EntityId,
label: &str,
channel: ChatChannel,
) {
if !matches!(channel, ChatChannel::Whisper | ChatChannel::WhisperStone) {
return;
}
self.last_whisper_peer = Some(LastWhisperPeer {
entity_id,
label: label.to_string(),
channel,
});
}
pub fn set_whisper_thread(&mut self, peer: EntityId, label: &str, stone: bool) {
self.picking_stone = false;
self.peer_label = label.to_string();
self.thread = if stone {
ChatThreadKind::Stone { peer }
} else {
ChatThreadKind::Whisper { peer }
};
self.input_focused = true;
self.remember_whisper_peer(
peer,
label,
if stone {
ChatChannel::WhisperStone
} else {
ChatChannel::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.set_whisper_thread(peer, label, false);
self.push_system(format!(
"Whispering {label} — type and Enter · Esc cancels · /nearby"
));
}
pub fn focus_stone(&mut self, peer: EntityId, label: &str) {
self.set_whisper_thread(peer, label, true);
self.push_system(format!("Stone link to {label} — type and Enter · /nearby"));
}
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 set_qty_min(&mut self) {
if let Some(entry) = self.qty_entry.as_mut() {
entry.typed.clear();
entry.quantity = 1;
}
}
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 }));
}
#[test]
fn parse_chat_slash_commands() {
assert_eq!(parse_chat_slash("/help"), Some(ChatSlashCommand::Help));
assert_eq!(
parse_chat_slash("/nearby hello"),
Some(ChatSlashCommand::Nearby {
message: Some("hello".into())
})
);
assert_eq!(
parse_chat_slash("/n"),
Some(ChatSlashCommand::Nearby { message: None })
);
assert_eq!(
parse_chat_slash("/r thanks"),
Some(ChatSlashCommand::Reply {
message: Some("thanks".into())
})
);
assert_eq!(
parse_chat_slash("/whisper"),
Some(ChatSlashCommand::Whisper {
name: None,
message: None
})
);
assert_eq!(
parse_chat_slash("/w Ada"),
Some(ChatSlashCommand::Whisper {
name: Some("Ada".into()),
message: None
})
);
assert_eq!(
parse_chat_slash("/w Ada hi there"),
Some(ChatSlashCommand::Whisper {
name: Some("Ada".into()),
message: Some("hi there".into())
})
);
assert_eq!(parse_chat_slash("hello"), None);
assert_eq!(parse_chat_slash("/unknown"), None);
assert!(is_chat_slash_line(" /w Ada"));
assert!(!is_chat_slash_line("w Ada"));
}
#[test]
fn note_speech_records_last_whisper_peer() {
let mut chat = SocialChatState::default();
let msg = ChatMessage {
channel: ChatChannel::Whisper,
from_entity: 9,
from_name: "Mira".into(),
to_entity: Some(1),
text: "psst".into(),
tick: 1,
clarity: ChatClarity::Clear,
};
chat.note_speech(&msg, 1, 1000);
let peer = chat.last_whisper_peer.expect("peer");
assert_eq!(peer.entity_id, 9);
assert_eq!(peer.label, "Mira");
assert_eq!(peer.channel, ChatChannel::Whisper);
}
}