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:
commit
1006254beb
32 files changed
+2229
-174
No files matched your search
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user