Compare commits

...
1 Commits
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 a190dbbc0d Crates ship their own lang files: horizon_hooks::lang (layer plan M5)
horizon_hooks::lang::register(&'static [(code, json)]), re-exported as
horizon_platform::lang. The game's entry installs the registered files
into horizon_client::lang; Language::load reads them in the horizon
namespace right after the engine's bundled file for the same language,
before packs, and the engine's file wins on a key both have. Regional
variants share one alias table with the bundled files (fr_ca -> fr_fr,
de_ch -> de_de without ß). Nothing registered: languages load as before.

tools/lang_coverage.py reports crates/*/assets/lang beside the game's
folder; a crate's review record is tools/lang_review/<crate>/, and
--mark-reviewed puts each key in the folder that holds it. lang.rs's
file checks run on every crate folder, and a crate key the engine also
has fails them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:30:16 +01:00
9 changed files with 375 additions and 65 deletions

No files matched your search

+172 -37
View File
@@ -74,11 +74,70 @@ pub trait ResourceSource {
vec!["minecraft".into()]
}
}
/// The bundled file a language reads, and whether it drops `ß`: a regional variant reads its
/// language's file (Swiss German writes "ss" for "ß", as vanilla's de_ch does).
fn bundled_code(code: &str) -> (&str, bool) {
match code {
"ko" => ("ko_kr", false),
"hr" => ("hr_hr", false),
"de_at" => ("de_de", false),
"de_ch" => ("de_de", true),
"fr_ca" | "fr_ch" => ("fr_fr", false),
"es_ar" | "es_cl" | "es_ec" | "es_mx" | "es_uy" | "es_ve" => ("es_es", false),
code => (code, false),
}
}
/// Lang files crates above the engine ship (`horizon_hooks::lang`), each a list of
/// (code, JSON) pairs. The game's entry installs them once, before any language loads.
static CRATE_FILES: std::sync::OnceLock<&'static [&'static [(&'static str, &'static str)]]> =
std::sync::OnceLock::new();
/// Installs the crates' lang files (`horizon_hooks::lang::engine::all()`). Only the first call
/// counts.
pub fn install_crate_files(files: &'static [&'static [(&'static str, &'static str)]]) {
let _ = CRATE_FILES.set(files);
}
/// The crates' entries for language `code` (through [`bundled_code`]'s variants) that the
/// engine's own `horizon` file for it doesn't have: on a key both have, the engine wins.
fn read_crate_files(files: &[&[(&str, &str)]], code: &str, map: &mut HashMap<String, String>) {
if files.is_empty() {
return;
}
let (file, swiss) = bundled_code(code);
let mut own = HashMap::new();
if let Some(bytes) = Bundled.read(&format!("assets/horizon/lang/{code}.json")) {
read_lang_file(&bytes, &mut own);
}
for (c, json) in files.iter().flat_map(|f| f.iter()) {
if *c != file {
continue;
}
let mut theirs = HashMap::new();
if swiss {
read_lang_file(json.replace('ß', "ss").as_bytes(), &mut theirs);
} else {
read_lang_file(json.as_bytes(), &mut theirs);
}
for (key, value) in theirs {
if !own.contains_key(&key) {
map.insert(key, value);
}
}
}
}
/// Horizon's own strings, compiled in so they need no prepared resources.
struct Bundled;
impl ResourceSource for Bundled {
fn read(&self, path: &str) -> Option<Vec<u8>> {
let body = match path {
let code = path
.strip_prefix("assets/horizon/lang/")?
.strip_suffix(".json")?;
let (file, swiss) = bundled_code(code);
let path = format!("assets/horizon/lang/{file}.json");
let body = match path.as_str() {
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
@@ -103,10 +162,10 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
"assets/horizon/lang/ko_kr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
}
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
"assets/horizon/lang/hr_hr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
}
"assets/horizon/lang/pt_pt.json" => {
@@ -331,30 +390,20 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/vec_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
"assets/horizon/lang/de_de.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
"assets/horizon/lang/de_ch.json" => {
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
return Some(de.replace('ß', "ss").into_bytes());
}
"assets/horizon/lang/fr_fr.json"
| "assets/horizon/lang/fr_ca.json"
| "assets/horizon/lang/fr_ch.json" => {
"assets/horizon/lang/fr_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
}
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
| "assets/horizon/lang/es_ec.json"
| "assets/horizon/lang/es_mx.json"
| "assets/horizon/lang/es_uy.json"
| "assets/horizon/lang/es_ve.json" => {
"assets/horizon/lang/es_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
}
_ => return None,
};
if swiss {
return Some(body.replace('ß', "ss").into_bytes());
}
Some(body.as_bytes().to_vec())
}
fn namespaces(&self) -> Vec<String> {
@@ -438,6 +487,16 @@ impl Language {
/// from every namespace and every source in priority order (later sources
/// override).
pub fn load(sources: &[&dyn ResourceSource], code: &str) -> Self {
let crate_files = CRATE_FILES.get().copied().unwrap_or_default();
Self::load_with(sources, code, crate_files)
}
/// [`Language::load`] with these crates' lang files.
fn load_with(
sources: &[&dyn ResourceSource],
code: &str,
crate_files: &[&[(&str, &str)]],
) -> Self {
let mut map = HashMap::new();
let mut codes = vec![DEFAULT];
if code != DEFAULT {
@@ -453,12 +512,14 @@ impl Language {
sources.iter().flat_map(|s| s.namespaces()).collect();
for c in codes {
for namespace in &namespaces {
for source in sources {
let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json"))
else {
continue;
};
read_lang_file(&bytes, &mut map);
for (i, source) in sources.iter().enumerate() {
if let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json")) {
read_lang_file(&bytes, &mut map);
}
// The crates' text right after the engine's own (`Bundled`, the first source).
if i == 0 && namespace == "horizon" {
read_crate_files(crate_files, c, &mut map);
}
}
}
}
@@ -688,6 +749,43 @@ mod tests {
);
}
#[test]
fn a_crates_text_loads_after_the_engines_and_before_packs() {
static CRATE: &[(&str, &str)] = &[
(
"en_us",
r#"{"test.crate.hello": "Hello", "horizon.osk.close": "Shut", "test.crate.packed": "Crate"}"#,
),
(
"de_de",
r#"{"test.crate.hello": "Grüß dich", "horizon.osk.close": "Zu"}"#,
),
("fr_fr", r#"{"test.crate.hello": "Bonjour"}"#),
];
let pack = Mem(HashMap::from([(
"assets/horizon/lang/en_us.json".to_owned(),
br#"{"test.crate.packed": "Pack"}"#.to_vec(),
)]));
let load = |code| Language::load_with(&[&pack], code, &[CRATE]);
let en = load("en_us");
assert_eq!(en.get("test.crate.hello"), Some("Hello"));
// The engine's own file wins on a key both have; a pack wins over both.
assert_eq!(en.get("horizon.osk.close"), Some("Close"));
assert_eq!(en.get("test.crate.packed"), Some("Pack"));
let de = load("de_de");
assert_eq!(de.get("test.crate.hello"), Some("Grüß dich"));
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
// Regional variants read their language's file, as the engine's do.
assert_eq!(load("de_ch").get("test.crate.hello"), Some("Grüss dich"));
assert_eq!(load("fr_ca").get("test.crate.hello"), Some("Bonjour"));
// A language the crate lacks falls back to its English.
assert_eq!(load("pl_pl").get("test.crate.hello"), Some("Hello"));
// Without crate files, nothing changes.
let plain = Language::load_with(&[&pack], "de_de", &[]);
assert_eq!(plain.get("test.crate.hello"), None);
assert_eq!(plain.len() + 1, de.len());
}
#[test]
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
let empty = Mem(HashMap::new());
@@ -739,6 +837,20 @@ mod tests {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../horizon-client/assets/horizon/lang")
}
/// The crates whose own lang files the game loads (`crates/<name>/assets/lang`,
/// registered through `horizon_hooks::lang`), with that folder.
fn crate_lang_dirs() -> Vec<(String, std::path::PathBuf)> {
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut dirs: Vec<_> = std::fs::read_dir(&crates)
.unwrap()
.flatten()
.map(|e| (e.file_name().into_string().unwrap(), e.path()))
.map(|(name, path)| (name, path.join("assets/lang")))
.filter(|(_, dir)| dir.join("en_us.json").is_file())
.collect();
dirs.sort();
dirs
}
/// The keys of a flat bundled file in file order, duplicates included (a map
/// would keep only the last of two equal keys). One `"key": "value"` per line.
fn file_keys(body: &str) -> Vec<String> {
@@ -776,11 +888,33 @@ mod tests {
#[test]
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
let dir = bundled_dir();
let engine = check_lang_dir(&bundled_dir(), true);
assert!(
engine.1 >= 7,
"found {} bundled lang files in {}",
engine.1,
bundled_dir().display()
);
// A crate's files pass the same checks. Its keys are its own: on a key the engine's
// files also have, the engine's text would win and hide the crate's.
for (name, dir) in crate_lang_dirs() {
let (en, _) = check_lang_dir(&dir, false);
for key in en.keys() {
assert!(
!engine.0.contains_key(key),
"{name}: {key} is also the engine's"
);
}
}
}
/// The checks every lang folder passes: flat maps, no repeated key, no key en_us lacks, the
/// same placeholders as English, no empty text. Its en_us and how many files it has.
fn check_lang_dir(dir: &std::path::Path, bundled: bool) -> (HashMap<String, String>, usize) {
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
let mut seen = 0;
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let name = entry.file_name().into_string().unwrap();
let Some(code) = name.strip_suffix(".json") else {
continue;
@@ -789,7 +923,7 @@ mod tests {
// A file that Bundled::read does not name is never loaded.
let path = format!("assets/horizon/lang/{name}");
assert!(
Bundled.read(&path).is_some(),
!bundled || Bundled.read(&path).is_some(),
"{name} is not served by Bundled"
);
let body = std::fs::read_to_string(entry.path()).unwrap();
@@ -815,11 +949,7 @@ mod tests {
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
}
}
assert!(
seen >= 7,
"found {seen} bundled lang files in {}",
dir.display()
);
(en, seen)
}
#[test]
@@ -834,10 +964,10 @@ mod tests {
}
}
}
let en: HashMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
)
.unwrap();
let english = |dir: &std::path::Path| -> HashMap<String, String> {
serde_json::from_str(&std::fs::read_to_string(dir.join("en_us.json")).unwrap()).unwrap()
};
let mut en = english(&bundled_dir());
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut files = Vec::new();
for krate in [
@@ -847,6 +977,11 @@ mod tests {
] {
rust_files(&crates.join(krate).join("src"), &mut files);
}
// A crate that ships lang files names its keys in its own sources.
for (name, dir) in crate_lang_dirs() {
en.extend(english(&dir));
rust_files(&crates.join(name).join("src"), &mut files);
}
assert!(files.len() > 100, "found only {} sources", files.len());
let mut found = 0;
for file in files {
+2
View File
@@ -27,6 +27,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
horizon_hooks::engine::seal();
// The text crates ship (`horizon_hooks::lang`), loaded with the game's own languages.
horizon_client::lang::install_crate_files(horizon_hooks::lang::engine::all());
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
let args: Vec<String> = std::env::args().skip(1).collect();
+47
View File
@@ -0,0 +1,47 @@
//! Lang files a crate above the engine ships with its text (docs/layers.md, "Platform
//! registries"). A crate [`register`]s its `(code, JSON)` pairs, usually `include_str!`s of its
//! `assets/lang/<code>.json`, before the game starts. The game loads them as part of its own
//! `horizon` namespace, right after its bundled files for the same language, so resource packs
//! still override them; on a key both have, the engine's file wins. A regional variant reads its
//! language's file as the engine's do (`fr_ca` reads `fr_fr`, `de_ch` reads `de_de` without `ß`).
use crate::{TooLate, registry::Registry};
/// One crate's lang files: a language code (`en_us`, `fr_fr`) and that file's JSON text.
pub type Files = [(&'static str, &'static str)];
static FILES: Registry<Files> = Registry::new();
/// Adds a crate's lang files. Before the game starts, as [`crate::register`]; a later call is
/// refused.
pub fn register(files: &'static Files) -> Result<(), TooLate> {
FILES.register(files)
}
pub(crate) fn all() -> &'static [&'static Files] {
FILES.all()
}
/// The engine's side. Only the engine calls it.
pub mod engine {
/// Every crate's files, in registration order, sealing the list on the first call.
pub fn all() -> &'static [&'static super::Files] {
super::all()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn files_registered_before_the_game_starts_are_kept_in_order() {
static A: &Files = &[("en_us", r#"{"a.b.c": "A"}"#)];
static B: &Files = &[("en_us", r#"{"d.e.f": "B"}"#), ("fr_fr", "{}")];
assert_eq!(register(A), Ok(()));
assert_eq!(register(B), Ok(()));
let all = engine::all();
assert_eq!(all.len(), 2);
assert_eq!(all[1][1].0, "fr_fr");
assert_eq!(register(A), Err(TooLate));
}
}
+3 -1
View File
@@ -14,10 +14,11 @@
//!
//! The platform registries (docs/layers.md, "Platform registries") have the same shape:
//! [`settings`] for the settings each crate keeps in its own lines, [`pages`] for its settings
//! pages, [`hud`] for what it draws on the HUD.
//! pages, [`hud`] for what it draws on the HUD, [`lang`] for the lang files with its text.
#![forbid(unsafe_code)]
pub mod hud;
pub mod lang;
pub mod pages;
pub mod players;
mod registry;
@@ -155,6 +156,7 @@ pub mod engine {
crate::settings::all();
crate::pages::engine::all();
crate::hud::engine::all();
crate::lang::all();
}
/// [`Hooks::started`].
+6
View File
@@ -68,6 +68,12 @@ pub mod pages {
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, register};
}
/// The lang files with a crate's text, loaded with the game's own (docs/layers.md, "Platform
/// registries").
pub mod lang {
pub use horizon_hooks::lang::{Files, register};
}
/// Drawing on the HUD (docs/layers.md, "Platform registries").
pub mod hud {
pub use horizon_hooks::hud::{Canvas, Corner, Layer, register};
+26 -2
View File
@@ -322,8 +322,8 @@ hook (#14081), `seat()` (#14096).
only calls it.
- With nothing registered, the engine does no work: a load of an empty slice where it would dispatch. It makes
no allocation, no draw call and no file. A vanilla build (horizon-client, horizon-plain) is unchanged.
- Visible text is a translation key the registrant ships in its lang files, never English in code. Values a
player typed (a name, a path) are the exception.
- Visible text is a translation key the registrant ships in its lang files ([Lang files](#lang-files-horizon_platformlang)),
never English in code. Values a player typed (a name, a path) are the exception.
- Splitscreen players are one process, one thread, each `Game` in turn (`game/split.rs`), so a crate's statics
are shared by them. Every call (stores, pages, layers, hooks) happens on one player's turn, and
`horizon_platform::hooks::seat()` (0 for P1) says whose: a crate keeps per-player state indexed by it.
@@ -500,6 +500,30 @@ fn container_draw(&self, c: &dyn Container, canvas: &mut dyn hud::Canvas); //
- `order` is the only text: Sorting ranks by it. The rest is ids and numbers, so the API names no engine type
(`ItemStack` stays in the engine).
### Lang files (`horizon_platform::lang`)
A crate keeps the text for its keys in its own folder, `crates/<name>/assets/lang/<code>.json`, with the same
shape as the engine's `assets/horizon/lang` files (flat `"key": "text"` lines, `en_us.json` the reference).
It registers them as `(code, JSON)` pairs, usually `include_str!`s:
```rust
static LANG: &horizon_platform::lang::Files = &[
("en_us", include_str!("../assets/lang/en_us.json")),
("fr_fr", include_str!("../assets/lang/fr_fr.json")),
];
horizon_platform::lang::register(LANG)?;
```
- The game loads them into its `horizon` namespace right after its own bundled file for the same language, so a
resource pack still overrides them, and a language a crate lacks falls back to its English.
- A key belongs to one folder. On a key the engine's file also has, the engine's text wins (and the
`lang.rs` test fails).
- Regional variants read their language's file as the engine's do (`fr_ca` reads `fr_fr`, `de_ch` reads `de_de`
without `ß`).
- `tools/lang_coverage.py` reports each crate's folder beside the engine's. A person's review of a crate's entry
is kept in `tools/lang_review/<name>/<code>.json` ([translations.md](translations.md)).
- With nothing registered, a language loads exactly as before.
## Extras that may leave the game
Horizon's extras are Horizon-layer code. Each one is a candidate to become an optional native mod that players add
+2 -1
View File
@@ -11,8 +11,9 @@ no difference: the mark lives in a file the game and the launcher never read.
| | Files | Keys | Read by |
|---|---|---|---|
| Game | `crates/horizon-client/assets/horizon/lang/<code>.json` (`fr_fr`, `de_de`...) | `horizon.options.foo` style; `en_us.json` is the reference | `crates/horizon-client-core/src/lang.rs` (`Text::tr`, `lang::get`); a missing key falls back to English |
| A crate's own text | `crates/<name>/assets/lang/<code>.json`, the same shape | the crate's own keys, never one of the engine's | the same, once the crate registers them ([layers.md](layers.md#lang-files-horizon_platformlang)) |
| Launcher | `launcher/lang/<code>.json` (`fr`, `de`, `pt-br`...) | the English sentence itself | `launcher/src/i18n.rs` (`tr("…")`, `trf("… {arg} …")`) |
| Review record | `tools/lang_review/game/<code>.json`, `tools/lang_review/launcher/<code>.json` | the same keys, with the text a person checked | `tools/lang_coverage.py` only |
| Review record | `tools/lang_review/game/<code>.json`, `tools/lang_review/<crate name>/<code>.json`, `tools/lang_review/launcher/<code>.json` | the same keys, with the text a person checked | `tools/lang_coverage.py` only |
An entry is **reviewed** when the review record holds its key with exactly its present text. Anything else is
unreviewed: an entry the record does not hold, and one whose text changed since a person checked it (so a later
+42 -1
View File
@@ -215,8 +215,49 @@ class ReviewTests(unittest.TestCase):
self.assertEqual(code, 0)
self.assertTrue((Path(tmp) / "review" / "launcher" / "xx.json").exists())
def test_a_crates_own_folder_is_reported_and_its_reviews_kept_apart(self):
with tempfile.TemporaryDirectory() as tmp:
game, crates, review = Path(tmp) / "game", Path(tmp) / "crates", Path(tmp) / "review"
friends = crates / "horizon-friends" / "assets" / "lang"
game.mkdir()
friends.mkdir(parents=True)
(crates / "horizon-plain" / "src").mkdir(parents=True) # no lang folder: not reported
write(game, "en_us.json", '{\n "a": "Apple"\n}\n')
write(game, "xx_yy.json", '{\n "a": "Manzana"\n}\n')
write(friends, "en_us.json", '{\n "f.one": "One",\n "f.two": "Two"\n}\n')
write(friends, "xx_yy.json", '{\n "f.one": "Uno"\n}\n')
common = ("--game-dir", str(game), "--crates-dir", str(crates), "--review-dir", str(review),
"--game-only")
code, out, _ = run(*common, "--summary")
self.assertEqual(code, 0)
self.assertIn("GAME xx_yy 1/1 keys, missing 0", out)
self.assertIn("GAME horizon-friends xx_yy 1/2 keys, missing 1", out)
self.assertNotIn("horizon-plain", out)
code, _, err = run(*common, "--fail-under", "90")
self.assertEqual(code, 1)
self.assertIn("xx_yy: 50.0%", err)
# One command marks the keys of both folders; each lands in its folder's record.
code, out, _ = run(*common, "--lang", "xx_yy", "--mark-reviewed", "a", "f.one")
self.assertEqual(code, 0, out)
self.assertEqual(json.loads((review / "game" / "xx_yy.json").read_text(encoding="utf-8")),
{"a": "Manzana"})
self.assertEqual(json.loads((review / "horizon-friends" / "xx_yy.json").read_text(encoding="utf-8")),
{"f.one": "Uno"})
# Marking the game's folder again keeps the crate's record.
code, _, _ = run(*common, "--lang", "xx_yy", "--mark-reviewed", "a")
self.assertEqual(code, 0)
self.assertTrue((review / "horizon-friends" / "xx_yy.json").exists())
code, _, err = run(*common, "--lang", "xx_yy", "--mark-reviewed", "nosuch")
self.assertEqual(code, 1)
self.assertIn("no key 'nosuch'", err)
# A scratch --game-dir alone leaves the repository's crates out.
code, out, _ = run("--game-dir", str(game), "--review-dir", str(review), "--game-only", "--summary")
self.assertNotIn("horizon-friends", out)
def test_every_repository_record_belongs_to_a_lang_file_and_reads(self):
for kind, folder in (("game", lang_coverage.GAME), ("launcher", lang_coverage.LAUNCHER)):
folders = [("game", lang_coverage.GAME), ("launcher", lang_coverage.LAUNCHER),
*lang_coverage.crate_dirs(lang_coverage.CRATES)]
for kind, folder in folders:
for record in sorted((lang_coverage.REVIEW / kind).glob("*.json")):
with self.subTest(record=record.name, kind=kind):
self.assertTrue((folder / record.name).exists(), f"{record} names no lang file")
+75 -23
View File
@@ -8,6 +8,10 @@ a gap is not an error — it is an English sentence on a translated screen. That
tests catch stale keys, repeated keys, empty values and changed placeholders, but
deliberately not gaps, because a language is allowed to lag. This tool reports them.
A crate that ships its own text keeps it in `crates/<name>/assets/lang/<code>.json`, the same
shape with its own `en_us.json` reference (docs/layers.md, "Lang files"); each such folder is
reported beside the game's, and its review record is `tools/lang_review/<name>/`.
`launcher/lang/<code>.json` is the launcher's: its keys are the English sentence itself,
so a file is complete by construction (`launcher/src/i18n/tests.rs` checks every `tr("…")`
the code asks for). What it can still hold is a value identical to its English key, and a
@@ -43,6 +47,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GAME = ROOT / "crates/horizon-client/assets/horizon/lang"
CRATES = ROOT / "crates"
LAUNCHER = ROOT / "launcher/lang"
REVIEW = ROOT / "tools/lang_review"
REFERENCE = "en_us.json"
@@ -192,6 +197,12 @@ def game_rows(directory: Path, codes: set[str],
return reference, rows
def crate_dirs(crates: Path) -> list[tuple[str, Path]]:
"""The crates that ship their own lang files: (crate name, its `assets/lang`), by name."""
return sorted((path.parents[2].name, path.parent)
for path in crates.glob("*/assets/lang/" + REFERENCE))
def launcher_rows(directory: Path, codes: set[str], review: Path | None = None) -> list[dict]:
"""Every launcher language: the values still identical to their English key, the
`{holes}` a value does not match it on, and with `review` (a `lang_review/launcher` folder)
@@ -228,8 +239,8 @@ def print_problems(rows: list[dict]) -> None:
print(f" ! {row['code']}: {row['problem']}")
def print_game(reference: LangFile, directory: Path, rows: list[dict]) -> None:
print(f"GAME {shown(directory)} ({reference.code}: {len(reference.entries)} keys, the reference)")
def print_game(reference: LangFile, directory: Path, rows: list[dict], kind: str = "GAME") -> None:
print(f"{kind} {shown(directory)} ({reference.code}: {len(reference.entries)} keys, the reference)")
print()
print(f" {'code':<10}{'keys':>6}{'cover':>8}{'missing':>9}{'extra':>7}{'empty':>7}{'same':>6}"
f"{'unreviewed':>12}")
@@ -278,9 +289,9 @@ def print_launcher_keys(rows: list[dict]) -> None:
print(f" {side}: {', '.join(bad[side])}")
def print_game_summary(reference: LangFile, rows: list[dict]) -> None:
def print_game_summary(reference: LangFile, rows: list[dict], kind: str = "GAME") -> None:
for row in rows:
line = (f"GAME {row['code']} {row['keys']}/{len(reference.entries)} keys, "
line = (f"{kind} {row['code']} {row['keys']}/{len(reference.entries)} keys, "
f"missing {len(row['missing'])}, extra {len(row['extra'])}, "
f"placeholders {len(row['placeholders'])}, empty {len(row['empty'])}, "
f"same {len(row['same'])}, unreviewed {len(row['unreviewed'])}")
@@ -336,6 +347,9 @@ def main(argv: list[str] | None = None) -> int:
which.add_argument("--launcher-only", action="store_true", help="only the launcher's lang")
parser.add_argument("--game-dir", type=Path, default=GAME,
help="the game's lang folder (default: %(default)s)")
parser.add_argument("--crates-dir", type=Path,
help="report every <dir>/<crate>/assets/lang too (default: the repository's crates, "
"unless --game-dir names another folder)")
parser.add_argument("--launcher-dir", type=Path, default=LAUNCHER,
help="the launcher's lang folder (default: %(default)s)")
parser.add_argument("--lang", nargs="+", metavar="CODE",
@@ -359,6 +373,8 @@ def main(argv: list[str] | None = None) -> int:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
codes = set(args.lang or ())
if args.crates_dir is None and args.game_dir == GAME:
args.crates_dir = CRATES
if args.mark_reviewed is not None:
return mark_command(args, codes)
if args.fail_under is not None and args.launcher_only:
@@ -377,6 +393,18 @@ def main(argv: list[str] | None = None) -> int:
print(error, file=sys.stderr)
return 2
result["game"] = {"reference": reference.code, "keys": len(reference.entries), "files": game}
# (crate name, its folder, its reference, its rows): the text crates ship.
crates: list[tuple[str, Path, LangFile, list[dict]]] = []
if not args.launcher_only and args.crates_dir is not None:
for name, folder in crate_dirs(args.crates_dir):
try:
crates.append((name, folder, *game_rows(folder, codes, args.review_dir / name)))
except ValueError as error:
print(error, file=sys.stderr)
return 2
if crates:
result["crates"] = {name: {"reference": ref.code, "keys": len(ref.entries), "files": rows}
for name, _, ref, rows in crates}
launcher: list[dict] = []
if not args.game_only:
if not args.launcher_dir.is_dir():
@@ -388,7 +416,7 @@ def main(argv: list[str] | None = None) -> int:
print(error, file=sys.stderr)
return 2
result["launcher"] = {"files": launcher}
if codes and not game and not launcher:
if codes and not game and not launcher and not any(rows for *_, rows in crates):
print(f"no lang file is named {' or '.join(sorted(codes))}", file=sys.stderr)
return 2
@@ -397,14 +425,21 @@ def main(argv: list[str] | None = None) -> int:
elif args.unreviewed:
if reference:
print_unreviewed("GAME", game, reference.entries)
for name, _, ref, rows in crates:
print_unreviewed(f"GAME {name}", rows, ref.entries)
print_unreviewed("LAUNCHER", launcher, None)
elif args.summary:
if reference:
print_game_summary(reference, game)
for name, _, ref, rows in crates:
print_game_summary(ref, rows, f"GAME {name}")
print_launcher_summary(launcher)
else:
if reference:
print_game(reference, args.game_dir, game)
for name, folder, ref, rows in crates:
print()
print_game(ref, folder, rows, f"GAME {name}")
if not args.game_only:
if reference:
print()
@@ -412,11 +447,14 @@ def main(argv: list[str] | None = None) -> int:
if args.show_keys:
if reference:
print_game_keys(game)
for *_, rows in crates:
print_game_keys(rows)
if launcher:
print_launcher_keys(launcher)
if args.fail_under is not None:
under = [row for row in game if 100 * row["coverage"] + 1e-9 < args.fail_under]
every = game + [row for *_, rows in crates for row in rows]
under = [row for row in every if 100 * row["coverage"] + 1e-9 < args.fail_under]
for row in under:
print(f"{row['code']}: {100 * row['coverage']:.1f}% < {args.fail_under:g}%", file=sys.stderr)
if under:
@@ -425,8 +463,9 @@ def main(argv: list[str] | None = None) -> int:
def mark_command(args, codes: set[str]) -> int:
"""`--mark-reviewed`: one file, named by `--lang`; --game-only or --launcher-only picks the
folder when the code is in both."""
"""`--mark-reviewed`: one language, named by `--lang`; --game-only or --launcher-only picks the
folder when the code is in both. The game's text may be split between its own folder and the
crates' (`--crates-dir`): each key is recorded for the folder that holds it."""
if len(codes) != 1:
print("--mark-reviewed needs exactly one --lang code", file=sys.stderr)
return 2
@@ -434,31 +473,44 @@ def mark_command(args, codes: set[str]) -> int:
if code == "en_us":
print("en_us is the English reference, not a translation", file=sys.stderr)
return 2
found = [(kind, folder / f"{code}.json") for kind, folder, skip in (
("game", args.game_dir, args.launcher_only), ("launcher", args.launcher_dir, args.game_only))
if not skip and (folder / f"{code}.json").exists()]
if not found:
game = [("game", args.game_dir)]
if args.crates_dir is not None:
game += crate_dirs(args.crates_dir)
sides = [[(kind, folder / f"{code}.json") for kind, folder in folders
if (folder / f"{code}.json").exists()]
for folders, skip in ((game, args.launcher_only), ([("launcher", args.launcher_dir)], args.game_only))
if not skip]
sides = [side for side in sides if side]
if not sides:
print(f"no lang file is named {code}", file=sys.stderr)
return 2
if len(found) > 1:
if len(sides) > 1:
print(f"{code} is a game and a launcher file: add --game-only or --launcher-only", file=sys.stderr)
return 2
kind, lang = found[0]
found = sides[0]
keys = list(args.mark_reviewed)
if args.keys_file:
keys += [line for line in args.keys_file.read_text(encoding="utf-8").splitlines() if line.strip()]
if not keys:
print("--mark-reviewed needs keys, on the command line or in --keys-file", file=sys.stderr)
return 2
try:
unknown = mark_reviewed(lang, args.review_dir / kind / lang.name, keys)
except ValueError as error:
print(error, file=sys.stderr)
return 2
for key in unknown:
print(f"{shown(lang)} has no key {key!r}", file=sys.stderr)
print(f"{len(keys) - len(unknown)} entries of {shown(lang)} recorded as reviewed")
return 1 if unknown else 0
# Each key goes to the first folder that has it; a key none has is reported against them all.
left = list(keys)
for kind, lang in found:
entries = LangFile(lang).entries
mine = [key for key in left if key in entries]
left = [key for key in left if key not in entries]
if not mine and len(found) > 1:
continue
try:
mark_reviewed(lang, args.review_dir / kind / lang.name, mine)
except ValueError as error:
print(error, file=sys.stderr)
return 2
print(f"{len(mine)} entries of {shown(lang)} recorded as reviewed")
for key in left:
print(f"{' or '.join(shown(lang) for _, lang in found)} has no key {key!r}", file=sys.stderr)
return 1 if left else 0
if __name__ == "__main__":