138 lines
7.6 KiB
TypeScript
138 lines
7.6 KiB
TypeScript
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';
|
|
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
|
|
|
|
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, skills = false): SemutaNativeKaramaFixture {
|
|
const ix = semutaIxBattleFixture(advanced, skills);
|
|
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 if (game.decision?.kind === 'leaderSkillVisibility')
|
|
step = { actor: game.decision.player, action: { type: 'leaderSkillVisibility', event: game.decision.event, hide: true } };
|
|
else if (game.decision?.kind === 'mentatQuestion')
|
|
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, 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);
|
|
game = finishSemutaBattleOutcome(game, 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;
|
|
}
|