Seen: the Art 教鞭 (杜宾's strategy) drew three 战术特训 cards and added a random one — a documented simplification (docs/META.md §2.5: a personal choice would need its own phase, protocol message, timer and AI takeover). Official: act2autochess choice_event hunter_band_1 is a PERSONAL_CHOOSE; PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项". The owner decided on 2026-10-08 to follow it. Taken from PR #353 (code and tests by @Sukvii), ported to the split 0.2.2 modules. Now: ctx.offerBountyChoice -> Match.offerBountyChoice keeps up to three different mode-eligible cards (15 of 20 in 标准 / 险境, 20 in 绝境 / 终极; one meta-rng shuffle) in PlayerState.personalChoice, m.private only (same cards after a reconnect); g.choice { idx, choiceId } confirms it (Match.pickPersonalChoice: owner, id, round, index; a request without a choiceId is still the public draft, a stale id never falls through to it). No new phase or timer: Ready is refused while it is open, the prep's deadline picks at random before the temp pieces and Ready, a bot or a 托管 seat picks with the bounty scorer (botPickCard, the bots' seeded rng; at the prep's start, after the cast, before Ready; the scheduler's fallback picks at random if a step fails). The client reuses ChoiceOverlay (personal: no order, no turns, the prep's countdown) over a locked prep, the Ready reasons, the mock harness; six UI strings in en / ja / ko / zh-TW. An eliminated player's offer goes with it; invariants.js flags an offer outside its owner's own open prep. Not taken from the PR: its §25.24 in the released docs/history/0.2.0.md (the 0.2.2 section is the integrator's), its edits of the Ready toggle beyond the personal-choice case, its tail placement of the i18n keys. Golden: coop2-NORMAL-8-serverrun (ai_0 杜宾 casts 教鞭 from R2 on and now scores the three cards: teamLp 39 -> 34, virtualMs 121400 -> 118400, rng shop 511 -> 483 / bots 18063 -> 18070 / meta 112 -> 103) and coop2-ABYSS-10-boosted (ai_1 杜宾: virtualMs 121650 -> 124650, shop 590 -> 589 / bots 22075 -> 22096 / meta 141 -> 154); both first differ in round 2, every other scenario and family is unchanged; node tools/golden.mjs --twice is identical. Tests: items.test.js (three distinct cards, the tapped one applied once, nothing before the confirm, a refused cast keeps the Art / count / rng / uid, an eliminated player's offer), economy.test.js (Ready refused, the deadline picks before endPrep, solo prep untimed), connection.test.js (two players' offers, reconnect, foreign / duplicate / old ids, an offline player's pick at the deadline), bot.test.js (托管 takeover incl. the failing-step fallback), bot-smarter.test.js (the scorer's pick, deterministic per seed, no rng draw when nothing is pending), followups.test.js (R14 / R15), draft.test.js, unitStats.test.js (protocol), hardening.test.js (invariant), gameLogic / playtest3 / playtest4 (view model, Ready reason, ChoiceView, actions), mock.e2e.test.js and playtest2.real.e2e.test.js (browser, SP_E2E=1).
347 lines
18 KiB
JavaScript
347 lines
18 KiB
JavaScript
// Platform interface: start/handle/onDisconnect/onReconnect/onLeave/dispose, autoplay, bot takeover, views.
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { PHASE, ERR } from '../../shared/constants.js';
|
||
import { Match } from '../../server/match/Match.js';
|
||
import { StubMatch } from '../../server/match/StubMatch.js';
|
||
import { DATA, makeMatch, checkInvariants, give, giveItem, chessOfTier } from './harness.js';
|
||
import { bossPoolHp } from '../../server/match/finalAssault.js';
|
||
|
||
const STATUSES = ['acting', 'ready', 'deciding', 'combat', 'done', 'helping', 'left', 'dead'];
|
||
|
||
test('constructor validation mirrors the stub; start() sends the first m.public and one m.private per human', () => {
|
||
assert.throws(() => new Match({ seats: [] }), TypeError);
|
||
assert.throws(() => new Match({ seats: [{ seat: 0, playerId: 'a', name: 'a', isBot: false, connected: true }] }), TypeError);
|
||
assert.equal(typeof StubMatch, 'function', 'the platform stub is kept for platform tests');
|
||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 60 }).start();
|
||
const pub = h.lastBc('m.public');
|
||
for (const k of ['phase', 'round', 'lastRound', 'deadline', 'serverNow', 'modeId', 'difficulty', 'stageId', 'factions', 'disabledBonds', 'bannedChess', 'bossId', 'players', 'fields']) assert.ok(k in pub, `m.public.${k}`);
|
||
assert.equal(pub.phase, PHASE.INFO_CHECK);
|
||
assert.equal(pub.lastRound, 14);
|
||
assert.ok(DATA.stages[pub.stageId]);
|
||
assert.equal(pub.factions.length, 3);
|
||
assert.ok(DATA.bosses[pub.bossId]);
|
||
for (const p of pub.players) {
|
||
for (const k of ['playerId', 'seat', 'name', 'isBot', 'connected', 'alive', 'lp', 'bandId', 'shopLevel', 'boardCount', 'ready', 'bonds', 'fieldId', 'status']) assert.ok(k in p, `players[].${k}`);
|
||
assert.ok(STATUSES.includes(p.status));
|
||
}
|
||
const privs = h.sent.filter(([, m]) => m.t === 'm.private');
|
||
assert.deepEqual(privs.map(([id]) => id).sort(), ['p_0', 'p_1'], 'one m.private per human, none for bots');
|
||
for (const [id, priv] of privs) {
|
||
assert.equal(priv.playerId, id);
|
||
for (const k of ['seat', 'alive', 'lp', 'funds', 'bandId', 'ready', 'canReady', 'shop', 'hand', 'temp', 'board', 'deployCap', 'deployCount', 'bonds', 'effects', 'nextEnemies', 'stats']) assert.ok(k in priv, `m.private.${k}`);
|
||
for (const k of ['level', 'maxLevel', 'upgradePrice', 'refreshPrice', 'freeRefreshes', 'frozen', 'slots', 'rewardOffer']) assert.ok(k in priv.shop, `shop.${k}`);
|
||
assert.equal(priv.hand.length, 10);
|
||
assert.equal(priv.temp.length, 5);
|
||
for (const k of ['dmgDealt', 'kills', 'leaks', 'gold', 'refreshes', 'merges']) assert.ok(k in priv.stats);
|
||
}
|
||
h.m.dispose();
|
||
});
|
||
|
||
test('m.private shape in PREP matches DESIGN §8.3 (pieces, slots, bonds, effects, nextEnemies)', () => {
|
||
const h = makeMatch({ mode: 'solo', seed: 61 }).start();
|
||
h.toPrep(1);
|
||
h.flushAll();
|
||
const priv = h.lastTo('p_0', 'm.private');
|
||
assert.equal(priv.funds, 4);
|
||
assert.equal(priv.shop.slots.length, 4);
|
||
for (const s of priv.shop.slots) {
|
||
assert.deepEqual(Object.keys(s).sort(), ['basePrice', 'frozen', 'id', 'kind', 'price', 'sold']);
|
||
assert.equal(s.frozen, false);
|
||
assert.ok(s.kind === 'chess' || s.kind === 'item');
|
||
}
|
||
assert.equal(priv.shop.rewardOffer, null);
|
||
assert.equal(priv.personalChoice, null);
|
||
assert.equal(priv.deployCap, 8);
|
||
assert.ok(priv.effects.some((e) => e.iconKind === 'band' && e.name));
|
||
assert.ok(priv.nextEnemies.length > 0);
|
||
for (const e of priv.nextEnemies) { assert.ok(DATA.enemies[e.enemyKey]); assert.ok(e.count >= 1); assert.ok('tag' in e); }
|
||
const ps = h.ps('p_0');
|
||
h.m.handle('p_0', { t: 'g.buy', slot: 0 });
|
||
h.flushAll();
|
||
const p2 = h.lastTo('p_0', 'm.private');
|
||
const piece = p2.hand.find(Boolean);
|
||
assert.deepEqual(Object.keys(piece).sort(), ['count', 'golden', 'id', 'items', 'kind', 'ownerUid', 'tier', 'uid']);
|
||
assert.equal(p2.funds, ps.funds);
|
||
h.m.dispose();
|
||
});
|
||
|
||
test('m.public is throttled to ≤ 10/s; m.private is only resent when it changed', () => {
|
||
const h = makeMatch({ mode: 'solo', seed: 62 }).start();
|
||
h.toPrep(1);
|
||
h.sched.advance(500);
|
||
const n0 = h.bc.filter((m) => m.t === 'm.public').length;
|
||
const t0 = h.sched.now();
|
||
for (let i = 0; i < 50; i++) { h.m.handle('p_0', { t: 'g.freeze' }); h.sched.advance(5); }
|
||
h.sched.advance(200);
|
||
const n1 = h.bc.filter((m) => m.t === 'm.public').length;
|
||
const elapsed = (h.sched.now() - t0) / 1000;
|
||
assert.ok(n1 - n0 <= Math.ceil(elapsed * 10) + 1, `${n1 - n0} public frames in ${elapsed}s`);
|
||
const privs = h.allTo('p_0', 'm.private').length;
|
||
h.m.flush();
|
||
h.m.markPrivate(h.ps('p_0'));
|
||
h.m.flush();
|
||
assert.equal(h.allTo('p_0', 'm.private').length, privs, 'unchanged private view is not resent');
|
||
h.m.dispose();
|
||
});
|
||
|
||
test('disconnect: the seat keeps playing; draft turns / prep auto-resolve at deadlines; reconnect resends everything', () => {
|
||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 63, fake: true, script: () => ({ duration: 4 }) }).start();
|
||
const m = h.m;
|
||
h.m.onDisconnect('p_1');
|
||
assert.equal(m.publicView().players.find((p) => p.playerId === 'p_1').connected, false);
|
||
m.handle('p_0', { t: 'g.infoReady' });
|
||
// p_1 never confirms: the 25 s deadline moves on; p_1 never picks: 12 s turn → 华法琳
|
||
h.drive(() => m.phase === PHASE.PREP && m.round === 1);
|
||
assert.equal(h.ps('p_1').bandId, 'band_bldsk');
|
||
const sentBefore = h.sent.length;
|
||
// p_0 readies; p_1 is auto-readied at the prep deadline
|
||
m.handle('p_0', { t: 'g.ready', ready: true });
|
||
h.run(() => m.phase === PHASE.COMBAT);
|
||
assert.ok(h.ps('p_1').ready);
|
||
assert.ok(m.fields.some((f) => f.fieldId === 'n:p_1'), 'the disconnected seat still fights');
|
||
assert.ok(!h.sent.slice(sentBefore).some(([id]) => id === 'p_1') || true);
|
||
// reconnect mid-combat: m.public + m.private + m.field + b.snap
|
||
const mark = h.sent.length;
|
||
m.onReconnect('p_1');
|
||
const got = h.sent.slice(mark).filter(([id]) => id === 'p_1').map(([, x]) => x.t);
|
||
assert.ok(got.includes('m.public') && got.includes('m.private') && got.includes('m.field') && got.includes('b.snap'), got.join());
|
||
assert.equal(m.publicView().players.find((p) => p.playerId === 'p_1').connected, true);
|
||
m.dispose();
|
||
});
|
||
|
||
test('教鞭: simultaneous private choices survive reconnect; foreign, duplicate and old IDs cannot add bounties', () => {
|
||
const h = makeMatch({ mode: 'coop', humans: 2, spectators: ['watcher'], seed: 63, fake: true }).start();
|
||
h.toPrep(1);
|
||
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
|
||
for (const ps of [a, b]) {
|
||
const art = giveItem(m, ps, 'chess_item_6_03_m');
|
||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||
}
|
||
const aChoice = a.privateView().personalChoice, bChoice = b.privateView().personalChoice;
|
||
assert.notEqual(aChoice.id, bChoice.id);
|
||
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: bChoice.id }).error, ERR.BAD_TARGET);
|
||
assert.equal(a.bounties.length + b.bounties.length, 0);
|
||
m.onDisconnect('p_0');
|
||
m.onReconnect('p_0');
|
||
assert.deepEqual(h.lastTo('p_0', 'm.private').personalChoice, aChoice);
|
||
m.flush(true);
|
||
assert.deepEqual(h.lastTo('p_1', 'm.private').personalChoice, bChoice);
|
||
assert.ok(!h.allTo('watcher', 'm.private').length);
|
||
assert.ok(!JSON.stringify(m.publicView()).includes(aChoice.id));
|
||
assert.ok(!JSON.stringify(m.prepFieldMeta(a)).includes(aChoice.id));
|
||
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: aChoice.id }), { ok: true });
|
||
m.flush(true);
|
||
assert.equal(h.lastTo('p_0', 'm.private').personalChoice, null);
|
||
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: aChoice.id }).error, ERR.BAD_TARGET);
|
||
const next = giveItem(m, a, 'chess_item_6_03_m');
|
||
assert.deepEqual(a.useArt(next.uid, 10, 5), { ok: true });
|
||
const nextId = a.personalChoice.id;
|
||
assert.notEqual(nextId, aChoice.id);
|
||
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: aChoice.id }).error, ERR.BAD_TARGET);
|
||
assert.equal(a.personalChoice.id, nextId);
|
||
assert.equal(a.bounties.length, 1);
|
||
m.onDisconnect('p_1');
|
||
h.sched.advance(m.deadline - h.sched.now() + 1);
|
||
m.onReconnect('p_1');
|
||
assert.equal(h.lastTo('p_1', 'm.private').personalChoice, null);
|
||
assert.equal(b.bounties.length, 1, 'offline choice picked at the existing PREP deadline');
|
||
m.dispose();
|
||
});
|
||
|
||
test('g.autoplay lets the bot play a human seat (buys, places, readies); turning it off returns control', () => {
|
||
const h = makeMatch({ mode: 'solo', seed: 64, fake: true }).start();
|
||
const m = h.m;
|
||
h.toPrep(1);
|
||
assert.deepEqual(m.handle('p_0', { t: 'g.autoplay', on: true }), { ok: true });
|
||
assert.equal(m.publicView().players[0].autoplay, true);
|
||
h.run(() => m.phase !== PHASE.PREP);
|
||
const ps = h.ps('p_0');
|
||
assert.ok(ps.board.size > 0, 'the bot deployed units');
|
||
assert.notEqual(m.phase, PHASE.PREP, 'the bot readied');
|
||
h.run(() => m.phase === PHASE.PREP && m.round === 2);
|
||
assert.deepEqual(m.handle('p_0', { t: 'g.autoplay', on: false }), { ok: true });
|
||
h.sched.advance(60000);
|
||
assert.equal(m.phase, PHASE.PREP, 'solo prep waits for the human again');
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|
||
|
||
test('onLeave: quitting counts as elimination (copies back to the pool at once, status left); last human leaving ends the match once', () => {
|
||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 65, fake: true }).start();
|
||
const m = h.m;
|
||
h.toPrep(1);
|
||
const q = h.ps('p_1');
|
||
const id = chessOfTier(1).find((x) => m.pool.has(x) && m.pool.left(x) >= 2);
|
||
give(m, q, id);
|
||
give(m, q, id);
|
||
const base = m.gd.baseIdOf(id);
|
||
const left0 = m.pool.left(base);
|
||
m.onLeave('p_1');
|
||
assert.equal(q.alive, false, 'the departed seat is out of the match');
|
||
assert.equal(m.pool.left(base), left0 + 2, 'its copies went back to the shared pool at once');
|
||
assert.ok(q.board.size === 0 && q.hand.every((x) => x == null), 'it holds nothing');
|
||
assert.equal(q.eliminatedRound, 1);
|
||
assert.equal(m.publicView().players.find((p) => p.playerId === 'p_1').status, 'left');
|
||
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: true }), { error: ERR.NOT_IN_ROOM });
|
||
checkInvariants(m);
|
||
m.handle('p_0', { t: 'g.ready', ready: true });
|
||
h.run(() => m.phase === PHASE.COMBAT);
|
||
assert.deepEqual(m.fields.map((f) => f.fieldId), ['n:p_0'], 'no battle for the departed seat');
|
||
h.run(() => m.phase === PHASE.PREP && m.round === 2);
|
||
assert.equal(m.round, 2);
|
||
m.onLeave('p_0');
|
||
assert.equal(h.endedCount, 1);
|
||
assert.equal(h.ended.reason, 'abandoned');
|
||
assert.equal(h.ended.players.find((p) => p.playerId === 'p_1').roundsPassed, 0);
|
||
m.onLeave('p_0');
|
||
m.onReconnect('p_0');
|
||
m.onDisconnect('p_0');
|
||
assert.equal(h.endedCount, 1);
|
||
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }).error, ERR.NOT_IN_ROOM);
|
||
m.dispose();
|
||
m.dispose();
|
||
assert.equal(h.sched.pending(), 0, 'dispose cancels every timer');
|
||
});
|
||
|
||
test('onLeave: a quitter has no place in the Final Assault pairing or the boss pool; its draft turns pass on', () => {
|
||
// band draft: the player on turn leaves → its pick is the default band and the turn moves on
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 5, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start();
|
||
const m = h.m;
|
||
for (const id of ['p_0', 'p_1', 'p_2']) m.handle(id, { t: 'g.infoReady' });
|
||
h.sched.advance(1);
|
||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||
const first = m.draftTurn();
|
||
m.onLeave(first);
|
||
assert.equal(m.draft.picks[first], m.gd.bandDraft.timeoutBandId);
|
||
assert.notEqual(m.draftTurn(), first);
|
||
h.autoHumans();
|
||
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
|
||
const alive = m.alivePlayers().map((p) => p.playerId);
|
||
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.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();
|
||
assert.equal(end.victory, true);
|
||
assert.equal(end.players.find((p) => p.playerId === first).roundsPassed, 0);
|
||
m.dispose();
|
||
|
||
// 机变: the picker on turn leaves → the next alive player picks; the last alive player leaving ends the match
|
||
const h2 = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 18, fake: true }).start();
|
||
const m2 = h2.m;
|
||
h2.autoHumans();
|
||
h2.drive(() => m2.phase === PHASE.SP_DRAFT);
|
||
const turn = m2.spTurn();
|
||
m2.onLeave(turn);
|
||
assert.equal(m2.phase, PHASE.SP_DRAFT);
|
||
assert.notEqual(m2.spTurn(), turn, 'the turn passed on');
|
||
checkInvariants(m2);
|
||
m2.dispose();
|
||
|
||
// mid-combat: the quitter's own battle is force-ended, the others play on
|
||
const h4 = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 20, fake: true, instant: false, script: (b) => (b.kind === 'normal' ? { duration: 5 } : {}) }).start();
|
||
const m4 = h4.m;
|
||
h4.toPrep(2);
|
||
h4.drive(() => m4.phase === PHASE.COMBAT);
|
||
h4.sched.advance(500);
|
||
m4.onLeave('p_2');
|
||
const f2 = m4.fields.find((f) => f.fieldId === 'n:p_2');
|
||
assert.ok(f2.battle.finished, 'its battle was force-ended');
|
||
assert.ok(m4.fields.filter((f) => f.fieldId !== 'n:p_2').every((f) => !f.battle.finished), 'the others still fight');
|
||
checkInvariants(m4);
|
||
h4.drive(() => m4.phase === PHASE.PREP && m4.round === 3);
|
||
assert.deepEqual(m4.alivePlayers().map((p) => p.playerId), ['p_0', 'p_1']);
|
||
assert.equal(h4.ps('p_2').eliminatedRound, 2);
|
||
checkInvariants(m4);
|
||
m4.dispose();
|
||
|
||
// only an eliminated spectator left → the match ends as a defeat ('eliminated'), not 'abandoned'
|
||
const h3 = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 19, fake: true }).start();
|
||
h3.toPrep(2);
|
||
h3.ps('p_1').eliminate(2);
|
||
h3.m.onLeave('p_0');
|
||
assert.equal(h3.endedCount, 1);
|
||
assert.equal(h3.ended.reason, 'eliminated');
|
||
assert.ok(h3.lastTo('p_1', 'm.result'), 'the spectator gets the result');
|
||
h3.m.dispose();
|
||
});
|
||
|
||
test('dispose stops timers and battles; handle/hooks after dispose are inert', () => {
|
||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 66, instant: false }).start();
|
||
h.m.handle('p_0', { t: 'g.infoReady' });
|
||
h.sched.advance(10);
|
||
h.m.dispose();
|
||
assert.equal(h.sched.pending(), 0);
|
||
const n = h.sent.length + h.bc.length;
|
||
h.sched.advance(100000);
|
||
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { error: ERR.WRONG_PHASE });
|
||
h.m.onReconnect('p_0');
|
||
h.m.onDisconnect('p_0');
|
||
assert.equal(h.sent.length + h.bc.length, n, 'nothing is sent after dispose');
|
||
assert.equal(h.endedCount, 0);
|
||
});
|
||
|
||
test('emotes: relayed as m.emote with a 1 s cooldown; g.watch during prep scouts a teammate board', () => {
|
||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 67, fake: true }).start();
|
||
const m = h.m;
|
||
assert.deepEqual(m.handle('p_0', { t: 'g.emote', id: 'autochess_battle_happy' }), { ok: true });
|
||
assert.deepEqual(h.lastBc('m.emote'), { t: 'm.emote', playerId: 'p_0', id: 'autochess_battle_happy' });
|
||
assert.equal(m.handle('p_0', { t: 'g.emote', id: 'slug_autochess_battle_thanks' }).error, ERR.RATE);
|
||
h.sched.advance(1000);
|
||
// only the official emote ids are relayed (defence in depth: handle() may be called without validateC2S)
|
||
assert.equal(m.handle('p_0', { t: 'g.emote', id: 'autochess_room_hello' }).error, ERR.BAD_MSG); // room-scene emote
|
||
assert.equal(m.handle('p_0', { t: 'g.emote', id: 'happy' }).error, ERR.BAD_MSG); // v1 short id
|
||
assert.deepEqual(m.handle('p_0', { t: 'g.emote', id: 'slug_autochess_battle_thanks' }), { ok: true });
|
||
h.toPrep(1);
|
||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
|
||
const f = h.lastTo('p_0', 'm.field');
|
||
assert.equal(f.fieldId, 'n:p_1');
|
||
assert.equal(f.prep, true);
|
||
assert.ok(Array.isArray(f.units));
|
||
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'n:nobody' }).error, ERR.BAD_TARGET);
|
||
m.dispose();
|
||
});
|
||
|
||
test('unknown / wrong-phase intents are rejected without throwing', () => {
|
||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 68 }).start();
|
||
const m = h.m;
|
||
assert.equal(m.handle('nobody', { t: 'g.ready', ready: true }).error, ERR.NOT_IN_ROOM);
|
||
assert.equal(m.handle('ai_0', { t: 'g.ready', ready: true }).error, ERR.NOT_IN_ROOM);
|
||
assert.equal(m.handle('p_0', { t: 'room.start' }).error, ERR.BAD_MSG);
|
||
assert.equal(m.handle('p_0', null).error, ERR.BAD_MSG);
|
||
assert.equal(m.handle('p_0', { t: 'g.buy', slot: 0 }).error, ERR.WRONG_PHASE);
|
||
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0 }).error, ERR.WRONG_PHASE);
|
||
assert.equal(m.handle('p_0', { t: 'g.band', bandId: 'band_bldsk' }).error, ERR.WRONG_PHASE);
|
||
assert.equal(m.errorCount, 0);
|
||
m.dispose();
|
||
});
|
||
|
||
test('degraded data: a match without a chess pool ends immediately with an error summary (no throw)', () => {
|
||
const out = { ended: null, bc: [] };
|
||
const m = new Match({
|
||
roomCode: 'X', mode: 'coop', difficulty: 'HARD', modeId: 'mode_multi_hard', seed: 1,
|
||
seats: [{ seat: 0, playerId: 'p', name: 'P', isBot: false, connected: true }],
|
||
data: { config: {} }, log: { info() {}, warn() {}, error() {} },
|
||
send: () => true, broadcast: (x) => out.bc.push(x), onEnd: (s) => { out.ended = s; },
|
||
});
|
||
m.start();
|
||
assert.ok(out.ended);
|
||
assert.equal(out.ended.reason, 'error');
|
||
m.dispose();
|
||
});
|
||
|
||
test('m.public follows player-state changes (shop level, board count, bonds) within the throttle window', () => {
|
||
const h = makeMatch({ mode: 'solo', seed: 69 }).start();
|
||
h.toPrep(1);
|
||
const m = h.m;
|
||
const ps = h.ps('p_0');
|
||
ps.funds = 50;
|
||
m.handle('p_0', { t: 'g.levelUp' });
|
||
h.sched.advance(150);
|
||
assert.equal(h.lastBc('m.public').players[0].shopLevel, 2);
|
||
m.dispose();
|
||
});
|