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,
rest_tooltip,
save_tooltip,
..
} => {
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);
}
if let Some(t) = rest_tooltip.as_mut() {
f(&format!("{keybase}.rest_tooltip"), t);
}
if let Some(t) = save_tooltip.as_mut() {
f(&format!("{keybase}.save_tooltip"), t);
}
}
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);
}
}
}
if let Some(t) = w.health_bar.as_mut().and_then(|b| b.title.as_mut()) {
f(&format!("wave.{wl}.health_bar.title"), t);
}
}
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);
}
}
if let Some(t) = a.health_bar.as_mut().and_then(|b| b.title.as_mut()) {
f(&format!("actor.{al}.health_bar.title"), t);
}
}
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, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextKind {
Name,
Title,
Description,
Objective,
Refusal,
Dialogue,
Bark,
OptionLabel,
ButtonTooltip,
ItemName,
ItemTooltip,
Prompt,
Narration,
}
pub fn key_kind(key: &str) -> Option<TextKind> {
use TextKind::*;
let segs: Vec<&str> = key.split('.').collect();
let first = *segs.first()?;
let last = *segs.last()?;
let n = segs.len();
Some(match first {
"world" => match key {
"world.title" => Title,
"world.boundary.message" => Refusal,
"world.outro" => Narration,
_ => return None,
},
"area" | "npc" | "state" if n == 3 && last == "name" => Name,
"class" => match (n, last) {
(3, "name") => Name,
(3, "blurb") => Description,
(5, "name") if segs[2] == "kit" => ItemName,
_ => return None,
},
"quest" if n == 3 && last == "goal" => Objective,
"obj" if n == 4 => match last {
"title" | "hint" => Objective,
"missing_item_hint" => Refusal,
"item_name" => ItemName,
_ => return None,
},
"cast" if n == 6 && segs[4] == "bark" => Bark,
"dlg" => match (n, last) {
(4, "text") => Dialogue,
(6, "label") if segs[3] == "opt" => OptionLabel,
(6, "tooltip") if segs[3] == "opt" => ButtonTooltip,
_ => return None,
},
"wave" | "actor" => match last {
"name" if segs.contains(&"drop") => ItemName,
"name" => Name,
"title" if segs[n - 2] == "health_bar" => Title,
_ => return None,
},
"loot" if n == 5 && last == "name" => ItemName,
"lethal" if n == 3 && last == "message" => Narration,
"stake" if n == 3 && last == "collected" => Narration,
"shop" => match (n, last) {
(3, "title") => Title,
(5, "label") => OptionLabel,
(5, "tooltip") => ItemTooltip,
_ => return None,
},
"fx" => match last {
"narrate" => Narration,
"give" => ItemName,
"rest_prompt" => Prompt,
"rest_label" | "save_label" => OptionLabel,
"rest_tooltip" | "save_tooltip" => ButtonTooltip,
"sealed_hint" => Refusal,
_ => return None,
},
_ => return None,
})
}
pub fn key_situations(c: &Campaign) -> BTreeMap<String, Vec<String>> {
let goals: BTreeMap<&str, &str> = c
.quest_plan
.content
.quests
.iter()
.map(|q| (local(q.id.as_str()), q.goal.as_str()))
.collect();
let quests: BTreeMap<&str, &crate::stages::Quest> = c
.quests
.content
.quests
.iter()
.map(|q| (local(q.id.as_str()), q))
.collect();
let npc_names: BTreeMap<&str, &str> = c
.npcs
.content
.npcs
.iter()
.map(|n| (local(n.id.as_str()), n.name.as_str()))
.collect();
let mut nodes: BTreeMap<(&str, &str), &crate::stages::DialogueNode> = BTreeMap::new();
for tree in &c.dialogue.content.dialogues {
for node in &tree.nodes {
nodes.insert((local(tree.npc.as_str()), local(node.id.as_str())), node);
}
}
let shops: BTreeMap<&str, &crate::stages::Shop> = c
.quests
.content
.shops
.iter()
.map(|s| (local(s.id.as_str()), s))
.collect();
let mut beats: BTreeMap<String, &str> = BTreeMap::new();
each_effect_ref(c, &mut |_stage, _path, keybase, eff| {
if let Some(h) = &eff.happening {
beats.insert(keybase.to_string(), h.text.as_str());
}
});
let objective = |q: &str, o: &str| {
quests
.get(q)
.and_then(|q| q.objectives.iter().find(|x| local(x.id().as_str()) == o))
};
let quest_lines = |q: &str, out: &mut Vec<String>, own_goal: bool| {
if !own_goal && let Some(g) = goals.get(q) {
out.push(format!("Quest: {g}"));
}
if let Some(h) = quests.get(q).and_then(|x| x.happening.as_ref()) {
out.push(format!("What the quest does to the story: {}", h.text));
}
};
let option_lines = |np: &str, nd: &str, oi: &str, out: &mut Vec<String>, own: &str| {
let Some(node) = nodes.get(&(np, nd)) else {
return;
};
out.push(format!("Answers the NPC line: {}", node.text));
let Some(opt) = oi.parse::<usize>().ok().and_then(|i| node.options.get(i)) else {
return;
};
if own != "label" {
out.push(format!("Caption on the button: {}", opt.label));
}
if own == "label"
&& let Some(t) = &opt.tooltip
{
out.push(format!("Full line in the tooltip: {t}"));
}
if let Some(h) = &opt.happening {
out.push(format!("Choosing it: {}", h.text));
}
};
let mut out = BTreeMap::new();
for key in inventory(c).into_keys() {
let segs: Vec<&str> = key.split('.').collect();
let mut lines: Vec<String> = Vec::new();
match segs.as_slice() {
["world", "boundary", "message"] => {
lines.push("Shown when a player walks past the edge of the map.".into())
}
["world", "outro"] => {
lines.push("The closing line, shown when the delve is complete.".into())
}
["class", cl, "blurb"] | ["class", cl, "kit", _, "name"] => {
if let Some(class) = c
.classes
.content
.classes
.iter()
.find(|x| local(x.id.as_str()) == *cl)
{
lines.push(format!("Class: {}", class.name));
}
}
["quest", q, "goal"] => quest_lines(q, &mut lines, true),
["obj", q, o, field] => {
quest_lines(q, &mut lines, false);
if let Some(obj) = objective(q, o) {
if *field != "title"
&& let Some(t) = obj.title()
{
lines.push(format!("Objective: {t}"));
}
if *field != "hint"
&& let Some(h) = obj.hint()
{
lines.push(format!("Objective hint: {h}"));
}
if let Some(h) = obj.happening() {
lines.push(format!("Completing it: {}", h.text));
}
}
}
["cast", q, np, b, "bark", _] => {
quest_lines(q, &mut lines, false);
let doing = quests
.get(q)
.and_then(|x| {
x.cast
.iter()
.find(|(id, _)| local(id.as_str()) == *np)
.map(|(_, e)| e)
})
.and_then(|e| {
b.parse::<usize>()
.ok()
.and_then(|i| e.placements().get(i).copied())
})
.and_then(|p| p.doing.as_deref());
if let Some(d) = doing {
let who = npc_names.get(np).copied().unwrap_or(np);
lines.push(format!("{who}, during this quest: {d}"));
}
}
["dlg", np, nd, "text"] => {
if let Some(node) = nodes.get(&(*np, *nd))
&& !node.options.is_empty()
{
let labels: Vec<&str> = node.options.iter().map(|o| o.label.as_str()).collect();
lines.push(format!("The player can answer: {}", labels.join(" / ")));
}
}
["dlg", np, nd, "opt", oi, field] => option_lines(np, nd, oi, &mut lines, field),
["lethal", _, "message"] => {
lines.push("Shown to a player as this hazard kills them.".into())
}
["stake", _, "collected"] => {
lines.push("Shown when a player picks up what they dropped at death.".into())
}
["shop", h, rest @ ..] => {
if let Some(shop) = shops.get(h) {
if rest != ["title"] {
lines.push(format!("Shop: {}", shop.title));
}
if let [_, i, "tooltip"] = rest
&& let Some(off) = i.parse::<usize>().ok().and_then(|i| shop.offers.get(i))
{
lines.push(format!("Caption on the button: {}", off.label));
}
}
}
["fx", rest @ ..] => {
match rest {
["trig", ..] => lines.push("Fires from a trigger placed in the world.".into()),
["trap", ..] => lines.push("Fires from a trap.".into()),
["sc", ..] => lines.push("Fires when a shortcut opens.".into()),
["death", ..] => lines.push("Fires when a player dies.".into()),
["dlg", np, nd, oi, ..] => {
if let Some(opt) = nodes
.get(&(*np, *nd))
.and_then(|n| oi.parse::<usize>().ok().and_then(|i| n.options.get(i)))
{
lines.push(format!("Fires after the player chooses: {}", opt.label));
}
}
["shop", h, oi, ..] => {
if let Some(shop) = shops.get(h)
&& let Some(off) =
oi.parse::<usize>().ok().and_then(|i| shop.offers.get(i))
{
lines.push(format!(
"Fires after buying: {} (shop: {})",
off.label, shop.title
));
}
}
[q, "oc", o, ..] => {
quest_lines(q, &mut lines, false);
if let Some(obj) = objective(q, o) {
let name = obj.title().unwrap_or(o);
lines.push(format!("Fires when this objective is done: {name}"));
if let Some(h) = obj.happening() {
lines.push(format!("Completing it: {}", h.text));
}
}
}
[q, "done", ..] => {
quest_lines(q, &mut lines, false);
lines.push("Fires when the quest completes.".into());
}
_ => {}
}
let mut base: &str = key.rsplit_once('.').map(|(b, _)| b).unwrap_or(&key);
loop {
if let Some(text) = beats.get(base) {
lines.push(format!("This beat: {text}"));
break;
}
match base.rsplit_once('.') {
Some((up, _)) => base = up,
None => break,
}
}
}
_ => {}
}
out.insert(key, lines);
}
out
}
#[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 const PACK_KEY_ROOT: &str = "delve";
pub fn pack_namespace(campaign_id: &str) -> String {
format!("{PACK_KEY_ROOT}.{campaign_id}.")
}
pub fn pack_key(campaign_id: &str, key: &str) -> String {
format!("{}{key}", pack_namespace(campaign_id))
}
pub fn pack_texture_dir(campaign_id: &str) -> String {
format!("{campaign_id}/")
}
pub fn pack_texture_id(campaign_id: &str, texture_id: &str) -> String {
format!("{}{texture_id}", pack_texture_dir(campaign_id))
}
pub fn namespace_skin_textures(c: &mut Campaign) -> BTreeMap<String, String> {
let dir = pack_texture_dir(c.world.campaign_id.as_str());
let mut sources = BTreeMap::new();
for skin in crate::stages::body_skins_mut(c) {
let packed = format!("{dir}{}", skin.texture_id);
sources.insert(
packed.clone(),
std::mem::replace(&mut skin.texture_id, packed),
);
}
sources
}
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 ns = pack_namespace(c.world.campaign_id.as_str());
let mut inv = BTreeMap::new();
each_string(c, &mut |key, value| {
inv.insert(key.to_string(), value.clone());
*value = tag(&format!("{ns}{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/creator/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/creator/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
}