feedback5: merge W5H — 自选 data (72 owned 6★ without the 6 collab operators, every DIY form, summons, prototypes' locked skills), shared/diy.js, the sim fields DIY picks, OPERATOR_KIT_FILES with 推进之王 as the first operator kit, golden family diy, the art plan (--add-only)

Conflicts: tools/golden.mjs / test/golden/README.md take both new families (standins from W5G, diy from W5H); DATA.md / SIM.md keep both texts. data/ equals an offline build of the merged tools/build-data.mjs. Golden regenerated on the merged code: against feedback5 nothing moved, diy.json is new; against fb5-diy the W5G / W5J / W5L scenarios moved, and diy-004 / diy-006 (the prototype battles with Touch, changed by W5L) — diy-001/002/003/005 identical.
This commit is contained in:
sganggs committed 2026-10-05 22:52:24 +08:00
commit 1006254beb
32 files changed
+2229 -174

No files matched your search

+10 -3
View File
@@ -10,7 +10,8 @@
// - Idempotent: an existing file is kept when its size matches the ledger entry
// of a previous download or the expected byte count from research, or (when
// neither is known) when it passes format validation. The ledger lives in
// .cache/assets-ledger.json.
// .cache/assets-ledger.json. `keepExisting` (fetch-assets --add-only) keeps
// every existing file as it is; `written` = the files this run wrote.
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
@@ -39,17 +40,21 @@ export class Downloader {
* @param {number} [o.retries]
* @param {number} [o.timeoutMs]
* @param {boolean} [o.force] re-download even when files exist
* @param {boolean} [o.keepExisting] never re-download or rewrite an existing file (a checkout sharing public/assets)
* @param {(msg:string)=>void} [o.log]
* @param {typeof fetch} [o.fetchImpl]
* @param {number} [o.backoffMs] base retry delay (doubles per attempt)
*/
constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400 }) {
constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, keepExisting = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400 }) {
this.root = root;
this.ledgerPath = ledgerPath;
this.concurrency = Math.max(1, Math.min(64, Number(concurrency) || 16));
this.retries = Math.max(1, Number(retries) || 3);
this.timeoutMs = timeoutMs;
this.force = force;
this.force = force && !keepExisting;
this.keepExisting = !!keepExisting;
/** @type {Set<string>} the files (paths under root) this run wrote */
this.written = new Set();
this.log = log;
this.fetch = fetchImpl;
this.backoffMs = Math.max(0, Number(backoffMs) || 0);
@@ -87,6 +92,7 @@ export class Downloader {
let st;
try { st = await stat(abs); } catch { return -1; }
if (!st.isFile() || st.size <= 0) return -1;
if (this.keepExisting) return st.size;
const led = this.ledger.files[job.rel];
if (job.mutable) {
// Post-processed files (atlases) change size; validate content instead.
@@ -162,6 +168,7 @@ export class Downloader {
return { status: 'error', bytes: 0, error: `write failed: ${e.message}` };
}
this.ledger.files[job.rel] = { url: src, bytes: r.buf.length };
this.written.add(job.rel);
this.dirty++;
const sizeChanged = !!(job.bytes && job.bytes !== r.buf.length && !job.mutable);
return { status: 'ok', bytes: r.buf.length, url: src, sizeChanged };
+52 -7
View File
@@ -11,7 +11,10 @@
// audio_data.json and Ark-Models' models_data.json.
//
// Scope (research 07 §1, DESIGN §0): all 138 pool charIds (incl. backup
// operators), the 20 pool tokens, every enemy that can appear in an
// operators), the 自选 owned-6★ picks of data/backups.json (`extraOperators`:
// research 07 does not list them — their URLs follow its patterns) and their
// summons, the module type icons of every module the data offers
// (`moduleTypes`), the 20 pool tokens, every enemy that can appear in an
// act2autochess match (07 enemy list ∪ act1autochess wave/boss levels used by
// act2 modes ∪ bosses ∪ their summons ∪ enemy units spawned by operator kits),
// the 23 bonds, 59 shop items, 40 bands,
@@ -165,6 +168,32 @@ function walkKeys(node, add) {
}
}
/**
* A research-07-shaped operator record of a character research 07 does not list (the 自选 owned-6★ picks): every URL
* from the patterns of 07-assets.json `meta.patterns` — avatar / portrait (E0–E1 and E2), the default-skin battle Spine
* Front / Back, the skill icons, the sub-profession icon. No expected byte counts (the downloader validates the files);
* a file the mirrors lack is a miss the client falls back from (the E2 art to the E0–E1 one, a missing Back to Front).
* @param {string} id charId
* @param {{ subProfessionId?: string|null, nationId?: string|null, skills?: Array<{ index: number, skillId: string, iconId?: string|null }> }} x
*/
export function patternOperator(id, x = {}) {
const sp = (side) => {
const b = `${RAW.fexli}spine/${id}/${id}/${side}/${id}`;
return { skel: `${b}.skel`, atlas: `${b}.atlas`, png: `${b}.png` };
};
return {
name: x.name ?? null, subProfessionId: x.subProfessionId ?? null, nationId: x.nationId ?? null, chess: [],
avatar: { e0e1: { url: `${RAW.yuanyan}avatar/${id}.png` }, e2: { url: `${RAW.yuanyan}avatar/${id}_2.png` } },
portrait: { e0e1: { url: `${RAW.yuanyan}portrait/${id}_1.png` }, e2: { url: `${RAW.yuanyan}portrait/${id}_2.png` } },
skills: (x.skills || []).map((s) => ({
index: s.index, skillId: s.skillId, iconId: s.iconId || s.skillId,
icon: { url: `${RAW.yuanyan}skill/skill_icon_${encodeURIComponent(s.iconId || s.skillId)}.png` },
})),
battleSpine: { front: sp('Front'), back: sp('Back'), note: null },
subProfessionIcon: x.subProfessionId ? joinUrl(RAW.aa2, `arts/ui/subprofessionicon/sub_${x.subProfessionId}_icon.png`) : null,
};
}
// ---------------------------------------------------------------------------
// id sets
@@ -249,9 +278,14 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
* @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localEnemySpines] metadata of the enemy models the
* local client has (the committed tools/assets/local-enemy-spines.json, never the disk): each planned enemy listed
* gets `spineLocal` = { group: 'spine/enemy/<id>', ...meta } beside its web `spine`
* @param {Record<string, any>} [p.extraOperators] charId → { subProfessionId, nationId, skills: [{ index, skillId, iconId }] }
* of characters research 07 does not list (the 自选 owned-6★ picks, data/backups.json `units`): planned like the pool
* operators from patternOperator
* @param {string[]} [p.moduleTypes] module type icon ids (`typeIcon`, e.g. 'sol-x') → manifest `modules[typeIcon]`, the
* official type icon (arts/ui/uniequiptype) the 干员调配 / 自选 module tiles draw when the local-client art lacks it
* @returns {{ template: any, models: Map<string, any>, notes: string[] }}
*/
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {} }) {
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {}, extraOperators = {}, moduleTypes = [] }) {
const notes = [];
/** @type {Map<string, any>} */
const models = new Map();
@@ -280,9 +314,13 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
const skills = {};
const skillsById = {};
const unitsSfx = {};
const charIds = Object.keys(assets07?.operators || {}).sort();
const known = assets07?.operators || {};
const extraOps = {};
for (const [id, x] of Object.entries(extraOperators || {})) if (/^char_\d+_[a-z0-9]+$/i.test(id) && !known[id]) extraOps[id] = patternOperator(id, x);
const operators = { ...known, ...extraOps };
const charIds = Object.keys(operators).sort();
for (const id of charIds) {
const o = assets07.operators[id];
const o = operators[id];
// DESIGN §16 operator loadouts: any skill of the character can be equipped — the icons, skill SFX and Spine skill
// clips of every skill index (the pool's primary index first, as before)
const idx0 = skillIdx.get(id) || [0];
@@ -448,7 +486,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
for (const p of [...PROFESSIONS, 'token']) {
prof.battlecard[p] = leaf(alt(`prof/battlecard_${p}.png`, joinUrl(RAW.aa2, `arts/ui/[uc]battlecommon/ui_battle_new/battlecard/icon_profession_${p}.png`)));
}
for (const o of Object.values(assets07?.operators || {})) {
for (const o of Object.values(operators)) {
const sub = o.subProfessionId;
if (typeof sub === 'string' && sub && !prof.sub[sub] && o.subProfessionIcon) prof.sub[sub] = leaf(alt(`prof/sub/${safeName(sub)}.png`, o.subProfessionIcon));
}
@@ -463,7 +501,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
for (const [group, entries] of Object.entries(assets07?.autochessUi || {})) {
for (const [key, url] of Object.entries(entries || {})) addUi(group, key, url);
}
const nations = new Set(Object.values(assets07?.operators || {}).map((o) => o.nationId).filter(Boolean));
const nations = new Set(Object.values(operators).map((o) => o.nationId).filter(Boolean));
const logos = new Set(['logo_rhodes', ...[...nations].map((n) => `logo_${n}`)]);
for (const b of Object.values(assets07?.bonds || {})) if (b.fallbackCampLogo) logos.add(urlBase(b.fallbackCampLogo).replace(/\.png$/i, ''));
for (const [src, group] of Object.entries(ARTS_GROUPS)) {
@@ -517,8 +555,15 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
const sfxBattle = {};
for (const [name, spec] of Object.entries(BATTLE_SFX)) { const l = soundLeaf(resolveSpec(spec, audio.bank)); if (l) sfxBattle[name] = l; else notes.push(`battle SFX ${name}: no sound`); }
// --- module type icons ----------------------------------------------------
const modules = {};
for (const t of [...new Set(moduleTypes || [])].filter((x) => typeof x === 'string' && /^[a-z0-9-]+$/i.test(x)).sort()) {
// the client's file name, else its lower-case form (the type id of a few modules is mixed case: WAH-Y → wah-y.png)
modules[t] = leaf(alt(`module/${safeName(t)}.png`, [...new Set([t, t.toLowerCase()])].map((n) => joinUrl(RAW.aa2, `arts/ui/uniequiptype/${n}.png`))));
}
const template = {
chars, enemies, tokens, bonds, items, bands, skills, skillsById, ui, prof,
chars, enemies, tokens, bonds, items, bands, skills, skillsById, modules, ui, prof,
audio: { bgm, bossBgm: Object.fromEntries(Object.entries(bossBgm).sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true }))), sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx } },
};
return { template, models, notes };
+5 -1
View File
@@ -123,10 +123,12 @@ export async function loadLocalEnemySpines(path) {
* @param {import('./downloader.mjs').Downloader} o.dl
* @param {string} o.cachePath parse cache JSON path
* @param {boolean} [o.download] false = only post-process what is on disk
* @param {(rel: string) => boolean} [o.writable] whether a file on disk may be rewritten (normalized atlas) or deleted
* (corrupt skeleton); fetch-assets --add-only allows only the files this run downloaded
* @param {(m:string)=>void} [o.log]
* @returns {Promise<{ entries: Map<string, SpineEntry>, problems: string[] }>}
*/
export async function processModels(models, { root, dl, cachePath, download = true, log = console.log }) {
export async function processModels(models, { root, dl, cachePath, download = true, writable = () => true, log = console.log }) {
const problems = [];
const list = [...models.values()];
if (download) {
@@ -180,6 +182,7 @@ export async function processModels(models, { root, dl, cachePath, download = tr
const norm = normalizeAtlas(atlasText, { pageSize: (p) => sizes.get(p) || null, pma: !!m.pma, renamePage: (p) => safeName(p) });
if (norm.missingSize.length) { problems.push(`${m.key}: cannot size pages ${norm.missingSize.join(',')}`); continue; }
if (norm.changed) {
if (!writable(m.atlas.rel)) { problems.push(`${m.key}: atlas needs normalizing, left as it is (an existing file)`); continue; }
await writeFile(atlasAbs + '.tmp', norm.text);
await rename(atlasAbs + '.tmp', atlasAbs);
}
@@ -196,6 +199,7 @@ export async function processModels(models, { root, dl, cachePath, download = tr
} catch (e) {
// A corrupt skeleton would otherwise be kept forever (its size matches the ledger):
// delete it and forget it so the next online run downloads it again.
if (!writable(m.skel.rel)) { problems.push(`${m.key}: skel parse failed (${e.message}); an existing file, left as it is`); continue; }
problems.push(`${m.key}: skel parse failed (${e.message}); deleted, re-run to re-download`);
try { await unlink(skelAbs); } catch { /* ignore */ }
if (dl?.ledger?.files) delete dl.ledger.files[m.skel.rel];
+207 -55
View File
@@ -1071,6 +1071,21 @@ function buildChess(ctx) {
*/
const DIY_EXTRA_PROTOTYPES = Object.freeze({ 5: Object.freeze({ rarity: 4, excludedProfessions: Object.freeze(['PIONEER', 'SPECIAL']) }) });
/**
* 自选 owned picks left out of the data and the pool by the owner's decision of 2026-10-05: the collab operators
* (copyright) — every 6★ whose `mainPower` or a `subPower` names one of these 联动 teams: rainbow (灰烬, 艾拉), action4
* (麒麟R夜刀), mujica (丰川祥子), sees (结城理), laios (玛露西尔). The excel does not exclude them (research 0.2.0 §2.2).
*/
const DIY_EXCLUDED_TEAMS = Object.freeze(['rainbow', 'action4', 'mujica', 'sees', 'laios']);
/**
* The skill a prototype carries in a 自选 slot when no 补位 row of the slot's tier names it (only 预备干员-医疗 at tier 5:
* it stands in at tier 3 only). PRTS 卫戍协议 says the prototypes' "技能携带规则与系统补位时一致"; [ASSUMED] (the owner's
* decision of 2026-10-05) the selection of its 补位 rows at that tier, and for this one S3 — every other 4★ reserve's
* tier-5 row — with no module.
*/
const DIY_PROTOTYPE_FALLBACK_SKILL = 2;
const rarityOf = (char) => Number(String(char?.rarity).replace('TIER_', '')) || null;
const statusCmp = (a, b) => a.phase - b.phase || a.level - b.level || a.skillLevel - b.skillLevel || a.equipLevel - b.equipLevel;
@@ -1173,16 +1188,21 @@ function buildUnitForm(ctx, charId, status, { chessId = null, skillIndex = null
/**
* data/backups.json (DATA.md §18; DESIGN 0.2.0 draft): the data of 补位 and 自选.
* - `units`: every character a NORMAL chess names as `backup.charId` — the 17 原型干员 (预备干员 char_600–607, the 6★
* 罗德岛特派高级资深干员 char_608–615 and 领主·Sharp char_617) — with a form for every status it fights at: the statuses
* of the chess it stands in for (normal and elite) and of the DIY slots it may fill. No unit for a PRESET chess
* (特许: always the real operator; its backup is itself) or a DIY slot (no backup).
* - `units`: every character a 补位 or 自选 piece fields, with a form for every status it fights at. First the 17
* 原型干员 a NORMAL chess names as `backup.charId` (预备干员 char_600–607, the 6★ 罗德岛特派高级资深干员 char_608–615 and
* 领主·Sharp char_617) at the statuses of the chess it stands in for (normal and elite) and of the DIY slots it may fill;
* then every owned-6★ pick (`diy.ownedPool`) at the DIY slot statuses — E2 Lv1 skill rank 4 without a module, E2 Lv60
* rank 7 with every module at stage 1 (tier 5) and at stage 3 (tier 6). No unit for a PRESET chess (特许: always the
* real operator; its backup is itself) or a DIY slot (no backup).
* - `tokens`: the summons of the owned picks (buildDiyTokens: tokens.json records whose `variants` are keyed by owner
* form, `<charId>@<statusKey>`).
* - `diy`: the slots (tier, elite id, the shop level that lists them, the rarity requirement), the prototype picks per
* slot tier (DIY_EXTRA_PROTOTYPES), the owned-6★ pool (obtainable, not a roster operator: no chess names it, hidden
* chess included — "不可甄选加入已在名单中的固定干员") and, for every pick, its faction ids (`mainPower` and every
* `subPower`: "依据其「所属势力」「隐藏势力」等属性决定其盟约") and the core bonds whose `powerIdList` meets them, else
* `constData.fallbackBondId` (协防干员) — PRTS 「卫戍协议」 "甄选加入的干员会根据其实际阵营所属分配核心盟约,若没有可匹配的
* 则改为分配协防干员盟约".
* slot tier (DIY_EXTRA_PROTOTYPES) and the skill / module each carries there (`locked`: its 补位 rows' selection at
* that tier — "技能携带规则与系统补位时一致"), the owned-6★ pool (obtainable, not a roster operator: no chess names it,
* hidden chess included — "不可甄选加入已在名单中的固定干员" — and not a collab, DIY_EXCLUDED_TEAMS: `excluded`) and, for
* every pick, its faction ids (`mainPower` and every `subPower`: "依据其「所属势力」「隐藏势力」等属性决定其盟约") and the
* core bonds whose `powerIdList` meets them, else `constData.fallbackBondId` (协防干员) — PRTS 「卫戍协议」
* "甄选加入的干员会根据其实际阵营所属分配核心盟约,若没有可匹配的则改为分配协防干员盟约".
*/
function buildBackups(ctx, chess) {
const { act, ac, charTable } = ctx;
@@ -1207,12 +1227,15 @@ function buildBackups(ctx, chess) {
for (const d of Object.values(act.shopLevelDisplayDataDict || {})) for (const id of d.charChessDiySlotIdList || []) shopLevelOf[id] = d.shopLevel;
const slots = {};
const prototypes = {};
const locked = {};
const requirements = new Set();
const diyStatuses = new Map(); // statusKey → status of every DIY slot record (both forms)
for (const s of Object.values(chess).filter((c) => c.isDiy && !c.isGolden).sort((x, y) => naturalCmp(x.chessId, y.chessId))) {
const golden = chess[s.goldenId];
if (!golden) continue;
requirements.add(s.diyRequirement);
slots[s.chessId] = { tier: s.tier, goldenId: s.goldenId, shopLevel: shopLevelOf[s.chessId] ?? null, requirement: s.diyRequirement };
for (const rec of [s, golden]) diyStatuses.set(statusKey(rec.status), rec.status);
const extra = DIY_EXTRA_PROTOTYPES[s.tier];
const picks = standInIds.filter((id) => {
const ch = charTable[id];
@@ -1221,27 +1244,48 @@ function buildBackups(ctx, chess) {
if (prototypes[s.tier] && JSON.stringify(prototypes[s.tier]) !== JSON.stringify(picks)) warn(`DIY tier ${s.tier}: slots disagree on the prototype picks`);
prototypes[s.tier] = picks;
for (const id of picks) { addNeed(id, s.status); addNeed(id, golden.status); }
// the skill / module a prototype carries in a slot of this tier: its 补位 rows' (the NORMAL base chess of the tier
// whose backup it is), else DIY_PROTOTYPE_FALLBACK_SKILL without a module [ASSUMED]
locked[s.tier] = {};
for (const id of picks) {
const rows = Object.values(chess).filter((c) => c.chessType === 'NORMAL' && !c.isGolden && c.tier === s.tier && c.backup?.charId === id)
.sort((x, y) => naturalCmp(x.chessId, y.chessId));
const kinds = new Set(rows.map((c) => `${c.backup.skillIndex}|${c.backup.uniEquipId ?? ''}`));
if (kinds.size > 1) warn(`DIY tier ${s.tier} prototype ${id}: its 补位 rows carry different selections (${[...kinds].join(', ')}); the first row's is used`);
locked[s.tier][id] = rows.length
? { skillIndex: rows[0].backup.skillIndex, uniEquipId: rows[0].backup.uniEquipId ?? null, from: rows.map((c) => c.chessId) }
: { skillIndex: DIY_PROTOTYPE_FALLBACK_SKILL, uniEquipId: null, from: [] };
}
}
const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean));
const teamsOf = (ch) => [ch.mainPower?.teamId ?? ch.teamId, ...(ch.subPower || []).map((p) => p?.teamId)].filter(Boolean);
const excludedTeams = new Set(DIY_EXCLUDED_TEAMS);
const legal6 = Object.keys(charTable).filter((id) => {
const ch = charTable[id];
return id.startsWith('char_') && requirements.has(ch.rarity) && ch.profession !== 'TOKEN' && ch.profession !== 'TRAP'
&& !ch.isNotObtainable && !roster.has(id);
}).sort(naturalCmp);
const excluded = legal6.filter((id) => teamsOf(charTable[id]).some((t) => excludedTeams.has(t)));
for (const t of DIY_EXCLUDED_TEAMS) if (!excluded.some((id) => teamsOf(charTable[id]).includes(t))) warn(`DIY_EXCLUDED_TEAMS: no owned-6★ pick of team ${t}`);
const ownedPool = legal6.filter((id) => !excluded.includes(id));
for (const id of ownedPool) for (const st of diyStatuses.values()) addNeed(id, st);
const units = {};
for (const id of [...need.keys()].sort(naturalCmp)) {
for (const id of [...standInIds, ...ownedPool]) {
const unit = buildUnitHead(ctx, id);
unit.standsIn = (standsIn.get(id) || []).sort(naturalCmp);
unit.forms = {};
for (const st of [...need.get(id).values()].sort(statusCmp)) {
const form = buildUnitForm(ctx, id, st);
if (form.tokens.length) warn(`unit ${id}@${statusKey(st)}: summons ${form.tokens.join(', ')} have no tokens.json variant for a stand-in owner`);
if (form.tokens.length && unit.standsIn.length) warn(`unit ${id}@${statusKey(st)}: summons ${form.tokens.join(', ')} have no tokens.json variant for a stand-in owner`);
unit.forms[statusKey(st)] = form;
}
units[id] = unit;
}
for (const id of need.keys()) if (!units[id]) warn(`unit ${id}: needed but neither a stand-in nor an owned pick`);
const tokens = buildDiyTokens(ctx, units, ownedPool);
const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean));
const ownedPool = Object.keys(charTable).filter((id) => {
const ch = charTable[id];
return id.startsWith('char_') && requirements.has(ch.rarity) && ch.profession !== 'TOKEN' && ch.profession !== 'TRAP'
&& !ch.isNotObtainable && !roster.has(id);
}).sort(naturalCmp);
const coreBonds = Object.values(ac.bondInfoDict || {}).filter((b) => b.isPower && (b.powerIdList || []).length);
const fallback = act.constData.fallbackBondId;
const operators = {};
@@ -1257,7 +1301,7 @@ function buildBackups(ctx, chess) {
obtainable: !ch.isNotObtainable, powers, bonds: hit.length ? hit : [fallback],
};
}
return { units, diy: { slots, prototypes, ownedPool, operators } };
return { units, tokens, diy: { slots, prototypes, locked, ownedPool, excluded, operators } };
}
/**
@@ -1380,14 +1424,130 @@ const TOKEN_ABNORMAL = Object.freeze({
enemy_9012_acloon: ['isolated'], // “炎佑”
});
/**
* The tokens.json record of a summon (buildTokens; the 自选 picks' summons, buildDiyTokens) from its per-owner
* `variants` (key → variant; `owners` = the keys, in order; the defaults are the first owner's variant).
* Hand cards placed during the prep phase (`placeable`) are the MANUALLY DEPLOYABLE summons (PRTS 卫戍协议/帮助
* §战斗部署: "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区"; user playtest #6): the shop
* state's tokenDisplayType DEFAULT — 赫默's 医疗探机 and 巫恋's 诅咒娃娃 (skill summons) as well as 浊心斯卡蒂's 海嗣,
* 伺夜's 狼群 and 缪尔赛思's 流形 (talent summons) — and a summon the shop state does not list at all: 凯瑟琳's
* 爬行号·防护单元 (talent "携带3个支援装置(最多部署2个)", deployed by hand in the base game; a friend of the user:
* placed by hand officially; confirmed by the user after playtest #6 — DESIGN §20). It is the only pool summon
* missing from shopStateTokenDict (every other one is listed, as are newer tokens such as 10040, 10042, 10043,
* 10055–10058, 10065), so no entry is read as the default display. HIDDEN tokens exist in battle only (e.g.
* 投递坐标 — PRTS: "携带技能【使命必达!】的新约能天使,不会提供所属召唤物"). Only a token its owner actually makes
* (`produced`: a talent or a skill of some loadout, `sources`) is a card; which loadouts make it is per variant
* (`sources`, `bySkill[i].sources`: 赫默 / 巫恋 on S1 get none). In battle a skill's summon deploys once at the start, then
* takes its tile again each time the skill gives one (sim/content/tokens.js dockSkillSummons, shared/constants.js
* SKILL_SUMMON_START_DEPLOY).
* `ownerRange`: the token text "只能部署在召唤者攻击范围内" (the tacticians' 援军 — 伺夜's 狼群, 缪尔赛思's 流形; PRTS 狼群
* 特性): its hand piece may only be placed on a tile of its owner's attack range (server/match/board.js
* ownerRangeKeys, PlayerState._legal; player report #9 after 0.1.0: 伺夜's tactical point could go anywhere).
* `abnormal` = TOKEN_ABNORMAL (PRTS).
*/
function summonRecord(ctx, tokenId, char, variants, produced) {
const displayType = ctx.ac.shopStateTokenDict?.[tokenId]?.tokenDisplayType || null;
const owners = Object.keys(variants);
const first = variants[owners[0]];
const ownerRange = /只能部署在\S*攻击范围内/.test(stripRich(first.trait.desc) || '');
return {
tokenId, kind: 'summon', name: char.name, appellation: char.appellation || null,
desc: stripRich(first.trait.desc), descRaw: first.trait.descRaw,
profession: char.profession, subProfessionId: char.subProfessionId, position: char.position,
displayType, placeable: displayType !== 'HIDDEN' && produced, ownerRange,
owners,
// Defaults = first owner's variant; per-owner data in variants[owner].
stats: first.stats, rangeGrid: first.rangeGrid, dmgType: first.dmgType, attackKind: first.attackKind,
projectile: first.projectile, canHitFly: first.canHitFly,
skill: first.skill ? { skillId: first.skill.skillId, bb: first.skill.bb } : null,
deployLimit: first.stats?.deployLimit ?? 1,
count: first.count,
abnormal: TOKEN_ABNORMAL[tokenId] ? [...TOKEN_ABNORMAL[tokenId]] : [],
variants,
assets: { avatar: tokenId, spine: tokenId },
};
}
/**
* The summons of the 自选 owned picks (data/backups.json `tokens`, DATA.md §18): one summonRecord per token whose
* `variants` are keyed by the owner FORM, `<charId>@<statusKey>` (shared/diy.js diyTokenOwner), never by a chess id — a
* DIY piece is a slot, and two players may fill the same slot with different operators. A variant is the token at the
* owner's status (the owner's phase / level / skill level, as for a chess owner) for the owner's FIRST skill and no
* module; `bySkill[i]` the token skill, count and sources under each other skill, `byModule[id]` the module token
* attributes / trait / talents under each module of the form — the chess variant shape (DESIGN §16), applied with the
* pick as the owner's loadout (simdata getToken, content/tokens.js withLoadout). `count` / `sources` come from the
* owner's no-module talents and the selected skill, as buildChess's `tokenUse`.
* @param {object} units backups `units`
* @param {string[]} owned the owned-6★ picks (their forms are the DIY slot statuses)
*/
function buildDiyTokens(ctx, units, owned) {
const { charTable, battleEquip } = ctx;
const owners = new Map(); // tokenId → [{ key, variant, produced }]
const makes = (list) => list.includes('talent') || list.includes('skill');
for (const charId of owned) {
for (const [key, form] of Object.entries(units[charId]?.forms || {})) {
if (!form.tokens.length) continue;
const { phase, level, skillLevel, equipLevel } = form.status;
/** token id → its sources under `skill` (buildChess tokenUse), and the count a talent / the skill sends */
const tokenUse = (skill) => {
const skTok = skill?.overrideTokenKey || null;
const use = new Map();
const add = (id, src) => { if (!use.has(id)) use.set(id, new Set()); use.get(id).add(src); };
for (const id of form.displayTokens) add(id, 'display');
if (skTok) add(skTok, 'skill');
for (const t of form.talents) if (t.tokenKey) add(t.tokenKey, 'talent');
const count = (id) => {
const tal = form.talents.find((t) => t.tokenKey === id && typeof t.bb.cnt === 'number');
const skillCnt = skTok === id ? skill?.bb?.cnt : undefined;
return tal ? tal.bb.cnt : typeof skillCnt === 'number' ? skillCnt : null;
};
const sources = (id) => ['talent', 'skill', 'display'].filter((x) => use.get(id)?.has(x));
return { count, sources };
};
const [first, ...alts] = form.skills;
for (const tokenId of form.tokens) {
const tchar = charTable[tokenId];
const label = `token ${tokenId}@${charId}@${key}`;
const at = (o) => tokenVariant(ctx, tokenId, tchar, { phase, level, skillIndex: first.index, skillLevel, modulePhase: null, moduleTokenParts: [], label, ...o });
const u0 = tokenUse(first);
const v = { ...at({}), count: u0.count(tokenId), sources: u0.sources(tokenId) };
let produced = makes(v.sources);
if (alts.length) {
v.bySkill = {};
for (const s of alts) {
const u = tokenUse(s);
v.bySkill[s.index] = { skill: at({ skillIndex: s.index }).skill, count: u.count(tokenId), sources: u.sources(tokenId) };
produced = produced || makes(v.bySkill[s.index].sources);
}
}
if (equipLevel > 0 && form.modules?.length) {
v.byModule = {};
for (const m of form.modules) {
const ph = battleEquip[m.uniEquipId]?.phases?.find((p) => p.equipLevel === equipLevel) || null;
const tv = at({ modulePhase: ph, moduleTokenParts: splitModuleParts(ph).token });
v.byModule[m.uniEquipId] = { stats: tv.stats, immunities: tv.immunities, trait: tv.trait, talents: tv.talents };
}
}
if (!owners.has(tokenId)) owners.set(tokenId, []);
owners.get(tokenId).push({ key: `${charId}@${key}`, variant: v, produced });
}
}
}
const out = {};
for (const tokenId of [...owners.keys()].sort(naturalCmp)) {
const list = owners.get(tokenId);
out[tokenId] = summonRecord(ctx, tokenId, charTable[tokenId], Object.fromEntries(list.map((o) => [o.key, o.variant])), list.some((o) => o.produced));
}
return out;
}
/**
* Build data/tokens.json: summons of chess (per-owner variants), bond summons (炎佑) and band map
* characters (band_amedic 预备干员-医疗 / Touch). `abnormal` = TOKEN_ABNORMAL (PRTS).
*/
function buildTokens(ctx, chess, tokenOwners, enemies) {
const { charTable, ac } = ctx;
const { charTable } = ctx;
const out = {};
const displayType = (id) => ac.shopStateTokenDict?.[id]?.tokenDisplayType || null;
for (const tokenId of [...tokenOwners.keys()].sort(naturalCmp)) {
const char = charTable[tokenId];
const owners = tokenOwners.get(tokenId);
@@ -1417,42 +1577,9 @@ function buildTokens(ctx, chess, tokenOwners, enemies) {
}
}
}
const first = variants[owners[0].chessId];
// Hand cards placed during the prep phase (`placeable`) are the MANUALLY DEPLOYABLE summons (PRTS 卫戍协议/帮助
// §战斗部署: "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区"; user playtest #6): the shop
// state's tokenDisplayType DEFAULT — 赫默's 医疗探机 and 巫恋's 诅咒娃娃 (skill summons) as well as 浊心斯卡蒂's 海嗣,
// 伺夜's 狼群 and 缪尔赛思's 流形 (talent summons) — and a summon the shop state does not list at all: 凯瑟琳's
// 爬行号·防护单元 (talent "携带3个支援装置(最多部署2个)", deployed by hand in the base game; a friend of the user:
// placed by hand officially; confirmed by the user after playtest #6 — DESIGN §20). It is the only pool summon
// missing from shopStateTokenDict (every other one is listed, as are newer tokens such as 10040, 10042, 10043,
// 10055–10058, 10065), so no entry is read as the default display. HIDDEN tokens exist in battle only (e.g.
// 投递坐标 — PRTS: "携带技能【使命必达!】的新约能天使,不会提供所属召唤物"). Only a token its owner actually makes
// (a talent or a skill of some loadout, `sources`) is a card; which loadouts make it is per variant (`sources`,
// `bySkill[i].sources`: 赫默 / 巫恋 on S1 get none). In battle a skill's summon deploys once at the start, then
// takes its tile again each time the skill gives one (sim/content/tokens.js dockSkillSummons, shared/constants.js
// SKILL_SUMMON_START_DEPLOY).
const makes = (list) => (list || []).some((s) => s === 'talent' || s === 'skill');
const produced = owners.some((o) => makes(o.sources) || (o.skillAlts || []).some((a) => makes(a.sources)));
// `ownerRange`: the token text "只能部署在召唤者攻击范围内" (the tacticians' 援军 — 伺夜's 狼群, 缪尔赛思's 流形; PRTS 狼群
// 特性): its hand piece may only be placed on a tile of its owner's attack range (server/match/board.js
// ownerRangeKeys, PlayerState._legal; player report #9 after 0.1.0: 伺夜's tactical point could go anywhere)
const ownerRange = /只能部署在\S*攻击范围内/.test(stripRich(first.trait.desc) || '');
out[tokenId] = {
tokenId, kind: 'summon', name: char.name, appellation: char.appellation || null,
desc: stripRich(first.trait.desc), descRaw: first.trait.descRaw,
profession: char.profession, subProfessionId: char.subProfessionId, position: char.position,
displayType: displayType(tokenId), placeable: displayType(tokenId) !== 'HIDDEN' && produced, ownerRange,
owners: owners.map((o) => o.chessId),
// Defaults = first owner's variant; per-owner data in variants[chessId].
stats: first.stats, rangeGrid: first.rangeGrid, dmgType: first.dmgType, attackKind: first.attackKind,
projectile: first.projectile, canHitFly: first.canHitFly,
skill: first.skill ? { skillId: first.skill.skillId, bb: first.skill.bb } : null,
deployLimit: first.stats?.deployLimit ?? 1,
count: first.count,
abnormal: TOKEN_ABNORMAL[tokenId] ? [...TOKEN_ABNORMAL[tokenId]] : [],
variants,
assets: { avatar: tokenId, spine: tokenId },
};
out[tokenId] = summonRecord(ctx, tokenId, char, variants, produced);
}
// 炎佑 (yanShip 6-member summon) — allied flying unit built from its enemy template.
@@ -3495,9 +3622,32 @@ function validateAll(f) {
const slot = chess[id], golden = chess[s.goldenId];
if (!slot?.isDiy || slot.isGolden || slot.tier !== s.tier || !golden?.isDiy) { err(`DIY slot ${id}: not a DIY chess pair of tier ${s.tier}`); continue; }
if (!backups.diy.prototypes[s.tier]?.length) err(`DIY slot ${id}: no prototype picks`);
for (const p of backups.diy.prototypes[s.tier] || []) for (const rec of [slot, golden]) if (!unitForm(backups, p, rec.status)) err(`DIY slot ${rec.chessId}: prototype ${p} has no form ${statusKey(rec.status)}`);
for (const p of backups.diy.prototypes[s.tier] || []) {
for (const rec of [slot, golden]) if (!unitForm(backups, p, rec.status)) err(`DIY slot ${rec.chessId}: prototype ${p} has no form ${statusKey(rec.status)}`);
// the locked selection exists at both slot forms (the module at the elite's stage)
const lk = backups.diy.locked?.[s.tier]?.[p];
if (!lk) { err(`DIY slot ${id}: prototype ${p} has no locked selection`); continue; }
for (const rec of [slot, golden]) {
const f = unitForm(backups, p, rec.status);
if (f && !f.skills.some((x) => x.index === lk.skillIndex)) err(`DIY slot ${rec.chessId}: prototype ${p} has no skill ${lk.skillIndex} at ${statusKey(rec.status)}`);
if (f && lk.uniEquipId && rec.status.equipLevel > 0 && !f.modules.some((m) => m.uniEquipId === lk.uniEquipId)) err(`DIY slot ${rec.chessId}: prototype ${p} has no module ${lk.uniEquipId} at ${statusKey(rec.status)}`);
}
}
// every owned pick: a form at both slot statuses with every skill, and at the elite every module of the character
for (const p of backups.diy.ownedPool) {
const u = backups.units[p];
for (const rec of [slot, golden]) {
const f = unitForm(backups, p, rec.status);
if (!f) { err(`DIY slot ${rec.chessId}: owned pick ${p} has no form ${statusKey(rec.status)}`); continue; }
if (f.skills.length !== 3) err(`DIY slot ${rec.chessId}: owned pick ${p} has ${f.skills.length} skills at ${statusKey(rec.status)}`);
const ids = Object.keys(u.moduleNames).filter((m) => u.moduleNames[m].typeName !== 'ORIGINAL');
if (rec.status.equipLevel > 0 && JSON.stringify(f.modules.map((m) => m.uniEquipId)) !== JSON.stringify(ids)) err(`DIY slot ${rec.chessId}: owned pick ${p} modules ${f.modules.map((m) => m.uniEquipId)} ≠ ${ids}`);
for (const t of f.tokens) if (!backups.tokens?.[t]?.variants?.[`${p}@${statusKey(rec.status)}`]) err(`DIY pick ${p}@${statusKey(rec.status)}: summon ${t} has no variant in backups.json tokens`);
}
}
}
for (const [id, o] of Object.entries(backups.diy.operators)) for (const b of o.bonds) if (!bonds[b]) err(`DIY pick ${id}: bond ${b} missing`);
for (const id of Object.keys(backups.tokens || {})) if (tokens[id]) err(`backups.json token ${id} is also a tokens.json record`);
// the 重装 stand-ins' deviations (STANDIN_TRIGGER_DEVIATIONS) still override an official TAKE_DAMAGE row, on every form
for (const [charId, skillsOf] of Object.entries(STANDIN_TRIGGER_DEVIATIONS)) {
const forms = Object.values(backups.units[charId]?.forms || {});
@@ -3640,6 +3790,8 @@ async function main() {
tokens: Object.keys(tokens).length, factionEntries: Object.keys(factions.entries).length,
backupUnits: Object.keys(backups.units).length,
backupForms: Object.values(backups.units).reduce((n, u) => n + Object.keys(u.forms).length, 0),
backupTokens: Object.keys(backups.tokens).length,
diyOwnedPicks: backups.diy.ownedPool.length,
},
sizes, totalBytes: total, out: OPTS.out, written: write, warnings, errors,
};
+61 -15
View File
@@ -24,9 +24,21 @@
// the entries it would drop and exits 1; --allow-shrink (or --prune) writes the
// smaller manifest.
//
// Beyond research 07's ids the plan covers what data/*.json adds: spawnable
// enemies / tokens (data/enemies.json, data/tokens.json), the 自选 owned-6★ picks
// of data/backups.json `units` (art from 07's URL patterns) and their summons
// (`tokens`), and the module type icons of every module in data/chess.json /
// data/backups.json (manifest `modules`).
//
// --add-only: for a checkout whose public/assets / public/fonts are shared with
// another one (a git worktree): download only the files missing on disk, never
// re-download, rewrite or delete an existing file (atlases already on disk are
// left as they are; fonts are not rebuilt — the manifest keeps its current
// `fonts`).
//
// Usage: node tools/fetch-assets.mjs [--concurrency=16] [--force] [--offline]
// [--dry-run] [--refresh-index] [--prune]
// [--allow-shrink] [--local-spines] [--help]
// [--allow-shrink] [--add-only] [--local-spines] [--help]
import { readFile, writeFile, mkdir, rename, readdir, unlink } from 'node:fs/promises';
import { existsSync, realpathSync } from 'node:fs';
@@ -59,6 +71,8 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
(public/assets/local/** of tools/local-extract is never deleted); implies --allow-shrink
--allow-shrink write data/assets.json even when it loses entries the current one has
(without it such a run keeps the current manifest, lists the entries and exits 1)
--add-only download only files missing on disk; never re-download, rewrite or delete an existing
file, no font rebuild (a worktree sharing public/assets and public/fonts)
--local-spines rewrite ${LOCAL_ENEMY_SPINES_FILE} from the enemy models extracted
by tools/local-extract/extract.py (public/assets/local/spine/enemy/)
--help this text`;
@@ -66,10 +80,10 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
/**
* Parse CLI flags.
* @param {string[]} argv
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, allowShrink:boolean, localSpines:boolean, help:boolean}}
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, allowShrink:boolean, addOnly:boolean, localSpines:boolean, help:boolean}}
*/
export function parseArgs(argv) {
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, allowShrink: false, localSpines: false, help: false };
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, allowShrink: false, addOnly: false, localSpines: false, help: false };
for (const a of argv) {
const [k, v] = a.split('=');
if (k === '--concurrency') o.concurrency = Math.max(1, Math.min(64, parseInt(v, 10) || 16));
@@ -79,13 +93,38 @@ export function parseArgs(argv) {
else if (k === '--refresh-index') o.refreshIndex = true;
else if (k === '--prune') o.prune = true;
else if (k === '--allow-shrink') o.allowShrink = true;
else if (k === '--add-only') o.addOnly = true;
else if (k === '--local-spines') o.localSpines = true;
else if (k === '--help' || k === '-h') o.help = true;
else throw new Error(`unknown option ${a}\n${HELP}`);
}
if (o.addOnly && (o.prune || o.force)) throw new Error(`--add-only never deletes or rewrites files: not with --prune / --force\n${HELP}`);
return o;
}
/**
* What data/backups.json and data/chess.json add to the asset plan: `extraOperators` — every unit of backups.json
* (`{ name, subProfessionId, nationId, skills: [{ index, skillId, iconId }] }`; buildPlan plans those research 07 lacks,
* the 自选 owned-6★ picks), `tokenIds` — the summons of the 自选 picks (backups.json `tokens`), `moduleTypes` — the type
* icon of every module a chess or a unit form offers.
* @param {any} backups data/backups.json (or null)
* @param {any} chess data/chess.json (or null)
*/
export function dataExtras(backups, chess) {
const extraOperators = {};
const moduleTypes = new Set();
for (const [id, u] of Object.entries(backups?.units || {})) {
const skills = new Map();
for (const f of Object.values(u.forms || {})) {
for (const sk of f.skills || []) if (!skills.has(sk.index)) skills.set(sk.index, { index: sk.index, skillId: sk.skillId, iconId: sk.iconId || sk.skillId });
for (const m of f.modules || []) if (m.typeIcon) moduleTypes.add(m.typeIcon);
}
extraOperators[id] = { name: u.name, subProfessionId: u.subProfessionId, nationId: u.nationId, skills: [...skills.values()].sort((a, b) => a.index - b.index) };
}
for (const c of Object.values(chess || {})) for (const m of c?.modules || []) if (m.typeIcon) moduleTypes.add(m.typeIcon);
return { extraOperators, tokenIds: Object.keys(backups?.tokens || {}).sort(), moduleTypes: [...moduleTypes].sort() };
}
/**
* The shrink guard of data/assets.json (header): the entries of the current manifest `prev` that `next` would drop,
* and whether `next` may be written — always when nothing is dropped (or there is no current manifest), otherwise
@@ -156,6 +195,7 @@ function countStats(m, bytes, files) {
items: Object.keys(m.items || {}).length,
bands: Object.keys(m.bands || {}).length,
skills: Object.keys(m.skills || {}).length,
modules: Object.keys(m.modules || {}).length,
ui: Object.keys(m.ui || {}).length,
sfxUnits: Object.keys(m.audio?.sfx?.units || {}).length,
};
@@ -222,17 +262,20 @@ async function main() {
const audio = indexAudio(audioData);
// The game data built by tools/build-data.mjs (when present) may reference more
// spawnable enemies/tokens than research lists (e.g. 机变 enemy swaps): cover them too.
const [dataEnemies, dataTokens, dataBosses] = await Promise.all(
['data/enemies.json', 'data/tokens.json', 'data/bosses.json'].map((f) => readJson(f).catch(() => null)));
const [dataEnemies, dataTokens, dataBosses, dataBackups, dataChess] = await Promise.all(
['data/enemies.json', 'data/tokens.json', 'data/bosses.json', 'data/backups.json', 'data/chess.json'].map((f) => readJson(f).catch(() => null)));
const extras = dataExtras(dataBackups, dataChess);
const extraHandbook = {};
for (const b of Object.values(dataBosses || {})) if (b?.enemyKey && typeof b.handbookId === 'string') extraHandbook[b.enemyKey] = b.handbookId;
const localEnemySpines = await syncLocalEnemySpines(opts);
const plan = buildPlan({
assets07, ops03, enemies05, maps05, audio, modelsData,
extraEnemyIds: Object.keys(dataEnemies || {}),
extraTokenIds: Object.keys(dataTokens || {}),
extraTokenIds: [...Object.keys(dataTokens || {}), ...extras.tokenIds],
extraHandbook,
localEnemySpines,
extraOperators: extras.extraOperators,
moduleTypes: extras.moduleTypes,
});
const leaves = collectLeaves(plan.template);
log(`[plan] ${leaves.length} files + ${plan.models.size} Spine models ` +
@@ -246,26 +289,32 @@ async function main() {
const dl = new Downloader({
root: ASSETS, ledgerPath: join(CACHE, 'assets-ledger.json'),
concurrency: opts.concurrency, force: opts.force, log,
concurrency: opts.concurrency, force: opts.force, keepExisting: opts.addOnly, log,
});
await dl.loadLedger();
const downloadErrors = opts.offline ? [] : await downloadLeaves(leaves, dl, ASSETS, 'files');
// Fonts
let current = null;
if (existsSync(MANIFEST)) {
try { current = JSON.parse(await readFile(MANIFEST, 'utf8')); } catch (e) { log(`[manifest] the current ${relative(ROOT, MANIFEST)} is unreadable (${e.message}): replaced`); }
}
// Fonts (--add-only: not rebuilt — public/fonts may be another checkout's; the manifest keeps its current entry)
let fontErrors = [];
if (!opts.offline) {
if (!opts.offline && !opts.addOnly) {
const fdl = new Downloader({ root: FONTS, ledgerPath: join(CACHE, 'fonts-ledger.json'), concurrency: 4, force: opts.force, log });
await fdl.loadLedger();
await fdl.run(fontJobs(), 'fonts');
dl.totals.bytesDownloaded += fdl.totals.bytesDownloaded;
for (const k of ['ok', 'skip', 'miss', 'error']) dl.totals[k] += fdl.totals[k];
}
const fonts = await buildFonts(FONTS, log);
const fonts = opts.addOnly ? { files: {}, errors: [] } : await buildFonts(FONTS, log);
fontErrors = fonts.errors;
// Spine
const spine = await processModels(plan.models, {
root: ASSETS, dl, cachePath: join(CACHE, 'spine-info.json'), download: !opts.offline, log,
...(opts.addOnly ? { writable: (rel) => dl.written.has(rel) } : null),
});
// Manifest
@@ -274,7 +323,8 @@ async function main() {
tidyManifest(body);
const fontFaces = {};
for (const [name, f] of Object.entries(fonts.files)) fontFaces[name] = f;
body.fonts = existsSync(join(FONTS, 'fonts.css')) ? { css: '/fonts/fonts.css', faces: fontFaces } : { faces: fontFaces };
body.fonts = opts.addOnly && current?.fonts ? current.fonts
: existsSync(join(FONTS, 'fonts.css')) ? { css: '/fonts/fonts.css', faces: fontFaces } : { faces: fontFaces };
const bytes = totalBytes(ASSETS, resolved.files);
const manifest = {
version: MANIFEST_VERSION,
@@ -283,10 +333,6 @@ async function main() {
stats: countStats(body, bytes, resolved.files.size),
...body,
};
let current = null;
if (existsSync(MANIFEST)) {
try { current = JSON.parse(await readFile(MANIFEST, 'utf8')); } catch (e) { log(`[manifest] the current ${relative(ROOT, MANIFEST)} is unreadable (${e.message}): replaced`); }
}
const guard = shrinkGuard(current, manifest, opts);
if (guard.write) await writeJsonAtomic(MANIFEST, manifest);
+131 -2
View File
@@ -33,6 +33,10 @@
// standIn: true — DATA.md §18: the stand-in's body, its backup skill / module, its kit by charId; all 17
// stand-ins and every skill a chess names for them), 12 per battle by strength band, laid out by the stand-in's
// position on a real stage, against the round's real wave three times over, an item each, bonds from the board
// diy 自选 pieces (PlayerBattleInput `diy`, shared/diy.js): every owned 6★ with an operator kit (kits/index.js
// OPERATOR_KITS) in each form of tiers 5 and 6 — normal, elite with no module and with each module — under
// each of its skills, then every prototype pick with a kit at its locked selection, both forms of each tier;
// 12 pieces per battle on a real stage against the round's real wave three times over (diyScenarios)
// Battles run through the production BattleSpec path (server/sim/spec.js buildBattleSpec → createBattleFromSpec, the
// path browsers and the server's headless fields use) with every option explicit; matches construct Match directly
// with a VirtualScheduler (as tools/botbench.mjs) — test-harness defaults never move a digest.
@@ -69,11 +73,14 @@ import { computeBonds, bondSnapshot } from '../server/match/bondsMeta.js';
import { Match } from '../server/match/Match.js';
import { VirtualScheduler } from '../server/match/scheduler.js';
import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../shared/loadoutRecord.js';
import { diyRecordOf, DIY_TIERS } from '../shared/diy.js';
import { unitForm } from '../shared/standIn.js';
import { OPERATOR_KITS, KITTED_CHARS } from '../server/sim/content/kits/index.js';
import { GEO } from '../shared/constants.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
export const GOLDEN_DIR = join(ROOT, 'test', 'golden');
export const FAMILY_NAMES = Object.freeze(['roster', 'bonds', 'fields', 'matches', 'standins']);
export const FAMILY_NAMES = Object.freeze(['roster', 'bonds', 'fields', 'matches', 'standins', 'diy']);
const QUIET = Object.freeze({ warn() {}, error() {}, info() {}, log() {}, debug() {} });
const data = getData({ log: QUIET });
@@ -815,16 +822,138 @@ export function runMatch(cfg) {
return digest;
}
// ---------------------------------------------------------------------------------------------------------------
// family: diy (自选 pieces: a DIY slot fielded with its `diy` pick — simdata getDiy, shared/diy.js)
const DIY_SLOTS = data.backups?.diy?.slots ?? {};
/** The slot record of a tier's first slot, normal or elite (the forms every slot of the tier shares). */
const diySlotRec = (tier, elite) => {
const base = Object.keys(DIY_SLOTS).find((id) => DIY_SLOTS[id].tier === tier);
return base ? data.chess[elite ? DIY_SLOTS[base].goldenId : base] : null;
};
/**
* The 自选 configurations of the corpus, in a fixed order: every owned 6★ with an operator kit (OPERATOR_KITS, file
* order) in each form of both tiers — normal (no module: none is active), elite with no module and with each module of
* the form — under each skill; then every prototype pick with a kit (KITTED_CHARS: its stand-in kit, a 预备干员's generic
* kit) at its locked selection, normal and elite, per tier. `{ tier, elite, pick, group }`.
*/
function diyConfigs() {
const diy = data.backups?.diy;
if (!diy) return [];
const out = [];
for (const charId of Object.keys(OPERATOR_KITS)) {
if (!diy.ownedPool.includes(charId)) continue;
for (const tier of DIY_TIERS) {
for (const elite of [false, true]) {
const form = unitForm(data.backups, charId, diySlotRec(tier, elite)?.status);
if (!form) continue;
for (const uniEquipId of elite ? [null, ...(form.modules ?? []).map((m) => m.uniEquipId)] : [null]) {
for (const s of form.skills) out.push({ tier, elite, pick: { charId, skillIndex: s.index, uniEquipId }, group: 'operator' });
}
}
}
}
for (const tier of DIY_TIERS) {
for (const charId of diy.prototypes?.[tier] ?? []) {
if (!KITTED_CHARS.includes(charId)) continue;
for (const elite of [false, true]) out.push({ tier, elite, pick: { charId }, group: 'prototype' });
}
}
return out;
}
/**
* Lay 自选 pieces on a player's board like `layout` (melee — most blockers first — on the deployable tiles the enemy
* ground paths cross most, ranged ones beside the paths, every 7th one facing UP / LEFT / DOWN), their class read from
* the composed record (`w.rec`, shared/diy.js diyRecordOf). No summons are placed (the 自选 hand pieces come with the
* per-player shop).
*/
function diyLayout(stageId, wanted) {
const map = buildDeployMap(data.stages[stageId], { field: 'normal' });
const traffic = pathTraffic(stageId, 'normal', 0);
const pathList = [...traffic.keys()].map((k) => k.split(',').map(Number));
const dist = (r, c) => (pathList.length ? Math.min(...pathList.map(([pr, pc]) => Math.max(Math.abs(pr - r), Math.abs(pc - c)))) : 9);
const used = new Set();
const tiles = (cls) => {
const out = [];
for (let r = 12; r >= 9; r--) for (let c = 2; c <= 10; c++) if (!used.has(tileKey(r, c)) && canPlace(map, cls, r, c)) out.push([r, c]);
if (cls === 'melee') return out.sort((a, b) => (traffic.get(tileKey(b[0], b[1])) || 0) - (traffic.get(tileKey(a[0], a[1])) || 0));
const rank = (r, c) => { const d = dist(r, c); return d === 0 ? 1.5 : d; };
return out.sort((a, b) => rank(a[0], a[1]) - rank(b[0], b[1]));
};
const cls = (w) => positionClass(w.rec, w.diy.uniEquipId ?? null);
const order = wanted.slice().sort((a, b) => ((cls(b) === 'melee') - (cls(a) === 'melee')) || (cls(a) === 'melee' ? (b.rec.stats.blockCnt || 0) - (a.rec.stats.blockCnt || 0) : 0));
const units = [];
let uid = 1;
for (const w of order) {
const free = tiles(cls(w));
if (!free.length) continue;
const [r, c] = free[0];
used.add(tileKey(r, c));
const dir = units.length % 7 === 6 ? ['UP', 'LEFT', 'DOWN'][Math.floor(units.length / 7) % 3] : 'RIGHT';
units.push({ uid: uid++, kind: 'chess', chessId: w.chessId, diy: { ...w.diy }, row: r, col: c, dir, items: [] });
}
return units;
}
/**
* The diy family: the configurations (diyConfigs) 12 per battle, each on the next of its tier's two slots (the elite
* twin for an elite form); a real stage and mode in turn, the round of the strongest piece (BAND_ROUND), the round's
* real wave three times over, no bonds / items / band (the 自选 bonds come with the per-player roster). `pass` 0 = the
* first battle of each group (operator kits, prototypes): the fast subset.
*/
export function diyScenarios() {
const configs = diyConfigs();
const scenarios = [];
const firstOf = new Set();
for (let i = 0, n = 0; i < configs.length; i += 12, n++) {
const chunk = configs.slice(i, i + 12);
const stageId = STAGES[n % STAGES.length];
const modeId = ROSTER_MODES[n % ROSTER_MODES.length];
const gd = gdFor(modeId);
const turn = {};
const wanted = chunk.map((c) => {
const ids = Object.keys(DIY_SLOTS).filter((id) => DIY_SLOTS[id].tier === c.tier);
turn[c.tier] = (turn[c.tier] ?? -1) + 1;
const base = ids[turn[c.tier] % ids.length];
const chessId = c.elite ? DIY_SLOTS[base].goldenId : base;
const rec = diyRecordOf(data.chess[chessId], c.pick, data);
if (!rec) throw new Error(`diy: ${c.pick.charId} is no legal pick of ${chessId}`);
return { chessId, diy: c.pick, rec, band: c.tier + (c.elite ? 1 : 0) };
});
const units = diyLayout(stageId, wanted);
if (units.length !== wanted.length) throw new Error(`diy: ${wanted.length - units.length} pieces do not fit on ${stageId}`);
const round = BAND_ROUND[Math.max(...wanted.map((w) => w.band))];
const seed = deriveSeed(20261005, `diy:${n}`);
const wave = normalWave(gd, round, seed);
const pid = 'p1';
const groups = [...new Set(chunk.map((c) => c.group))];
const pass = groups.some((g) => !firstOf.has(g)) ? 0 : 1;
for (const g of groups) firstOf.add(g);
scenarios.push({
id: `diy-${String(n + 1).padStart(3, '0')}`, family: 'diy', kind: 'normal', modeId, round, stageId, seed, pass,
rect: { ...GEO.NORMAL_RECT }, timeLimit: wave.timeLimit, routes: wave.routes, waveId: wave.templateId, enemyOverrides: wave.overrides,
flags: { layerGainsEnabled: true, ...gd.dp },
players: [playerInput(pid, 0, units)],
spawns: [0, 1, 2].flatMap((k) => wave.spawns.map((s) => ({ ...s, time: s.time + k * Math.round(wave.timeLimit / 4), ownerPlayerId: pid }))),
about: `${units.map((u) => `${u.chessId.replace(/^chess_char_/, '')}=${u.diy.charId.replace(/^char_\d+_/, '')}${u.diy.skillIndex != null ? `/S${u.diy.skillIndex + 1}` : ''}${u.diy.uniEquipId ? `/${u.diy.uniEquipId.replace(/^uniequip_/, '')}` : ''}`).join(' ')}`,
});
}
return scenarios;
}
// ---------------------------------------------------------------------------------------------------------------
// families, fast subset, comparison
const GENERATORS = { roster: rosterScenarios, bonds: bondScenarios, fields: fieldScenarios, matches: matchScenarios, standins: standInScenarios };
const GENERATORS = { roster: rosterScenarios, bonds: bondScenarios, fields: fieldScenarios, matches: matchScenarios, standins: standInScenarios, diy: diyScenarios };
const ABOUT = {
roster: 'every visible chess record × every selectable skill / module, every stage, every non-leader enemy kind, items, bands, 机变 cards, map cards, placeable summons',
bonds: 'every bond at its activation threshold (layers 1) and at its top tier (layers 999)',
fields: 'Final Assault / Hidden Core leaders (pair + solo templates, shared pool, 200 s cap) and 联防 fields (1 / 2 helpers)',
matches: 'matches run to the end in virtual time: bot-only (solo ×4 difficulties ×2 seeds, co-op 2/3/4, one server-run, two boosted to the Hidden Core) and one co-op match whose human seat fields 补位 stand-ins',
standins: 'every NORMAL chess record (normal + elite) fielded as its 补位 stand-in (all 17 stand-ins and the skills the chess name for them), 12 per battle on real stages and waves',
diy: '自选 pieces: every kitted owned 6★ × form (tiers 5 / 6, normal / elite × module) × skill, every kitted prototype at its locked selection',
};
/**
* The default test subset (GOLDEN_FULL=1 runs everything): the pass-0 roster battles (every chess record with its