Offer Semuta on printed native battle cancellation costs

This commit is contained in:
Quintine committed 2026-10-12 06:57:15 +11:00
1 parent 74768647f4
commit bab8f3403d
14 files changed
+818 -14

No files matched your search

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