diff --git a/Cargo.lock b/Cargo.lock index c80aae7065..506a5a51c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7173,6 +7173,7 @@ dependencies = [ name = "veloren-voxygen-i18n-helpers" version = "0.10.0" dependencies = [ + "hashbrown 0.12.3", "tracing", "veloren-client-i18n", "veloren-common", diff --git a/client/src/lib.rs b/client/src/lib.rs index 7432f09998..939065ee47 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -2280,13 +2280,15 @@ impl Client { .any(|r| !matches!(r, group::Role::Pet)) { frontend_events - .push(Event::Chat(comp::ChatType::Meta.chat_msg( + // TODO: localise + .push(Event::Chat(comp::ChatType::Meta.into_plain_msg( "Type /g or /group to chat with your group members", ))); } if let Some(player_info) = self.player_list.get(&uid) { frontend_events.push(Event::Chat( - comp::ChatType::GroupMeta("Group".into()).chat_msg(format!( + // TODO: localise + comp::ChatType::GroupMeta("Group".into()).into_plain_msg(format!( "[{}] joined group", self.personalize_alias(uid, player_info.player_alias.clone()) )), @@ -2303,7 +2305,8 @@ impl Client { Removed(uid) => { if let Some(player_info) = self.player_list.get(&uid) { frontend_events.push(Event::Chat( - comp::ChatType::GroupMeta("Group".into()).chat_msg(format!( + // TODO: localise + comp::ChatType::GroupMeta("Group".into()).into_plain_msg(format!( "[{}] left group", self.personalize_alias(uid, player_info.player_alias.clone()) )), @@ -2790,20 +2793,20 @@ impl Client { KillSource::Other => (), }; }, - comp::ChatType::Tell(from, to) | comp::ChatType::NpcTell(from, to, _) => { + comp::ChatType::Tell(from, to) | comp::ChatType::NpcTell(from, to) => { alias_of_uid(from); alias_of_uid(to); }, comp::ChatType::Say(uid) | comp::ChatType::Region(uid) | comp::ChatType::World(uid) - | comp::ChatType::NpcSay(uid, _) => { + | comp::ChatType::NpcSay(uid) => { alias_of_uid(uid); }, comp::ChatType::Group(uid, _) | comp::ChatType::Faction(uid, _) => { alias_of_uid(uid); }, - comp::ChatType::Npc(uid, _) => alias_of_uid(uid), + comp::ChatType::Npc(uid) => alias_of_uid(uid), comp::ChatType::Meta => (), }; result diff --git a/common/net/src/msg/server.rs b/common/net/src/msg/server.rs index 65d5d6d856..a40a932bab 100644 --- a/common/net/src/msg/server.rs +++ b/common/net/src/msg/server.rs @@ -6,7 +6,7 @@ use crate::sync; use common::{ calendar::Calendar, character::{self, CharacterItem}, - comp::{self, invite::InviteKind, item::MaterialStatManifest}, + comp::{self, invite::InviteKind, item::MaterialStatManifest, Content}, event::UpdateCharacterMetadata, lod, outcome::Outcome, @@ -215,11 +215,10 @@ pub enum ServerGeneral { } impl ServerGeneral { - pub fn server_msg(chat_type: comp::ChatType, msg: S) -> Self - where - S: Into, - { - ServerGeneral::ChatMsg(chat_type.chat_msg(msg)) + // TODO: Don't use `Into` since this treats all strings as plaintext, + // properly localise server messages + pub fn server_msg(chat_type: comp::ChatType, content: impl Into) -> Self { + ServerGeneral::ChatMsg(chat_type.into_msg(content.into())) } } diff --git a/common/src/comp/chat.rs b/common/src/comp/chat.rs index 228f12cbab..0322b51472 100644 --- a/common/src/comp/chat.rs +++ b/common/src/comp/chat.rs @@ -2,6 +2,7 @@ use crate::{ comp::{group::Group, BuffKind}, uid::Uid, }; +use hashbrown::HashMap; use serde::{Deserialize, Serialize}; use specs::{Component, DenseVecStorage}; use std::time::{Duration, Instant}; @@ -29,8 +30,8 @@ impl Component for ChatMode { } impl ChatMode { - /// Create a message from your current chat mode and uuid. - pub fn new_message(&self, from: Uid, message: String) -> UnresolvedChatMsg { + /// Create a plain message from your current chat mode and uuid. + pub fn to_plain_msg(&self, from: Uid, text: impl ToString) -> UnresolvedChatMsg { let chat_type = match self { ChatMode::Tell(to) => ChatType::Tell(from, *to), ChatMode::Say => ChatType::Say(from), @@ -39,7 +40,10 @@ impl ChatMode { ChatMode::Faction(faction) => ChatType::Faction(from, faction.clone()), ChatMode::World => ChatType::World(from), }; - UnresolvedChatMsg { chat_type, message } + UnresolvedChatMsg { + chat_type, + content: Content::Plain(text.to_string()), + } } } @@ -102,26 +106,28 @@ pub enum ChatType { /// World chat World(Uid), /// Messages sent from NPCs (Not shown in chat but as speech bubbles) - /// - /// The u16 field is a random number for selecting localization variants. - Npc(Uid, u16), + Npc(Uid), /// From NPCs but in the chat for clients in the near vicinity - NpcSay(Uid, u16), + NpcSay(Uid), /// From NPCs but in the chat for a specific client. Shows a chat bubble. /// (from, to, localization variant) - NpcTell(Uid, Uid, u16), + NpcTell(Uid, Uid), /// Anything else Meta, } impl ChatType { - pub fn chat_msg(self, msg: S) -> GenericChatMsg - where - S: Into, - { + pub fn into_plain_msg(self, text: impl ToString) -> GenericChatMsg { GenericChatMsg { chat_type: self, - message: msg.into(), + content: Content::Plain(text.to_string()), + } + } + + pub fn into_msg(self, content: Content) -> GenericChatMsg { + GenericChatMsg { + chat_type: self, + content, } } @@ -140,9 +146,9 @@ impl ChatType { ChatType::Faction(u, _s) => Some(*u), ChatType::Region(u) => Some(*u), ChatType::World(u) => Some(*u), - ChatType::Npc(u, _r) => Some(*u), - ChatType::NpcSay(u, _r) => Some(*u), - ChatType::NpcTell(u, _t, _r) => Some(*u), + ChatType::Npc(u) => Some(*u), + ChatType::NpcSay(u) => Some(*u), + ChatType::NpcTell(u, _t) => Some(*u), ChatType::Meta => None, } } @@ -156,9 +162,9 @@ impl ChatType { | ChatType::CommandError | ChatType::FactionMeta(_) | ChatType::GroupMeta(_) - | ChatType::Npc(_, _) - | ChatType::NpcSay(_, _) - | ChatType::NpcTell(_, _, _) + | ChatType::Npc(_) + | ChatType::NpcSay(_) + | ChatType::NpcTell(_, _) | ChatType::Meta | ChatType::Kill(_, _) => None, ChatType::Tell(_, _) | ChatType::Group(_, _) | ChatType::Faction(_, _) => Some(true), @@ -167,11 +173,87 @@ impl ChatType { } } +/// The content of a chat message. +// TODO: This could be generalised to *any* in-game text, not just chat messages (hence it not being +// called `ChatContent`). A few examples: +// +// - Signposts, both those appearing as overhead messages and those displayed 'in-world' on a shop +// sign +// - UI elements +// - In-game notes/books (we could add a variant that allows structuring complex, novel textual +// information as a syntax tree or some other intermediate format that can be localised by the +// client) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Content { + /// The content is a plaintext string that should be shown to the user + /// verbatim. + Plain(String), + /// The content is a localizable message with the given arguments. + Localized { + /// i18n key + key: String, + /// Pseudorandom seed value that allows frontends to select a + /// deterministic (but pseudorandom) localised output + seed: u16, + /// i18n arguments + args: HashMap, + }, +} + +impl From for Content { + fn from(text: String) -> Self { Self::Plain(text) } +} + +impl<'a> From<&'a str> for Content { + fn from(text: &'a str) -> Self { Self::Plain(text.to_string()) } +} + +impl Content { + pub fn localized(key: impl ToString) -> Self { + Self::Localized { + key: key.to_string(), + r: rand::random(), + args: HashMap::default(), + } + } + + pub fn localized_with_args<'a, S: ToString>( + key: impl ToString, + args: impl IntoIterator, + ) -> Self { + Self::Localized { + key: key.to_string(), + r: rand::random(), + args: args + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + } + } + + pub fn as_plain(&self) -> Option<&str> { + match self { + Self::Plain(text) => Some(text.as_str()), + Self::Localized { .. } => None, + } + } + + pub fn localize(&self, i18n_variation: F) -> String + where + F: Fn(&str, u16, &HashMap) -> String, + { + match self { + Content::Plain(text) => text.to_string(), + Content::Localized { key, seed, args } => i18n_variation(key, *seed, args), + } + } +} + // Stores chat text, type #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenericChatMsg { pub chat_type: ChatType, - pub message: String, + content: Content, } pub type ChatMsg = GenericChatMsg; @@ -183,19 +265,19 @@ impl GenericChatMsg { pub const REGION_DISTANCE: f32 = 1000.0; pub const SAY_DISTANCE: f32 = 100.0; - pub fn npc(uid: Uid, message: String) -> Self { - let chat_type = ChatType::Npc(uid, rand::random()); - Self { chat_type, message } + pub fn npc(uid: Uid, content: Content) -> Self { + let chat_type = ChatType::Npc(uid); + Self { chat_type, content } } - pub fn npc_say(uid: Uid, message: String) -> Self { - let chat_type = ChatType::NpcSay(uid, rand::random()); - Self { chat_type, message } + pub fn npc_say(uid: Uid, content: Content) -> Self { + let chat_type = ChatType::NpcSay(uid); + Self { chat_type, content } } - pub fn npc_tell(from: Uid, to: Uid, message: String) -> Self { - let chat_type = ChatType::NpcTell(from, to, rand::random()); - Self { chat_type, message } + pub fn npc_tell(from: Uid, to: Uid, content: Content) -> Self { + let chat_type = ChatType::NpcTell(from, to); + Self { chat_type, content } } pub fn map_group(self, mut f: impl FnMut(G) -> T) -> GenericChatMsg { @@ -213,15 +295,15 @@ impl GenericChatMsg { ChatType::Faction(a, b) => ChatType::Faction(a, b), ChatType::Region(a) => ChatType::Region(a), ChatType::World(a) => ChatType::World(a), - ChatType::Npc(a, b) => ChatType::Npc(a, b), - ChatType::NpcSay(a, b) => ChatType::NpcSay(a, b), - ChatType::NpcTell(a, b, c) => ChatType::NpcTell(a, b, c), + ChatType::Npc(a) => ChatType::Npc(a), + ChatType::NpcSay(a) => ChatType::NpcSay(a), + ChatType::NpcTell(a, b) => ChatType::NpcTell(a, b), ChatType::Meta => ChatType::Meta, }; GenericChatMsg { chat_type, - message: self.message, + content: self.content, } } @@ -234,15 +316,8 @@ impl GenericChatMsg { } pub fn to_bubble(&self) -> Option<(SpeechBubble, Uid)> { - let icon = self.icon(); - if let ChatType::Npc(from, r) | ChatType::NpcSay(from, r) | ChatType::NpcTell(from, _, r) = - self.chat_type - { - Some((SpeechBubble::npc_new(&self.message, r, icon), from)) - } else { - self.uid() - .map(|from| (SpeechBubble::player_new(&self.message, icon), from)) - } + self.uid() + .map(|from| (SpeechBubble::new(self.content.clone(), self.icon()), from)) } pub fn icon(&self) -> SpeechBubbleType { @@ -260,14 +335,18 @@ impl GenericChatMsg { ChatType::Faction(_u, _s) => SpeechBubbleType::Faction, ChatType::Region(_u) => SpeechBubbleType::Region, ChatType::World(_u) => SpeechBubbleType::World, - ChatType::Npc(_u, _r) => SpeechBubbleType::None, - ChatType::NpcSay(_u, _r) => SpeechBubbleType::Say, - ChatType::NpcTell(_f, _t, _) => SpeechBubbleType::Say, + ChatType::Npc(_u) => SpeechBubbleType::None, + ChatType::NpcSay(_u) => SpeechBubbleType::Say, + ChatType::NpcTell(_f, _t) => SpeechBubbleType::Say, ChatType::Meta => SpeechBubbleType::None, } } pub fn uid(&self) -> Option { self.chat_type.uid() } + + pub fn content(&self) -> &Content { &self.content } + + pub fn set_content(&mut self, content: Content) { self.content = content; } } /// Player factions are used to coordinate pvp vs hostile factions or segment @@ -283,15 +362,6 @@ impl From for Faction { fn from(s: String) -> Self { Faction(s) } } -/// The contents of a speech bubble -pub enum SpeechBubbleMessage { - /// This message was said by a player and needs no translation - Plain(String), - /// This message was said by an NPC. The fields are a i18n key and a random - /// u16 index - Localized(String, u16), -} - /// List of chat types for players and NPCs. Each one has its own icon. /// /// This is a subset of `ChatType`, and a superset of `ChatMode` @@ -311,7 +381,7 @@ pub enum SpeechBubbleType { /// Adds a speech bubble above the character pub struct SpeechBubble { - pub message: SpeechBubbleMessage, + pub content: Content, pub icon: SpeechBubbleType, pub timeout: Instant, } @@ -320,33 +390,14 @@ impl SpeechBubble { /// Default duration in seconds of speech bubbles pub const DEFAULT_DURATION: f64 = 5.0; - pub fn npc_new(i18n_key: &str, r: u16, icon: SpeechBubbleType) -> Self { - let message = SpeechBubbleMessage::Localized(i18n_key.to_string(), r); + pub fn new(content: Content, icon: SpeechBubbleType) -> Self { let timeout = Instant::now() + Duration::from_secs_f64(SpeechBubble::DEFAULT_DURATION); Self { - message, + content, icon, timeout, } } - pub fn player_new(message: &str, icon: SpeechBubbleType) -> Self { - let message = SpeechBubbleMessage::Plain(message.to_string()); - let timeout = Instant::now() + Duration::from_secs_f64(SpeechBubble::DEFAULT_DURATION); - Self { - message, - icon, - timeout, - } - } - - pub fn message(&self, i18n_variation: F) -> String - where - F: Fn(&str, u16) -> String, - { - match &self.message { - SpeechBubbleMessage::Plain(m) => m.to_string(), - SpeechBubbleMessage::Localized(k, i) => i18n_variation(k, *i), - } - } + pub fn content(&self) -> &Content { &self.content } } diff --git a/common/src/comp/mod.rs b/common/src/comp/mod.rs index 9b47025300..e5ad810d87 100644 --- a/common/src/comp/mod.rs +++ b/common/src/comp/mod.rs @@ -75,7 +75,8 @@ pub use self::{ }, character_state::{CharacterActivity, CharacterState, StateUpdate}, chat::{ - ChatMode, ChatMsg, ChatType, Faction, SpeechBubble, SpeechBubbleType, UnresolvedChatMsg, + ChatMode, ChatMsg, ChatType, Content, Faction, SpeechBubble, SpeechBubbleType, + UnresolvedChatMsg, }, combo::Combo, controller::{ diff --git a/rtsim/src/rule/simulate_npcs.rs b/rtsim/src/rule/simulate_npcs.rs index 290bbad8a8..1f537be5cf 100644 --- a/rtsim/src/rule/simulate_npcs.rs +++ b/rtsim/src/rule/simulate_npcs.rs @@ -88,7 +88,7 @@ fn on_death(ctx: EventCtx) { ); Some((npc_id, site_id)) } else { - warn!("No site found for respawning humaniod"); + warn!("No site found for respawning humanoid"); None } }, diff --git a/server/agent/src/action_nodes.rs b/server/agent/src/action_nodes.rs index a05bdb3711..bad47a3f8b 100644 --- a/server/agent/src/action_nodes.rs +++ b/server/agent/src/action_nodes.rs @@ -22,7 +22,7 @@ use common::{ }, item_drop, projectile::ProjectileConstructor, - Agent, Alignment, Body, CharacterState, ControlAction, ControlEvent, Controller, + Agent, Alignment, Body, CharacterState, Content, ControlAction, ControlEvent, Controller, HealthChange, InputKind, InventoryAction, Pos, Scale, UnresolvedChatMsg, UtteranceKind, }, effect::{BuffEffect, Effect}, @@ -1529,10 +1529,10 @@ impl<'a> AgentData<'a> { } } - pub fn chat_npc(&self, msg: impl ToString, event_emitter: &mut Emitter<'_, ServerEvent>) { + pub fn chat_npc(&self, key: impl ToString, event_emitter: &mut Emitter<'_, ServerEvent>) { event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc( *self.uid, - msg.to_string(), + Content::localized(key), ))); } diff --git a/server/src/cmd.rs b/server/src/cmd.rs index 1abbbcf4c5..cb64723a05 100644 --- a/server/src/cmd.rs +++ b/server/src/cmd.rs @@ -2745,7 +2745,7 @@ fn handle_tell( } else { message_opt.join(" ") }; - server.state.send_chat(mode.new_message(target_uid, msg)); + server.state.send_chat(mode.to_plain_msg(target_uid, msg)); server.notify_client(target, ServerGeneral::ChatMode(mode)); Ok(()) } else { @@ -2770,7 +2770,7 @@ fn handle_faction( let msg = args.join(" "); if !msg.is_empty() { if let Some(uid) = server.state.ecs().read_storage().get(target) { - server.state.send_chat(mode.new_message(*uid, msg)); + server.state.send_chat(mode.to_plain_msg(*uid, msg)); } } server.notify_client(target, ServerGeneral::ChatMode(mode)); @@ -2797,7 +2797,7 @@ fn handle_group( let msg = args.join(" "); if !msg.is_empty() { if let Some(uid) = server.state.ecs().read_storage().get(target) { - server.state.send_chat(mode.new_message(*uid, msg)); + server.state.send_chat(mode.to_plain_msg(*uid, msg)); } } server.notify_client(target, ServerGeneral::ChatMode(mode)); @@ -2921,7 +2921,7 @@ fn handle_region( let msg = args.join(" "); if !msg.is_empty() { if let Some(uid) = server.state.ecs().read_storage().get(target) { - server.state.send_chat(mode.new_message(*uid, msg)); + server.state.send_chat(mode.to_plain_msg(*uid, msg)); } } server.notify_client(target, ServerGeneral::ChatMode(mode)); @@ -2942,7 +2942,7 @@ fn handle_say( let msg = args.join(" "); if !msg.is_empty() { if let Some(uid) = server.state.ecs().read_storage().get(target) { - server.state.send_chat(mode.new_message(*uid, msg)); + server.state.send_chat(mode.to_plain_msg(*uid, msg)); } } server.notify_client(target, ServerGeneral::ChatMode(mode)); @@ -2963,7 +2963,7 @@ fn handle_world( let msg = args.join(" "); if !msg.is_empty() { if let Some(uid) = server.state.ecs().read_storage().get(target) { - server.state.send_chat(mode.new_message(*uid, msg)); + server.state.send_chat(mode.to_plain_msg(*uid, msg)); } } server.notify_client(target, ServerGeneral::ChatMode(mode)); @@ -2992,8 +2992,9 @@ fn handle_join_faction( .flatten() .map(|f| f.0); server.state.send_chat( + // TODO: Localise ChatType::FactionMeta(faction.clone()) - .chat_msg(format!("[{}] joined faction ({})", alias, faction)), + .into_plain_msg(format!("[{}] joined faction ({})", alias, faction)), ); (faction_join, mode) } else { @@ -3009,8 +3010,9 @@ fn handle_join_faction( }; if let Some(faction) = faction_leave { server.state.send_chat( + // TODO: Localise ChatType::FactionMeta(faction.clone()) - .chat_msg(format!("[{}] left faction ({})", alias, faction)), + .into_plain_msg(format!("[{}] left faction ({})", alias, faction)), ); } server.notify_client(target, ServerGeneral::ChatMode(mode)); diff --git a/server/src/events/entity_manipulation.rs b/server/src/events/entity_manipulation.rs index e7479382e3..b54cfedf8d 100644 --- a/server/src/events/entity_manipulation.rs +++ b/server/src/events/entity_manipulation.rs @@ -35,7 +35,6 @@ use common::{ }; use common_net::{msg::ServerGeneral, sync::WorldSyncExt}; use common_state::BlockChange; -use comp::chat::GenericChatMsg; use hashbrown::HashSet; use rand::{distributions::WeightedIndex, Rng}; use rand_distr::Distribution; @@ -198,10 +197,7 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt _ => KillSource::Other, }; - state.send_chat(GenericChatMsg { - chat_type: comp::ChatType::Kill(kill_source, *uid), - message: "".to_string(), - }); + state.send_chat(comp::ChatType::Kill(kill_source, *uid).into_plain_msg("")); } } diff --git a/server/src/state_ext.rs b/server/src/state_ext.rs index c56a46bf90..1f83105f53 100644 --- a/server/src/state_ext.rs +++ b/server/src/state_ext.rs @@ -797,7 +797,11 @@ impl StateExt for State { (*ecs.read_resource::()) .retrieve_entity_internal(sender.0) .map_or(false, |e| { - self.validate_chat_msg(e, &msg.chat_type, &msg.message) + self.validate_chat_msg( + e, + &msg.chat_type, + msg.content().as_plain().unwrap_or_default(), + ) }) }) { match &msg.chat_type { @@ -827,7 +831,6 @@ impl StateExt for State { } }, comp::ChatType::Kill(kill_source, uid) => { - let comp::chat::GenericChatMsg { message, .. } = msg; let clients = ecs.read_storage::(); let clients_count = clients.count(); // Avoid chat spam, send kill message only to group or nearby players if a @@ -870,7 +873,7 @@ impl StateExt for State { } else { self.notify_players(ServerGeneral::server_msg( comp::ChatType::Kill(kill_source.clone(), *uid), - message, + msg.content().clone(), )) } }, @@ -900,7 +903,7 @@ impl StateExt for State { } } }, - comp::ChatType::Npc(uid, _r) => { + comp::ChatType::Npc(uid) => { let entity_opt = (*ecs.read_resource::()).retrieve_entity_internal(uid.0); @@ -913,7 +916,7 @@ impl StateExt for State { } } }, - comp::ChatType::NpcSay(uid, _r) => { + comp::ChatType::NpcSay(uid) => { let entity_opt = (*ecs.read_resource::()).retrieve_entity_internal(uid.0); @@ -926,7 +929,7 @@ impl StateExt for State { } } }, - comp::ChatType::NpcTell(from, to, _r) => { + comp::ChatType::NpcTell(from, to) => { for (client, uid) in (&ecs.read_storage::(), &ecs.read_storage::()).join() { @@ -950,12 +953,10 @@ impl StateExt for State { comp::ChatType::Group(from, g) => { if group_info.is_none() { // group not found, reply with command error - let reply = comp::ChatMsg { - chat_type: comp::ChatType::CommandError, - message: "You are using group chat but do not belong to a group. Use \ - /world or /region to change chat." - .into(), - }; + let reply = comp::ChatType::CommandError.into_plain_msg( + "You are using group chat but do not belong to a group. Use /world or \ + /region to change chat.", + ); if let Some((client, _)) = (&ecs.read_storage::(), &ecs.read_storage::()) diff --git a/server/src/sys/agent/behavior_tree/interaction.rs b/server/src/sys/agent/behavior_tree/interaction.rs index 92e0c15deb..e097e1eae4 100644 --- a/server/src/sys/agent/behavior_tree/interaction.rs +++ b/server/src/sys/agent/behavior_tree/interaction.rs @@ -6,7 +6,8 @@ use common::{ inventory::item::{ItemTag, MaterialStatManifest}, invite::{InviteKind, InviteResponse}, tool::AbilityMap, - BehaviorState, ControlAction, Item, TradingBehavior, UnresolvedChatMsg, UtteranceKind, + BehaviorState, Content, ControlAction, Item, TradingBehavior, UnresolvedChatMsg, + UtteranceKind, }, event::ServerEvent, rtsim::PersonalityTrait, @@ -434,7 +435,7 @@ pub fn handle_inbox_update_pending_trade(bdata: &mut BehaviorData) -> bool { let (tradeid, pending, prices, inventories) = *boxval; if agent.behavior.is(BehaviorState::TRADING) { let who = usize::from(!agent.behavior.is(BehaviorState::TRADING_ISSUER)); - let mut message = |msg| { + let mut message = |text| { if let Some(with) = agent .target .as_ref() @@ -443,12 +444,14 @@ pub fn handle_inbox_update_pending_trade(bdata: &mut BehaviorData) -> bool { event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc_tell( *agent_data.uid, *with, - msg, + // TODO: localise this + Content::Plain(text), ))); } else { event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc_say( *agent_data.uid, - msg, + // TODO: localise this + Content::Plain(text), ))); } }; diff --git a/server/src/sys/msg/character_screen.rs b/server/src/sys/msg/character_screen.rs index 1aea142188..2e2677a704 100644 --- a/server/src/sys/msg/character_screen.rs +++ b/server/src/sys/msg/character_screen.rs @@ -11,7 +11,7 @@ use crate::{ EditableSettings, }; use common::{ - comp::{Admin, AdminRole, ChatType, Player, Presence, UnresolvedChatMsg, Waypoint}, + comp::{Admin, AdminRole, ChatType, Player, Presence, Waypoint}, event::{EventBus, ServerEvent}, resources::Time, terrain::TerrainChunkSize, @@ -48,7 +48,7 @@ impl Sys { if !editable_settings.server_description.is_empty() { client.send(ServerGeneral::server_msg( ChatType::CommandInfo, - &*editable_settings.server_description, + editable_settings.server_description.as_str(), ))?; } @@ -62,10 +62,9 @@ impl Sys { if !client.login_msg_sent.load(Ordering::Relaxed) { if let Some(player_uid) = uids.get(entity) { - server_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg { - chat_type: ChatType::Online(*player_uid), - message: "".to_string(), - })); + server_emitter.emit(ServerEvent::Chat( + ChatType::Online(*player_uid).into_plain_msg(""), + )); client.login_msg_sent.store(true, Ordering::Relaxed); } diff --git a/server/src/sys/msg/general.rs b/server/src/sys/msg/general.rs index 515e326098..17162b3227 100644 --- a/server/src/sys/msg/general.rs +++ b/server/src/sys/msg/general.rs @@ -28,7 +28,7 @@ impl Sys { const CHAT_MODE_DEFAULT: &ChatMode = &ChatMode::default(); let mode = chat_modes.get(entity).unwrap_or(CHAT_MODE_DEFAULT); // Send chat message - server_emitter.emit(ServerEvent::Chat(mode.new_message(*from, message))); + server_emitter.emit(ServerEvent::Chat(mode.to_plain_msg(*from, message))); } else { error!("Could not send message. Missing player uid"); } diff --git a/voxygen/i18n-helpers/Cargo.toml b/voxygen/i18n-helpers/Cargo.toml index 7126d15c74..1ffaff8df5 100644 --- a/voxygen/i18n-helpers/Cargo.toml +++ b/voxygen/i18n-helpers/Cargo.toml @@ -10,4 +10,5 @@ common-net = {package = "veloren-common-net", path = "../../common/net"} common = {package = "veloren-common", path = "../../common"} i18n = {package = "veloren-client-i18n", path = "../../client/i18n"} # Utility -tracing = "0.1" \ No newline at end of file +tracing = "0.1" +hashbrown = { version = "0.12" } diff --git a/voxygen/i18n-helpers/src/lib.rs b/voxygen/i18n-helpers/src/lib.rs index 6c3f4ed608..dd6df72e39 100644 --- a/voxygen/i18n-helpers/src/lib.rs +++ b/voxygen/i18n-helpers/src/lib.rs @@ -1,19 +1,32 @@ #![feature(let_chains)] use common::comp::{ chat::{KillSource, KillType}, - BuffKind, ChatMsg, ChatType, + BuffKind, ChatMsg, ChatType, Content, }; use common_net::msg::{ChatTypeContext, PlayerInfo}; +use hashbrown::HashMap; use i18n::Localization; +pub fn make_localizer( + localisation: &Localization, +) -> impl Fn(&str, u16, &HashMap) -> String + Copy + '_ { + move |key: &str, seed: u16, args: &HashMap| { + localisation + .get_variation_ctx(key, seed, &args.iter().collect()) + .into_owned() + } +} + pub fn localize_chat_message( - mut msg: ChatMsg, + msg: ChatMsg, lookup_fn: impl Fn(&ChatMsg) -> ChatTypeContext, localisation: &Localization, show_char_name: bool, -) -> ChatMsg { +) -> (ChatType, String) { let info = lookup_fn(&msg); + let localizer = make_localizer(localisation); + let name_format = |uid: &common::uid::Uid| match info.player_alias.get(uid).cloned() { Some(pi) => insert_alias(info.you == *uid, pi, localisation), None => info @@ -23,13 +36,14 @@ pub fn localize_chat_message( .expect("client didn't proved enough info"), }; - let message_format = |from: &common::uid::Uid, message: &str, group: Option<&String>| { + let message_format = |from: &common::uid::Uid, content: &Content, group: Option<&String>| { let alias = name_format(from); let name = if let Some(pi) = info.player_alias.get(from).cloned() && show_char_name { pi.character.map(|c| c.name) } else { None }; + let message = content.localize(localizer); match (group, name) { (Some(group), None) => format!("({group}) [{alias}]: {message}"), (None, None) => format!("[{alias}]: {message}"), @@ -50,40 +64,40 @@ pub fn localize_chat_message( ), }) .into_owned(), - ChatType::CommandError => msg.message.to_string(), - ChatType::CommandInfo => msg.message.to_string(), - ChatType::FactionMeta(_) => msg.message.to_string(), - ChatType::GroupMeta(_) => msg.message.to_string(), + ChatType::CommandError => msg.content().localize(localizer), + ChatType::CommandInfo => msg.content().localize(localizer), + ChatType::FactionMeta(_) => msg.content().localize(localizer), + ChatType::GroupMeta(_) => msg.content().localize(localizer), ChatType::Tell(from, to) => { let from_alias = name_format(from); let to_alias = name_format(to); // TODO: internationalise if *from == info.you { - format!("To [{to_alias}]: {}", msg.message) + format!("To [{to_alias}]: {}", msg.content().localize(localizer)) } else { - format!("From [{from_alias}]: {}", msg.message) + format!("From [{from_alias}]: {}", msg.content().localize(localizer)) } }, - ChatType::Say(uid) => message_format(uid, &msg.message, None), - ChatType::Group(uid, s) => message_format(uid, &msg.message, Some(s)), - ChatType::Faction(uid, s) => message_format(uid, &msg.message, Some(s)), - ChatType::Region(uid) => message_format(uid, &msg.message, None), - ChatType::World(uid) => message_format(uid, &msg.message, None), + ChatType::Say(uid) => message_format(uid, msg.content(), None), + ChatType::Group(uid, s) => message_format(uid, msg.content(), Some(s)), + ChatType::Faction(uid, s) => message_format(uid, msg.content(), Some(s)), + ChatType::Region(uid) => message_format(uid, msg.content(), None), + ChatType::World(uid) => message_format(uid, msg.content(), None), // NPCs can't talk. Should be filtered by hud/mod.rs for voxygen and // should be filtered by server (due to not having a Pos) for chat-cli - ChatType::Npc(uid, _r) => message_format(uid, &msg.message, None), - ChatType::NpcSay(uid, _r) => message_format(uid, &msg.message, None), - ChatType::NpcTell(from, to, _r) => { + ChatType::Npc(uid) => message_format(uid, msg.content(), None), + ChatType::NpcSay(uid) => message_format(uid, msg.content(), None), + ChatType::NpcTell(from, to) => { let from_alias = name_format(from); let to_alias = name_format(to); // TODO: internationalise if *from == info.you { - format!("To [{to_alias}]: {}", msg.message) + format!("To [{to_alias}]: {}", msg.content().localize(localizer)) } else { - format!("From [{from_alias}]: {}", msg.message) + format!("From [{from_alias}]: {}", msg.content().localize(localizer)) } }, - ChatType::Meta => msg.message.to_string(), + ChatType::Meta => msg.content().localize(localizer), ChatType::Kill(kill_source, victim) => { let i18n_buff = |buff| match buff { BuffKind::Burning => "hud-outcome-burning", @@ -209,8 +223,7 @@ pub fn localize_chat_message( }, }; - msg.message = new_msg; - msg + (msg.chat_type, new_msg) } fn insert_alias(you: bool, info: PlayerInfo, localisation: &Localization) -> String { diff --git a/voxygen/src/hud/chat.rs b/voxygen/src/hud/chat.rs index afe095a5d2..d92913eb10 100644 --- a/voxygen/src/hud/chat.rs +++ b/voxygen/src/hud/chat.rs @@ -226,8 +226,8 @@ impl<'a> Widget for Chat<'a> { for message in self.new_messages.iter() { // Log the output of commands since the ingame terminal doesn't support copying // the output to the clipboard - if let ChatType::CommandInfo = message.chat_type { - tracing::info!("Chat command info: {}", message.message); + if let ChatType::CommandInfo = &message.chat_type { + tracing::info!("Chat command info: {:?}", message.content()); } } //new messages - update chat w/ them & scroll down if at bottom of chat @@ -426,14 +426,6 @@ impl<'a> Widget for Chat<'a> { let messages = &state .messages .iter() - .map(|m| { - localize_chat_message( - m.clone(), - |msg| self.client.lookup_msg_context(msg), - self.localized_strings, - show_char_name, - ) - }) .filter(|m| { if let Some(chat_tab) = current_chat_tab { chat_tab.filter.satisfies(m, &group_members) @@ -447,13 +439,19 @@ impl<'a> Widget for Chat<'a> { .uid() .and_then(|uid| { self.client - .lookup_msg_context(&m) + .lookup_msg_context(m) .player_alias .get(&uid) .map(|i| i.is_moderator) }) .unwrap_or(false); - (is_moderator, m) + let (chat_type, text) = localize_chat_message( + m.clone(), + |msg| self.client.lookup_msg_context(msg), + self.localized_strings, + show_char_name, + ); + (is_moderator, chat_type, text) }) .collect::>(); let n_badges = messages.iter().filter(|t| t.0).count(); @@ -478,14 +476,14 @@ impl<'a> Widget for Chat<'a> { while let Some(item) = items.next(ui) { // This would be easier if conrod used the v-metrics from rusttype. if item.i < messages.len() { - let (is_moderator, message) = &messages[item.i]; - let (color, icon) = render_chat_line(&message.chat_type, self.imgs); + let (is_moderator, chat_type, text) = &messages[item.i]; + let (color, icon) = render_chat_line(chat_type, self.imgs); // For each ChatType needing localization get/set matching pre-formatted // localized string. This string will be formatted with the data // provided in ChatType in the client/src/mod.rs // fn format_message called below - let text = Text::new(&message.message) + let text = Text::new(text) .font_size(self.fonts.opensans.scale(15)) .font_id(self.fonts.opensans.conrod_id) .w(CHAT_BOX_WIDTH - 17.0) @@ -688,10 +686,10 @@ impl<'a> Widget for Chat<'a> { if let Some(msg) = msg.strip_prefix(chat_settings.chat_cmd_prefix) { match parse_cmd(msg) { Ok((name, args)) => events.push(Event::SendCommand(name, args)), - Err(err) => self.new_messages.push_back(ChatMsg { - chat_type: ChatType::CommandError, - message: err, - }), + // TODO: Localise + Err(err) => self + .new_messages + .push_back(ChatType::CommandError.into_plain_msg(err)), } } else { events.push(Event::SendMessage(msg)); @@ -783,9 +781,9 @@ fn render_chat_line(chat_type: &ChatType, imgs: &Imgs) -> (Color, conrod ChatType::Faction(_uid, _s) => (FACTION_COLOR, imgs.chat_faction_small), ChatType::Region(_uid) => (REGION_COLOR, imgs.chat_region_small), ChatType::World(_uid) => (WORLD_COLOR, imgs.chat_world_small), - ChatType::Npc(_uid, _r) => panic!("NPCs can't talk!"), // Should be filtered by hud/mod.rs - ChatType::NpcSay(_uid, _r) => (SAY_COLOR, imgs.chat_say_small), - ChatType::NpcTell(_from, _to, _r) => (TELL_COLOR, imgs.chat_tell_small), + ChatType::Npc(_uid) => panic!("NPCs can't talk!"), // Should be filtered by hud/mod.rs + ChatType::NpcSay(_uid) => (SAY_COLOR, imgs.chat_say_small), + ChatType::NpcTell(_from, _to) => (TELL_COLOR, imgs.chat_tell_small), ChatType::Meta => (INFO_COLOR, imgs.chat_command_info_small), } } diff --git a/voxygen/src/hud/mod.rs b/voxygen/src/hud/mod.rs index 66a459f807..d561da6490 100644 --- a/voxygen/src/hud/mod.rs +++ b/voxygen/src/hud/mod.rs @@ -3278,7 +3278,7 @@ impl Hud { // Don't put NPC messages in chat box. self.new_messages - .retain(|m| !matches!(m.chat_type, comp::ChatType::Npc(_, _))); + .retain(|m| !matches!(m.chat_type, comp::ChatType::Npc(_))); // Chat box if global_state.settings.interface.toggle_chat { diff --git a/voxygen/src/hud/overhead.rs b/voxygen/src/hud/overhead.rs index 32d655af8d..425ef488b3 100644 --- a/voxygen/src/hud/overhead.rs +++ b/voxygen/src/hud/overhead.rs @@ -20,6 +20,7 @@ use conrod_core::{ widget_ids, Color, Colorable, Positionable, Sizeable, Widget, WidgetCommon, }; use i18n::Localization; +use i18n_helpers::make_localizer; use keyboard_keynames::key_layout::KeyLayout; const MAX_BUBBLE_WIDTH: f64 = 250.0; @@ -534,8 +535,7 @@ impl<'a> Widget for Overhead<'a> { // Speech bubble if let Some(bubble) = self.bubble { let dark_mode = self.settings.speech_bubble_dark_mode; - let localizer = |s: &str, i| -> String { self.i18n.get_variation(s, i).to_string() }; - let bubble_contents: String = bubble.message(localizer); + let bubble_contents: String = bubble.content().localize(make_localizer(self.i18n)); let (text_color, shadow_color) = bubble_color(bubble, dark_mode); let mut text = Text::new(&bubble_contents) .color(text_color) diff --git a/voxygen/src/session/mod.rs b/voxygen/src/session/mod.rs index 849dc0c313..990347d6ff 100644 --- a/voxygen/src/session/mod.rs +++ b/voxygen/src/session/mod.rs @@ -18,7 +18,7 @@ use common::{ inventory::slot::{EquipSlot, Slot}, invite::InviteKind, item::{tool::ToolKind, ItemDesc}, - ChatMsg, ChatType, InputKind, InventoryUpdateEvent, Pos, PresenceKind, Stats, + ChatType, Content, InputKind, InventoryUpdateEvent, Pos, PresenceKind, Stats, UtteranceKind, Vel, }, consts::MAX_MOUNT_RANGE, @@ -309,17 +309,17 @@ impl SessionState { InviteAnswer::TimedOut => "timed out", }; let msg = format!("{} invite to {} {}", kind_str, target_name, answer_str); - self.hud.new_message(ChatType::Meta.chat_msg(msg)); + // TODO: Localise + self.hud.new_message(ChatType::Meta.into_plain_msg(msg)); }, client::Event::TradeComplete { result, trade: _ } => { self.hud.clear_cursor(); - let i18n = global_state.i18n.read(); - let msg = match result { - TradeResult::Completed => i18n.get_msg("hud-trade-result-completed"), - TradeResult::Declined => i18n.get_msg("hud-trade-result-declined"), - TradeResult::NotEnoughSpace => i18n.get_msg("hud-trade-result-nospace"), - }; - self.hud.new_message(ChatType::Meta.chat_msg(msg)); + self.hud + .new_message(ChatType::Meta.into_msg(Content::localized(match result { + TradeResult::Completed => "hud-trade-result-completed", + TradeResult::Declined => "hud-trade-result-declined", + TradeResult::NotEnoughSpace => "hud-trade-result-nospace", + }))); }, client::Event::InventoryUpdated(inv_event) => { let sfx_triggers = self.scene.sfx_mgr.triggers.read(); @@ -380,21 +380,13 @@ impl SessionState { }, client::Event::Disconnect => return Ok(TickAction::Disconnect), client::Event::DisconnectionNotification(time) => { - let i18n = global_state.i18n.read(); - - let message = match time { - 0 => String::from(i18n.get_msg("hud-chat-goodbye")), - _ => i18n - .get_msg_ctx("hud-chat-connection_lost", &i18n::fluent_args! { - "time" => time - }) - .into_owned(), - }; - - self.hud.new_message(ChatMsg { - chat_type: ChatType::CommandError, - message, - }); + self.hud + .new_message(ChatType::CommandError.into_msg(match time { + 0 => Content::localized("hud-chat-goodbye"), + _ => Content::localized_with_args("hud-chat-connection_lost", [( + "time", time, + )]), + })); }, client::Event::Kicked(reason) => { global_state.info_message = Some(format!( @@ -992,18 +984,12 @@ impl PlayState for SessionState { |e| e.name.to_owned(), ) }); - let msg = global_state - .i18n - .read() - .get_msg_ctx( + self.hud.new_message(ChatType::Meta.into_msg( + Content::localized_with_args( "hud-trade-invite_sent", - &i18n::fluent_args! { - "playername" => &name - }, - ) - .into_owned(); - self.hud - .new_message(ChatType::Meta.chat_msg(msg)); + [("playername", &name)], + ), + )); client.send_invite(uid, InviteKind::Trade) }; }, @@ -1115,10 +1101,11 @@ impl PlayState for SessionState { ); }, }, - Event::ScreenshotMessage(screenshot_message) => self.hud.new_message(ChatMsg { - chat_type: ChatType::CommandInfo, - message: screenshot_message, - }), + + // TODO: Localise + Event::ScreenshotMessage(screenshot_msg) => self + .hud + .new_message(ChatType::CommandInfo.into_plain_msg(screenshot_msg)), Event::Zoom(delta) if self.zoom_lock => { // only fire this Hud event when player has "intent" to zoom @@ -1414,11 +1401,15 @@ impl PlayState for SessionState { match run_command(&mut self.client.borrow_mut(), global_state, &name, args) { Ok(Some(info)) => { - self.hud.new_message(ChatType::CommandInfo.chat_msg(&info)) + // TODO: Localise + self.hud + .new_message(ChatType::CommandInfo.into_plain_msg(&info)) }, Ok(None) => {}, // Server will provide an info message Err(error) => { - self.hud.new_message(ChatType::CommandError.chat_msg(error)) + // TODO: Localise + self.hud + .new_message(ChatType::CommandError.into_plain_msg(error)) }, }; },