use crate::stages::Verb;
use std::collections::{BTreeMap, BTreeSet};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::diagnostic::{Diagnostic, codes};
use crate::envelope::{Campaign, DSL_VERSION};
use crate::ids::CampaignId;
use crate::stages::{NarrateStyle, QuestEffect};
fn effect_strings(eff: &mut QuestEffect, keybase: &str, f: &mut dyn FnMut(&str, &mut String)) {
match &mut eff.verb {
Verb::Narrate { text, .. } => f(&format!("{keybase}.narrate"), text),
Verb::GiveItem { name: Some(n), .. } => f(&format!("{keybase}.give"), n),
Verb::Bonfire {
prompt,
rest_label,
save_label,
..
} => {
if let Some(p) = prompt.as_mut() {
f(&format!("{keybase}.rest_prompt"), p);
}
if let Some(r) = rest_label.as_mut() {
f(&format!("{keybase}.rest_label"), r);
}
if let Some(s) = save_label.as_mut() {
f(&format!("{keybase}.save_label"), s);
}
}
Verb::CloseGate {
sealed_hint: Some(h),
..
} => f(&format!("{keybase}.sealed_hint"), h),
_ => {}
}
}
fn effect_strings_deep(eff: &mut QuestEffect, keybase: &str, f: &mut dyn FnMut(&str, &mut String)) {
effect_strings(eff, keybase, f);
for (seg, list) in eff.nested_effect_lists_keyed_mut() {
for (j, inner) in list.iter_mut().enumerate() {
effect_strings_deep(inner, &format!("{keybase}.{seg}.{j}"), f);
}
}
}
pub const CANONICAL_LANG: &str = "en";
pub const MARKER_SIGIL: &str = "[dw:complete";
pub fn validate_marker_channel(
c: &Campaign,
sidecars: &BTreeMap<String, L10nDoc>,
) -> Vec<Diagnostic> {
let mut d = Vec::new();
let mut flag = |where_: String, key: &str, text: &str| {
if text.contains(MARKER_SIGIL) {
d.push(Diagnostic::error(
codes::MARKER_RESERVED,
"l10n",
where_,
format!(
"player-visible string `{key}` contains the reserved completion-marker \
sigil `{MARKER_SIGIL}` — that chat sequence is the validation bot's \
completion oracle, and authored text carrying it could forge a passing \
critical-path step. Reword the line to drop `{MARKER_SIGIL}`"
),
));
}
};
for (key, text) in inventory(c) {
flag(format!("#/{key}"), &key, &text);
}
for (lang, doc) in sidecars {
for (key, text) in &doc.content {
flag(format!("l10n/{lang}.json#/content/{key}"), key, text);
}
}
d
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum L10nKind {
L10n,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct L10nDoc {
pub dsl_version: String,
pub campaign_id: CampaignId,
pub kind: L10nKind,
pub lang: String,
pub content: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub source: BTreeMap<String, String>,
}
fn local(id: &str) -> &str {
id.split_once('/').map(|(_, r)| r).unwrap_or(id)
}
pub fn local_id(id: &str) -> &str {
local(id)
}
pub fn each_string(c: &mut Campaign, f: &mut dyn FnMut(&str, &mut String)) {
let mut entity_names: BTreeMap<String, String> = BTreeMap::new();
f("world.title", &mut c.world.content.title);
for area in &mut c.world.content.areas {
let key = format!("area.{}.name", local(area.id.as_str()));
f(&key, &mut area.name);
}
if let Some(b) = c.world.content.boundary.as_mut()
&& let Some(msg) = b.message.as_mut()
{
f("world.boundary.message", msg);
}
if let Some(outro) = c.world.content.outro.as_mut() {
f("world.outro", outro);
}
for class in &mut c.classes.content.classes {
let cl = local(class.id.as_str()).to_string();
f(&format!("class.{cl}.name"), &mut class.name);
f(&format!("class.{cl}.blurb"), &mut class.blurb);
for (i, item) in class.kit.iter_mut().enumerate() {
if let Some(name) = item.name.as_mut() {
f(&format!("class.{cl}.kit.{i}.name"), name);
}
}
}
for npc in &mut c.npcs.content.npcs {
let key = entity_name_key(
&mut entity_names,
&npc.name,
format!("npc.{}.name", local(npc.id.as_str())),
);
f(&key, &mut npc.name);
}
for q in &mut c.quest_plan.content.quests {
let key = format!("quest.{}.goal", local(q.id.as_str()));
f(&key, &mut q.goal);
}
for q in &mut c.quests.content.quests {
let ql = local(q.id.as_str()).to_string();
for o in &mut q.objectives {
let ol = local(o.id().as_str()).to_string();
if let Some(title) = o.title_mut().as_mut() {
f(&format!("obj.{ql}.{ol}.title"), title);
}
if let Some(hint) = o.hint_mut().as_mut() {
f(&format!("obj.{ql}.{ol}.hint"), hint);
}
if let crate::stages::Objective::Interact {
missing_item_hint: Some(m),
..
} = o
{
f(&format!("obj.{ql}.{ol}.missing_item_hint"), m);
}
if let crate::stages::Objective::Collect {
item_name: Some(n), ..
} = o
{
f(&format!("obj.{ql}.{ol}.item_name"), n);
}
}
for (npc, entry) in &mut q.cast {
let np = local(npc.as_str()).to_string();
for (b, p) in entry.placements_mut().into_iter().enumerate() {
let Some(crate::stages::CastDialogue::Barks(pool)) = p.dialogue.as_mut() else {
continue;
};
for (i, line) in pool.barks.iter_mut().enumerate() {
f(&format!("cast.{ql}.{np}.{b}.bark.{i}"), line);
}
}
}
}
for tree in &mut c.dialogue.content.dialogues {
let np = local(tree.npc.as_str()).to_string();
for node in &mut tree.nodes {
let nd = local(node.id.as_str()).to_string();
f(&format!("dlg.{np}.{nd}.text"), &mut node.text);
for (i, opt) in node.options.iter_mut().enumerate() {
f(&format!("dlg.{np}.{nd}.opt.{i}.label"), &mut opt.label);
if let Some(tip) = opt.tooltip.as_mut() {
f(&format!("dlg.{np}.{nd}.opt.{i}.tooltip"), tip);
}
}
}
}
for w in &mut c.quests.content.waves {
let wl = local(w.id.as_str()).to_string();
for (i, mob) in w.mobs.iter_mut().enumerate() {
if let Some(name) = mob.name.as_mut() {
f(&format!("wave.{wl}.mob.{i}.name"), name);
}
for (n, dr) in mob.drops.iter_mut().enumerate() {
if let Some(name) = dr.name_mut() {
f(&format!("wave.{wl}.mob.{i}.drop.{n}.name"), name);
}
}
}
}
for a in &mut c.quests.content.actors {
let al = local(a.id.as_str()).to_string();
if let Some(name) = a.name.as_mut() {
let key = entity_name_key(&mut entity_names, name, format!("actor.{al}.name"));
f(&key, name);
}
for (n, dr) in a.drops.iter_mut().enumerate() {
if let Some(name) = dr.name_mut() {
f(&format!("actor.{al}.drop.{n}.name"), name);
}
}
}
for l in &mut c.quests.content.loot {
let ll = local(l.id.as_str()).to_string();
for (i, item) in l.items.iter_mut().enumerate() {
if let Some(name) = item.name.as_mut() {
f(&format!("loot.{ll}.item.{i}.name"), name);
}
}
}
for v in &mut c.quests.content.lethal_volumes {
let vl = local(v.id.as_str()).to_string();
f(&format!("lethal.{vl}.message"), &mut v.message);
}
for st in &mut c.quests.content.state {
let sl = local(st.id.as_str()).to_string();
if let Some(name) = st.name.as_mut() {
f(&format!("state.{sl}.name"), name);
}
}
for sh in &mut c.quests.content.shops {
let hl = local(sh.id.as_str()).to_string();
f(&format!("shop.{hl}.title"), &mut sh.title);
for (i, off) in sh.offers.iter_mut().enumerate() {
f(&format!("shop.{hl}.offer.{i}.label"), &mut off.label);
if let Some(t) = off.tooltip.as_mut() {
f(&format!("shop.{hl}.offer.{i}.tooltip"), t);
}
}
}
for st in &mut c.quests.content.stakes {
let sl = local(st.id.as_str()).to_string();
f(&format!("stake.{sl}.collected"), &mut st.collected_message);
}
for (_stage, _path, keybase, eff) in effect_roots_mut(c) {
effect_strings_deep(eff, &keybase, f);
}
}
fn entity_name_key(claimed: &mut BTreeMap<String, String>, text: &str, own: String) -> String {
claimed.entry(text.to_string()).or_insert(own).clone()
}
pub fn inventory(c: &Campaign) -> BTreeMap<String, String> {
let mut c2 = c.clone();
let mut out = BTreeMap::new();
each_string(&mut c2, &mut |key, value| {
out.insert(key.to_string(), value.clone());
});
out
}
pub fn key_speaker(key: &str) -> Option<&str> {
let (kind, rest) = key.split_once('.')?;
match kind {
"dlg" | "npc" => rest.split('.').next(),
"cast" => rest.split('.').nth(1),
_ => None,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtNarrate {
pub stage: &'static str,
pub path: String,
pub key: String,
pub text: String,
}
pub fn art_narrates(c: &Campaign) -> Vec<ArtNarrate> {
let mut out = Vec::new();
each_effect_ref(c, &mut |stage, path, keybase, eff| {
if let Some(text) = eff.narrate_art_text() {
out.push(ArtNarrate {
stage,
path: format!("{path}/text"),
key: format!("{keybase}.narrate"),
text: text.to_string(),
});
}
});
out
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScreenNarrate {
pub stage: &'static str,
pub path: String,
pub key: String,
pub style: NarrateStyle,
pub text: String,
}
pub fn on_screen_narrates(c: &Campaign) -> Vec<ScreenNarrate> {
let mut out = Vec::new();
each_effect_ref(c, &mut |stage, path, keybase, eff| {
if let Some((style, text)) = eff.narrate_on_screen() {
out.push(ScreenNarrate {
stage,
path: format!("{path}/text"),
key: format!("{keybase}.narrate"),
style,
text: text.to_string(),
});
}
});
out
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OptionLabel {
pub stage: &'static str,
pub path: String,
pub key: String,
pub text: String,
}
pub fn dialogue_option_labels(c: &Campaign) -> Vec<OptionLabel> {
let mut out = Vec::new();
for (ti, tree) in c.dialogue.content.dialogues.iter().enumerate() {
let np = local(tree.npc.as_str());
for (ni, node) in tree.nodes.iter().enumerate() {
let nd = local(node.id.as_str());
for (oi, opt) in node.options.iter().enumerate() {
out.push(OptionLabel {
stage: "dialogue",
path: format!("/content/dialogues/{ti}/nodes/{ni}/options/{oi}/label"),
key: format!("dlg.{np}.{nd}.opt.{oi}.label"),
text: opt.label.clone(),
});
}
}
}
out
}
pub fn bonfire_option_labels(c: &Campaign) -> Vec<OptionLabel> {
let mut out = Vec::new();
each_effect_ref(c, &mut |stage, path, keybase, eff| {
let Some(l) = eff.bonfire_labels() else {
return;
};
for (text, field, key) in [
(l.rest_label, "rest_label", "rest_label"),
(l.save_label, "save_label", "save_label"),
] {
if let Some(text) = text {
out.push(OptionLabel {
stage,
path: format!("{path}/{field}"),
key: format!("{keybase}.{key}"),
text: text.to_string(),
});
}
}
});
out
}
fn effect_roots(c: &Campaign) -> Vec<EffectRoot<'_>> {
let mut out = Vec::new();
crate::effects::for_each_effect_root(c, &mut |site, list| {
for (i, eff) in list.iter().enumerate() {
out.push(EffectRoot {
stage: site.stage,
path: format!("{}/{i}", site.path),
key: format!("{}.{i}", site.key),
eff,
});
}
});
out
}
struct EffectRoot<'a> {
stage: &'static str,
path: String,
key: String,
eff: &'a QuestEffect,
}
fn effect_roots_mut(c: &mut Campaign) -> Vec<(&'static str, String, String, &mut QuestEffect)> {
let mut out: Vec<(&'static str, String, String, &mut QuestEffect)> = Vec::new();
crate::effects::for_each_effect_root_mut(c, &mut |kind, path, key, list| {
for (i, eff) in list.iter_mut().enumerate() {
out.push((
kind.stage(),
format!("{path}/{i}"),
format!("{key}.{i}"),
eff,
));
}
});
out
}
fn each_effect_ref<'a>(
c: &'a Campaign,
f: &mut dyn FnMut(&'static str, &str, &str, &'a QuestEffect),
) {
for r in effect_roots(c) {
effect_deep(r.eff, r.stage, &r.path, &r.key, f);
}
}
fn effect_deep<'a>(
eff: &'a QuestEffect,
stage: &'static str,
path: &str,
keybase: &str,
f: &mut dyn FnMut(&'static str, &str, &str, &'a QuestEffect),
) {
f(stage, path, keybase, eff);
for (pseg, kseg, list) in eff.nested_effect_lists_labeled() {
for (j, inner) in list.iter().enumerate() {
effect_deep(
inner,
stage,
&format!("{path}/{pseg}/{j}"),
&format!("{keybase}.{kseg}.{j}"),
f,
);
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SoundRef {
pub stage: &'static str,
pub path: String,
pub sound: String,
}
pub fn sound_refs(c: &Campaign) -> Vec<SoundRef> {
let mut out = Vec::new();
each_effect_ref(c, &mut |stage, path, _key, eff| {
for (sub, sound) in eff.sound_refs() {
out.push(SoundRef {
stage,
path: format!("{path}/{sub}"),
sound: sound.to_string(),
});
}
});
out
}
pub fn play_sound_actor_refs(c: &Campaign) -> Vec<SoundRef> {
let mut out = Vec::new();
each_effect_ref(c, &mut |stage, path, _key, eff| {
if let Some(actor) = eff.play_sound_actor() {
out.push(SoundRef {
stage,
path: format!("{path}/at/actor"),
sound: actor.to_string(),
});
}
});
out
}
pub fn localize(c: &mut Campaign, translations: &BTreeMap<String, String>) {
each_string(c, &mut |key, value| {
if let Some(t) = translations.get(key) {
*value = t.clone();
}
});
}
pub const TR_SIGIL: char = '\u{E000}';
const PUA: std::ops::RangeInclusive<char> = '\u{E000}'..='\u{F8FF}';
pub fn tag(key: &str, english: &str) -> String {
format!("{TR_SIGIL}{key}{TR_SIGIL}{english}")
}
pub fn untag(s: &str) -> Option<(&str, &str)> {
let rest = s.strip_prefix(TR_SIGIL)?;
let (key, english) = rest.split_once(TR_SIGIL)?;
Some((key, english))
}
pub fn plain(s: &str) -> &str {
untag(s).map(|(_, e)| e).unwrap_or(s)
}
pub fn has_tr_sigil(s: &str) -> bool {
s.chars().any(|c| PUA.contains(&c))
}
pub fn tag_translatables(c: &mut Campaign) -> BTreeMap<String, String> {
let mut inv = BTreeMap::new();
each_string(c, &mut |key, value| {
inv.insert(key.to_string(), value.clone());
*value = tag(key, value);
});
inv
}
pub fn validate_tr_sigil(c: &Campaign, sidecars: &BTreeMap<String, L10nDoc>) -> Vec<Diagnostic> {
let mut d = Vec::new();
let mut flag = |where_: String, key: &str, text: &str| {
let Some(bad) = text.chars().find(|ch| PUA.contains(ch)) else {
return;
};
d.push(Diagnostic::error(
codes::TR_SIGIL_RESERVED,
"l10n",
where_,
format!(
"player-visible string `{key}` contains the reserved private-use character \
U+{:04X} — that block is how the compiler carries an l10n key into the text \
component this string is emitted as, and it has no rendering in any \
Minecraft font. Remove U+{:04X} from the line",
bad as u32, bad as u32
),
));
};
for (key, text) in inventory(c) {
flag(format!("#/{key}"), &key, &text);
}
for (lang, doc) in sidecars {
for (key, text) in &doc.content {
flag(format!("l10n/{lang}.json#/content/{key}"), key, text);
}
}
d
}
pub fn declared_mc_codes(c: &Campaign) -> Result<Vec<(String, &'static str)>, Diagnostic> {
let mut out = Vec::new();
for lang in &c.world.content.languages {
match crate::mclang::mc_lang_code(lang) {
Some(mc) => out.push((lang.clone(), mc)),
None => {
return Err(Diagnostic::error(
codes::LANG_CODE_UNMAPPED,
"world",
format!("/content/languages/{lang}"),
format!(
"declared language `{lang}` has no Minecraft language-file code — the \
resource pack has nowhere to write its \
`assets/delvewright/lang/<code>.json`, and the language would ship \
invisible. Use a code the pinned 1.21.11 client really loads \
(`dsl::mclang::CLIENT_LANGS`, derived from Mojang's own asset index) \
— e.g. `zh-cn`, `ja-jp`, `de-de`"
),
));
}
}
}
Ok(out)
}
pub fn validate_l10n_provenance(
c: &Campaign,
sidecars: &BTreeMap<String, L10nDoc>,
) -> Vec<Diagnostic> {
let mut d = Vec::new();
if c.world.content.languages.is_empty() {
return d;
}
let inv = inventory(c);
for lang in &c.world.content.languages {
let Some(doc) = sidecars.get(lang) else {
continue; };
for (key, was) in &doc.source {
match inv.get(key) {
Some(now) if now == was => {}
Some(now) => d.push(Diagnostic::error(
codes::L10N_STALE,
"l10n",
format!("l10n/{lang}.json#/source/{key}"),
format!(
"`{key}` was translated from {was:?} but now reads {now:?} — the \
translation in `content` still renders the old line and would ship \
attached to the new one. Re-translate `{key}` and update its `source` \
(`tools/i18n-translate.py <campaign> --lang {lang}` does both). If a \
RENAME surprised you here: an entity display name's key belongs to the \
first body declaring that text, so renaming one body can hand its key \
to another"
),
)),
None => d.push(Diagnostic::error(
codes::L10N_STALE,
"l10n",
format!("l10n/{lang}.json#/source/{key}"),
format!(
"`source` records `{key}`, which is not in the string inventory — the \
provenance is stale even if the translation is gone. Remove `{key}` \
from `source` in `l10n/{lang}.json`"
),
)),
}
}
let unguarded = doc
.content
.keys()
.filter(|k| !doc.source.contains_key(*k))
.count();
if unguarded > 0 {
let total = doc.content.len();
d.push(Diagnostic::warning(
codes::L10N_PROVENANCE_MISSING,
"l10n",
format!("l10n/{lang}.json"),
format!(
"{unguarded} of {total} translated rows record no `source`, so nothing can \
tell whether they still translate the English they render — an edited line \
leaves its translation present, applied and wrong, and no key moves. Run \
`tools/i18n-translate.py <campaign> --lang {lang}` to record provenance for \
the rows it already has. This warning is the one-version deprecation \
window; `source` becomes required after it"
),
));
}
}
d
}
pub fn validate_l10n(c: &Campaign, sidecars: &BTreeMap<String, L10nDoc>) -> Vec<Diagnostic> {
let mut d = Vec::new();
let declared = &c.world.content.languages;
if declared.is_empty() {
return d;
}
let inv = inventory(c);
let required_keys: BTreeSet<&str> = inv.keys().map(String::as_str).collect();
let inv_keys: BTreeSet<&str> = required_keys.clone();
let campaign_id = c.world.campaign_id.as_str();
for lang in declared {
if lang == CANONICAL_LANG {
d.push(Diagnostic::error(
codes::L10N_MISSING,
"l10n",
format!("/world/languages/{lang}"),
format!(
"`{lang}` is the canonical language and must not be declared in \
`world.languages` — English is implicit; remove `{lang}` from the list"
),
));
continue;
}
let Some(doc) = sidecars.get(lang) else {
d.push(Diagnostic::error(
codes::L10N_MISSING,
"l10n",
format!("l10n/{lang}.json"),
format!(
"declared language `{lang}` has no `l10n/{lang}.json` sidecar — add the \
sidecar (a full key→translation map), or remove `{lang}` from \
`world.languages`"
),
));
continue;
};
if doc.campaign_id.as_str() != campaign_id {
d.push(Diagnostic::error(
codes::L10N_MISSING,
"l10n",
format!("l10n/{lang}.json"),
format!(
"sidecar `campaign_id` `{}` differs from the campaign's `{campaign_id}` — set \
the sidecar's `campaign_id` to `{campaign_id}`",
doc.campaign_id
),
));
}
if doc.lang != *lang {
d.push(Diagnostic::error(
codes::L10N_MISSING,
"l10n",
format!("l10n/{lang}.json"),
format!(
"sidecar `lang` field `{}` differs from filename code `{lang}` — set the \
sidecar's `lang` to `{lang}` (it must match the `l10n/{lang}.json` filename)",
doc.lang
),
));
}
if doc.dsl_version != DSL_VERSION {
d.push(Diagnostic::error(
codes::L10N_MISSING,
"l10n",
format!("l10n/{lang}.json"),
format!(
"sidecar declares dsl_version `{}` — set it to `{DSL_VERSION}`, the one \
number this engine accepts, matching the stage documents",
doc.dsl_version,
),
));
}
let side_keys: BTreeSet<&str> = doc.content.keys().map(String::as_str).collect();
for missing in required_keys.difference(&side_keys) {
d.push(Diagnostic::error(
codes::L10N_MISSING,
"l10n",
format!("l10n/{lang}.json"),
format!(
"sidecar is missing a translation for inventory key `{missing}` — add \
`{missing}` to `l10n/{lang}.json` (coverage must be exact)"
),
));
}
for orphan in side_keys.difference(&inv_keys) {
d.push(Diagnostic::error(
codes::L10N_ORPHAN,
"l10n",
format!("l10n/{lang}.json#/content/{orphan}"),
format!(
"orphan key `{orphan}` is not in the string inventory — remove it from \
`l10n/{lang}.json` (the sidecar must cover exactly the inventory, no extras)"
),
));
}
}
d
}