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).
278 lines
17 KiB
JavaScript
278 lines
17 KiB
JavaScript
// Match follow-ups: multi-round bounties in the Final Assault / Hidden Core, 驰援 cards of fully banned bonds,
|
||
// 坚若磐石 = least LP lost, combat time limits in real seconds (× the forced 2× speed), boss-round layout model.
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { PHASE } from '../../shared/constants.js';
|
||
import { GameData, COMBAT_TIME_SCALE } from '../../server/match/gamedata.js';
|
||
import { generateDraft, reinforcementBond, cardTargetBonds, bountyBattles } from '../../server/match/choices.js';
|
||
import { assignTitles } from '../../server/match/results.js';
|
||
import { routeByMotion } from '../../server/match/waves.js';
|
||
import { fieldModel } from '../../server/match/bot.js';
|
||
import { FIELD, parseKey } from '../../server/match/board.js';
|
||
import { createRng } from '../../server/sim/rng.js';
|
||
import { FakeBattle } from './fakeBattle.js';
|
||
import { DATA, makeMatch, checkInvariants, giveItem } from './harness.js';
|
||
|
||
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
|
||
|
||
/** A multi-round ("之后的每场作战") kill bounty whose enemy is active in the mode. */
|
||
function everyBattleBounty(gd) {
|
||
const c = DATA.choices.cards.bounty.find((x) => x.rounds >= 90 && x.payout === 'kill' && DATA.enemies[x.enemyKey] && !gd.inactiveEnemies.has(x.enemyKey));
|
||
assert.ok(c, 'data has a multi-round kill bounty');
|
||
return c;
|
||
}
|
||
|
||
test('multi-round bounties spawn in the Final Assault on the owner\'s half, show in the boss preview, then use up a battle', () => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start();
|
||
const m = h.m;
|
||
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
|
||
const card = everyBattleBounty(m.gd);
|
||
const [p0, p1] = [h.ps('p_0'), h.ps('p_1')];
|
||
m.addBounty(p0, card);
|
||
m.addBounty(p1, card);
|
||
assert.ok(p0.privateView().nextEnemies.some((e) => e.enemyKey === card.enemyKey && e.tag === 'bounty'), 'boss-round preview lists the bounty');
|
||
assert.ok(p0.privateView().nextEnemies.some((e) => e.tag === 'boss'));
|
||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||
const f = bossFields()[0];
|
||
const bs = f.opts.spawns.filter((s) => s.tag === 'bounty');
|
||
assert.equal(bs.length, 2, 'one bounty spawn per player of the pair');
|
||
const fly = DATA.enemies[card.enemyKey].stats.motion === 'FLY';
|
||
for (const s of bs) {
|
||
const rt = f.opts.routes[s.routeIndex];
|
||
assert.ok(rt, 'a valid route');
|
||
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 pending = p0.pendingFunds;
|
||
const end = h.runToEnd();
|
||
assert.equal(end.victory, true);
|
||
// (a multi-round card lasts two battles since the user's playtest #6 answer — choices.js MULTI_ROUND_BOUNTY_BATTLES)
|
||
assert.equal(p0.bounties[0].roundsLeft, bountyBattles(card) - 1, 'the Final Assault used one of the bounty\'s battles');
|
||
assert.equal(p0.pendingFunds, pending + 3, 'kill-bounty coins of the boss field are credited (spent in the Hidden Core prep)');
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|
||
|
||
// A 战术特训 card pays its coins when the player's own battle is perfect (PRTS "若各自行动阶段就达成完美作战,获得N资金");
|
||
// the Final Assault and the Hidden Core are such battles — they pay it with the kill coins, at the next income.
|
||
test('perfect-payout bounties in the boss rounds: a 战术特训 card\'s coins join the kill coins at the next income (R14 → R15, then the Hidden Core)', () => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true,
|
||
script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start();
|
||
const m = h.m;
|
||
h.toPrep(14);
|
||
const ps = h.ps('p_0');
|
||
const [card, second] = DATA.choices.cards.bounty.filter((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey) && !m.gd.inactiveEnemies.has(c.enemyKey));
|
||
m.addBounty(ps, card);
|
||
const pending = ps.pendingFunds, gained = ps.stats.fundsGained;
|
||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||
m.hiddenLayerSum = 1e6; // the Hidden Core opens
|
||
assert.ok(bossFields()[0].opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === card.enemyKey && s.ownerPlayerId === ps.playerId), 'the card\'s enemies are in the boss battle');
|
||
h.run(() => m.runner === null);
|
||
assert.equal(ps.pendingFunds, pending + 3 + card.coin, 'kill coins + one card amount, regardless of its enemy count');
|
||
assert.equal(ps.stats.fundsGained, gained + 3 + card.coin);
|
||
const left = bountyBattles(card);
|
||
assert.equal(ps.bounties.length, left > 1 ? 1 : 0, 'the boss battle used one of its battles');
|
||
if (left > 1) assert.equal(ps.bounties[0].roundsLeft, left - 1);
|
||
h.toPrep(15);
|
||
assert.equal(ps.pendingFunds, 0);
|
||
assert.equal(ps.funds, m.gd.income(15) + pending + 3 + card.coin, 'the base income is separate from the bounty funds');
|
||
m.addBounty(ps, second);
|
||
const owed = ps.bounties.filter((b) => b.card.payout === 'perfect').reduce((n, b) => n + b.card.coin, 0);
|
||
h.drive(() => m.phase === PHASE.HIDDEN_CORE);
|
||
assert.ok(bossFields().at(-1).opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === second.enemyKey), 'and in the Hidden Core');
|
||
h.runToEnd();
|
||
assert.equal(ps.pendingFunds, 3 + owed, 'the Hidden Core pays by the same rule');
|
||
assert.equal(ps.bounties.length, 0, 'every bounty used its battles');
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|
||
|
||
test('教鞭 in the boss rounds (R14, R15): the card the owner chose spawns in the boss battle and pays at the next income, the Hidden Core takes another choice', () => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true,
|
||
script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start();
|
||
const m = h.m;
|
||
h.toPrep(14);
|
||
const ps = h.ps('p_0');
|
||
const art = giveItem(m, ps, 'chess_item_6_03_m');
|
||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||
assert.equal(ps.bounties.length, 0, 'nothing is applied before the owner confirms');
|
||
assert.equal(ps.setReady(true).error, 'BAD_TARGET', 'Ready waits for the choice');
|
||
const card = ps.personalChoice.cards[2];
|
||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.choice', idx: 2, choiceId: ps.personalChoice.id }), { ok: true });
|
||
assert.equal(ps.bounties[0].card.effectId, card.effectId, 'the card of the tapped slot');
|
||
const pending = ps.pendingFunds;
|
||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||
m.hiddenLayerSum = 1e6;
|
||
assert.ok(bossFields()[0].opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === card.enemyKey && s.ownerPlayerId === ps.playerId), 'the chosen card\'s enemies come in the boss battle');
|
||
h.run(() => m.runner === null);
|
||
assert.equal(ps.pendingFunds, pending + 3 + card.coin, 'kill coins + the chosen card\'s coins');
|
||
h.toPrep(15);
|
||
assert.equal(ps.funds, m.gd.income(15) + pending + 3 + card.coin, 'paid at the R15 income, apart from the base income');
|
||
const hiddenArt = giveItem(m, ps, 'chess_item_6_03_m');
|
||
assert.deepEqual(ps.useArt(hiddenArt.uid, 10, 5), { ok: true });
|
||
const hiddenCard = ps.personalChoice.cards[0];
|
||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.choice', idx: 0, choiceId: ps.personalChoice.id }), { ok: true });
|
||
const owed = ps.bounties.filter((b) => b.card.payout === 'perfect').reduce((n, b) => n + b.card.coin, 0);
|
||
h.drive(() => m.phase === PHASE.HIDDEN_CORE);
|
||
assert.ok(bossFields().at(-1).opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === hiddenCard.enemyKey));
|
||
h.runToEnd();
|
||
assert.equal(ps.pendingFunds, 3 + owed, 'the Hidden Core pays by the same rule');
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|
||
|
||
test('boss victory without a perfect own result keeps the kill coins but gives no perfect-card funds', () => {
|
||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 61, fake: true,
|
||
script: (b) => (b.kind === 'boss' ? { bossDps: 1e9, leaks: { p_0: 1 }, coins: { p_0: 3 } } : {}) }).start();
|
||
h.toPrep(14);
|
||
const m = h.m, ps = h.ps('p_0');
|
||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && !m.gd.inactiveEnemies.has(c.enemyKey));
|
||
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
|
||
const gained = ps.stats.fundsGained;
|
||
const result = h.drive(() => h.ended != null);
|
||
assert.ok(result && h.ended.victory);
|
||
assert.equal(ps.pendingFunds, 3);
|
||
assert.equal(ps.stats.fundsGained, gained + 3);
|
||
assert.equal(ps.bounties[0].roundsLeft, 1);
|
||
m.dispose();
|
||
});
|
||
|
||
test('solo Final Assault: the bounty spawns on the `_s` template; bountySpawns route choice by side', () => {
|
||
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 62, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start();
|
||
const m = h.m;
|
||
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
|
||
m.addBounty(h.ps('p_0'), everyBattleBounty(m.gd));
|
||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||
const f = bossFields()[0];
|
||
assert.ok(/_s$/.test(f.opts.waveId));
|
||
assert.equal(f.opts.spawns.filter((s) => s.tag === 'bounty' && s.ownerPlayerId === 'p_0').length, 1);
|
||
m.dispose();
|
||
const routes = [{ motion: 'WALK', end: [1, 17] }, { motion: 'WALK', end: [1, 3] }, { motion: 'FLY', end: [1, 17] }, { motion: 'FLY', end: [1, 3] }];
|
||
assert.equal(routeByMotion(routes, false), 0);
|
||
assert.equal(routeByMotion(routes, false, 'L'), 1);
|
||
assert.equal(routeByMotion(routes, false, 'R'), 0);
|
||
assert.equal(routeByMotion(routes, true, 'L'), 3);
|
||
assert.equal(routeByMotion(routes, true, 'R'), 2);
|
||
assert.equal(routeByMotion([{ motion: 'WALK', end: [9, 2] }], false, 'R'), 0, 'falls back to any route of the motion');
|
||
});
|
||
|
||
test('驰援 cards: a bond without chess in this match\'s pool is never offered', () => {
|
||
const gd = new GameData(DATA, 'mode_multi_funny');
|
||
assert.equal(reinforcementBond(gd, 'allybuff_select_7_1'), 'yanShip');
|
||
assert.equal(reinforcementBond(gd, 'allybuff_select_11'), null);
|
||
const offered = (bondAvailable) => {
|
||
const seen = new Set();
|
||
for (let s = 1; s <= 300; s++) {
|
||
const d = generateDraft(gd, createRng(s), 9, { bondAvailable });
|
||
if (d && d.family === 'tactic') for (const c of d.cards) seen.add(c.id);
|
||
}
|
||
return seen;
|
||
};
|
||
const all = offered(null);
|
||
assert.ok(all.has('allybuff_select_7_1'), 'sanity: 炎盟约驰援 is offered normally');
|
||
const filtered = offered((b) => b !== 'yanShip');
|
||
assert.ok(!filtered.has('allybuff_select_7_1'), '炎盟约驰援 filtered');
|
||
assert.ok(filtered.has('allybuff_select_7_3') || filtered.has('allybuff_select_7_2'), 'other 驰援 cards stay');
|
||
// Match wiring: bondInPool follows the pool (banned chess are never in it)
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, seed: 63, fake: true });
|
||
const m = h.m;
|
||
for (const id of m.gd.bondIds) {
|
||
const want = [...m.pool.entries.keys()].some((c) => (m.gd.chess(c).bonds || []).includes(id));
|
||
assert.equal(m.bondInPool(id), want, id);
|
||
}
|
||
for (const id of [...m.pool.entries.keys()]) if ((m.gd.chess(id).bonds || []).includes('yanShip')) m.pool.entries.delete(id);
|
||
assert.equal(m.bondInPool('yanShip'), false);
|
||
m.dispose();
|
||
});
|
||
|
||
test('标准 (FUNNY) 机变: tactic cards acting only on mode-inactive bonds (玛恩纳 / 莫斯提马的盟誓, 卡西米尔 / 拉特兰 / 阿戈尔驰援) are never offered', () => {
|
||
// the draft the finding saw at R3 of seed 18: 玛恩纳的盟誓 (卡西米尔 +12) and 卡西米尔驰援 in a mode without 卡西米尔
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 1, bots: 1, seed: 18, fake: true }).start();
|
||
const m = h.m;
|
||
h.autoHumans();
|
||
h.drive(() => m.phase === PHASE.SP_DRAFT);
|
||
const inactive = m.gd.modeInactiveBonds;
|
||
assert.ok(['kazimierzShip', 'lateranoShip', 'arcaneShip', 'egirShip'].every((b) => inactive.has(b)));
|
||
const dead = (c) => { const bonds = cardTargetBonds(m.gd, c.id); return !!bonds && bonds.every((b) => inactive.has(b) || !m.bondInPool(b)); };
|
||
assert.deepEqual(m.sp.cards.filter((c) => c.kind === 'tactic' && dead(c)).map((c) => c.name), []);
|
||
// every FUNNY tactic draft of the match's wiring (Match.bondLive)
|
||
const seen = new Set();
|
||
for (let s = 1; s <= 400; s++) {
|
||
const d = generateDraft(m.gd, createRng(s), 3, { stageId: m.stageId, bondAvailable: (b) => m.bondLive(b) });
|
||
if (d && d.family === 'tactic') for (const c of d.cards) seen.add(c.id);
|
||
}
|
||
for (const id of ['allybuff_select_2_7', 'allybuff_select_2_5', 'allybuff_select_7_5', 'allybuff_select_7_6', 'allybuff_select_7_8']) assert.ok(!seen.has(id), `${id} never offered in 标准`);
|
||
assert.ok(seen.has('allybuff_select_2_1'), '斯卡蒂的盟誓 (阿戈尔 off, but 深海猎人 / 坚守 on) stays');
|
||
assert.ok(seen.has('allybuff_select_7_1'), '炎盟约驰援 stays');
|
||
assert.deepEqual(cardTargetBonds(m.gd, 'allybuff_select_2_5'), ['lateranoShip', 'arcaneShip']);
|
||
assert.equal(cardTargetBonds(m.gd, 'allybuff_select_11'), null, 'cards that do anything else are always useful');
|
||
m.dispose();
|
||
// other modes keep them
|
||
const h2 = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, seed: 18, fake: true });
|
||
if (h2.m.bondInPool('kazimierzShip')) assert.equal(h2.m.bondLive('kazimierzShip'), true);
|
||
h2.m.dispose();
|
||
});
|
||
|
||
test('坚若磐石 (目标生命值损失最少): least LP lost among the players still alive (tuning override of the title rule)', () => {
|
||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||
const t3 = gd.titles.find((t) => t.id === 'comment_3');
|
||
assert.deepEqual([t3.stat, t3.rule, t3.name], ['lpLost', 'min', '坚若磐石']);
|
||
const fake = (seat, lpLost, alive = true, s = {}) => ({ playerId: `p${seat}`, seat, alive, lp: 10, lpAtFinal: 10, stats: { bossDamage: 0, merges: 0, itemsEquipped: 0, gold: 0, lpLost, ...s }, activatedLayers: () => 0 });
|
||
const players = [fake(0, 12, true, { gold: 30 }), fake(1, 3, true, { gold: 10 }), fake(2, 0, false, { gold: 5 }), fake(3, 7, true, { merges: 2 })];
|
||
const won = assignTitles(gd, players, true);
|
||
assert.equal(won.get('p1').id, 'comment_3', 'the least LP lost among the alive');
|
||
assert.notEqual(won.get('p2')?.id, 'comment_3', 'an eliminated player never is 坚若磐石');
|
||
// a perfect player (0 LP lost) still qualifies
|
||
const perfect = assignTitles(gd, [fake(0, 0), fake(1, 5, true, { gold: 9 })], false);
|
||
assert.equal(perfect.get('p0').id, 'comment_3');
|
||
// without the tuning override the config rule applies
|
||
const { tuning, ...raw } = DATA; // eslint-disable-line no-unused-vars
|
||
const plain = new GameData(raw, 'mode_multi_hard');
|
||
assert.equal(plain.titles.find((t) => t.id === 'comment_3').stat, 'lpRemaining');
|
||
});
|
||
|
||
test('combat time limits: data maxPlayTime is real seconds, the sim gets game seconds (× the forced 2× speed)', () => {
|
||
const gd = new GameData(DATA, 'mode_multi_normal');
|
||
assert.equal(COMBAT_TIME_SCALE, 2);
|
||
for (let r = 1; r <= 13; r++) {
|
||
assert.equal(gd.combatTimeLimitReal(r), DATA.config.modes.mode_multi_normal.rounds[r].combatTimeLimit);
|
||
assert.equal(gd.combatTimeLimit(r), gd.combatTimeLimitReal(r) * 2);
|
||
// every enemy of the round's template spawns inside the limit (the game-second reading fails R2 / R3)
|
||
const tpl = DATA.waves[gd.roundCfg(r).template];
|
||
const last = Math.max(...tpl.spawns.map((s) => (s.time || 0) + ((s.count || 1) - 1) * (s.interval || 0)));
|
||
assert.ok(last < gd.combatTimeLimit(r), `R${r}: last spawn ${last} s < limit ${gd.combatTimeLimit(r)} s`);
|
||
}
|
||
const custom = new GameData({ ...DATA, config: { ...DATA.config, combatTimeScale: 1 } }, 'mode_multi_normal');
|
||
assert.equal(custom.combatTimeLimit(3), 55);
|
||
// the combat deadline shown to players is maxPlayTime real seconds
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, seed: 64, fake: true, instant: false, script: () => ({ duration: 1e9 }) }).start();
|
||
const m = h.m;
|
||
h.drive(() => m.phase === PHASE.COMBAT);
|
||
assert.equal(m.wave.timeLimit, 90);
|
||
assert.ok(Math.abs(m.deadline - m.sched.now() - 45000) < 50, 'deadline = 45 real seconds at R1');
|
||
m.dispose();
|
||
});
|
||
|
||
test('boss-round layout model: the player\'s boss field on the own board, the leader weighted with a dwell, sides mirrored', () => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 65, fake: true }).start();
|
||
const m = h.m;
|
||
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
|
||
assert.equal(m.wave, null);
|
||
const left = fieldModel(m, h.ps('p_0'));
|
||
const right = fieldModel(m, h.ps('p_1'));
|
||
for (const model of [left, right]) {
|
||
assert.ok(model.routes.length > 0);
|
||
assert.ok(model.routes.some((r) => r.dwell > 0), 'the leader\'s route carries a dwell');
|
||
for (const rt of model.routes) for (const k of rt.tiles) {
|
||
const [r, c] = parseKey(k);
|
||
assert.ok(r >= FIELD.r0 && r <= FIELD.r1 && c >= 0 && c <= FIELD.c1, `tile ${k} on the own board`);
|
||
}
|
||
}
|
||
assert.notEqual(left.key, right.key, 'cached per side');
|
||
assert.equal(fieldModel(m, h.ps('p_1')), right);
|
||
m.dispose();
|
||
});
|