157 lines
7.9 KiB
TypeScript
157 lines
7.9 KiB
TypeScript
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, skills = 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, skills);
|
|
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, skills = false): SemutaIxFixture {
|
|
const game = initializeSemutaIxGame(advanced, skills);
|
|
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 };
|
|
}
|