0.2.4-dev: round-29 community fixes and the official shared card pool

- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
This commit is contained in:
sganggs committed 2026-10-11 01:58:52 +08:00
1 parent 1db8e51023
commit 6bfc08270c
129 files changed
+3477 -1011

No files matched your search

+3
View File
@@ -406,6 +406,9 @@ test('机密商店 E2E (co-op): two identical cards are two cards — both can b
// six 变形同构体: every slot draws the one item `coin` names
const one = Object.values(DATA.items).find((i) => i.name === '变形同构体' && !i.isGolden).id;
const data = dataWith('mode_multi_hard', 3, 'shop');
// This case isolates card-index ownership with sufficient stock; shared-stock.test.js covers exhaustion.
data.config = { ...data.config, economy: { ...data.config.economy,
itemPoolCopiesOverrides: { ...data.config.economy.itemPoolCopiesOverrides, [one]: 8 } } };
data.choices = { ...data.choices, shopDraft: { ...data.choices.shopDraft, rounds: [3], slots: Array.from({ length: 6 }, () => ({ coin: 1 })), coin: one } };
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, bots: 2, seed: 43, data, registry: REG, fake: true });
const m = h.m;
+209
View File
@@ -0,0 +1,209 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const done = (h) => { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); };
test('six Kazimierz stuns a nearby hovering enemy while blocking ground enemies (#463)', () => {
const h = makeBattle({ autoFinish: false, hooks: ['status'],
defs: { chess: { wall: chessRec({ id: 'wall', bonds: ['kazimierzShip'], skill: null, stats: { maxHp: 1e7, atk: 10, block: 3 } }) },
enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { kazimierzShip: { count: 6, active: true, tier: 3, layers: 1 } },
units: [{ uid: 1, chessId: 'wall', row: 10, col: 4 }] });
h.step(); const u = h.unit(1);
const ground = h.spawn('dummy', { pos: [10, 4], routeIndex: 0 });
const near = h.spawn('enemy_2025_syufo', { pos: [10, 4.5], routeIndex: 0 });
const far = h.spawn('enemy_2025_syufo', { pos: [10, 7], routeIndex: 0 });
for (const e of [near, far]) h.b.addBuff(e, { key: 'test:stationary', flags: { noMove: true, disarm: true } });
assert.ok(near.s.flags.unblockable);
h.step(2); assert.equal(ground.blockedBy, u);
assert.ok(h.runUntil(() => near.s.flags.stun, 4), 'the pulse can stun hovering targets');
assert.ok(!near.s.flags.unblockable, 'hovering ends while stunned');
assert.ok(!far.s.flags.stun, '周围 is the official 0.8 radius, not the whole field');
done(h);
});
test('a Raid revive inside the tick hook stays on the enemy tile instead of the taunt', () => {
const h = makeBattle({ autoFinish: false,
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'chess_char_4_16_a', skillIndex: 1, row: 10, col: 4 }] });
h.step(); const u = h.unit(1);
const local = h.spawn('dummy', { pos: [10, 4], routeIndex: 0 });
const far = h.spawn('dummy', { pos: [10, 8], routeIndex: 0 });
h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
let did = false;
h.b.on('tick', () => {
if (did || h.b.time < 7 / 30 - 1e-9 || u.blocking.length < 1) return;
did = true;
h.b.kill(u, null);
assert.ok(h.b.redeploy(u, { free: true }));
assert.equal(u.blocking.length, 0, 'redeploy clears the block list before the next tick');
});
h.step(8);
assert.equal(did, true);
h.step(1);
assert.deepEqual([u.tileR, u.tileC], [10, 4], 'the enemy is still on this tile when the revive poll runs');
assert.equal(local.blockedBy, u);
done(h);
});
test('an ordinary Raid poll prefers the nearer gate over a farther taunt', () => {
const probe = chessRec({ id: 'probe', position: 'MELEE', bonds: ['raidShip'], rangeGrid: [[0, 0]], skill: null, stats: { maxHp: 5000, atk: 10, block: 2, def: 0 } });
const h = makeBattle({ autoFinish: false,
defs: { chess: { probe }, enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'probe', row: 10, col: 5 }] });
h.step(); const u = h.unit(1);
const near = h.spawn('dummy', { pos: [9, 3], routeIndex: 0 });
const far = h.spawn('dummy', { pos: [9, 8], routeIndex: 0 });
h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
assert.ok(h.b.remainingDistance(near) < h.b.remainingDistance(far));
h.run(10.5);
assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(near), 'ordinary order is remaining distance, then id');
assert.ok(!h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(far));
done(h);
});
test('a retreat redeploy does not arm the Raid revive reselect', () => {
const wide = chessRec({ id: 'wide', position: 'RANGED', bonds: ['raidShip'], rangeGrid: [[0, 0], [0, 1], [0, -1]], skill: null, stats: { maxHp: 5000, atk: 10, block: 0 } });
const h = makeBattle({ autoFinish: false,
defs: { chess: { wide }, enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'wide', row: 10, col: 5 }] });
h.step(); const u = h.unit(1);
const e = h.spawn('dummy', { pos: [10, 6], routeIndex: 0 });
h.run(0.4);
assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(e));
const tile = [u.tileR, u.tileC];
h.b.retreat(u, { reason: 'retreat' });
assert.ok(h.b.redeploy(u, { free: true }));
const seq = u.deploySeq;
h.run(0.3);
assert.deepEqual([u.tileR, u.tileC], tile, 'an enemy already in range keeps the old poll from jumping');
assert.equal(u.deploySeq, seq);
done(h);
});
test('a revived Raid operator that has already blocked an enemy stays in place', () => {
const h = makeBattle({ autoFinish: false,
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'chess_char_4_16_a', skillIndex: 1, row: 10, col: 4 }] });
h.step(); const u = h.unit(1);
const local = h.spawn('dummy', { pos: [10, 4], routeIndex: 0 });
const far = h.spawn('dummy', { pos: [10, 8], routeIndex: 0 });
h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
h.b.kill(u, null); assert.ok(h.b.redeploy(u, { free: true }));
h.step(2); assert.equal(local.blockedBy, u);
h.run(0.3); assert.deepEqual([u.tileR, u.tileC], [10, 4]); done(h);
});
for (const taunt of [false, true]) test(`revived unblocked Texas S2 reselects a Raid target by taunt then remaining route (${taunt})`, () => {
const h = makeBattle({ autoFinish: false, hooks: ['deploy'],
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'chess_char_4_16_a', skillIndex: 1, row: 10, col: 4 }] });
h.step(); const u = h.unit(1);
h.spawn('dummy', { pos: [10, 5.2], routeIndex: 0 });
const nearGate = h.spawn('dummy', { pos: [9, 3], routeIndex: 0 });
const far = h.spawn('dummy', { pos: [10, 8], routeIndex: 0 });
if (taunt) h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
h.run(0.3); assert.deepEqual([u.tileR, u.tileC], [10, 4], 'initial deployment still uses the ordinary range gate');
h.b.kill(u, null); assert.ok(h.b.redeploy(u, { free: true }));
assert.equal(u.blocking.length, 0);
h.run(0.3);
const target = taunt ? far : nearGate;
assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(target), 'the higher-priority target is in the new range');
assert.notDeepEqual([u.tileR, u.tileC], [10, 4]);
const count = u.deploySeq; h.run(0.3); assert.equal(u.deploySeq, count, 'Raid itself cannot continuously rearm the revival check');
done(h);
});
test('Skadi S1 and 坚守 sharing retain transfer ancestry and do not revisit either sharing effect', () => {
const op = (id, bonds) => chessRec({ id, bonds, skill: null, stats: { maxHp: 1e7, atk: 0, def: 0 } });
const h = makeBattle({ autoFinish: false, defs: { chess: {
a: op('a', ['steadShip']), b: op('b', ['steadShip']), c: op('c', ['steadShip']), d: op('d', []),
} }, bonds: { steadShip: { count: 3, active: true, tier: 2, layers: 10 } },
units: [{ uid: 1, chessId: 'chess_char_6_04_a', row: 10, col: 4, skillIndex: 0 },
{ chessId: 'a', row: 9, col: 4 }, { chessId: 'b', row: 11, col: 4 },
{ chessId: 'c', row: 10, col: 3 }, { chessId: 'd', row: 10, col: 5 }] });
h.step(); assert.ok(h.unit(1).skill.activate('test', { free: true }));
let attempts = 0;
// Fail fast on the broken implementation without letting the branching recursion freeze the test worker.
h.b.on('hit', (c) => { if (++attempts > 64) c.dmg.cancel = true; }, { priority: 1000 });
h.b.dealDamage(null, h.unit('d'), { amount: 1000, type: 'true' });
assert.ok(attempts <= 12, `bounded transfer tree, got ${attempts} hits`);
assert.ok(h.unit('d').hp < h.unit('d').s.maxHp); done(h);
});
for (const mode of ['both', 'stead', 'skadi']) test(`Skadi S1 and 坚守 conserve a 1000 true hit (${mode})`, () => {
const op = (id, bonds) => chessRec({ id, bonds, skill: null, stats: { maxHp: 1e7, atk: 0, def: 0 } });
const both = mode === 'both';
const units = [];
if (mode !== 'stead') units.push({ uid: 1, chessId: 'chess_char_6_04_a', row: 10, col: 4, skillIndex: 0 });
units.push({ chessId: 'a', row: 9, col: 4 }, { chessId: 'b', row: 11, col: 4 }, { chessId: 'c', row: 10, col: 3 }, { chessId: 'd', row: 10, col: 5 });
const h = makeBattle({ autoFinish: false, defs: { chess: {
a: op('a', ['steadShip']), b: op('b', ['steadShip']), c: op('c', ['steadShip']), d: op('d', []),
} }, bonds: mode === 'skadi' ? {} : { steadShip: { count: 3, active: true, tier: 2, layers: 10 } }, units });
h.step();
if (mode !== 'stead') assert.ok(h.unit(1).skill.activate('test', { free: true }));
let attempts = 0;
h.b.on('hit', (c) => { if (++attempts > 64) c.dmg.cancel = true; }, { priority: 1000 });
const loss = (id) => { const u = h.unit(id); return u.s.maxHp - u.hp; };
h.b.dealDamage(null, h.unit('d'), { amount: 1000, type: 'true' });
const parts = [loss('d'), loss('a'), loss('b'), loss('c')];
if (mode !== 'stead') parts.push(loss(1));
const sum = parts.reduce((s, n) => s + n, 0);
const near = (got, want) => Math.abs(got - want) < 1e-3;
assert.ok(near(sum, 1000), `total ${sum}`);
if (both) {
assert.ok(near(loss('d'), 100), `victim ${loss('d')}`);
for (const id of ['a', 'b', 'c']) assert.ok(near(loss(id), 400 / 3), `${id} ${loss(id)}`);
assert.ok(near(loss(1), 500), `skadi ${loss(1)}`);
assert.ok(attempts <= 12, `bounded transfer tree, got ${attempts} hits`);
const before = attempts;
h.b.dealDamage(null, h.unit('d'), { amount: 1000, type: 'true' });
assert.ok(attempts - before <= 12, `second hit stayed bounded, got ${attempts - before}`);
assert.ok(near(loss('d'), 200) && near(loss(1), 1000), 'the second hit conserves the same split');
} else if (mode === 'stead') {
assert.ok(near(loss('d'), 600), `victim ${loss('d')}`);
for (const id of ['a', 'b', 'c']) assert.ok(near(loss(id), 400 / 3), `${id} ${loss(id)}`);
assert.ok(attempts <= 4, `stead-only hits ${attempts}`);
} else {
assert.ok(near(loss('d'), 500) && near(loss(1), 500));
assert.ok(near(loss('a'), 0) && near(loss('b'), 0) && near(loss('c'), 0));
assert.ok(attempts <= 2, `skadi-only hits ${attempts}`);
}
done(h);
});
for (const combo of [false, true]) test(`家族徽章 consumes its bonus on the first damage immediately after stealth ends (combo=${combo})`, () => {
const op = chessRec({ id: 'op', bonds: ['siracusaShip'], skill: null, rangeGrid: [[0, 0]], stats: { atk: 1000 } });
const h = makeBattle({ autoFinish: false, defs: { chess: { op }, enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
units: [{ uid: 1, chessId: 'op', row: 10, col: 4, items: ['chess_item_6_11_e_b', ...(combo ? ['chess_item_3_01_e_a'] : [])] }] });
h.step(); const u = h.unit(1), base = u.s.atk;
h.b.addBuff(u, { key: 'test:hidden', flags: { stealth: true } }); h.run(2);
const boosted = u.s.atk; assert.ok(boosted > base);
h.b.removeBuff(u, 'test:hidden'); const e = h.spawn('dummy', { pos: [10, 9] }); const hp = e.hp;
h.b.dealDamage(u, e, { amount: 100, type: 'true', isAttack: true });
assert.equal(u.s.atk, base, 'no waiting for the next 0.25-second growth poll');
assert.ok(Math.abs(hp - e.hp - (100 + (combo ? boosted * 8 : 0))) < 1e-6);
const hp2 = e.hp; h.b.dealDamage(u, e, { amount: 100, type: 'true', isAttack: true });
assert.equal(hp2 - e.hp, 100, 'bonus consumed once'); done(h);
});
for (const elite of [false, true]) test(`松果 S2 growth restarts on redeployment without resetting the lifetime activation sequence (${elite})`, () => {
const id = `chess_char_3_10_${elite ? 'b' : 'a'}`;
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: id, skillIndex: 1, row: 10, col: 4 }] });
h.step(); const u = h.unit(1), values = [];
for (let i = 0; i < 3; i++) {
assert.ok(u.skill.activate('test', { free: true })); values.push(u.findBuff('skill:pinecn_atk').mods.atkPct); u.skill.end('test');
}
assert.ok(values[2] > values[0]); const activations = u.skill.activations;
h.b.retreat(u, { reason: 'test' }); assert.ok(h.b.redeploy(u, { free: true }));
assert.ok(u.skill.activate('test', { free: true }));
assert.equal(u.findBuff('skill:pinecn_atk').mods.atkPct, values[0]);
assert.equal(u.skill.activations, activations + 1); done(h);
});
for (const elite of [false, true]) test(`哈洛德 S2 starts for an ally at full HP with only elemental injury (${elite})`, () => {
const wall = chessRec({ id: 'wall', skill: null, stats: { maxHp: 1e6, atk: 0 } });
const h = makeBattle({ autoFinish: false, defs: { chess: { wall } },
units: [{ uid: 1, chessId: `chess_char_2_05_${elite ? 'b' : 'a'}`, skillIndex: 1, row: 10, col: 4 },
{ uid: 2, chessId: 'wall', row: 10, col: 5 }] });
h.step(); const u = h.unit(1), a = h.unit(2); assert.equal(u.skill.id, 'skchr_harold_2');
u.skill.gainSp(999, 'init'); h.run(0.5); assert.equal(u.skill.activations, 0, 'no injury means no cast');
h.b.dealDamage(null, a, { type: 'element', element: 'burn', amount: 600 }); assert.equal(a.hp, a.s.maxHp);
assert.ok(h.runUntil(() => u.skill.activations > 0, 4)); assert.ok(a.elem.burn < 600); done(h);
});
@@ -10,6 +10,26 @@ import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.
const TRAP = 'token_10031_swire2_gdtrap';
const SW = 'chess_char_3_04_a';
for (const path of ['owner', 'token']) test(`香槟炸弹 (${path}) rejects ordinary allied auras and inspiration without isolating normal summons (#476)`, () => {
const { h, bomb: ownBomb } = herBomb({ units: [
{ uid: 2, chessId: 'chess_char_5_20_a', row: 10, col: 4, skillIndex: 0 },
{ uid: 3, chessId: 'chess_char_6_04_a', row: 10, col: 5, skillIndex: 1 },
] });
const angelina = h.unit(2), skadi = h.unit(3);
if (path === 'token') h.b.retreat(ownBomb, { reason: 'expired', permanent: true });
const bomb = path === 'owner' ? ownBomb : h.b.spawnToken(h.unit(1), TRAP, 9, 6);
const control = h.b.spawnToken(skadi, 'token_10017_skadi2_dedant', 9, 4);
assert.ok(bomb && control);
assert.ok(skadi.skill.activate('test', { free: true })); h.run(0.7);
assert.equal(h.b.allySelectable(bomb, angelina), false);
assert.equal(bomb.s.aspd, bomb.base.aspd);
assert.equal(bomb.s.hpRegen, 0);
assert.equal(bomb.findBuff('inspire'), null);
assert.equal(bomb.findBuff('inspire:def'), null);
assert.ok(h.b.allySelectable(control, angelina), 'a normal unhealable summon is still selectable');
assert.ok(control.s.aspd > control.base.aspd, 'Angelina still affects the ordinary summon');
assert.ok(bomb.alive && bomb.hp === bomb.s.maxHp); checkInvariants(h.b);
});
/** 琳琅诗怀雅 on (9,5) of a field whose only free x-6 tile is (9,6) — 活性源石 when `infected`; she throws one bomb there. */
function herBomb({ infected = false, units = [], enemies = [] } = {}) {
const row9 = infected ? '##ERRriRrrSrrrrrrrS##' : '##ERRrrRrrSrrrrrrrS##';
+20
View File
@@ -839,6 +839,26 @@ test('塞雷娅 99 / 白面鸮 86: the copier runs the front operator\'s prep-en
assert.equal(w.ps.shop.freeRefreshes, 3);
});
// Maintainer-confirmed replay of the linked official-game footage (#473): only Ptilopsis copying these two gifts.
for (const targetId of ['chess_char_4_12_a', 'chess_char_6_03_a']) {
for (const ownRow of [false, true]) for (const sourceRow of [false, true]) {
test(`白面 copies ${targetId} gift without either row condition (own=${ownRow}, source=${sourceRow})`, () => {
const s = setup(11);
const white = give(s.m, s.ps, 'chess_char_4_21_a', 'board', [11, 4]); white.dir = 'DOWN';
give(s.m, s.ps, targetId, 'board', [10, 4]);
const fillers = plain(c => c.position === 'RANGED').slice(0, 4); let filler = 0;
for (const [r, on] of [[11, ownRow], [10, sourceRow]]) if (on) {
give(s.m, s.ps, fillers[filler++], 'board', [r, 3]);
give(s.m, s.ps, fillers[filler++], 'board', [r, 5]);
}
s.ps.recompute(); const before = s.ps.round.gainedChess;
s.roundStart();
assert.equal(s.ps.round.gainedChess - before, 1 + (sourceRow ? 1 : 0), 'copier always gives one; original still checks its own row');
s.h.invariants();
});
}
}
test('triggerGainEffects export (band 铃兰): re-runs 获得时 garrisons ×投资人 and returns the effects run', () => {
const s = setup();
const p = give(s.m, s.ps, 'chess_char_5_03_a', 'board', [10, 4]); // 烛煌 炎/维多利亚 +5
+12 -13
View File
@@ -954,7 +954,7 @@ test('拟态物质: owning 2 copies gives the 3rd (→ elite); otherwise a rando
cover(A('5_05'), B('5_05'));
});
test('拟态物质 with 2 copies owned and none left in the pool gives nothing — never a same-bond operator; with copies left the 3rd still merges (GitHub #207)', () => {
test('拟态物质 grants the third copy even when stock is empty; the gain still merges and occupies stock (measured pool rule)', () => {
// the report: an elite 溯光星源 (3 of the Ⅵ阶's 5 copies) and 2 normal ones — the pool is empty, the item text's 否则
// (a random same-bond operator) is only for fewer than 2 owned
const cid = 'chess_char_6_16_a';
@@ -966,18 +966,17 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing
const t = give(m, ps, cid, 'hand');
give(m, ps, cid, 'hand');
assert.equal(m.pool.left(cid), 0, 'elite 3 + 2 normal = the pool cap 5');
const before = chessIn(ps);
const got = spyGrants(ps);
const warns = spyWarns(m);
const it = giveItem(m, ps, item);
assert.deepEqual(equip(it, t), OK, item);
assert.deepEqual(got, [], `${item}: nothing granted`);
assert.deepEqual(chessIn(ps), before, `${item}: no other operator, no second elite`);
assert.deepEqual(got, [cid], `${item}: same operator granted despite empty stock`);
assert.deepEqual(chessIn(ps), [elite, elite], `${item}: the third normal copy merges`);
assert.ok(!ps.find(it.uid), `${item}: consumed`);
// …and says why (GitHub #401, the owner's OK of 2026-10-09)
assert.deepEqual(warns, [{ msgid: '{who}:卡池中已没有{name}', who: '拟态物质', name: '溯光星源' }], `${item}: the toast`);
assert.deepEqual(warns, [], `${item}: a successful grant must not report an empty-pool failure`);
assert.equal(m.pool.entries.get(cid).left, -1, 'the sixth owned copy still occupies the pool');
}
// 2 normal copies, no elite, the pool drained by other players: nothing either
// 2 normal copies, no elite, the pool drained by other players: the third copy is still granted.
{
const { m, ps, equip } = setup({ seed: 9 });
const t = give(m, ps, cid, 'hand');
@@ -986,9 +985,9 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing
const got = spyGrants(ps);
const warns = spyWarns(m);
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK);
assert.deepEqual(got, []);
assert.deepEqual(chessIn(ps), [cid, cid]);
assert.deepEqual(warns.map((w) => w.msgid), ['{who}:卡池中已没有{name}']);
assert.deepEqual(got, [cid]);
assert.deepEqual(chessIn(ps), [elite]);
assert.deepEqual(warns, []);
}
// 2 normal copies with copies left: the 3rd merges into the elite
{
@@ -1004,7 +1003,7 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing
}
});
test('拟态物质 with fewer than 2 copies and no same-bond operator left gives nothing and says so (GitHub #401)', () => {
test('拟态物质 with fewer than 2 copies samples eligible identities independently of remaining stock', () => {
// 缪尔赛思's only bond is 调和 and she is its only member: one copy owned, the rest of her pool taken by others
const cid = 'chess_char_6_11_a';
const { m, ps, equip } = setup({ seed: 9 });
@@ -1013,8 +1012,8 @@ test('拟态物质 with fewer than 2 copies and no same-bond operator left gives
const got = spyGrants(ps);
const warns = spyWarns(m);
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK);
assert.deepEqual(got, [], 'nothing granted');
assert.deepEqual(warns, [{ msgid: '{who}:没有可获得的同盟约干员', who: '拟态物质', name: null }]);
assert.deepEqual(got, [cid], 'the only eligible identity remains available even with no stock');
assert.deepEqual(warns, []);
});
test('博士投影: golden promotes at once; normal stays equipped and promotes at the next round start', () => {
+38 -2
View File
@@ -11,8 +11,8 @@ const approx = (a, b, label = '') => assert.ok(Math.abs(a - b) < 1e-5 * Math.max
const enemy = (key, opts = {}) => enemyRec({ key, hp: 1e8, atk: 0, speed: 0, ...opts });
const defs = { enemies: { dummy: enemy('dummy'), light: enemy('light', { mass: 3 }), mid: enemy('mid', { mass: 4 }), heavy: enemy('heavy', { mass: 9 }),
fly: enemy('fly', { motion: 'FLY' }), zero: enemy('zero', { mass: 0 }), armored: enemy('armored', { def: 100 }) } };
function field({ tier = 6, elite = true, skill = 0, potential = 6, dir = 'RIGHT', row = 10, col = 3 } = {}) {
const h = makeBattle({ seed: 23, autoFinish: false, timeLimit: 180, defs,
function field({ tier = 6, elite = true, skill = 0, potential = 6, dir = 'RIGHT', row = 10, col = 3, flags = {} } = {}) {
const h = makeBattle({ seed: 23, autoFinish: false, timeLimit: 180, defs, flags,
hooks: ['damaged', 'attack', 'elementBurst', 'ammoUsed'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: tier, charId: C, skillIndex: skill }, elite, potential, row, col, dir }] });
h.step(); return { h, u: h.unit(1) };
@@ -21,6 +21,26 @@ function done(h) { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); }
function start(u) { u.skill.gainSp(999, 'init'); assert.equal(u.skill.activate('test'), true); }
const hits = (h, tag) => h.hooksOf('damaged').filter((c) => c.dmg?.tags?.includes(tag));
for (const tier of [5, 6]) for (const elite of [false, true]) test(`风暴潮 air-only automation retains ground-only normal attacks (${tier}/${elite})`, () => {
const { h, u } = field({ tier, elite, skill: 1, flags: { startOpCooldown: 3 } });
const e = h.spawn('fly', { pos: [10, 6] }); u.skill.gainSp(999, 'init');
h.run(2); assert.equal(u.skill.activations, 0, 'operation cooldown still applies');
assert.ok(h.runUntil(() => u.skill.active, 2)); h.run(3);
const damaged = h.hooksOf('damaged').filter(c => c.source === u && c.target === e);
assert.ok(damaged.length > 0, 'the vortex hits the air-only target');
assert.ok(damaged.every(c => !c.dmg.isAttack), 'normal attacks remain ground-only'); done(h);
});
test('风暴潮 auto-cast rejects distant/hidden/sleeping flyers and waits through stun', () => {
const { h, u } = field({ skill: 1 }); u.skill.gainSp(999, 'init');
h.spawn('fly', { pos: [12, 12] }); h.run(0.3); assert.equal(u.skill.activations, 0);
const e = h.spawn('fly', { pos: [10, 6] }); h.b.addBuff(e, { key: 'test:hide', flags: { stealth: true } });
h.run(0.3); assert.equal(u.skill.activations, 0);
h.b.removeBuff(e, 'test:hide'); h.b.applyStatus(e, 'sleep', { duration: 0.5 });
h.run(0.3); assert.equal(u.skill.activations, 0);
h.b.applyStatus(u, 'stun', { duration: 0.6 }); h.run(0.4); assert.equal(u.skill.activations, 0);
h.run(0.5); assert.equal(u.skill.activations, 1); done(h);
});
test('克莱门莎 is selectable with authored skills in both slots, every form and potential', () => {
assert.ok(KITTED_CHARS.includes(C));
for (const tier of [5, 6]) {
@@ -186,6 +206,22 @@ test('风暴潮 normal attack hits five ground enemies in total, never all neigh
assert.equal(victims.size, 5); assert.ok(!victims.has(fly)); done(h);
});
for (const elite of [false, true]) test(`风暴潮 splash uses the published 1.1 collision radius and 100% ATK (${elite})`, () => {
const { h, u } = field({ skill: 1, elite }); start(u); h.run(1.6); h.b.rng = () => 0.99;
const main = h.spawn('dummy', { pos: [10, 4] });
const near = h.spawn('dummy', { pos: [10, 5] });
const outside = h.spawn('dummy', { pos: [10, 5.2] });
const body = h.spawn('dummy', { pos: [10, 5.8] });
body.hitArea = { w: 1.6, h: 1, dx: 0, dy: 0 };
const hp = [main, near, outside, body].map(e => e.hp);
performAttack(h.b, u, effectiveProfile(u), [main]);
approx(hp[0] - main.hp, u.s.atk * u.skill.bb['attack@aoe_atk_scale']);
approx(hp[1] - near.hp, u.s.atk);
assert.equal(outside.hp, hp[2], 'a point outside radius 1.1 is not splashed');
approx(hp[3] - body.hp, u.s.atk, 'a large collision body overlapping radius 1.1 is splashed');
done(h);
});
test('与海为敌 normal attacks hit three and spend no ammunition; remote erosion bursts fire three cross-shaped mixed hits', () => {
const { h, u } = field({ skill: 2 }); start(u); h.b.rng = () => 0.99;
const close = Array.from({ length: 3 }, () => h.spawn('dummy', { pos: [10, 4] }));
+3 -1
View File
@@ -272,7 +272,9 @@ test('config: modes, rounds and templates', () => {
assert.equal(config.economy.benchSize, 10);
assert.equal(config.economy.deployCap, 8);
assert.equal(config.economy.mergeCountOverrides.chess_char_2_11_a, 2);
assert.equal(config.economy.poolCopiesOverrides.chess_char_6_11_a, 4);
assert.equal(config.economy.poolCopiesOverrides.chess_char_2_11_a, 8);
assert.equal(config.economy.coopPoolMultiplier, 2);
assert.equal(config.economy.itemPoolCopiesOverrides.chess_item_5_07_e_a, 2);
assert.deepEqual(config.hiddenCore.single, 350);
assert.deepEqual(config.hiddenCore.multi, 1200);
assert.equal(config.titles.length, 6);
+1 -1
View File
@@ -11,7 +11,7 @@ const MATCHES = read('./golden/matches.json');
const BACKUPS = read('../data/backups.json');
test('matches: the 自选 match\'s human seat draws its 自选 pieces in its shop and fields 推进之王 in battle', () => {
const [id, dg] = Object.entries(MATCHES.scenarios).find(([k]) => /-diy$/.test(k)) || [];
const [id, dg] = Object.entries(MATCHES.scenarios).find(([k]) => /-coverage-diy$/.test(k)) || [];
assert.ok(dg, 'scenario present');
assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 }, id);
assert.deepEqual(Object.keys(dg.diy.picks).sort(), Object.keys(BACKUPS.diy.slots).sort(), 'all four slots filled');
+1 -1
View File
@@ -52,7 +52,7 @@ test('standins: every active skill a chess names for its stand-in is cast; passi
});
test('matches: the 补位 match\'s human seat fields stand-ins (listed per round) and the match ends without errors', () => {
const dg = MATCHES.scenarios['coop2-NORMAL-14-standins'];
const dg = MATCHES.scenarios['coop2-NORMAL-1-coverage-standins'];
assert.ok(dg, 'scenario present');
assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 });
const fielded = dg.standIns.rounds.filter(([, s]) => s).flatMap(([, s]) => s.split(' '));
+3 -1
View File
@@ -17,7 +17,7 @@ through.
| `roster` | 49 battles: every visible chess record (normal and elite) with every selectable skill and module (DESIGN §16), 12 operators per battle on a real stage (all 11 in turn) against the round's real wave three times over, every non-leader enemy kind of `data/enemies.json` as extra spawns (half of them bounties), placeable summons on the board, every equipment item, band, battle-side 机变 card and stage map card in turn, bonds from the board |
| `bonds` | 46 battles: every bond at its activation threshold (1 layer) and at its top tier (999 layers) |
| `fields` | 22 battles: every Final Assault / Hidden Core leader on a pair and a solo template (shared pool, 200 s cap) and the 联防 field with 1 and 2 helpers on the round's stage, both halves (carried HP / SP, a knocked-out operator, two leakers' enemies) |
| `matches` | 18 matches to the end in virtual time: 16 bot-only (solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core), one co-op match whose human seat (AI 托管, offline) does not own 9 NORMAL chess — they fight as their 补位 stand-ins (its digest lists them per round, `standIns`) — and one whose human seat slots 自选 picks (推进之王 and prototypes) with its 调度中心 at level 5 from the first prep: its own shop draws them and its AI fields 推进之王 (its digest lists per round the 自选 shop draws and the fielded pieces, `diy`) |
| `matches` | 20 matches to the end in virtual time: 16 bot-only (solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core), one co-op match whose human seat (AI 托管, offline) does not own 9 NORMAL chess — they fight as their 补位 stand-ins (its digest lists them per round, `standIns`) — and one whose human seat slots 自选 picks (推进之王 and prototypes) with its 调度中心 at level 5 from the first prep: its own shop draws them and its AI fields 推进之王 (its digest lists per round the 自选 shop draws and the fielded pieces, `diy`) |
| `standins` | 10 battles: every NORMAL chess record (normal and elite, 110) fielded as its 补位 stand-in (`standIn: true`: the stand-in's body, backup skill / module and kit — all 17 stand-ins, 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 plus 8 ground enemy kinds (melee stand-ins always meet an enemy), an item each, bonds from the board |
| `diy` | battles of 自选 pieces (a DIY slot with its `diy` pick, `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 skill, then every prototype pick with a kit at its locked selection, normal and elite, 12 per battle against the round's real wave; a new operator kit adds its scenarios (the battle count grows with the kits) |
@@ -58,3 +58,5 @@ node tools/golden.mjs --twice # determinism: the corpus twice
- A new engine hook, event or chess record does not change the digests of the existing scenarios by itself — the
hook list is frozen in the tool and the corpus is regenerated only on `golden:update` — but new data records do join
the corpus there (the roster family enumerates `data/chess.json`).
After the measured shared-stock update (DESIGN §29.8), the original stand-in / DIY seeds remain as outcome regressions even when they field no such operator. Companion seeds `coop2-NORMAL-1-coverage-standins` and `coop2-NORMAL-5-coverage-diy` exercise those full paths; the corpus now contains 287 scenarios.
+39 -39
View File
@@ -5850,38 +5850,38 @@
"round": 13,
"seed": 3994643046,
"waveId": "act1autochess_h06",
"time": 198.03,
"ticks": 5941,
"reason": "cleared",
"killed": 150,
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 149,
"total": 150,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 1626,
"rngDraws": 1637,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":150,"total":150,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1661420,"bossDmg":0,"heal":56685,"deaths":1,"layerGains":{}}},
"players": {"p1":{"killed":149,"total":150,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":1648194,"bossDmg":0,"heal":65699,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",0,null,2244,1,5,1,6436,260,0,9,2],
["p1",2,"op","chess_char_5_diy2_a",1,null,2244,1,7,29,321186,4202,0,103,5],
["p1",3,"op","chess_char_5_diy1_a",2,null,2244,1,10,42,323043,24114,0,196,2],
["p1",4,"op","chess_char_5_diy2_b",0,"none",2740.1,1,11,0,17637,2190,0,32,8],
["p1",5,"op","chess_char_5_diy1_b",1,"none",2740.1,1,13,54,636268,3391,0,191,6],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1833.1,1,145,24,356851,9329,0,109,2],
["p1",1,"op","chess_char_5_diy1_a",0,null,2244,1,5,0,795,0,0,2,0],
["p1",2,"op","chess_char_5_diy2_a",1,null,2244,1,7,17,177136,4652,0,104,5],
["p1",3,"op","chess_char_5_diy1_a",2,null,2244,1,10,26,292321,14630,0,171,2],
["p1",4,"op","chess_char_5_diy2_b",0,"none",2740.1,1,11,1,23925,219,0,21,5],
["p1",5,"op","chess_char_5_diy1_b",1,"none",2740.1,1,13,44,563489,2024,0,171,6],
["p1",6,"op","chess_char_5_diy2_b",2,"none",2677.81,1,15,61,590529,13446,0,162,2],
["p1",7,"op","chess_char_6_diy1_b",0,"none",1818.3,1,8,0,0,0,0,0,0],
["p1",8,"op","chess_char_6_diy2_b",1,"none",1818.3,1,12,0,0,0,0,0,0],
["p1",9,"op","chess_char_6_diy1_b",2,"none",1818.3,1,14,0,0,0,0,0,0],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_002_monstr",1818.3,1,4,0,0,0,24672,66,16],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_002_monstr",1818.3,1,6,0,0,0,22077,56,2],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_monstr",1818.3,1,9,0,0,16846,9935,34,0],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_002_monstr",1818.3,1,4,0,0,0,38426,75,18],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_002_monstr",1818.3,1,6,0,0,0,17126,50,2],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_monstr",1818.3,1,9,0,0,30791,10147,31,0],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1249_lysdb_2":[60,60,0,22385,671846],"enemy_1251_lysyta":[51,51,0,170,261740],"enemy_1329_cbshld":[39,39,0,37777,727834]},
"hooks": [1,166,5941,1054,1054,2053,2565,473,151,151,43,39,24,10083,0,64,150,0,1,151,0,47,0],
"events": {"atk":1558,"deploy":13,"die":151,"dmg":2565,"engage":6,"fx":855,"heal":194,"skill":82,"spawn":165,"status":97},
"fx": {"anchor":11,"blast":72,"burst":47,"lock":24,"shieldBreak":60,"teleport":630,"tide":11},
"snaps": "b5491880 3d470ed3 e24470a9 9efdac52 682ba7c7 5053e7b1 372bc538 4f6990b6 78e9810f 2e63af8e c036a7f6 85430b6a f0f306e0 83b41054 a23bd5e0 216b4871 ad7483be 0e1ae0c5 b71814eb 25d90322"
"enemies": {"enemy_1249_lysdb_2":[60,60,0,13932,671846],"enemy_1251_lysyta":[51,51,0,261,261740],"enemy_1329_cbshld":[39,38,0,51568,714608]},
"hooks": [1,165,6900,1038,1038,2096,2633,480,149,149,40,38,30,12173,0,68,150,0,1,149,0,46,0],
"events": {"atk":1587,"deploy":12,"die":149,"dmg":2633,"engage":6,"fx":973,"heal":194,"skill":78,"spawn":165,"status":93},
"fx": {"anchor":11,"blast":90,"burst":46,"lock":30,"shieldBreak":60,"teleport":725,"tide":11},
"snaps": "d4a56f82 64456d40 69d788a6 1a64a697 4c406731 aced5db8 c80b4ecb b75d0c6b d00450e6 c01e3305 a7cf048f b62cb632 1d43a004 027135f0 c43aadd9 edcbe0cc f2c9091e 7ca44db3 1842ebec c818f908 8b4dc80f 94959613 0d364901"
},
"diy-136": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 6_diy1_a=clemnt/S1 6_diy2_a=clemnt/S2 6_diy1_a=clemnt/S3 6_diy2_b=clemnt/S1 6_diy1_b=clemnt/S2 6_diy2_b=clemnt/S3 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe",
@@ -5895,35 +5895,35 @@
"time": 212.4,
"ticks": 6372,
"reason": "cleared",
"killed": 13,
"killed": 12,
"total": 123,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 358,
"rngDraws": 336,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":13,"total":123,"perfect":false,"leaks":110,"leaksUncounted":0,"coins":0,"dmg":651626,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}},
"players": {"p1":{"killed":12,"total":123,"perfect":false,"leaks":111,"leaksUncounted":0,"coins":0,"dmg":540205,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,107,0,28,4256,0,2,1],
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,127,0,702,5816,0,6,1],
["p1",3,"op","chess_char_6_diy1_a",0,null,0,0,136,0,7472,4488,0,8,1],
["p1",4,"op","chess_char_6_diy2_a",1,null,0,0,147,7,383594,4488,0,38,1],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,86,0,1871,4488,0,7,0],
["p1",6,"op","chess_char_6_diy2_b",0,"none",0,0,82,1,26868,5480,0,25,5],
["p1",7,"op","chess_char_6_diy1_b",1,"none",0,0,79,5,136419,5480,0,15,1],
["p1",8,"op","chess_char_6_diy2_b",2,"none",0,0,74,0,11047,5480,0,15,0],
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,107,0,41,4256,0,3,1],
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,127,0,686,5816,0,5,1],
["p1",3,"op","chess_char_6_diy1_a",0,null,0,0,136,0,5820,4488,0,8,1],
["p1",4,"op","chess_char_6_diy2_a",1,null,0,0,147,6,320372,4488,0,38,1],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,87,0,2545,4488,0,10,0],
["p1",6,"op","chess_char_6_diy2_b",0,"none",0,0,82,1,7708,5480,0,16,3],
["p1",7,"op","chess_char_6_diy1_b",1,"none",0,0,79,5,121380,5480,0,14,1],
["p1",8,"op","chess_char_6_diy2_b",2,"none",0,0,74,0,9062,5480,0,15,0],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,91,0,6214,5128,0,11,1],
["p1",10,"op","chess_char_5_diy2_b",2,"none",3334,1,149,0,469,6668,0,8,1],
["p1",11,"op","chess_char_5_diy1_a",2,null,1271,1,4,0,36941,0,0,186,3],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1510,1,5,0,40002,0,0,191,3],
["p1",10,"op","chess_char_5_diy2_b",2,"none",3334,1,149,0,494,6668,0,8,2],
["p1",11,"op","chess_char_5_diy1_a",2,null,1271,1,4,0,31537,0,0,186,3],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1510,1,5,0,34346,0,0,191,3],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1006_shield_3":[21,0,21,21703,200406],"enemy_1043_zomsbr":[66,13,53,7416,374461],"enemy_1402_tgshd_2":[36,0,36,20624,172729]},
"hooks": [1,149,6372,612,612,2274,2370,18794,33,143,18,18,0,15658,0,43,123,110,1,33,0,12,0],
"events": {"atk":612,"deploy":23,"die":33,"dmg":2370,"engage":12,"fx":298,"leak":110,"skill":36,"spawn":138,"status":104},
"fx": {"anchor":2,"burst":12,"expose":254,"teleport":28,"tide":2},
"snaps": "86776e30 c1bad963 559e3264 46aa7ee9 3425ff8c 70c737e8 aa3c0e2a 1d8b3ed4 c6110eba 814d43d6 e77ad147 812eda91 095ac019 ef11e0a6 6c647700 5b7d036c ee81eb83 3567cf14 ae42e2ad 3b143a2e 94d57690 805579e9"
"enemies": {"enemy_1006_shield_3":[21,0,21,23335,174202],"enemy_1043_zomsbr":[66,12,54,6877,334513],"enemy_1402_tgshd_2":[36,0,36,19209,128930]},
"hooks": [1,149,6372,593,593,2269,2376,18508,32,143,17,17,0,15626,0,43,123,111,1,32,0,16,0],
"events": {"atk":593,"deploy":23,"die":32,"dmg":2376,"engage":12,"fx":302,"leak":111,"skill":34,"spawn":138,"status":112},
"fx": {"anchor":2,"burst":16,"expose":254,"teleport":28,"tide":2},
"snaps": "c41bdf1e ba9689e9 95a94805 6a5c07f5 6a39faef baaf84d6 c789d836 720ae281 580554e9 16c94931 4134a0d2 14068077 5c33c2f3 7c5d0f33 12b17543 7301d5e3 93e46c21 91620dab e2816266 67997963 515c8b4e b7f90d0f"
},
"diy-137": {
"about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo",
+795 -569
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -50,7 +50,7 @@ describe('extract.py helpers (no UnityPy needed)', { skip: !PY && 'no python3' }
assert.equal(enemySpines.sub, 'spine/enemy');
// every listed id is a token of the game data without a web model (data/assets.json tokens[id].spine), sorted, unique
const ids = tokenSpines.ids;
assert.equal(ids.length, 39);
assert.equal(ids.length, 40);
assert.deepEqual([...ids].sort(), ids);
assert.equal(new Set(ids).size, ids.length);
const tokens = JSON.parse(readFileSync(path.join(ROOT, 'data/tokens.json'), 'utf8'));
@@ -58,7 +58,8 @@ describe('extract.py helpers (no UnityPy needed)', { skip: !PY && 'no python3' }
const web = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')).tokens;
for (const id of ids) {
assert.ok(tokens[id] || backups.tokens[id], `${id}: a token of the game data`);
assert.equal(web[id]?.spine, undefined, `${id}: no web model`);
if (id === 'token_10031_swire2_gdtrap') assert.ok(web[id]?.spine, 'champagne has a web skin fallback');
else assert.equal(web[id]?.spine, undefined, `${id}: no web model`);
}
// the 自选 summons whose prefab draws nothing (an EmptyAnimator: displayType HIDDEN / an effect) are left out
for (const id of ['token_10039_ulpia_block', 'token_10055_phatm2_mndclv', 'token_10058_sbell2_icetgt', 'token_10065_demetr_dmtpos',
+10 -8
View File
@@ -1,4 +1,4 @@
// test/local-token-models.test.js — the token (summon) models only the local client has (0.2.0): 39 summons — most of the
// test/local-token-models.test.js — the token (summon) models only the local client has (0.2.0): 40 summon overlays — most of the
// 自选 picks' (data/backups.json `tokens`), 凯瑟琳's 爬行号·防护单元 and 凛御银灰's 风雪之眼 — have no battle Spine in
// any community dump (fetch-assets reported "missing skel"), so the client drew their avatar diamond. Their official
// skeletons come from the battle token packs of the local client (tools/local-extract/extract.py TOKEN_SPINES →
@@ -55,7 +55,7 @@ function localManifest(ids, drop = null) {
describe('the 自选 summon models from the local client are an optional overlay', () => {
test('the committed metadata: the TOKEN_SPINES of extract.py, each a skeleton + same-stem atlas + pages, its roles on its own clips', () => {
assert.equal(IDS.length, 39);
assert.equal(IDS.length, 40);
if (TOKEN_SPINES) assert.deepEqual(IDS, [...TOKEN_SPINES].sort(), 'one entry per extract.py TOKEN_SPINES id');
for (const id of IDS) {
const m = COMMITTED[id];
@@ -116,11 +116,13 @@ describe('the 自选 summon models from the local client are an optional overlay
assert.deepEqual(withLocal, IDS);
for (const id of IDS) {
assert.deepEqual(MANIFEST.tokens[id].spineLocal, { group: `spine/token/${id}`, ...COMMITTED[id] }, `${id}: the committed metadata`);
assert.equal(MANIFEST.tokens[id].spine, undefined, `${id}: no web model (it was the avatar diamond)`);
assert.equal(spineEntry(MANIFEST, id), null, `${id}: without the local art the client keeps the diamond`);
const web = MANIFEST.tokens[id].spine;
if (id === 'token_10031_swire2_gdtrap') assert.ok(validSpine(web), 'champagne keeps its web skin fallback');
else assert.equal(web, undefined, `${id}: no web model`);
assert.deepEqual(spineEntry(MANIFEST, id), web || null, `${id}: without local art keep the existing fallback`);
}
// the tokens that always had a web model keep it, without an overlay
for (const [id, t] of Object.entries(MANIFEST.tokens)) if (t.spine) { assert.ok(validSpine(t.spine), id); assert.equal(t.spineLocal, undefined, id); }
for (const [id, t] of Object.entries(MANIFEST.tokens)) if (t.spine) { assert.ok(validSpine(t.spine), id); if (id !== 'token_10031_swire2_gdtrap') assert.equal(t.spineLocal, undefined, id); }
});
test('the committed metadata is what the extracted models parse to', { skip: !IDS.every((id) => existsSync(path.join(ASSETS, 'local/spine/token', id))) && 'token models not extracted (tools/local-extract)' }, async () => {
@@ -166,10 +168,10 @@ describe('client: the official summon model only when data/local-assets.json lis
assert.equal(e.pma, true);
assert.deepEqual(e.anims, m.anims);
assert.equal(e.local, true);
assert.equal(e.fallback, null, 'no web model to fall back to');
assert.equal(e.fallback, MANIFEST.tokens[id].spine || null, 'retain the existing web model as fallback');
assert.equal(spineEntry(MANIFEST, id, { local }), e, 'one entry object per manifest pair');
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([]) }), null, 'not extracted → no model');
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([id], m.textures[0]) }), null, 'a page missing → no model');
assert.deepEqual(spineEntry(MANIFEST, id, { local: localManifest([]) }), MANIFEST.tokens[id].spine || null, 'not extracted → original fallback');
assert.deepEqual(spineEntry(MANIFEST, id, { local: localManifest([id], m.textures[0]) }), MANIFEST.tokens[id].spine || null, 'missing local page → original fallback');
}
assert.equal(spineEntry(MANIFEST, 'token_10028_vigil_wolf', { local }), MANIFEST.tokens.token_10028_vigil_wolf.spine, 'a web token unchanged');
const store = createAssets({ manifest: MANIFEST, localManifest: local });
+4 -2
View File
@@ -254,8 +254,10 @@ test('withdrawing a deployed summon into a full hand: HAND_FULL like any other c
assert.ok(drone && drone.count === 1, 'one 狼群');
// (9,6): inside 伺夜's range ("只能部署在召唤者攻击范围内", player report #9 after 0.1.0)
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'board', row: 9, col: 6 } }), { ok: true });
// golden items never merge with each other: a plain full hand
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, 'chess_item_1_02_e_b', 'hand', i);
// One distinct golden per empty slot: goldens do not merge, and each base stays within its shared cap.
const fillers = Object.values(DATA.items).filter((r) => r.isGolden && r.itemType === 'EQUIP' && (m.gd.itemPoolCopies(r.id) ?? 0) >= 2).map((r) => r.id);
let n = 0;
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, fillers[n++], 'hand', i);
assert.ok(ps.hand.every(Boolean) && ps.tempEmpty);
assert.equal(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'a new card for a full hand');
assert.equal(ps.board.get('9,6'), drone, 'the 狼群 stays on the board');
+15
View File
@@ -68,6 +68,21 @@ test('调和: +1 to core bonds with ≥ 1 real member on the board while active'
assert.equal(s.yanShip.count, 2);
});
test('actual normal/elite 缪尔赛思 activates every available core bond at two real members; duplicates add only one', () => {
for (const id of gd.bondIds.filter(id => gd.bond(id).isCore && !gd.modeInactiveBonds.has(id))) {
const two = members(id, 2, c => !c.bonds.includes('maniShip'));
assert.equal(two.length, 2, id);
for (const mu of ['chess_char_6_11_a', 'chess_char_6_11_b']) {
const s = computeBonds(gd, state({ board: [piece(mu), ...two.map(id => piece(id))] }));
assert.equal(s[id].count, 3, `${id}: two operators plus ${mu}`); assert.ok(s[id].active);
}
const dup = computeBonds(gd, state({ board: [piece('chess_char_6_11_a'), piece('chess_char_6_11_b'), ...two.map(id => piece(id))] }));
assert.equal(dup[id].count, 3, `${id}: duplicate harmony is not +2`);
const hand = computeBonds(gd, state({ board: two.map(id => piece(id)), hand: [piece('chess_char_6_11_a')] }));
assert.equal(hand[id].count, 2, `${id}: harmony needs deployment`);
}
});
test('独行 (downward): active only while exactly 1 distinct 独行 operator is on the board', () => {
const solo = members('soloShip', 2);
assert.equal(tierFor(DATA.bonds.soloShip, 1), 1);
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
test('a solo bot used for rehearsal has no AI teammate and gets no extra grace', () => {
const h = makeMatch({ mode: 'solo', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true }], fake: true, instant: false }).start();
const m = h.m; m.setDeadline(0); m.order[0].lp = 40; m.round = m.gd.bossRound;
m.startFinalAssault(false);
assert.equal(m.bossOvertimeStartReal, m.gd.bossLevelTime(m.round)); m.dispose();
});
for (const hidden of [false, true]) for (const bots of [0, 1]) {
test(`boss overtime follows the visible clock unless an AI teammate is present (hidden=${hidden}, bots=${bots})`, () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots, fake: true, instant: false }).start();
const m = h.m;
m.setDeadline(0);
for (const p of m.order) p.lp = 40;
m.round = hidden ? m.gd.hiddenRound : m.gd.bossRound;
if (hidden) m.teamLp = 100;
m.startFinalAssault(hidden);
const level = m.gd.bossLevelTime(m.round);
const after = bots ? m.gd.bossOvertimeAfterReal : level;
assert.equal(m.overtimeAt, m.sched.now() + after * m.gd.combatTimeScale / m.gameSpeed * 1000);
const lp = m.teamLp;
m._applyOvertime((after + 0.9) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp);
m._applyOvertime((after + 1) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp - 1);
m._applyOvertime((after + 1) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp - 1, 'no repeated deduction');
m._applyOvertime((after + 3) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp - 3);
m.dispose();
});
}
+17
View File
@@ -22,6 +22,23 @@ import { DATA, makeMatch, checkInvariants } from './harness.js';
const gd = new GameData(DATA, 'mode_multi_hard');
test('client result names DIY and stand-in operators from the authoritative lineup, never the client or empty slot (#469)', () => {
const ordinary = Object.values(DATA.chess).find(c => c.visible && !c.isGolden && gd.standIn(c.chessId));
const cases = [
{ chessId: 'chess_char_6_diy1_a', diy: { charId: 'char_4231_clemnt', skillIndex: 2, uniEquipId: null }, expected: '克莱门莎' },
{ chessId: ordinary.chessId, standIn: true, expected: gd.standIn(ordinary.chessId).name },
];
for (const { expected, ...unit } of cases) {
const spec = buildBattleSpec({ battleId: 'names', fieldId: 'n:p', kind: 'normal', seed: 1, round: 3, timeLimit: 60,
players: [{ playerId: 'p', units: [{ uid: 7, kind: 'chess', row: 10, col: 3, items: [], ...unit }], bonds: {} }],
spawns: [{ enemyKey: plainKey, count: 1, time: 1 }], flags: { layerGainsEnabled: true } });
const raw = { reason: 'cleared', time: 20, killed: 1, total: 1, errors: 0,
perPlayer: { p: perPlayer({ killed: 1, total: 1, unitStats: [{ uid: 7, defId: 'forged', name: 'forged', kind: 'op', dmg: 1e6 }] }) } };
const v = validateClientResult(spec, raw, { gd }); assert.ok(v.ok, v.reason);
assert.equal(v.result.perPlayer.p.unitStats[0].name, expected);
}
});
test('eliminated humans auto-observe the first field at COMBAT start (display replica) and may switch freely', () => {
const h = makeMatch({ mode: 'coop', humans: 3, bots: 1, seed: 9301, fake: true, clientCombat: true, instant: false, pace: 'paced',
script: () => ({ duration: 6 }) }).start();
+5 -5
View File
@@ -286,7 +286,7 @@ test('Final Assault: two client-run pair fields share the server pool (b.progres
m.dispose();
});
test('Final Assault: leaks and leader LP effects from b.progress drain the team LP; overtime after 150 real s at 1 LP/real s; team LP 0 → b.end → defeat', () => {
test('Final Assault: leaks and leader LP effects from b.progress drain the team LP; all-human overtime after 120 real s at 1 LP/real s; team LP 0 → b.end → defeat', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9109, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 0, leakEvents: [{ at: 2, lpr: 3 }], lpLossEvents: [{ at: 3, amount: 2 }] } : { duration: 2 }) }).start();
const m = h.m;
@@ -296,14 +296,14 @@ test('Final Assault: leaks and leader LP effects from b.progress drain the team
h.sched.advance(4000);
assert.equal(m.teamLp, lp0 - 5, 'leak lpr 3 + leader effect 2');
const lp1 = m.teamLp;
// overtime (bossTurnHpReduceTime): 150 REAL s like the level's 120 s countdown, then 1 LP per real second
// All-human teams start overtime at the visible level countdown, without the AI team's extra 30 seconds.
const t0 = m._bossStartAt;
const pub = () => h.bc.filter((x) => x.t === 'm.public').pop();
m.flush(true);
assert.equal(pub().deadline, t0 + 120000, 'the HUD counts the boss level\'s 120 s down');
assert.equal(pub().overtimeAt, t0 + 150000, 'and knows when the drain starts');
h.sched.advance(t0 + 150000 - m.sched.now() + 500);
assert.equal(m.teamLp, lp1, 'no drain before 150 real s (the countdown ran out at 120 s, the battle went on)');
assert.equal(pub().overtimeAt, t0 + 120000, 'and knows when the drain starts');
h.sched.advance(t0 + 120000 - m.sched.now() + 500);
assert.equal(m.teamLp, lp1, 'the first deduction is at the next whole real second');
h.sched.advance(5000);
assert.ok(m.teamLp <= lp1 - 4 && m.teamLp >= lp1 - 6, `overtime drain ≈ 5 LP over 5 real s (${lp1} → ${m.teamLp})`);
const end = h.runToEnd();
+5 -3
View File
@@ -191,9 +191,11 @@ test('#9 hand and temp full: a re-orientation that would push 狼群 out is refu
const toasts = [];
const toast = m.toast.bind(m);
m.toast = (p, kind, text) => { toasts.push(renderMessage(text)); return toast(p, kind, text); }; // string or msg()
const golden = Object.keys(DATA.items).find((id) => DATA.items[id].isGolden && DATA.items[id].itemType === 'EQUIP');
while (ps.hand.includes(null)) giveItem(m, ps, golden);
while (ps.temp.includes(null)) giveItem(m, ps, golden, 'temp');
// One distinct golden per empty slot: goldens do not merge, and each base stays within its shared cap.
const fillers = Object.values(DATA.items).filter((r) => r.isGolden && r.itemType === 'EQUIP' && (m.gd.itemPoolCopies(r.id) ?? 0) >= 2).map((r) => r.id);
let n = 0;
while (ps.hand.includes(null)) giveItem(m, ps, fillers[n++]);
while (ps.temp.includes(null)) giveItem(m, ps, fillers[n++], 'temp');
// facing LEFT would leave (11,5) outside her range and the wolf has nowhere to go: refused, nothing changes
assert.equal(move(m, vigil.uid, board(10, 4), 'LEFT').error, ERR.HAND_FULL);
assert.equal(ps.board.get('10,4').dir, 'RIGHT');
+3 -2
View File
@@ -77,6 +77,7 @@ for (const n of [1, 2, 3, 4]) {
assert.equal(pool.maxHp, bossPoolHp(m.gd, m.bossId, n));
for (const f of fields) {
assert.deepEqual(f.opts.rect, GEO.BOSS_RECT);
assert.equal(f.opts.modeId, 'mode_multi_funny', '#475: an unpaired field retains multiplayer mode despite its solo wave template');
assert.equal(f.opts.timeLimit, Infinity);
assert.equal(f.opts.flags.layerGainsEnabled, false);
assert.deepEqual(f.opts.flags.enemyScale, m.gd.enemyScale(14), 'the round\'s enemy effects for the leader\'s summons (PR #272)');
@@ -109,7 +110,7 @@ for (const n of [1, 2, 3, 4]) {
});
}
test('overtime: −1 team LP per REAL second after 150 real s (300 game s at 2×); team LP 0 ends every field → defeat (13 rounds passed)', () => {
test('all-human overtime: −1 team LP per REAL second after 120 real s (240 game s at 2×); team LP 0 ends every field → defeat (13 rounds passed)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 50, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1 } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
@@ -120,7 +121,7 @@ test('overtime: −1 team LP per REAL second after 150 real s (300 game s at 2×
const end = h.runToEnd();
assert.equal(end.victory, false);
assert.equal(end.roundsPassed, 13);
assert.ok(Math.abs(f.time - 340) < 2.5, `ended ≈ 300 + 2 × 20 game s (${f.time})`);
assert.ok(Math.abs(f.time - 280) < 2.5, `ended ≈ 240 + 2 × 20 game s (${f.time})`);
assert.equal(m.teamLp, 0);
m.dispose();
});
+2 -1
View File
@@ -222,7 +222,8 @@ function legacyInvariants(m) {
}
}
for (const [base, e] of pool.entries) {
assert.ok(e.left >= 0 && e.left <= e.cap, `pool ${base} left ${e.left} cap ${e.cap}`);
assert.ok(Number.isInteger(e.left) && e.left <= e.cap, `pool ${base} balance ${e.left} cap ${e.cap}`);
assert.equal(pool.left(base), Math.max(0, e.left), 'overdrawn ownership never becomes a negative shop supply');
assert.equal(e.left + (held.get(base) || 0), e.cap, `pool accounting ${base}: left ${e.left} + held ${held.get(base) || 0} != cap ${e.cap}`);
}
for (const [base, n] of held) if (!pool.has(base)) assert.equal(n, 0, `non-pool chess ${base} holds copies`);
+133
View File
@@ -0,0 +1,133 @@
// Jessica's shield placement uses the real Match actions and the client's drag-preview mirror. The only fixture
// grant is the selected operator; its shield is generated by normal deployment. No battle/UI is simulated here.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
import { placementContext, canPlace, boardTargets } from '../../public/js/ui/gameLogic/placement.js';
import { planLayout } from '../../server/match/bot.js';
const SLOT = 'chess_char_5_diy1_a';
const JESCA = 'char_1034_jesca2';
const SHIELD = 'token_10032_jesca2_jckshd';
const board = (row, col) => ({ area: 'board', row, col });
const move = (h, uid, to, dir) => h.m.handle('p_0', { t: 'g.move', uid, to, ...(dir ? { dir } : {}) });
const stackOf = (ps, owner) => [...ps.hand, ...ps.temp].find((p) => p?.kind === 'token' && p.ownerUid === owner.uid);
const ctx = (h, ps) => placementContext({ priv: ps.privateView(), stage: h.m.stage, editable: true,
getChess: (id) => ps.gd.chess(id), getToken: (id) => ps.gd.token(id), backups: ps.gd.raw.backups });
function prep(t, { row = 10, col = 4, skillIndex = 0, uniEquipId = null } = {}) {
const h = makeMatch({ mode: 'solo', seed: 11, seats: [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false,
connected: true, diy: { [SLOT]: { charId: JESCA, skillIndex, uniEquipId } } }] }).start();
t.after(() => h.m.dispose());
h.toPrep(1);
assert.equal(h.m.stageId, 'act2autochess_m04');
const ps = h.ps('p_0');
const owner = ps.acquireChess(SLOT, { source: 'grant' });
assert.ok(owner);
assert.deepEqual(move(h, owner.uid, board(row, col)), { ok: true });
assert.equal(stackOf(ps, owner)?.id, SHIELD);
return { h, ps, owner };
}
test('Jessica: hand shield refuses diagonal and distant tiles, with unchanged state and matching drag highlights', (t) => {
const { h, ps, owner } = prep(t, { row: 9, col: 5 });
const shield = stackOf(ps, owner);
for (const [row, col] of [[9, 7], [10, 4], [11, 5], [12, 3]]) {
const before = structuredClone(ps.privateView());
assert.equal(canPlace(ctx(h, ps), shield.uid, board(row, col)).code, 'BAD_TILE', `client ${row},${col}`);
assert.equal(move(h, shield.uid, board(row, col)).error, 'BAD_TILE', `server ${row},${col}`);
assert.deepEqual(ps.privateView(), before, 'rejected placement changes no inventory or board state');
}
assert.deepEqual(boardTargets(ctx(h, ps), shield.uid).legal.sort(), [[9, 4], [9, 6], [10, 5]].sort());
h.invariants();
});
test('Jessica: the server rejects distant and diagonal placements even when the client sends them directly', (t) => {
const { h, ps, owner } = prep(t, { row: 9, col: 5 });
const shield = stackOf(ps, owner);
for (const [row, col] of [[9, 7], [10, 4]]) {
assert.equal(move(h, shield.uid, board(row, col)).error, 'BAD_TILE');
assert.equal(stackOf(ps, owner), shield);
assert.ok(!ps.board.has(`${row},${col}`));
}
h.invariants();
});
test('Jessica: all four orthogonal tiles are legal regardless of her facing, skill or selected module', (t) => {
for (const [skillIndex, uniEquipId] of [[0, null], [1, 'uniequip_002_jesca2'], [2, 'uniequip_003_jesca2']]) {
const { h, ps, owner } = prep(t, { skillIndex, uniEquipId });
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
assert.deepEqual(move(h, owner.uid, board(10, 4), dir), { ok: true });
for (const [row, col] of [[9, 4], [11, 4], [10, 3], [10, 5]]) {
const shield = stackOf(ps, owner);
assert.equal(canPlace(ctx(h, ps), shield.uid, board(row, col)).ok, true);
assert.deepEqual(move(h, shield.uid, board(row, col)), { ok: true });
assert.deepEqual(move(h, ps.board.get(`${row},${col}`).uid, { area: 'hand', idx: ps.hand.findIndex((p) => !p) }), { ok: true });
}
}
h.invariants();
}
});
test('Jessica: a deployed shield cannot move or be swapped to a distant tile; adjacent moves and owner swaps work', (t) => {
const { h, ps, owner } = prep(t);
assert.deepEqual(move(h, stackOf(ps, owner).uid, board(10, 5)), { ok: true });
const shield = ps.board.get('10,5');
for (const [row, col] of [[11, 5], [9, 7]]) {
assert.equal(canPlace(ctx(h, ps), shield.uid, board(row, col)).code, 'BAD_TILE');
assert.equal(move(h, shield.uid, board(row, col)).error, 'BAD_TILE');
assert.equal(ps.board.get('10,5'), shield);
}
const other = ps.acquireChess('chess_char_1_02_a', { source: 'grant' });
assert.deepEqual(move(h, other.uid, board(9, 7)), { ok: true });
for (const [uid, target] of [[shield.uid, board(9, 7)], [other.uid, board(10, 5)]]) {
assert.equal(canPlace(ctx(h, ps), uid, target).code, 'BAD_TILE');
assert.equal(move(h, uid, target).error, 'BAD_TILE');
}
assert.equal(canPlace(ctx(h, ps), shield.uid, board(11, 4)).ok, true);
assert.deepEqual(move(h, shield.uid, board(11, 4)), { ok: true });
assert.equal(canPlace(ctx(h, ps), shield.uid, board(10, 4)).ok, true);
assert.deepEqual(move(h, shield.uid, board(10, 4)), { ok: true });
assert.equal(ps.board.get('11,4'), owner);
assert.equal(ps.board.get('10,4'), shield);
h.invariants();
});
test('Jessica: owner reorientation keeps the shield; moving her returns it and withdrawal removes it', (t) => {
const { h, ps, owner } = prep(t);
assert.deepEqual(move(h, stackOf(ps, owner).uid, board(10, 5)), { ok: true });
const shield = ps.board.get('10,5');
assert.equal(canPlace(ctx(h, ps), shield.uid, board(10, 5)).action, 'orient');
assert.deepEqual(move(h, shield.uid, board(10, 5), 'LEFT'), { ok: true });
assert.deepEqual(move(h, owner.uid, board(10, 4), 'UP'), { ok: true });
assert.equal(ps.board.get('10,5'), shield);
assert.deepEqual(move(h, owner.uid, board(9, 4)), { ok: true });
assert.ok(!ps.board.has('10,5'));
const returned = stackOf(ps, owner);
assert.equal(returned.count, 1);
assert.equal(canPlace(ctx(h, ps), returned.uid, board(10, 5)).code, 'BAD_TILE');
assert.equal(move(h, returned.uid, board(10, 5)).error, 'BAD_TILE');
assert.deepEqual(move(h, returned.uid, board(9, 5)), { ok: true });
assert.deepEqual(move(h, owner.uid, { area: 'hand', idx: ps.hand.findIndex((p) => !p) }), { ok: true });
assert.ok(![...ps.board.values(), ...ps.hand, ...ps.temp].some((p) => p?.ownerUid === owner.uid));
h.invariants();
});
test('Jessica: bots plan her shield on an orthogonal tile', (t) => {
const { h, ps, owner } = prep(t);
const shield = stackOf(ps, owner);
const plan = planLayout(h.m, ps, [shield], undefined, { occupied: new Set(ps.board.keys()) });
assert.ok(['9,4', '11,4', '10,3', '10,5'].includes(plan.get(shield.uid)));
h.invariants();
});
test('other summons keep their own placement rules, including a medical drone far from its owner', (t) => {
const { h, ps } = prep(t);
const silence = ps.acquireChess('chess_char_2_02_a', { source: 'grant' });
assert.deepEqual(move(h, silence.uid, board(9, 3)), { ok: true });
const drone = stackOf(ps, silence);
assert.ok(drone);
assert.equal(canPlace(ctx(h, ps), drone.uid, board(12, 8)).ok, true);
assert.deepEqual(move(h, drone.uid, board(12, 8)), { ok: true });
h.invariants();
});
+54
View File
@@ -0,0 +1,54 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../../server/index.js';
import { TestClient } from '../helpers/wsClient.js';
import { PHASE, EMOTES } from '../../shared/constants.js';
const ok = async (c, msg) => {
const r = await c.request(msg);
assert.equal(r.t, 'ok', JSON.stringify(r));
};
// Exercise the real lobby/session routing after the match has eliminated a leaker. Only the battle outcome is
// supplied directly: these tests target room ownership, not a particular operator composition or wave duration.
for (const leaverSeat of [0, 1]) for (const phase of [PHASE.PREP, PHASE.COMBAT, PHASE.UNITE]) {
test(`an eliminated ${leaverSeat === 0 ? 'host' : 'guest'} leaving ${phase} cannot evict the survivor`, async (t) => {
const errors = [], clients = [];
const srv = await startServer({ port: 0, host: '127.0.0.1', seedFn: () => 123,
log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } });
t.after(async () => { await Promise.all(clients.map(c => c.terminate())); await srv.close(); assert.deepEqual(errors, []); });
for (const name of ['Host', 'Guest']) {
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
clients.push(c); const w = await c.hello(name); c.id = w.playerId; c.token = w.token;
}
const [host, guest] = clients;
await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
const state = await host.waitFor('room.state');
await ok(guest, { t: 'room.join', code: state.code });
await ok(guest, { t: 'room.ready', ready: true });
await ok(host, { t: 'room.start' });
await host.waitFor('m.public', p => p.phase === PHASE.INFO_CHECK);
const room = srv.lobby.rooms.get(state.code), m = room.match;
// Stop phase progression without replacing the actual match/lobby/session implementation.
m.setDeadline(0); m.phase = phase; m.round = 2;
for (const ps of m.order) ps.lp = 20;
const leaver = clients[leaverSeat], survivor = clients[1 - leaverSeat];
const lost = m.players.get(leaver.id); lost.lp = 0; lost.eliminate(2);
await ok(leaver, { t: 'g.leave' });
const leftAgain = await leaver.request({ t: 'room.leave' });
assert.equal(leftAgain.code, 'NOT_IN_ROOM', 'the UI tolerates this only on the departing client');
const retained = await survivor.waitFor('room.state', r => r.code === state.code && !r.seats.some(s => s?.playerId === leaver.id && s.connected));
assert.ok(retained.seats.some(s => s?.playerId === survivor.id && s.connected));
assert.equal(room.seatOf(leaver.id).left, true);
assert.equal(retained.hostId, survivor.id);
assert.equal(srv.lobby.registry.byId(survivor.id).roomCode, state.code);
assert.equal(room.match, m); assert.equal(m.ended, false);
await ok(survivor, { t: 'g.emote', id: EMOTES[0] });
assert.equal(survivor.log.some(x => x.t === 'room.closed'), false);
await survivor.terminate();
const restored = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`); clients.push(restored);
const w = await restored.hello('Survivor', survivor.token); assert.equal(w.playerId, survivor.id);
assert.equal((await restored.waitFor('room.state')).code, state.code);
await ok(restored, { t: 'g.autoplay', on: false });
});
}
+6 -3
View File
@@ -108,11 +108,14 @@ test('bench vs board, half pairs, two 变形同构体, the equip order, distinct
// half pairs grant nothing
wearer(h, 'p_0', target, [HAMMER]);
assert.equal(count(ps, bond), 0, 'the bond item alone');
// A normal plus its golden occupy 3 of the tier-VI cap. The lone copy is a separate match so the pair can sit at that cap.
const alone = prep({ fake: true });
wearer(alone, 'p_0', target, [ISO]);
assert.equal(count(alone.ps('p_0'), bond), 0, '变形同构体 alone');
const h2 = prep({ fake: true });
wearer(h2, 'p_0', target, [ISO]);
const other = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier <= 2 && !c.bonds.includes(bond) && c.chessId !== target).chessId;
wearer(h2, 'p_0', other, [ISO, ISO_B]);
assert.equal(count(h2.ps('p_0'), bond), 0, '变形同构体 alone / two of them');
assert.equal(count(h2.ps('p_0'), bond), 0, 'two of them');
// order: 变形同构体 first
const h3 = prep({ fake: true });
const w = wearer(h3, 'p_0', target, [ISO, HAMMER], { board: false });
@@ -136,7 +139,7 @@ test('bench vs board, half pairs, two 变形同构体, the equip order, distinct
const h5 = prep({ fake: true });
wearer(h5, 'p_0', members(bond)[0], [ISO, HAMMER]);
assert.equal(count(h5.ps('p_0'), bond), 1);
for (const x of [h, h2, h3, h4, h5]) x.invariants();
for (const x of [h, alone, h2, h3, h4, h5]) x.invariants();
});
test('a teammate\'s views: m.public counts the wearer; the scouting UnitInfo and the battle\'s UnitInfo carry the items', () => {
+2 -2
View File
@@ -8,14 +8,14 @@ import { DATA, makeMatch, give, checkInvariants, chessOfTier } from './harness.j
const gdOf = (modeId = 'mode_multi_normal') => new GameData(DATA, modeId);
test('pool caps follow config (12/14/18/16/8/5, 缪尔赛思 4) and only visible, unbanned chess enter', () => {
test('co-op pool doubles the measured single-player capacities; only visible, unbanned chess enter', () => {
const gd = gdOf();
const pool = new SharedPool(gd, { banned: [] });
const caps = { 1: 12, 2: 14, 3: 18, 4: 16, 5: 8, 6: 5 };
assert.equal(pool.entries.size, gd.visibleChess.length);
assert.equal(pool.entries.size, 112);
for (const [id, e] of pool.entries) {
const expect = id === 'chess_char_6_11_a' ? 4 : caps[e.tier];
const expect = 2 * (DATA.config.economy.poolCopiesOverrides[id] ?? caps[e.tier]);
assert.equal(e.cap, expect, id);
assert.equal(e.left, e.cap);
assert.ok(DATA.chess[id].visible && !DATA.chess[id].isGolden);
+1 -1
View File
@@ -7,7 +7,7 @@ const OP = 'chess_char_1_06_a'; // 刺玫, the operator of the report
const SCROLL = 'chess_item_6_02_m';
const HAMMER = 'chess_item_1_01_e_a';
const SHIELD = 'chess_item_1_02_e_a';
const FILLER = 'chess_item_6_09_e_a'; // non-mergeable
const FILLER = 'chess_item_2_03_e_b'; // non-mergeable effect-only item: fill storage without exhausting shop stock
const OK = { ok: true };
function prep(t, { copies = 2, golden = false, items = [HAMMER] } = {}) {
+2 -1
View File
@@ -30,7 +30,8 @@ function equip(m, ps, owner, count) {
}
function fillStorage(ps) {
while ([...ps.hand, ...ps.temp].some((p) => p === null)) assert.ok(ps.acquireItem(EQUIPMENT, { silent: true }));
// Fill storage independently of the equipment-stock limit; the sold operator's real equipment remains unchanged.
while ([...ps.hand, ...ps.temp].some((p) => p === null)) assert.ok(ps.acquireItem('chess_item_2_03_e_b', { silent: true }));
}
function swapIntoTemp(m, ps, piece) {
+135
View File
@@ -0,0 +1,135 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GameData } from '../../server/match/gamedata.js';
import { DATA, makeMatch, give, checkInvariants } from './harness.js';
import { PHASE } from '../../shared/constants.js';
import { cardPickable } from '../../public/js/ui/choiceOverlay.js';
const PACK = 'chess_item_5_07_e_a';
const byName = name => Object.values(DATA.chess).find(c => !c.isGolden && c.name === name).chessId;
test('a duplicate draft card becomes unavailable when the preceding player exhausts equipment stock', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
a.acquireItem(PACK);
m.phase = PHASE.SP_DRAFT;
m.sp = { cards: [PACK, PACK, 'chess_item_1_01_e_a'].map((id, idx) => ({ idx, id, kind: 'item' })),
order: ['p_0', 'p_1'], idx: 0, picks: {}, taken: {}, untimed: true };
assert.ok(m.pickCard(a, 0).ok); assert.equal(m.spTurn(), 'p_1');
assert.equal(m.pickCard(b, 1).error, 'SOLD_OUT'); assert.equal(m.sp.picks.p_1, undefined);
const card = m.publicView().sp.cards[1]; assert.equal(card.soldOut, true);
assert.equal(cardPickable({ turnPid: 'p_1', pickOf: new Map() }, card, { myId: 'p_1', solo: false }), false);
assert.ok(m.pickCard(b, 2).ok); m.dispose();
});
test('measured operator capacities and exceptions: co-op doubles shared stock, never private DIY stock', () => {
const solo = new GameData(DATA, 'mode_single_normal'), coop = new GameData(DATA, 'mode_multi_normal');
for (const [name, n] of [['普罗旺斯', 10], ['跃跃', 10], ['风丸', 8], ['蒂比', 12], ['哈洛德', 14], ['缪尔赛思', 5]]) {
assert.equal(solo.poolCopies(byName(name)), n, name); assert.equal(coop.poolCopies(byName(name)), 2 * n, name);
}
assert.equal(coop.poolCopies('chess_char_6_diy1_a'), solo.poolCopies('chess_char_6_diy1_a'));
assert.equal(solo.itemPoolCopies(PACK), 2); assert.equal(coop.itemPoolCopies(PACK), 2);
});
test('shared equipment occupancy includes hand/equipped/elite, never displayed offers; destroying frees stock', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
assert.equal(m.itemPool.left(PACK), 2);
a.funds = 10; const slot = a.shop.slots.findIndex(s => s?.kind === 'item');
assert.ok(slot >= 0); a.shop.slots[slot] = { kind: 'item', id: PACK, basePrice: 1, sold: false, frozen: true };
a.pushItemOffer([PACK]); assert.equal(m.itemPool.left(PACK), 2, 'display reserves nothing');
const x = a.acquireItem(PACK), y = b.acquireItem(PACK); assert.ok(x && y);
assert.equal(m.itemPool.left(PACK), 0);
const money = a.funds;
assert.equal(a.buy(slot).error, 'SOLD_OUT'); assert.equal(a.funds, money); assert.equal(a.shop.slots[slot].sold, false);
assert.equal(a.pickReward(0).error, 'SOLD_OUT'); assert.equal(a.offers.length, 1);
const id = [...m.pool.entries.keys()].find(id => m.gd.tierOf(id) === 1), owner = give(m, a, id);
assert.ok(a.equip(x.uid, owner.uid).ok); assert.equal(m.itemPool.held(PACK), 2);
assert.ok(a.sell(owner.uid).ok); assert.equal(m.itemPool.held(PACK), 2, 'selling returns equipment to hand, not to stock');
assert.ok(a.destroy(x.uid).ok); assert.equal(m.itemPool.left(PACK), 1);
assert.ok(a.buy(slot).ok, 'a third purchase is legal after one of the two owned copies was destroyed');
assert.equal(m.itemPool.left(PACK), 0);
b.eliminate(1); assert.equal(m.itemPool.left(PACK), 1);
const gold = a.acquireItem(PACK); assert.ok(gold && m.gd.isGolden(gold.id));
assert.equal(m.itemPool.held(PACK), 2, 'one upgraded item occupies two normal copies');
assert.ok(a.destroy(gold.uid).ok); assert.equal(m.itemPool.left(PACK), 2);
checkInvariants(m); m.dispose();
});
test('整备 does not upgrade a purchase that would exceed the shared item cap', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
const KEY = 'effect:builtin_next_buy_golden_item';
assert.ok(b.acquireItem(PACK));
assert.equal(m.itemPool.left(PACK), 1);
a.effects.push({ id: 'zb', key: KEY, name: '整备', counter: 1, battle: false });
a.funds = 50;
const slot = a.shop.slots.findIndex((s) => s?.kind === 'item');
assert.ok(slot >= 0);
a.shop.slots[slot] = { kind: 'item', id: PACK, basePrice: 1, sold: false, frozen: true };
assert.ok(a.buy(slot).ok);
const piece = [...a.hand, ...a.temp].find((p) => p && m.gd.baseIdOf(p.id) === PACK);
assert.ok(piece);
assert.equal(m.gd.isGolden(piece.id), false, 'the last copy stays normal');
assert.equal(a.effects.find((e) => e.key === KEY).counter, 1, 'the charge is kept');
assert.equal(m.itemPool.held(PACK), m.itemPool.cap(PACK));
checkInvariants(m);
const owned = [...b.hand, ...b.temp].find((p) => p && m.gd.baseIdOf(p.id) === PACK);
assert.ok(b.destroy(owned.uid).ok);
assert.equal(m.itemPool.left(PACK), 1, 'the freed copy is available again');
checkInvariants(m);
m.dispose();
});
test('整备 upgrades when the extra golden copy fits, including an uncapped item', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0');
const KEY = 'effect:builtin_next_buy_golden_item';
const uncapped = 'chess_item_2_03_e_a';
assert.equal(m.itemPool.cap(uncapped), null);
assert.equal(m.itemPool.left(PACK), 2);
a.effects.push({ id: 'zb', key: KEY, name: '整备', counter: 1, battle: false });
a.funds = 50;
const slot = a.shop.slots.findIndex((s) => s?.kind === 'item');
assert.ok(slot >= 0);
a.shop.slots[slot] = { kind: 'item', id: PACK, basePrice: 1, sold: false, frozen: true };
assert.ok(a.buy(slot).ok);
const piece = [...a.hand, ...a.temp].find((p) => p && m.gd.baseIdOf(p.id) === PACK);
assert.ok(piece && m.gd.isGolden(piece.id));
assert.equal(m.itemPool.held(PACK), 2);
assert.equal(a.effects.some((e) => e.key === KEY), false, 'the charge is spent');
a.effects.push({ id: 'zb2', key: KEY, name: '整备', counter: 1, battle: false });
const slot2 = slot === 0 ? 1 : 0;
assert.ok(a.shop.slots.length > slot2);
a.shop.slots[slot2] = { kind: 'item', id: uncapped, basePrice: 1, sold: false, frozen: true };
assert.ok(a.buy(slot2).ok);
const free = [...a.hand, ...a.temp].find((p) => p && m.gd.baseIdOf(p.id) === uncapped);
assert.ok(free && m.gd.isGolden(free.id), 'an uncapped item may still become golden');
checkInvariants(m);
m.dispose();
});
test('consumed equipment releases its stock; effect-only items have no shared shop cap', () => {
const h = makeMatch({ mode: 'solo', seed: 11, fake: true }).start().toPrep();
const m = h.m, p = h.ps('p_0'), id = [...m.pool.entries.keys()][0], owner = give(m, p, id);
const item = 'chess_item_1_04_e_a', before = m.itemPool.left(item);
const piece = p.acquireItem(item); assert.ok(piece); assert.equal(m.itemPool.left(item), before - 1);
assert.ok(p.equip(piece.uid, owner.uid).ok); assert.equal(p.find(piece.uid), null); assert.equal(m.itemPool.left(item), before);
assert.equal(m.itemPool.cap('chess_item_2_03_e_a'), null);
checkInvariants(m); m.dispose();
});
test('Mimic can exceed the pool; excess copies keep stock empty until enough ownership leaves', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 11, fake: true }).start().toPrep();
const m = h.m, p = h.ps('p_0');
const id = [...m.pool.entries.keys()].find(id => m.gd.tierOf(id) === 6);
assert.equal(m.pool.cap(id), 5);
for (let i = 0; i < 5; i++) p.acquireChess(id);
const target = p.allChess().find(c => c.id === id);
assert.ok(target); assert.equal(m.pool.left(id), 0);
const mimic = p.acquireItem('chess_item_5_05_e_a'); assert.ok(mimic);
assert.ok(p.equip(mimic.uid, target.uid).ok);
assert.equal(p.allChess().filter(c => m.gd.baseIdOf(c.id) === id).length, 2);
assert.equal(m.pool.entries.get(id).left, -1);
assert.equal(m.pool.left(id), 0); checkInvariants(m);
const elite = p.allChess().find(c => m.gd.baseIdOf(c.id) === id);
assert.ok(p.sell(elite.uid).ok); assert.equal(m.pool.left(id), 2); checkInvariants(m); m.dispose();
});
+47
View File
@@ -0,0 +1,47 @@
// 外勤医疗: the official preparation field already shows 预备干员-医疗; Touch replaces it at battle start.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
const MEDIC = 'char_605_cmedic';
const STAGE = 'act2autochess_m01';
test('外勤医疗 shows the reserve medic on every prep board and in prep scouting, without adding a movable piece', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 6301, fake: true }).start().toPrep();
h.setStage(STAGE);
const [holder, teammate] = [h.ps('p_0'), h.ps('p_1')];
assert.deepEqual(holder.privateView().prepMapChars, [], 'no medic without the strategy');
holder.bandId = 'band_amedic';
for (const ps of [holder, teammate]) {
const priv = ps.privateView();
assert.equal(priv.prepMapChars.length, 1);
const medic = priv.prepMapChars[0];
assert.deepEqual([medic.defId, medic.kind, medic.x, medic.y, medic.dir, medic.ownerId],
[MEDIC, 'token', 2, 10, 'RIGHT', ps.playerId]);
assert.ok(medic.uid < 0 && !priv.board.some((p) => p.uid === medic.uid), 'display only: no board piece to move or sell');
assert.equal(h.m.prepFieldMeta(ps).units.find((u) => u.uid === medic.uid)?.defId, MEDIC);
}
holder.bandId = null;
assert.deepEqual(teammate.privateView().prepMapChars, [], 'the preview goes away when nobody holds the strategy');
h.m.dispose();
});
test('外勤医疗 boss preparation shows both reserve medics on their matching halves, mirrored for the right player', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 6302, fake: true }).start().toPrep();
h.setStage(STAGE);
const m = h.m;
m.bossWaves = [{ players: ['p_0', 'p_1'], wave: { spawns: [] } }];
h.ps('p_0').bandId = 'band_amedic';
const [left, right] = [h.ps('p_0'), h.ps('p_1')];
const own = left.privateView().prepMapChars[0];
const mate = left.privateView().bossMate.units.find((u) => u.defId === MEDIC);
assert.deepEqual([own.y, own.x, own.dir], [10, 2, 'RIGHT'], 'own prep uses board coordinates');
assert.deepEqual([mate.y, mate.x, mate.dir, mate.ownerId], [3, 18, 'LEFT', 'p_1']);
const scout = m.prepFieldMeta(right);
assert.equal(scout.kind, 'boss');
const medics = scout.units.filter((u) => u.defId === MEDIC).sort((a, b) => a.x - b.x);
assert.deepEqual(medics.map((u) => [u.y, u.x, u.dir, u.ownerId]),
[[3, 2, 'RIGHT', 'p_0'], [3, 18, 'LEFT', 'p_1']]);
assert.notEqual(medics[0].uid, medics[1].uid, 'both model IDs remain distinct on the shared field');
m.dispose();
});
+7 -1
View File
@@ -503,7 +503,8 @@ describe('createAssets store', () => {
test('image cache + preload progress (failures resolve to null)', async () => {
const loaded = [];
const a = createAssets({ manifest: M, loadImage: async (u) => { loaded.push(u); if (u.includes('bad')) throw new Error('x'); return { src: u }; } });
let fail = true;
const a = createAssets({ manifest: M, loadImage: async (u) => { loaded.push(u); if (u.includes('bad') && fail) throw new Error('x'); return { src: u }; } });
const progress = [];
const r = await a.preload(['/a.png', '/bad.png', '/a.png', null, ''], (d, t) => progress.push([d, t]));
assert.deepEqual(r, { ok: 1, failed: 1, total: 2 });
@@ -513,6 +514,11 @@ describe('createAssets store', () => {
assert.equal(loaded.filter((u) => u === '/a.png').length, 1, 'cached');
assert.equal(a.imageNow('/a.png').src, '/a.png');
assert.equal(await a.image(null), null);
fail = false;
assert.equal(await a.image('/bad.png'), null, 'ordinary consumers retain the remembered failure');
assert.equal((await a.image('/bad.png', { retry: true })).src, '/bad.png');
assert.equal(loaded.filter((u) => u === '/bad.png').length, 2, 'explicit board retry performs a new load');
assert.equal((await a.image('/bad.png')).src, '/bad.png');
});
test('spine acquire/release through the store with an injected loader', async () => {
+25 -1
View File
@@ -15,7 +15,7 @@ import {
buildBoard, classifyStage, heightOf, AREAS, areaFor, unionAreas, objToBoard, boxProjectUV, tube, Geom, uvAt, ROWS, COLS,
} from '../../public/js/render/board3d/layout.js';
import { BoardScene, gatePulse, DIR_TURNS, boxData, LIGHTING } from '../../public/js/render/board3d/scene.js';
import { loadBoardPack, resetBoardPack, PACK_IMAGES } from '../../public/js/render/board3d/load.js';
import { loadBoardPack, loadThree, resetBoardPack, PACK_IMAGES } from '../../public/js/render/board3d/load.js';
import { parseStage } from '../../public/js/render/tiles.js';
import { TILE_H } from '../../public/js/render/style.js';
import { presetCamera, syncThreeCamera } from '../../public/js/render/projection.js';
@@ -303,6 +303,30 @@ describe('BoardScene (three.js scene graph, stub renderer)', () => {
});
describe('asset pack loader (fake store)', () => {
test('resetBoardPack lets a later three.js import try again after three failures', async () => {
resetBoardPack();
const missing = new URL('./no-such-three.mjs', import.meta.url).href;
for (let i = 0; i < 3; i++) assert.equal(await loadThree(missing), null);
assert.equal(await loadThree(missing), null, 'three failures stop further imports');
resetBoardPack();
const mod = await loadThree(new URL('./fake-three.mjs', import.meta.url).href);
assert.equal(typeof mod?.WebGLRenderer, 'function');
resetBoardPack();
});
test('a transient diffuse-image failure is retried by the next mount instead of cached as permanent 2D', async () => {
resetBoardPack();
let calls = 0;
const store = { local: async () => ({ groups: {} }), localUrl: (g, n) => n === 'TX_autochessi_D' ? '/test/atlas.png' : null,
image: async () => ++calls === 1 ? null : { width: 8, height: 8 } };
const original = globalThis.fetch;
globalThis.fetch = async () => ({ ok: false });
try {
assert.equal(await loadBoardPack(store), null);
const pack = await loadBoardPack(store);
assert.ok(pack?.images.D); assert.equal(calls, 2);
assert.equal(await loadBoardPack(store), pack, 'successful pack remains shared');
} finally { globalThis.fetch = original; resetBoardPack(); }
});
test('loads images through the store and meshes through fetch; null without the diffuse atlas', async () => {
resetBoardPack();
const manifest = { groups: {} };
+79
View File
@@ -73,6 +73,85 @@ describe('render engine in headless Chrome', { skip }, () => {
});
}
test('Swire coins from a real snapshot remain separate from the elemental gauge', async () => {
const { makeBattle } = await import('../helpers/battleHarness.js');
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 10, col: 4, skillIndex: 1 }] });
h.step(); const u = h.unit(1); u.mem.coins = 1;
h.b.dealDamage(null, u, { type: 'element', element: 'burn', amount: 300 });
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
try {
await page.evaluate(({ meta, snap }) => {
window.__demo.pause(); const v = window.__demo.view;
v.enterBattle(meta); v.pushSnapshot(snap);
}, { meta: h.b.fieldMeta(), snap: h.b.snapshot() });
await page.waitForFunction(id => window.__demo.view.debug.views.get(id)?.coinHud?.root.visible, {}, u.id);
const bounds = await page.evaluate(id => {
const v = window.__demo.view.debug.views.get(id), coins = v.coinHud.root.getBounds(), elem = v._elBar.root.getBounds();
return { label: v.coinHud.text.text, coinBottom: coins.bottom, elementTop: elem.top };
}, u.id);
assert.equal(bounds.label, `1/${u.skill.bb.sp}`);
assert.ok(bounds.coinBottom <= bounds.elementTop, JSON.stringify(bounds));
await page.screenshot({ path: path.join(OUT, 'swire-coins-element.png') });
assert.deepEqual(problems, []);
} finally { await page.close(); }
});
test('外勤医疗 reserve medic is visible but not draggable in normal and right boss preparation', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
const first = await page.evaluate(() => {
const v = window.__demo.view;
const st = JSON.parse(JSON.stringify(window.__demo.scene.state));
st.prepMapChars = [{ id: -1, uid: -1, kind: 'token', side: 'ally', ownerId: 'p_0',
defId: 'char_605_cmedic', name: '预备干员-医疗', spine: 'char_605_cmedic', avatar: 'char_605_cmedic',
area: 'board', x: 2, y: 10, dir: 'RIGHT', facing: 1, maxHp: 1202 }];
window.__touchPrepChar = st.prepMapChars[0];
v.setPrep(st, { editable: true });
const medic = v.debug.views.get('m:-1');
const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect();
const out = { exists: !!medic, id: medic?.info.defId, x: medic?.x, y: medic?.y,
draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top) };
return out;
});
await wait(500);
await page.screenshot({ path: path.join(OUT, 'touch-prep-normal.png') });
const boss = await page.evaluate(() => {
const v = window.__demo.view;
v.setCamera('bossPrep', { side: 'R', instant: true });
const mirrored = v.debug.views.get('m:-1');
return [mirrored?.x, mirrored?.y, mirrored?.dir];
});
await wait(500);
await page.screenshot({ path: path.join(OUT, 'touch-prep-boss-right.png') });
const removed = await page.evaluate(() => {
const v = window.__demo.view;
const st = JSON.parse(JSON.stringify(window.__demo.scene.state));
st.prepMapChars = [];
v.setPrep(st, { editable: true });
return !v.debug.views.has('m:-1');
});
const fallback = await page.evaluate(async () => {
const { createFallbackView } = await import('/js/ui/fallbackField.js');
const host = document.createElement('div');
host.style.cssText = 'position:absolute;width:800px;height:600px';
document.body.appendChild(host);
const ff = createFallbackView(host, { data: { get: () => null, lookup: () => null } });
ff.setPrep({ board: [], hand: [], temp: [], prepMapChars: [window.__touchPrepChar] }, { editable: true });
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const el = host.querySelector('.ff-piece--map-char');
const out = { exists: !!el, name: el?.title, pointerEvents: el ? getComputedStyle(el).pointerEvents : null,
movableId: el?.dataset.uid ?? null };
ff.destroy(); host.remove();
return out;
});
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual(first, { exists: true, id: 'char_605_cmedic', x: 2, y: 10,
draggable: false });
assert.deepEqual(boss, [18, 3, 'LEFT']);
assert.equal(removed, true);
assert.deepEqual(fallback, { exists: true, name: '预备干员-医疗', pointerEvents: 'none', movableId: null });
});
test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => {
const { page, problems } = await open('scene=fx&panel=0', 1280, 720);
// cycle through the whole FX_KINDS table (one kind every 0.5 game s = 0.25 real s), watching the FX system: shots
+1
View File
@@ -0,0 +1 @@
export function WebGLRenderer() {}
+29
View File
@@ -30,6 +30,35 @@ const tuple = (id, hp = 1000, extra = {}) => [id, 5, 10, hp, 1000, extra.sp ?? 0
const view = (info = {}, extra = {}) => new UnitView(fakeViewCtx(fake.P, { cam, ...extra }),
{ id: 1, side: 'ally', kind: 'op', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT', ...info });
const sample = (o = {}) => ({ x: 5, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, ...o });
test('Swire purse snapshots include zero and the selected skill cap; death removes the readout', () => {
for (const skillIndex of [0, 1, 2]) {
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 10, col: 4, skillIndex }] });
h.b.start(); const u = h.unit(1); const cap = u.skill.bb.sp;
assert.deepEqual(h.b.snapshot().coins, [[u.id, u.mem.coins, cap]]);
u.mem.coins = 0; assert.deepEqual(h.b.snapshot().coins, [[u.id, 0, cap]]);
h.b.retreat(u, { reason: 'test' }); assert.equal(h.b.snapshot().coins, undefined);
}
});
test('coin balance is snapshot-owned: preserves zero, rejects malformed entries and clears when absent', () => {
const first = normalizeSnapshot({ t: 0, units: [tuple(1)], coins: [[1, 0, 10], [2, 3, 10], [1, -1, 10], [1, 3.5, 10]] });
assert.deepEqual([...first.coins], [[1, [0, 10]]]);
const b = new SnapshotBuffer();
b.push({ t: 0, units: [tuple(1)], coins: [[1, 3, 10]] });
b.push({ t: 1, units: [tuple(1)] });
assert.deepEqual(b.sample(0).get(1).coins, [3, 10]);
assert.equal(b.sample(1).get(1).coins, null);
});
test('the coin HUD shows balance/cap and does not leave a stale label when its snapshot entry disappears', () => {
const v = view();
try {
v.sync(sample({ coins: [0, 10] }), 0); v.update(0.02, cam(), 0);
assert.equal(v.coinHud?.text.text, '0/10'); assert.equal(v.coinHud.root.visible, true);
v.sync(sample(), 1); v.update(0.02, cam(), 1); assert.equal(v.coinHud.root.visible, false);
} finally { v.destroy(); }
});
const frames = (v, n = 2) => { for (let i = 0; i < n; i++) v.update(1 / 60, cam(), i / 60); };
const barWidth = (v) => v.hpBg.width - 2;
+149
View File
@@ -0,0 +1,149 @@
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME);
const art = existsSync(new URL('../../public/assets/local/map/autochess/TX_autochessi_D.png', import.meta.url));
describe('renderer recovery after transient resource failures', { skip: !enabled }, () => {
let srv, browser, serverRun = false;
before(async () => {
const { startServer } = await import('../../server/index.js');
const { Match } = await import('../../server/match/Match.js');
class RecoveryMatch extends Match { constructor(opts) { super({ ...opts, clientCombat: !serverRun }); } }
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, MatchClass: RecoveryMatch, seedFn: () => 123 });
const P = (await import('puppeteer-core')).default;
browser = await P.launch({ executablePath: CHROME, headless: true,
args: ['--no-first-run', '--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist'] });
mkdirSync(new URL('../e2e/out/', import.meta.url), { recursive: true });
});
after(async () => { await browser?.close(); await srv?.close(); });
for (const streamed of [false, true]) test(`a live ${streamed ? 'server' : 'local'} battle survives renderer replacement with current enemy metadata`, { timeout: 45000 }, async () => {
serverRun = streamed;
const context = await browser.createBrowserContext(), page = await context.newPage(); let failing = true;
try {
await page.setRequestInterception(true);
page.on('request', req => {
if (failing && new URL(req.url()).pathname === '/vendor/pixi.min.js') void req.abort('failed'); else void req.continue();
});
await page.evaluateOnNewDocument(() => { localStorage.setItem('sp.name', 'Recovery'); sessionStorage.setItem('sp.entered', '1'); });
await page.goto(`http://127.0.0.1:${srv.port}/?board=2d`);
await page.waitForFunction(() => window.__SP__?.store.get().connection.status === 'online');
const send = (type, args = {}) => page.evaluate(async (t, a) => (await import('/js/net.js')).net.request(t, a), type, args);
await send('room.create', { mode: 'solo', difficulty: 'FUNNY' }); await send('room.start');
await page.waitForFunction(() => window.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
await send('g.infoReady', { setupRevision: 0 });
await page.waitForFunction(() => window.__SP__.store.get().match.public?.phase === 'BAND_DRAFT');
await send('g.band', { bandId: 'band_bldsk' });
await page.waitForFunction(() => window.__SP__.store.get().match.public?.phase === 'PREP');
await page.waitForFunction(() => window.__SP_VIEW__?.kind === 'fallback');
await send('g.ready', { ready: true });
await page.waitForSelector('.ff-unit.is-enemy:not(.ff-pen__enemy)');
const code = await page.evaluate(() => window.__SP__.store.get().room.code);
const match = srv.lobby.rooms.get(code).match;
const serverBattle = streamed ? match.fields[0].battle : null;
const serverTime = serverBattle?.time;
const enemies = streamed ? match.fields[0].battle.enemies.filter(e => e.alive).map(e => [e.id, e.defId])
: await page.evaluate(async () => {
const runner = (await import('/js/battle/runner.js')).battleRunner;
const entry = [...runner._entries.values()].find(e => e.battle && !e.done);
window.__originalBattle = entry.battle; window.__originalTime = entry.battle.time;
return entry.battle.enemies.filter(e => e.alive).map(e => [e.id, e.defId]);
});
assert.ok(enemies.length > 0, 'enemies spawned after the original field metadata');
failing = false; await page.evaluate(() => window.dispatchEvent(new Event('online')));
await page.waitForFunction(() => window.__SP_VIEW__?.kind === 'engine', { timeout: 15000 });
await page.waitForFunction(([id, defId]) => {
const v = window.__SP_VIEW__.raw;
return v.stats().mode === 'battle' && v.debug.views.get(id)?.info.defId === defId;
}, { timeout: 7000 }, enemies[0]);
assert.equal(srv.lobby.rooms.get(code).match, match, 'renderer recovery never restarts the match');
if (streamed) {
assert.equal(match.fields[0].battle, serverBattle); assert.ok(serverBattle.time >= serverTime);
} else {
assert.equal(await page.evaluate(async () => {
const runner = (await import('/js/battle/runner.js')).battleRunner;
return [...runner._entries.values()].some(e => e.battle === window.__originalBattle && e.battle.time >= window.__originalTime);
}), true, 'the same local simulation continues from its current clock');
}
await send('g.leave');
} finally { await context.close(); }
});
for (const forced of [false, true]) test(`field hook retries a failed Pixi download; forced fallback=${forced}`, async () => {
const page = await browser.newPage(); let pixiRequests = 0, failing = true;
try {
await page.setRequestInterception(true);
page.on('request', req => {
const pixi = new URL(req.url()).pathname === '/vendor/pixi.min.js';
if (pixi) pixiRequests++;
if (pixi && failing) void req.abort('failed'); else void req.continue();
});
await page.goto(`http://127.0.0.1:${srv.port}/?${forced ? 'render=fallback&' : ''}board=2d`);
await page.evaluate(async () => {
const { render, h } = await import('/vendor/preact.module.js');
const { useRef, useEffect } = await import('/vendor/hooks.module.js');
const { useFieldView } = await import('/js/ui/fieldHost.js');
const { data } = await import('/js/data.js');
await data.loadAll('stages', 'chess', 'assets', 'local');
function Harness() {
const host = useRef(null), state = useFieldView(host);
useEffect(() => {
if (!state.view) return;
state.view.setStage(data.lookup('stages', 'act2autochess_m01'));
state.view.setPrep({ playerId: 'p', board: [], hand: [] });
}, [state.view]);
return h('div', { ref: host, id: 'recovery-host', style: 'width:100vw;height:100vh' });
}
render(h(Harness), document.getElementById('app'));
window.__unmount = () => render(null, document.getElementById('app'));
});
await page.waitForFunction(() => window.__SP_VIEW__?.kind === 'fallback');
const before = pixiRequests; failing = false;
await page.evaluate(() => {
window.__firstView = window.__SP_VIEW__;
for (let i = 0; i < 5; i++) window.dispatchEvent(new Event('online'));
});
if (forced) {
assert.equal(await page.evaluate(() => window.__SP_VIEW__ === window.__firstView), true);
assert.equal(pixiRequests, before, 'explicit fallback never requests an engine retry');
} else {
await page.waitForFunction(() => window.__SP_VIEW__?.kind === 'engine', { timeout: 20000 });
assert.equal(pixiRequests, before + 1, 'one successful retry after the failed prefetch and initialization');
const result = await page.evaluate(() => {
const view = window.__SP_VIEW__;
for (let i = 0; i < 5; i++) document.dispatchEvent(new Event('visibilitychange'));
return { same: view === window.__SP_VIEW__, canvases: document.querySelectorAll('#recovery-host canvas').length,
field: view.raw.stats().mode };
});
assert.deepEqual(result, { same: true, canvases: 1, field: 'prep' });
}
await page.evaluate(() => window.__unmount());
assert.equal(await page.evaluate(() => window.__SP_VIEW__), null, 'unmount releases the recovery view');
} finally { await page.close(); }
});
for (const resource of ['atlas', 'three']) test(`${resource} download can recover the 3D board on visibility`, { skip: !art }, async () => {
const page = await browser.newPage(); let failing = true, requests = 0;
try {
await page.setRequestInterception(true);
page.on('request', req => {
const p = new URL(req.url()).pathname;
const selected = resource === 'atlas' ? /\/TX_autochessi_D\.(?:png|webp)$/.test(p) : p === '/vendor/three.module.js';
if (selected) requests++;
if (selected && failing) void req.abort('failed'); else void req.continue();
});
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=prep&stage=act2autochess_m01&board=3d&panel=0`);
await page.waitForFunction(() => window.__demo?.ready, { timeout: 30000 });
assert.equal(await page.evaluate(() => window.__demo.stats().board3d.on), false);
const before = requests; failing = false;
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')));
await page.waitForFunction(() => window.__demo.stats().board3d.on, { timeout: 15000 });
assert.ok(requests > before, 'the failed resource is actually requested again');
assert.equal(await page.evaluate(() => window.__demo.view.debug.app.view.parentNode.querySelectorAll('canvas').length), 2);
await page.screenshot({ path: new URL(`../e2e/out/recovery-${resource}.png`, import.meta.url).pathname });
} finally { await page.close(); }
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
// test/render/token-models.browser.test.js — the 39 summon models only the local client has (tools/local-extract/
// test/render/token-models.browser.test.js — the 40 original summon models extracted from the local client (tools/local-extract/
// extract.py TOKEN_SPINES → data/assets.json tokens[id].spineLocal, docs/ASSETS.md "Token models from the local client")
// drawn on the prep board of the dev render demo (public/dev/render-demo.html) in headless Chrome: every summon placed on
// a board tile draws its official model (the local entry, not the avatar diamond), on its own idle clip (the mapped ones
+29 -6
View File
@@ -16,6 +16,11 @@
// (withHeavyHitters): the bots' own hits of ≥ 300000 came from whatever 维多利亚 / 精准 / 奥术 mix they drafted — none
// since 0.2.0's CB3 (the 受到攻击时 counters answer every damage instance) re-drafted seed 22, whose best hit is now
// about half the limit; the cancel path itself is unchanged (the earlier captures still cancel the same hits).
// 无来源 HP (drone 死亡集群 links and 剑 / 锤 transfers) likewise, on a fourth copy (withTransferWindow). Shared co-op
// stock (DESIGN §29.8) re-drafted seed 22: the 999-layer boards now clear in tens of seconds, before a drone is killed
// on every field and before a blade takes a transferable hit (a forced replace is not damage). The transfer path is
// unchanged — the 0.2.3 specs of this seed still lose leader HP on every field under this sim. The copy keeps the
// drafted operators and holds 维多利亚 / 精准 / 奥术 / 卡西米尔 / 绝技 at 1 layer so the fight lasts long enough.
//
// Opt-in (starts Chrome): SIM_E2E=1 node --test test/sim/bossfield.browser.test.js (or RENDER_E2E=1)
// Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default.
@@ -117,6 +122,23 @@ function withHeavyHitters(spec) {
return s;
}
/**
* A copy of `spec` whose 维多利亚 / 精准 / 奥术 / 卡西米尔 / 绝技 layers are held at 1. At 999 those bonds end seed 22's
* shared-stock boards before a 死亡集群 drone dies on every field and before a 斩胄之剑 / 破胄之锤 takes damage
* (DESIGN §29.8). One layer keeps the bond and lets the parts land; the operators stay the ones the bots drafted.
*/
const TRANSFER_BONDS = ['victoriaShip', 'preciShip', 'arcaneShip', 'kazimierzShip', 'suntShip'];
function withTransferWindow(spec) {
const s = JSON.parse(JSON.stringify(spec));
for (const p of s.players || []) {
for (const id of TRANSFER_BONDS) {
const b = p.bonds && p.bonds[id];
if (b && b.active && b.layers > 1) b.layers = 1;
}
}
return s;
}
/**
* Runs a spec to the end (the same code in Node and in the page: `S` = spec module, `ds` = data source) and returns
* its digest, the local pool, the fx count per kind and the leader-side checks (奥术 instances, 无来源 HP losses).
@@ -158,9 +180,9 @@ describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () =
test('real boss specs at 999 layers: Chrome and Node give the same digest, pool and fx; 限伤, drones, 瘫痪 and 奥术 all occur', { timeout: 180000 }, async () => {
const specs = captureBossSpecs();
assert.deepEqual(specs.map((s) => `${s.kind}:${s.bossId}`).sort(), ['boss:boss_1', 'boss:boss_1', 'hidden:boss_8', 'hidden:boss_8']);
// the four as captured, the same four with a 麻痹 source on every field (withTearGas), and with 限伤-sized hits
// on every field (withHeavyHitters)
const runs = [...specs, ...specs.map(withTearGas), ...specs.map(withHeavyHitters)];
// the four as captured, the same four with a 麻痹 source on every field (withTearGas), with 限伤-sized hits
// on every field (withHeavyHitters), and with the damage bonds held at 1 layer (withTransferWindow)
const runs = [...specs, ...specs.map(withTearGas), ...specs.map(withHeavyHitters), ...specs.map(withTransferWindow)];
const ds = new DataSource(DATA, null);
const node = runs.map((spec) => runField({ createBattleFromSpec, resultDigest }, ds, spec, MAX_SECONDS));
@@ -180,7 +202,7 @@ describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () =
await page.close();
assert.deepEqual(problems, []);
const copies = ['', ' +催泪瓦斯', ' +heavy hitters'];
const copies = ['', ' +催泪瓦斯', ' +heavy hitters', ' +transfer window'];
runs.forEach((spec, i) => {
const at = `${spec.kind} ${spec.fieldId} ${spec.bossId}${copies[Math.floor(i / specs.length)]}`;
assert.equal(node[i].reason, 'cleared', `${at}: the 999-layer pair clears the field`);
@@ -193,11 +215,12 @@ describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () =
});
const real = node.slice(0, specs.length);
const gassed = node.slice(specs.length, 2 * specs.length);
const heavy = node.slice(2 * specs.length);
const heavy = node.slice(2 * specs.length, 3 * specs.length);
const opened = node.slice(3 * specs.length);
const sum = (rows, k) => rows.reduce((a, r) => a + (r.fx[k] || 0), 0);
assert.ok(sum(heavy, 'hitCap') >= 1, `a 999-layer hit reached 300000 and was cancelled (限伤: ${heavy.map((r) => r.fx.hitCap || 0)} with the heavy hitters, ${sum(real, 'hitCap')} on the bots' own boards)`);
assert.ok(sum(gassed, 'palsy') >= 1, `瘫痪 occurred with the 催泪瓦斯 (${gassed.map((r) => r.fx.palsy || 0)})`);
assert.ok(real.every((r) => r.hpLoss >= 1), `every field cost the leader 无来源 HP (drone links / 剑 · 锤 transfers: ${real.map((r) => r.hpLoss)})`);
assert.ok(opened.every((r) => r.hpLoss >= 1), `holding the damage bonds at 1 layer, every field cost the leader 无来源 HP (drone links / 剑 · 锤 transfers: ${opened.map((r) => r.hpLoss)}; the 999-layer boards: ${real.map((r) => r.hpLoss)})`);
// 奥术 on the bots' own boards or on the heavy-hitter copy (洛洛 + 深靛 = 奥术 2): seed 22's bots stopped drafting it
// when 0.2.0's last fixes re-drafted the earlier rounds
assert.ok([...real, ...heavy].some((r) => r.maxArcane === 1), `the leader carried 奥术 (bots' boards ${real.map((r) => r.maxArcane)}, heavy hitters ${heavy.map((r) => r.maxArcane)})`);
+87 -5
View File
@@ -10,11 +10,12 @@
// taken — the two-helper 联防 field and a Final Assault / Hidden Core pair field: raidTile takes the tiles of
// Battle.onFieldBoard (the board of any player of the field). The lone helper's empty half, a solo boss field's other
// half and the hand rows stay closed; 乌尔比安's S3 【移动】 keeps his own board.
// 0.2.4 (#475, maintainer-approved): only multiplayer boss/hidden Raid may also land on the empty half's board.
// Run: node --test test/sim/feedback5-raid-own-board.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { makeBattle, flatStage, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const ID = 'chess_char_6_13_a'; // 新约能天使 (RANGED), given the 突袭 bond as the 转职球 / 突袭手雷 would
@@ -26,8 +27,8 @@ const BONDS = { raidShip: { count: 2, active: true, tier: 1, layers: 0 } };
const dummy = enemyRec({ key: 'dummy', hp: 1e9, speed: 0, mass: 0 });
const defs = { enemies: { dummy }, chess: { [ID]: raider } };
function jump({ kind, stageId, players, enemyPos, seconds = 11 }) {
const h = makeBattle({ kind, stageId, autoFinish: false, timeLimit: 60, defs, players });
function jump({ kind, stageId, players, enemyPos, seconds = 11, modeId }) {
const h = makeBattle({ kind, stageId, autoFinish: false, timeLimit: 60, defs, players, modeId });
h.step();
const mine = h.b.allyUnits.filter((u) => u.defId === ID);
const homes = mine.map((u) => [u.tileR, u.tileC]);
@@ -44,7 +45,7 @@ const mate = (playerId, s, o = {}) => seat(playerId, s, { ...o, bonds: {}, units
test('boss field (solo 最终攻势): a ranged 突袭 member never lands on the hand row 0 or the 临时整备区 row 1', () => {
// 战场#01: the enemy on the telin (1,3); before the fix the member landed on the hand tile (0,3)
const { h, mine } = jump({ kind: 'boss', stageId: 'act1autochess_m01', players: [seat('p1', 0, { units: [unitAt(12, 3)] })], enemyPos: [1, 3] });
const { h, mine } = jump({ modeId: 'mode_single_normal', kind: 'boss', stageId: 'act1autochess_m01', players: [seat('p1', 0, { units: [unitAt(12, 3)] })], enemyPos: [1, 3] });
const u = mine[0];
assert.deepEqual([u.tileR, u.tileC], [2, 3], 'the board tile next to it, not (0,3)');
assert.ok(h.b.onOwnBoard(u.player, u.tileR, u.tileC));
@@ -55,7 +56,7 @@ test('boss field (solo 最终攻势): a ranged 突袭 member never lands on the
// no partner: the field's boards are its own (the right half stays closed)
for (let r = 0; r <= 5; r++) for (let c = 0; c <= 20; c++) assert.equal(h.b.onFieldBoard(r, c), h.b.onOwnBoard(u.player, r, c), `(${r},${c})`);
// an enemy only the temp row could reach: it stays home and does not hop
const far = jump({ kind: 'boss', stageId: 'act1autochess_m01', players: [seat('p1', 0, { units: [unitAt(12, 3)] })], enemyPos: [0, 9] });
const far = jump({ modeId: 'mode_single_normal', kind: 'boss', stageId: 'act1autochess_m01', players: [seat('p1', 0, { units: [unitAt(12, 3)] })], enemyPos: [0, 9] });
assert.deepEqual([far.mine[0].tileR, far.mine[0].tileC], far.homes[0]);
});
@@ -124,3 +125,84 @@ test('联防 with two helpers (escaped_multi): a member lands on its teammate\'s
assert.ok(ul.b.onFieldBoard(10, 13) && !ul.b.onOwnBoard(a.player, 10, 13));
assert.deepEqual([a.tileR, a.tileC], [10, 7], 'he stays on his own half');
});
for (const kind of ['boss', 'hidden']) for (const side of ['L', 'R']) {
test(`#475 ${kind}, lone ${side} multiplayer player: ranged Raid reaches the empty half, without opening its hand rows`, () => {
const c = side === 'L' ? 17 : 3;
const players = [seat('p1', 0, { side, units: [unitAt(12, 3)] })];
for (const modeId of ['mode_multi_normal', 'mode_multi_hard']) {
const { h, mine } = jump({ kind, modeId, stageId: 'act1autochess_m01', players, enemyPos: [1, c] });
assert.deepEqual([mine[0].tileR, mine[0].tileC], [2, c]);
assert.equal(h.b.onFieldBoard(2, c), false, 'the general board contract still excludes an absent teammate');
const far = jump({ kind, modeId, stageId: 'act1autochess_m01', players, enemyPos: [0, c] });
assert.deepEqual([far.mine[0].tileR, far.mine[0].tileC], far.homes[0], 'no legal board tile reaches the hand-only target');
}
for (const modeId of ['mode_single_normal', 'mode_single_hard']) {
const solo = jump({ kind, modeId, stageId: 'act1autochess_m01', players, enemyPos: [1, c] });
assert.deepEqual([solo.mine[0].tileR, solo.mine[0].tileC], solo.homes[0], 'actual solo mode keeps its own half');
}
});
}
// Give the member only its own tile as attack range, so each case has exactly one possible landing. All tested
// off-board tiles are intentionally buildable: the scope predicate, not incidental terrain, must reject them.
function emptyHalfLanding({ kind = 'boss', side = 'L', pos, glyph = 'r', ranged = false, reserved = false }) {
const stage = flatStage({ rows: Object.fromEntries(Array.from({ length: 7 }, (_, r) => [r, 'r'.repeat(21)])) });
stage.rows[pos[0]] = stage.rows[pos[0]].slice(0, pos[1]) + glyph + stage.rows[pos[0]].slice(pos[1] + 1);
const h = makeBattle({
kind, modeId: 'mode_multi_normal', stage, autoFinish: false,
defs: { enemies: { dummy }, chess: {
probe: chessRec({ id: 'probe', position: ranged ? 'RANGED' : 'MELEE', bonds: ['raidShip'], rangeGrid: [[0, 0]], skill: null }),
guard: chessRec({ id: 'guard', skill: null, stats: { respawnTime: 60 } }),
} },
players: [seat('p1', 0, { side, units: [unitAt(12, 3, 1, 'probe'),
...(reserved ? [{ ...unitAt(pos[0], pos[1], 2, 'guard'), abs: true }] : []),
] })],
});
h.step();
h.spawn('dummy', { pos });
return h;
}
for (const kind of ['boss', 'hidden']) for (const side of ['L', 'R']) {
test(`#475 ${kind}, lone ${side} multiplayer player: melee Raid reaches the empty half`, () => {
const pos = [3, side === 'L' ? 17 : 3];
const h = emptyHalfLanding({ kind, side, pos });
const u = h.unit('probe');
h.run(11);
assert.deepEqual([u.tileR, u.tileC], pos);
assert.equal(h.b.onFieldBoard(...pos), false);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
}
test('#475: even buildable hand, temporary-storage, side-margin and off-rect tiles remain invalid Raid landings', () => {
for (const pos of [[0, 3], [1, 3], [0, 17], [1, 17], [3, 1], [3, 19], [6, 17]]) {
const h = emptyHalfLanding({ pos, ranged: true });
const u = h.unit('probe'), home = [u.tileR, u.tileC];
h.run(11);
assert.deepEqual([u.tileR, u.tileC], home, `excluded tile ${pos}`);
checkInvariants(h.b);
}
});
test('#475: empty-half Raid still respects melee/high-ground, water and occupied/downed-unit reservations', () => {
for (const glyph of ['h', 'd']) {
const h = emptyHalfLanding({ pos: [3, 17], glyph });
const u = h.unit('probe'), home = [u.tileR, u.tileC];
assert.equal(h.b.grid.canStand(3, 17), false, `terrain ${glyph} is undeployable for melee`);
h.run(11);
assert.deepEqual([u.tileR, u.tileC], home, `terrain ${glyph}`);
}
const h = emptyHalfLanding({ pos: [3, 17], reserved: true });
const u = h.unit('probe'), guard = h.unit('guard'), home = [u.tileR, u.tileC];
h.run(11);
assert.deepEqual([u.tileR, u.tileC], home, 'living occupant');
h.b.kill(guard, null);
assert.equal(h.b.isReservedTile(3, 17), true, 'downed operator reserves its rest tile');
h.run(11);
assert.deepEqual([u.tileR, u.tileC], home, 'downed occupant');
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
+126
View File
@@ -0,0 +1,126 @@
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.SP_E2E === '1' && existsSync(CHROME);
describe('community UI: lobby header and shared DIY details', { skip: !enabled }, () => {
let srv, browser;
before(async () => {
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
const P = (await import('puppeteer-core')).default;
browser = await P.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
mkdirSync(new URL('../e2e/out/', import.meta.url), { recursive: true });
});
after(async () => { await browser?.close(); await srv?.close(); });
test('equipment draft clears an armed choice when shared stock becomes unavailable', async () => {
const { context, page } = await open(1600);
try {
await page.evaluate(async () => {
const { render, h } = await import('/vendor/preact.module.js');
const { ChoiceOverlay } = await import('/js/ui/choiceOverlay.js');
const { data } = await import('/js/data.js');
await data.loadAll('items', 'choices', 'config');
const props = { pub: { players: [{ playerId: 'p_0', name: 'Doctor' }] }, myId: 'p_0', solo: true,
onPick: i => { (window.__picks ||= []).push(i); },
sp: { family: 'supply', name: '道具补给', turnPid: 'p_0', pickOf: new Map(), order: ['p_0'], cards: [
{ idx: 0, kind: 'item', id: 'chess_item_5_07_e_a' }, { idx: 1, kind: 'item', id: 'chess_item_1_01_e_a' },
] } };
const host = document.getElementById('app'); render(h(ChoiceOverlay, props), host);
window.__exhaustStock = () => render(h(ChoiceOverlay, { ...props,
sp: { ...props.sp, cards: props.sp.cards.map((c, i) => i ? c : { ...c, soldOut: true }) } }), host);
});
await page.waitForSelector('.spcard'); await page.click('.spcard');
await page.waitForSelector('.spcard.is-armed');
await page.evaluate(() => window.__exhaustStock());
await page.waitForFunction(() => document.querySelector('.spcard').disabled && !document.querySelector('.spcard.is-armed'));
assert.match(await page.$eval('.spcard', e => e.textContent), /库存不足/);
assert.equal(await page.evaluate(() => window.__picks?.length || 0), 0);
await page.screenshot({ path: new URL('../e2e/out/shared-stock-unavailable.png', import.meta.url).pathname });
await page.click('.spcard:nth-child(2)'); await page.click('.spcard:nth-child(2)');
assert.deepEqual(await page.evaluate(() => window.__picks), [1]);
} finally { await context.close(); }
});
async function open(width, lang = 'zh-CN') {
const context = await browser.createBrowserContext(), page = await context.newPage();
await page.setViewport({ width, height: width < 1000 ? 390 : 900, hasTouch: width < 1000 });
await page.evaluateOnNewDocument(l => {
localStorage.setItem('sp.name', '超长名称的测试博士名称'); sessionStorage.setItem('sp.entered', '1');
localStorage.setItem('sp.pref.settings', JSON.stringify({ textSize: 'xl' }));
localStorage.setItem('sp.pref.lang', JSON.stringify(l));
}, lang);
await page.goto(`http://127.0.0.1:${srv.port}/`, { waitUntil: 'networkidle0' });
await page.waitForSelector('.lobby-screen');
return { context, page };
}
for (const width of [1600, 844]) for (const lang of ['zh-CN', 'en', 'ja', 'ko', 'zh-TW']) {
test(`lobby ${width}px ${lang}, XL text, long name, resume and install controls`, async () => {
const { context, page } = await open(width, lang);
try {
// Supply only the optional controls' eligibility; their production components, CSS and click handlers remain.
await page.evaluate(async () => {
const { identity } = await import('/js/net.js');
identity.recoverable = () => [{ id: 'layout-only', name: 'Doctor', code: 'ABCD' }];
window.dispatchEvent(new Event('focus'));
const e = new Event('beforeinstallprompt', { cancelable: true });
e.prompt = async () => {}; e.userChoice = Promise.resolve({ outcome: 'dismissed' });
window.dispatchEvent(e);
await document.fonts.ready;
});
await page.waitForSelector('[data-testid="resume-local-match"]');
await page.waitForSelector('[data-testid="pwa-install"]');
const boxes = await page.$$eval('.lobby-screen .topbar button, .lobby-screen .topbar__center', els => els.map(e => {
const r = e.getBoundingClientRect(), hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return { label: e.getAttribute('aria-label') || e.textContent.trim(), x: r.x, y: r.y, right: r.right, bottom: r.bottom,
hit: e.tagName !== 'BUTTON' || hit === e || e.contains(hit) };
}));
for (const b of boxes) {
assert.ok(b.x >= 0 && b.right <= width && b.y >= 0, JSON.stringify(b));
assert.equal(b.hit, true, JSON.stringify(b));
}
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
const a = boxes[i], b = boxes[j];
assert.ok(Math.min(a.right, b.right) - Math.max(a.x, b.x) <= 1 || Math.min(a.bottom, b.bottom) - Math.max(a.y, b.y) <= 1,
`${a.label} overlaps ${b.label}`);
}
await page.screenshot({ path: new URL(`../e2e/out/lobby-header-${width}-${lang}.png`, import.meta.url).pathname });
await page.click('.stats-entry'); await page.waitForSelector('.st .st-tab');
await page.click('.st-back');
await page.waitForSelector('.st', { hidden: true });
await page.click('.lobby-screen [data-testid="settings-btn"]'); await page.waitForSelector('.modal');
} finally { await context.close(); }
});
}
for (const width of [1600, 844]) test(`DIY ${width}px XL: shared stats and normal/elite skill details stay usable`, async () => {
const { context, page } = await open(width);
try {
await page.click('[data-testid="loadout-open"]'); await page.click('[data-tab="diy"]');
await page.click('.diy-slot[data-slot="chess_char_6_diy1_a"] .diy-slot__fill');
const picker = '[data-testid="diy-picker"]';
await page.waitForSelector(`${picker} [data-char="char_112_siege"]`);
await page.click(`${picker} [data-char="char_112_siege"]`);
await page.waitForSelector(`${picker} .lo-sec--stats[data-variant="elite"]`);
const detail = () => page.$eval(`${picker} .diy-pick__detail`, e => ({ stats: [...e.querySelectorAll('.dstat__v')].map(n => n.textContent),
skill: [...e.querySelectorAll('.diy-choice__desc')].map(n => n.textContent), talents: e.querySelectorAll('.lo-minfo__row').length,
tags: e.querySelector('.diy-choice__tags')?.textContent }));
const elite = await detail(); assert.equal(elite.stats.length, 8); assert.ok(elite.talents >= 2); assert.match(elite.tags, /初始|被动/);
await page.click(`${picker} button[data-variant="normal"]`);
await page.waitForSelector(`${picker} .lo-sec--stats[data-variant="normal"]`);
const normal = await detail(); assert.notDeepEqual(normal.stats, elite.stats); assert.notDeepEqual(normal.skill, elite.skill);
await page.click(`${picker} button[data-variant="elite"]`);
const restored = await detail(); assert.deepEqual(restored.stats, elite.stats);
// Independent scrolling must reach all skills/modules without displacing the fixed confirm control.
await page.$eval(`${picker} .diy-pick__detail`, e => { e.scrollTop = e.scrollHeight; });
await page.screenshot({ path: new URL(`../e2e/out/diy-detail-${width}-xl.png`, import.meta.url).pathname });
const accessible = await page.$eval('[data-testid="diy-confirm"]', e => {
const r = e.getBoundingClientRect(), at = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return r.bottom <= innerHeight && (at === e || e.contains(at));
});
assert.equal(accessible, true);
await page.click('[data-testid="diy-confirm"]');
await page.waitForSelector(picker, { hidden: true });
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.diy')).picks.chess_char_6_diy1_a.charId), 'char_112_siege');
} finally { await context.close(); }
});
});
+45
View File
@@ -0,0 +1,45 @@
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const results = JSON.parse(readFileSync(new URL('../fixtures/stats-results.json', import.meta.url)));
describe('settlement return control under enlarged text (#458)', { skip: !ENABLED }, () => {
let srv, browser;
before(async () => {
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
const P = (await import('puppeteer-core')).default;
browser = await P.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
mkdirSync(new URL('../e2e/out/', import.meta.url), { recursive: true });
});
after(async () => { await browser?.close(); await srv?.close(); });
for (const [width, height] of [[1366, 768], [844, 390]]) for (const textSize of ['md', 'lg', 'xl']) {
test(`${width}×${height}, ${textSize}: return is inside the viewport and clickable`, async () => {
const page = await browser.newPage();
try {
await page.setViewport({ width, height });
await page.evaluateOnNewDocument(ts => localStorage.setItem('sp.pref.settings', JSON.stringify({ textSize: ts })), textSize);
await page.goto(`http://127.0.0.1:${srv.port}/`, { waitUntil: 'networkidle0' });
// Use the production ResultView with a captured real match result; no CSS or geometry is changed by the fixture.
await page.evaluate(async res => {
const { render, h } = await import('/vendor/preact.module.js');
const { ResultView } = await import('/js/screens/result.js');
render(h(ResultView, { res, myId: 'p_0', quiet: true, backLabel: '返回同盟', onBack: () => { window.__returned = true; } }), document.getElementById('app'));
await document.fonts.ready;
}, results['coop4-hard']);
await page.waitForSelector('.result__foot button');
await page.screenshot({ path: new URL(`../e2e/out/result-text-${width}-${textSize}.png`, import.meta.url).pathname });
const bounds = await page.$eval('.result__foot button', b => {
const r = b.getBoundingClientRect(), hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, reachable: !!hit && (hit === b || b.contains(hit)) };
});
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.right <= width && bounds.bottom <= height, JSON.stringify(bounds));
assert.equal(bounds.reachable, true);
await page.click('.result__foot button'); assert.equal(await page.evaluate(() => window.__returned), true);
} finally { await page.close(); }
});
}
});
+17
View File
@@ -171,6 +171,23 @@ describe('stage device tip in the browser', { skip: !ENABLED && 'set SP_E2E=1 (a
assert.ok(text !== null, 'the tap opened a card');
assert.match(text, new RegExp(CRATE.name), text);
assert.equal(await hpText(page), '100 / 100');
// A repeated same-field meta can omit once-spawned devices. The screen's remembered device must also remain a
// device in the renderer, not an unknown enemy which intercepts the tile click before the card lookup.
const deviceId = await page.evaluate(() => globalThis.__MOCK__.S().battle.devices[0].id);
await page.evaluate(async () => {
const { net } = await import('/js/net.js');
const { store } = globalThis.__MOCK__;
await net.request('g.watch', { fieldId: store.get().match.field.fieldId });
});
await page.waitForFunction((id) => globalThis.__SP_VIEW__?.raw?.debug.views.get(id)?.info.kind === 'device', {}, deviceId);
const p = await at();
await page.mouse.click(p.x, p.y);
await page.waitForSelector('.dpanel');
assert.match(await card(page), new RegExp(CRATE.name));
assert.ok(await page.evaluate(() => globalThis.__MOCK__.hurtDevice(0, 40)));
await page.waitForFunction(() => /60 \/ 100/.test(document.querySelector('.dpanel .dhp')?.textContent || ''));
assert.ok(await page.evaluate(() => globalThis.__MOCK__.hurtDevice(0, 100)));
await page.waitForFunction(() => !document.querySelector('.dpanel'));
assert.deepEqual(problems, []);
await page.close();
});
+10 -1
View File
@@ -18,7 +18,7 @@ import { makeBattle } from '../helpers/battleHarness.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const { resolveDetail } = await import('../../public/js/ui/detailPanel.js');
const { deviceInfo, deviceTipAt, noteDeviceUnits, effectiveStage, closesOnFieldPress } = await import('../../public/js/ui/gameLogic.js');
const { deviceInfo, deviceTipAt, noteDeviceUnits, deviceUnitsForEntry, effectiveStage, closesOnFieldPress } = await import('../../public/js/ui/gameLogic.js');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data', 'stages.json'), 'utf8'));
const pack = (code) => { const p = JSON.parse(readFileSync(path.join(ROOT, 'public', 'i18n', `${code}.json`), 'utf8')); delete p._meta; return p; };
@@ -267,6 +267,15 @@ test('the prep and a scouted prep board: the stage\'s own device entries answer
assert.deepEqual([...m.keys()], [2, 3]);
assert.equal(noteDeviceUnits(new Map(), {}).size, 0);
assert.equal(noteDeviceUnits(new Map(), { units: 'x', events: 5 }).size, 0);
// a same-field refresh whose meta has no device keeps spawn-noted crates; a meta that names devices replaces them;
// another field does not
const kept = deviceUnitsForEntry(m, new Map(), true);
assert.equal(kept, m);
const fresh = new Map([[9, { id: 9, kind: 'device' }]]);
assert.equal(deviceUnitsForEntry(m, fresh, true), fresh);
assert.notEqual(deviceUnitsForEntry(m, new Map(), false), m);
assert.equal(deviceUnitsForEntry(null, new Map(), true).size, 0);
assert.equal(deviceUnitsForEntry(m, null, true).size, 0);
});
test('a device target resolves into the panel card; a malformed one resolves to nothing; the card closes on a field press', () => {
+15 -13
View File
@@ -54,11 +54,12 @@ describe('自选编队 picker: reselecting retains the draft (real server, no ar
await c.click('.lobby-screen [data-testid="loadout-open"]');
await c.click('.lo .lo-tab[data-tab="diy"]');
await open();
await c.click(operator);
await c.click(`${picker} [data-skill="0"]`);
await c.click(`${picker} [data-module="none"]`);
await c.page.$eval(operator, e => e.scrollIntoView({ block: 'nearest' }));
await c.page.click(operator);
await c.page.click(`${picker} [data-skill="0"]`);
await c.page.click(`${picker} [data-module="none"]`);
await c.shot('empty-edited');
await c.click(operator);
await c.page.click(operator);
await c.shot('empty-reselected');
assert.deepEqual(await choices(), { skill: '0', module: 'none' }, 'an empty slot retains its edited draft');
// Hide the current operator, then find and reselect it through the filter/search controls.
@@ -67,15 +68,15 @@ describe('自选编队 picker: reselecting retains the draft (real server, no ar
await c.click(`${picker} [data-filter="owned"]`);
await c.click(`${picker} input[type="search"]`);
await c.page.keyboard.type('推进');
await c.click(operator);
await c.page.click(operator);
assert.deepEqual(await choices(), { skill: '0', module: 'none' });
await confirm(edited);
await c.shot('empty-saved');
// A saved non-default pick must not overwrite new edits on a repeated operator click.
await open();
await c.click(`${picker} [data-skill="1"]`);
await c.click(`${picker} [data-module="uniequip_003_siege"]`);
await c.click(operator);
await c.page.click(`${picker} [data-skill="1"]`);
await c.page.click(`${picker} [data-module="uniequip_003_siege"]`);
await c.page.click(operator);
assert.deepEqual(await choices(), { skill: '1', module: 'uniequip_003_siege' });
await c.shot('saved-reselected');
await c.click(`${picker} .diy-pick__foot button`, '取消');
@@ -83,9 +84,9 @@ describe('自选编队 picker: reselecting retains the draft (real server, no ar
assert.deepEqual(await saved(), edited, 'cancel leaves the saved pick unchanged');
await open();
assert.deepEqual(await choices(), { skill: '0', module: 'none' });
await c.click(`${picker} [data-skill="1"]`);
await c.click(`${picker} [data-module="uniequip_003_siege"]`);
await c.click(operator);
await c.page.click(`${picker} [data-skill="1"]`);
await c.page.click(`${picker} [data-module="uniequip_003_siege"]`);
await c.page.click(operator);
await confirm(changed);
await c.page.reload({ waitUntil: 'domcontentloaded' });
await c.page.waitForSelector('.lobby-screen', { timeout: 20000 });
@@ -178,10 +179,11 @@ describe('0.2.0 自选编队 — a slotted operator in the own shop and battle (
await c.page.waitForSelector(`[data-testid="diy-picker"] .diy-opt[data-char="${SIEGE}"]`, { visible: true, timeout: 8000 });
const listed = await c.page.evaluate(() => [...document.querySelectorAll('[data-testid="diy-picker"] .diy-opt')].map((el) => el.dataset.char));
assert.ok(listed.includes('char_609_acguad') && listed.includes('char_601_cguard'), 'the tier-5 prototypes are listed');
await c.page.$eval(`[data-testid="diy-picker"] .diy-opt[data-char="${SIEGE}"]`, e => e.scrollIntoView({ block: 'nearest' }));
await c.click(`[data-testid="diy-picker"] .diy-opt[data-char="${SIEGE}"]`);
await c.page.waitForSelector('[data-testid="diy-picker"] .diy-choice[data-skill="2"]', { visible: true, timeout: 5000 });
await c.click('[data-testid="diy-picker"] .diy-choice[data-skill="2"]');
await c.click('[data-testid="diy-picker"] .diy-choice[data-module="uniequip_002_siege"]');
await c.page.click('[data-testid="diy-picker"] .diy-choice[data-skill="2"]');
await c.page.click('[data-testid="diy-picker"] .diy-choice[data-module="uniequip_002_siege"]');
await c.shot('picker');
await c.click('[data-testid="diy-confirm"]');
await c.page.waitForFunction((slot, id) => document.querySelector(`.diy-slot[data-slot="${slot}"]`)?.dataset.char === id, { timeout: 4000 }, SLOT, SIEGE);
+10 -5
View File
@@ -120,7 +120,7 @@ describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
await net.request('room.join', { code }); await net.request('room.ready', { ready: true });
}, code);
await host.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK', { polling: 50 });
await guest.evaluate(() => globalThis.__SP__.net.request('g.infoReady', { setupRevision: 0 }));
await host.waitForSelector('.brief-reroll button');
await host.bringToFront();
@@ -128,20 +128,25 @@ describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
await guest.waitForSelector('.brief-reroll .btn--primary');
assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().match.public.deadline), 0);
await guest.bringToFront();
await guest.click('.brief-reroll .btn--primary');
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1);
// The host is now a background tab: WebSocket/store updates continue, but animation-frame polling can stop.
// Arm its state wait before the vote so the regression covers a result arriving after the initial check.
await Promise.all([
host.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1, { polling: 50 }),
guest.click('.brief-reroll .btn--primary'),
]);
await guest.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1, { polling: 50 });
const state = await guest.evaluate(() => globalThis.__SP__.store.get().match.public);
assert.equal(state.rerollVote, null);
assert.ok(state.players.filter((p) => !p.isBot).every((p) => !p.ready));
await host.bringToFront();
await host.waitForFunction(() => !!document.querySelector('.brief-reroll button:not(:disabled)'));
// Wait out the documented server anti-burst interval before a second request.
await new Promise((resolve) => setTimeout(resolve, 350));
await host.bringToFront();
await host.click('.brief-reroll button');
await guest.waitForSelector('.brief-reroll .btn--danger');
await guest.bringToFront();
await guest.click('.brief-reroll .btn--danger');
await host.waitForFunction(() => !globalThis.__SP__.store.get().match.public.rerollVote);
await host.waitForFunction(() => !globalThis.__SP__.store.get().match.public.rerollVote, { polling: 50 });
assert.equal(await host.evaluate(() => globalThis.__SP__.store.get().match.public.setupRevision), 1);
} finally { await ctx.close(); }
});
+5 -3
View File
@@ -256,9 +256,11 @@ test('place in the detail: after the skills, before the modules; the first .lo-s
const mods = detail.indexOf('<section class="lo-sec lo-sec--mod">');
assert.ok(toggle > 0 && skills > toggle && stats > skills && mods > stats, 'skills (and their level toggle) → 局内数值 → modules');
// one record resolution, shared with the card: chessLoadout, the card's block, trait and talent helpers — no stat arithmetic of its own
assert.match(src, /import \{ chessStatsBlock, traitText, chessTalents, GarrisonBlock \} from '\.\.\/ui\/detailPanel\.js';/);
assert.match(src, /import \{ chessLoadout \} from '\.\.\/ui\/gameLogic\.js';/);
const fn = src.slice(src.indexOf('export function statsPreview'), src.indexOf('export function LoadoutStats'));
const shared = read('public/js/ui/loadoutStats.js');
assert.match(src, /import \{ LoadoutStats \} from '\.\.\/ui\/loadoutStats\.js';/);
assert.match(shared, /import \{ chessStatsBlock, traitText, chessTalents \} from '\.\/detailPanel\.js';/);
assert.match(shared, /import \{ chessLoadout \} from '\.\/gameLogic\.js';/);
const fn = shared.slice(shared.indexOf('export function statsPreview'), shared.indexOf('export function LoadoutStats'));
assert.ok(!/composeStats|attr\b|maxHp|statsBase/.test(fn), 'no stat arithmetic in the screen');
// the screen's own state: 精锐 by default, independent of the skill toggle's 普通
assert.match(src, /const \[statLevel, setStatLevel\] = useState\('elite'\)/);
+2 -2
View File
@@ -36,7 +36,7 @@ describe('the settings entry of the lobby and the room', { skip: !ENABLED && 'se
const settingsBeforeGuide = (page, screen, guideClass) => page.evaluate((s, g) => {
const btn = document.querySelector(`${s} [data-testid="settings-btn"]`);
const guide = document.querySelector(`${s} .${g}`);
return !!btn && !!guide && btn.nextElementSibling === guide && /设置/.test(btn.textContent) && btn.querySelector('svg.btn__icon') != null;
return !!btn && !!guide && btn.nextElementSibling === guide && /设置/.test(btn.getAttribute('aria-label') || btn.textContent) && btn.querySelector('svg.btn__icon') != null;
}, screen, guideClass);
const modalText = (page) => page.evaluate(() => document.querySelector('.modal')?.textContent.replace(/\s+/g, ' ').trim() ?? null);
@@ -53,7 +53,7 @@ describe('the settings entry of the lobby and the room', { skip: !ENABLED && 'se
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
await sleep(500);
assert.ok(await settingsBeforeGuide(page, '.lobby-screen .topbar__right', 'lobby-guide'), '设置 sits right before 玩法说明 in the lobby\'s top bar');
assert.ok(await settingsBeforeGuide(page, '.lobby-screen .topbar__tools', 'lobby-guide'), '设置 sits right before 玩法说明 in the lobby\'s top bar');
assert.equal(await modalText(page), null, 'closed until asked');
await page.click('.lobby-screen [data-testid="settings-btn"]');
await page.waitForSelector('.modal', { timeout: 5000 });
+3 -3
View File
@@ -88,8 +88,8 @@ describe('统计数据 page (real server, headless Chrome)', { skip: !ENABLED &&
}
const texts = (c, sel) => c.page.$$eval(sel, (els) => els.map((e) => e.innerText.replace(/\s+/g, ' ').trim()));
const stored = (c) => c.page.evaluate(() => { const r = localStorage.getItem('sp.pref.stats'); return r ? JSON.parse(r) : null; });
const openStats = async (c, from = '.lobby-screen .topbar__right .btn') => {
await c.click(from, '统计');
const openStats = async (c, from = '.lobby-screen .stats-entry') => {
await c.click(from, from.includes('stats-entry') ? null : '统计');
await c.page.waitForSelector('.st .st-tab', { visible: true, timeout: 15000 });
};
const toast = (c) => c.page.waitForFunction(() => document.querySelector('.toast')?.textContent || false, { timeout: 8000 }).then((h) => h.jsonValue());
@@ -305,7 +305,7 @@ describe('统计数据: the real flows (a fast real server, headless Chrome)', {
assert.equal(env.records[0].players[0].roundsPassed, 1, 'one round cleared — the server\'s own count for a quit in round 2');
assert.ok(env.records[0].players[0].bandId, 'its strategy');
// the page: one game counted, one left out, both in the history
await c.click('.lobby-screen .topbar__right .btn', '统计');
await c.click('.lobby-screen .stats-entry');
await c.page.waitForSelector('.st .st-card', { visible: true });
assert.equal((await c.page.$$eval('.st-card__num', (els) => els.map((e) => e.textContent)))[0], '1');
assert.match(await c.page.$eval('.st-rule', (e) => e.textContent), /另有 1 局未计入统计/);