0.2.5-dev: community round 30 and follow-ups

- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
This commit is contained in:
sganggs committed 2026-10-12 14:07:08 +08:00
1 parent bc50cac01f
commit 221e6b8dd2
134 files changed
+4590 -1201

No files matched your search

+4 -4
View File
@@ -3499,11 +3499,11 @@ function buildChoices(ctx, effects, items, chess) {
// weights are server-side (PRTS 11-25 note: "装备【灼燃维式重锤】的出现概率调整") — uniform [ASSUMED]
pool_equip_vict: { kind: 'equip', items: ['灼燃维式重锤', '坚固维式重锤', '加速维式重锤', '战栗维式重锤'].map(itemByName), assumed: true },
pool_equip_pepe: { kind: 'equip', weighted: [['盟约之币', 45], ['萨尔贡浓茶', 45], ['黄沙罗盘', 10]].map(([n, w]) => [itemByName(n), w]), goldenWeights: [40, 40, 20], assumed: true },
// 洛洛的定制品 = the same 4 special hammers (user playtest #4, first-hand: "几个特殊的维式重锤是干员洛洛或者维多利亚
// 阵营获得的"); uniform [ASSUMED]
pool_equip_rockr: { kind: 'equip', items: ['灼燃维式重锤', '坚固维式重锤', '加速维式重锤', '战栗维式重锤'].map(itemByName), assumed: true },
// 路标月报#2 BV1eLXXBqEgF, card-pool rule 6: measured subset weights. The four special hammers are still
// exclusive to this and Victoria's pools, but are not the whole of Rockrock's pool.
pool_equip_rockr: { kind: 'equip', weighted: [['维式重锤', 10], ['战栗维式重锤', 2], ['坚固维式重锤', 2], ['加速维式重锤', 2], ['灼燃维式重锤', 2], ['有限加速器', 1], ['护盾无人机', 4], ['激光发射器', 4]].map(([n, w]) => [itemByName(n), w]), assumed: true },
pool_chess_glady: { kind: 'chess', items: ['斯卡蒂', '幽灵鲨', '深巡'].map(chessByName), assumed: true },
pool_char_pinus: { kind: 'chess', weighted: [['野鬃', 45], ['灰毫', 45], ['远牙', 10]].map(([n, w]) => [chessByName(n), w]), assumed: true },
pool_char_pinus: { kind: 'chess', weighted: [['野鬃', 10], ['灰毫', 10], ['远牙', 3]].map(([n, w]) => [chessByName(n), w]), assumed: true },
pool_char_later: { kind: 'chess', bond: 'lateranoShip', minTier: 4, golden: true, rule: 'shopEligible', assumed: true },
...Object.fromEntries([1, 2, 3, 4, 5, 6].map((t) => [`pool_chess_shop_${t}_reward`, { kind: 'chess', tier: t, rule: 'shopEligible', assumed: true }])),
},
+20 -11
View File
@@ -10,6 +10,8 @@
// How a text is matched (no id table to keep up to date — the official tables are walked side by side):
// 1. the zh_CN and the EN tables are walked in parallel (same keys; arrays of keyed records by their key), giving
// pairs Chinese source → English source with the ids on their path as "scope";
// 0. tools/i18n/corrections-<lang>.json first, when the language has one: an official translation that contradicts
// the Chinese text and the engine (exact match on the data text, each entry with a `_why:` note) (correction);
// 2. a data text equal to a Chinese source takes its English source (exact);
// 3. a data text whose blackboard placeholders were resolved by build-data ({atk:0%} → 80%) is matched against the
// Chinese templates of the same shape: the numbers are read back into a blackboard, the Chinese template must
@@ -463,11 +465,14 @@ class Translator {
/**
* @param {PairIndex} index
* @param {{ name: string, map: Map<string, string> }[]} fallbacks in order of preference
* @param {{ composites?: boolean, corrections?: Map<string, string> }} [opts] corrections: tried before the official
* texts (an official translation that contradicts the Chinese and the engine, tools/i18n/corrections-<lang>.json)
*/
constructor(index, fallbacks, { composites = true } = {}) {
constructor(index, fallbacks, { composites = true, corrections = new Map() } = {}) {
this.index = index;
this.fallbacks = fallbacks;
this.composites = composites;
this.corrections = corrections;
}
/**
@@ -476,6 +481,8 @@ class Translator {
* @returns {{ en: string, how: string } | null}
*/
translate(zh, ctx) {
const fix = this.corrections.get(zh);
if (fix) return { en: fix, how: 'correction' };
const ex = this.index.exact.get(zh);
if (ex) return { en: bestVariant(ex, ctx).en, how: 'exact' };
// a plain text (build-data strips the markup of `desc`, `text`, card / device descriptions) against stripped sources
@@ -626,7 +633,7 @@ function translateScalar(file, id, text, tr, stats, samples) {
class Stats {
constructor() { this.kinds = {}; this.missing = new Map(); }
row(kind) { return this.kinds[kind] || (this.kinds[kind] = { texts: 0, exact: 0, template: 0, composite: 0, remake: 0, pr70: 0, ui: 0, dict: 0, missing: 0, notes: 0 }); }
row(kind) { return this.kinds[kind] || (this.kinds[kind] = { texts: 0, exact: 0, template: 0, composite: 0, correction: 0, remake: 0, pr70: 0, ui: 0, dict: 0, missing: 0, notes: 0 }); }
hit(kind, how) { const r = this.row(kind); r.texts++; r[how]++; }
miss(kind, text) {
const r = this.row(kind);
@@ -645,10 +652,10 @@ const dictMap = (d, untranslated = (zh, t) => hasCjk(t)) => new Map(Object.entri
/**
* Build the overlay. Exported for tests (they pass in-memory tables).
* @param {{ zh: object[], en: object[], data: Record<string, any>, fallback?: { remake?: object, pr70?: object, ui?: object }, source?: object }} input
* zh / en: the picked sub-trees of TABLES, in order
* @param {{ zh: object[], en: object[], data: Record<string, any>, fallback?: { remake?: object, pr70?: object, ui?: object }, corrections?: object, source?: object }} input
* zh / en: the picked sub-trees of TABLES, in order; corrections: { zh: en } tried before the official texts
*/
export function buildOverlay({ zh, en, data, fallback = {}, source = null, lang = 'en' }) {
export function buildOverlay({ zh, en, data, fallback = {}, corrections = {}, source = null, lang = 'en' }) {
const untranslated = untranslatedTest(lang, LANG_SOURCES[lang]?.untranslated === 'native' ? nativeCharset(zh, en) : null);
const index = new PairIndex(untranslated);
for (let i = 0; i < zh.length; i++) index.walk(zh[i], en[i], []);
@@ -657,7 +664,7 @@ export function buildOverlay({ zh, en, data, fallback = {}, source = null, lang
{ name: 'remake', map: dictMap(fallback.remake, untranslated) },
{ name: 'pr70', map: dictMap(fallback.pr70, untranslated) },
{ name: 'ui', map: dictMap(fallback.ui, untranslated) },
], { composites: LANG_SOURCES[lang]?.composite ?? false });
], { composites: LANG_SOURCES[lang]?.composite ?? false, corrections: dictMap(corrections, untranslated) });
const stats = new Stats();
const samples = {};
const files = {};
@@ -695,7 +702,7 @@ export function buildOverlay({ zh, en, data, fallback = {}, source = null, lang
const coverage = {};
let total = 0, translated = 0, official = 0;
for (const [kind, r] of Object.entries(stats.kinds).sort()) {
const ok = r.exact + r.template + r.composite + r.remake + r.pr70 + r.ui + r.dict;
const ok = r.exact + r.template + r.composite + r.correction + r.remake + r.pr70 + r.ui + r.dict;
coverage[kind] = { ...r, translated: ok, pct: r.texts ? Math.round((ok / r.texts) * 1000) / 10 : 100 };
total += r.texts; translated += ok; official += r.exact + r.template + r.composite;
}
@@ -706,8 +713,8 @@ export function buildOverlay({ zh, en, data, fallback = {}, source = null, lang
generator: 'tools/build-i18n.mjs',
source: source || null,
fallback: lang === 'en'
? 'tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json'
: `public/i18n/${lang}.json (the pack's UI strings)`,
? 'tools/i18n/corrections-en.json (before the official texts), tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json'
: `${Object.keys(corrections).some((k) => !k.startsWith('_')) ? `tools/i18n/corrections-${lang}.json (before the official texts), ` : ''}public/i18n/${lang}.json (the pack's UI strings)`,
coverage: Object.fromEntries(Object.entries(coverage).map(([k, v]) => [k, { texts: v.texts, translated: v.translated, official: v.exact + v.template + v.composite, pct: v.pct }])),
totals: { texts: total, translated, official, pct: total ? Math.round((translated / total) * 1000) / 10 : 100 },
},
@@ -758,7 +765,9 @@ async function main() {
ui: await readDict(join(ROOT, 'public', 'i18n', 'en.json')),
} : { ui: await readDict(join(ROOT, 'public', 'i18n', `${opts.lang}.json`)) };
if (opts.dict) fallback.dict = JSON.parse(await readFile(opts.dict, 'utf8'));
const { overlay, report } = buildOverlay({ zh, en, data, fallback, lang: opts.lang, source: client ? { id: opts.source, label: src.label, home: src.home, season: seasonEn } : { id: 'dict', label: src.label } });
// corrections of the language's own official texts (none for a language without a file)
const corrections = await readDict(join(ROOT, 'tools', 'i18n', `corrections-${opts.lang}.json`));
const { overlay, report } = buildOverlay({ zh, en, data, fallback, corrections, lang: opts.lang, source: client ? { id: opts.source, label: src.label, home: src.home, season: seasonEn } : { id: 'dict', label: src.label } });
// self-check: applying the overlay keeps every record's shape (strings replace strings only)
let applied = 0;
@@ -774,7 +783,7 @@ async function main() {
if (client) log(` season in the ${opts.lang} build: ${seasonEn}${version ? ` · ${version.replace(/\s+/g, ' ')}` : ''}`);
log(` ${report.pairs} zh→${opts.lang} source pairs; coverage per kind (translated / texts):`);
for (const [kind, c] of Object.entries(report.coverage)) {
const fb = [c.remake && `remake ${c.remake}`, c.pr70 && `PR #70 ${c.pr70}`, c.ui && `UI ${c.ui}`, c.dict && `dictionary ${c.dict}`].filter(Boolean).join(' · ');
const fb = [c.correction && `corrections ${c.correction}`, c.remake && `remake ${c.remake}`, c.pr70 && `PR #70 ${c.pr70}`, c.ui && `UI ${c.ui}`, c.dict && `dictionary ${c.dict}`].filter(Boolean).join(' · ');
log(` ${kind.padEnd(10)} ${String(c.translated).padStart(5)} / ${String(c.texts).padEnd(5)} ${String(c.pct).padStart(5)} % official ${c.exact + c.template + c.composite} (exact ${c.exact} · template ${c.template} · composite ${c.composite})${fb ? ` · ${fb}` : ''}${c.notes ? ` · notes skipped ${c.notes}` : ''}`);
}
log(` total ${report.totals.translated} / ${report.totals.texts} (${report.totals.pct} %), official ${report.totals.official}; ${Object.keys(overlay.names).length} names (${report.nameConflicts.length} with several ${opts.lang} forms)`);
+5
View File
@@ -0,0 +1,5 @@
{
"_about": "Corrections of the official EN client text: { \"<Chinese data text>\": \"<English>\" }, tried BEFORE the official EN data (tools/build-i18n.mjs; another language has its own corrections-<code>.json). Only for an official translation that contradicts the Chinese text and the engine; each entry has a `_why:<key start>` note. Raw keys only: the plain twin (desc / effectDesc) is derived by stripping the markup.",
"_why:<@ba.vup>携带装备</>的【维多利亚】": "Victoria Alliance, 6 operators: the Chinese says +50% ATK per equipment carried (每携带1件装备), +80% per advanced one; server/sim/content/bonds/core.js installVictoria adds atk_normal_equip / +atk_golden_equip per item. The official EN ('gain +50% ATK, or +80% ATK if equipment is upgraded') reads as a flat bonus.",
"<@ba.vup>携带装备</>的【维多利亚】干员伤害提升至<@ba.vup>{0:0%}</><@ba.acrem>(受层数影响)</>\n<每叠加<@ba.vup>25</>层>获得一件带有随机特殊效果的维式重锤\n<在场<@ba.vup>6</>名不同【维多利亚】干员>【维多利亚】干员每携带1件装备攻击力<@ba.vup>+50%</>,携带进阶装备改为<@ba.vup>+80%</>": "[Victoria] Operators <@ba.vup>carrying equipment</> deal <@ba.vup>{0:0%}</> damage <@ba.acrem>(affected by no. of stacks)</>\n<Every <@ba.vup>25</> stacks> Obtain a random special Victorian Hammer\n<With <@ba.vup>6</> different [Victoria] Operators on field> [Victoria] Operators gain <@ba.vup>+50%</> ATK per equipment carried, or <@ba.vup>+80%</> per upgraded equipment"
}
+124
View File
@@ -0,0 +1,124 @@
// The cross-engine check of the battle sim (docs/SIM.md「Cross-engine check」). A browser simulates its battles and
// the server re-simulates them (SP_VERIFY, takeovers), so every engine must give the same bits as Node. The golden
// battle corpus traced in Node is the reference; public/dev/sim-check.html runs the corpus in any browser — a desktop
// or a phone on the LAN — and compares it tick by tick (public/dev/simcheck: core.js, ui.js, stronghold.js).
//
// node tools/sim-check.mjs write public/dev/simcheck/data/specs.json (the corpus's BattleSpecs)
// and reference.json (their Node traces); git-ignored
// node tools/sim-check.mjs --engines chromium,firefox,webkit [--subset fast|all|<family>] [--only id,id]
// then start the server on a free port and run the page headless in each
// engine; exit 1 when one differs. Firefox and WebKit need the
// playwright package (npm i -D playwright && npx playwright install
// firefox webkit); without it `chrome` runs a local Chrome
// ($CHROME_PATH) through puppeteer-core
// node tools/sim-check.mjs --diff <dump.json> the block a browser downloaded (下载这一段的状态) against Node's run of
// the same ticks, entry by entry
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import * as spec from '../server/sim/spec.js';
import { DataSource } from '../server/sim/simdata.js';
import { APP_VERSION } from '../shared/constants.js';
import { getData } from '../server/data.js';
import { DATA_SHIM_JS } from '../server/http/static.js';
import { scenariosOf as goldenScenarios, isFast } from './golden.mjs';
import { strongholdAdapter, scenariosOf, SIM_DATA_FILES } from '../public/dev/simcheck/stronghold.js';
import { buildReference, runEngines, formatEngines, diffDumpFile } from '../public/dev/simcheck/node.mjs';
import { VERSION, textFingerprint, selectScenarios } from '../public/dev/simcheck/core.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
export const OUT_DIR = join(ROOT, 'public', 'dev', 'simcheck', 'data');
export const FAMILIES = Object.freeze(['roster', 'bonds', 'fields', 'standins', 'diy']);
/** The golden battle corpus as BattleSpecs, in the corpus's order; a field battle keeps its cap (golden's forceEnd). */
export function corpusSpecs(families = FAMILIES) {
const specs = [];
for (const fam of families) {
for (const sc of goldenScenarios(fam)) {
specs.push({ id: sc.id, fam, fast: isFast(sc), cap: sc.cap ?? null, spec: spec.buildBattleSpec({
battleId: sc.id, fieldId: sc.fieldId ?? 'g', kind: sc.kind, seed: sc.seed, modeId: sc.modeId, round: sc.round, stageId: sc.stageId, rect: sc.rect,
timeLimit: sc.timeLimit, players: sc.players, spawns: sc.spawns, routes: sc.routes, flags: sc.flags, enemyOverrides: sc.enemyOverrides ?? {},
waveId: sc.waveId ?? null, bossId: sc.bossId ?? null, boss: sc.boss ?? null, content: 'full' }) });
}
}
return specs;
}
/** The adapter over Node's sim and data (the same files the browser loads: loadBrowserSim reads data/*.json). */
export function nodeAdapter(doc) {
return strongholdAdapter({ spec, ds: new DataSource(getData({ log: { info() {}, warn() {}, error() {} } }), null), doc });
}
const commit = () => { try { return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim(); } catch { return ''; } };
export function parseOptions(argv) {
const out = {};
for (let i = 0; i < argv.length; i += 2) {
const k = argv[i], v = argv[i + 1];
if (!['--diff', '--engines', '--subset', '--only'].includes(k) || !v || v.startsWith('--') || out[k]) throw new Error(`invalid or missing argument: ${k}`);
out[k] = v;
}
if (out['--diff'] && Object.keys(out).length !== 1) throw new Error('--diff cannot be combined with run options');
if ((out['--subset'] || out['--only']) && !out['--engines']) throw new Error('--subset/--only require --engines');
if (out['--subset'] && !['all', 'fast', ...FAMILIES].includes(out['--subset'])) throw new Error('unknown subset');
if (out['--engines'] && out['--engines'].split(',').some(n => !['chrome', 'chromium', 'firefox', 'webkit'].includes(n))) throw new Error('unknown engine');
return out;
}
/** Served runtime inputs, by content (dirty source/data changes included; never host paths in the document). */
export function runtimeFiles() {
const files = [];
const add = (url, file) => files.push({ url, hash: textFingerprint(readFileSync(join(ROOT, file), 'utf8')) });
const walk = (dir, url) => {
for (const e of readdirSync(join(ROOT, dir), { withFileTypes: true })) {
if (e.isDirectory()) walk(`${dir}/${e.name}`, `${url}/${e.name}`);
else if (e.name.endsWith('.js') && e.name !== 'nodeData.js') add(`${url}/${e.name}`, `${dir}/${e.name}`);
}
};
walk('server/sim', '/sim'); walk('shared', '/shared');
for (const p of ['js/battle/runner.js', 'dev/simcheck/core.js', 'dev/simcheck/stronghold.js']) add(`/${p}`, `public/${p}`);
for (const p of SIM_DATA_FILES) add(`/data/${p}.json`, `data/${p}.json`);
files.push({ url: '/data.js', hash: textFingerprint(DATA_SHIM_JS) });
return files.sort((a, b) => a.url < b.url ? -1 : a.url > b.url ? 1 : 0);
}
export const bundleIdentity = (specs, files) => textFingerprint(JSON.stringify({ specs, files }));
async function main(argv) {
const options = parseOptions(argv), opt = k => options[k] ?? null;
if (opt('--diff')) {
const doc = JSON.parse(readFileSync(join(OUT_DIR, 'specs.json'), 'utf8'));
if (doc.identity !== bundleIdentity(doc.specs, runtimeFiles())) throw new Error('reference inputs are stale; regenerate before comparing a dump');
for (const line of diffDumpFile(opt('--diff'), nodeAdapter(doc), scenariosOf(doc), { identity: doc.identity })) console.log(line);
return 0;
}
const t0 = Date.now();
const doc = { simcheck: VERSION, made: { version: APP_VERSION, commit: commit() }, specs: corpusSpecs(), files: runtimeFiles() };
doc.identity = bundleIdentity(doc.specs, doc.files);
const scenarios = scenariosOf(doc);
if (opt('--engines')) selectScenarios(scenarios, [{ id: 'all', pick: () => true }, { id: 'fast', pick: s => s.fast }, ...FAMILIES.map(id => ({ id, pick: s => s.family === id }))], opt('--subset') || 'all', opt('--only'));
let last = 0;
const reference = buildReference(nodeAdapter(doc), scenarios, {
project: 'stronghold', made: { version: APP_VERSION, commit: doc.made.commit },
onProgress: (i, n) => { if (Date.now() - last > 2000) { last = Date.now(); process.stdout.write(`\r ${i} / ${n}`); } },
});
reference.identity = doc.identity;
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'specs.json'), JSON.stringify(doc));
writeFileSync(join(OUT_DIR, 'reference.json'), JSON.stringify(reference));
console.log(`\r${scenarios.length} battles traced in ${((Date.now() - t0) / 1000).toFixed(1)} s → public/dev/simcheck/data/ (open /dev/sim-check.html)`);
const engines = opt('--engines');
if (!engines) return 0;
const { startServer } = await import('../server/index.js');
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
try {
const q = new URLSearchParams({ auto: '1', subset: opt('--subset') || 'all' });
if (opt('--only')) q.set('only', opt('--only'));
const res = await runEngines(`http://127.0.0.1:${srv.port}/dev/sim-check.html?${q}`, engines.split(','), { log: (s) => console.log(s) });
for (const line of formatEngines(res)) console.log(line);
return Object.values(res).every((r) => r.status === 'done' && r.expected > 0 && r.completed === r.expected && r.summary?.ok === r.expected) ? 0 : 1;
} finally { await srv.close(); }
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1] && existsSync(ROOT)) {
main(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(e); process.exit(2); });
}