178 lines
8.8 KiB
TypeScript
178 lines
8.8 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
|
|
viewGame, type Action, type Game,
|
|
} from '../game/engine';
|
|
import type { NoFieldValue, RicheseNoField } from '../game/richese-no-field';
|
|
import { defaultHarassWithdrawAllocation } from '../game/harass-withdraw';
|
|
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
|
import { holdOccupyCardsTraitor } from './fixture-ecaz-occupy-cards';
|
|
import {
|
|
ecazOccupyRicheseFixture, openOccupyRichese, finishOccupyRichese,
|
|
occupyRicheseSeat, type EcazOccupyRicheseFixture,
|
|
} from './fixture-ecaz-occupy-richese';
|
|
|
|
export const NO_FIELD_REINFORCEMENTS = 'ecaz-reinforcements';
|
|
export const NO_FIELD_HARASS = 'ecaz-harass-withdraw';
|
|
export type NoFieldReinforcementsOptions = {
|
|
initial?: Game;
|
|
marker?: NoFieldValue;
|
|
richeseReserves?: number;
|
|
lead?: 'ecaz' | 'ally';
|
|
ecazReserves?: number;
|
|
opposingTraitor?: boolean;
|
|
omitPreview?: boolean;
|
|
};
|
|
export type NoFieldReinforcementsFixture = {
|
|
source: EcazOccupyRicheseFixture;
|
|
initial: Game;
|
|
afterSetup: Game;
|
|
game: Game;
|
|
owner: string;
|
|
ecaz: string;
|
|
ally: string;
|
|
richese: string;
|
|
opponent: string;
|
|
territory: string;
|
|
location: string;
|
|
marker: NoFieldValue;
|
|
noField: RicheseNoField;
|
|
materializes: number;
|
|
reservesBefore: Record<string, number>;
|
|
fixedEcazDial: number;
|
|
variableDial: number;
|
|
opposingTraitor: boolean;
|
|
plans: { both: Action; reinforcements: Action; harass: Action; plain: Action; opponent: Action };
|
|
staging: string[];
|
|
};
|
|
|
|
/** Select the independent physical cards BEFORE the original admitted deal. */
|
|
export function createNoFieldReinforcementsSetup(): Game {
|
|
const roster = ['ecaz', 'richese', 'guild', 'choam'] as const;
|
|
let game = createGame('NOFIELDREINFORCEMENTS', newPlayer(roster[0], roster[0], roster[0]),
|
|
true, ['ecaz', 'choam']);
|
|
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
|
for (let index = roster.length - 1; index >= 0; index--) {
|
|
if (viewGame(game, roster[index]).playerPositions[roster[index]] !== index + 1)
|
|
game = applyAction(game, roster[index], { type: 'seatPosition', position: index + 1 });
|
|
}
|
|
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
|
game.ecazTreachery = true;
|
|
// Only the original initializer is deterministic. Native setup decisions,
|
|
// shipment, marker identities and subsequent phase windows remain real.
|
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
|
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
|
assert.ok(array);
|
|
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
|
|
return array;
|
|
};
|
|
try { return initializeEcazOccupyGameForAudit(game); }
|
|
finally {
|
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
|
}
|
|
}
|
|
|
|
/** Before either private plan: actual original setup, Nexus alliance, No-Field
|
|
* shipment, Occupy lead and native preleader readiness. No marker or window is
|
|
* assigned, cleared, removed/recreated or replaced with a synthetic receipt. */
|
|
export function noFieldReinforcementsFixture(options: NoFieldReinforcementsOptions = {}): NoFieldReinforcementsFixture {
|
|
const marker = options.marker ?? 3;
|
|
const source = ecazOccupyRicheseFixture({
|
|
initial: options.initial ?? createNoFieldReinforcementsSetup(),
|
|
allyFaction: 'richese', opponentFaction: 'guild', marker,
|
|
richeseReserves: options.richeseReserves ?? 6, omitPreview: options.omitPreview,
|
|
});
|
|
assert.equal(source.initial.ecazTreachery, true);
|
|
const owner = options.lead === 'ecaz' ? source.ecaz : source.richese;
|
|
// Return every unselected real face to the conserved remaining deck first;
|
|
// two staged utility cards never leave an over-limit native hand behind.
|
|
for (const player of source.game.players) {
|
|
source.game.deck.push(...player.hand);
|
|
player.hand = [];
|
|
}
|
|
source.staging.push('After native shipment/admission: return unselected original hands to the remaining deck, then stage the two original utility faces in the actual lead hand.');
|
|
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_REINFORCEMENTS);
|
|
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_HARASS);
|
|
if (options.ecazReserves !== undefined) {
|
|
const ecaz = occupyRicheseSeat(source.game, source.ecaz);
|
|
assert.ok(Number.isSafeInteger(options.ecazReserves) && options.ecazReserves >= 0 && options.ecazReserves <= ecaz.reserves);
|
|
const staged = ecaz.reserves - options.ecazReserves;
|
|
if (staged) ecaz.forces['polar_sink:0'] = (ecaz.forces['polar_sink:0'] ?? 0) + staged;
|
|
ecaz.reserves -= staged;
|
|
source.staging.push(`Conserve Ecaz counters: retain ${options.ecazReserves} own reserves and move ${staged} unused own reserves to Polar Sink.`);
|
|
}
|
|
const game = openOccupyRichese(source, options.lead ?? 'ally');
|
|
const profile = viewGame(game, owner).battle?.ecazOccupy?.profile;
|
|
assert.ok(profile);
|
|
const noField = structuredClone(occupyRicheseSeat(game, source.richese).noField!);
|
|
assert.ok(noField.deployed);
|
|
assert.equal(noField.deployed.tokenId, noField.lastShipped);
|
|
assert.deepEqual(noField.tokens, occupyRicheseSeat(source.afterSetup, source.richese).noField!.tokens);
|
|
const physicalTraitors = new Set([...(game.traitorReserve ?? []), ...game.players.flatMap(player => player.traitors)]);
|
|
const actorLeader = occupyRicheseSeat(game, owner).leaders
|
|
.filter(leader => !leader.dead && !leader.usedAt && (!options.opposingTraitor || physicalTraitors.has(leader.id)))
|
|
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
|
|
const enemyLeader = occupyRicheseSeat(game, source.opponent).leaders
|
|
.filter(leader => !leader.dead && !leader.usedAt)
|
|
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
|
|
assert.ok(actorLeader && enemyLeader);
|
|
if (options.opposingTraitor) {
|
|
holdOccupyCardsTraitor(game, source.opponent, actorLeader.id);
|
|
source.staging.push(`Conserved original Traitor Card exchange gives ${source.opponent} the selected real leader ${actorLeader.id}.`);
|
|
}
|
|
const variableDial = marker === 0 ? 0 : 1;
|
|
const plain: Action = { type: 'battlePlan', dial: profile.fixedEcazDial + variableDial,
|
|
support: variableDial, leader: actorLeader.id };
|
|
return {
|
|
source, initial: source.initial, afterSetup: source.afterSetup, game, owner,
|
|
ecaz: source.ecaz, ally: source.ally, richese: source.richese, opponent: source.opponent,
|
|
territory: source.territory, location: source.location, marker, noField,
|
|
materializes: Math.min(marker, occupyRicheseSeat(game, source.richese).reserves),
|
|
reservesBefore: Object.fromEntries(game.players.map(player => [player.id, player.reserves])),
|
|
fixedEcazDial: profile.fixedEcazDial, variableDial,
|
|
opposingTraitor: options.opposingTraitor ?? false,
|
|
plans: {
|
|
plain,
|
|
both: { ...plain, weapon: NO_FIELD_REINFORCEMENTS, defense: NO_FIELD_HARASS },
|
|
reinforcements: { ...plain, weapon: NO_FIELD_REINFORCEMENTS },
|
|
harass: { ...plain, defense: NO_FIELD_HARASS },
|
|
opponent: { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader.id },
|
|
},
|
|
staging: source.staging,
|
|
};
|
|
}
|
|
|
|
/** Actual second sealed plan invokes the original native marker revelation. */
|
|
export function sealNoFieldReinforcements(fixture: NoFieldReinforcementsFixture,
|
|
plan: 'both' | 'reinforcements' | 'harass' | 'plain' = 'both', state = fixture.game): Game {
|
|
let game = applyAction(state, fixture.owner, fixture.plans[plan]);
|
|
game = applyAction(game, fixture.opponent, fixture.plans.opponent);
|
|
return game;
|
|
}
|
|
|
|
/** Revealed physical allocation, native Traitor votes, then the shared native
|
|
* Occupy aftermath driver. Returns both the after-vote and completed controls. */
|
|
export function finishNoFieldReinforcements(fixture: NoFieldReinforcementsFixture, state: Game): {
|
|
afterVotes: Game; game: Game;
|
|
} {
|
|
let game = state;
|
|
if (game.decision?.kind === 'harassWithdraw') {
|
|
const decision = game.decision;
|
|
const allocation = viewGame(game, decision.player).battle?.harassAllocation;
|
|
assert.ok(allocation);
|
|
game = applyAction(game, decision.player, { type: 'decision', event: decision.event,
|
|
returns: defaultHarassWithdrawAllocation(allocation.context, allocation.dial, allocation.support) });
|
|
}
|
|
for (let attempt = 0; attempt < 10 && game.battle; attempt++) {
|
|
const battle = viewGame(game, fixture.owner).battle;
|
|
assert.ok(battle);
|
|
const voter = battle.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
|
|
if (!voter) break;
|
|
game = applyAction(game, voter, { type: 'traitorCall',
|
|
call: fixture.opposingTraitor && voter === fixture.opponent });
|
|
}
|
|
const afterVotes = game;
|
|
return { afterVotes, game: finishOccupyRichese(game) };
|
|
}
|