feedback7: merge fb7-voices — the 选中干员 line on every tap (prep / shop / hand, official FOCUS_CHAR: priority 10, cooldown 0, no gap restart), the Japanese voice tree (audio.voiceJp, 2674 files, setting 语音语言 中文 / 日本語, CN fallback), the full zip ships both

- Conflict: public/js/ui/settings.js imports (store from #321 + VOICE_LANGS) — both kept. test/ui/playtest6-elite.test.js's
  source pin of the onDetail line allows the new tap counter (bcee737f).
- Grok review: MERGE AFTER FIXES — the three findings fixed on the branch (bcee737f). Golden unchanged; broad node --test
  5334 (the one failure was that source pin, fixed), i18n strict clean, lint 0 errors, typecheck.
This commit is contained in:
sganggs committed 2026-10-08 20:35:26 +08:00
commit fbcbfdffbc
26 files changed
+584 -104

No files matched your search

+5 -5
View File
@@ -319,10 +319,10 @@ export const VOICE_SLOTS = Object.freeze({
});
/**
* The slots a running battle can actually request — the only ones `public/js/audio.js` ever asks for (the 休整期 is
* silent, so nothing else is played): 行动出发 start, 首次接敌 faceEnemy, 作战中1-4 skillN, 部署 place (the deploy
* events) and 选中干员 select (the detail panel, behind its combat flag), plus the settlement lines
* resultFour / resultThree / resultTwo / resultLose (public/js/screens/game.js onResult).
* The slots the client can actually request — the only ones `public/js/audio.js` ever asks for: 行动出发 start, 首次接敌
* faceEnemy, 作战中1-4 skillN, 部署 place (a running battle's deploy events), the settlement lines resultFour /
* resultThree / resultTwo / resultLose (public/js/screens/game.js onResult) and 选中干员 select (the detail panel opening
* on an operator the player tapped, in every phase since 0.2.2).
* `buildPlan` plans these by default; `--voice-all` widens it to every slot of VOICE_SLOTS.
*/
export const VOICE_BATTLE_SLOTS = Object.freeze(['start', 'faceEnemy', 'select', 'place',
@@ -333,7 +333,7 @@ export const VOICE_BATTLE_SLOTS = Object.freeze(['start', 'faceEnemy', 'select',
* 任命队长 squadFirst). `test/docs-consistency.test.js` proves the client never asks for one, so planning them only
* makes every `npm run assets` download 360 files (19.3 MB, CN dub) that no player will ever hear — they are left out
* unless `--voice-all` is passed. 部署 `place` and 选中干员 `select` deliberately stay in: the official client groups
* them with the prep lines, but a battle does play them (the deploy events; the detail panel behind its combat flag).
* them with the prep lines, but the client does play them (the deploy events; a tap on an operator).
*/
export const VOICE_PREP_SLOTS = Object.freeze(['gacha', 'squad', 'squadFirst']);
+37 -18
View File
@@ -29,10 +29,19 @@ import { EMOTE_CATALOG } from '../../shared/constants.js';
/**
* The voice slots are read from the zh_CN charword table, whose voiceIds are always `CN_*`: the other dubs
* (`--voice-lang=jp|en|kr`) share the very same slot numbering and file names, only the dump folder differs.
* (`--voice-lang=jp|en|kr`, the JP tree `audio.voiceJp`) share the very same slot numbering and file names, only the
* dump folder differs.
*/
const VOICE_ID_LANG = 'CN';
/**
* The second voice tree, `audio.voiceJp`: the Japanese dub (ArknightsAssets2 `voice/`, audio.mjs VOICE_DIRS.jp) of the
* same slots and lines as `audio.voice`, under `audio/voice/jp/`. The client plays it when the player picks 日本語
* (settings 语音语言) and falls back to `audio.voice` for a line it lacks (public/js/audio.js voiceLine). The owner's
* request of 2026-10-08 「全套的日配语音」: the JP dub of every line the game plays, beside the Chinese one.
*/
export const VOICE_JP_LANG = 'jp';
/**
* Enemies whose Spine no community dump carries: the web model is another enemy's (research 07 §5.6). Their official
* models come from the local client only (tools/local-extract ENEMY_SPINES): `localEnemySpines` adds them as the
@@ -294,8 +303,10 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
* @param {any} p.maps05 docs/research/05-maps.json
* @param {ReturnType<import('./audio.mjs').indexAudio>} p.audio indexed audio_data.json
* @param {any} p.modelsData Ark-Models models_data.json
* @param {any} [p.charword] parsed excel/charword_table.json — the operators' official voice slots (voice)
* @param {string} [p.voiceLang] voice dump to plan: cn (default) | jp | en | kr
* @param {any} [p.charword] parsed excel/charword_table.json — the operators' official voice slots (voice, voiceJp)
* @param {string} [p.voiceLang] voice dump of `audio.voice` (the 中文 setting): cn (default) | jp | en | kr — `audio.voiceJp`
* is the JP dub (`voice/`) whatever it is
* @param {boolean} [p.voiceJp] plan the JP tree `audio.voiceJp` (default true)
* @param {Iterable<string>|null} [p.voiceSlots] which voice slots to plan: VOICE_BATTLE_SLOTS (default) plans only the
* lines a battle can play, null plans every slot of audio.mjs VOICE_SLOTS (`--voice-all`). The prep-only slots
* (干员报到 / 编入队伍 / 任命队长) are never requested by the client and cost 360 files / 19.3 MB of downloads.
@@ -316,7 +327,7 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
* @returns {{ template: any, models: Map<string, any>, notes: string[] }}
*/
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, charword = null, voiceLang = 'cn',
voiceSlots = VOICE_BATTLE_SLOTS,
voiceJp = true, voiceSlots = VOICE_BATTLE_SLOTS,
extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {}, localTokenSpines = {}, extraOperators = {},
moduleTypes = [] }) {
const notes = [];
@@ -612,8 +623,8 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
modules[t] = leaf(alt(`module/${safeName(t).toLowerCase()}.png`, [...new Set([t, t.toLowerCase()])].map((n) => joinUrl(RAW.aa2, `arts/ui/uniequiptype/${n}.png`))));
}
// --- 干员战斗语音 (excel/charword_table.json → audio.voice) ---------------------------------------------
// The official lines of every operator the mode can field, for the slots a battle can actually play: 行动出发 start /
// --- 干员战斗语音 (excel/charword_table.json → audio.voice, audio.voiceJp) ---------------------------------
// The official lines of every operator the mode can field, for the slots the client can actually play: 行动出发 start /
// 行动开始 faceEnemy / 选中干员 select / 部署 place / 作战中1-4 skillN / 结算 result* (charword `placeType`,
// audio.mjs VOICE_BATTLE_SLOTS). A slot with several lines stays an array — the client draws one at random
// (public/js/audio.js voice). Operators without official battle voice keep no entry at all: the 17 预备干员
@@ -621,19 +632,26 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
// The three prep-only slots (干员报到 gacha / 编入队伍 squad / 任命队长 squadFirst) are NOT planned by default: the
// client never requests them, and downloading them adds 360 files / 19.3 MB to every `npm run assets` — pass --voice-all for
// the complete official set (`voiceSlots: null`, reviewer note on the voice PR).
const voice = {};
for (const [charId, slots] of indexVoice(charword, VOICE_ID_LANG, voiceSlots)) {
if (!chars[charId]) continue; // only the operators this game can field (`chars`: the 138 pool charIds and the 自选 picks)
const v = {};
for (const [slot, assets] of Object.entries(slots)) {
// one leaf per line (部署1 / 部署2 …): an array stays an array so the client can draw one — chaining them as
// alternatives of a single leaf would keep only the first line that landed on disk.
const lines = assets.map((a) => leaf(voiceAlt(a, voiceLang))).filter(Boolean);
if (!lines.length) continue;
v[slot] = lines.length === 1 ? lines[0] : lines;
// `audio.voiceJp` (VOICE_JP_LANG): the same slots and lines in the Japanese dub, a second tree beside the Chinese one
// (the owner's request of 2026-10-08) — same file names, `audio/voice/jp/`.
const voiceSlotsByChar = indexVoice(charword, VOICE_ID_LANG, voiceSlots);
const voiceTree = (lang) => {
const tree = {};
for (const [charId, slots] of voiceSlotsByChar) {
if (!chars[charId]) continue; // only the operators this game can field (`chars`: the 138 pool charIds and the 自选 picks)
const v = {};
for (const [slot, assets] of Object.entries(slots)) {
// one leaf per line (部署1 / 部署2 …): an array stays an array so the client can draw one — chaining them as
// alternatives of a single leaf would keep only the first line that landed on disk.
const lines = assets.map((a) => leaf(voiceAlt(a, lang))).filter(Boolean);
if (!lines.length) continue;
v[slot] = lines.length === 1 ? lines[0] : lines;
}
if (Object.keys(v).length) tree[charId] = v;
}
if (Object.keys(v).length) voice[charId] = v;
}
return tree;
};
const voice = voiceTree(voiceLang);
if (!Object.keys(voice).length) notes.push('battle voice: charword_table.json has no slots (index missing?)');
const template = {
@@ -642,6 +660,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
bgm,
bossBgm: Object.fromEntries(Object.entries(bossBgm).sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true }))),
voice,
...(voiceJp ? { voiceJp: voiceTree(VOICE_JP_LANG) } : {}),
sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx },
},
};
+6 -3
View File
@@ -95,7 +95,8 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
--offline no network: post-process what is on disk and rebuild data/assets.json
--dry-run print the plan and exit
--refresh-index re-download the audio_data.json / charword_table.json / models_data.json indexes
--voice-lang=cn operator battle voice language: cn (default) | jp | en | kr
--voice-lang=cn the dub of audio.voice (the 中文 voice setting): cn (default) | jp | en | kr
(audio.voiceJp, the 日本語 setting, is always the JP dub: both trees are planned)
--voice-all plan every official voice slot, including the prep-only lines no battle plays
(干员报到 / 编入队伍 / 任命队长; 360 files / 19.3 MB more per run — off by default)
--prune delete files under public/assets that the manifest no longer references
@@ -256,6 +257,7 @@ function countStats(m, bytes, files) {
ui: Object.keys(m.ui || {}).length,
sfxUnits: Object.keys(m.audio?.sfx?.units || {}).length,
voiceChars: Object.keys(m.audio?.voice || {}).length,
voiceJpChars: Object.keys(m.audio?.voiceJp || {}).length,
};
}
@@ -347,7 +349,8 @@ async function main() {
`(${Object.keys(plan.template.chars).length} chars, ${Object.keys(plan.template.enemies).length} enemies, ` +
`${Object.keys(plan.template.tokens).length} tokens, ${Object.keys(plan.template.ui).length} UI sprites, ` +
`${Object.keys(plan.template.audio.sfx.units).length} units with SFX, ` +
`${Object.keys(plan.template.audio.voice).length} operators with ${opts.voiceLang.toUpperCase()} voice)`);
`${Object.keys(plan.template.audio.voice).length} operators with ${opts.voiceLang.toUpperCase()} voice, ` +
`${Object.keys(plan.template.audio.voiceJp || {}).length} with JP voice)`);
if (opts.dryRun) {
for (const n of plan.notes) log(` note: ${n}`);
return 0;
@@ -437,7 +440,7 @@ async function main() {
log(`bonds ${s.bonds} · items ${s.items} · bands ${s.bands} · skill icons ${s.skills} · UI ${s.ui} · units with SFX ${s.sfxUnits}`);
const overlays = (o) => Object.values(o || {}).filter((e) => e?.spineLocal).length;
log(`local-client models (spineLocal, drawn when extracted): enemies ${overlays(manifest.enemies)} · tokens ${overlays(manifest.tokens)}`);
log(`operator battle voice: ${s.voiceChars} charIds (--voice-lang=${opts.voiceLang})`);
log(`operator battle voice: ${s.voiceChars} charIds (--voice-lang=${opts.voiceLang}) · JP dub (audio.voiceJp): ${s.voiceJpChars} charIds`);
log(`fonts: ${Object.values(fonts.files).map((f) => f.woff2 || f.original).join(', ') || 'none'}`);
if (resolved.fallbacks.length) { log(`fallbacks used (${resolved.fallbacks.length}):`); for (const f of resolved.fallbacks.slice(0, 20)) log(` ${f}`); }
if (downloadErrors.length) log(`download errors (${downloadErrors.length}, re-run to retry): ${downloadErrors.slice(0, 10).join(', ')}`);
+31 -11
View File
@@ -21,7 +21,8 @@
// stage adds the production node_modules and (postinstall) public/vendor. The full zip adds the git-ignored art: the
// files data/assets.json lists, public/fonts, and the local-client extraction (public/assets/local/,
// data/local-assets.json) when present — nothing else on disk, so art the data no longer lists (焰狐龙梓兰, left out of
// 自选 in 0.2.0, or files of an old mapping) never ships even when this machine still has it.
// 自选 in 0.2.0, or files of an old mapping) never ships even when this machine still has it. FULL_ZIP_JP_VOICE (below)
// decides whether the full zip carries the Japanese voice dub too (default: yes).
// Left out: test/, the maintainer tools (build-data, golden, botbench, i18n, check-imports, this file …),
// scripts/make-windows-bundle.mjs, the other docs (DESIGN, SIM, the research notes, docs/img …), public/dev/, handoff/,
// .github/, types/, lint / editor / Docker files (Docker builds from a git clone).
@@ -93,6 +94,18 @@ const NOT_TRACKED_SHIP = ['public/dev/', 'public/assets/', 'public/fonts/', 'pub
/** The npm scripts a player runs: each `node <file>` of them must ship. */
export const PLAYER_NPM_SCRIPTS = ['start', 'setup', 'doctor', 'launch', 'postinstall', 'vendor', 'assets'];
/**
* THE SWITCH for the Japanese voice dub in the full zip (`audio.voiceJp` of data/assets.json: 2674 files under
* public/assets/audio/voice/jp/, 89,388,041 bytes = 85.2 MiB on disk, about 76 MB zipped). true (0.2.2): the full zip ships both
* dubs, so it still runs with nothing to download. false: the full zip (and an update zip built from it) leaves the JP
* files out — setup downloads them on the first start like the lite zip's art, the 日本語 setting plays the Chinese line
* until then (public/js/audio.js voiceLine), and an update never deletes a player's copy (`held`). The lite zip carries
* no art either way.
*/
export const FULL_ZIP_JP_VOICE = true;
/** Where the JP dub lies (plan.mjs voiceAlt: audio/voice/<lang>/<charId>/<file>). */
export const JP_VOICE_DIR = 'public/assets/audio/voice/jp/';
/**
* Refused in a plan and in a stage: 0.1.x's list (owner-only notes, the promo project, caches, per-machine config) and
* what 0.2.0 leaves out on purpose. A path is refused when it is one of these or lies under one.
@@ -205,17 +218,20 @@ export const manifestFiles = listedArtFiles;
/**
* The art of a full package: every file data/assets.json lists, public/fonts, public/assets/local/ and
* data/local-assets.json (when present). `missing`: listed files not on disk; `orphans`: files under public/assets
* that nothing lists (left out).
* that nothing lists (left out); `held`: listed files the zip holds back on purpose — the JP voice dub when `jpVoice`
* (FULL_ZIP_JP_VOICE) is off: neither shipped nor missing, and never deleted by an update.
*/
export function artPlan(root, { lite = false } = {}) {
if (lite) return { files: [], missing: [], orphans: [], local: false };
export function artPlan(root, { lite = false, jpVoice = FULL_ZIP_JP_VOICE } = {}) {
if (lite) return { files: [], missing: [], orphans: [], held: [], local: false };
const listed = manifestFiles(readJson(path.join(root, 'data', 'assets.json')) ?? {});
const local = isFile(path.join(root, 'data', 'local-assets.json'));
if (local) for (const f of manifestFiles(readJson(path.join(root, 'data', 'local-assets.json')) ?? {})) listed.add(f);
const files = new Set();
const missing = [];
const held = [];
for (const f of listed) {
if (isFile(path.join(root, f))) files.add(f);
if (!jpVoice && f.startsWith(JP_VOICE_DIR)) held.push(f);
else if (isFile(path.join(root, f))) files.add(f);
else missing.push(f);
}
for (const f of listTree(root, 'public/fonts')) files.add(f);
@@ -224,9 +240,9 @@ export function artPlan(root, { lite = false } = {}) {
files.add('data/local-assets.json');
}
// without case: on Windows / macOS a file whose name differs only in case is the listed one (fetch-assets orphanFiles)
const lower = new Set([...files].map((f) => f.toLowerCase()));
const lower = new Set([...files, ...held].map((f) => f.toLowerCase()));
const orphans = listTree(root, 'public/assets').filter((f) => !lower.has(f.toLowerCase()));
return { files: [...files].sort(), missing: missing.sort(), orphans, local };
return { files: [...files].sort(), missing: missing.sort(), orphans, held: held.sort(), local };
}
const URL_MOUNTS = [['/data/', 'data/'], ['/shared/', 'shared/'], ['/sim/', 'server/sim/']];
@@ -335,13 +351,14 @@ function groupOf(rel) {
* Everything a package would hold, with every check: no copying, no install. `scan: false` skips the personal-info
* scan and `measure: false` the node_modules / public/vendor sizes (tests that only want the selection).
* @param {string} root
* @param {{ lite?: boolean, paths?: string[], allowDirty?: boolean, env?: object, scan?: boolean, measure?: boolean }} [opts]
* @param {{ lite?: boolean, paths?: string[], allowDirty?: boolean, env?: object, scan?: boolean, measure?: boolean,
* jpVoice?: boolean }} [opts] jpVoice: the full zip's JP dub (default FULL_ZIP_JP_VOICE)
*/
export function plan(root, opts = {}) {
const lite = !!opts.lite;
const all = (opts.paths || trackedFiles(root)).map(posixRel);
const { keep, drop } = selectTracked(all);
const art = artPlan(root, { lite });
const art = artPlan(root, { lite, jpVoice: opts.jpVoice ?? FULL_ZIP_JP_VOICE });
const pkg = readJson(path.join(root, 'package.json')) || {};
const lock = readJson(path.join(root, 'package-lock.json')) || {};
const generated = shipsPacks(keep) ? [GENERATED_PACK_INDEX] : [];
@@ -383,7 +400,7 @@ export function plan(root, opts = {}) {
const dropBytes = Object.values(dropped).reduce((n, g) => n + g.bytes, 0);
return {
version: pkg.version || '0.0.0', lite, tracked: keep, art: art.files, generated, files, dropped, orphans: art.orphans,
localArt: art.local, problems, names: names.length, scanned,
held: art.held, localArt: art.local, problems, names: names.length, scanned,
bytes: {
tracked: trackedBytes, art: artBytes, modules: modulesBytes, vendor: vendorBytes,
total: trackedBytes + artBytes + modulesBytes + vendorBytes,
@@ -409,6 +426,7 @@ export function formatSummary(p) {
const groups = Object.entries(p.dropped).sort((a, b) => b[1].bytes - a[1].bytes);
if (groups.length) lines.push(`left out: ${groups.map(([g, v]) => `${g} ${v.files} (${MB(v.bytes)})`).join(', ')}`);
if (!p.lite && p.orphans.length) lines.push(`left out art: ${p.orphans.length} files under public/assets that nothing lists (${MB(p.bytes.orphans)})`);
if (!p.lite && p.held?.length) lines.push(`held back: ${p.held.length} files of the JP voice dub (FULL_ZIP_JP_VOICE off: setup downloads them on the first start)`);
lines.push(`personal-info scan: ${p.scanned} files, ${p.names ? `home paths + ${p.names} account name(s)` : 'home paths only (no account name to refuse)'}`);
lines.push(p.problems.length ? `PROBLEMS (${p.problems.length}):\n${p.problems.map((x) => ` ${x}`).join('\n')}` : 'checks: ok');
return lines.join('\n') + '\n';
@@ -598,7 +616,9 @@ export function buildUpdate(root, p, bases, { out, install = true, force = false
}
}
// a path no update deletes (a per-machine name such as node_modules/x/.env.example) stays behind, named in the summary
const diff = diffBases(next, bases, { removable: (rel) => !removalProblem(rel) });
// …nor a file this zip holds back on purpose (the JP dub with FULL_ZIP_JP_VOICE off): the manifest still lists it
const heldBack = new Set(p.held || []);
const diff = diffBases(next, bases, { removable: (rel) => !removalProblem(rel) && !heldBack.has(rel) });
for (const rel of diff.left) {
const bad = pathProblem(rel);
if (bad) throw new Error(`a base ships ${rel}, which is ${bad}`);
+4 -4
View File
@@ -8,7 +8,7 @@
// 2. Dependencies: `npm ci` (falls back to `npm install`) when node_modules is missing or incomplete.
// 3. Client libraries in public/vendor (tools/vendor.mjs) when any is missing.
// 4. Game data (data/*.json, committed) present and parseable.
// 5. Art/audio (tools/fetch-assets.mjs, ~460 MB into public/assets, resumable, mirror fallback) when public/assets
// 5. Art/audio (tools/fetch-assets.mjs, ~550 MB into public/assets, resumable, mirror fallback) when public/assets
// is missing or data/assets.json lists files that are not on disk. A failure is a warning: the game still runs
// with fallback visuals and the next run resumes.
// 6. Optional: official board/UI art from a locally installed Arknights client (Windows native install, CrossOver
@@ -76,9 +76,9 @@ export const nodeMajor = () => Number(process.versions.node.split('.')[0]);
const exists = (p) => { try { fs.accessSync(p); return true; } catch { return false; } };
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
const mb = (n) => `${(n / 1048576).toFixed(0)} MB`;
/** The art download when data/assets.json gives no size (missing, unreadable or without stats.bytes): the 0.2.0
* manifest's stats.bytes, 463 MB, as README and docs/DEPLOY.md quote it. */
const ART_DOWNLOAD_FALLBACK = '460 MB';
/** The art download when data/assets.json gives no size (missing, unreadable or without stats.bytes): the 0.2.2
* manifest's stats.bytes, 548 MB (both voice dubs; 463 MB before the JP one), as README and docs/DEPLOY.md quote it. */
const ART_DOWNLOAD_FALLBACK = '550 MB';
/** Terminal display width (CJK / full-width characters take two columns). */
export const displayWidth = (s) => [...String(s)].reduce((n, ch) => n + (ch.codePointAt(0) >= 0x2e80 ? 2 : 1), 0);
export const padDisplay = (s, w) => s + ' '.repeat(Math.max(0, w - displayWidth(s)));