0.2.5-dev: community round 30 and follow-ups

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

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
import { BOND_LAYER_CAP, PHASE } from '../../shared/constants.js';
const cues = h => h.sent.filter(([, msg]) => msg.t === 'm.layer');
function prep(t) {
const h = makeMatch({ mode: 'coop', humans: 2, fake: true }).start().toPrep(1);
t.after(() => h.m.dispose());
h.sent.length = 0;
return h;
}
test('prep gains unicast the actual increase once, even for an inactive bond', t => {
const h = prep(t), p = h.ps('p_0');
assert.equal(p.bonds.sargonShip?.active ?? false, false);
assert.equal(p.addLayers('sargonShip', 3), 3);
assert.deepEqual(cues(h), [['p_0', { t: 'm.layer', playerId: 'p_0', bondId: 'sargonShip', add: 3 }]]);
assert.equal(h.bc.some(msg => msg.t === 'm.layer'), false);
p.layers.sargonShip = BOND_LAYER_CAP - 1;
p.addLayers('sargonShip', 10);
assert.equal(cues(h).at(-1)[1].add, 1);
const n = cues(h).length;
for (const v of [1, 0, -2, NaN, Infinity]) p.addLayers('sargonShip', v);
p.addLayers('not-a-bond', 2);
p.addLayers('kjeragShip', 2, { requireActive: true });
assert.equal(cues(h).length, n);
});
test('only live prep phases emit, never settlement/backfill or resynchronization views', t => {
const h = prep(t), p = h.ps('p_0');
for (const phase of Object.values(PHASE)) {
h.m.phase = phase; h.sent.length = 0;
p.addLayers('sargonShip', 1);
assert.equal(cues(h).length, [PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP].includes(phase) ? 1 : 0, phase);
}
h.m.phase = PHASE.PREP; h.sent.length = 0;
p.recompute(); p.privateView(); h.m.publicView();
assert.equal(cues(h).length, 0);
});
+5 -3
View File
@@ -264,9 +264,11 @@ test('Final Assault: two client-run pair fields share the server pool (b.progres
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const [b1, b2] = fields(h);
assert.deepEqual([b1.fieldId, b2.fieldId], ['b1', 'b2']);
assert.deepEqual([b1.mode, b1.authority, b2.mode, b2.authority], ['client', 'p_0', 'client', 'p_2'], 'lowest seat of each pair');
const authorities = [b1, b2].map(f => [...f.players].sort((a, b) => h.ps(a).seat - h.ps(b).seat)[0]);
assert.deepEqual([b1.mode, b1.authority, b2.mode, b2.authority], ['client', authorities[0], 'client', authorities[1]], 'lowest seat of each pair');
const startMsgs = h.sent.filter(([, x]) => x.t === 'b.start' && x.kind === 'boss');
assert.deepEqual(startMsgs.map(([pid, x]) => `${pid}:${x.fieldId}:${x.authoritative ? 'A' : 'r'}`).sort(), ['p_0:b1:A', 'p_1:b1:r', 'p_2:b2:A', 'p_3:b2:r']);
assert.deepEqual(startMsgs.map(([pid, x]) => `${pid}:${x.fieldId}:${x.authoritative ? 'A' : 'r'}`).sort(),
[b1, b2].flatMap((f, i) => f.players.map(pid => `${pid}:${f.fieldId}:${pid === authorities[i] ? 'A' : 'r'}`)).sort());
assert.equal(startMsgs[0][1].spec.boss.poolMax, m.bossPool.maxHp);
const max = m.bossPool.maxHp;
h.sched.advance(3000);
@@ -597,7 +599,7 @@ test('boss bounty eligibility: forced real results on a team victory qualify; sy
function bossPaid(seed, { leaks = 0, leaksBy = null, leaked = {} }) {
const { h, m, f } = faWithForger(seed);
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
const ps = f.players.map((pid) => h.ps(pid));
const ps = f.players.map((pid) => h.ps(pid)).sort((a, b) => a.seat - b.seat); // return payouts by player, not random side
for (const p of ps) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
const before = ps.map((p) => p.pendingFunds);
const lp0 = m.teamLp;
+83
View File
@@ -0,0 +1,83 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA, makeMatch, give, checkInvariants } from './harness.js';
import { GameData } from '../../server/match/gamedata.js';
import { computeBonds } from '../../server/match/bondsMeta.js';
function emptyPrep() {
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 11, fake: true }).start().toPrep(1);
const p = h.ps('p_0');
for (const x of p.allChess()) p.returnCopies(x);
p.board.clear(); p.hand.fill(null); p.temp.fill(null); p.offers.length = 0; p.bandId = null; p.recompute();
return { h, m: h.m, p };
}
for (const g of Object.values(DATA.garrisons).filter(g => g.effectKey === 'SERVER_GAIN_EQUIP')) {
for (const owner of g.owners) test(`fixed gift ${g.garrisonId}/${owner} is produced past stock and still occupies it`, () => {
const { m, p } = emptyPrep(), id = g.bbStr.chess, cap = m.itemPool.cap(id);
assert.ok(Number.isInteger(cap));
for (let i = 0; i < cap; i++) assert.ok(p.acquireItem(id));
assert.equal(m.itemPool.left(id), 0);
assert.ok(p.acquireChess(owner, { fromPool: false }));
assert.equal(m.itemPool.held(id), cap + g.bb.count, 'the trait must finish all grants, including merges');
assert.equal(m.itemPool.left(id), 0, 'overstock prevents new shop draws');
assert.equal(p.acquireItem(id), null, 'an ordinary stock-backed grant is still refused');
checkInvariants(m); m.dispose();
});
}
test('unlimited fixed-item gifts do not exempt unrelated strategy equipment from stock', () => {
const { m, p } = emptyPrep(), id = 'chess_item_5_07_e_a';
assert.ok(p.acquireItem(id)); assert.ok(p.acquireItem(id));
assert.equal(p.acquireItem(id, { requirePool: false }), null, 'commercial packaging is not a fixed operator gift');
assert.equal(m.itemPool.held(id), 2); checkInvariants(m); m.dispose();
});
for (const invest of [false, true]) test(`repeated Muelsyse acquisition and elite gifts remain unlimited (investor=${invest})`, () => {
const { m, p } = emptyPrep(), id = 'chess_item_6_09_e_a';
if (invest) {
const members = Object.values(DATA.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes('investShip')).slice(0, 3);
for (const c of members) give(m, p, c.chessId);
}
assert.equal(m.dispatcher.investRepeat(p), invest ? 2 : 1);
const expected = invest ? [2, 4, 8, 10, 12] : [1, 2, 4, 5, 6];
for (const n of expected) {
assert.ok(p.acquireChess('chess_char_6_11_a'));
assert.equal(m.itemPool.held(id), n); checkInvariants(m);
// Stow the non-merging isomorphs on holders so this stock test does not exhaust the player's inventory.
for (const item of [...p.hand, ...p.temp].filter(x => x?.kind === 'item')) {
const holder = p.allChess().find(c => c.items.length < m.gd.equipPerChess);
if (holder) assert.ok(p.equip(item.uid, holder.uid).ok);
}
}
m.dispose();
});
test('Damazti strategy grants its fixed item at rounds 5/10/15 past cap; releasing overflow does not free stock early', () => {
const { m, p } = emptyPrep(), id = 'chess_item_6_09_e_a';
for (let i = 0; i < 3; i++) assert.ok(p.acquireItem(id));
p.bandId = 'band_damaztic'; p.recompute();
for (const [i, round] of [5, 10, 15].entries()) {
m.round = round; m.dispatch(p, 'onRoundStart', { round });
assert.equal(m.itemPool.held(id), 4 + i); checkInvariants(m);
}
const copy = () => [...p.hand, ...p.temp].find(x => x?.kind === 'item' && x.id === id);
for (let i = 0; i < 3; i++) { assert.ok(p.destroy(copy().uid).ok); assert.equal(m.itemPool.left(id), 0); }
assert.ok(p.destroy(copy().uid).ok);
assert.equal(m.itemPool.held(id), 2); assert.equal(m.itemPool.left(id), 1);
checkInvariants(m); m.dispose();
});
for (const mu of ['chess_char_6_11_a', 'chess_char_6_11_b']) test(`seven distinct Yan operators plus Yan-converted ${mu} activate nine Yan`, () => {
const gd = new GameData(DATA, 'mode_multi_hard');
const seven = Object.values(DATA.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes('yanShip')).slice(0, 7);
assert.equal(seven.length, 7);
const pieces = seven.map((c, i) => ({ uid: i + 1, kind: 'chess', id: c.chessId, items: [] }));
pieces.push({ uid: 8, kind: 'chess', id: mu, items: [{ id: 'chess_item_6_09_e_a' }, { id: 'chess_item_3_04_e_a' }] });
const state = { board: new Map(pieces.map((p, i) => [`${9 + i % 4},${3 + Math.floor(i / 4)}`, p])), hand: [], layers: {} };
const b = computeBonds(gd, state);
assert.equal(b.yanShip.count, 9); assert.equal(b.yanShip.tier, 3); assert.equal(b.yanShip.harmony, 1);
pieces[7].items.pop();
const withoutConversion = computeBonds(gd, state);
assert.equal(withoutConversion.yanShip.count, 8); assert.equal(withoutConversion.yanShip.tier, 2);
});
+1 -1
View File
@@ -221,7 +221,7 @@ test('onLeave: a quitter has no place in the Final Assault pairing or the boss p
assert.equal(alive.length, 2);
assert.ok(!alive.includes(first));
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(m.fields.map((f) => f.players), [alive], 'one boss field for the two players left');
assert.deepEqual(m.fields.map((f) => [...f.players].sort()), [[...alive].sort()], 'one boss field for the two players left');
assert.equal(m.bossPool.maxHp, bossPoolHp(m.gd, m.bossId, 2), 'the co-op boss pool of two alive players');
assert.equal(m.bossPool.maxHp, m.gd.boss(m.bossId).bloodPoint[m.gd.difficulty] * 2, 'bloodPoint × the two players left; the quitter does not count (DESIGN §25.13.4)');
const end = h.runToEnd();
+3 -1
View File
@@ -173,7 +173,9 @@ test('buy → 3 copies merge into the elite (the stock pays the copies); sell /
for (let i = 0; i < 8; i++) gain(p0, T5A);
assert.equal(p0.diyStock.left(T5A), 0);
p0.shop.slots[0] = { kind: 'chess', id: T5A, basePrice: 4, frozen: false, sold: false };
assert.equal(m.handle('p_0', { t: 'g.buy', slot: 0 }).error, 'SOLD_OUT');
assert.ok(m.handle('p_0', { t: 'g.buy', slot: 0 }).ok, 'a displayed operator remains purchasable after exhaustion');
assert.equal(p0.allChess().filter(p => p0.gd.baseIdOf(p.id) === T5A).reduce((n, p) => n + p.poolCopies, 0), 9);
assert.equal(p0.diyStock.left(T5A), 0, 'the extra copy does not reopen available stock');
h.invariants();
// reward offers (the temporary refresh) draw it once the 调度中心 is at the slot's level
clear(p0);
+5 -3
View File
@@ -103,15 +103,17 @@ test('shop slots per level (+ the item slot), buy prices by tier, SOLD_OUT, NO_F
assert.equal(ps.hand[9].id, slot0.id, 'hand fills right→left');
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: ERR.SOLD_OUT });
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 15 }).error, ERR.BAD_TARGET);
// a display is not a reservation: an empty pool makes the buy fail
// a display is not a reservation, but its operator remains purchasable after the pool is exhausted (#498)
const s1 = ps.shop.slots[1];
const taken = m.pool.take(s1.id, 100);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.SOLD_OUT });
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { ok: true });
assert.equal(m.pool.entries.get(s1.id).left, -1);
m.pool.give(s1.id, taken);
// full hand blocks buys
const fillers = chessOfTier(3).filter((x) => m.pool.has(x) && x !== s1.id);
for (let i = 0; ps.hand.some((x) => x == null); i++) give(m, ps, fillers[i]);
if (ps.countCopies(m.gd.baseIdOf(s1.id)) === 0) assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.HAND_FULL });
ps.shop.slots[1] = { ...s1, sold: false };
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.HAND_FULL });
const itemSlot = ps.shop.slots.findIndex((s) => s && s.kind === 'item');
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: itemSlot }), { error: ERR.HAND_FULL });
checkInvariants(m);
@@ -17,6 +17,9 @@ import { teamFrameIds } from '../../public/js/ui/gameLogic.js';
function bossPrep(humans, seed) {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans, seed, fake: true }).start();
const m = h.m;
// Explicit layout fixture: these cases test mirrored metadata, not the random draw (covered separately).
const ids = m.order.map(p => p.playerId);
m.bossPairs = [ids.slice(0, 2), ids.slice(2)].filter(g => g.length);
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
for (const ps of m.players.values()) { ps.ready = false; for (const k of [...ps.board.keys()]) ps.board.delete(k); ps.recompute(); }
m.flush(true);
@@ -17,6 +17,7 @@ import { leaderStand } from '../../public/js/render/prepfield.js';
function bossPrep(seed) {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed, fake: true }).start();
const m = h.m;
m.bossPairs = [['p_0', 'p_1'], ['p_2', 'p_3']]; // explicit mirrored-layout fixture, not the pairing policy
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
return h;
}
@@ -64,6 +65,7 @@ test('a scout of a boss round\'s prep is the boss field: the player\'s half (mir
test('the eliminated viewer is pushed the boss-field scout at the boss round\'s start (item 56); a normal round\'s scout stays the normal board', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 56, fake: true }).start();
const m = h.m;
m.bossPairs = [['p_0', 'p_1'], ['p_2']]; // keep this scout fixture's named left/right roles
h.toPrep(1);
h.ps('p_2').eliminate(1);
h.toPrep(2);
@@ -101,6 +101,7 @@ test('client combat: an eliminated human is started on the field of the player i
test('Final Assault: an eliminated viewer is started on its preference\'s boss field; a shared field records the tapped player only', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed: 54, fake: true, instant: false, script: (b) => (b.kind === 'boss' ? { bossDps: 1 } : {}) }).start();
const m = h.m;
m.bossPairs = [['p_0', 'p_1'], ['p_2', 'p_3']]; // named watcher-role fixture; random assignment has its own tests
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
h.ps('p_3').eliminate(13);
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true }, 'records the preference in prep');
+58 -17
View File
@@ -6,15 +6,54 @@ import { pairPlayers, bossPoolHp, SharedBossPool, hiddenEligible } from '../../s
import { GameData } from '../../server/match/gamedata.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
import { createRng } from '../../server/sim/rng.js';
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
test('pairing by seat: (1,2), (3,4); an odd player alone', () => {
test('pairing is seeded, independent of input order, includes everyone once and can draw every partner', () => {
const p = (seat) => ({ seat, playerId: `s${seat}` });
assert.deepEqual(pairPlayers([p(0)]).map((g) => g.map((x) => x.seat)), [[0]]);
assert.deepEqual(pairPlayers([p(2), p(0)]).map((g) => g.map((x) => x.seat)), [[0, 2]]);
assert.deepEqual(pairPlayers([p(3), p(1), p(0)]).map((g) => g.map((x) => x.seat)), [[0, 1], [3]]);
assert.deepEqual(pairPlayers([p(3), p(1), p(0), p(2)]).map((g) => g.map((x) => x.seat)), [[0, 1], [2, 3]]);
const partners = new Set(), lone = new Set();
for (let seed = 1; seed <= 64; seed++) for (const n of [1, 2, 3, 4]) {
const alive = Array.from({ length: n }, (_, i) => p(i));
const groups = pairPlayers(alive, createRng(seed));
assert.deepEqual(groups, pairPlayers([...alive].reverse(), createRng(seed)));
assert.equal(groups.length, Math.ceil(n / 2));
assert.deepEqual(groups.flat().map(x => x.seat).sort(), alive.map(x => x.seat));
if (n === 4) partners.add(groups.find(g => g.some(x => x.seat === 0)).find(x => x.seat !== 0).seat);
if (n === 3) lone.add(groups.find(g => g.length === 1)[0].seat);
}
assert.deepEqual([...partners].sort(), [1, 2, 3]);
assert.deepEqual([...lone].sort(), [0, 1, 2]);
});
test('previous boss partners and sides persist; only orphaned survivors form a new pair', () => {
const all = [0, 1, 2, 3].map(seat => ({ seat, playerId: `s${seat}` }));
const previous = [['s3', 's0'], ['s2', 's1']];
const ids = groups => groups.map(g => g.map(p => p.playerId));
assert.deepEqual(ids(pairPlayers(all, createRng(99), previous)), previous);
assert.deepEqual(ids(pairPlayers(all.filter(p => p.seat !== 0), createRng(99), previous)), [['s2', 's1'], ['s3']]);
assert.deepEqual(ids(pairPlayers(all.filter(p => p.seat > 1), createRng(99), previous)), [['s3', 's2']]);
});
test('boss preview, actual fields and Hidden Core retain one per-match draw', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 4, seed: 31, fake: true,
script: b => ['boss', 'hidden'].includes(b.kind) ? { bossDps: 1e9 } : {} }).start();
try {
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 14);
const pairs = structuredClone(h.m.bossPairs);
assert.deepEqual(h.m.bossWaves.map(w => w.players), pairs);
for (const p of h.m.order) {
p.bondCountBonus.yanShip = 3; p.layers.yanShip = 400; p.recompute();
const group = h.m.bossGroupOf(p);
assert.equal(group.side, pairs.find(g => g.includes(p.playerId)).indexOf(p.playerId) === 0 ? 'L' : 'R');
}
h.drive(() => h.m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(h.m.fields.map(f => f.players), pairs);
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 15);
assert.deepEqual(h.m.bossWaves.map(w => w.players), pairs);
h.drive(() => h.m.phase === PHASE.HIDDEN_CORE);
assert.deepEqual(h.m.fields.map(f => f.players), pairs);
} finally { h.m.dispose(); }
});
test('boss pool = bloodPoint[difficulty] × the players alive at the fight\'s start (solo × 1) × tuning; config restores the fixed pool; shared and never negative', () => {
@@ -200,22 +239,24 @@ test('Final Assault: a fighting player may only watch its own boss field; elimin
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
h.ps('p_3').eliminate(13);
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(m.fields.map((f) => [f.fieldId, f.players]), [['b1', ['p_0', 'p_1']], ['b2', ['p_2']]]);
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'b2' }).error, 'BAD_TARGET', 'the other group is hidden');
assert.equal(m.handle('p_2', { t: 'g.watch', fieldId: 'b1' }).error, 'BAD_TARGET');
assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'b1' }), { ok: true });
const pair = m.fields.find(f => f.players.length === 2), lone = m.fields.find(f => f.players.length === 1);
assert.deepEqual(m.fields.flatMap(f => f.players).sort(), ['p_0', 'p_1', 'p_2']);
const [first, second] = pair.players, third = lone.players[0];
assert.equal(m.handle(first, { t: 'g.watch', fieldId: lone.fieldId }).error, 'BAD_TARGET', 'the other group is hidden');
assert.equal(m.handle(third, { t: 'g.watch', fieldId: pair.fieldId }).error, 'BAD_TARGET');
assert.deepEqual(m.handle(second, { t: 'g.watch', fieldId: pair.fieldId }), { ok: true });
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b2' }), { ok: true }, 'an eliminated player spectates freely');
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1' }), { ok: true });
assert.equal(m.watchers.get('p_0'), 'b1', 'the refused watch keeps the default (own) field');
assert.equal(m.watchers.get(first), pair.fieldId, 'the refused watch keeps the default (own) field');
// the prep-scouting id of a player of the other group is no way around the rule, and never detaches the viewer
const scouted = h.allTo('p_0', 'm.field').length;
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_2' }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.equal(h.allTo('p_0', 'm.field').length, scouted, 'no board of the other group was sent');
assert.equal(m.watchers.get('p_0'), 'b1', 'still streaming the own boss field');
const snaps = h.allTo('p_0', 'b.snap').length;
const scouted = h.allTo(first, 'm.field').length;
assert.deepEqual(m.handle(first, { t: 'g.watch', fieldId: `n:${third}` }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.deepEqual(m.handle(first, { t: 'g.watch', fieldId: `n:${second}` }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.equal(h.allTo(first, 'm.field').length, scouted, 'no board of the other group was sent');
assert.equal(m.watchers.get(first), pair.fieldId, 'still streaming the own boss field');
const snaps = h.allTo(first, 'b.snap').length;
h.sched.advance(500);
assert.ok(h.allTo('p_0', 'b.snap').length > snaps, 'b.snap of b1 keeps coming');
assert.ok(h.allTo(first, 'b.snap').length > snaps, 'snapshots of the own field keep coming');
m.dispose();
});
+3 -2
View File
@@ -43,8 +43,9 @@ test('multi-round bounties spawn in the Final Assault on the owner\'s half, show
assert.equal(rt.motion === 'FLY', fly, 'route motion matches the enemy');
assert.equal(s.count, card.count);
assert.ok(s.mods.hpMul > 0, 'bounty enemies take the round\'s multipliers');
if (s.ownerPlayerId === 'p_0') assert.ok(rt.end[1] < 10, `left player's bounty goes to the left goal (${rt.end})`);
else { assert.equal(s.ownerPlayerId, 'p_1'); assert.ok(rt.end[1] > 10, `right player's bounty goes to the right goal (${rt.end})`); }
const side = f.opts.players.find(p => p.playerId === s.ownerPlayerId)?.side;
assert.ok(side === 'L' || side === 'R', 'bounty has an actual field owner');
assert.ok(side === 'L' ? rt.end[1] < 10 : rt.end[1] > 10, `owner's ${side} bounty goes to its own goal (${rt.end})`);
}
const pending = p0.pendingFunds;
const end = h.runToEnd();
+4 -2
View File
@@ -156,14 +156,16 @@ test('merge whose copies are not deployed (temp) with a full hand overflows the
m.dispose();
});
test('reward pick respects the pool and a full hand; offers expire at prep end', () => {
test('displayed operator rewards survive stock exhaustion but respect a full hand and expire at prep end', () => {
const { h, m, ps } = prep(24);
ps.pushRewardOffer('merge');
const offer = ps.offers[0];
const id0 = offer.slots[0].id;
const taken = m.pool.take(id0, 100);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { error: ERR.SOLD_OUT });
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { ok: true });
assert.equal(m.pool.entries.get(id0).left, -1);
m.pool.give(id0, taken);
ps.pushRewardOffer('merge', { ids: [id0] });
const others = chessOfTier(3).filter((c) => m.pool.has(c) && !offer.slots.some((s) => s.id === c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { error: ERR.HAND_FULL });
+7 -6
View File
@@ -145,13 +145,14 @@ describe('#7 the Final Assault prep deploys on the boss field', () => {
h.setStage(STAGE);
h.toPrep(14);
const m = h.m;
assert.equal(m.deployFieldOf(h.ps('p_1')), 'bossR');
assert.equal(m.deployFieldOf(h.ps('p_2')), 'bossL', 'the odd player alone on the left half');
m.onLeave('p_0');
assert.equal(m.deployFieldOf(h.ps('p_1')), 'bossL');
assert.equal(m.deployFieldOf(h.ps('p_2')), 'bossR');
const [left, right] = m.bossPairs.find(g => g.length === 2), lone = m.bossPairs.find(g => g.length === 1)[0];
assert.equal(m.deployFieldOf(h.ps(right)), 'bossR');
assert.equal(m.deployFieldOf(h.ps(lone)), 'bossL', 'the odd player alone on the left half');
m.onLeave(left);
assert.equal(m.deployFieldOf(h.ps(right)), 'bossL');
assert.equal(m.deployFieldOf(h.ps(lone)), 'bossR');
// re-checked at once (Match._quit recomputes the players left), not at their next action
for (const [id, field] of [['p_1', 'bossL'], ['p_2', 'bossR']]) {
for (const [id, field] of [[right, 'bossL'], [lone, 'bossR']]) {
assert.equal(h.ps(id)._deployField, field, `${id} board re-checked on ${field}`);
assert.equal(h.ps(id)._legalityStale, false);
}
+20 -2
View File
@@ -123,10 +123,10 @@ test('effect-only items are never shop items: not in shopItemsByTier, never in t
m.dispose();
});
test('维多利亚 25-layer reward and 洛洛的定制品: the 4 special 维式重锤 (and nothing else)', () => {
test('维多利亚 25-layer reward: the 4 special 维式重锤 (and nothing else)', () => {
const special = ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a'];
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
for (const pid of ['pool_equip_vict', 'pool_equip_rockr']) {
for (const pid of ['pool_equip_vict']) {
assert.deepEqual([...DATA.choices.pools[pid].items].sort(), special, pid);
const seen = new Set();
for (let i = 0; i < 200; i++) seen.add(m.rollPool(pid).id);
@@ -135,6 +135,24 @@ test('维多利亚 25-layer reward and 洛洛的定制品: the 4 special 维式
m.dispose();
});
test('洛洛 and 焰尾 use the measured eight-item and 10/10/3 subsets; exhausted shop items are excluded', () => {
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
const rock = DATA.choices.pools.pool_equip_rockr.weighted;
assert.deepEqual(rock.map(([id, n]) => [m.gd.item(id).name, n]), [
['维式重锤', 10], ['战栗维式重锤', 2], ['坚固维式重锤', 2], ['加速维式重锤', 2], ['灼燃维式重锤', 2],
['有限加速器', 1], ['护盾无人机', 4], ['激光发射器', 4],
]);
assert.deepEqual(DATA.choices.pools.pool_char_pinus.weighted.map(([id, n]) => [m.gd.chess(id).name, n]), [['野鬃', 10], ['灰毫', 10], ['远牙', 3]]);
const seen = new Set();
for (let i = 0; i < 300; i++) seen.add(m.rollPool('pool_equip_rockr').id);
assert.deepEqual([...seen].sort(), rock.map(x => x[0]).sort());
const normal = rock[0][0], ps = m.order[0];
for (let i = 0; i < m.itemPool.cap(normal); i++) assert.ok(ps.acquireItem(normal));
assert.equal(m.itemPool.left(normal), 0);
for (let i = 0; i < 100; i++) assert.notEqual(m.rollPool('pool_equip_rockr').id, normal);
m.dispose();
});
test('per-match disabled bonds: 3 core + 4 add-on (NORMAL+), FUNNY static + 0 + 1; weight-0 never drawn; subset ban rule', () => {
for (const [modeId, core, addon] of [['mode_multi_hard', 3, 4], ['mode_single_abyss', 3, 4], ['mode_multi_funny', 0, 1], ['mode_single_normal', 3, 4]]) {
const gd = new GameData(DATA, modeId);
+38
View File
@@ -7,6 +7,33 @@ 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('four already displayed operator offers may exceed stock; selling first repays the deficit', () => {
const h = makeMatch({ mode: 'coop', humans: 4, seed: 11, fake: true }).start().toPrep();
const m = h.m, id = byName('溯光星源'), ps = [...m.players.values()];
assert.equal(m.pool.cap(id), 10);
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) ps[i].acquireChess(id);
assert.equal(m.pool.left(id), 1);
for (const p of ps) {
p.funds = 100;
p.shop.slots[0] = { kind: 'chess', id, basePrice: 3, frozen: true, sold: false };
}
assert.equal(m.pool.left(id), 1, 'displaying an offer does not reserve stock');
for (const p of ps) {
const funds = p.funds; assert.ok(p.buy(0).ok, 'every pre-existing offer stays purchasable');
assert.equal(p.funds, funds - 3); assert.equal(p.shop.slots[0].sold, true);
assert.equal(p.buy(0).error, 'SOLD_OUT', 'the same offer cannot be bought twice');
}
assert.equal(m.pool.entries.get(id).left, -3); assert.equal(m.pool.left(id), 0);
assert.equal(m.pool.roll(m.rngShop, { tier: 6, filter: candidate => candidate === id }), null);
for (let i = 0; i < 4; i++) {
const piece = ps[i].allChess().find(c => c.id === id); assert.ok(piece);
assert.ok(ps[i].sell(piece.uid).ok);
assert.equal(m.pool.left(id), Math.max(0, i - 2));
checkInvariants(m);
}
m.dispose();
});
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');
@@ -29,6 +56,17 @@ test('measured operator capacities and exceptions: co-op doubles shared stock, n
assert.equal(solo.itemPoolCopies(PACK), 2); assert.equal(coop.itemPoolCopies(PACK), 2);
});
test('an operator reward shown before stock exhaustion still arrives and records its copy', () => {
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'), id = byName('溯光星源');
a.pushRewardOffer('test', { ids: [id] });
for (let i = 0; i < m.pool.cap(id); i++) b.acquireChess(id);
assert.equal(m.pool.left(id), 0);
assert.ok(a.pickReward(0).ok); assert.equal(a.offers.length, 0);
assert.ok(a.allChess().some(c => c.id === id));
assert.equal(m.pool.entries.get(id).left, -1); checkInvariants(m); m.dispose();
});
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');
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
import { Battle } from '../../server/sim/Battle.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const MEDICS = new Set(['char_605_cmedic', 'char_613_acmedc']);
const medics = b => b.allyUnits.filter(u => MEDICS.has(u.defId) && u.alive && !u.removed);
const clean = () => ({ leaked: [], perfect: true, coins: 0, layerGains: {}, killed: 0, damageDealt: 0 });
function setup(t) {
const h = makeMatch({ mode: 'coop', humans: 3, seed: 334401, fake: true }).start().toPrep();
t.after(() => h.m.dispose()); h.setStage('act2autochess_m01');
for (const p of h.m.order) p.bandId = null;
h.ps('p_0').bandId = 'band_amedic'; return h;
}
function field(h, elites = 0) {
const opts = h.m._normalOpts(h.ps('p_1'));
opts.players[0].units = ['chess_char_1_01_b', 'chess_char_1_02_b'].slice(0, elites)
.map((chessId, i) => ({ uid: 100 + i, kind: 'chess', chessId, row: 10 + i, col: 4 }));
const b = new Battle({ ...opts, data: getDefaultSource(), spawns: [], timeLimit: 300, autoFinish: false });
b.start(); assert.equal(b.errorCount, 0); return b;
}
for (const reason of ['eliminated', 'quit']) for (const elites of [0, 2]) {
test(`Touch team grant survives holder ${reason}, receiver elites=${elites}`, t => {
const h = setup(t), before = field(h, elites), expected = elites ? 'char_613_acmedc' : 'char_605_cmedic';
assert.deepEqual(medics(before).map(u => u.defId), [expected]);
if (reason === 'quit') h.m.onLeave('p_0');
else {
h.ps('p_0').lp = 1;
h.m.lastResults = new Map(h.m.order.map(p => [p.playerId, clean()]));
h.m.lastResults.set('p_0', { ...clean(), perfect: false, leaked: [{ enemyKey: 'enemy_1422_lrsldr', counted: true }] });
h.m.settle(null, null); h.toPrep(2); h.setStage('act2autochess_m01');
}
assert.equal(h.ps('p_0').alive, false);
assert.equal(h.ps('p_1').privateView().prepMapChars.length, 1);
const after = field(h, elites);
assert.deepEqual(medics(after).map(u => u.defId), [expected]);
assert.equal(after.players[0].input.contentInfo.teammates.some(p => p.playerId === 'p_0'), false,
'retaining Touch does not revive dead teammate contexts');
for (let i = 0; i < 30; i++) before.step();
assert.deepEqual(medics(before).map(u => u.defId), [expected]);
h.ps('p_0').bandId = 'band_amiya';
assert.equal(field(h).players[0].input.contentInfo.matchBands.includes('band_amiya'), false,
'other eliminated strategies are not retained by this exception');
});
}
test('Touch requires a confirmed selection and does not leak to another match', t => {
const h = makeMatch({ mode: 'coop', humans: 3, seed: 334405, fake: true }).start();
t.after(() => h.m.dispose()); h.drive(() => h.m.phase === 'BAND_DRAFT', { ready: false });
const holder = h.m.players.get(h.m.draftTurn());
assert.equal(h.m.bandFocus(holder, 'band_amedic').ok, true);
assert.equal(h.m.teamMedicGranted(), false);
assert.equal(h.m.pickBand(holder, 'band_amedic').ok, true);
h.m.onLeave(holder.playerId); assert.equal(h.m.teamMedicGranted(), true);
const next = makeMatch({ mode: 'coop', humans: 3, seed: 334405, fake: true }).start();
t.after(() => next.m.dispose()); assert.equal(next.m.teamMedicGranted(), false);
});
+94 -1
View File
@@ -20,8 +20,10 @@ import { createBattleRunner } from '../../public/js/battle/runner.js';
import { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
import { DATA, makeMatch, checkInvariants, legalTileFor } from './harness.js';
import { FakeBattle } from './fakeBattle.js';
import { ownerBoard } from '../../public/js/ui/watchBonds.js';
import { bondMembers, diyGetter, diyRecordFor, memberHeadCount } from '../../public/js/ui/gameLogic.js';
const row = (h, pid) => h.m.publicView().players.find((p) => p.playerId === pid);
const entry = (list, id) => (list || []).find((b) => b.bondId === id) || null;
@@ -273,3 +275,94 @@ test('runner: a battle that disables gains (联防 / boss) publishes no live lay
assert.deepEqual(r.store.get().match.battle.bondLayers, {});
r.runner.dispose();
});
const DIY_SLOT = 'chess_char_5_diy1_a';
const HUANG_PICK = { charId: 'char_017_huang', skillIndex: 1, uniEquipId: 'uniequip_002_huang' };
const SIEGE_PICK = { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
const DIY_DATA = { chess: DATA.chess, backups: DATA.backups };
const getChess = (id) => DATA.chess[id] || null;
/** The same record lookups as BondPopup: own picks from m.private, a teammate's from each field unit. */
function diyRows(bondId, priv) {
const get = diyGetter(getChess, priv, DIY_DATA);
const record = (p) => (p.diy ? diyRecordFor(getChess(p.id), p.diy, DIY_DATA) : null) || get(p.id);
return bondMembers(DATA.bonds[bondId], priv, [], get, () => null, record);
}
/** Controlled match fixture: legal seat picks, acquire/merge and g.move; the match builds the unmodified b.start. */
function diyWatchMatch(elite) {
const seats = [SIEGE_PICK, HUANG_PICK].map((pick, seat) => ({
seat, playerId: `p_${seat}`, name: `P${seat}`, isBot: false, connected: true, diy: { [DIY_SLOT]: pick },
}));
const h = makeMatch({ mode: 'coop', seats, spectators: ['s_spec'], seed: 7309, clientCombat: true, clients: false }).start();
h.toPrep(1);
const ps = h.ps('p_1');
let piece;
for (let i = 0; i < (elite ? 3 : 1); i++) piece = ps.acquireChess(DIY_SLOT);
assert.ok(piece);
const tile = legalTileFor(h.m, ps, piece.id);
assert.ok(tile);
assert.deepEqual(h.m.handle('p_1', { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
return h;
}
for (const elite of [false, true]) test(`runner: watched ${elite ? 'elite' : 'normal'} DIY operator is a bond member before and after deployment`, async (t) => {
const h = diyWatchMatch(elite);
t.after(() => h.m.dispose());
const assertHuang = (priv) => {
for (const bondId of ['yanShip', 'victoriaShip']) {
const rows = diyRows(bondId, priv);
assert.equal(memberHeadCount(rows, false), 1, `${bondId}: the fielded DIY member counts once`);
const row = rows.find((x) => x.diy);
assert.deepEqual([row.id, row.rec.charId, row.name, row.onBoard, row.rec.isGolden], [DIY_SLOT, HUANG_PICK.charId, '煌', true, false]);
assert.deepEqual(row.pick, HUANG_PICK, 'the watched owner\'s skill and module, not our pick in the same slot');
}
};
assertHuang(ownerBoard(h.m.prepFieldMeta(h.ps('p_1')), 'p_1'));
assert.deepEqual(diyRows('victoriaShip', h.ps('p_0').privateView()).filter((x) => x.diy).map((x) => [x.rec.charId, x.owned]), [[SIEGE_PICK.charId, false]], 'our unbought pick stays our own');
assert.equal(diyRows('yanShip', h.ps('p_0').privateView()).some((x) => x.diy), false);
for (const pid of ['p_0', 'p_1']) assert.deepEqual(h.m.handle(pid, { t: 'g.ready', ready: true }), { ok: true });
h.runToPhase(PHASE.COMBAT, 1);
assert.deepEqual(h.m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { error: 'WRONG_PHASE', detail: 'own battle running' });
assert.deepEqual(h.m.handle('s_spec', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const start = JSON.parse(JSON.stringify(h.lastTo('s_spec', 'b.start')));
assert.deepEqual([start.watch, start.authoritative, start.elapsed], [true, false, 0]);
assert.equal(start.spec.players[0].units[0].chessId, elite ? DIY_SLOT.replace(/_a$/, '_b') : DIY_SLOT);
assert.deepEqual(start.spec.players[0].units[0].diy, HUANG_PICK);
const r = rig();
t.after(() => r.runner.dispose());
r.net.emit('b.start', start);
await r.settle();
const field = r.store.get().match.field;
assert.equal(field.units.some((u) => u.kind === 'op'), false, 'the initial meta precedes deployment');
const board = () => ownerBoard(field, 'p_1', r.runner.ownerOps('p_1', start.fieldId));
t.diagnostic(JSON.stringify({ elite, specPick: start.spec.players[0].units[0].diy, initialMetaOps: field.units.filter((u) => u.kind === 'op').length, runnerOps: r.runner.ownerOps('p_1'), yanMembers: memberHeadCount(diyRows('yanShip', board()), false) }));
assertHuang(board());
r.advance(20000, 50);
const unit = r.runner._entries.get(start.battleId).battle.allyUnits.find((u) => u.kind === 'op');
assert.ok(unit.deployed, 'the real simulation deployed the operator');
assertHuang(board());
const op = r.runner.ownerOps('p_1')[0];
assert.notEqual(op.diy, unit.def.loadout.diy, 'the popup receives a copy of the frozen sim pick');
op.diy.charId = SIEGE_PICK.charId;
assert.deepEqual(r.runner.ownerOps('p_1')[0].diy, HUANG_PICK, 'a consumer cannot change the battle or the next read');
assert.deepEqual(r.runner.ownerOps('p_0'), [], 'another owner');
assert.deepEqual(r.runner.ownerOps('p_1', 'n:p_0'), [], 'another field');
// A fresh runner after a spectator reconnect, and switching back from another field, retain the member too.
const starts = h.allTo('s_spec', 'b.start').length;
h.sched.advance(1000);
h.m.addSpectator('s_spec');
assert.equal(h.allTo('s_spec', 'b.start').length, starts + 1, 'the spectator reconnect emits a fresh b.start');
assert.ok(h.lastTo('s_spec', 'b.start').elapsed > 0, 'the resumed replica catches up to the match clock');
const resumed = rig();
t.after(() => resumed.runner.dispose());
resumed.net.emit('b.start', JSON.parse(JSON.stringify(h.lastTo('s_spec', 'b.start'))));
await resumed.settle();
assertHuang(ownerBoard(resumed.store.get().match.field, 'p_1', resumed.runner.ownerOps('p_1')));
r.net.emit('b.start', h.lastTo('p_0', 'b.start'));
await r.settle();
assert.deepEqual(r.runner.ownerOps('p_1'), [], 'switching fields drops the other field\'s members');
r.net.emit('b.start', start);
await r.settle();
assertHuang(ownerBoard(r.store.get().match.field, 'p_1', r.runner.ownerOps('p_1')));
});