232 lines
12 KiB
TypeScript
232 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, viewGame, type Game, type Player } from '../game/engine';
|
|
import type { EcazTreacheryCardId } from '../game/ecaz-cards';
|
|
import { defaultHarassWithdrawAllocation, type HarassWithdrawForces, type HarassWithdrawSelection } from '../game/harass-withdraw';
|
|
import {
|
|
ecazOccupyFixture, chooseEcazOccupyLead, cancelEcazOccupy, openEcazOccupyPlans,
|
|
allowEcazOccupyResponses, stageEcazOccupyCard, type EcazOccupyFixture,
|
|
type EcazOccupyFixtureOptions,
|
|
} from './fixture-ecaz-occupy';
|
|
|
|
export const occupyCardsPlayer = (game: Game, id: string): Player => {
|
|
const player = game.players.find(p => p.id === id);
|
|
assert.ok(player, 'The original roster must contain the physical owner.');
|
|
return player;
|
|
};
|
|
|
|
function stageCard(game: Game, owner: string, predicate: (card: Player['hand'][number]) => boolean): string {
|
|
const id = stageEcazOccupyCard(game, owner, predicate);
|
|
const hand = occupyCardsPlayer(game, owner).hand;
|
|
// Keep the selected physical face when the second conserved slot is staged.
|
|
const index = hand.findIndex(card => card.id === id);
|
|
hand.push(hand.splice(index, 1)[0]);
|
|
return id;
|
|
}
|
|
|
|
/** Exchange one original held Traitor Card for a physical reserve/held identity. */
|
|
export function holdOccupyCardsTraitor(game: Game, owner: string, identity: string): void {
|
|
const player = occupyCardsPlayer(game, owner);
|
|
if (player.traitors.includes(identity)) return;
|
|
assert.ok(player.traitors[0], 'An original Traitor Card must fund this exchange.');
|
|
const reserve = game.traitorReserve;
|
|
const index = reserve?.indexOf(identity) ?? -1;
|
|
if (reserve && index >= 0) {
|
|
reserve[index] = player.traitors[0];
|
|
player.traitors[0] = identity;
|
|
} else {
|
|
const donor = game.players.find(p => p.traitors.includes(identity));
|
|
assert.ok(donor, 'A real non-loyal Traitor Card must exist in the original source.');
|
|
const donorIndex = donor.traitors.indexOf(identity);
|
|
donor.traitors[donorIndex] = player.traitors[0];
|
|
player.traitors[0] = identity;
|
|
}
|
|
}
|
|
|
|
function leader(game: Game, owner: string, strongest: boolean, traitorable = false): string {
|
|
const physical = new Set([...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors)]);
|
|
const available = occupyCardsPlayer(game, owner).leaders.filter(l =>
|
|
!l.dead && !l.usedAt && (!traitorable || physical.has(l.id)));
|
|
available.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
|
|
assert.ok(available[0], 'Use an available original physical leader.');
|
|
return available[0].id;
|
|
}
|
|
|
|
export type OccupyCardsOutcome = 'win' | 'loss' | 'soleTraitor' | 'opposingTraitor' |
|
|
'mutualTraitors' | 'explosion';
|
|
export type OccupyCardsOptions = EcazOccupyFixtureOptions & {
|
|
lead?: 'ecaz' | 'ally';
|
|
holder?: 'lead' | 'opponent';
|
|
card?: Extract<EcazTreacheryCardId, 'ecaz-reinforcements' | 'ecaz-harass-withdraw'>;
|
|
opponentCard?: Extract<EcazTreacheryCardId, 'ecaz-reinforcements'>;
|
|
slot?: 'weapon' | 'defense';
|
|
outcome?: OccupyCardsOutcome;
|
|
canceled?: boolean;
|
|
variableDial?: number;
|
|
support?: number;
|
|
opponentDial?: number;
|
|
opponentSupport?: number;
|
|
/** Find real equal-strength leaders for a decisive +2 score comparison. */
|
|
equalLeaders?: boolean;
|
|
actorLeader?: string;
|
|
returns?: HarassWithdrawSelection;
|
|
/** One native battle location, selected only after the fixture chooses its storm-safe sector. */
|
|
returnedForces?: HarassWithdrawForces;
|
|
losses?: { normal: number; elite: number };
|
|
substitute?: number;
|
|
funding?: number;
|
|
cleanupShield?: boolean;
|
|
/** Return at the actual Face Dance offer, after ordinary cleanup. */
|
|
stopAtFaceDance?: boolean;
|
|
};
|
|
export type OccupyCardsCase = {
|
|
fixture: EcazOccupyFixture;
|
|
actor: string;
|
|
holder: string;
|
|
card: string;
|
|
shield?: string;
|
|
actorLeader: string;
|
|
opponentLeader: string;
|
|
before: Game;
|
|
revealed: Game;
|
|
afterVotes: Game;
|
|
game: Game;
|
|
};
|
|
|
|
/** Consume native aftermath offers only; never manufacture a phase or receipt. */
|
|
export function finishOccupyCardsAftermath(state: Game, options: Pick<OccupyCardsOptions,
|
|
'losses' | 'substitute' | 'stopAtFaceDance'> = {}, discardCard?: string): Game {
|
|
let game = state;
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
const decision = game.decision;
|
|
if (game.response) game = allowEcazOccupyResponses(game);
|
|
else if (decision?.kind === 'battleLosses') {
|
|
const choice = options.losses ? decision.options.findIndex(loss =>
|
|
loss.normal === options.losses!.normal && loss.elite === options.losses!.elite) : 0;
|
|
assert.ok(choice >= 0, 'The original typed commitment must offer this physical loss allocation.');
|
|
game = applyAction(game, decision.player, { type: 'decision', choice });
|
|
} else if (decision?.kind === 'ixSubstitution') {
|
|
const count = options.substitute ?? 0;
|
|
const key = Object.keys(decision.losses).find(location => decision.losses[location] >= count);
|
|
if (count) {
|
|
assert.ok(key, 'A real lost Cyborg sector must fund substitution.');
|
|
game = applyAction(game, decision.player, { type: 'decision',
|
|
sources: { [key]: count }, recover: { [key]: count } });
|
|
} else game = applyAction(game, decision.player, { type: 'decision', decline: true });
|
|
} else if (decision?.kind === 'battleCards') {
|
|
const discard = discardCard && decision.cards.includes(discardCard) ? [discardCard] : [];
|
|
game = applyAction(game, decision.player, { type: 'decision', discard });
|
|
} else if (decision?.kind === 'choamMarket') {
|
|
game = applyAction(game, decision.player, { type: 'decision', done: true });
|
|
} else if (game.pendingTreacheryDiscard) {
|
|
game = applyAction(game, game.players[0].id, { type: 'advanceBots' });
|
|
} else if (decision?.kind === 'faceDance' && options.stopAtFaceDance) return game;
|
|
else {
|
|
assert.equal(game.battle, null, 'The original sealed plans and votes must resolve.');
|
|
assert.equal(game.decision, null, 'Every required native aftermath offer must finish.');
|
|
return game;
|
|
}
|
|
}
|
|
throw new Error('The original card battle aftermath did not finish.');
|
|
}
|
|
|
|
/** Original family deal, real Nexus alliance, shared entry and actual lead choice.
|
|
* Only conserved physical hand/Traitor Card exchanges expose each printed rule. */
|
|
export function ecazOccupyCardsCase(options: OccupyCardsOptions = {}): OccupyCardsCase {
|
|
const lead = options.lead ?? 'ecaz';
|
|
const outcome = options.outcome ?? 'win';
|
|
const slot = options.slot ?? 'weapon';
|
|
const cardId = options.card ?? 'ecaz-harass-withdraw';
|
|
const fixture = ecazOccupyFixture({ ...options,
|
|
ecazTreachery: options.initial ? options.initial.ecazTreachery === true : true,
|
|
ecazForces: options.ecazForces ?? 5, allyFaction: options.allyFaction ?? 'guild',
|
|
opponentFaction: options.opponentFaction ?? 'emperor' });
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const holder = options.holder === 'opponent' ? fixture.opponent : actor;
|
|
assert.equal(fixture.initial.ecazTreachery, true, 'This case requires an originally selected physical card variant.');
|
|
const card = stageCard(fixture.game, holder, c => c.id === cardId);
|
|
let game = options.canceled ? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead);
|
|
game = allowEcazOccupyResponses(game);
|
|
if (game.decision?.kind === 'choamBattleFunding')
|
|
game = applyAction(game, game.decision.player, { type: 'decision', amount: options.funding ?? 0 });
|
|
else assert.equal(options.funding ?? 0, 0, 'Funding requires the genuine CHOAM offer.');
|
|
game = openEcazOccupyPlans(game);
|
|
const profile = viewGame(game, actor).battle?.ecazOccupy?.profile;
|
|
assert.ok(profile);
|
|
let actorLeader = options.actorLeader ?? leader(game, actor, outcome !== 'loss',
|
|
outcome === 'opposingTraitor' || outcome === 'mutualTraitors');
|
|
let opponentLeader = leader(game, fixture.opponent, outcome === 'loss',
|
|
outcome === 'soleTraitor' || outcome === 'mutualTraitors');
|
|
if (options.stopAtFaceDance && !options.actorLeader) {
|
|
const dancers = game.players.find(p => p.faction === 'tleilaxu')?.faceDancers;
|
|
const candidate = occupyCardsPlayer(game, actor).leaders.find(l => !l.dead && !l.usedAt &&
|
|
dancers?.some(c => !c.revealed && c.leader === l.id));
|
|
assert.ok(candidate, 'Use the original first-draw Face Dancer of the real selected leader.');
|
|
actorLeader = candidate.id;
|
|
}
|
|
if (options.equalLeaders) {
|
|
const own = occupyCardsPlayer(game, actor).leaders.find(l => !l.dead && !l.usedAt &&
|
|
occupyCardsPlayer(game, fixture.opponent).leaders.some(other =>
|
|
!other.dead && !other.usedAt && other.strength === l.strength));
|
|
assert.ok(own, 'The actual faction rosters must supply equal-strength leaders.');
|
|
actorLeader = own.id;
|
|
opponentLeader = occupyCardsPlayer(game, fixture.opponent).leaders.find(l =>
|
|
!l.dead && !l.usedAt && l.strength === own.strength)!.id;
|
|
}
|
|
const voter = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
|
if (outcome === 'soleTraitor' || outcome === 'mutualTraitors')
|
|
holdOccupyCardsTraitor(game, voter, opponentLeader);
|
|
if (outcome === 'opposingTraitor' || outcome === 'mutualTraitors')
|
|
holdOccupyCardsTraitor(game, fixture.opponent, actorLeader);
|
|
const ownCards: { weapon?: string; defense?: string } = {};
|
|
const enemyCards: { weapon?: string; defense?: string } = {};
|
|
(holder === actor ? ownCards : enemyCards)[slot] = card;
|
|
if (options.opponentCard)
|
|
enemyCards.weapon = stageCard(game, fixture.opponent, c => c.id === options.opponentCard);
|
|
let shield: string | undefined;
|
|
if (options.cleanupShield) {
|
|
assert.equal(slot, 'weapon', 'The actual shield occupies the other legal slot.');
|
|
shield = stageCard(game, actor, c => c.kind === 'shield');
|
|
ownCards.defense = shield;
|
|
}
|
|
if (outcome === 'explosion') {
|
|
const lasgunOwner = slot === 'defense' ? holder : holder === actor ? fixture.opponent : actor;
|
|
const shieldOwner = lasgunOwner === actor ? fixture.opponent : actor;
|
|
(lasgunOwner === actor ? ownCards : enemyCards).weapon =
|
|
stageCard(game, lasgunOwner, c => c.kind === 'lasgun');
|
|
(shieldOwner === actor ? ownCards : enemyCards).defense =
|
|
stageCard(game, shieldOwner, c => c.kind === 'shield');
|
|
}
|
|
const variable = options.variableDial ?? 2;
|
|
const free = occupyCardsPlayer(game, profile.forceOwner).faction === 'fremen';
|
|
const support = options.support ?? (free ? 0 : variable);
|
|
const opponentDial = options.opponentDial ?? (outcome === 'loss' ? 8 : 0);
|
|
const before = structuredClone(game);
|
|
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + variable,
|
|
support, allyPayment: options.funding ?? 0, leader: actorLeader, ...ownCards });
|
|
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: opponentDial,
|
|
support: options.opponentSupport ?? opponentDial, leader: opponentLeader, ...enemyCards });
|
|
const revealed = structuredClone(game);
|
|
if (game.decision?.kind === 'harassWithdraw') {
|
|
const decision = game.decision;
|
|
const offer = viewGame(game, decision.player).battle?.harassAllocation;
|
|
assert.ok(offer, 'Use only the actual revealed physical allocation offer.');
|
|
const returns = options.returns ?? (options.returnedForces ?
|
|
{ [fixture.location]: options.returnedForces } :
|
|
defaultHarassWithdrawAllocation(offer.context, offer.dial, offer.support));
|
|
game = applyAction(game, decision.player, { type: 'decision', event: decision.event, returns });
|
|
}
|
|
for (let attempt = 0; attempt < 10 && game.battle; attempt++) {
|
|
const battle = viewGame(game, actor).battle;
|
|
assert.ok(battle);
|
|
const next = battle.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
|
|
if (!next) break;
|
|
const call = next === voter && (outcome === 'soleTraitor' || outcome === 'mutualTraitors') ||
|
|
next === fixture.opponent && (outcome === 'opposingTraitor' || outcome === 'mutualTraitors');
|
|
game = applyAction(game, next, { type: 'traitorCall', call });
|
|
}
|
|
const afterVotes = structuredClone(game);
|
|
game = finishOccupyCardsAftermath(game, options, shield);
|
|
return { fixture, actor, holder, card, shield, actorLeader, opponentLeader,
|
|
before, revealed, afterVotes, game };
|
|
}
|