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:
1 parent
1db8e51023
commit
6bfc08270c
129 files changed
+3477
-1011
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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##';
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(' '));
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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] } = {}) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {} };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export function WebGLRenderer() {}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)})`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
|
||||
@@ -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'\)/);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 局未计入统计/);
|
||||
|
||||
Reference in new issue
Block a user