Offer Semuta on printed native battle cancellation costs
This commit is contained in:
1 parent
74768647f4
commit
bab8f3403d
14 files changed
+818
-14
No files matched your search
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { isAuditorLeader } from '../game/cards';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
|
||||
export type SemutaBattleKaramaKind = 'capture' | 'choamAudit' | 'choamBattleIncome';
|
||||
export const reloadBattleKarama = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
export const battleKaramaPlayer = (game: Game, id: string) => game.players.find(p => p.id === id)!;
|
||||
|
||||
function take(game: Game, id: string) {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(p => p.hand)]) {
|
||||
const index = zone.findIndex(card => card.id === id || card.effect === id);
|
||||
if (index >= 0) return zone.splice(index, 1)[0];
|
||||
}
|
||||
throw new Error(`Missing physical ${id}`);
|
||||
}
|
||||
|
||||
export function battleKaramaInventory(game: Game) {
|
||||
const ids = [...game.deck, ...game.discard, ...game.richeseCache!, ...game.players.flatMap(p => p.hand)]
|
||||
.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function passBattleKaramaOffer(game: Game): Game {
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
|
||||
export function battleKaramaCostAction(card: string): Action {
|
||||
return { type: 'card', mode: 'cancel', card };
|
||||
}
|
||||
|
||||
export function settleBattleKaramaCost(game: Game, claim: boolean): Game {
|
||||
return claim
|
||||
? applyAction(game, 'r', { type: 'semutaCommit', event: game.pendingTreacheryDiscard!.batch.event })
|
||||
: passBattleKaramaOffer(game);
|
||||
}
|
||||
|
||||
// Complete any remaining original income window and native CHOAM market.
|
||||
export function finishBattleKarama(game: Game): Game {
|
||||
for (let step = 0; step < 12 && game.response?.kind === 'choamBattleIncome'; step++) {
|
||||
const seat = game.players.find(p => !game.response!.passed.includes(p.id))!;
|
||||
game = applyAction(game, seat.id, { type: 'passResponse' });
|
||||
}
|
||||
if (game.decision?.kind === 'choamMarket')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', done: true });
|
||||
assert.equal(game.phase, 7);
|
||||
return game;
|
||||
}
|
||||
|
||||
export function semutaBattleKaramaFixture(kind: SemutaBattleKaramaKind) {
|
||||
const capture = kind === 'capture';
|
||||
let game = createGame('SEMUTABK', newPlayer('r', 'Richese', 'richese'), true,
|
||||
capture ? ['choam'] : ['choam', 'ix']);
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('e', capture ? 'Harkonnen' : 'CHOAM', capture ? 'harkonnen' : 'choam'));
|
||||
for (const seat of game.players) { seat.bot = 'Medium'; seat.ready = true; }
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
for (let step = 0; game.status === 'setup' && step < 80; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const seat of game.players) {
|
||||
const action = botActions(viewGame(game, seat.id))[0];
|
||||
if (action) { next = applyAction(game, seat.id, action); break; }
|
||||
}
|
||||
assert.ok(next, 'native setup must provide a legal choice');
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
|
||||
// Only later physical staging changes: every printed card and force remains conserved.
|
||||
game.deck.push(...game.players.flatMap(p => p.hand));
|
||||
for (const seat of game.players) {
|
||||
Object.assign(seat, { hand: [], spice: 10, forces: {}, reserves: 20, tanks: 0 });
|
||||
}
|
||||
const semuta = take(game, SEMUTA_DRUG_ID), karama = take(game, 'karama'), privateCard = take(game, 'hajr');
|
||||
const weapon = take(game, game.deck.find(card => card.kind === 'projectile')!.id);
|
||||
const defense = take(game, game.deck.find(card => card.kind === 'snooper')!.id);
|
||||
const winner = capture ? 'e' : 'a', loser = capture ? 'a' : 'e';
|
||||
battleKaramaPlayer(game, 'r').hand.push(semuta);
|
||||
battleKaramaPlayer(game, 'a').hand.push(karama, privateCard);
|
||||
battleKaramaPlayer(game, winner).hand.push(weapon);
|
||||
battleKaramaPlayer(game, loser).hand.push(defense);
|
||||
for (const id of ['a', 'e'])
|
||||
Object.assign(battleKaramaPlayer(game, id), { forces: { 'arrakeen:10': 5 }, reserves: 15 });
|
||||
Object.assign(game, { turn: 2, phase: 6, active: winner, order: [winner, loser, 'r'],
|
||||
storm: 18, response: null, decision: null, phaseOpening: null, stormPending: null,
|
||||
movementRemaining: [], ready: [] });
|
||||
const staged = reloadBattleKarama(game), inventory = battleKaramaInventory(game);
|
||||
const winnerLeader = battleKaramaPlayer(game, winner).leaders[0];
|
||||
const loserLeader = kind === 'choamAudit'
|
||||
? battleKaramaPlayer(game, loser).leaders.find(isAuditorLeader)!
|
||||
: battleKaramaPlayer(game, loser).leaders[0];
|
||||
assert.equal(winnerLeader.strength, capture ? 6 : 5);
|
||||
assert.equal(loserLeader.strength, capture ? 5 : kind === 'choamAudit' ? 2 : 4);
|
||||
|
||||
game = applyAction(game, winner, { type: 'chooseBattle', territory: 'arrakeen', target: loser });
|
||||
for (let step = 0; step < 40; step++) {
|
||||
if (game.response) {
|
||||
const seat = game.players.find(p => !game.response!.passed.includes(p.id))!;
|
||||
game = applyAction(game, seat.id, { type: 'passResponse' });
|
||||
} else if (game.battle?.preparation)
|
||||
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
|
||||
else if (game.decision?.kind === 'fullPlanOffer')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
||||
else break;
|
||||
}
|
||||
for (const id of [winner, loser])
|
||||
game = applyAction(game, id, { type: 'battlePreparationReady', event: game.battle!.preLeader!.event });
|
||||
game = applyAction(game, winner, { type: 'battlePlan', dial: 2, support: 2,
|
||||
leader: winnerLeader.id, weapon: weapon.id });
|
||||
game = applyAction(game, loser, { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: loserLeader.id, defense: defense.id });
|
||||
for (const id of [winner, loser]) game = applyAction(game, id, { type: 'traitorCall', call: false });
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
const earlierEvents = [game.pendingTreacheryDiscard!.batch.event];
|
||||
game = passBattleKaramaOffer(game);
|
||||
assert.equal(game.decision?.kind, 'battleCards');
|
||||
game = applyAction(game, winner, { type: 'decision', discard: [weapon.id] });
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
earlierEvents.push(game.pendingTreacheryDiscard!.batch.event);
|
||||
game = passBattleKaramaOffer(game);
|
||||
assert.equal(battleKaramaPlayer(game, 'r').hand[0].id, semuta.id);
|
||||
if (kind === 'choamAudit') {
|
||||
// Original force income precedes Auditor; allow it before opening the audit.
|
||||
for (let step = 0; game.response?.kind === 'choamBattleIncome' && step < 12; step++) {
|
||||
const seat = game.players.find(p => !game.response!.passed.includes(p.id))!;
|
||||
game = applyAction(game, seat.id, { type: 'passResponse' });
|
||||
}
|
||||
}
|
||||
const nativeOffer = reloadBattleKarama(game);
|
||||
if (capture) {
|
||||
assert.equal(game.decision?.kind, 'captureOffer');
|
||||
game = applyAction(game, 'e', { type: 'decision', accept: true });
|
||||
} else if (kind === 'choamAudit') {
|
||||
assert.equal(game.decision?.kind, 'choamAudit');
|
||||
game = applyAction(game, 'e', { type: 'decision', event: game.pendingAuditor!.event, audit: true });
|
||||
}
|
||||
assert.equal(game.response?.kind, kind);
|
||||
return { kind, staged, nativeOffer, originalResponse: reloadBattleKarama(game), game,
|
||||
spender: 'a', holder: 'r', winner, loser, karama: karama.id, semuta: semuta.id,
|
||||
privateCard: privateCard.id, weapon: weapon.id, defense: defense.id,
|
||||
winnerLeader: winnerLeader.id, loserLeader: loserLeader.id, bounty: loserLeader.strength,
|
||||
earlierEvents, inventory, cancel: battleKaramaCostAction(karama.id) };
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
semutaIxBattleFixture, semutaIxPlanSteps, semutaIxSubstitutionStep,
|
||||
semutaIxPlayer, type SemutaIxFixture,
|
||||
} from './fixture-semuta-ix-substitution';
|
||||
import {
|
||||
semutaMoritaniRetentionFixture, resolveSemutaMoritaniBattle,
|
||||
finishSemutaMoritaniWinner, chooseSemutaMoritaniRetention,
|
||||
type SemutaMoritaniFixture,
|
||||
} from './fixture-semuta-moritani-retention';
|
||||
|
||||
export type SemutaNativeKaramaStep = { actor: string; action: Action };
|
||||
export type SemutaNativeKaramaFixture = {
|
||||
game: Game; kind: 'ixSubstitution' | 'moritaniRetention';
|
||||
holder: string; spender: string; owner: string;
|
||||
karama: string; semuta: string; cards: string[];
|
||||
ix?: SemutaIxFixture; moritani?: SemutaMoritaniFixture;
|
||||
};
|
||||
export const semutaNativeKaramaReload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
export function semutaNativeKaramaAct(game: Game, step: SemutaNativeKaramaStep, actions?: SemutaNativeKaramaStep[]) {
|
||||
actions?.push(structuredClone(step));
|
||||
return applyAction(game, step.actor, step.action);
|
||||
}
|
||||
|
||||
/** Original lobby and conserved later battle; only the existing printed Karama changes hands. */
|
||||
export function semutaNativeIxKaramaFixture(advanced = false): SemutaNativeKaramaFixture {
|
||||
const ix = semutaIxBattleFixture(advanced);
|
||||
const richese = semutaIxPlayer(ix.game, ix.holder);
|
||||
const index = richese.hand.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(index >= 0);
|
||||
const [karama] = richese.hand.splice(index, 1);
|
||||
semutaIxPlayer(ix.game, ix.loser).hand.push(karama);
|
||||
return { game: ix.game, kind: 'ixSubstitution', holder: ix.holder,
|
||||
spender: ix.loser, owner: ix.winner, karama: karama.id, semuta: ix.semuta, cards: ix.cards, ix };
|
||||
}
|
||||
export function semutaNativeMoritaniKaramaFixture(
|
||||
options: { karamaHolder?: 'w' | 'l'; semutaHolder?: 'r' | 'l' } = {},
|
||||
): SemutaNativeKaramaFixture {
|
||||
const spender = options.karamaHolder ?? 'w', holder = options.semutaHolder ?? 'r';
|
||||
const moritani = semutaMoritaniRetentionFixture({ karamaHolder: spender, semutaHolder: holder });
|
||||
assert.ok(moritani.karama);
|
||||
return { game: moritani.game, kind: 'moritaniRetention', holder, spender,
|
||||
owner: 'm', karama: moritani.karama, semuta: moritani.semuta, cards: moritani.cards, moritani };
|
||||
}
|
||||
|
||||
/** Export exact legal reaction steps, including neutral later offers after Semuta was spent. */
|
||||
export function semutaNativeKaramaReactionSteps(game: Game, f: SemutaNativeKaramaFixture, claim = false): SemutaNativeKaramaStep[] {
|
||||
const pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending);
|
||||
const reaction = pending.reaction;
|
||||
if (reaction?.stage !== 'offer') throw Error('Expected the neutral fresh discard offer.');
|
||||
const event = pending.batch.event;
|
||||
if (claim) {
|
||||
assert.equal(pending.batch.entries.length, 1, 'This API commits the singleton printed cost.');
|
||||
return [{ actor: f.holder, action: { type: 'semutaCommit', event } }];
|
||||
}
|
||||
return game.players.filter(p => !reaction.passed.includes(p.id))
|
||||
.map(p => ({ actor: p.id, action: { type: 'semutaPass' as const, event } }));
|
||||
}
|
||||
export function semutaNativeKaramaReact(game: Game, f: SemutaNativeKaramaFixture, claim = false, actions?: SemutaNativeKaramaStep[]) {
|
||||
for (const step of semutaNativeKaramaReactionSteps(game, f, claim))
|
||||
game = semutaNativeKaramaAct(game, step, actions);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Native pre-cost actions: pass Snooper, declare the actual 1/1 exchange; or choose a played losing card. */
|
||||
export function semutaNativeKaramaPreCost(f: SemutaNativeKaramaFixture, actions?: SemutaNativeKaramaStep[]): Game {
|
||||
if (f.kind === 'moritaniRetention') {
|
||||
assert.ok(f.moritani);
|
||||
let game = resolveSemutaMoritaniBattle(f.moritani, actions);
|
||||
game = finishSemutaMoritaniWinner(game, [], actions);
|
||||
return chooseSemutaMoritaniRetention(game, f.moritani.weapon, actions);
|
||||
}
|
||||
assert.ok(f.ix);
|
||||
const ix = f.ix;
|
||||
let game = semutaNativeKaramaAct(f.game,
|
||||
{ actor: ix.winner, action: { type: 'chooseBattle', territory: ix.territory, target: ix.loser } }, actions);
|
||||
for (let count = 0; count < 40; count++) {
|
||||
let step: SemutaNativeKaramaStep;
|
||||
if (game.response) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
step = { actor: actor.id, action: { type: 'passResponse' } };
|
||||
} else if (game.battle?.preparation)
|
||||
step = { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } };
|
||||
else if (game.decision?.kind === 'fullPlanOffer')
|
||||
step = { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
else break;
|
||||
game = semutaNativeKaramaAct(game, step, actions);
|
||||
}
|
||||
assert.ok(game.battle?.preLeader);
|
||||
for (const actor of [ix.winner, ix.loser])
|
||||
game = semutaNativeKaramaAct(game,
|
||||
{ actor, action: { type: 'battlePreparationReady', event: game.battle!.preLeader!.event } }, actions);
|
||||
for (const step of semutaIxPlanSteps(game, ix)) game = semutaNativeKaramaAct(game, step, actions);
|
||||
assert.equal(game.decision?.kind, 'poisonTooth');
|
||||
game = semutaNativeKaramaAct(game, { actor: ix.winner, action: { type: 'decision', activate: true } }, actions);
|
||||
for (const actor of [ix.winner, ix.loser])
|
||||
game = semutaNativeKaramaAct(game, { actor, action: { type: 'traitorCall', call: false } }, actions);
|
||||
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(entry => entry.card.id), [ix.snooper]);
|
||||
game = semutaNativeKaramaReact(game, f, false, actions);
|
||||
assert.equal(game.decision?.kind, 'ixSubstitution');
|
||||
return semutaNativeKaramaAct(game, semutaIxSubstitutionStep(ix, true), actions);
|
||||
}
|
||||
export function semutaNativeKaramaCostStep(f: SemutaNativeKaramaFixture): SemutaNativeKaramaStep {
|
||||
return { actor: f.spender, action: { type: 'card', card: f.karama, mode: 'cancel' } };
|
||||
}
|
||||
export function semutaNativeKaramaCost(game: Game, f: SemutaNativeKaramaFixture, actions?: SemutaNativeKaramaStep[]): Game {
|
||||
assert.equal(game.response?.kind, f.kind);
|
||||
assert.equal(game.response?.owner, f.owner);
|
||||
return semutaNativeKaramaAct(game, semutaNativeKaramaCostStep(f), actions);
|
||||
}
|
||||
|
||||
/** Pass later fresh native batches; Ix still discards its used Tooth before optional Shield cleanup. */
|
||||
export function semutaNativeKaramaRemainder(game: Game, f: SemutaNativeKaramaFixture, actions?: SemutaNativeKaramaStep[]): Game {
|
||||
for (let count = 0; game.phase === 6 && count < 16; count++) {
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
assert.notEqual(game.pendingTreacheryDiscard.batch.cause, `karama:${f.kind}`,
|
||||
'Retire the cost reaction before entering its native suffix.');
|
||||
game = semutaNativeKaramaReact(game, f, false, actions);
|
||||
} else if (f.kind === 'ixSubstitution' && game.decision?.kind === 'battleCards') {
|
||||
assert.ok(f.ix);
|
||||
assert.deepEqual(game.decision.cards, [f.ix.shield]);
|
||||
game = semutaNativeKaramaAct(game,
|
||||
{ actor: f.ix.winner, action: { type: 'decision', discard: [f.ix.shield] } }, actions);
|
||||
} else throw Error('The canceled native aftermath did not expose its original cleanup.');
|
||||
}
|
||||
assert.equal(game.phase, 7);
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { battleKaramaInventory, battleKaramaPlayer, finishBattleKarama, reloadBattleKarama,
|
||||
semutaBattleKaramaFixture, settleBattleKaramaCost, type SemutaBattleKaramaKind } from './fixture-semuta-battle-karama';
|
||||
|
||||
const kinds: SemutaBattleKaramaKind[] = ['capture', 'choamAudit', 'choamBattleIncome'];
|
||||
|
||||
function battleQuantities(game: Game) {
|
||||
return game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces,
|
||||
reserves: p.reserves, tanks: p.tanks, leaders: p.leaders }));
|
||||
}
|
||||
|
||||
for (const kind of kinds) for (const claim of [false, true]) void test(
|
||||
`printed Karama canceling native ${kind} offers only its cost before ${claim ? 'claim' : 'all-seat pass'} and finishes once`, () => {
|
||||
const fixture = semutaBattleKaramaFixture(kind);
|
||||
const original = reloadBattleKarama(fixture.originalResponse);
|
||||
const native = reloadBattleKarama(original);
|
||||
delete native.semutaPreview;
|
||||
const nativeCanceled = applyAction(native, fixture.spender, fixture.cancel);
|
||||
const winner = battleKaramaPlayer(original, fixture.winner);
|
||||
const loser = battleKaramaPlayer(original, fixture.loser);
|
||||
assert.equal(winner.spice, 10 - 2 + fixture.bounty);
|
||||
assert.equal(loser.spice, kind === 'choamAudit' ? 11 : 10);
|
||||
assert.deepEqual(winner.forces, { 'arrakeen:10': 3 });
|
||||
assert.deepEqual(loser.forces, {});
|
||||
assert.equal(winner.tanks, 2);
|
||||
assert.equal(loser.tanks, 5);
|
||||
assert.equal(winner.reserves, 15);
|
||||
assert.equal(loser.reserves, 15);
|
||||
assert.equal(loser.leaders.find(l => l.id === fixture.loserLeader)!.dead, true);
|
||||
assert.equal(loser.leaders.find(l => l.id === fixture.loserLeader)!.deaths, 1);
|
||||
if (kind === 'capture') {
|
||||
assert.equal(fixture.nativeOffer.decision?.kind, 'captureOffer');
|
||||
assert.deepEqual(original.pendingCapture, { player: 'e', loser: 'a', territory: 'arrakeen' });
|
||||
} else if (kind === 'choamAudit') {
|
||||
assert.equal(fixture.nativeOffer.decision?.kind, 'choamAudit');
|
||||
assert.equal(original.pendingAuditor?.stage, 'response');
|
||||
assert.equal(original.pendingAuditor?.survived, false);
|
||||
assert.deepEqual(original.pendingAuditor?.usedCards, [fixture.weapon]);
|
||||
assert.deepEqual(battleKaramaPlayer(original, 'a').hand.map(c => c.id).sort(),
|
||||
[fixture.karama, fixture.privateCard].sort());
|
||||
} else assert.deepEqual(original.pendingChoamBattleIncome, { owner: 'e', amount: 1 });
|
||||
|
||||
const pending = applyAction(original, fixture.spender, fixture.cancel);
|
||||
// This is the missing producer on the baseline: the cost used to skip Semuta entirely.
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'karamaBattleDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
const receipt = pending.pendingTreacheryDiscard!;
|
||||
assert.equal(receipt.batch.cause, `karama:${kind}`);
|
||||
assert.equal(receipt.batch.entries.length, 1);
|
||||
assert.equal(receipt.batch.entries[0].card.id, fixture.karama);
|
||||
assert.equal(receipt.batch.entries[0].card.effect, 'karama');
|
||||
assert.equal(receipt.batch.entries[0].discardedBy, fixture.spender);
|
||||
assert.equal(receipt.batch.entries[0].publicFace, true);
|
||||
assert.equal(pending.discard.filter(c => c.id === fixture.karama).length, 1);
|
||||
assert.equal(battleKaramaPlayer(pending, fixture.spender).hand.some(c => c.id === fixture.karama), false);
|
||||
assert.equal(pending.response, null);
|
||||
assert.deepEqual(pending.pendingCapture, original.pendingCapture);
|
||||
assert.deepEqual(pending.pendingAuditor, original.pendingAuditor);
|
||||
assert.deepEqual(pending.pendingChoamBattleIncome, original.pendingChoamBattleIncome);
|
||||
assert.deepEqual(battleQuantities(pending), battleQuantities(original));
|
||||
assert.equal(pending.auditorInsight == null, true);
|
||||
assert.deepEqual(battleKaramaInventory(pending), fixture.inventory);
|
||||
assert.deepEqual(normalizeAutomaticGame(reloadBattleKarama(pending)), reloadBattleKarama(pending));
|
||||
assert.equal(viewGame(pending, fixture.holder).semutaReaction?.canCommit, true);
|
||||
for (const oldEvent of fixture.earlierEvents)
|
||||
assert.throws(() => applyAction(pending, fixture.holder, { type: 'semutaCommit', event: oldEvent }));
|
||||
assert.throws(() => applyAction(pending, fixture.spender, fixture.cancel));
|
||||
if (kind === 'capture') {
|
||||
assert.equal(pending.players.some(p => p.leaders.some(l => l.capturedBy || l.concealed)), false);
|
||||
assert.throws(() => applyAction(pending, 'e', { type: 'decision', mode: 'keep' }));
|
||||
} else if (kind === 'choamAudit') {
|
||||
assert.throws(() => applyAction(pending, 'a', { type: 'decision',
|
||||
event: original.pendingAuditor!.event, pay: false }));
|
||||
}
|
||||
|
||||
const event = receipt.batch.event;
|
||||
// Save at the physically discarded, still-unsettled boundary; a neutral pass must not retire it.
|
||||
let saved = applyAction(reloadBattleKarama(pending), fixture.spender, { type: 'semutaPass', event });
|
||||
assert.equal(saved.pendingTreacheryDiscard?.batch.event, event);
|
||||
assert.deepEqual(battleQuantities(saved), battleQuantities(original));
|
||||
assert.throws(() => applyAction(saved, fixture.spender, { type: 'semutaPass', event }));
|
||||
saved = reloadBattleKarama(saved);
|
||||
let resumed: Game;
|
||||
if (claim) resumed = settleBattleKaramaCost(saved, true);
|
||||
else {
|
||||
resumed = saved;
|
||||
for (const seat of resumed.players.filter(p => p.id !== fixture.spender))
|
||||
resumed = applyAction(resumed, seat.id, { type: 'semutaPass', event });
|
||||
}
|
||||
resumed = reloadBattleKarama(resumed);
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
assert.equal(resumed.pendingCapture == null, true);
|
||||
assert.equal(resumed.pendingAuditor == null, true);
|
||||
assert.equal(resumed.auditorInsight == null, true);
|
||||
assert.deepEqual(battleQuantities(resumed), battleQuantities(nativeCanceled));
|
||||
assert.equal(battleKaramaPlayer(resumed, fixture.holder).hand.filter(c => c.id === fixture.karama).length, Number(claim));
|
||||
assert.equal(resumed.discard.filter(c => c.id === fixture.karama).length, Number(!claim));
|
||||
assert.equal(resumed.discard.filter(c => c.id === fixture.semuta).length, Number(claim));
|
||||
assert.equal(battleKaramaPlayer(resumed, 'a').hand.some(c => c.id === fixture.privateCard), true);
|
||||
assert.deepEqual(battleKaramaInventory(resumed), fixture.inventory);
|
||||
assert.throws(() => applyAction(resumed, fixture.holder, { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(resumed, fixture.spender, fixture.cancel));
|
||||
if (kind === 'choamAudit') {
|
||||
assert.equal(resumed.pendingChoamBattleIncome, null);
|
||||
assert.equal(resumed.decision?.kind, 'choamMarket');
|
||||
assert.throws(() => applyAction(resumed, 'e', { type: 'decision',
|
||||
event: original.pendingAuditor!.event, audit: true }));
|
||||
} else if (kind === 'choamBattleIncome') {
|
||||
assert.equal(resumed.pendingChoamBattleIncome, null);
|
||||
assert.equal(resumed.decision?.kind, 'choamMarket');
|
||||
} else {
|
||||
assert.equal(resumed.phase, 7);
|
||||
assert.equal(resumed.players.some(p => p.leaders.some(l => l.capturedBy || l.concealed)), false);
|
||||
assert.throws(() => applyAction(resumed, 'e', { type: 'decision', mode: 'execute' }));
|
||||
}
|
||||
|
||||
const finished = finishBattleKarama(resumed);
|
||||
assert.equal(finished.turn, 2);
|
||||
assert.equal(finished.pendingChoamBattleIncome == null, true);
|
||||
assert.equal(finished.auditorInsight == null, true);
|
||||
assert.equal(battleKaramaPlayer(finished, 'a').spice, kind === 'choamBattleIncome' ? 12 : 10);
|
||||
assert.equal(battleKaramaPlayer(finished, 'e').spice, kind === 'capture' ? 15 : kind === 'choamAudit' ? 11 : 10);
|
||||
assert.equal(battleKaramaPlayer(finished, 'r').spice, 10);
|
||||
assert.deepEqual(battleKaramaPlayer(finished, fixture.winner).forces, { 'arrakeen:10': 3 });
|
||||
assert.equal(battleKaramaPlayer(finished, fixture.winner).tanks, 2);
|
||||
assert.equal(battleKaramaPlayer(finished, fixture.loser).tanks, 5);
|
||||
assert.deepEqual(finished.players.map(p => p.leaders), original.players.map(p => p.leaders));
|
||||
assert.deepEqual(battleKaramaInventory(finished), fixture.inventory);
|
||||
for (const seat of finished.players)
|
||||
assert.equal(viewGame(finished, seat.id).auditorInsight == null, true);
|
||||
assert.throws(() => applyAction(finished, fixture.holder, { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(finished, 'e', { type: 'decision', done: true }));
|
||||
if (claim) assert.throws(() => applyAction(finished, fixture.holder, fixture.cancel),
|
||||
'acquiring the printed cost cannot reopen its original canceled effect');
|
||||
});
|
||||
|
||||
for (const kind of kinds) void test(`all four AI profiles legally participate in the native ${kind} cost receipt`, () => {
|
||||
const fixture = semutaBattleKaramaFixture(kind);
|
||||
const pending = applyAction(fixture.originalResponse, fixture.spender, fixture.cancel);
|
||||
const native = reloadBattleKarama(fixture.originalResponse);
|
||||
delete native.semutaPreview;
|
||||
const nativeCanceled = applyAction(native, fixture.spender, fixture.cancel);
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'karamaBattleDiscard');
|
||||
for (const profile of DIFFICULTIES) for (const id of ['a', 'r', 'e']) {
|
||||
const game = reloadBattleKarama(pending);
|
||||
battleKaramaPlayer(game, id).bot = profile;
|
||||
const action = botActions(viewGame(game, id))[0];
|
||||
assert.ok(action, `${profile} ${id} must have a neutral reaction action`);
|
||||
assert.ok(['semutaPass', 'semutaCommit'].includes(action.type));
|
||||
const after = applyAction(game, id, action);
|
||||
assert.deepEqual(battleKaramaInventory(after), fixture.inventory);
|
||||
assert.deepEqual(battleQuantities(after), battleQuantities(after.pendingTreacheryDiscard ? fixture.originalResponse : nativeCanceled));
|
||||
}
|
||||
});
|
||||
@@ -189,9 +189,14 @@ void test('Semuta preserves quoted CHOAM battle income through original allowanc
|
||||
assert.deepEqual(resumed.pendingChoamBattleIncome, state.pendingChoamBattleIncome);
|
||||
assert.equal(player(resumed, 'e').spice, beforeIncome);
|
||||
for (const cancel of [false, true]) {
|
||||
const settled = cancel
|
||||
let settled = cancel
|
||||
? applyAction(reload(resumed), 'r', { type: 'card', mode: 'cancel', card: karama.id })
|
||||
: applyAction(reload(resumed), 'r', { type: 'passResponse' });
|
||||
if (cancel) {
|
||||
assert.deepEqual(settled.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id), [karama.id]);
|
||||
assert.deepEqual(settled.pendingChoamBattleIncome, state.pendingChoamBattleIncome);
|
||||
settled = pass(settled);
|
||||
}
|
||||
assert.equal(settled.pendingChoamBattleIncome, null);
|
||||
assert.equal(player(settled, 'e').spice, beforeIncome + Number(!cancel));
|
||||
assert.equal(player(settled, 'a').spice, paidBalance);
|
||||
|
||||
@@ -112,9 +112,13 @@ for (const holder of ['w', 'l'] as const) for (const cancel of [false, true]) {
|
||||
assert.equal(viewGame(response, 'w').semutaReaction, null);
|
||||
assert.equal(viewGame(response, 'w').moritaniRetentionCard?.id, f.weapon);
|
||||
rejected(response, 'l', { type: 'card', card: f.weapon, mode: 'discard' });
|
||||
const settled = cancel
|
||||
let settled = cancel
|
||||
? applyAction(reload(response), holder, { type: 'card', card: f.karama!, mode: 'cancel' })
|
||||
: allowSemutaMoritaniResponse(reload(response));
|
||||
if (cancel) {
|
||||
assert.deepEqual(settled.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.karama]);
|
||||
settled = passSemutaMoritaniDiscard(settled);
|
||||
}
|
||||
assert.equal(settled.pendingTreacheryDiscard?.batch.cause, 'battle:moritani');
|
||||
assert.equal(settled.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(settled.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), cancel ? [f.weapon, f.defense] : [f.defense]);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { semutaIxPlayer, semutaIxInventory, semutaIxPhysical } from './fixture-semuta-ix-substitution';
|
||||
import { semutaMoritaniPlayer, semutaMoritaniInventory, semutaMoritaniResources } from './fixture-semuta-moritani-retention';
|
||||
import {
|
||||
semutaNativeIxKaramaFixture, semutaNativeMoritaniKaramaFixture,
|
||||
semutaNativeKaramaPreCost, semutaNativeKaramaCost, semutaNativeKaramaCostStep,
|
||||
semutaNativeKaramaReact, semutaNativeKaramaRemainder,
|
||||
semutaNativeKaramaReload as reload, type SemutaNativeKaramaFixture,
|
||||
} from './fixture-semuta-native-karama';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function assertCostPause(game: Game, before: Game, f: SemutaNativeKaramaFixture) {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.equal(pending.continuation.kind, 'karamaBattleDiscard');
|
||||
assert.equal(pending.batch.cause, `karama:${f.kind}`);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ id: entry.card.id, player: entry.discardedBy, publicFace: entry.publicFace })),
|
||||
[{ id: f.karama, player: f.spender, publicFace: true }]);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(game.players.find(p => p.id === f.spender)!.hand.some(card => card.id === f.karama), false);
|
||||
assert.equal(game.response, null, 'The old response is detached, not still open to another cancel or allow.');
|
||||
if (pending.continuation.kind !== 'karamaBattleDiscard')
|
||||
throw Error('The printed cost needs its exact typed cancellation continuation.');
|
||||
const continuation = pending.continuation;
|
||||
assert.equal(continuation.player, f.spender);
|
||||
assert.equal(continuation.card, f.karama);
|
||||
assert.equal(continuation.use.kind, 'cancel');
|
||||
assert.equal(continuation.use.response.kind, f.kind);
|
||||
assert.equal(continuation.use.response.owner, f.owner);
|
||||
assert.notEqual(f.owner, f.spender);
|
||||
assert.equal(game.phase, 6);
|
||||
const step = semutaNativeKaramaCostStep(f);
|
||||
rejected(game, step.actor, step.action);
|
||||
rejected(game, f.spender, { type: 'passResponse' });
|
||||
}
|
||||
function assertIxCounters(game: Game, f: SemutaNativeKaramaFixture) {
|
||||
assert.ok(f.ix);
|
||||
const ix = semutaIxPlayer(game, f.ix.winner), emperor = semutaIxPlayer(game, f.ix.loser);
|
||||
assert.deepEqual(ix.forces, { [f.ix.location]: 2 });
|
||||
assert.equal(ix.elites!.forces[f.ix.location] ?? 0, 0);
|
||||
assert.equal(ix.reserves, 17); assert.equal(ix.elites!.reserves, 6);
|
||||
assert.equal(ix.tanks, 1); assert.equal(ix.elites!.tanks, 1); assert.equal(ix.battleLosses, 1);
|
||||
assert.deepEqual(emperor.forces, {}); assert.equal(emperor.reserves, 15);
|
||||
assert.equal(emperor.tanks, 5); assert.equal(emperor.battleLosses, 5);
|
||||
const richese = semutaIxPlayer(game, f.holder);
|
||||
assert.deepEqual(richese.forces, {}); assert.equal(richese.reserves, 20); assert.equal(richese.tanks, 0);
|
||||
assert.equal(game.lastBattleContext!.winner, f.ix.winner);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.deepEqual(semutaIxInventory(game), f.cards);
|
||||
}
|
||||
function assertMoritaniCounters(game: Game, f: SemutaNativeKaramaFixture) {
|
||||
assert.ok(f.moritani);
|
||||
const winner = semutaMoritaniPlayer(game, 'w'), loser = semutaMoritaniPlayer(game, 'l');
|
||||
assert.deepEqual(winner.forces, { 'arrakeen:10': 3 }); assert.equal(winner.reserves, 15);
|
||||
assert.equal(winner.tanks, 2); assert.equal(winner.battleLosses, 2);
|
||||
assert.deepEqual(loser.forces, {}); assert.equal(loser.reserves, 15);
|
||||
assert.equal(loser.tanks, 5); assert.equal(loser.battleLosses, 5);
|
||||
for (const id of ['r', 'm']) {
|
||||
const player = semutaMoritaniPlayer(game, id);
|
||||
assert.deepEqual(player.forces, {}); assert.equal(player.reserves, 20); assert.equal(player.tanks, 0);
|
||||
}
|
||||
assert.equal(winner.spice, semutaMoritaniPlayer(f.game, 'w').spice + f.moritani.loserStrength);
|
||||
for (const id of ['r', 'm', 'l']) assert.equal(semutaMoritaniPlayer(game, id).spice, semutaMoritaniPlayer(f.game, id).spice);
|
||||
assert.equal(loser.leaders.find(leader => leader.id === f.moritani!.loserLeader)!.dead, true);
|
||||
assert.equal(loser.hand.some(card => card.id === f.moritani!.unrelated), true);
|
||||
assert.equal(winner.hand.some(card => card.id === f.moritani!.winnerWeapon), true);
|
||||
assert.equal(winner.hand.some(card => card.id === f.moritani!.winnerDefense), true);
|
||||
assert.deepEqual(semutaMoritaniInventory(game), f.cards);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const claim of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Ix printed cancellation cost ${claim ? 'claim' : 'pass'} cannot restore the declared 1/1 substitution`, () => {
|
||||
const f = semutaNativeIxKaramaFixture(advanced); assert.ok(f.ix);
|
||||
assert.notEqual(f.holder, f.spender);
|
||||
const before = reload(semutaNativeKaramaPreCost(f));
|
||||
assert.equal(before.response?.kind, 'ixSubstitution'); assert.equal(before.response?.owner, f.ix.winner);
|
||||
assert.deepEqual(before.pendingIxSubstitution!.sources, { [f.ix.location]: 1 });
|
||||
assert.deepEqual(before.pendingIxSubstitution!.recover, { [f.ix.location]: 1 });
|
||||
const pending = reload(semutaNativeKaramaCost(before, f));
|
||||
assertCostPause(pending, before, f); assertIxCounters(pending, f);
|
||||
assert.deepEqual(pending.pendingIxSubstitution, before.pendingIxSubstitution);
|
||||
assert.deepEqual(pending.pendingWinnerDiscards, before.pendingWinnerDiscards);
|
||||
assert.equal(pending.discard.some(card => card.id === f.ix!.tooth), false);
|
||||
assert.equal(semutaIxPlayer(pending, f.owner).hand.some(card => card.id === f.ix!.tooth), true);
|
||||
const declaration: Action = { type: 'decision', sources: { [f.ix.location]: 1 }, recover: { [f.ix.location]: 1 } };
|
||||
rejected(pending, f.owner, declaration);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
let game = reload(semutaNativeKaramaReact(reload(pending), f, claim));
|
||||
assert.equal(game.pendingIxSubstitution, null); assert.equal(game.pendingWinnerDiscards, null);
|
||||
assertIxCounters(game, f);
|
||||
assert.deepEqual(semutaIxPhysical(game), semutaIxPhysical(before));
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard');
|
||||
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id), [f.ix.tooth]);
|
||||
assert.equal(game.discard.filter(card => card.id === f.ix!.tooth).length, 1);
|
||||
assert.notEqual(game.pendingTreacheryDiscard!.batch.event, event);
|
||||
if (claim) rejected(game, f.holder, { type: 'semutaCommit', event: game.pendingTreacheryDiscard!.batch.event });
|
||||
rejected(game, f.holder, { type: 'semutaCommit', event });
|
||||
rejected(game, f.holder, { type: 'semutaPass', event });
|
||||
rejected(game, f.owner, declaration);
|
||||
game = semutaNativeKaramaRemainder(reload(game), f);
|
||||
assertIxCounters(game, f);
|
||||
assert.deepEqual(semutaIxPhysical(game), semutaIxPhysical(before));
|
||||
assert.equal(game.discard.filter(card => card.id === f.ix!.snooper).length, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.ix!.tooth).length, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.ix!.shield).length, 1);
|
||||
assert.equal(semutaIxPlayer(game, f.owner).hand.length, 0);
|
||||
assert.equal(semutaIxPlayer(game, f.holder).hand.some(card => card.id === f.karama), claim);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, Number(!claim));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claim));
|
||||
assert.equal(game.lastBattleContext!.winnerDiscards!.completed, true);
|
||||
rejected(game, f.owner, declaration);
|
||||
const cost = semutaNativeKaramaCostStep(f); rejected(game, cost.actor, cost.action);
|
||||
rejected(game, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
rejected(game, f.holder, { type: 'semutaCommit', event });
|
||||
});
|
||||
}
|
||||
|
||||
for (const spender of ['w', 'l'] as const) for (const holder of ['r', 'l'] as const)
|
||||
if (spender !== holder) for (const claim of [false, true]) {
|
||||
void test(`Basic allied retention canceled by ${spender}, cost ${claim ? 'claimed' : 'passed'} by ${holder}, never keeps the proposal or adds the cost to its played pool`, () => {
|
||||
const f = semutaNativeMoritaniKaramaFixture({ karamaHolder: spender, semutaHolder: holder }); assert.ok(f.moritani);
|
||||
const before = reload(semutaNativeKaramaPreCost(f));
|
||||
assert.equal(before.response?.kind, 'moritaniRetention'); assert.equal(before.response?.owner, 'm');
|
||||
assert.equal(before.moritaniRetention?.source, undefined); assert.equal(before.moritaniRetention?.keep, f.moritani.weapon);
|
||||
assert.deepEqual(before.moritaniRetention!.played, [f.moritani.weapon, f.moritani.defense]);
|
||||
const pending = reload(semutaNativeKaramaCost(before, f));
|
||||
assertCostPause(pending, before, f); assertMoritaniCounters(pending, f);
|
||||
assert.deepEqual(pending.moritaniRetention, before.moritaniRetention);
|
||||
for (const card of [f.moritani.weapon, f.moritani.defense]) {
|
||||
assert.equal(semutaMoritaniPlayer(pending, 'l').hand.some(held => held.id === card), true);
|
||||
assert.equal(pending.discard.some(held => held.id === card), false);
|
||||
}
|
||||
rejected(pending, 'l', { type: 'decision', keep: f.moritani.weapon });
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
let game = reload(semutaNativeKaramaReact(reload(pending), f, claim));
|
||||
assert.equal(game.moritaniRetention, null); assert.equal(game.response, null);
|
||||
assert.deepEqual(semutaMoritaniResources(game), semutaMoritaniResources(before));
|
||||
const later = game.pendingTreacheryDiscard; assert.ok(later);
|
||||
assert.equal(later.continuation.kind, 'battleCleanup');
|
||||
assert.equal(later.batch.cause, 'battle:moritani'); assert.notEqual(later.batch.event, event);
|
||||
assert.deepEqual(later.batch.entries.map(entry => entry.card.id), [f.moritani.weapon, f.moritani.defense]);
|
||||
if (later.continuation.kind !== 'battleCleanup') throw Error('Expected native Moritani cleanup.');
|
||||
assert.deepEqual(later.continuation.played, [f.moritani.weapon, f.moritani.defense]);
|
||||
assert.deepEqual(later.continuation.kept, []);
|
||||
assert.equal(later.batch.entries.some(entry => entry.card.id === f.karama), false);
|
||||
for (const card of [f.moritani.weapon, f.moritani.defense]) {
|
||||
assert.equal(semutaMoritaniPlayer(game, 'l').hand.some(held => held.id === card), false);
|
||||
assert.equal(game.discard.filter(held => held.id === card).length, 1);
|
||||
}
|
||||
if (claim) rejected(game, holder, { type: 'semutaCommit', event: later.batch.event });
|
||||
rejected(game, holder, { type: 'semutaCommit', event });
|
||||
rejected(game, holder, { type: 'semutaPass', event });
|
||||
rejected(game, 'l', { type: 'decision', keep: f.moritani.weapon });
|
||||
game = semutaNativeKaramaRemainder(reload(game), f);
|
||||
assertMoritaniCounters(game, f);
|
||||
assert.deepEqual(semutaMoritaniResources(game), semutaMoritaniResources(before));
|
||||
assert.equal(game.moritaniRetention, null); assert.equal(game.battle, null); assert.equal(game.decision, null);
|
||||
assert.equal(semutaMoritaniPlayer(game, holder).hand.some(card => card.id === f.karama), claim);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, Number(!claim));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claim));
|
||||
for (const card of [f.moritani.weapon, f.moritani.defense]) {
|
||||
assert.equal(game.discard.filter(held => held.id === card).length, 1);
|
||||
assert.equal(semutaMoritaniPlayer(game, 'l').hand.some(held => held.id === card), false);
|
||||
}
|
||||
rejected(game, holder, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
const cost = semutaNativeKaramaCostStep(f); rejected(game, cost.actor, cost.action);
|
||||
rejected(game, 'l', { type: 'decision', keep: f.moritani.weapon });
|
||||
rejected(game, holder, { type: 'semutaCommit', event });
|
||||
});
|
||||
}
|
||||
|
||||
void test('All four profiles legally retire native printed costs and their original cancellation aftermath', () => {
|
||||
for (const f of [semutaNativeIxKaramaFixture(), semutaNativeIxKaramaFixture(true), semutaNativeMoritaniKaramaFixture({ semutaHolder: 'l' })]) {
|
||||
const before = reload(semutaNativeKaramaPreCost(f)), pending = reload(semutaNativeKaramaCost(before, f));
|
||||
for (const profile of DIFFICULTIES) {
|
||||
let game = reload(pending);
|
||||
for (const player of game.players) player.bot = profile;
|
||||
for (let count = 0; game.phase === 6 && count < 64; count++) {
|
||||
let next: Game | undefined;
|
||||
for (const player of game.players) {
|
||||
const action = botActions(viewGame(game, player.id))[0];
|
||||
if (action) { next = applyAction(reload(game), player.id, action); break; }
|
||||
}
|
||||
assert.ok(next, `${profile} must provide a legal original continuation.`);
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.phase, 7); assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(game.response, null); assert.equal(game.pendingIxSubstitution == null, true);
|
||||
assert.equal(game.moritaniRetention == null, true);
|
||||
if (f.kind === 'ixSubstitution') {
|
||||
assertIxCounters(game, f); assert.deepEqual(semutaIxPhysical(game), semutaIxPhysical(before));
|
||||
} else {
|
||||
assertMoritaniCounters(game, f); assert.deepEqual(semutaMoritaniResources(game), semutaMoritaniResources(before));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user