diff --git a/Cargo.lock b/Cargo.lock index 86350ff101..5d10af247c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6946,8 +6946,8 @@ dependencies = [ "serde", "tracing", "unic-langid", - "veloren-common", "veloren-common-assets", + "veloren-common-i18n", ] [[package]] @@ -6994,6 +6994,7 @@ dependencies = [ "vek 0.15.8", "veloren-common-assets", "veloren-common-base", + "veloren-common-i18n", ] [[package]] @@ -7054,6 +7055,15 @@ dependencies = [ "veloren-common-base", ] +[[package]] +name = "veloren-common-i18n" +version = "0.1.0" +dependencies = [ + "hashbrown 0.13.2", + "rand 0.8.5", + "serde", +] + [[package]] name = "veloren-common-net" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 53665a2a94..4ed1120afa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ resolver = "2" members = [ "common", "common/assets", + "common/i18n", "common/base", "common/dynlib", "common/ecs", diff --git a/client/i18n/Cargo.toml b/client/i18n/Cargo.toml index be93a45c1c..4584e09f48 100644 --- a/client/i18n/Cargo.toml +++ b/client/i18n/Cargo.toml @@ -7,8 +7,8 @@ version = "0.13.0" [dependencies] # Assets -common = {package = "veloren-common", path = "../../common"} common-assets = {package = "veloren-common-assets", path = "../../common/assets"} +common-i18n = { package = "veloren-common-i18n", path = "../../common/i18n" } serde = { workspace = true } # Localization unic-langid = { version = "0.9"} diff --git a/client/i18n/src/lib.rs b/client/i18n/src/lib.rs index cb104fbf5c..c6ec4c70ac 100644 --- a/client/i18n/src/lib.rs +++ b/client/i18n/src/lib.rs @@ -19,8 +19,8 @@ use std::{borrow::Cow, io}; use assets::{source::DirEntry, AssetExt, AssetGuard, AssetHandle, ReloadWatcher, SharedString}; use tracing::warn; // Re-export because I don't like prefix -use common::comp::{Content, LocalizationArg}; use common_assets as assets; +use common_i18n::{Content, LocalizationArg}; // Re-export for argument creation pub use fluent::{fluent_args, FluentValue}; diff --git a/common/Cargo.toml b/common/Cargo.toml index c889e6d8f1..10fdb3830a 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -20,6 +20,7 @@ default = ["simd"] [dependencies] common-base = { package = "veloren-common-base", path = "base" } +common-i18n = { package = "veloren-common-i18n", path = "i18n" } # inline_tweak = { workspace = true } # Serde diff --git a/common/i18n/Cargo.toml b/common/i18n/Cargo.toml new file mode 100644 index 0000000000..0197bf77ed --- /dev/null +++ b/common/i18n/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "veloren-common-i18n" +version = "0.1.0" +edition = "2021" +description = "Crate for structs and methods that acknowledge the need for localization of the game" + +[dependencies] +serde = { workspace = true, features = ["rc"] } +hashbrown = { workspace = true } +rand = { workspace = true } diff --git a/common/i18n/src/lib.rs b/common/i18n/src/lib.rs new file mode 100644 index 0000000000..cf41ea015b --- /dev/null +++ b/common/i18n/src/lib.rs @@ -0,0 +1,120 @@ +use hashbrown::HashMap; +use serde::{Deserialize, Serialize}; + +/// The type to represent generic localization request, to be sent from server +/// to client and then localized (or internationalized) there. +// 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) +// TODO: We probably want to have this type be able to represent similar things to +// `fluent::FluentValue`, such as numeric values, so that they can be properly localised in whatever +// manner is required. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +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 + #[serde(default = "random_seed")] + seed: u16, + /// i18n arguments + #[serde(default)] + args: HashMap, + }, +} + +// TODO: Remove impl and make use of `Plain(...)` explicit (to discourage it) +impl From for Content { + fn from(text: String) -> Self { Self::Plain(text) } +} + +// TODO: Remove impl and make use of `Plain(...)` explicit (to discourage it) +impl<'a> From<&'a str> for Content { + fn from(text: &'a str) -> Self { Self::Plain(text.to_string()) } +} + +/// A localisation argument for localised content (see [`Content::Localized`]). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum LocalizationArg { + /// The localisation argument is itself a section of content. + /// + /// Note that this allows [`Content`] to recursively refer to itself. It may + /// be tempting to decide to parameterise everything, having dialogue + /// generated with a compact tree. "It's simpler!", you might say. False. + /// Over-parameterisation is an anti-pattern that hurts translators. Where + /// possible, prefer fewer levels of nesting unless doing so would result + /// in an intractably larger number of combinations. See [here] for the + /// guidance provided by the docs for `fluent`, the localisation library + /// used by clients. + /// + /// [here]: https://github.com/projectfluent/fluent/wiki/Good-Practices-for-Developers#prefer-wet-over-dry + Content(Content), + /// The localisation argument is a natural number + Nat(u64), +} + +impl From for LocalizationArg { + fn from(content: Content) -> Self { Self::Content(content) } +} + +// TODO: Remove impl and make use of `Content(Plain(...))` explicit (to +// discourage it) +impl From for LocalizationArg { + fn from(text: String) -> Self { Self::Content(Content::Plain(text)) } +} + +// TODO: Remove impl and make use of `Content(Plain(...))` explicit (to +// discourage it) +impl<'a> From<&'a str> for LocalizationArg { + fn from(text: &'a str) -> Self { Self::Content(Content::Plain(text.to_string())) } +} + +// TODO: Remove impl and make use of `Content(Plain(...))` explicit (to +// discourage it) +impl From for LocalizationArg { + fn from(n: u64) -> Self { Self::Nat(n) } +} + +fn random_seed() -> u16 { rand::random() } + +impl Content { + pub fn localized(key: impl ToString) -> Self { + Self::Localized { + key: key.to_string(), + seed: random_seed(), + args: HashMap::default(), + } + } + + pub fn localized_with_args<'a, A: Into>( + key: impl ToString, + args: impl IntoIterator, + ) -> Self { + Self::Localized { + key: key.to_string(), + seed: rand::random(), + args: args + .into_iter() + .map(|(k, v)| (k.to_string(), v.into())) + .collect(), + } + } + + pub fn as_plain(&self) -> Option<&str> { + match self { + Self::Plain(text) => Some(text.as_str()), + Self::Localized { .. } => None, + } + } +} diff --git a/common/src/comp/body.rs b/common/src/comp/body.rs index ef58087c45..8011fdb1b1 100644 --- a/common/src/comp/body.rs +++ b/common/src/comp/body.rs @@ -19,11 +19,11 @@ pub mod theropod; use crate::{ assets::{self, Asset}, - comp::Content, consts::{HUMAN_DENSITY, WATER_DENSITY}, make_case_elim, npc::NpcKind, }; +use common_i18n::Content; use serde::{Deserialize, Serialize}; use specs::{Component, DerefFlaggedStorage}; use strum::Display; diff --git a/common/src/comp/body/biped_large.rs b/common/src/comp/body/biped_large.rs index 9c117e1308..85228afbb7 100644 --- a/common/src/comp/body/biped_large.rs +++ b/common/src/comp/body/biped_large.rs @@ -1,4 +1,5 @@ -use crate::{comp::Content, make_case_elim, make_proj_elim}; +use crate::{make_case_elim, make_proj_elim}; +use common_i18n::Content; use rand::{seq::SliceRandom, thread_rng}; use serde::{Deserialize, Serialize}; diff --git a/common/src/comp/chat.rs b/common/src/comp/chat.rs index 07c554d31d..6feaa82d17 100644 --- a/common/src/comp/chat.rs +++ b/common/src/comp/chat.rs @@ -2,7 +2,7 @@ use crate::{ comp::{group::Group, BuffKind}, uid::Uid, }; -use hashbrown::HashMap; +use common_i18n::Content; use serde::{Deserialize, Serialize}; use specs::{Component, DenseVecStorage}; use std::time::{Duration, Instant}; @@ -179,121 +179,6 @@ 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) -// TODO: We probably want to have this type be able to represent similar things to -// `fluent::FluentValue`, such as numeric values, so that they can be properly localised in whatever -// manner is required. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -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 - #[serde(default = "random_seed")] - seed: u16, - /// i18n arguments - #[serde(default)] - args: HashMap, - }, -} - -// TODO: Remove impl and make use of `Plain(...)` explicit (to discourage it) -impl From for Content { - fn from(text: String) -> Self { Self::Plain(text) } -} - -// TODO: Remove impl and make use of `Plain(...)` explicit (to discourage it) -impl<'a> From<&'a str> for Content { - fn from(text: &'a str) -> Self { Self::Plain(text.to_string()) } -} - -/// A localisation argument for localised content (see [`Content::Localized`]). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum LocalizationArg { - /// The localisation argument is itself a section of content. - /// - /// Note that this allows [`Content`] to recursively refer to itself. It may - /// be tempting to decide to parameterise everything, having dialogue - /// generated with a compact tree. "It's simpler!", you might say. False. - /// Over-parameterisation is an anti-pattern that hurts translators. Where - /// possible, prefer fewer levels of nesting unless doing so would - /// result in an intractably larger number of combinations. See [here](https://github.com/projectfluent/fluent/wiki/Good-Practices-for-Developers#prefer-wet-over-dry) for the - /// guidance provided by the docs for `fluent`, the localisation library - /// used by clients. - Content(Content), - /// The localisation argument is a natural number - Nat(u64), -} - -impl From for LocalizationArg { - fn from(content: Content) -> Self { Self::Content(content) } -} - -// TODO: Remove impl and make use of `Content(Plain(...))` explicit (to -// discourage it) -impl From for LocalizationArg { - fn from(text: String) -> Self { Self::Content(Content::Plain(text)) } -} - -// TODO: Remove impl and make use of `Content(Plain(...))` explicit (to -// discourage it) -impl<'a> From<&'a str> for LocalizationArg { - fn from(text: &'a str) -> Self { Self::Content(Content::Plain(text.to_string())) } -} - -// TODO: Remove impl and make use of `Content(Plain(...))` explicit (to -// discourage it) -impl From for LocalizationArg { - fn from(n: u64) -> Self { Self::Nat(n) } -} - -fn random_seed() -> u16 { rand::random() } - -impl Content { - pub fn localized(key: impl ToString) -> Self { - Self::Localized { - key: key.to_string(), - seed: random_seed(), - args: HashMap::default(), - } - } - - pub fn localized_with_args<'a, A: Into>( - key: impl ToString, - args: impl IntoIterator, - ) -> Self { - Self::Localized { - key: key.to_string(), - seed: rand::random(), - args: args - .into_iter() - .map(|(k, v)| (k.to_string(), v.into())) - .collect(), - } - } - - pub fn as_plain(&self) -> Option<&str> { - match self { - Self::Plain(text) => Some(text.as_str()), - Self::Localized { .. } => None, - } - } -} - // Stores chat text, type #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenericChatMsg { diff --git a/common/src/comp/compass.rs b/common/src/comp/compass.rs index 7809347dbc..0a867c7f4f 100644 --- a/common/src/comp/compass.rs +++ b/common/src/comp/compass.rs @@ -1,4 +1,4 @@ -use super::Content; +use common_i18n::Content; use vek::Vec2; // TODO: Move this to common/src/, it's not a component diff --git a/common/src/comp/mod.rs b/common/src/comp/mod.rs index 29ff961ce7..dedd41e83d 100644 --- a/common/src/comp/mod.rs +++ b/common/src/comp/mod.rs @@ -62,8 +62,7 @@ pub use self::{ }, character_state::{CharacterActivity, CharacterState, StateUpdate}, chat::{ - ChatMode, ChatMsg, ChatType, Content, Faction, LocalizationArg, SpeechBubble, - SpeechBubbleType, UnresolvedChatMsg, + ChatMode, ChatMsg, ChatType, Faction, SpeechBubble, SpeechBubbleType, UnresolvedChatMsg, }, combo::Combo, controller::{ @@ -107,5 +106,6 @@ pub use self::{ teleport::Teleporting, visual::{LightAnimation, LightEmitter}, }; +pub use common_i18n::{Content, LocalizationArg}; pub use health::{Health, HealthChange}; diff --git a/common/src/rtsim.rs b/common/src/rtsim.rs index dc661ec1e0..5c7b6879e0 100644 --- a/common/src/rtsim.rs +++ b/common/src/rtsim.rs @@ -3,11 +3,8 @@ // `Agent`). When possible, this should be moved to the `rtsim` // module in `server`. -use crate::{ - character::CharacterId, - comp::{dialogue::Subject, Content}, - util::Dir, -}; +use crate::{character::CharacterId, comp::dialogue::Subject, util::Dir}; +use common_i18n::Content; use rand::{seq::IteratorRandom, Rng}; use serde::{Deserialize, Serialize}; use specs::Component; diff --git a/common/src/terrain/sprite.rs b/common/src/terrain/sprite.rs index 381f9eb279..5e92d5a99d 100644 --- a/common/src/terrain/sprite.rs +++ b/common/src/terrain/sprite.rs @@ -2,12 +2,12 @@ use crate::{ comp::{ item::{ItemDefinitionId, ItemDefinitionIdOwned}, tool::ToolKind, - Content, }, lottery::LootSpec, make_case_elim, terrain::Block, }; +use common_i18n::Content; use hashbrown::HashMap; use lazy_static::lazy_static; use num_derive::FromPrimitive; diff --git a/common/src/terrain/structure.rs b/common/src/terrain/structure.rs index 67bdc7c665..7b2a003e01 100644 --- a/common/src/terrain/structure.rs +++ b/common/src/terrain/structure.rs @@ -1,11 +1,11 @@ use super::{BlockKind, SpriteKind}; use crate::{ assets::{self, AssetExt, AssetHandle, BoxedError, DotVoxAsset}, - comp::Content, make_case_elim, vol::{BaseVol, ReadVol, SizedVol, WriteVol}, volumes::dyna::{Dyna, DynaError}, }; +use common_i18n::Content; use dot_vox::DotVoxData; use hashbrown::HashMap; use serde::Deserialize;