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

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

No files matched your search

+3
View File
@@ -406,6 +406,9 @@ test('机密商店 E2E (co-op): two identical cards are two cards — both can b
// six 变形同构体: every slot draws the one item `coin` names
const one = Object.values(DATA.items).find((i) => i.name === '变形同构体' && !i.isGolden).id;
const data = dataWith('mode_multi_hard', 3, 'shop');
// This case isolates card-index ownership with sufficient stock; shared-stock.test.js covers exhaustion.
data.config = { ...data.config, economy: { ...data.config.economy,
itemPoolCopiesOverrides: { ...data.config.economy.itemPoolCopiesOverrides, [one]: 8 } } };
data.choices = { ...data.choices, shopDraft: { ...data.choices.shopDraft, rounds: [3], slots: Array.from({ length: 6 }, () => ({ coin: 1 })), coin: one } };
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, bots: 2, seed: 43, data, registry: REG, fake: true });
const m = h.m;
+209
View File
@@ -0,0 +1,209 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const done = (h) => { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); };
test('six Kazimierz stuns a nearby hovering enemy while blocking ground enemies (#463)', () => {
const h = makeBattle({ autoFinish: false, hooks: ['status'],
defs: { chess: { wall: chessRec({ id: 'wall', bonds: ['kazimierzShip'], skill: null, stats: { maxHp: 1e7, atk: 10, block: 3 } }) },
enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { kazimierzShip: { count: 6, active: true, tier: 3, layers: 1 } },
units: [{ uid: 1, chessId: 'wall', row: 10, col: 4 }] });
h.step(); const u = h.unit(1);
const ground = h.spawn('dummy', { pos: [10, 4], routeIndex: 0 });
const near = h.spawn('enemy_2025_syufo', { pos: [10, 4.5], routeIndex: 0 });
const far = h.spawn('enemy_2025_syufo', { pos: [10, 7], routeIndex: 0 });
for (const e of [near, far]) h.b.addBuff(e, { key: 'test:stationary', flags: { noMove: true, disarm: true } });
assert.ok(near.s.flags.unblockable);
h.step(2); assert.equal(ground.blockedBy, u);
assert.ok(h.runUntil(() => near.s.flags.stun, 4), 'the pulse can stun hovering targets');
assert.ok(!near.s.flags.unblockable, 'hovering ends while stunned');
assert.ok(!far.s.flags.stun, '周围 is the official 0.8 radius, not the whole field');
done(h);
});
test('a Raid revive inside the tick hook stays on the enemy tile instead of the taunt', () => {
const h = makeBattle({ autoFinish: false,
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'chess_char_4_16_a', skillIndex: 1, row: 10, col: 4 }] });
h.step(); const u = h.unit(1);
const local = h.spawn('dummy', { pos: [10, 4], routeIndex: 0 });
const far = h.spawn('dummy', { pos: [10, 8], routeIndex: 0 });
h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
let did = false;
h.b.on('tick', () => {
if (did || h.b.time < 7 / 30 - 1e-9 || u.blocking.length < 1) return;
did = true;
h.b.kill(u, null);
assert.ok(h.b.redeploy(u, { free: true }));
assert.equal(u.blocking.length, 0, 'redeploy clears the block list before the next tick');
});
h.step(8);
assert.equal(did, true);
h.step(1);
assert.deepEqual([u.tileR, u.tileC], [10, 4], 'the enemy is still on this tile when the revive poll runs');
assert.equal(local.blockedBy, u);
done(h);
});
test('an ordinary Raid poll prefers the nearer gate over a farther taunt', () => {
const probe = chessRec({ id: 'probe', position: 'MELEE', bonds: ['raidShip'], rangeGrid: [[0, 0]], skill: null, stats: { maxHp: 5000, atk: 10, block: 2, def: 0 } });
const h = makeBattle({ autoFinish: false,
defs: { chess: { probe }, enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'probe', row: 10, col: 5 }] });
h.step(); const u = h.unit(1);
const near = h.spawn('dummy', { pos: [9, 3], routeIndex: 0 });
const far = h.spawn('dummy', { pos: [9, 8], routeIndex: 0 });
h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
assert.ok(h.b.remainingDistance(near) < h.b.remainingDistance(far));
h.run(10.5);
assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(near), 'ordinary order is remaining distance, then id');
assert.ok(!h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(far));
done(h);
});
test('a retreat redeploy does not arm the Raid revive reselect', () => {
const wide = chessRec({ id: 'wide', position: 'RANGED', bonds: ['raidShip'], rangeGrid: [[0, 0], [0, 1], [0, -1]], skill: null, stats: { maxHp: 5000, atk: 10, block: 0 } });
const h = makeBattle({ autoFinish: false,
defs: { chess: { wide }, enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'wide', row: 10, col: 5 }] });
h.step(); const u = h.unit(1);
const e = h.spawn('dummy', { pos: [10, 6], routeIndex: 0 });
h.run(0.4);
assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(e));
const tile = [u.tileR, u.tileC];
h.b.retreat(u, { reason: 'retreat' });
assert.ok(h.b.redeploy(u, { free: true }));
const seq = u.deploySeq;
h.run(0.3);
assert.deepEqual([u.tileR, u.tileC], tile, 'an enemy already in range keeps the old poll from jumping');
assert.equal(u.deploySeq, seq);
done(h);
});
test('a revived Raid operator that has already blocked an enemy stays in place', () => {
const h = makeBattle({ autoFinish: false,
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'chess_char_4_16_a', skillIndex: 1, row: 10, col: 4 }] });
h.step(); const u = h.unit(1);
const local = h.spawn('dummy', { pos: [10, 4], routeIndex: 0 });
const far = h.spawn('dummy', { pos: [10, 8], routeIndex: 0 });
h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
h.b.kill(u, null); assert.ok(h.b.redeploy(u, { free: true }));
h.step(2); assert.equal(local.blockedBy, u);
h.run(0.3); assert.deepEqual([u.tileR, u.tileC], [10, 4]); done(h);
});
for (const taunt of [false, true]) test(`revived unblocked Texas S2 reselects a Raid target by taunt then remaining route (${taunt})`, () => {
const h = makeBattle({ autoFinish: false, hooks: ['deploy'],
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'chess_char_4_16_a', skillIndex: 1, row: 10, col: 4 }] });
h.step(); const u = h.unit(1);
h.spawn('dummy', { pos: [10, 5.2], routeIndex: 0 });
const nearGate = h.spawn('dummy', { pos: [9, 3], routeIndex: 0 });
const far = h.spawn('dummy', { pos: [10, 8], routeIndex: 0 });
if (taunt) h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
h.run(0.3); assert.deepEqual([u.tileR, u.tileC], [10, 4], 'initial deployment still uses the ordinary range gate');
h.b.kill(u, null); assert.ok(h.b.redeploy(u, { free: true }));
assert.equal(u.blocking.length, 0);
h.run(0.3);
const target = taunt ? far : nearGate;
assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(target), 'the higher-priority target is in the new range');
assert.notDeepEqual([u.tileR, u.tileC], [10, 4]);
const count = u.deploySeq; h.run(0.3); assert.equal(u.deploySeq, count, 'Raid itself cannot continuously rearm the revival check');
done(h);
});
test('Skadi S1 and 坚守 sharing retain transfer ancestry and do not revisit either sharing effect', () => {
const op = (id, bonds) => chessRec({ id, bonds, skill: null, stats: { maxHp: 1e7, atk: 0, def: 0 } });
const h = makeBattle({ autoFinish: false, defs: { chess: {
a: op('a', ['steadShip']), b: op('b', ['steadShip']), c: op('c', ['steadShip']), d: op('d', []),
} }, bonds: { steadShip: { count: 3, active: true, tier: 2, layers: 10 } },
units: [{ uid: 1, chessId: 'chess_char_6_04_a', row: 10, col: 4, skillIndex: 0 },
{ chessId: 'a', row: 9, col: 4 }, { chessId: 'b', row: 11, col: 4 },
{ chessId: 'c', row: 10, col: 3 }, { chessId: 'd', row: 10, col: 5 }] });
h.step(); assert.ok(h.unit(1).skill.activate('test', { free: true }));
let attempts = 0;
// Fail fast on the broken implementation without letting the branching recursion freeze the test worker.
h.b.on('hit', (c) => { if (++attempts > 64) c.dmg.cancel = true; }, { priority: 1000 });
h.b.dealDamage(null, h.unit('d'), { amount: 1000, type: 'true' });
assert.ok(attempts <= 12, `bounded transfer tree, got ${attempts} hits`);
assert.ok(h.unit('d').hp < h.unit('d').s.maxHp); done(h);
});
for (const mode of ['both', 'stead', 'skadi']) test(`Skadi S1 and 坚守 conserve a 1000 true hit (${mode})`, () => {
const op = (id, bonds) => chessRec({ id, bonds, skill: null, stats: { maxHp: 1e7, atk: 0, def: 0 } });
const both = mode === 'both';
const units = [];
if (mode !== 'stead') units.push({ uid: 1, chessId: 'chess_char_6_04_a', row: 10, col: 4, skillIndex: 0 });
units.push({ chessId: 'a', row: 9, col: 4 }, { chessId: 'b', row: 11, col: 4 }, { chessId: 'c', row: 10, col: 3 }, { chessId: 'd', row: 10, col: 5 });
const h = makeBattle({ autoFinish: false, defs: { chess: {
a: op('a', ['steadShip']), b: op('b', ['steadShip']), c: op('c', ['steadShip']), d: op('d', []),
} }, bonds: mode === 'skadi' ? {} : { steadShip: { count: 3, active: true, tier: 2, layers: 10 } }, units });
h.step();
if (mode !== 'stead') assert.ok(h.unit(1).skill.activate('test', { free: true }));
let attempts = 0;
h.b.on('hit', (c) => { if (++attempts > 64) c.dmg.cancel = true; }, { priority: 1000 });
const loss = (id) => { const u = h.unit(id); return u.s.maxHp - u.hp; };
h.b.dealDamage(null, h.unit('d'), { amount: 1000, type: 'true' });
const parts = [loss('d'), loss('a'), loss('b'), loss('c')];
if (mode !== 'stead') parts.push(loss(1));
const sum = parts.reduce((s, n) => s + n, 0);
const near = (got, want) => Math.abs(got - want) < 1e-3;
assert.ok(near(sum, 1000), `total ${sum}`);
if (both) {
assert.ok(near(loss('d'), 100), `victim ${loss('d')}`);
for (const id of ['a', 'b', 'c']) assert.ok(near(loss(id), 400 / 3), `${id} ${loss(id)}`);
assert.ok(near(loss(1), 500), `skadi ${loss(1)}`);
assert.ok(attempts <= 12, `bounded transfer tree, got ${attempts} hits`);
const before = attempts;
h.b.dealDamage(null, h.unit('d'), { amount: 1000, type: 'true' });
assert.ok(attempts - before <= 12, `second hit stayed bounded, got ${attempts - before}`);
assert.ok(near(loss('d'), 200) && near(loss(1), 1000), 'the second hit conserves the same split');
} else if (mode === 'stead') {
assert.ok(near(loss('d'), 600), `victim ${loss('d')}`);
for (const id of ['a', 'b', 'c']) assert.ok(near(loss(id), 400 / 3), `${id} ${loss(id)}`);
assert.ok(attempts <= 4, `stead-only hits ${attempts}`);
} else {
assert.ok(near(loss('d'), 500) && near(loss(1), 500));
assert.ok(near(loss('a'), 0) && near(loss('b'), 0) && near(loss('c'), 0));
assert.ok(attempts <= 2, `skadi-only hits ${attempts}`);
}
done(h);
});
for (const combo of [false, true]) test(`家族徽章 consumes its bonus on the first damage immediately after stealth ends (combo=${combo})`, () => {
const op = chessRec({ id: 'op', bonds: ['siracusaShip'], skill: null, rangeGrid: [[0, 0]], stats: { atk: 1000 } });
const h = makeBattle({ autoFinish: false, defs: { chess: { op }, enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
units: [{ uid: 1, chessId: 'op', row: 10, col: 4, items: ['chess_item_6_11_e_b', ...(combo ? ['chess_item_3_01_e_a'] : [])] }] });
h.step(); const u = h.unit(1), base = u.s.atk;
h.b.addBuff(u, { key: 'test:hidden', flags: { stealth: true } }); h.run(2);
const boosted = u.s.atk; assert.ok(boosted > base);
h.b.removeBuff(u, 'test:hidden'); const e = h.spawn('dummy', { pos: [10, 9] }); const hp = e.hp;
h.b.dealDamage(u, e, { amount: 100, type: 'true', isAttack: true });
assert.equal(u.s.atk, base, 'no waiting for the next 0.25-second growth poll');
assert.ok(Math.abs(hp - e.hp - (100 + (combo ? boosted * 8 : 0))) < 1e-6);
const hp2 = e.hp; h.b.dealDamage(u, e, { amount: 100, type: 'true', isAttack: true });
assert.equal(hp2 - e.hp, 100, 'bonus consumed once'); done(h);
});
for (const elite of [false, true]) test(`松果 S2 growth restarts on redeployment without resetting the lifetime activation sequence (${elite})`, () => {
const id = `chess_char_3_10_${elite ? 'b' : 'a'}`;
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: id, skillIndex: 1, row: 10, col: 4 }] });
h.step(); const u = h.unit(1), values = [];
for (let i = 0; i < 3; i++) {
assert.ok(u.skill.activate('test', { free: true })); values.push(u.findBuff('skill:pinecn_atk').mods.atkPct); u.skill.end('test');
}
assert.ok(values[2] > values[0]); const activations = u.skill.activations;
h.b.retreat(u, { reason: 'test' }); assert.ok(h.b.redeploy(u, { free: true }));
assert.ok(u.skill.activate('test', { free: true }));
assert.equal(u.findBuff('skill:pinecn_atk').mods.atkPct, values[0]);
assert.equal(u.skill.activations, activations + 1); done(h);
});
for (const elite of [false, true]) test(`哈洛德 S2 starts for an ally at full HP with only elemental injury (${elite})`, () => {
const wall = chessRec({ id: 'wall', skill: null, stats: { maxHp: 1e6, atk: 0 } });
const h = makeBattle({ autoFinish: false, defs: { chess: { wall } },
units: [{ uid: 1, chessId: `chess_char_2_05_${elite ? 'b' : 'a'}`, skillIndex: 1, row: 10, col: 4 },
{ uid: 2, chessId: 'wall', row: 10, col: 5 }] });
h.step(); const u = h.unit(1), a = h.unit(2); assert.equal(u.skill.id, 'skchr_harold_2');
u.skill.gainSp(999, 'init'); h.run(0.5); assert.equal(u.skill.activations, 0, 'no injury means no cast');
h.b.dealDamage(null, a, { type: 'element', element: 'burn', amount: 600 }); assert.equal(a.hp, a.s.maxHp);
assert.ok(h.runUntil(() => u.skill.activations > 0, 4)); assert.ok(a.elem.burn < 600); done(h);
});
@@ -10,6 +10,26 @@ import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.
const TRAP = 'token_10031_swire2_gdtrap';
const SW = 'chess_char_3_04_a';
for (const path of ['owner', 'token']) test(`香槟炸弹 (${path}) rejects ordinary allied auras and inspiration without isolating normal summons (#476)`, () => {
const { h, bomb: ownBomb } = herBomb({ units: [
{ uid: 2, chessId: 'chess_char_5_20_a', row: 10, col: 4, skillIndex: 0 },
{ uid: 3, chessId: 'chess_char_6_04_a', row: 10, col: 5, skillIndex: 1 },
] });
const angelina = h.unit(2), skadi = h.unit(3);
if (path === 'token') h.b.retreat(ownBomb, { reason: 'expired', permanent: true });
const bomb = path === 'owner' ? ownBomb : h.b.spawnToken(h.unit(1), TRAP, 9, 6);
const control = h.b.spawnToken(skadi, 'token_10017_skadi2_dedant', 9, 4);
assert.ok(bomb && control);
assert.ok(skadi.skill.activate('test', { free: true })); h.run(0.7);
assert.equal(h.b.allySelectable(bomb, angelina), false);
assert.equal(bomb.s.aspd, bomb.base.aspd);
assert.equal(bomb.s.hpRegen, 0);
assert.equal(bomb.findBuff('inspire'), null);
assert.equal(bomb.findBuff('inspire:def'), null);
assert.ok(h.b.allySelectable(control, angelina), 'a normal unhealable summon is still selectable');
assert.ok(control.s.aspd > control.base.aspd, 'Angelina still affects the ordinary summon');
assert.ok(bomb.alive && bomb.hp === bomb.s.maxHp); checkInvariants(h.b);
});
/** 琳琅诗怀雅 on (9,5) of a field whose only free x-6 tile is (9,6) — 活性源石 when `infected`; she throws one bomb there. */
function herBomb({ infected = false, units = [], enemies = [] } = {}) {
const row9 = infected ? '##ERRriRrrSrrrrrrrS##' : '##ERRrrRrrSrrrrrrrS##';
+20
View File
@@ -839,6 +839,26 @@ test('塞雷娅 99 / 白面鸮 86: the copier runs the front operator\'s prep-en
assert.equal(w.ps.shop.freeRefreshes, 3);
});
// Maintainer-confirmed replay of the linked official-game footage (#473): only Ptilopsis copying these two gifts.
for (const targetId of ['chess_char_4_12_a', 'chess_char_6_03_a']) {
for (const ownRow of [false, true]) for (const sourceRow of [false, true]) {
test(`白面 copies ${targetId} gift without either row condition (own=${ownRow}, source=${sourceRow})`, () => {
const s = setup(11);
const white = give(s.m, s.ps, 'chess_char_4_21_a', 'board', [11, 4]); white.dir = 'DOWN';
give(s.m, s.ps, targetId, 'board', [10, 4]);
const fillers = plain(c => c.position === 'RANGED').slice(0, 4); let filler = 0;
for (const [r, on] of [[11, ownRow], [10, sourceRow]]) if (on) {
give(s.m, s.ps, fillers[filler++], 'board', [r, 3]);
give(s.m, s.ps, fillers[filler++], 'board', [r, 5]);
}
s.ps.recompute(); const before = s.ps.round.gainedChess;
s.roundStart();
assert.equal(s.ps.round.gainedChess - before, 1 + (sourceRow ? 1 : 0), 'copier always gives one; original still checks its own row');
s.h.invariants();
});
}
}
test('triggerGainEffects export (band 铃兰): re-runs 获得时 garrisons ×投资人 and returns the effects run', () => {
const s = setup();
const p = give(s.m, s.ps, 'chess_char_5_03_a', 'board', [10, 4]); // 烛煌 炎/维多利亚 +5
+12 -13
View File
@@ -954,7 +954,7 @@ test('拟态物质: owning 2 copies gives the 3rd (→ elite); otherwise a rando
cover(A('5_05'), B('5_05'));
});
test('拟态物质 with 2 copies owned and none left in the pool gives nothing — never a same-bond operator; with copies left the 3rd still merges (GitHub #207)', () => {
test('拟态物质 grants the third copy even when stock is empty; the gain still merges and occupies stock (measured pool rule)', () => {
// the report: an elite 溯光星源 (3 of the Ⅵ阶's 5 copies) and 2 normal ones — the pool is empty, the item text's 否则
// (a random same-bond operator) is only for fewer than 2 owned
const cid = 'chess_char_6_16_a';
@@ -966,18 +966,17 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing
const t = give(m, ps, cid, 'hand');
give(m, ps, cid, 'hand');
assert.equal(m.pool.left(cid), 0, 'elite 3 + 2 normal = the pool cap 5');
const before = chessIn(ps);
const got = spyGrants(ps);
const warns = spyWarns(m);
const it = giveItem(m, ps, item);
assert.deepEqual(equip(it, t), OK, item);
assert.deepEqual(got, [], `${item}: nothing granted`);
assert.deepEqual(chessIn(ps), before, `${item}: no other operator, no second elite`);
assert.deepEqual(got, [cid], `${item}: same operator granted despite empty stock`);
assert.deepEqual(chessIn(ps), [elite, elite], `${item}: the third normal copy merges`);
assert.ok(!ps.find(it.uid), `${item}: consumed`);
// …and says why (GitHub #401, the owner's OK of 2026-10-09)
assert.deepEqual(warns, [{ msgid: '{who}:卡池中已没有{name}', who: '拟态物质', name: '溯光星源' }], `${item}: the toast`);
assert.deepEqual(warns, [], `${item}: a successful grant must not report an empty-pool failure`);
assert.equal(m.pool.entries.get(cid).left, -1, 'the sixth owned copy still occupies the pool');
}
// 2 normal copies, no elite, the pool drained by other players: nothing either
// 2 normal copies, no elite, the pool drained by other players: the third copy is still granted.
{
const { m, ps, equip } = setup({ seed: 9 });
const t = give(m, ps, cid, 'hand');
@@ -986,9 +985,9 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing
const got = spyGrants(ps);
const warns = spyWarns(m);
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK);
assert.deepEqual(got, []);
assert.deepEqual(chessIn(ps), [cid, cid]);
assert.deepEqual(warns.map((w) => w.msgid), ['{who}:卡池中已没有{name}']);
assert.deepEqual(got, [cid]);
assert.deepEqual(chessIn(ps), [elite]);
assert.deepEqual(warns, []);
}
// 2 normal copies with copies left: the 3rd merges into the elite
{
@@ -1004,7 +1003,7 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing
}
});
test('拟态物质 with fewer than 2 copies and no same-bond operator left gives nothing and says so (GitHub #401)', () => {
test('拟态物质 with fewer than 2 copies samples eligible identities independently of remaining stock', () => {
// 缪尔赛思's only bond is 调和 and she is its only member: one copy owned, the rest of her pool taken by others
const cid = 'chess_char_6_11_a';
const { m, ps, equip } = setup({ seed: 9 });
@@ -1013,8 +1012,8 @@ test('拟态物质 with fewer than 2 copies and no same-bond operator left gives
const got = spyGrants(ps);
const warns = spyWarns(m);
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK);
assert.deepEqual(got, [], 'nothing granted');
assert.deepEqual(warns, [{ msgid: '{who}:没有可获得的同盟约干员', who: '拟态物质', name: null }]);
assert.deepEqual(got, [cid], 'the only eligible identity remains available even with no stock');
assert.deepEqual(warns, []);
});
test('博士投影: golden promotes at once; normal stays equipped and promotes at the next round start', () => {
+38 -2
View File
@@ -11,8 +11,8 @@ const approx = (a, b, label = '') => assert.ok(Math.abs(a - b) < 1e-5 * Math.max
const enemy = (key, opts = {}) => enemyRec({ key, hp: 1e8, atk: 0, speed: 0, ...opts });
const defs = { enemies: { dummy: enemy('dummy'), light: enemy('light', { mass: 3 }), mid: enemy('mid', { mass: 4 }), heavy: enemy('heavy', { mass: 9 }),
fly: enemy('fly', { motion: 'FLY' }), zero: enemy('zero', { mass: 0 }), armored: enemy('armored', { def: 100 }) } };
function field({ tier = 6, elite = true, skill = 0, potential = 6, dir = 'RIGHT', row = 10, col = 3 } = {}) {
const h = makeBattle({ seed: 23, autoFinish: false, timeLimit: 180, defs,
function field({ tier = 6, elite = true, skill = 0, potential = 6, dir = 'RIGHT', row = 10, col = 3, flags = {} } = {}) {
const h = makeBattle({ seed: 23, autoFinish: false, timeLimit: 180, defs, flags,
hooks: ['damaged', 'attack', 'elementBurst', 'ammoUsed'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: tier, charId: C, skillIndex: skill }, elite, potential, row, col, dir }] });
h.step(); return { h, u: h.unit(1) };
@@ -21,6 +21,26 @@ function done(h) { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); }
function start(u) { u.skill.gainSp(999, 'init'); assert.equal(u.skill.activate('test'), true); }
const hits = (h, tag) => h.hooksOf('damaged').filter((c) => c.dmg?.tags?.includes(tag));
for (const tier of [5, 6]) for (const elite of [false, true]) test(`风暴潮 air-only automation retains ground-only normal attacks (${tier}/${elite})`, () => {
const { h, u } = field({ tier, elite, skill: 1, flags: { startOpCooldown: 3 } });
const e = h.spawn('fly', { pos: [10, 6] }); u.skill.gainSp(999, 'init');
h.run(2); assert.equal(u.skill.activations, 0, 'operation cooldown still applies');
assert.ok(h.runUntil(() => u.skill.active, 2)); h.run(3);
const damaged = h.hooksOf('damaged').filter(c => c.source === u && c.target === e);
assert.ok(damaged.length > 0, 'the vortex hits the air-only target');
assert.ok(damaged.every(c => !c.dmg.isAttack), 'normal attacks remain ground-only'); done(h);
});
test('风暴潮 auto-cast rejects distant/hidden/sleeping flyers and waits through stun', () => {
const { h, u } = field({ skill: 1 }); u.skill.gainSp(999, 'init');
h.spawn('fly', { pos: [12, 12] }); h.run(0.3); assert.equal(u.skill.activations, 0);
const e = h.spawn('fly', { pos: [10, 6] }); h.b.addBuff(e, { key: 'test:hide', flags: { stealth: true } });
h.run(0.3); assert.equal(u.skill.activations, 0);
h.b.removeBuff(e, 'test:hide'); h.b.applyStatus(e, 'sleep', { duration: 0.5 });
h.run(0.3); assert.equal(u.skill.activations, 0);
h.b.applyStatus(u, 'stun', { duration: 0.6 }); h.run(0.4); assert.equal(u.skill.activations, 0);
h.run(0.5); assert.equal(u.skill.activations, 1); done(h);
});
test('克莱门莎 is selectable with authored skills in both slots, every form and potential', () => {
assert.ok(KITTED_CHARS.includes(C));
for (const tier of [5, 6]) {
@@ -186,6 +206,22 @@ test('风暴潮 normal attack hits five ground enemies in total, never all neigh
assert.equal(victims.size, 5); assert.ok(!victims.has(fly)); done(h);
});
for (const elite of [false, true]) test(`风暴潮 splash uses the published 1.1 collision radius and 100% ATK (${elite})`, () => {
const { h, u } = field({ skill: 1, elite }); start(u); h.run(1.6); h.b.rng = () => 0.99;
const main = h.spawn('dummy', { pos: [10, 4] });
const near = h.spawn('dummy', { pos: [10, 5] });
const outside = h.spawn('dummy', { pos: [10, 5.2] });
const body = h.spawn('dummy', { pos: [10, 5.8] });
body.hitArea = { w: 1.6, h: 1, dx: 0, dy: 0 };
const hp = [main, near, outside, body].map(e => e.hp);
performAttack(h.b, u, effectiveProfile(u), [main]);
approx(hp[0] - main.hp, u.s.atk * u.skill.bb['attack@aoe_atk_scale']);
approx(hp[1] - near.hp, u.s.atk);
assert.equal(outside.hp, hp[2], 'a point outside radius 1.1 is not splashed');
approx(hp[3] - body.hp, u.s.atk, 'a large collision body overlapping radius 1.1 is splashed');
done(h);
});
test('与海为敌 normal attacks hit three and spend no ammunition; remote erosion bursts fire three cross-shaped mixed hits', () => {
const { h, u } = field({ skill: 2 }); start(u); h.b.rng = () => 0.99;
const close = Array.from({ length: 3 }, () => h.spawn('dummy', { pos: [10, 4] }));