Preserve native Ixian casualties and Moritani retention across Semuta

This commit is contained in:
Quintine committed 2026-10-12 06:13:47 +11:00
1 parent a6869b0166
commit 74768647f4
14 files changed
+879 -9

No files matched your search

+156
View File
@@ -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 };
}
+148
View File
@@ -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;
}
+7 -2
View File
@@ -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]);
+166
View File
@@ -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);
}
});
}
+243
View File
@@ -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 });
});