Tools: wiederverwendbares Skript zum Items-Reuse für Actor-Pakete + Liste der 757 noch unübersetzten Enemy-Within-Items

This commit is contained in:
alzer
2026-08-10 21:50:09 +02:00
parent e9bd8c6dac
commit 835799b473
2 changed files with 916 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env node
// Reuse-only actor translator: fills in an Actors pack's "items" (Skills/Talents/Traits/
// Trappings) from whatever is already translated elsewhere in compendium/, and leaves
// everything else (NSC name, description/gmnotes, effects) exactly as in the English
// source. Items with no existing translation anywhere in the corpus are left in English
// too - this script never invents new translations, it only splices in prior work.
//
// Usage:
// node tools/translate_actor_items_from_corpus.js <file.actors.json> "<German label>"
// node tools/translate_actor_items_from_corpus.js --list-untranslated <file1.actors.json> [file2 ...]
//
// Examples:
// node tools/translate_actor_items_from_corpus.js wfrp4e-pbtt.actors.json "Akteure (Power Behind the Throne)"
// node tools/translate_actor_items_from_corpus.js --list-untranslated wfrp4e-pbtt.actors.json wfrp4e-dotr.actors.json
//
// Run from anywhere; paths are resolved relative to the repo root (parent of tools/).
const fs = require('fs');
const path = require('path');
const REPO = path.join(__dirname, '..');
const COMPENDIUM = path.join(REPO, 'compendium');
const TODO = path.join(COMPENDIUM, 'To Do');
// Actor packs that were translated in "items-only reuse" mode: they still contain
// English-language leftovers under their original keys/names for items with no
// existing translation anywhere else. They must NEVER be trusted as a lookup source
// (their own untranslated leftovers would otherwise be mistaken for "already translated").
const PARTIAL_PACKS = new Set([
'wfrp4e-pbtt.actors.json',
'wfrp4e-dotr.actors.json',
'wfrp4e-horned-rat.actors.json',
'wfrp4e-empire-ruins.actors.json'
]);
function buildLookups(extraExclude = []) {
const exclude = new Set([...PARTIAL_PACKS, ...extraExclude]);
const itemsLookup = {}; // key -> translated item, from already-translated *.items.json packs
const actorLookup = {}; // key -> translated item, from nested items inside already-translated *.actors.json/*.bestiary.json
const byName = {}; // English name -> translated item (fallback for actor-embedded items with random Foundry IDs as keys)
for (const f of fs.readdirSync(COMPENDIUM)) {
if (exclude.has(f)) continue;
const full = path.join(COMPENDIUM, f);
if (!fs.statSync(full).isFile() || !f.endsWith('.json')) continue;
let data;
try { data = JSON.parse(fs.readFileSync(full, 'utf8')); } catch { continue; }
if (!data.entries) continue;
if (f.endsWith('.items.json')) {
for (const [k, v] of Object.entries(data.entries)) {
if (!itemsLookup[k]) itemsLookup[k] = v;
}
} else if (f.endsWith('.actors.json') || f.endsWith('.bestiary.json')) {
for (const actor of Object.values(data.entries)) {
for (const [k, v] of Object.entries(actor.items || {})) {
if (!actorLookup[k]) actorLookup[k] = v;
}
}
}
}
for (const [k, v] of Object.entries(itemsLookup)) if (!byName[k]) byName[k] = v;
for (const [k, v] of Object.entries(actorLookup)) if (!byName[k]) byName[k] = v;
return { itemsLookup, actorLookup, byName };
}
function translateItems(items, lookups) {
const { itemsLookup, actorLookup, byName } = lookups;
const out = {};
let translated = 0;
for (const [k, v] of Object.entries(items)) {
if (itemsLookup[k]) { out[k] = JSON.parse(JSON.stringify(itemsLookup[k])); translated++; }
else if (actorLookup[k]) { out[k] = JSON.parse(JSON.stringify(actorLookup[k])); translated++; }
else if (byName[v.name]) { out[k] = JSON.parse(JSON.stringify(byName[v.name])); translated++; }
else { out[k] = v; } // leave untranslated (English original) - never invent new translations
}
return { out, translated };
}
function runTranslate(srcFile, labelDE) {
const lookups = buildLookups();
const src = JSON.parse(fs.readFileSync(path.join(TODO, srcFile), 'utf8'));
const out = { label: labelDE, entries: {} };
if (src.folders) out.folders = src.folders;
let totalItems = 0, translatedItems = 0;
for (const [key, actor] of Object.entries(src.entries)) {
const entry = { name: actor.name };
if (actor.items) {
const { out: items, translated } = translateItems(actor.items, lookups);
entry.items = items;
totalItems += Object.keys(actor.items).length;
translatedItems += translated;
}
if (actor.description !== undefined) entry.description = actor.description;
if (actor.gmnotes !== undefined) entry.gmnotes = actor.gmnotes;
if (actor.effects !== undefined) entry.effects = actor.effects;
out.entries[key] = entry;
}
// structural safety check: item key sets must match exactly
for (const k of Object.keys(src.entries)) {
const srcKeys = Object.keys(src.entries[k].items || {}).sort();
const outKeys = Object.keys(out.entries[k].items || {}).sort();
if (JSON.stringify(srcKeys) !== JSON.stringify(outKeys)) {
throw new Error('item key mismatch in ' + k);
}
}
const destPath = path.join(COMPENDIUM, srcFile);
fs.writeFileSync(destPath, JSON.stringify(out, null, 1) + '\n');
console.log(`OK ${srcFile} | items total: ${totalItems} | reused: ${translatedItems} | left English: ${totalItems - translatedItems}`);
console.log(`-> ${destPath}`);
console.log('Note: source file was intentionally NOT removed from "To Do/" since names/descriptions/some items remain English by design.');
}
function runListUntranslated(files) {
const lookups = buildLookups();
const { itemsLookup, actorLookup, byName } = lookups;
const missing = {}; // itemName -> { files: Set, actorCount: Set }
for (const f of files) {
const src = JSON.parse(fs.readFileSync(path.join(TODO, f), 'utf8'));
for (const [an, actor] of Object.entries(src.entries)) {
for (const [ik, item] of Object.entries(actor.items || {})) {
if (itemsLookup[ik] || actorLookup[ik] || byName[item.name]) continue;
const label = f.replace(/^wfrp4e-/, '').replace(/\.actors\.json$/, '');
if (!missing[item.name]) missing[item.name] = { files: new Set(), actors: new Set() };
missing[item.name].files.add(label);
missing[item.name].actors.add(an);
}
}
}
const rows = Object.entries(missing)
.map(([name, v]) => ({ name, files: [...v.files].sort(), actorCount: v.actors.size }))
.sort((a, b) => a.name.localeCompare(b.name));
const outPath = path.join(REPO, 'untranslated_ew_items.txt');
fs.writeFileSync(outPath, rows.map(r => `${r.name}\t[${r.files.join(', ')}]\t(${r.actorCount} NSC)`).join('\n') + '\n');
console.log('Unique untranslated item names:', rows.length);
console.log('->', outPath);
}
// --- CLI entry point ---
const argv = process.argv.slice(2);
if (argv[0] === '--list-untranslated') {
runListUntranslated(argv.slice(1));
} else if (argv.length === 2) {
runTranslate(argv[0], argv[1]);
} else {
console.error('Usage:');
console.error(' node tools/translate_actor_items_from_corpus.js <file.actors.json> "<German label>"');
console.error(' node tools/translate_actor_items_from_corpus.js --list-untranslated <file1.actors.json> [file2 ...]');
process.exit(1);
}