Compose concealed No-Field battle cards before reserve costs
This commit is contained in:
1 parent
f50dda6078
commit
a58b8bc92c
18 files changed
+1064
-45
No files matched your search
@@ -0,0 +1,241 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { NoFieldValue } from '../game/richese-no-field';
|
||||
import { finishHarassPreparation, takeHarassCard } from './fixture-harass-withdraw';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type HarassNoFieldStep = { actor: string; action: Action };
|
||||
export type HarassNoFieldOptions = {
|
||||
advanced?: boolean;
|
||||
marker?: NoFieldValue;
|
||||
/** Physical own reserves immediately before the actual marker shipment. */
|
||||
reserves?: number;
|
||||
ordinaryAtMarker?: number;
|
||||
ordinaryOtherSector?: number;
|
||||
slot?: 'weapon' | 'defense';
|
||||
dial?: number;
|
||||
support?: number;
|
||||
traitor?: 'owner' | 'opponent' | 'both';
|
||||
seatIds?: [string, string, string];
|
||||
/** Continue the original fresh independent-variant initializer, without redealing. */
|
||||
initial?: Game;
|
||||
};
|
||||
export type HarassNoFieldFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeShipment: Game;
|
||||
shipmentStep: HarassNoFieldStep;
|
||||
afterShipment: Game;
|
||||
beforeBattle: Game;
|
||||
battleStep: HarassNoFieldStep;
|
||||
/** Open original battle, all native preparation completed, neither plan sealed. */
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
observer: string;
|
||||
territory: string;
|
||||
location: string;
|
||||
otherLocation: string;
|
||||
token: { id: string; value: NoFieldValue };
|
||||
materializes: number;
|
||||
planActions: [HarassNoFieldStep, HarassNoFieldStep];
|
||||
nativeSteps: HarassNoFieldStep[];
|
||||
staging: string[];
|
||||
};
|
||||
export function harassNoFieldPlayer(game: Game, actor: string) {
|
||||
const player = game.players.find(p => p.id === actor);
|
||||
assert.ok(player, `Missing original seat ${actor}`);
|
||||
return player;
|
||||
}
|
||||
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening;
|
||||
|
||||
function initialize(game: Game): Game {
|
||||
// Only the original initializer is deterministic; live token IDs/events remain native.
|
||||
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 initializeEcazTreacheryGameForAudit(game); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
function place(game: Game, actor: string, location: string, amount: number) {
|
||||
const player = harassNoFieldPlayer(game, actor);
|
||||
assert.ok(Number.isSafeInteger(amount) && amount >= 0 && amount <= player.reserves);
|
||||
player.reserves -= amount;
|
||||
if (amount) player.forces[location] = (player.forces[location] ?? 0) + amount;
|
||||
}
|
||||
function giveTraitor(game: Game, actor: string, identity: string, staging: string[]) {
|
||||
const player = harassNoFieldPlayer(game, actor);
|
||||
if (player.traitors.includes(identity)) return;
|
||||
const returned = player.traitors[0];
|
||||
assert.ok(returned);
|
||||
const at = game.traitorReserve?.indexOf(identity) ?? -1;
|
||||
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
||||
assert.ok(at >= 0 || donor, 'Exchange an original physical traitor, never fabricate one');
|
||||
if (at >= 0) game.traitorReserve!.splice(at, 1, returned);
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
||||
player.traitors[0] = identity;
|
||||
staging.push(`Conserved original traitor ${identity} exchanged for ${returned} into ${actor}'s hand.`);
|
||||
}
|
||||
|
||||
/** Genuine Basic/Advanced ordinary Richese/classic setup: CHOAM's selected
|
||||
* thirty-five-card deck plus the three independent cards, with the original
|
||||
* separate Richese cache. Only labeled physical board/card/bank staging follows
|
||||
* setup. No status, phase, marker, token, response or sealed plan is fabricated. */
|
||||
export function createHarassNoFieldFixture(options: HarassNoFieldOptions = {}): HarassNoFieldFixture {
|
||||
let game: Game;
|
||||
if (options.initial) {
|
||||
game = structuredClone(options.initial);
|
||||
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => p.hand.length === 0));
|
||||
assert.equal(game.advanced, options.advanced ?? game.advanced);
|
||||
assert.deepEqual(game.expansions, ['choam']);
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'guild', 'richese']);
|
||||
if (game.status === 'setup') assert.equal(game.ecazTreachery, true);
|
||||
} else {
|
||||
const ids = options.seatIds ?? ['richese', 'guild', 'atreides'];
|
||||
game = createGame('HARASSNOFIELD', newPlayer(ids[0], 'Richese', 'richese'), options.advanced ?? false, ['choam']);
|
||||
joinGame(game, newPlayer(ids[1], 'Guild', 'guild'));
|
||||
joinGame(game, newPlayer(ids[2], 'Atreides', 'atreides'));
|
||||
}
|
||||
if (game.status === 'lobby') {
|
||||
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = initialize(game);
|
||||
}
|
||||
const initial = structuredClone(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 200; count++) {
|
||||
let step: HarassNoFieldStep | undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(p => p.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) { step = { actor: player.id, action }; break; }
|
||||
}
|
||||
assert.ok(step, `Original setup stalled at ${game.setupStage}`);
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
const owner = game.players.find(p => p.faction === 'richese')!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
||||
const observer = game.players.find(p => p.faction === 'atreides')!.id;
|
||||
const territory = 'imperial_basin', sector = 9;
|
||||
const location = `${territory}:${sector}`, otherLocation = `${territory}:10`;
|
||||
const staging: string[] = [];
|
||||
const nativeSteps: HarassNoFieldStep[] = [];
|
||||
const act = (step: HarassNoFieldStep) => {
|
||||
nativeSteps.push(structuredClone(step));
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
};
|
||||
const advance = (predicate: (state: Game) => boolean) => {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (predicate(game)) return;
|
||||
const step = nextRicheseStrongholdsNativeStep(game);
|
||||
assert.ok(step, 'Native continuation cannot invent an unsealed battle plan');
|
||||
if (game.turn === 1 && step.action.type === 'stormDial') step.action.amount = 0;
|
||||
act(step);
|
||||
}
|
||||
throw Error('Native Harass/No-Field continuation did not reach its boundary');
|
||||
};
|
||||
// Clear original starting positions before native phases, so no incidental battle is introduced.
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
|
||||
player.forces = {};
|
||||
player.spice = 30;
|
||||
}
|
||||
staging.push('After actual faction setup: return each original board counter to its own reserves and stage thirty-spice bank balances.');
|
||||
advance(g => g.phase === 5 && g.active === owner && clean(g));
|
||||
const mixed = (options.ordinaryAtMarker ?? 0) + (options.ordinaryOtherSector ?? 0);
|
||||
const reserves = options.reserves ?? 20 - mixed;
|
||||
assert.ok(Number.isSafeInteger(reserves) && reserves >= 0 && reserves + mixed <= 20);
|
||||
place(game, owner, location, options.ordinaryAtMarker ?? 0);
|
||||
place(game, owner, otherLocation, options.ordinaryOtherSector ?? 0);
|
||||
place(game, owner, 'polar_sink:0', 20 - reserves - mixed);
|
||||
place(game, opponent, location, 5);
|
||||
staging.push(`Before actual shipment: conserve ${mixed} ordinary Richese battle counters, ${reserves} Richese reserves, the remainder in Polar Sink, and five opposing ordinary counters at ${location}.`);
|
||||
const beforeShipment = structuredClone(game);
|
||||
const token = structuredClone(harassNoFieldPlayer(game, owner).noField!.tokens.find(t => t.value === (options.marker ?? 3))!);
|
||||
assert.ok(token);
|
||||
const shipmentStep: HarassNoFieldStep = { actor: owner, action: {
|
||||
type: 'ship', territory, sector, noField: token.id,
|
||||
event: harassNoFieldPlayer(game, owner).noFieldEvent,
|
||||
} };
|
||||
act(shipmentStep);
|
||||
advance(clean);
|
||||
const afterShipment = structuredClone(game);
|
||||
advance(g => g.phase === 6 && clean(g));
|
||||
// Return unselected real hands before selecting H, keeping every hand below its printed limit.
|
||||
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
|
||||
takeHarassCard(game, owner, 'ecaz-harass-withdraw');
|
||||
staging.push('Before native battle admission: return every unselected original hand to the remaining deck, then move the one physical Harass & Withdraw into the Richese hand. Original cache custody remains untouched.');
|
||||
const ownLeader = harassNoFieldPlayer(game, owner).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
|
||||
const otherLeader = harassNoFieldPlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
|
||||
assert.ok(ownLeader && otherLeader);
|
||||
if (options.traitor === 'owner' || options.traitor === 'both') giveTraitor(game, owner, otherLeader.id, staging);
|
||||
if (options.traitor === 'opponent' || options.traitor === 'both') giveTraitor(game, opponent, ownLeader.id, staging);
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === territory);
|
||||
assert.ok(choice, 'Even a concealed zero must enter an actual ordinary battle');
|
||||
const battleStep: HarassNoFieldStep = { actor: choice.chooser, action: {
|
||||
type: 'chooseBattle', territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
|
||||
} };
|
||||
act(battleStep);
|
||||
for (let count = 0; count < 120; count++) {
|
||||
const step = nextRicheseStrongholdsNativeStep(game);
|
||||
if (!step) break;
|
||||
act(step);
|
||||
}
|
||||
assert.ok(game.battle && !game.battle.revealed && clean(game));
|
||||
assert.equal(Object.keys(game.battle.plans).length, 0);
|
||||
const materializes = Math.min(token.value, reserves);
|
||||
const dial = options.dial ?? Math.min(2, mixed + materializes);
|
||||
const support = options.support ?? (game.advanced ? dial : 0);
|
||||
const slot = options.slot ?? 'defense';
|
||||
const planActions: [HarassNoFieldStep, HarassNoFieldStep] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', dial, support, leader: ownLeader.id,
|
||||
weapon: slot === 'weapon' ? 'ecaz-harass-withdraw' : null,
|
||||
defense: slot === 'defense' ? 'ecaz-harass-withdraw' : null } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: 5, support: game.advanced ? 5 : 0, leader: otherLeader.id } },
|
||||
];
|
||||
return { initial, afterSetup, beforeShipment, shipmentStep, afterShipment,
|
||||
beforeBattle, battleStep, game, owner, opponent, observer, territory, location,
|
||||
otherLocation, token, materializes, planActions, nativeSteps, staging };
|
||||
}
|
||||
|
||||
/** Seal a real plan and consume only the native response/preparation windows it opens. */
|
||||
export function sealHarassNoFieldPlan(game: Game, step: HarassNoFieldStep): Game {
|
||||
return finishHarassPreparation(applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function revealHarassNoFieldBattle(fixture: HarassNoFieldFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const step of fixture.planActions) game = sealHarassNoFieldPlan(game, step);
|
||||
assert.ok(game.battle?.revealed);
|
||||
return game;
|
||||
}
|
||||
/** Resolve original allocation, Traitor declarations, losses and cleanup without inventing plans. */
|
||||
export function finishHarassNoFieldBattle(
|
||||
game: Game,
|
||||
options: { callers?: string[]; returns?: Record<string, { normal: number; elite: number }> } = {},
|
||||
): Game {
|
||||
for (let count = 0; count < 160; count++) {
|
||||
if (!game.battle && clean(game) && !game.pendingTreacheryDiscard) return game;
|
||||
let step: HarassNoFieldStep | null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw') {
|
||||
assert.ok(options.returns, 'Choose actual after-reveal physical return locations');
|
||||
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, returns: options.returns } };
|
||||
} else step = nextRicheseStrongholdsNativeStep(game);
|
||||
assert.ok(step, 'Native battle continuation requires both real plans');
|
||||
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('Native Harass/No-Field battle did not finish');
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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) };
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import {
|
||||
createHarassNoFieldFixture, finishHarassNoFieldBattle, harassNoFieldPlayer,
|
||||
revealHarassNoFieldBattle, sealHarassNoFieldPlan, type HarassNoFieldFixture,
|
||||
} from './fixture-harass-no-field';
|
||||
|
||||
function assertCustody(fixture: HarassNoFieldFixture, game: Game) {
|
||||
const cards = (state: Game) => [
|
||||
...state.deck, ...state.discard, ...(state.richeseCache ?? []), ...(state.richeseRemoved ?? []),
|
||||
...state.players.flatMap(p => p.hand),
|
||||
].map(c => c.id).sort();
|
||||
assert.deepEqual(cards(game), cards(fixture.afterSetup));
|
||||
const traitors = (state: Game) => [
|
||||
...(state.traitorReserve ?? []), ...state.players.flatMap(p => p.traitors),
|
||||
].sort();
|
||||
assert.deepEqual(traitors(game), traitors(fixture.afterSetup));
|
||||
for (const player of game.players) {
|
||||
const counters = [player.reserves, player.tanks, ...Object.values(player.forces)];
|
||||
assert.equal(counters.reduce((sum, n) => sum + n, 0), 20);
|
||||
assert.ok(counters.every(n => Number.isSafeInteger(n) && n >= 0));
|
||||
}
|
||||
}
|
||||
function assertOriginalMarkerHistory(fixture: HarassNoFieldFixture, revealed: Game, done: Game) {
|
||||
const shipped = harassNoFieldPlayer(fixture.afterShipment, fixture.owner);
|
||||
const exposed = harassNoFieldPlayer(revealed, fixture.owner);
|
||||
const finished = harassNoFieldPlayer(done, fixture.owner);
|
||||
assert.equal(shipped.noField!.deployed!.tokenId, fixture.token.id);
|
||||
assert.equal(exposed.noField!.deployed, null);
|
||||
assert.equal(finished.noField!.deployed, null);
|
||||
assert.deepEqual(exposed.noField!.tokens, shipped.noField!.tokens);
|
||||
assert.deepEqual(finished.noField!.tokens, shipped.noField!.tokens);
|
||||
assert.equal(exposed.noField!.lastShipped, fixture.token.id);
|
||||
assert.equal(finished.noField!.lastShipped, fixture.token.id);
|
||||
assert.notEqual(exposed.noFieldEvent, shipped.noFieldEvent);
|
||||
// Declaration, withdrawal, losses and cleanup cannot perform a second native reveal.
|
||||
assert.equal(finished.noFieldEvent, exposed.noFieldEvent);
|
||||
for (const actor of [fixture.owner, fixture.opponent, fixture.observer]) {
|
||||
const before = viewGame(fixture.game, actor).players.find(p => p.id === fixture.owner)!.noField!;
|
||||
const after = viewGame(done, actor).players.find(p => p.id === fixture.owner)!.noField!;
|
||||
assert.ok(before.deployed);
|
||||
assert.equal(before.lastUsed, null);
|
||||
assert.equal(after.deployed, null);
|
||||
assert.equal(after.lastUsed, fixture.token.value);
|
||||
}
|
||||
assert.equal(done.discard.filter(c => c.id === 'ecaz-harass-withdraw').length, 1);
|
||||
assert.equal(done.players.flatMap(p => p.hand).filter(c => c.id === 'ecaz-harass-withdraw').length, 0);
|
||||
assertCustody(fixture, done);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const slot of ['weapon', 'defense'] as const) {
|
||||
for (const marker of [3, 5] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${marker} No-Field: Harass in ${slot} privately forecasts the native pool and returns only undialed counters`, () => {
|
||||
const fixture = createHarassNoFieldFixture({ advanced, marker, slot });
|
||||
const original = harassNoFieldPlayer(fixture.game, fixture.owner);
|
||||
const offer = viewGame(fixture.game, fixture.owner).battle!.harassWithdraw!;
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.forces.normal, marker);
|
||||
assert.equal(offer.locations[fixture.location].normal, marker);
|
||||
assert.equal(offer.noField!.materializes, marker);
|
||||
assert.equal(offer.noField!.reservesAfterReveal, original.reserves - marker);
|
||||
const sealed = sealHarassNoFieldPlan(fixture.game, fixture.planActions[0]);
|
||||
assert.equal(sealed.battle!.revealed, false);
|
||||
assert.equal(sealed.battle!.harassAllocation, undefined);
|
||||
const hidden = harassNoFieldPlayer(sealed, fixture.owner);
|
||||
assert.deepEqual(hidden.noField, original.noField);
|
||||
assert.equal(hidden.noFieldEvent, original.noFieldEvent);
|
||||
assert.equal(hidden.reserves, original.reserves);
|
||||
assert.equal(hidden.forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(viewGame(sealed, fixture.opponent).battle!.plans[fixture.owner], undefined);
|
||||
const revealed = sealHarassNoFieldPlan(sealed, fixture.planActions[1]);
|
||||
assert.equal(revealed.battle!.revealed, true);
|
||||
const exposed = harassNoFieldPlayer(revealed, fixture.owner);
|
||||
assert.equal(exposed.forces[fixture.location], offer.locations[fixture.location].normal);
|
||||
assert.equal(exposed.reserves, offer.noField!.reservesAfterReveal);
|
||||
const done = finishHarassNoFieldBattle(revealed);
|
||||
const owner = harassNoFieldPlayer(done, fixture.owner);
|
||||
assert.equal(done.lastBattleContext?.winner, fixture.opponent);
|
||||
assert.equal(owner.reserves, original.reserves - 2);
|
||||
assert.equal(owner.tanks, 2);
|
||||
assert.equal(owner.forces[fixture.location] ?? 0, 0);
|
||||
// Original paid support is charged once, not refunded with undialed counters.
|
||||
assert.equal(owner.spice, original.spice - (advanced ? 2 : 0));
|
||||
assertOriginalMarkerHistory(fixture, revealed, done);
|
||||
});
|
||||
}
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} zero marker permits leader plus ${slot} Harass and is exposed/disposed only once`, () => {
|
||||
const fixture = createHarassNoFieldFixture({ advanced, marker: 0, slot });
|
||||
const original = harassNoFieldPlayer(fixture.game, fixture.owner);
|
||||
const offer = viewGame(fixture.game, fixture.owner).battle!.harassWithdraw!;
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.forces.normal, 0);
|
||||
assert.equal(offer.noField!.materializes, 0);
|
||||
const first = sealHarassNoFieldPlan(fixture.game, fixture.planActions[0]);
|
||||
assert.ok(harassNoFieldPlayer(first, fixture.owner).noField!.deployed);
|
||||
assert.equal(first.battle!.plans[fixture.owner].leader, fixture.planActions[0].action.leader);
|
||||
const revealed = sealHarassNoFieldPlan(first, fixture.planActions[1]);
|
||||
assert.equal(harassNoFieldPlayer(revealed, fixture.owner).reserves, original.reserves);
|
||||
assert.equal(harassNoFieldPlayer(revealed, fixture.owner).forces[fixture.location] ?? 0, 0);
|
||||
const done = finishHarassNoFieldBattle(revealed);
|
||||
const owner = harassNoFieldPlayer(done, fixture.owner);
|
||||
assert.equal(owner.reserves, 20);
|
||||
assert.equal(owner.tanks, 0);
|
||||
assert.equal(owner.leaders.find(l => l.id === fixture.planActions[0].action.leader)!.dead, false);
|
||||
assertOriginalMarkerHistory(fixture, revealed, done);
|
||||
});
|
||||
}
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} scarce reserves cap a five marker before Harass, conserving all twenty physical counters`, () => {
|
||||
const fixture = createHarassNoFieldFixture({ advanced, marker: 5, reserves: 2, dial: 1 });
|
||||
const offer = viewGame(fixture.game, fixture.owner).battle!.harassWithdraw!;
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.forces.normal, 2);
|
||||
assert.equal(offer.locations[fixture.location].normal, 2);
|
||||
assert.equal(offer.noField!.materializes, 2);
|
||||
assert.equal(offer.noField!.reservesAfterReveal, 0);
|
||||
const revealed = revealHarassNoFieldBattle(fixture);
|
||||
assert.equal(harassNoFieldPlayer(revealed, fixture.owner).reserves, 0);
|
||||
assert.equal(harassNoFieldPlayer(revealed, fixture.owner).forces[fixture.location], 2);
|
||||
assertCustody(fixture, revealed);
|
||||
const done = finishHarassNoFieldBattle(revealed);
|
||||
const owner = harassNoFieldPlayer(done, fixture.owner);
|
||||
assert.equal(owner.reserves, 1);
|
||||
assert.equal(owner.tanks, 1);
|
||||
assert.equal(owner.forces['polar_sink:0'], 18);
|
||||
assertOriginalMarkerHistory(fixture, revealed, done);
|
||||
});
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} mixed ordinary/marker sectors choose actual undialed locations only after reveal`, () => {
|
||||
const fixture = createHarassNoFieldFixture({ advanced, marker: 3, ordinaryAtMarker: 1, ordinaryOtherSector: 2 });
|
||||
const original = harassNoFieldPlayer(fixture.game, fixture.owner);
|
||||
const offer = viewGame(fixture.game, fixture.owner).battle!.harassWithdraw!;
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.forces.normal, 6);
|
||||
assert.equal(offer.locations[fixture.location].normal, 4);
|
||||
assert.equal(offer.locations[fixture.otherLocation].normal, 2);
|
||||
// Opponent can seal first too; no marker counters or return choice exist yet.
|
||||
const first = sealHarassNoFieldPlan(fixture.game, fixture.planActions[1]);
|
||||
assert.ok(harassNoFieldPlayer(first, fixture.owner).noField!.deployed);
|
||||
assert.equal(first.battle!.harassAllocation, undefined);
|
||||
const revealed = sealHarassNoFieldPlan(first, fixture.planActions[0]);
|
||||
assert.equal(revealed.decision!.kind, 'harassWithdraw');
|
||||
assert.equal(revealed.decision!.player, fixture.owner);
|
||||
const physical = harassNoFieldPlayer(revealed, fixture.owner);
|
||||
assert.equal(physical.forces[fixture.location], 4);
|
||||
assert.equal(physical.forces[fixture.otherLocation], 2);
|
||||
assert.equal(physical.reserves, original.reserves - 3);
|
||||
const returns = {
|
||||
[fixture.location]: { normal: 2, elite: 0 },
|
||||
[fixture.otherLocation]: { normal: 2, elite: 0 },
|
||||
};
|
||||
// A physically existing pool still cannot return the two dialed counters.
|
||||
const snapshot = JSON.stringify(revealed);
|
||||
assert.throws(() => applyAction(revealed, fixture.owner, { type: 'decision', event: revealed.battle!.harassAllocation!.event,
|
||||
returns: { [fixture.location]: { normal: 4, elite: 0 }, [fixture.otherLocation]: { normal: 2, elite: 0 } } }));
|
||||
assert.equal(JSON.stringify(revealed), snapshot);
|
||||
const chosen = applyAction(revealed, fixture.owner, { type: 'decision', event: revealed.battle!.harassAllocation!.event, returns });
|
||||
assert.equal(chosen.decision, null);
|
||||
assert.deepEqual(harassNoFieldPlayer(chosen, fixture.owner).forces, physical.forces);
|
||||
assert.equal(harassNoFieldPlayer(chosen, fixture.owner).reserves, physical.reserves);
|
||||
assert.deepEqual(viewGame(chosen, fixture.opponent).battle!.harassAllocation!.selection, returns);
|
||||
const done = finishHarassNoFieldBattle(chosen);
|
||||
const owner = harassNoFieldPlayer(done, fixture.owner);
|
||||
assert.equal(owner.reserves, 18);
|
||||
assert.equal(owner.tanks, 2);
|
||||
assert.equal(owner.forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(owner.forces[fixture.otherLocation] ?? 0, 0);
|
||||
assert.equal(owner.spice, original.spice - (advanced ? 2 : 0));
|
||||
assertOriginalMarkerHistory(fixture, revealed, done);
|
||||
});
|
||||
}
|
||||
|
||||
for (const traitor of ['owner', 'opponent', 'both'] as const) {
|
||||
void test(`${traitor} successful Traitor: native reveal remains original and only an opposing call cancels Harass`, () => {
|
||||
const fixture = createHarassNoFieldFixture({ advanced: true, marker: 5, traitor,
|
||||
ordinaryAtMarker: 1, ordinaryOtherSector: 1 });
|
||||
const revealed = revealHarassNoFieldBattle(fixture);
|
||||
const original = harassNoFieldPlayer(fixture.game, fixture.owner);
|
||||
const exposed = harassNoFieldPlayer(revealed, fixture.owner);
|
||||
assert.equal(exposed.reserves, original.reserves - 5);
|
||||
assert.equal(exposed.forces[fixture.location], 6);
|
||||
assert.equal(exposed.forces[fixture.otherLocation], 1);
|
||||
const callers = traitor === 'both' ? [fixture.owner, fixture.opponent]
|
||||
: [traitor === 'owner' ? fixture.owner : fixture.opponent];
|
||||
const done = finishHarassNoFieldBattle(revealed, { callers, returns: {
|
||||
[fixture.location]: { normal: 4, elite: 0 },
|
||||
[fixture.otherLocation]: { normal: 1, elite: 0 },
|
||||
} });
|
||||
const owner = harassNoFieldPlayer(done, fixture.owner);
|
||||
if (traitor === 'owner') {
|
||||
assert.equal(done.lastBattleContext?.winner, fixture.owner);
|
||||
assert.equal(owner.reserves, exposed.reserves + 5);
|
||||
assert.equal(owner.tanks, 0);
|
||||
assert.equal(owner.forces[fixture.location], 2);
|
||||
assert.equal(owner.forces[fixture.otherLocation] ?? 0, 0);
|
||||
} else {
|
||||
assert.equal(owner.reserves, exposed.reserves);
|
||||
assert.equal(owner.tanks, 7);
|
||||
assert.equal(owner.forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(owner.forces[fixture.otherLocation] ?? 0, 0);
|
||||
}
|
||||
assertOriginalMarkerHistory(fixture, revealed, done);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, RuleError, type Game } from '../game/engine';
|
||||
import { occupyRicheseSeat } from './fixture-ecaz-occupy-richese';
|
||||
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
||||
import { createHarassNoFieldFixture, sealHarassNoFieldPlan } from './fixture-harass-no-field';
|
||||
import {
|
||||
noFieldReinforcementsFixture, sealNoFieldReinforcements, finishNoFieldReinforcements,
|
||||
NO_FIELD_REINFORCEMENTS, NO_FIELD_HARASS, type NoFieldReinforcementsFixture,
|
||||
} from './fixture-no-field-reinforcements';
|
||||
|
||||
function assertConserved(fixture: NoFieldReinforcementsFixture, game: Game): void {
|
||||
const cards = (state: Game) => [...state.deck, ...state.discard,
|
||||
...(state.richeseCache ?? []), ...(state.richeseRemoved ?? []),
|
||||
...state.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
||||
assert.deepEqual(cards(game), cards(fixture.initial), 'Every original deck/cache face keeps physical custody.');
|
||||
const traitors = (state: Game) => [...(state.traitorReserve ?? []),
|
||||
...(state.ecazLoyalty?.card ? [state.ecazLoyalty.card] : []),
|
||||
...state.players.flatMap(player => player.traitors)].sort();
|
||||
assert.deepEqual(traitors(game), traitors(fixture.afterSetup));
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20,
|
||||
`${player.faction} retains its own original twenty counters.`);
|
||||
assert.ok([player.reserves, player.tanks, ...Object.values(player.forces)]
|
||||
.every(n => Number.isSafeInteger(n) && n >= 0));
|
||||
}
|
||||
}
|
||||
function assertMarkerRevealed(fixture: NoFieldReinforcementsFixture, game: Game): void {
|
||||
const richese = occupyRicheseSeat(game, fixture.richese);
|
||||
assert.equal(richese.noField!.deployed, null);
|
||||
assert.equal(richese.noField!.lastShipped, fixture.noField.lastShipped,
|
||||
'Native revelation preserves the actual most-recent-shipment identity.');
|
||||
assert.deepEqual(richese.noField!.tokens, fixture.noField.tokens);
|
||||
assert.equal(richese.reserves, fixture.reservesBefore[fixture.richese] - fixture.materializes,
|
||||
'Second seal materializes the physical marker before either utility card spends or returns counters.');
|
||||
assert.equal(richese.forces[fixture.location] ?? 0, fixture.materializes);
|
||||
assert.equal(richese.tanks, occupyRicheseSeat(fixture.game, fixture.richese).tanks);
|
||||
}
|
||||
function assertUtilitiesDiscarded(game: Game): void {
|
||||
for (const card of [NO_FIELD_REINFORCEMENTS, NO_FIELD_HARASS]) {
|
||||
assert.equal(game.discard.filter(face => face.id === card).length, 1);
|
||||
assert.ok(game.players.every(player => !player.hand.some(face => face.id === card)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (const [reserves, marker] of [[3, 3], [7, 5]] as const)
|
||||
void test(`No-Field ${marker} with reserves ${reserves} rejects the FIRST Reinforcements seal, but the same owner can seal Harass alone`, () => {
|
||||
const fixture = noFieldReinforcementsFixture({ marker, richeseReserves: reserves });
|
||||
const game = fixture.game;
|
||||
const saved = structuredClone(game);
|
||||
const preview = viewGame(game, fixture.owner).battle!.reinforcements!;
|
||||
assert.ok(preview.blocked, 'Three current reserves do not imply three reserves after native materialization.');
|
||||
assert.equal(preview.normal + preview.elite, reserves - marker);
|
||||
assert.equal(preview.noField!.materializes, marker);
|
||||
assert.equal(preview.noField!.reservesAfterReveal, reserves - marker);
|
||||
assert.equal(viewGame(game, fixture.owner).battle!.harassWithdraw!.blocked, null);
|
||||
for (const plan of [fixture.plans.reinforcements, fixture.plans.both]) {
|
||||
assert.throws(() => applyAction(game, fixture.owner, plan), RuleError);
|
||||
assert.deepEqual(game, saved, 'Rejection cannot debit resources, reveal the marker, reserve a card or hide a sealed plan.');
|
||||
assert.equal(game.battle!.plans[fixture.owner], undefined);
|
||||
assert.deepEqual(viewGame(game, fixture.opponent).battle!.plans, {});
|
||||
}
|
||||
const privatePlan = applyAction(game, fixture.owner, fixture.plans.harass);
|
||||
assert.ok(privatePlan.battle!.plans[fixture.owner]);
|
||||
assert.equal(privatePlan.battle!.plans[fixture.owner].weapon, null);
|
||||
assert.deepEqual(occupyRicheseSeat(privatePlan, fixture.richese).noField, fixture.noField);
|
||||
assert.equal(occupyRicheseSeat(privatePlan, fixture.richese).reserves, reserves);
|
||||
const revealed = applyAction(privatePlan, fixture.opponent, fixture.plans.opponent);
|
||||
assertMarkerRevealed(fixture, revealed);
|
||||
const { game: done } = finishNoFieldReinforcements(fixture, revealed);
|
||||
assert.equal(done.battle, null);
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, reserves - fixture.variableDial);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(game, fixture.richese).tanks,
|
||||
fixture.variableDial, 'Only the original one-counter variable commitment is lost.');
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(game, fixture.ecaz).tanks, 3);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
|
||||
assert.equal(done.discard.filter(card => card.id === NO_FIELD_HARASS).length, 1);
|
||||
assert.equal(done.discard.filter(card => card.id === NO_FIELD_REINFORCEMENTS).length, 0);
|
||||
assert.ok(occupyRicheseSeat(done, fixture.owner).hand.some(card => card.id === NO_FIELD_REINFORCEMENTS));
|
||||
assertConserved(fixture, done);
|
||||
});
|
||||
|
||||
for (const [reserves, marker] of [[6, 3], [8, 5], [3, 0]] as const)
|
||||
for (const slot of ['weapon', 'defense'] as const)
|
||||
void test(`Funded No-Field ${marker}/reserves ${reserves}, Reinforcements in ${slot}: reveal, original cost, own undialed return, original losses`, () => {
|
||||
const fixture = noFieldReinforcementsFixture({ marker, richeseReserves: reserves });
|
||||
const before = structuredClone(fixture.game);
|
||||
const preview = viewGame(before, fixture.owner).battle!.reinforcements!;
|
||||
assert.equal(preview.blocked, null);
|
||||
assert.equal(preview.normal, 3);
|
||||
assert.equal(preview.elite, 0);
|
||||
assert.equal(preview.noField!.materializes, marker);
|
||||
assert.equal(preview.noField!.reservesAfterReveal, 3);
|
||||
const plan = { ...fixture.plans.both,
|
||||
weapon: slot === 'weapon' ? NO_FIELD_REINFORCEMENTS : NO_FIELD_HARASS,
|
||||
defense: slot === 'defense' ? NO_FIELD_REINFORCEMENTS : NO_FIELD_HARASS };
|
||||
const first = applyAction(before, fixture.owner, plan);
|
||||
assert.equal(first.battle!.revealed, false);
|
||||
assert.deepEqual(occupyRicheseSeat(first, fixture.richese).noField, fixture.noField);
|
||||
assert.equal(occupyRicheseSeat(first, fixture.richese).reserves, reserves);
|
||||
assert.equal(occupyRicheseSeat(first, fixture.richese).forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(occupyRicheseSeat(first, fixture.richese).tanks, occupyRicheseSeat(before, fixture.richese).tanks);
|
||||
const revealed = applyAction(first, fixture.opponent, fixture.plans.opponent);
|
||||
assert.equal(revealed.battle!.revealed, true);
|
||||
assertMarkerRevealed(fixture, revealed);
|
||||
const { afterVotes, game: done } = finishNoFieldReinforcements(fixture, revealed);
|
||||
assert.equal(occupyRicheseSeat(afterVotes, fixture.richese).reserves, reserves - 3 - fixture.variableDial,
|
||||
'The future undialed return neither funds nor changes the three-counter original reserve cost.');
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, reserves - 3 - fixture.variableDial);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(before, fixture.richese).tanks,
|
||||
3 + fixture.variableDial);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3,
|
||||
'Harass never recomputes the original mandatory ceil(5/2) Ecaz commitment.');
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).reserves, fixture.reservesBefore[fixture.ecaz]);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.owner).spice, occupyRicheseSeat(before, fixture.owner).spice - fixture.variableDial,
|
||||
'Only original variable support is paid; the withdrawn lead receives no Carthag income.');
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).noField!.lastShipped, fixture.noField.lastShipped);
|
||||
assertUtilitiesDiscarded(done);
|
||||
assertConserved(fixture, done);
|
||||
assert.deepEqual(fixture.game, before, 'The before-commitment runtime control stays immutable.');
|
||||
});
|
||||
|
||||
for (const [reserves, marker] of [[6, 3], [8, 5]] as const)
|
||||
void test(`Opposing real Traitor with No-Field ${marker} cancels only Harass, never revelation or Reinforcements cost`, () => {
|
||||
const fixture = noFieldReinforcementsFixture({ marker, richeseReserves: reserves, opposingTraitor: true });
|
||||
const revealed = sealNoFieldReinforcements(fixture);
|
||||
assertMarkerRevealed(fixture, revealed);
|
||||
const { game: done } = finishNoFieldReinforcements(fixture, revealed);
|
||||
assert.equal(done.lastBattleContext!.result, 'traitor');
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.opponent);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, reserves - marker - 3,
|
||||
'No concealed-marker counter returns after an opposing successful Traitor.');
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.game, fixture.richese).tanks, marker + 3);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.game, fixture.ecaz).tanks, 5);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).noField!.deployed, null);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).noField!.lastShipped, fixture.noField.lastShipped);
|
||||
assertUtilitiesDiscarded(done);
|
||||
assertConserved(fixture, done);
|
||||
});
|
||||
|
||||
for (const [reserves, marker] of [[3, 3], [7, 5]] as const)
|
||||
void test(`Ecaz-held same-plan utilities with Richese No-Field ${marker} use only Ecaz original reserves and fixed-undialed counters`, () => {
|
||||
const fixture = noFieldReinforcementsFixture({ marker, richeseReserves: reserves, lead: 'ecaz' });
|
||||
const preview = viewGame(fixture.game, fixture.owner).battle!.reinforcements!;
|
||||
assert.equal(preview.blocked, null, 'The ally marker cannot consume the Ecaz card user reserve pool.');
|
||||
assert.equal(preview.normal, 3);
|
||||
assert.equal(preview.noField, undefined, 'An allied marker is not an own-marker funding forecast.');
|
||||
const revealed = sealNoFieldReinforcements(fixture);
|
||||
assertMarkerRevealed(fixture, revealed);
|
||||
assert.equal(occupyRicheseSeat(revealed, fixture.ecaz).reserves, fixture.reservesBefore[fixture.ecaz]);
|
||||
const { game: done } = finishNoFieldReinforcements(fixture, revealed);
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.ecaz);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).reserves, fixture.reservesBefore[fixture.ecaz] - 3 + 2);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.game, fixture.ecaz).tanks, 6);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, reserves - marker,
|
||||
'The Richese ally neither funds Ecaz Reinforcements nor receives the Ecaz Harass return.');
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location], marker - 1);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.game, fixture.richese).tanks, 1);
|
||||
assertUtilitiesDiscarded(done);
|
||||
assertConserved(fixture, done);
|
||||
});
|
||||
|
||||
void test('An Ecaz card user cannot fund Reinforcements from its Richese ally after revelation or either future Harass return', () => {
|
||||
const fixture = noFieldReinforcementsFixture({ marker: 5, richeseReserves: 8, lead: 'ecaz', ecazReserves: 2 });
|
||||
const before = structuredClone(fixture.game);
|
||||
assert.ok(viewGame(before, fixture.owner).battle!.reinforcements!.blocked);
|
||||
assert.equal(viewGame(before, fixture.owner).battle!.reinforcements!.normal, 2);
|
||||
assert.throws(() => applyAction(before, fixture.owner, fixture.plans.both), RuleError);
|
||||
assert.deepEqual(before, fixture.game);
|
||||
assertConserved(fixture, before);
|
||||
});
|
||||
|
||||
void test('Original Advanced native inventory without the selected Occupy profile still cannot use Reinforcements', () => {
|
||||
const fixture = noFieldReinforcementsFixture({ marker: 3, richeseReserves: 6, omitPreview: true });
|
||||
const before = structuredClone(fixture.game);
|
||||
assert.ok(viewGame(before, fixture.owner).battle!.reinforcements!.blocked);
|
||||
assert.throws(() => applyAction(before, fixture.owner, fixture.plans.reinforcements), RuleError);
|
||||
assert.deepEqual(before, fixture.game);
|
||||
assertConserved(fixture, before);
|
||||
});
|
||||
|
||||
void test('Ordinary Basic Richese Harass with a real concealed marker does not widen Reinforcements composition support', () => {
|
||||
const fixture = createHarassNoFieldFixture({ advanced: false, marker: 3, reserves: 6 });
|
||||
stageEcazOccupyCard(fixture.game, fixture.owner, card => card.id === NO_FIELD_REINFORCEMENTS);
|
||||
const before = structuredClone(fixture.game);
|
||||
const preview = viewGame(before, fixture.owner).battle!;
|
||||
assert.equal(before.advanced, false);
|
||||
assert.equal(preview.harassWithdraw!.blocked, null);
|
||||
assert.ok(preview.reinforcements!.blocked);
|
||||
assert.throws(() => applyAction(before, fixture.owner, {
|
||||
...fixture.planActions[0].action, weapon: NO_FIELD_REINFORCEMENTS, defense: null,
|
||||
}), RuleError);
|
||||
assert.deepEqual(before, fixture.game);
|
||||
const legal = sealHarassNoFieldPlan(before, fixture.planActions[0]);
|
||||
assert.ok(legal.battle!.plans[fixture.owner]);
|
||||
assert.equal(legal.battle!.revealed, false);
|
||||
assert.equal(occupyRicheseSeat(legal, fixture.owner).reserves, 6);
|
||||
assert.deepEqual(occupyRicheseSeat(legal, fixture.owner).noField,
|
||||
occupyRicheseSeat(before, fixture.owner).noField);
|
||||
});
|
||||
Reference in new issue
Block a user