Preserve native Ixian casualties and Moritani retention across Semuta
This commit is contained in:
1 parent
a6869b0166
commit
74768647f4
14 files changed
+879
-9
No files matched your search
@@ -0,0 +1,156 @@
|
||||
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 { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
|
||||
export type SemutaIxStep = { actor: string; action: Action };
|
||||
export interface SemutaIxFixture {
|
||||
game: Game;
|
||||
holder: string;
|
||||
winner: string;
|
||||
loser: string;
|
||||
territory: string;
|
||||
location: string;
|
||||
semuta: string;
|
||||
tooth: string;
|
||||
shield: string;
|
||||
snooper: string;
|
||||
cards: string[];
|
||||
}
|
||||
export const semutaIxPlayer = (game: Game, id: string) => game.players.find(p => p.id === id)!;
|
||||
export const semutaIxInventory = (game: Game) => [...game.deck, ...game.discard, ...game.richeseCache!,
|
||||
...game.players.flatMap(p => p.hand)].map(card => card.id).sort();
|
||||
export function semutaIxPhysical(game: Game) {
|
||||
return game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
elites: p.elites, battleLosses: p.battleLosses, spice: p.spice }));
|
||||
}
|
||||
|
||||
/** Original native lobby, private Ix card choice, Traitors and actual first Storm/HMS placement. */
|
||||
export function initializeSemutaIxGame(advanced = false): Game {
|
||||
let game = createGame('SEMUTAIX', newPlayer('r', 'Richese', 'richese'), advanced, ['choam', 'ix']);
|
||||
joinGame(game, newPlayer('i', 'Ixians', 'ixians'));
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
for (const p of game.players) { p.bot = 'Medium'; p.ready = true; }
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
for (let step = 0; (game.status === 'setup' || game.phase === 1 || !game.mobileStronghold?.location) && step < 160; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of game.players) {
|
||||
const action = botActions(viewGame(game, p.id))[0];
|
||||
if (action) { next = applyAction(game, p.id, action); break; }
|
||||
}
|
||||
assert.ok(next, 'Original Ix setup and first Storm require a legal native continuation.');
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.ok(game.mobileStronghold?.location);
|
||||
assert.equal(game.richeseCache!.length, 10);
|
||||
assert.equal(semutaIxInventory(game).length, 57, 'Original union-47 deck plus separate Richese ten-card cache.');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Later rule position uses conserved native pieces, not a synthetic Game or replacement players. */
|
||||
export function semutaIxBattleFixture(advanced = false): SemutaIxFixture {
|
||||
const game = initializeSemutaIxGame(advanced);
|
||||
const cards = semutaIxInventory(game);
|
||||
assert.equal(new Set(cards).size, cards.length);
|
||||
for (const p of game.players) {
|
||||
game.deck.push(...p.hand); p.hand = [];
|
||||
p.reserves += p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0);
|
||||
p.forces = {}; p.tanks = 0; p.battleLosses = 0;
|
||||
if (p.elites) {
|
||||
p.elites.reserves += p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0);
|
||||
p.elites.forces = {}; p.elites.tanks = 0;
|
||||
}
|
||||
}
|
||||
const take = (id: string) => {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache!]) {
|
||||
const index = zone.findIndex(c => c.id === id);
|
||||
if (index >= 0) return zone.splice(index, 1)[0];
|
||||
}
|
||||
throw new Error(`Missing original physical ${id}`);
|
||||
};
|
||||
const semuta = take(SEMUTA_DRUG_ID);
|
||||
const tooth = take(game.deck.find(c => c.kind === 'poisonTooth')!.id);
|
||||
const shield = take(game.deck.find(c => c.kind === 'shield')!.id);
|
||||
const snooper = take(game.deck.find(c => c.kind === 'snooper')!.id);
|
||||
semutaIxPlayer(game, 'r').hand.push(semuta);
|
||||
// A real rival-held Karama keeps the native substitution response actionable.
|
||||
semutaIxPlayer(game, 'r').hand.push(take(game.deck.find(c => c.effect === 'karama')!.id));
|
||||
semutaIxPlayer(game, 'i').hand.push(tooth, shield);
|
||||
semutaIxPlayer(game, 'e').hand.push(snooper);
|
||||
const location = 'arrakeen:10';
|
||||
const ix = semutaIxPlayer(game, 'i');
|
||||
assert.equal(ix.reserves, 20); assert.equal(ix.elites!.reserves, 7);
|
||||
ix.reserves -= 3; ix.forces[location] = 3;
|
||||
ix.elites!.reserves--; ix.elites!.forces[location] = 1;
|
||||
const emperor = semutaIxPlayer(game, 'e');
|
||||
emperor.reserves -= 5; emperor.forces[location] = 5;
|
||||
Object.assign(game, { turn: 2, phase: 6, active: 'i', order: ['i', 'e', 'r'], storm: 18,
|
||||
ready: [], response: null, decision: null, phaseOpening: null, movementRemaining: [] });
|
||||
assert.deepEqual(semutaIxInventory(game), cards);
|
||||
return { game, holder: 'r', winner: 'i', loser: 'e', territory: 'arrakeen', location,
|
||||
semuta: semuta.id, tooth: tooth.id, shield: shield.id, snooper: snooper.id, cards };
|
||||
}
|
||||
|
||||
/** Export exact native plan actions for CLI/UI proof; one supported Cyborg is the sole dial-two casualty. */
|
||||
export function semutaIxPlanSteps(game: Game, f: SemutaIxFixture): SemutaIxStep[] {
|
||||
return [
|
||||
{ actor: f.winner, action: { type: 'battlePlan', dial: 2, support: game.advanced ? 1 : 0,
|
||||
leader: semutaIxPlayer(game, f.winner).leaders.find(l => !l.dead)!.id, weapon: f.tooth, defense: f.shield } },
|
||||
{ actor: f.loser, action: { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: semutaIxPlayer(game, f.loser).leaders.find(l => !l.dead)!.id, defense: f.snooper } },
|
||||
];
|
||||
}
|
||||
|
||||
/** Stops at the LOSER'S physical Snooper discard, before winner casualties or substitution. */
|
||||
export function semutaIxLoserDiscardFixture(advanced = false): SemutaIxFixture {
|
||||
const f = semutaIxBattleFixture(advanced);
|
||||
let game = applyAction(f.game, f.winner, { type: 'chooseBattle', territory: f.territory, target: f.loser });
|
||||
for (let step = 0; step < 40; step++) {
|
||||
if (game.response) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id))!;
|
||||
game = applyAction(game, actor.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 actor of [f.winner, f.loser])
|
||||
game = applyAction(game, actor, { type: 'battlePreparationReady', event: game.battle!.preLeader!.event });
|
||||
for (const step of semutaIxPlanSteps(game, f)) game = applyAction(game, step.actor, step.action);
|
||||
assert.equal(game.decision?.kind, 'poisonTooth');
|
||||
game = applyAction(game, f.winner, { type: 'decision', activate: true });
|
||||
for (const actor of [f.winner, f.loser]) game = applyAction(game, actor, { type: 'traitorCall', call: false });
|
||||
return { ...f, game };
|
||||
}
|
||||
|
||||
export function semutaIxPassDiscard(game: Game): Game {
|
||||
const pending = game.pendingTreacheryDiscard!;
|
||||
assert.equal(pending.reaction?.stage, 'offer');
|
||||
const event = pending.batch.event;
|
||||
for (const p of game.players) game = applyAction(game, p.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
export function semutaIxSubstitutionStep(f: SemutaIxFixture, substitute: boolean): SemutaIxStep {
|
||||
return { actor: f.winner, action: substitute
|
||||
? { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.location]: 1 } }
|
||||
: { type: 'decision', decline: true } };
|
||||
}
|
||||
export function semutaIxAllowResponses(game: Game): Game {
|
||||
for (let step = 0; game.response && step < 12; step++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id))!;
|
||||
game = applyAction(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
/** Used Tooth discard is correctly AFTER native substitution, never offered before it. */
|
||||
export function semutaIxWinnerDiscardFixture(advanced = false, substitute = true): SemutaIxFixture {
|
||||
const f = semutaIxLoserDiscardFixture(advanced);
|
||||
let game = semutaIxPassDiscard(f.game);
|
||||
assert.equal(game.decision?.kind, 'ixSubstitution');
|
||||
const step = semutaIxSubstitutionStep(f, substitute);
|
||||
game = semutaIxAllowResponses(applyAction(game, step.actor, step.action));
|
||||
return { ...f, game };
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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 { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import type { Card } from '../game/cards';
|
||||
|
||||
export type SemutaMoritaniStep = { actor: string; action: Action };
|
||||
export type SemutaMoritaniOptions = { winnerMandatory?: boolean; karamaHolder?: 'r' | 'm' | 'w' | 'l'; semutaHolder?: 'r' | 'l' };
|
||||
export type SemutaMoritaniFixture = {
|
||||
game: Game; semuta: string; weapon: string; defense: string;
|
||||
winnerWeapon: string; winnerDefense: string; winnerHero: string | null;
|
||||
unrelated: string; karama: string | null; cards: string[];
|
||||
loserLeader: string; loserStrength: number; winnerLeader: string;
|
||||
};
|
||||
export const semutaMoritaniReload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
export function semutaMoritaniPlayer(game: Game, id: string) {
|
||||
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
|
||||
}
|
||||
export function semutaMoritaniInventory(game: Game) {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
|
||||
assert.equal(new Set(cards).size, cards.length); return cards;
|
||||
}
|
||||
export function semutaMoritaniResources(game: Game) {
|
||||
return game.players.map(({ id, spice, reserves, tanks, forces, leaders, ally }) => ({
|
||||
id, spice, reserves, tanks, forces, leaders, ally,
|
||||
}));
|
||||
}
|
||||
export function semutaMoritaniAct(game: Game, step: SemutaMoritaniStep, actions?: SemutaMoritaniStep[]) {
|
||||
actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
|
||||
}
|
||||
export function semutaMoritaniPolicy(game: Game, actor: string, difficulty: Difficulty) {
|
||||
const view = viewGame(game, actor), player = view.players.find(p => p.id === actor); assert.ok(player); player.bot = difficulty;
|
||||
return botActions(view);
|
||||
}
|
||||
|
||||
/** Genuine Basic Richese/Moritani + classic opponents; no optional modules. */
|
||||
export function initializeSemutaMoritaniRetentionGame(): Game {
|
||||
let game = createGame('SEMUTAMORITANI', newPlayer('r', 'Richese', 'richese'), false, ['choam', 'ecaz']);
|
||||
joinGame(game, newPlayer('m', 'Moritani', 'moritani'));
|
||||
joinGame(game, newPlayer('w', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('l', 'Guild', 'guild'));
|
||||
for (const player of game.players) { player.bot = 'Medium'; player.ready = true; }
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
for (let step = 0; game.status === 'setup' && step < 160; step++) {
|
||||
let next: SemutaMoritaniStep | undefined;
|
||||
for (const player of game.players) {
|
||||
const action = botActions(viewGame(game, player.id))[0];
|
||||
if (action) { next = { actor: player.id, action }; break; }
|
||||
}
|
||||
assert.ok(next, 'Native original setup must offer a legal continuation.');
|
||||
game = semutaMoritaniAct(game, next);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.equal(game.phase, 0);
|
||||
assert.equal(game.advanced, false); assert.deepEqual(game.expansions, ['choam', 'ecaz']);
|
||||
assert.ok(game.moritaniTerror); assert.equal(game.moritaniAssassinate == null, true);
|
||||
assert.equal(game.techTokens == null, true); assert.equal(game.leaderSkills == null, true);
|
||||
assert.equal(game.strongholdCards == null, true); assert.equal(game.homeworlds == null, true);
|
||||
assert.equal(game.nexusCards?.cards == null, true); assert.equal(game.ecazTreachery == null, true);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Later reciprocal alliance and conserved physical battle are explicitly staged,
|
||||
* not asserted to have arisen naturally in the original setup. */
|
||||
export function semutaMoritaniRetentionFixture(options: SemutaMoritaniOptions = {}): SemutaMoritaniFixture {
|
||||
const game = initializeSemutaMoritaniRetentionGame();
|
||||
game.deck.push(...game.players.flatMap(p => p.hand));
|
||||
for (const player of game.players) player.hand = [];
|
||||
const take = (predicate: (card: Card) => boolean): Card => {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache!]) {
|
||||
const index = zone.findIndex(predicate); if (index >= 0) return zone.splice(index, 1)[0];
|
||||
}
|
||||
throw Error('Missing physical original fixture card.');
|
||||
};
|
||||
const hold = (owner: string, predicate: (card: Card) => boolean) => {
|
||||
const card = take(predicate); semutaMoritaniPlayer(game, owner).hand.push(card);
|
||||
return card.id;
|
||||
};
|
||||
const semuta = hold(options.semutaHolder ?? 'r', c => c.id === SEMUTA_DRUG_ID);
|
||||
const weapon = hold('l', c => c.kind === 'projectile'), defense = hold('l', c => c.kind === 'snooper');
|
||||
const unrelated = hold('l', c => c.kind === 'worthless');
|
||||
const winnerWeapon = hold('w', c => c.kind === 'projectile'), winnerDefense = hold('w', c => c.kind === 'shield');
|
||||
const winnerHero = options.winnerMandatory ? hold('w', c => c.kind === 'hero') : null;
|
||||
const karama = options.karamaHolder ? hold(options.karamaHolder, c => c.effect === 'karama') : null;
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
|
||||
player.forces = {};
|
||||
assert.equal(player.tanks, 0); assert.equal(player.reserves, 20);
|
||||
}
|
||||
for (const id of ['w', 'l']) {
|
||||
const player = semutaMoritaniPlayer(game, id); player.reserves -= 5; player.forces['arrakeen:10'] = 5;
|
||||
}
|
||||
semutaMoritaniPlayer(game, 'm').ally = 'l'; semutaMoritaniPlayer(game, 'l').ally = 'm';
|
||||
Object.assign(game, { turn: 2, phase: 6, storm: 18, active: 'w', order: ['w', 'l', 'm', 'r'],
|
||||
ready: [], response: null, decision: null, phaseOpening: null, stormPending: null, movementRemaining: [] });
|
||||
const winnerLeader = semutaMoritaniPlayer(game, 'w').leaders.reduce((best, leader) => leader.strength > best.strength ? leader : best).id;
|
||||
const loser = semutaMoritaniPlayer(game, 'l').leaders[0];
|
||||
return { game, semuta, weapon, defense, winnerWeapon, winnerDefense, winnerHero,
|
||||
unrelated, karama, cards: semutaMoritaniInventory(game), winnerLeader,
|
||||
loserLeader: loser.id, loserStrength: loser.strength };
|
||||
}
|
||||
|
||||
export function resolveSemutaMoritaniBattle(f: SemutaMoritaniFixture, actions?: SemutaMoritaniStep[]): Game {
|
||||
let game = semutaMoritaniAct(f.game, { actor: 'w', action: { type: 'chooseBattle', territory: 'arrakeen', target: 'l' } }, actions);
|
||||
for (let step = 0; step < 40; step++) {
|
||||
if (game.response) game = semutaMoritaniAct(game, { actor: game.players.find(p => !game.response!.passed.includes(p.id))!.id, action: { type: 'passResponse' } }, actions);
|
||||
else if (game.battle?.preparation) game = semutaMoritaniAct(game, { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } }, actions);
|
||||
else if (game.decision?.kind === 'fullPlanOffer') game = semutaMoritaniAct(game, { actor: game.decision.player, action: { type: 'decision', decline: true } }, actions);
|
||||
else break;
|
||||
}
|
||||
assert.ok(game.battle?.preLeader);
|
||||
for (const actor of ['w', 'l']) game = semutaMoritaniAct(game, { actor, action: { type: 'battlePreparationReady', event: game.battle!.preLeader!.event } }, actions);
|
||||
game = semutaMoritaniAct(game, { actor: 'w', action: { type: 'battlePlan', dial: 2, support: 0,
|
||||
leader: f.winnerHero ?? f.winnerLeader, weapon: f.winnerWeapon, defense: f.winnerDefense } }, actions);
|
||||
game = semutaMoritaniAct(game, { actor: 'l', action: { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: f.loserLeader, weapon: f.weapon, defense: f.defense } }, actions);
|
||||
for (const actor of ['w', 'l']) game = semutaMoritaniAct(game, { actor, action: { type: 'traitorCall', call: false } }, actions);
|
||||
return game;
|
||||
}
|
||||
export function passSemutaMoritaniDiscard(game: Game, actions?: SemutaMoritaniStep[]): Game {
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
for (const actor of game.players.map(p => p.id)) game = semutaMoritaniAct(game, { actor, action: { type: 'semutaPass', event } }, actions);
|
||||
return game;
|
||||
}
|
||||
export function claimSemutaMoritaniDiscard(game: Game, card?: string, actions?: SemutaMoritaniStep[]): Game {
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
game = semutaMoritaniAct(game, { actor: 'r', action: { type: 'semutaCommit', event } }, actions);
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
assert.ok(card, 'A multiple fresh-card batch needs an exact committed selection.');
|
||||
game = semutaMoritaniAct(game, { actor: 'r', action: { type: 'semutaSelect', event, card } }, actions);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
export function finishSemutaMoritaniWinner(game: Game, discard: string[] = [], actions?: SemutaMoritaniStep[]): Game {
|
||||
assert.equal(game.decision?.kind, 'battleCards');
|
||||
return semutaMoritaniAct(game, { actor: 'w', action: { type: 'decision', discard } }, actions);
|
||||
}
|
||||
export function chooseSemutaMoritaniRetention(game: Game, keep: string | null, actions?: SemutaMoritaniStep[]): Game {
|
||||
assert.equal(game.decision?.kind, 'moritaniRetention');
|
||||
return semutaMoritaniAct(game, { actor: 'l', action: { type: 'decision', keep } }, actions);
|
||||
}
|
||||
export function allowSemutaMoritaniResponse(game: Game, actions?: SemutaMoritaniStep[]): Game {
|
||||
for (let step = 0; game.response && step < 40; step++) {
|
||||
game = semutaMoritaniAct(game, { actor: game.players.find(p => !game.response!.passed.includes(p.id))!.id, action: { type: 'passResponse' } }, actions);
|
||||
}
|
||||
assert.equal(game.response, null); return game;
|
||||
}
|
||||
@@ -1815,7 +1815,7 @@ void test('Semuta settles mandatory and winner discards before the original Hark
|
||||
}
|
||||
});
|
||||
|
||||
void test('a mandatory winner Hero discard can be claimed before optional battle cleanup', () => {
|
||||
void test('a mandatory winner Hero is reserved through the loser offer and claimable before optional cleanup', () => {
|
||||
const { g, semuta, hajr } = fixture();
|
||||
player(g, 'a').hand = [];
|
||||
g.deck.push(hajr);
|
||||
@@ -1852,7 +1852,12 @@ void test('a mandatory winner Hero discard can be claimed before optional battle
|
||||
battle = applyAction(battle, 'e', { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: player(battle, 'e').leaders[0].id, defense: defense.id });
|
||||
battle = applyAction(battle, 'a', { type: 'traitorCall', call: false });
|
||||
const pending = applyAction(battle, 'e', { type: 'traitorCall', call: false });
|
||||
let pending = applyAction(battle, 'e', { type: 'traitorCall', call: false });
|
||||
assert.equal(player(pending, 'a').hand.some(card => card.id === hero.id), true);
|
||||
assert.equal(pending.discard.some(card => card.id === hero.id), false);
|
||||
const loserEvent = pending.pendingTreacheryDiscard!.batch.event;
|
||||
for (const id of ['e', 'a', 'r'])
|
||||
pending = applyAction(pending, id, { type: 'semutaPass', event: loserEvent });
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(entry => entry.card.id), [hero.id]);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { semutaIxLoserDiscardFixture, semutaIxWinnerDiscardFixture,
|
||||
semutaIxPlayer, semutaIxInventory, semutaIxPhysical, semutaIxPassDiscard,
|
||||
semutaIxSubstitutionStep, semutaIxAllowResponses, type SemutaIxFixture } from './fixture-semuta-ix-substitution';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
function assertCasualties(game: Game, f: SemutaIxFixture, substitute: boolean) {
|
||||
const ix = semutaIxPlayer(game, f.winner), emperor = semutaIxPlayer(game, f.loser);
|
||||
assert.deepEqual(ix.forces, { [f.location]: 2 });
|
||||
assert.equal(ix.elites!.forces[f.location] ?? 0, Number(substitute));
|
||||
assert.equal(ix.reserves, 17);
|
||||
assert.equal(ix.elites!.reserves, 6);
|
||||
assert.equal(ix.tanks, 1);
|
||||
assert.equal(ix.elites!.tanks, substitute ? 0 : 1);
|
||||
assert.equal(ix.battleLosses, 1, 'One casualty remains one casualty when its type is exchanged.');
|
||||
assert.deepEqual(emperor.forces, {});
|
||||
assert.equal(emperor.reserves, 15);
|
||||
assert.equal(emperor.tanks, 5);
|
||||
assert.equal(emperor.battleLosses, 5);
|
||||
assert.equal(game.lastBattleContext!.winner, f.winner);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
|
||||
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'ixians' ? 7 : 5);
|
||||
}
|
||||
assert.deepEqual(semutaIxInventory(game), f.cards);
|
||||
}
|
||||
function finishShieldCleanup(game: Game, f: SemutaIxFixture): Game {
|
||||
assert.equal(game.decision?.kind, 'battleCards');
|
||||
if (game.decision?.kind !== 'battleCards') throw new Error('Original winner cleanup was not restored.');
|
||||
assert.equal(game.decision.player, f.winner);
|
||||
assert.deepEqual(game.decision.cards, [f.shield]);
|
||||
game = applyAction(game, f.winner, { type: 'decision', discard: [f.shield] });
|
||||
if (game.pendingTreacheryDiscard) game = semutaIxPassDiscard(game);
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(game.pendingIxSubstitution, null);
|
||||
assert.equal(game.pendingWinnerDiscards, null);
|
||||
assert.equal(game.discard.filter(c => c.id === f.shield).length, 1);
|
||||
assert.equal(game.phase, 7);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const mode = advanced ? 'Advanced' : 'Basic';
|
||||
|
||||
void test(`Semuta ${mode} reacts to actual loser discard before native Ix losses/substitution, then disposes Tooth once`, () => {
|
||||
const f = semutaIxLoserDiscardFixture(advanced), pending = f.game;
|
||||
// The former guard skipped this offer while the used winning Tooth was reserved.
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(pending.pendingTreacheryDiscard!.continuation.kind, 'battleResolved');
|
||||
assert.equal(pending.pendingTreacheryDiscard!.batch.cause, 'battle:mandatory');
|
||||
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.snooper]);
|
||||
assert.deepEqual(pending.pendingWinnerDiscards!.cards, [f.tooth]);
|
||||
assert.equal(pending.pendingIxSubstitution == null, true, 'Native substitution is staged only AFTER this reaction and winner losses.');
|
||||
assert.equal(semutaIxPlayer(pending, f.winner).forces[f.location], 3);
|
||||
assert.equal(semutaIxPlayer(pending, f.winner).tanks, 0);
|
||||
assert.equal(pending.discard.filter(c => c.id === f.snooper).length, 1);
|
||||
assert.equal(pending.discard.some(c => c.id === f.tooth), false);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
const premature = semutaIxSubstitutionStep(f, true);
|
||||
assert.throws(() => applyAction(reload(pending), premature.actor, premature.action));
|
||||
assert.throws(() => applyAction(reload(pending), f.winner, { type: 'decision', decline: true }));
|
||||
for (const claim of [false, true]) for (const substitute of [false, true]) {
|
||||
let game = claim ? applyAction(reload(pending), f.holder, { type: 'semutaCommit', event })
|
||||
: semutaIxPassDiscard(reload(pending));
|
||||
assert.equal(game.decision?.kind, 'ixSubstitution');
|
||||
assert.equal(game.decision?.player, f.winner);
|
||||
assert.deepEqual(game.pendingIxSubstitution!.losses, { [f.location]: 1 });
|
||||
assert.deepEqual(game.pendingIxSubstitution!.cards, [f.shield]);
|
||||
assertCasualties(game, f, false);
|
||||
assert.equal(semutaIxPlayer(game, f.winner).hand.some(c => c.id === f.tooth), true);
|
||||
assert.equal(game.discard.some(c => c.id === f.tooth), false);
|
||||
assert.equal(semutaIxPlayer(game, f.holder).hand.some(c => c.id === f.snooper), claim);
|
||||
assert.throws(() => applyAction(reload(game), f.holder, { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(reload(game), f.winner, { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 2 } }));
|
||||
const step = semutaIxSubstitutionStep(f, substitute);
|
||||
game = applyAction(reload(game), step.actor, step.action);
|
||||
if (substitute) {
|
||||
assert.equal(game.response?.kind, 'ixSubstitution');
|
||||
assertCasualties(game, f, false);
|
||||
game = semutaIxAllowResponses(reload(game));
|
||||
}
|
||||
assertCasualties(game, f, substitute);
|
||||
assert.equal(game.pendingIxSubstitution, null);
|
||||
assert.equal(game.pendingWinnerDiscards, null);
|
||||
assert.equal(game.discard.filter(c => c.id === f.tooth).length, 1);
|
||||
// Finish any later neutral Tooth opportunity before optional Shield cleanup.
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
assert.equal(game.pendingTreacheryDiscard.continuation.kind, 'winnerMandatoryDiscard');
|
||||
assert.deepEqual(game.pendingTreacheryDiscard.batch.entries.map(e => e.card.id), [f.tooth]);
|
||||
assert.throws(() => applyAction(reload(game), step.actor, step.action));
|
||||
if (claim) assert.throws(() => applyAction(reload(game), f.holder,
|
||||
{ type: 'semutaCommit', event: game.pendingTreacheryDiscard!.batch.event }));
|
||||
game = semutaIxPassDiscard(reload(game));
|
||||
}
|
||||
assert.throws(() => applyAction(reload(game), step.actor, step.action));
|
||||
const physical = reload(game);
|
||||
assert.throws(() => applyAction(game, f.holder, { type: 'semutaPass', event }));
|
||||
assert.deepEqual(game, physical);
|
||||
game = finishShieldCleanup(reload(game), f);
|
||||
assertCasualties(game, f, substitute);
|
||||
assert.equal(semutaIxPlayer(game, f.holder).hand.some(c => c.id === f.snooper), claim);
|
||||
assert.equal(game.discard.filter(c => c.id === f.semuta).length, Number(claim));
|
||||
assert.throws(() => applyAction(reload(game), step.actor, step.action));
|
||||
}
|
||||
});
|
||||
|
||||
void test(`Semuta ${mode} claims or passes used Tooth only after original substitution/decline and resumes shield cleanup`, () => {
|
||||
for (const substitute of [false, true]) {
|
||||
const f = semutaIxWinnerDiscardFixture(advanced, substitute), pending = f.game;
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(pending.pendingTreacheryDiscard!.continuation.kind, 'winnerMandatoryDiscard');
|
||||
assertCasualties(pending, f, substitute);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
for (const claim of [false, true]) {
|
||||
let game = claim ? applyAction(reload(pending), f.holder, { type: 'semutaCommit', event })
|
||||
: semutaIxPassDiscard(reload(pending));
|
||||
assert.deepEqual(semutaIxPhysical(game), semutaIxPhysical(pending), 'Semuta never replays support, bounty, losses or substitution.');
|
||||
assert.equal(semutaIxPlayer(game, f.holder).hand.some(c => c.id === f.tooth), claim);
|
||||
assert.equal(game.discard.filter(c => c.id === f.tooth).length, Number(!claim));
|
||||
assert.equal(game.discard.filter(c => c.id === f.snooper).length, 1);
|
||||
assert.equal(game.lastBattleContext!.winnerDiscards!.completed, true);
|
||||
game = finishShieldCleanup(reload(game), f);
|
||||
assertCasualties(game, f, substitute);
|
||||
assert.equal(semutaIxPlayer(game, f.holder).hand.some(c => c.id === f.tooth), claim);
|
||||
assert.equal(game.discard.filter(c => c.id === f.semuta).length, Number(claim));
|
||||
assert.throws(() => applyAction(reload(game), f.holder, { type: 'semutaCommit', event }));
|
||||
const step = semutaIxSubstitutionStep(f, true);
|
||||
assert.throws(() => applyAction(reload(game), step.actor, step.action));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test(`Semuta ${mode} all four original bot profiles legally participate in fresh discard and typed Ix aftermath`, () => {
|
||||
const f = semutaIxLoserDiscardFixture(advanced);
|
||||
for (const profile of DIFFICULTIES) {
|
||||
let game = reload(f.game);
|
||||
for (const p of game.players) p.bot = profile;
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
let sawIx = false;
|
||||
for (let step = 0; game.phase === 6 && step < 80; step++) {
|
||||
if (game.decision?.kind === 'ixSubstitution') sawIx = true;
|
||||
let next: Game | undefined;
|
||||
for (const p of game.players) {
|
||||
const action = botActions(viewGame(game, p.id))[0];
|
||||
if (action) { next = applyAction(reload(game), p.id, action); break; }
|
||||
}
|
||||
assert.ok(next, `${profile} must offer a legal native continuation.`);
|
||||
game = next;
|
||||
}
|
||||
assert.equal(sawIx, true);
|
||||
assert.equal(game.phase, 7);
|
||||
assert.equal(game.pendingIxSubstitution, null);
|
||||
assert.equal(game.pendingWinnerDiscards, null);
|
||||
assert.equal(semutaIxPlayer(game, f.winner).forces[f.location], 2);
|
||||
assert.equal(semutaIxPlayer(game, f.winner).tanks, 1);
|
||||
assert.equal(semutaIxPlayer(game, f.loser).tanks, 5);
|
||||
assert.deepEqual(semutaIxInventory(game), f.cards);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
semutaMoritaniRetentionFixture,
|
||||
resolveSemutaMoritaniBattle, finishSemutaMoritaniWinner, chooseSemutaMoritaniRetention,
|
||||
allowSemutaMoritaniResponse, claimSemutaMoritaniDiscard, passSemutaMoritaniDiscard,
|
||||
semutaMoritaniPlayer as player, semutaMoritaniInventory as inventory,
|
||||
semutaMoritaniResources as resources, semutaMoritaniReload as reload,
|
||||
semutaMoritaniPolicy, type SemutaMoritaniFixture,
|
||||
} from './fixture-semuta-moritani-retention';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function retentionOffer(f: SemutaMoritaniFixture) {
|
||||
const game = finishSemutaMoritaniWinner(resolveSemutaMoritaniBattle(f));
|
||||
assert.equal(game.decision?.kind, 'moritaniRetention'); assert.equal(game.decision?.player, 'l');
|
||||
assert.equal(game.moritaniRetention?.owner, 'm'); assert.equal(game.moritaniRetention?.source, undefined);
|
||||
assert.deepEqual(game.moritaniRetention!.played, [f.weapon, f.defense]);
|
||||
assert.deepEqual(game.moritaniRetention!.eligible, [f.weapon, f.defense]);
|
||||
for (const actor of ['r', 'm', 'w']) {
|
||||
const view = viewGame(reload(game), actor);
|
||||
assert.equal(view.players.find(p => p.id === 'l')!.hand, undefined);
|
||||
assert.equal(JSON.stringify(view.moritaniRetention).includes(f.unrelated), false);
|
||||
assert.deepEqual(view.moritaniRetention?.played, [f.weapon, f.defense]);
|
||||
}
|
||||
rejected(game, 'm', { type: 'decision', keep: f.weapon });
|
||||
rejected(game, 'l', { type: 'decision', keep: f.unrelated });
|
||||
rejected(game, 'l', { type: 'decision', keep: [f.weapon, f.defense] });
|
||||
return game;
|
||||
}
|
||||
function finalQuantities(game: Game, f: SemutaMoritaniFixture, before: Game, keep: string | null, claimed: string | null, karamaSpent = false) {
|
||||
assert.equal(game.phase, 7); assert.equal(game.battle, null); assert.equal(game.moritaniRetention, null);
|
||||
assert.equal(game.pendingTreacheryDiscard, null); assert.equal(game.response, null);
|
||||
assert.equal(game.decision, null);
|
||||
assert.equal(player(game, 'w').tanks, 2); assert.deepEqual(player(game, 'w').forces, { 'arrakeen:10': 3 });
|
||||
assert.equal(player(game, 'l').tanks, 5); assert.deepEqual(player(game, 'l').forces, {});
|
||||
assert.equal(player(game, 'w').reserves, 15); assert.equal(player(game, 'l').reserves, 15);
|
||||
for (const id of ['r', 'm']) { assert.equal(player(game, id).tanks, 0); assert.equal(player(game, id).reserves, 20); }
|
||||
for (const id of ['r', 'm', 'l']) assert.equal(player(game, id).spice, player(f.game, id).spice);
|
||||
assert.equal(player(game, 'w').spice, player(f.game, 'w').spice + f.loserStrength);
|
||||
assert.equal(player(game, 'l').leaders.find(l => l.id === f.loserLeader)!.dead, true);
|
||||
assert.deepEqual(resources(game), resources(before), 'Reaction must not repeat combat losses, bounty, payment or leader death.');
|
||||
for (const card of [f.weapon, f.defense]) {
|
||||
assert.equal(player(game, 'l').hand.some(c => c.id === card), card === keep);
|
||||
assert.equal(player(game, 'r').hand.some(c => c.id === card), card === claimed);
|
||||
assert.equal(game.discard.some(c => c.id === card), card !== keep && card !== claimed);
|
||||
}
|
||||
assert.equal(player(game, 'l').hand.some(c => c.id === f.unrelated), true);
|
||||
assert.equal(player(game, 'r').hand.some(c => c.id === f.semuta), claimed === null);
|
||||
assert.equal(game.discard.filter(c => c.id === f.semuta).length, Number(claimed !== null));
|
||||
if (f.karama) assert.equal(game.discard.filter(c => c.id === f.karama).length, Number(karamaSpent));
|
||||
assert.deepEqual(inventory(game), f.cards);
|
||||
}
|
||||
|
||||
|
||||
for (const choice of ['weapon', 'defense', 'none'] as const) for (const claim of [false, true]) {
|
||||
void test(`Semuta ${claim ? 'claims' : 'passes'} completed Moritani discard after keeping ${choice}, without re-offering cleanup`, () => {
|
||||
const f = semutaMoritaniRetentionFixture(), offered = retentionOffer(f);
|
||||
const keep = choice === 'none' ? null : f[choice];
|
||||
let pending = chooseSemutaMoritaniRetention(reload(offered), keep);
|
||||
pending = allowSemutaMoritaniResponse(pending);
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
const receipt = pending.pendingTreacheryDiscard!, event = receipt.batch.event;
|
||||
assert.equal(receipt.batch.cause, 'battle:moritani'); assert.equal(receipt.continuation.kind, 'battleCleanup');
|
||||
if (receipt.continuation.kind !== 'battleCleanup') throw Error('Expected original typed battle cleanup.');
|
||||
assert.equal(receipt.continuation.source, 'moritani'); assert.equal(receipt.continuation.player, 'l');
|
||||
assert.deepEqual(receipt.continuation.kept, keep ? [keep] : []);
|
||||
assert.deepEqual(receipt.continuation.played, [f.weapon, f.defense]);
|
||||
assert.equal(receipt.continuation.retention?.owner, 'm');
|
||||
assert.equal(pending.moritaniRetention, null);
|
||||
const fresh = [f.weapon, f.defense].filter(card => card !== keep);
|
||||
assert.deepEqual(receipt.batch.entries.map(e => e.card.id), fresh);
|
||||
assert.equal(receipt.batch.entries.every(e => e.publicFace && e.discardedBy === 'l'), true);
|
||||
assert.equal(fresh.every(card => pending.discard.some(c => c.id === card)), true, 'Physical discard precedes the reaction.');
|
||||
for (const id of ['r', 'm', 'w', 'l']) assert.deepEqual(viewGame(reload(pending), id).semutaReaction?.candidates, []);
|
||||
rejected(pending, 'l', { type: 'decision', keep });
|
||||
rejected(pending, 'r', { type: 'semutaSelect', event, card: fresh[0] });
|
||||
let final: Game;
|
||||
if (claim) {
|
||||
let committed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event });
|
||||
if (fresh.length === 2) {
|
||||
assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select');
|
||||
assert.deepEqual(viewGame(committed, 'r').semutaReaction!.candidates.map(c => c.id), fresh);
|
||||
for (const id of ['m', 'w', 'l']) assert.deepEqual(viewGame(committed, id).semutaReaction!.candidates, []);
|
||||
rejected(committed, 'm', { type: 'semutaSelect', event, card: fresh[0] });
|
||||
rejected(committed, 'r', { type: 'semutaSelect', event, card: f.unrelated });
|
||||
rejected(committed, 'r', { type: 'semutaPass', event });
|
||||
committed = applyAction(reload(committed), 'r', { type: 'semutaSelect', event, card: fresh[0] });
|
||||
}
|
||||
final = committed;
|
||||
} else final = passSemutaMoritaniDiscard(reload(pending));
|
||||
finalQuantities(final, f, pending, keep, claim ? fresh[0] : null);
|
||||
rejected(final, 'l', { type: 'decision', keep });
|
||||
rejected(final, 'r', { type: 'semutaCommit', event });
|
||||
rejected(final, 'r', { type: 'semutaSelect', event, card: fresh[0] });
|
||||
if (keep) {
|
||||
const committed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event });
|
||||
rejected(committed, 'r', { type: 'semutaSelect', event, card: keep });
|
||||
assert.equal(player(committed, 'l').hand.some(c => c.id === keep), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const holder of ['w', 'l'] as const) for (const cancel of [false, true]) {
|
||||
void test(`Original retention Karama response permits ${holder} to ${cancel ? 'cancel' : 'allow'} before Semuta sees the final batch`, () => {
|
||||
const f = semutaMoritaniRetentionFixture({ karamaHolder: holder }), offered = retentionOffer(f);
|
||||
const response = chooseSemutaMoritaniRetention(reload(offered), f.weapon);
|
||||
assert.equal(response.response?.kind, 'moritaniRetention'); assert.equal(response.response?.owner, 'm');
|
||||
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
|
||||
? applyAction(reload(response), holder, { type: 'card', card: f.karama!, mode: 'cancel' })
|
||||
: allowSemutaMoritaniResponse(reload(response));
|
||||
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]);
|
||||
const expected = cancel ? f.weapon : f.defense;
|
||||
const final = claimSemutaMoritaniDiscard(reload(settled), expected);
|
||||
finalQuantities(final, f, settled, cancel ? null : f.weapon, expected, cancel);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Moritani cannot cancel its own alliance power with its own unreserved Karama', () => {
|
||||
const f = semutaMoritaniRetentionFixture({ karamaHolder: 'm' }), offered = retentionOffer(f);
|
||||
const pending = chooseSemutaMoritaniRetention(offered, f.weapon);
|
||||
assert.equal(pending.response, null, 'Only the owner holds Karama: original automatic allowance applies.');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'battle:moritani');
|
||||
rejected(pending, 'm', { type: 'card', card: f.karama!, mode: 'cancel' });
|
||||
const final = claimSemutaMoritaniDiscard(pending);
|
||||
finalQuantities(final, f, pending, f.weapon, f.defense);
|
||||
assert.equal(player(final, 'm').hand.some(c => c.id === f.karama), true);
|
||||
});
|
||||
|
||||
void test('Winner optional fresh cleanup pauses with the later native retention choice still reserved', () => {
|
||||
const f = semutaMoritaniRetentionFixture();
|
||||
const pending = finishSemutaMoritaniWinner(resolveSemutaMoritaniBattle(f), [f.winnerWeapon]);
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'battle:winner');
|
||||
assert.equal(pending.moritaniRetention?.stage, 'choose');
|
||||
assert.equal(pending.moritaniRetention?.player, 'l');
|
||||
for (const claim of [false, true]) {
|
||||
const resumed = claim ? claimSemutaMoritaniDiscard(reload(pending)) : passSemutaMoritaniDiscard(reload(pending));
|
||||
assert.equal(resumed.decision?.kind, 'moritaniRetention');
|
||||
assert.deepEqual(resumed.moritaniRetention, pending.moritaniRetention);
|
||||
assert.deepEqual(resources(resumed), resources(pending));
|
||||
let final = chooseSemutaMoritaniRetention(resumed, f.weapon);
|
||||
if (claim) {
|
||||
assert.equal(final.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
final = passSemutaMoritaniDiscard(final);
|
||||
} else final = claimSemutaMoritaniDiscard(final);
|
||||
assert.equal(final.phase, 7); assert.equal(final.moritaniRetention, null);
|
||||
assert.equal(player(final, 'r').hand.some(c => c.id === (claim ? f.winnerWeapon : f.defense)), true);
|
||||
assert.deepEqual(inventory(final), f.cards); assert.deepEqual(resources(final), resources(pending));
|
||||
}
|
||||
});
|
||||
|
||||
void test('Winner compulsory Cheap Hero batch precedes optional cleanup and Moritani retention without replaying losses', () => {
|
||||
const f = semutaMoritaniRetentionFixture({ winnerMandatory: true }), pending = resolveSemutaMoritaniBattle(f);
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'battle:winnerMandatory');
|
||||
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.winnerHero]);
|
||||
assert.equal(pending.moritaniRetention?.stage, 'choose');
|
||||
for (const claim of [false, true]) {
|
||||
let resumed = claim ? claimSemutaMoritaniDiscard(reload(pending)) : passSemutaMoritaniDiscard(reload(pending));
|
||||
assert.equal(resumed.decision?.kind, 'battleCards');
|
||||
assert.deepEqual(resumed.moritaniRetention, pending.moritaniRetention);
|
||||
assert.deepEqual(resources(resumed), resources(pending));
|
||||
resumed = finishSemutaMoritaniWinner(resumed);
|
||||
assert.equal(resumed.decision?.kind, 'moritaniRetention');
|
||||
const discarded = chooseSemutaMoritaniRetention(resumed, f.weapon);
|
||||
assert.equal(discarded.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
const final = passSemutaMoritaniDiscard(discarded);
|
||||
assert.equal(final.phase, 7); assert.equal(final.moritaniRetention, null);
|
||||
assert.equal(player(final, 'r').hand.some(c => c.id === f.winnerHero), claim);
|
||||
assert.equal(final.discard.some(c => c.id === f.winnerHero), !claim);
|
||||
assert.deepEqual(resources(final), resources(pending)); assert.deepEqual(inventory(final), f.cards);
|
||||
assert.equal(player(final, 'w').tanks, 2); assert.equal(player(final, 'l').tanks, 5);
|
||||
}
|
||||
});
|
||||
|
||||
void test('All four bot profiles participate legally in native retention and sole/multiple Semuta batches', () => {
|
||||
const f = semutaMoritaniRetentionFixture(), offered = retentionOffer(f);
|
||||
for (const profile of DIFFICULTIES) {
|
||||
const choice = semutaMoritaniPolicy(reload(offered), 'l', profile)[0]; assert.ok(choice);
|
||||
const chosen = applyAction(reload(offered), 'l', choice);
|
||||
assert.equal(chosen.moritaniRetention == null || chosen.moritaniRetention.stage === 'response', true);
|
||||
for (const keep of [f.weapon, null]) {
|
||||
let pending = chooseSemutaMoritaniRetention(reload(offered), keep);
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
for (let step = 0; pending.pendingTreacheryDiscard && step < 8; step++) {
|
||||
const reaction = pending.pendingTreacheryDiscard.reaction; assert.ok(reaction);
|
||||
const actor = reaction.stage === 'select' ? reaction.player
|
||||
: pending.players.find(p => !reaction.passed.includes(p.id))!.id;
|
||||
const action = semutaMoritaniPolicy(reload(pending), actor, profile)[0]; assert.ok(action);
|
||||
pending = applyAction(reload(pending), actor, action);
|
||||
}
|
||||
assert.equal(pending.pendingTreacheryDiscard, null); assert.equal(pending.phase, 7);
|
||||
assert.deepEqual(inventory(pending), f.cards);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('Multiple discarded Moritani cards permit either exact fresh selection, never the unrelated hand card', () => {
|
||||
const f = semutaMoritaniRetentionFixture(), pending = chooseSemutaMoritaniRetention(retentionOffer(f), null);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
for (const card of [f.weapon, f.defense]) {
|
||||
const committed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event });
|
||||
assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select');
|
||||
assert.deepEqual(resources(committed), resources(pending));
|
||||
const final = applyAction(reload(committed), 'r', { type: 'semutaSelect', event, card });
|
||||
finalQuantities(final, f, pending, null, card);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Defeated ally claiming the winner card cannot add it to its original retention pool', () => {
|
||||
const f = semutaMoritaniRetentionFixture({ semutaHolder: 'l' });
|
||||
const pending = finishSemutaMoritaniWinner(resolveSemutaMoritaniBattle(f), [f.winnerWeapon]);
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
const resumed = applyAction(reload(pending), 'l', { type: 'semutaCommit', event });
|
||||
assert.equal(resumed.decision?.kind, 'moritaniRetention');
|
||||
assert.deepEqual(resumed.moritaniRetention!.played, [f.weapon, f.defense]);
|
||||
assert.deepEqual(resumed.moritaniRetention!.eligible, [f.weapon, f.defense]);
|
||||
assert.equal(player(resumed, 'l').hand.some(c => c.id === f.winnerWeapon), true);
|
||||
assert.equal(resumed.discard.filter(c => c.id === f.semuta).length, 1);
|
||||
assert.deepEqual(resources(resumed), resources(pending));
|
||||
rejected(resumed, 'l', { type: 'decision', keep: f.winnerWeapon });
|
||||
const discarded = chooseSemutaMoritaniRetention(reload(resumed), f.weapon);
|
||||
assert.deepEqual(discarded.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.defense]);
|
||||
const final = passSemutaMoritaniDiscard(discarded);
|
||||
assert.equal(final.phase, 7); assert.equal(final.moritaniRetention, null);
|
||||
assert.deepEqual(player(final, 'l').hand.map(c => c.id).sort(), [f.unrelated, f.weapon, f.winnerWeapon].sort());
|
||||
assert.equal(final.discard.filter(c => c.id === f.semuta).length, 1);
|
||||
assert.equal(final.discard.filter(c => c.id === f.defense).length, 1);
|
||||
assert.deepEqual(inventory(final), f.cards); assert.deepEqual(resources(final), resources(pending));
|
||||
rejected(final, 'l', { type: 'decision', keep: f.weapon });
|
||||
rejected(final, 'l', { type: 'semutaCommit', event });
|
||||
});
|
||||
Reference in new issue
Block a user