Distinguish Atreides special No-Field plan inspection
This commit is contained in:
1 parent
0dfdeb9578
commit
24c38ebccd
15 files changed
+1092
-33
No files matched your search
@@ -147,7 +147,7 @@ void test('Original concealed Richese presence permits a zero prospective pool,
|
||||
});
|
||||
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const) void test(`Atreides cannot inspect concealed partner's ${lead} plan dial or whole plan`, () => {
|
||||
for (const lead of ['ecaz', 'ally'] as const) void test(`Atreides ordinary Prescience cannot inspect concealed partner's ${lead} plan dial`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ opponentFaction: 'atreides', marker: 5, richeseReserves: 2 });
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
assert.ok(fixture.game.decision?.kind === 'ecazBattleLead');
|
||||
@@ -167,15 +167,6 @@ for (const lead of ['ecaz', 'ally'] as const) void test(`Atreides cannot inspect
|
||||
saved = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, fixture.opponent, { type: 'prescience', field: 'dial' }), /may not inspect/);
|
||||
assert.deepEqual(game, saved);
|
||||
game = applyAction(game, fixture.opponent, { type: 'declineBattlePower' });
|
||||
for (let attempt = 0; attempt < 100 && game.decision?.kind !== 'fullPlanOffer'; attempt++) game = nextOccupyRichese(game);
|
||||
assert.equal(game.decision!.kind, 'fullPlanOffer');
|
||||
const karama = occupyRicheseSeat(game, fixture.opponent).hand.find(c => c.effect === 'karama')!;
|
||||
saved = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, fixture.opponent, { type: 'card', mode: 'special', card: karama.id, target: actor }),
|
||||
/Whole-plan special prescience against a No-Field/);
|
||||
assert.deepEqual(game, saved);
|
||||
assert.ok(occupyRicheseSeat(game, fixture.richese).noField!.deployed);
|
||||
});
|
||||
|
||||
for (const marker of [0, 5] as const) for (const lead of ['ecaz', 'ally'] as const)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { NoFieldValue } 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, finishOccupyRichese, nextOccupyRichese, occupyRicheseSeat,
|
||||
type EcazOccupyRicheseFixture,
|
||||
} from './fixture-ecaz-occupy-richese';
|
||||
|
||||
export type NoFieldFullPlanOccupyOptions = {
|
||||
initial?: Game;
|
||||
lead?: 'ecaz' | 'ally';
|
||||
marker?: NoFieldValue;
|
||||
richeseReserves?: number;
|
||||
ordinary?: number;
|
||||
prevented?: boolean;
|
||||
utilities?: boolean;
|
||||
opposingTraitor?: boolean;
|
||||
variableDial?: number;
|
||||
support?: number;
|
||||
};
|
||||
export type NoFieldFullPlanOccupyFixture = EcazOccupyRicheseFixture & {
|
||||
source: EcazOccupyRicheseFixture;
|
||||
beforeLead: Game;
|
||||
prescience: Game;
|
||||
owner: string;
|
||||
atreides: string;
|
||||
inspectionCard: string;
|
||||
preventionCard: string | null;
|
||||
ordinary: number;
|
||||
marker: NoFieldValue;
|
||||
materializes: number;
|
||||
fixedEcazDial: number;
|
||||
variableDial: number;
|
||||
support: number;
|
||||
targetPlan: Action;
|
||||
atreidesPlan: Action;
|
||||
utilities: boolean;
|
||||
opposingTraitor: boolean;
|
||||
};
|
||||
|
||||
/** Select the independent physical three-card source before the one original
|
||||
* initialization. A captured setup is instead continued without redealing. */
|
||||
export function createNoFieldFullPlanOccupySetup(): Game {
|
||||
const roster = ['ecaz', 'richese', 'atreides', 'choam'] as const;
|
||||
let game = createGame('NOFIELDFULLPLANOCCUPY', 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 i = roster.length - 1; i >= 0; i--)
|
||||
if (viewGame(game, roster[i]).playerPositions[roster[i]] !== i + 1)
|
||||
game = applyAction(game, roster[i], { type: 'seatPosition', position: i + 1 });
|
||||
for (const faction of roster) game = applyAction(game, faction, { type: 'ready' });
|
||||
game.ecazTreachery = true;
|
||||
return initializeEcazOccupyGameForAudit(game);
|
||||
}
|
||||
|
||||
/** The driver never consumes the boundary that the actual control must offer. */
|
||||
export function advanceNoFieldFullPlanOccupy(state: Game, boundary: 'prescience' | 'offer'): Game {
|
||||
let game = state;
|
||||
for (let attempt = 0; attempt < 160; attempt++) {
|
||||
const prepared = !game.battle?.preLeader || game.battle.preLeader.closed;
|
||||
if (!game.response && !game.phaseOpening && prepared &&
|
||||
(boundary === 'prescience'
|
||||
? game.battle?.preparation?.kind === 'prescience'
|
||||
: game.decision?.kind === 'fullPlanOffer')) return game;
|
||||
assert.notEqual(game.decision?.kind, 'fullPlanOffer', 'Do not auto-decline a real full-plan offer.');
|
||||
assert.notEqual(!game.response && !game.phaseOpening && prepared && game.battle?.preparation?.kind, 'prescience',
|
||||
'Ordinary Prescience requires an explicit choice before advancing to the special offer.');
|
||||
game = nextOccupyRichese(game);
|
||||
}
|
||||
throw Error(`Native coalition continuation did not reach ${boundary}.`);
|
||||
}
|
||||
|
||||
/** The shared fixture performs genuine setup, Nexus alliance, native token
|
||||
* shipment and chooseBattle. Only labeled conserved card/Traitor staging follows. */
|
||||
export function noFieldFullPlanOccupyFixture(options: NoFieldFullPlanOccupyOptions = {}): NoFieldFullPlanOccupyFixture {
|
||||
const marker = options.marker ?? 3;
|
||||
const ordinary = options.ordinary ?? 2;
|
||||
assert.ok(Number.isSafeInteger(ordinary) && ordinary >= 0);
|
||||
const source = ecazOccupyRicheseFixture({
|
||||
initial: options.initial ?? (options.utilities ? createNoFieldFullPlanOccupySetup() : undefined),
|
||||
opponentFaction: 'atreides', marker, mixedRicheseForces: ordinary,
|
||||
richeseReserves: options.richeseReserves ?? 6,
|
||||
});
|
||||
if (options.utilities) assert.equal(source.initial.ecazTreachery, true);
|
||||
const owner = options.lead === 'ecaz' ? source.ecaz : source.richese;
|
||||
const atreides = source.opponent;
|
||||
const inspectionCard = occupyRicheseSeat(source.game, atreides).hand.find(card => card.effect === 'karama')!.id;
|
||||
// Return, do not replace, unselected original cards. Keep the actual Karama
|
||||
// already staged by the shared source; all subsequent draws use that same deck.
|
||||
for (const player of source.game.players) {
|
||||
const returned = player.hand.filter(card => card.id !== inspectionCard);
|
||||
source.game.deck.push(...returned);
|
||||
player.hand = player.hand.filter(card => card.id === inspectionCard);
|
||||
}
|
||||
source.staging.push('Return all unselected original held cards to the remaining native deck; retain the original Atreides inspection Karama and stage only the selected real battle faces.');
|
||||
const preventionCard = options.prevented
|
||||
? stageEcazOccupyCard(source.game, 'choam', card => card.effect === 'karama' && card.id !== inspectionCard)
|
||||
: null;
|
||||
if (preventionCard) source.staging.push(`Conserve distinct original CHOAM Karama ${preventionCard} for the actual Occupy response.`);
|
||||
const weapon = stageEcazOccupyCard(source.game, owner, card => options.utilities
|
||||
? card.id === 'ecaz-reinforcements' : card.kind === 'projectile');
|
||||
const defense = stageEcazOccupyCard(source.game, owner, card => options.utilities
|
||||
? card.id === 'ecaz-harass-withdraw' : card.kind === 'shield');
|
||||
const enemyDefense = stageEcazOccupyCard(source.game, atreides,
|
||||
card => card.kind === 'shield' && card.id !== defense);
|
||||
const physicalTraitors = new Set([...(source.game.traitorReserve ?? []),
|
||||
...source.game.players.flatMap(player => player.traitors)]);
|
||||
const leader = occupyRicheseSeat(source.game, owner).leaders
|
||||
.filter(face => !face.dead && !face.usedAt && (!options.opposingTraitor || physicalTraitors.has(face.id)))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
const enemyLeader = occupyRicheseSeat(source.game, atreides).leaders
|
||||
.filter(face => !face.dead && !face.usedAt).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(leader && enemyLeader);
|
||||
if (options.opposingTraitor) {
|
||||
holdOccupyCardsTraitor(source.game, atreides, leader.id);
|
||||
source.staging.push(`Exchange an original held Traitor Card for actual ${leader.id} in the Atreides hand.`);
|
||||
}
|
||||
const beforeLead = structuredClone(source.game);
|
||||
assert.equal(source.game.decision?.kind, 'ecazBattleLead');
|
||||
let game = applyAction(source.game, source.ecaz, { type: 'decision', event: source.game.decision!.event, lead: owner });
|
||||
if (preventionCard) {
|
||||
assert.equal(game.response?.kind, 'ecazOccupy');
|
||||
game = applyAction(game, 'choam', { type: 'card', mode: 'cancel', card: preventionCard });
|
||||
}
|
||||
const prescience = advanceNoFieldFullPlanOccupy(game, 'prescience');
|
||||
const profile = viewGame(prescience, owner).battle!.ecazOccupy!.profile!;
|
||||
assert.ok(profile);
|
||||
const materializes = Math.min(marker, occupyRicheseSeat(prescience, source.richese).reserves);
|
||||
const variableDial = options.variableDial ?? Math.min(3, profile.forces.normal);
|
||||
const support = options.support ?? variableDial;
|
||||
return {
|
||||
...source, source, beforeLead, game: prescience, prescience, owner, atreides,
|
||||
inspectionCard, preventionCard, ordinary, marker, materializes,
|
||||
fixedEcazDial: profile.fixedEcazDial, variableDial, support,
|
||||
targetPlan: { type: 'battlePlan', dial: profile.fixedEcazDial + variableDial,
|
||||
support, leader: leader.id, weapon, defense },
|
||||
atreidesPlan: { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader.id, defense: enemyDefense },
|
||||
utilities: options.utilities ?? false, opposingTraitor: options.opposingTraitor ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function offerNoFieldFullPlanOccupy(fixture: NoFieldFullPlanOccupyFixture, state = fixture.prescience): Game {
|
||||
assert.equal(state.battle?.preparation?.kind, 'prescience');
|
||||
return advanceNoFieldFullPlanOccupy(applyAction(state, fixture.atreides, { type: 'declineBattlePower' }), 'offer');
|
||||
}
|
||||
export function inspectNoFieldFullPlanOccupy(fixture: NoFieldFullPlanOccupyFixture,
|
||||
state = offerNoFieldFullPlanOccupy(fixture)): Game {
|
||||
return applyAction(state, fixture.atreides,
|
||||
{ type: 'card', mode: 'special', card: fixture.inspectionCard, target: fixture.owner });
|
||||
}
|
||||
|
||||
/** Real post-reveal allocation and declaration windows, including an optional
|
||||
* actual opposing Traitor call; never assigns an outcome or clears a decision. */
|
||||
export function finishNoFieldFullPlanOccupy(fixture: NoFieldFullPlanOccupyFixture, state: Game): Game {
|
||||
let game = state;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (game.response || game.phaseOpening) { game = nextOccupyRichese(game); continue; }
|
||||
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) });
|
||||
continue;
|
||||
}
|
||||
const battle = viewGame(game, fixture.owner).battle;
|
||||
const voter = !game.decision && battle?.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
|
||||
if (!voter) return finishOccupyRichese(game);
|
||||
game = applyAction(game, voter,
|
||||
{ type: 'traitorCall', call: fixture.opposingTraitor && voter === fixture.atreides });
|
||||
}
|
||||
throw Error('Native inspected coalition declarations did not finish.');
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeFactionExpansionsGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { Card } from '../game/cards';
|
||||
import type { NoFieldValue } from '../game/richese-no-field';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type NoFieldFullPlanStep = { actor: string; action: Action };
|
||||
export type NoFieldFullPlanOptions = {
|
||||
initial?: Game;
|
||||
seatIds?: [string, string, string];
|
||||
marker?: NoFieldValue;
|
||||
/** Own physical reserves immediately before the original shipment. */
|
||||
reserves?: number;
|
||||
ordinaryAtMarker?: number;
|
||||
ordinaryOtherSector?: number;
|
||||
dial?: number;
|
||||
support?: number;
|
||||
ordinaryField?: 'leader' | 'weapon' | 'defense';
|
||||
traitor?: 'owner' | 'atreides';
|
||||
};
|
||||
export type NoFieldFullPlanFixture = {
|
||||
options: NoFieldFullPlanOptions;
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeShipment: Game;
|
||||
shipmentStep: NoFieldFullPlanStep;
|
||||
afterShipment: Game;
|
||||
beforeBattle: Game;
|
||||
battleStep: NoFieldFullPlanStep;
|
||||
/** Original ordinary prescience, with native pre-leader preparation closed. */
|
||||
beforePrescience: Game;
|
||||
prescienceStep: NoFieldFullPlanStep;
|
||||
beforeAnswer: Game;
|
||||
answerStep: NoFieldFullPlanStep;
|
||||
/** Original fullPlanOffer, not consumed by a generic continuation driver. */
|
||||
game: Game;
|
||||
owner: string;
|
||||
atreides: string;
|
||||
opponent: string;
|
||||
observer: string;
|
||||
territory: string;
|
||||
location: string;
|
||||
otherLocation: string;
|
||||
token: { id: string; value: NoFieldValue };
|
||||
materializes: number;
|
||||
cards: { karama: Card; spareKarama: Card; weapon: Card; defense: Card; atreidesDefense: Card };
|
||||
inspectStep: NoFieldFullPlanStep;
|
||||
declineStep: NoFieldFullPlanStep;
|
||||
planActions: [NoFieldFullPlanStep, NoFieldFullPlanStep];
|
||||
nativeSteps: NoFieldFullPlanStep[];
|
||||
staging: string[];
|
||||
};
|
||||
export function noFieldFullPlanPlayer(game: Game, actor: string) {
|
||||
const player = game.players.find(p => p.id === actor);
|
||||
assert.ok(player, `Missing original full-plan seat ${actor}`);
|
||||
return player;
|
||||
}
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
|
||||
function initialize(game: Game): Game {
|
||||
// Scope deterministic dealer randomness to the original fresh initializer only.
|
||||
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 initializeFactionExpansionsGameForAudit(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 = noFieldFullPlanPlayer(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 take(game: Game, actor: string, matches: (card: Card) => boolean): Card {
|
||||
const player = noFieldFullPlanPlayer(game, actor);
|
||||
assert.ok(player.hand.length < 4);
|
||||
const at = game.deck.findIndex(matches);
|
||||
assert.ok(at >= 0, 'Move only a physical card from the originally selected native deck');
|
||||
const card = game.deck.splice(at, 1)[0];
|
||||
player.hand.push(card);
|
||||
return structuredClone(card);
|
||||
}
|
||||
function exchangeTraitor(game: Game, actor: string, identity: string, staging: string[]) {
|
||||
const player = noFieldFullPlanPlayer(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.`);
|
||||
}
|
||||
|
||||
/** Original Advanced faction setup, native CHOAM-selected deck and separate
|
||||
* Richese cache. Only labeled conserved board/hand/bank staging follows setup;
|
||||
* shipment, chooser, pre-leader readiness, prescience and offer are real actions. */
|
||||
export function createNoFieldFullPlanFixture(options: NoFieldFullPlanOptions = {}): NoFieldFullPlanFixture {
|
||||
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));
|
||||
assert.equal(game.advanced, true);
|
||||
assert.deepEqual(game.expansions, ['choam']);
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'guild', 'richese']);
|
||||
assert.equal(game.ecazTreachery, undefined);
|
||||
} else {
|
||||
const ids = options.seatIds ?? ['richese', 'atreides', 'guild'];
|
||||
game = createGame('NOFIELDFULLPLAN', newPlayer(ids[0], 'Richese', 'richese'), true, ['choam']);
|
||||
joinGame(game, newPlayer(ids[1], 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(ids[2], 'Guild', 'guild'));
|
||||
}
|
||||
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: NoFieldFullPlanStep | 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 full-plan 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 atreides = game.players.find(p => p.faction === 'atreides')!.id;
|
||||
const observer = game.players.find(p => p.faction === 'guild')!.id;
|
||||
const territory = 'imperial_basin', sector = 9;
|
||||
const location = `${territory}:${sector}`, otherLocation = `${territory}:10`;
|
||||
const staging: string[] = [], nativeSteps: NoFieldFullPlanStep[] = [];
|
||||
const act = (step: NoFieldFullPlanStep) => {
|
||||
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;
|
||||
// Never silently decline ordinary prescience or the special inspection.
|
||||
assert.notEqual(game.decision?.kind, 'fullPlanOffer');
|
||||
assert.ok(!game.battle?.preparation || game.response || game.battle.preLeader && !game.battle.preLeader.closed);
|
||||
const step = nextRicheseStrongholdsNativeStep(game);
|
||||
assert.ok(step, 'Native continuation stops before either human plan');
|
||||
if (game.turn === 1 && step.action.type === 'stormDial') step.action.amount = 0;
|
||||
act(step);
|
||||
}
|
||||
throw Error('Native full-plan continuation did not reach its boundary');
|
||||
};
|
||||
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 ordinary = (options.ordinaryAtMarker ?? 0) + (options.ordinaryOtherSector ?? 0);
|
||||
const reserves = options.reserves ?? 20 - ordinary;
|
||||
assert.ok(Number.isSafeInteger(reserves) && reserves >= 0 && reserves + ordinary <= 20);
|
||||
place(game, owner, location, options.ordinaryAtMarker ?? 0);
|
||||
place(game, owner, otherLocation, options.ordinaryOtherSector ?? 0);
|
||||
place(game, owner, 'polar_sink:0', 20 - reserves - ordinary);
|
||||
place(game, atreides, location, 5);
|
||||
staging.push(`Before actual shipment: conserve ${ordinary} ordinary Richese battle counters, ${reserves} own reserves, the remainder in Polar Sink and five opposing Atreides counters at ${location}.`);
|
||||
const beforeShipment = structuredClone(game);
|
||||
const token = structuredClone(noFieldFullPlanPlayer(game, owner).noField!.tokens.find(t => t.value === (options.marker ?? 3))!);
|
||||
assert.ok(token);
|
||||
const shipmentStep: NoFieldFullPlanStep = { actor: owner, action: {
|
||||
type: 'ship', territory, sector, noField: token.id,
|
||||
event: noFieldFullPlanPlayer(game, owner).noFieldEvent,
|
||||
} };
|
||||
act(shipmentStep);
|
||||
advance(clean);
|
||||
const afterShipment = structuredClone(game);
|
||||
advance(g => g.phase === 6 && clean(g));
|
||||
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
|
||||
const cards = {
|
||||
karama: take(game, atreides, c => c.effect === 'karama'),
|
||||
spareKarama: take(game, atreides, c => c.effect === 'karama'),
|
||||
weapon: take(game, owner, c => c.kind === 'poison'),
|
||||
defense: take(game, owner, c => c.kind === 'shield'),
|
||||
atreidesDefense: take(game, atreides, c => c.kind === 'snooper'),
|
||||
};
|
||||
staging.push('Before actual battle admission: return original dealt hands to their original deck; transfer two physical Karama and Snooper to Atreides, poison weapon and Shield to Richese. Cache/removed custody is unchanged; no hand exceeds four.');
|
||||
const ownLeader = noFieldFullPlanPlayer(game, owner).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
|
||||
const otherLeader = noFieldFullPlanPlayer(game, atreides).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') exchangeTraitor(game, owner, otherLeader.id, staging);
|
||||
if (options.traitor === 'atreides') exchangeTraitor(game, atreides, ownLeader.id, staging);
|
||||
const materializes = Math.min(token.value, reserves);
|
||||
const dial = options.dial ?? Math.min(2, ordinary + materializes);
|
||||
const planActions: [NoFieldFullPlanStep, NoFieldFullPlanStep] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', dial, support: options.support ?? dial,
|
||||
leader: ownLeader.id, weapon: cards.weapon.id, defense: cards.defense.id } },
|
||||
{ actor: atreides, action: { type: 'battlePlan', dial: 5, support: 5,
|
||||
leader: otherLeader.id, defense: cards.atreidesDefense.id } },
|
||||
];
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === territory);
|
||||
assert.ok(choice, 'A genuine concealed zero must still enter an ordinary battle');
|
||||
const battleStep: NoFieldFullPlanStep = { actor: choice.chooser, action: {
|
||||
type: 'chooseBattle', territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
|
||||
} };
|
||||
act(battleStep);
|
||||
// Stop at original ordinary prescience rather than letting the shared driver decline it.
|
||||
advance(g => g.battle?.preparation?.kind === 'prescience' && !g.response && !g.decision && !!g.battle.preLeader?.closed);
|
||||
const beforePrescience = structuredClone(game);
|
||||
const field = options.ordinaryField ?? 'leader';
|
||||
const prescienceStep: NoFieldFullPlanStep = { actor: atreides, action: { type: 'prescience', field } };
|
||||
act(prescienceStep);
|
||||
while (game.response) {
|
||||
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); act(next);
|
||||
}
|
||||
const beforeAnswer = structuredClone(game);
|
||||
const answerStep: NoFieldFullPlanStep = { actor: owner, action: {
|
||||
type: 'prescienceAnswer', value: field === 'leader' ? ownLeader.id : cards[field].id,
|
||||
} };
|
||||
act(answerStep);
|
||||
assert.ok(game.decision?.kind === 'fullPlanOffer');
|
||||
assert.equal(game.decision.player, atreides);
|
||||
assert.equal(game.battle?.preparation, undefined);
|
||||
assert.deepEqual(game.battle?.plans, {});
|
||||
return { options: { ...options, initial: undefined }, initial, afterSetup,
|
||||
beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep,
|
||||
beforePrescience, prescienceStep, beforeAnswer, answerStep, game,
|
||||
owner, atreides, opponent: atreides, observer, territory, location, otherLocation,
|
||||
token, materializes, cards,
|
||||
inspectStep: { actor: atreides, action: { type: 'card', mode: 'special', card: cards.karama.id, target: owner } },
|
||||
declineStep: { actor: atreides, action: { type: 'decision', decline: true } },
|
||||
planActions, nativeSteps, staging };
|
||||
}
|
||||
|
||||
export function applyNoFieldFullPlanStep(game: Game, step: NoFieldFullPlanStep): Game {
|
||||
return applyAction(game, step.actor, step.action);
|
||||
}
|
||||
/** Consume only actual response queues; never decide a power or seal a plan. */
|
||||
export function finishNoFieldFullPlanResponses(game: Game): Game {
|
||||
for (let count = 0; game.response && count < 80; count++) {
|
||||
const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step);
|
||||
game = applyNoFieldFullPlanStep(game, step);
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
/** Original Traitor declarations, native loss allocation, cards and aftermath. */
|
||||
export function finishNoFieldFullPlanBattle(game: Game, callers: string[] = []): Game {
|
||||
assert.ok(game.battle?.revealed, 'Both original plans must already be sealed');
|
||||
for (let count = 0; count < 160; count++) {
|
||||
if (!game.battle && clean(game) && !game.pendingTreacheryDiscard) return game;
|
||||
const step = nextRicheseStrongholdsNativeStep(game);
|
||||
assert.ok(step, 'Native aftermath cannot invent a battle plan');
|
||||
if (step.action.type === 'traitorCall') step.action.call = callers.includes(step.actor);
|
||||
game = applyNoFieldFullPlanStep(game, step);
|
||||
}
|
||||
throw Error('Original full-plan No-Field battle did not finish');
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { nextOccupyRichese, occupyRicheseSeat } from './fixture-ecaz-occupy-richese';
|
||||
import {
|
||||
noFieldFullPlanOccupyFixture, offerNoFieldFullPlanOccupy, inspectNoFieldFullPlanOccupy,
|
||||
finishNoFieldFullPlanOccupy, type NoFieldFullPlanOccupyFixture,
|
||||
} from './fixture-no-field-full-plan-occupy';
|
||||
|
||||
function assertCustody(fixture: NoFieldFullPlanOccupyFixture, 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 dealer/cache/removed face retains 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) {
|
||||
const counters = [player.reserves, player.tanks, ...Object.values(player.forces)];
|
||||
assert.equal(counters.reduce((sum, count) => sum + count, 0), 20,
|
||||
`${player.faction} retains twenty counters of its original physical ownership.`);
|
||||
assert.ok(counters.every(count => Number.isSafeInteger(count) && count >= 0));
|
||||
assert.ok(player.hand.length <= 4);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNativeMarker(fixture: NoFieldFullPlanOccupyFixture, game: Game): void {
|
||||
const before = occupyRicheseSeat(fixture.prescience, fixture.richese);
|
||||
const now = occupyRicheseSeat(game, fixture.richese);
|
||||
assert.deepEqual(now.noField, before.noField);
|
||||
assert.equal(now.noFieldEvent, before.noFieldEvent);
|
||||
assert.equal(now.reserves, before.reserves);
|
||||
assert.equal(now.forces[fixture.location] ?? 0, fixture.ordinary);
|
||||
assert.equal(now.tanks, before.tanks);
|
||||
}
|
||||
|
||||
function inspectAndSeal(fixture: NoFieldFullPlanOccupyFixture, offered = offerNoFieldFullPlanOccupy(fixture)): Game {
|
||||
assert.equal(offered.decision?.kind, 'fullPlanOffer');
|
||||
const inspection = inspectNoFieldFullPlanOccupy(fixture, offered);
|
||||
assert.equal(occupyRicheseSeat(inspection, fixture.atreides).specialKaramaUsed, true);
|
||||
assert.equal(occupyRicheseSeat(inspection, fixture.atreides).hand.some(card => card.id === fixture.inspectionCard), false);
|
||||
assert.equal(inspection.discard.filter(card => card.id === fixture.inspectionCard).length, 1);
|
||||
assertNativeMarker(fixture, inspection);
|
||||
const saved = structuredClone(inspection);
|
||||
assert.throws(() => applyAction(inspection, fixture.atreides, fixture.atreidesPlan), /requested by special/);
|
||||
assert.deepEqual(inspection, saved, 'The actual selected lead must seal before the inspecting opponent.');
|
||||
const first = applyAction(inspection, fixture.owner, fixture.targetPlan);
|
||||
assert.equal(first.battle!.revealed, false);
|
||||
assert.equal(first.decision, null, 'Reading the plan needs no acknowledgement or fabricated continuation.');
|
||||
assertNativeMarker(fixture, first);
|
||||
for (const actor of [fixture.ecaz, fixture.richese, fixture.atreides]) {
|
||||
const before = occupyRicheseSeat(fixture.prescience, actor);
|
||||
const sealed = occupyRicheseSeat(first, actor);
|
||||
assert.equal(sealed.spice, before.spice, 'The private read is not a battle payment.');
|
||||
assert.equal(sealed.reserves, before.reserves, 'R cannot spend the card holder reserves before the second seal.');
|
||||
assert.equal(sealed.tanks, before.tanks);
|
||||
assert.deepEqual(sealed.forces, before.forces, 'H cannot return any physical counter before the second seal.');
|
||||
}
|
||||
const privateView = viewGame(first, fixture.atreides).battle!;
|
||||
const insight = privateView.fullPlanInsight!;
|
||||
assert.ok(insight);
|
||||
assert.equal(insight.target, fixture.owner);
|
||||
assert.equal(insight.plan.dial, fixture.fixedEcazDial + fixture.variableDial);
|
||||
assert.equal(insight.plan.support, fixture.support);
|
||||
assert.equal(insight.plan.leader, fixture.targetPlan.leader);
|
||||
assert.equal(insight.plan.weapon, fixture.targetPlan.weapon);
|
||||
assert.equal(insight.plan.defense, fixture.targetPlan.defense);
|
||||
assert.deepEqual(insight.cards, occupyRicheseSeat(first, fixture.owner).hand.filter(card =>
|
||||
[fixture.targetPlan.weapon, fixture.targetPlan.defense].includes(card.id)),
|
||||
'The entitlement supplies the actual committed original physical card faces.');
|
||||
assert.equal(privateView.plans[fixture.owner], undefined, 'The special read does not turn the seal into an ordinary visible plan.');
|
||||
const concealed = occupyRicheseSeat(first, fixture.richese).noField!.deployed!;
|
||||
assert.equal(JSON.stringify(insight).includes(concealed.tokenId), false,
|
||||
'Complete plan entitlement is not ownership of the physical token identity.');
|
||||
for (const player of first.players) if (player.id !== fixture.atreides)
|
||||
assert.equal(viewGame(first, player.id).battle!.fullPlanInsight, null);
|
||||
assertCustody(fixture, first);
|
||||
return first;
|
||||
}
|
||||
|
||||
function secondSeal(fixture: NoFieldFullPlanOccupyFixture, first: Game): Game {
|
||||
const revealed = applyAction(first, fixture.atreides, fixture.atreidesPlan);
|
||||
const original = occupyRicheseSeat(fixture.prescience, fixture.richese);
|
||||
const richese = occupyRicheseSeat(revealed, fixture.richese);
|
||||
assert.equal(revealed.battle!.revealed, true);
|
||||
assert.equal(richese.noField!.deployed, null);
|
||||
assert.equal(richese.noField!.lastShipped, original.noField!.deployed!.tokenId);
|
||||
assert.deepEqual(richese.noField!.tokens, original.noField!.tokens);
|
||||
assert.notEqual(richese.noFieldEvent, original.noFieldEvent);
|
||||
assert.equal(richese.forces[fixture.location] ?? 0, fixture.ordinary + fixture.materializes,
|
||||
'Both seals add the reserve-capped marker to the original ordinary counters, without replacing them.');
|
||||
assert.equal(richese.reserves, original.reserves - fixture.materializes);
|
||||
assert.equal(richese.tanks, original.tanks);
|
||||
for (const player of revealed.players) assert.equal(viewGame(revealed, player.id).battle!.fullPlanInsight, null);
|
||||
const saved = structuredClone(revealed);
|
||||
assert.throws(() => applyAction(revealed, fixture.atreides, fixture.atreidesPlan));
|
||||
assert.deepEqual(revealed, saved, 'A repeated seal cannot materialize a marker twice.');
|
||||
assertCustody(fixture, revealed);
|
||||
return revealed;
|
||||
}
|
||||
|
||||
function assertHistory(fixture: NoFieldFullPlanOccupyFixture, revealed: Game, done: Game): void {
|
||||
assert.equal(done.battle, null);
|
||||
assert.equal(done.decision, null);
|
||||
const richese = occupyRicheseSeat(done, fixture.richese);
|
||||
assert.equal(richese.noField!.deployed, null);
|
||||
assert.equal(richese.noFieldEvent, occupyRicheseSeat(revealed, fixture.richese).noFieldEvent,
|
||||
'Payments, losses and aftermath cannot perform another marker reveal.');
|
||||
for (const player of done.players) {
|
||||
const history = viewGame(done, player.id).players.find(seat => seat.id === fixture.richese)!.noField!;
|
||||
assert.equal(history.deployed, null);
|
||||
assert.equal(history.lastUsed, fixture.marker);
|
||||
}
|
||||
assertCustody(fixture, done);
|
||||
}
|
||||
|
||||
function ordinaryDialRemainsBlocked(fixture: NoFieldFullPlanOccupyFixture): void {
|
||||
const before = fixture.prescience;
|
||||
assert.equal(before.battle!.preparation!.kind, 'prescience');
|
||||
const saved = structuredClone(before);
|
||||
assert.throws(() => applyAction(before, fixture.atreides, { type: 'prescience', field: 'dial' }), /may not inspect/);
|
||||
assert.deepEqual(before, saved);
|
||||
assertNativeMarker(fixture, before);
|
||||
const profile = viewGame(before, fixture.owner).battle!.ecazOccupy!.profile!;
|
||||
assert.equal(profile.forceOwner, fixture.richese,
|
||||
'Ordinary dial protection follows physical Richese ownership even when Ecaz forms the plan.');
|
||||
assert.equal(profile.fixedEcazDial, 3);
|
||||
assert.equal(profile.forces.normal, fixture.ordinary + fixture.materializes);
|
||||
assert.equal(profile.maxDial, 3 + fixture.ordinary + fixture.materializes);
|
||||
const rival = viewGame(before, fixture.atreides).battle!;
|
||||
assert.equal(rival.opponentForces, null);
|
||||
assert.equal(rival.ecazOccupy!.profile, null,
|
||||
'The distinct complete-plan entitlement does not broaden ordinary concealed force disclosure.');
|
||||
}
|
||||
|
||||
const cityIncome = (game: Game, actor: string, fixture: NoFieldFullPlanOccupyFixture) =>
|
||||
(occupyRicheseSeat(game, actor).forces[fixture.location] ?? 0) > 0 ? 2 : 0;
|
||||
|
||||
// One zero, one full positive and one capped positive marker, with both actual
|
||||
// selected faction leads. The capped Ecaz case also distinguishes paid strength
|
||||
// from unsupported physical losses without creating a cross-product of options.
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
for (const [marker, reserves] of [[0, 0], [3, 6], [5, 2]] as const)
|
||||
void test(`Private complete coalition plan: mixed marker ${marker}, reserves ${reserves}, ${lead} lead`, () => {
|
||||
const unsupported = lead === 'ecaz' && marker === 5;
|
||||
const fixture = noFieldFullPlanOccupyFixture({ lead, marker, richeseReserves: reserves,
|
||||
...(unsupported ? { variableDial: 2, support: 0 } : {}) });
|
||||
ordinaryDialRemainsBlocked(fixture);
|
||||
const first = inspectAndSeal(fixture);
|
||||
assert.equal(viewGame(first, fixture.atreides).battle!.opponentForces, null);
|
||||
assert.equal(viewGame(first, fixture.atreides).battle!.ecazOccupy!.profile, null);
|
||||
const revealed = secondSeal(fixture, first);
|
||||
const done = finishNoFieldFullPlanOccupy(fixture, revealed);
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
||||
const losses = unsupported ? fixture.variableDial * 2 : fixture.variableDial;
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0,
|
||||
fixture.ordinary + fixture.materializes - losses);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.prescience, fixture.richese).tanks, losses);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, reserves - fixture.materializes);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.prescience, fixture.ecaz).tanks, 3);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.owner).spice,
|
||||
occupyRicheseSeat(fixture.prescience, fixture.owner).spice - fixture.support + cityIncome(done, fixture.owner, fixture));
|
||||
const nonLead = fixture.owner === fixture.ecaz ? fixture.richese : fixture.ecaz;
|
||||
assert.equal(occupyRicheseSeat(done, nonLead).spice,
|
||||
occupyRicheseSeat(fixture.prescience, nonLead).spice + cityIncome(done, nonLead, fixture));
|
||||
assert.equal(occupyRicheseSeat(done, fixture.atreides).forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.atreides).tanks - occupyRicheseSeat(fixture.prescience, fixture.atreides).tanks, 8);
|
||||
assertHistory(fixture, revealed, done);
|
||||
});
|
||||
|
||||
for (const [lead, marker, reserves] of [['ecaz', 0, 0], ['ally', 5, 2]] as const)
|
||||
void test(`Marker-only ${lead} coalition still permits a private full plan, including marker ${marker}`, () => {
|
||||
const fixture = noFieldFullPlanOccupyFixture({ lead, marker, richeseReserves: reserves, ordinary: 0 });
|
||||
ordinaryDialRemainsBlocked(fixture);
|
||||
const revealed = secondSeal(fixture, inspectAndSeal(fixture));
|
||||
const done = finishNoFieldFullPlanOccupy(fixture, revealed);
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.prescience, fixture.richese).tanks,
|
||||
fixture.variableDial);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
|
||||
assertHistory(fixture, revealed, done);
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Distinct real CHOAM prevention leaves ${lead}'s own plan inspectable with native later marker reveal`, () => {
|
||||
const fixture = noFieldFullPlanOccupyFixture({ lead, marker: 5, richeseReserves: 2, prevented: true });
|
||||
assert.notEqual(fixture.preventionCard, fixture.inspectionCard);
|
||||
assert.equal(fixture.prescience.discard.filter(card => card.id === fixture.preventionCard).length, 1);
|
||||
assert.equal(occupyRicheseSeat(fixture.prescience, fixture.atreides).hand.some(card => card.id === fixture.inspectionCard), true);
|
||||
const profile = viewGame(fixture.prescience, fixture.owner).battle!.ecazOccupy!.profile!;
|
||||
assert.equal(profile.canceled, true);
|
||||
assert.equal(profile.forceOwner, fixture.owner);
|
||||
assert.equal(profile.fixedEcazDial, 0);
|
||||
assert.equal(profile.forces.normal, lead === 'ecaz' ? 5 : 4);
|
||||
assert.equal(profile.maxDial, lead === 'ecaz' ? 5 : 4);
|
||||
let offered: Game;
|
||||
if (lead === 'ecaz') {
|
||||
let asked = applyAction(fixture.prescience, fixture.atreides, { type: 'prescience', field: 'dial' });
|
||||
assert.equal(asked.battle!.preparation!.owner, fixture.ecaz);
|
||||
assertNativeMarker(fixture, asked);
|
||||
while (asked.response) asked = nextOccupyRichese(asked);
|
||||
const answered = applyAction(asked, fixture.ecaz, { type: 'prescienceAnswer', value: fixture.targetPlan.dial! });
|
||||
assert.equal(viewGame(answered, fixture.atreides).battle!.insight!.value, fixture.targetPlan.dial);
|
||||
assertNativeMarker(fixture, answered);
|
||||
offered = answered;
|
||||
} else {
|
||||
assert.throws(() => applyAction(fixture.prescience, fixture.atreides, { type: 'prescience', field: 'dial' }), /may not inspect/);
|
||||
offered = offerNoFieldFullPlanOccupy(fixture);
|
||||
}
|
||||
const revealed = secondSeal(fixture, inspectAndSeal(fixture, offered));
|
||||
const done = finishNoFieldFullPlanOccupy(fixture, revealed);
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
||||
const ecazLoss = lead === 'ecaz' ? fixture.variableDial : 0;
|
||||
const richeseLoss = lead === 'ally' ? fixture.variableDial : 0;
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location] ?? 0, 5 - ecazLoss);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0, 4 - richeseLoss);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.prescience, fixture.ecaz).tanks, ecazLoss);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.prescience, fixture.richese).tanks, richeseLoss);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.owner).spice,
|
||||
occupyRicheseSeat(fixture.prescience, fixture.owner).spice - fixture.support + cityIncome(done, fixture.owner, fixture));
|
||||
assertHistory(fixture, revealed, done);
|
||||
});
|
||||
|
||||
for (const [lead, opposingTraitor] of [['ally', false], ['ecaz', true]] as const)
|
||||
void test(`Inspected actual ${lead}-held H/R ${opposingTraitor ? 'is canceled by a real opposing Traitor' : 'settles its original owner returns and costs'}`, () => {
|
||||
const fixture = noFieldFullPlanOccupyFixture({ lead, utilities: true, opposingTraitor });
|
||||
ordinaryDialRemainsBlocked(fixture);
|
||||
const revealed = secondSeal(fixture, inspectAndSeal(fixture));
|
||||
const done = finishNoFieldFullPlanOccupy(fixture, revealed);
|
||||
assert.equal(done.lastBattleContext!.result, opposingTraitor ? 'traitor' : 'normal');
|
||||
assert.equal(done.lastBattleContext!.winner, opposingTraitor ? fixture.atreides : fixture.owner);
|
||||
for (const card of ['ecaz-reinforcements', 'ecaz-harass-withdraw'])
|
||||
assert.equal(done.discard.filter(face => face.id === card).length, 1);
|
||||
for (const actor of [fixture.ecaz, fixture.richese]) {
|
||||
const before = occupyRicheseSeat(fixture.prescience, actor);
|
||||
const after = occupyRicheseSeat(done, actor);
|
||||
const materialized = actor === fixture.richese ? fixture.materializes : 0;
|
||||
const reserveCost = actor === fixture.owner ? 3 : 0;
|
||||
const returned = !opposingTraitor && actor === fixture.owner ? 2 : 0;
|
||||
assert.equal(after.reserves, before.reserves - materialized - reserveCost + returned,
|
||||
'R spends the card holder reserves; an opposing real Traitor cancels every H return.');
|
||||
const losses = opposingTraitor ? actor === fixture.ecaz ? 5 : fixture.ordinary + materialized
|
||||
: actor === fixture.ecaz ? 3 : fixture.variableDial;
|
||||
assert.equal(after.tanks - before.tanks, losses + reserveCost);
|
||||
const remaining = opposingTraitor || actor === fixture.owner ? 0 : 2;
|
||||
assert.equal(after.forces[fixture.location] ?? 0, remaining);
|
||||
}
|
||||
if (opposingTraitor) {
|
||||
const committedLeader = fixture.targetPlan.leader;
|
||||
assert.ok(typeof committedLeader === 'string');
|
||||
assert.ok(occupyRicheseSeat(fixture.prescience, fixture.atreides).traitors.includes(committedLeader));
|
||||
assert.equal(occupyRicheseSeat(done, fixture.owner).leaders.find(face => face.id === committedLeader)!.dead, true);
|
||||
} else assert.equal(occupyRicheseSeat(done, fixture.owner).spice,
|
||||
occupyRicheseSeat(fixture.prescience, fixture.owner).spice - fixture.support);
|
||||
assertHistory(fixture, revealed, done);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
applyNoFieldFullPlanStep, createNoFieldFullPlanFixture, finishNoFieldFullPlanBattle,
|
||||
noFieldFullPlanPlayer, type NoFieldFullPlanFixture, type NoFieldFullPlanOptions,
|
||||
} from './fixture-no-field-full-plan';
|
||||
|
||||
function rejectedUnchanged(game: Game, actor: string, action: Action, reason?: RegExp) {
|
||||
const before = structuredClone(game);
|
||||
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
|
||||
else assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function assertCustody(fixture: NoFieldFullPlanFixture, 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();
|
||||
const actual = cards(game);
|
||||
assert.equal(actual.length, new Set(actual).size);
|
||||
assert.deepEqual(actual, 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) {
|
||||
assert.ok(player.hand.length <= 4);
|
||||
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 assertStillConcealed(fixture: NoFieldFullPlanFixture, game: Game) {
|
||||
const original = noFieldFullPlanPlayer(fixture.game, fixture.owner);
|
||||
const current = noFieldFullPlanPlayer(game, fixture.owner);
|
||||
assert.deepEqual(current.noField, original.noField);
|
||||
assert.equal(current.noFieldEvent, original.noFieldEvent);
|
||||
assert.equal(current.reserves, original.reserves);
|
||||
assert.deepEqual(current.forces, original.forces);
|
||||
assert.equal(current.spice, original.spice);
|
||||
for (const actor of [fixture.atreides, fixture.observer]) {
|
||||
const visible = viewGame(game, actor).players.find(p => p.id === fixture.owner)!;
|
||||
assert.ok(visible.noField?.deployed);
|
||||
assert.equal(visible.noField.lastUsed, null);
|
||||
assert.equal('private' in visible.noField, false);
|
||||
assert.equal(visible.hand, undefined);
|
||||
}
|
||||
assertCustody(fixture, game);
|
||||
}
|
||||
function assertNativeMaterialization(fixture: NoFieldFullPlanFixture, revealed: Game, done: Game) {
|
||||
const original = noFieldFullPlanPlayer(fixture.game, fixture.owner);
|
||||
const exposed = noFieldFullPlanPlayer(revealed, fixture.owner);
|
||||
const finished = noFieldFullPlanPlayer(done, fixture.owner);
|
||||
assert.equal(revealed.battle?.revealed, true);
|
||||
assert.equal(exposed.noField!.deployed, null);
|
||||
assert.equal(exposed.reserves, original.reserves - fixture.materializes);
|
||||
assert.equal(exposed.forces[fixture.location] ?? 0,
|
||||
(original.forces[fixture.location] ?? 0) + fixture.materializes);
|
||||
assert.equal(exposed.forces[fixture.otherLocation] ?? 0, original.forces[fixture.otherLocation] ?? 0);
|
||||
assert.deepEqual(exposed.noField!.tokens, original.noField!.tokens);
|
||||
assert.equal(exposed.noField!.lastShipped, fixture.token.id);
|
||||
assert.notEqual(exposed.noFieldEvent, original.noFieldEvent);
|
||||
assert.equal(done.battle, null);
|
||||
assert.equal(finished.noField!.deployed, null);
|
||||
assert.deepEqual(finished.noField!.tokens, original.noField!.tokens);
|
||||
assert.equal(finished.noField!.lastShipped, fixture.token.id);
|
||||
assert.equal(finished.noFieldEvent, exposed.noFieldEvent);
|
||||
for (const actor of [fixture.owner, fixture.atreides, fixture.observer]) {
|
||||
const publicOwner = viewGame(done, actor).players.find(p => p.id === fixture.owner)!;
|
||||
assert.equal(publicOwner.noField!.deployed, null);
|
||||
assert.equal(publicOwner.noField!.lastUsed, fixture.token.value);
|
||||
assert.equal(viewGame(revealed, actor).battle!.fullPlanInsight, null);
|
||||
assert.deepEqual(viewGame(revealed, actor).battle!.plans[fixture.owner], revealed.battle!.plans[fixture.owner]);
|
||||
}
|
||||
assertCustody(fixture, revealed);
|
||||
assertCustody(fixture, done);
|
||||
}
|
||||
|
||||
const materializationCases: { name: string; options: NoFieldFullPlanOptions }[] = [
|
||||
{ name: 'zero token still commits a leader and both physical battle cards', options: { marker: 0 } },
|
||||
{ name: 'positive token materializes three counters only at both seals', options: { marker: 3 } },
|
||||
{ name: 'five token is capped by two actual own reserves', options: { marker: 5, reserves: 2, dial: 1 } },
|
||||
{ name: 'ordinary counters in two sectors combine with the marker pool', options: {
|
||||
marker: 3, ordinaryAtMarker: 1, ordinaryOtherSector: 2, dial: 3.5, support: 2,
|
||||
} },
|
||||
];
|
||||
for (const { name, options } of materializationCases) {
|
||||
void test(`Advanced special full-plan inspection: ${name}`, () => {
|
||||
const fixture = createNoFieldFullPlanFixture(options);
|
||||
const { owner, atreides, observer, cards } = fixture;
|
||||
const original = noFieldFullPlanPlayer(fixture.game, owner);
|
||||
assert.ok(noFieldFullPlanPlayer(fixture.afterShipment, owner).noField!.deployed);
|
||||
assert.equal(fixture.beforePrescience.battle?.preLeader?.closed, true);
|
||||
assert.equal(fixture.beforePrescience.battle?.preparation?.kind, 'prescience');
|
||||
rejectedUnchanged(fixture.beforePrescience, atreides, { type: 'prescience', field: 'dial' }, /may not inspect the number dialed/);
|
||||
rejectedUnchanged(fixture.beforePrescience, atreides, fixture.inspectStep.action, /preparation/);
|
||||
assert.equal(fixture.beforeAnswer.battle?.preparation?.kind, 'prescienceAnswer');
|
||||
assert.equal(viewGame(fixture.game, atreides).battle!.insight!.field, 'leader');
|
||||
assert.equal(viewGame(fixture.game, atreides).battle!.fullPlanInsight, null);
|
||||
assert.equal(fixture.game.decision?.kind, 'fullPlanOffer');
|
||||
|
||||
const waiting = applyNoFieldFullPlanStep(fixture.game, fixture.inspectStep);
|
||||
assert.equal(noFieldFullPlanPlayer(waiting, atreides).specialKaramaUsed, true);
|
||||
assert.equal(noFieldFullPlanPlayer(waiting, atreides).hand.some(c => c.id === cards.karama.id), false);
|
||||
assert.equal(waiting.discard.filter(c => c.id === cards.karama.id).length, 1);
|
||||
assert.ok(noFieldFullPlanPlayer(waiting, atreides).hand.some(c => c.id === cards.spareKarama.id));
|
||||
assert.equal(waiting.response, null);
|
||||
assert.equal(waiting.decision, null);
|
||||
rejectedUnchanged(waiting, atreides, fixture.planActions[1].action, /requested by special/);
|
||||
rejectedUnchanged(waiting, atreides, { ...fixture.inspectStep.action, card: cards.spareKarama.id }, /already been used/);
|
||||
// Ordinary prescience remains a commitment even when the separate full-plan power is spent.
|
||||
const changedLeader = original.leaders.find(leader =>
|
||||
!leader.dead && leader.id !== fixture.planActions[0].action.leader)!;
|
||||
rejectedUnchanged(waiting, owner, { ...fixture.planActions[0].action, leader: changedLeader.id }, /remain unchanged/);
|
||||
assertStillConcealed(fixture, waiting);
|
||||
|
||||
const reading = applyNoFieldFullPlanStep(waiting, fixture.planActions[0]);
|
||||
assert.equal(reading.battle!.revealed, false);
|
||||
assert.equal(reading.decision, null);
|
||||
assert.equal(reading.response, null);
|
||||
assertStillConcealed(fixture, reading);
|
||||
const insight = viewGame(reading, atreides).battle!.fullPlanInsight!;
|
||||
assert.equal(insight.target, owner);
|
||||
assert.equal(insight.plan.dial, fixture.planActions[0].action.dial);
|
||||
assert.equal(insight.plan.support, fixture.planActions[0].action.support);
|
||||
assert.equal(insight.plan.leader, fixture.planActions[0].action.leader);
|
||||
assert.equal(insight.plan.weapon, cards.weapon.id);
|
||||
assert.equal(insight.plan.defense, cards.defense.id);
|
||||
assert.deepEqual(insight.cards.map(c => ({ id: c.id, name: c.name })).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
[cards.weapon, cards.defense].map(c => ({ id: c.id, name: c.name })).sort((a, b) => a.id.localeCompare(b.id)));
|
||||
assert.equal(viewGame(reading, atreides).battle!.plans[owner], undefined);
|
||||
assert.deepEqual(viewGame(reading, owner).battle!.plans[owner], reading.battle!.plans[owner]);
|
||||
for (const actor of [owner, observer]) assert.equal(viewGame(reading, actor).battle!.fullPlanInsight, null);
|
||||
assert.equal(viewGame(reading, observer).battle!.plans[owner], undefined);
|
||||
assert.equal(viewGame(reading, observer).battle!.insight, null);
|
||||
const newLogs = reading.log.slice(fixture.game.log.length);
|
||||
assert.ok(newLogs.every(entry => !entry.text.includes(cards.weapon.name) && !entry.text.includes(cards.defense.name)));
|
||||
rejectedUnchanged(reading, owner, { ...fixture.planActions[0].action, dial: 0 }, /not available/);
|
||||
rejectedUnchanged(reading, owner, { ...fixture.planActions[0].action, defense: null }, /not available/);
|
||||
rejectedUnchanged(reading, atreides, { type: 'decision', continue: true }, /not available/);
|
||||
|
||||
// The private read continues directly into the original opposing plan; no acknowledgement or marker action.
|
||||
const revealed = applyNoFieldFullPlanStep(reading, fixture.planActions[1]);
|
||||
const done = finishNoFieldFullPlanBattle(revealed);
|
||||
assertNativeMaterialization(fixture, revealed, done);
|
||||
assert.equal(done.lastBattleContext?.winner, atreides);
|
||||
const lost = noFieldFullPlanPlayer(done, owner);
|
||||
const battlePool = (original.forces[fixture.location] ?? 0) + (original.forces[fixture.otherLocation] ?? 0) + fixture.materializes;
|
||||
assert.equal(lost.tanks, battlePool);
|
||||
assert.equal(lost.forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(lost.forces[fixture.otherLocation] ?? 0, 0);
|
||||
assert.equal(lost.spice, original.spice - Number(fixture.planActions[0].action.support));
|
||||
assert.ok(done.discard.some(c => c.id === cards.weapon.id));
|
||||
assert.ok(done.discard.some(c => c.id === cards.defense.id));
|
||||
assert.equal(done.discard.filter(c => c.id === cards.karama.id).length, 1);
|
||||
assert.equal(lost.leaders.find(l => l.id === fixture.planActions[0].action.leader)!.dead, false);
|
||||
assert.equal(noFieldFullPlanPlayer(done, atreides).specialKaramaUsed, true);
|
||||
});
|
||||
}
|
||||
|
||||
void test('each permitted ordinary element can answer against a concealed marker, then declining preserves the physical power and ordinary planning', () => {
|
||||
for (const ordinaryField of ['leader', 'weapon', 'defense'] as const) {
|
||||
const fixture = createNoFieldFullPlanFixture({ marker: 3, ordinaryField });
|
||||
const { owner, atreides, cards } = fixture;
|
||||
rejectedUnchanged(fixture.beforePrescience, atreides, { type: 'prescience', field: 'dial' }, /may not inspect the number dialed/);
|
||||
assert.equal(fixture.beforeAnswer.battle?.preparation?.kind, 'prescienceAnswer');
|
||||
assert.equal(viewGame(fixture.game, atreides).battle!.insight!.field, ordinaryField);
|
||||
assert.equal(viewGame(fixture.game, atreides).battle!.insight!.value, fixture.answerStep.action.value);
|
||||
const declined = applyNoFieldFullPlanStep(fixture.game, fixture.declineStep);
|
||||
assert.equal(noFieldFullPlanPlayer(declined, atreides).specialKaramaUsed, undefined);
|
||||
assert.ok(noFieldFullPlanPlayer(declined, atreides).hand.some(c => c.id === cards.karama.id));
|
||||
rejectedUnchanged(declined, atreides, fixture.inspectStep.action, /preparation/);
|
||||
// No special target-first gate survives decline: Atreides may seal first.
|
||||
const first = applyNoFieldFullPlanStep(declined, fixture.planActions[1]);
|
||||
assert.equal(first.battle!.revealed, false);
|
||||
assert.equal(viewGame(first, owner).battle!.plans[atreides], undefined);
|
||||
assertStillConcealed(fixture, first);
|
||||
const revealed = applyNoFieldFullPlanStep(first, fixture.planActions[0]);
|
||||
const done = finishNoFieldFullPlanBattle(revealed);
|
||||
assertNativeMaterialization(fixture, revealed, done);
|
||||
assert.equal(done.lastBattleContext?.winner, atreides);
|
||||
assert.ok(noFieldFullPlanPlayer(done, atreides).hand.some(c => c.id === cards.karama.id));
|
||||
assert.equal(noFieldFullPlanPlayer(done, atreides).specialKaramaUsed, undefined);
|
||||
assert.equal(done.discard.some(c => c.id === cards.karama.id), false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a real conserved Traitor declaration after inspected mixed-plan reveal uses native losses and cannot reveal the marker twice', () => {
|
||||
const fixture = createNoFieldFullPlanFixture({ marker: 5, reserves: 2,
|
||||
ordinaryAtMarker: 1, ordinaryOtherSector: 1, dial: 2, traitor: 'atreides' });
|
||||
let game = applyNoFieldFullPlanStep(fixture.game, fixture.inspectStep);
|
||||
game = applyNoFieldFullPlanStep(game, fixture.planActions[0]);
|
||||
assertStillConcealed(fixture, game);
|
||||
const revealed = applyNoFieldFullPlanStep(game, fixture.planActions[1]);
|
||||
const done = finishNoFieldFullPlanBattle(revealed, [fixture.atreides]);
|
||||
assertNativeMaterialization(fixture, revealed, done);
|
||||
assert.equal(done.lastBattleContext?.winner, fixture.atreides);
|
||||
const owner = noFieldFullPlanPlayer(done, fixture.owner);
|
||||
assert.equal(owner.tanks, 4);
|
||||
assert.equal(owner.leaders.find(l => l.id === fixture.planActions[0].action.leader)!.dead, true);
|
||||
assert.equal(noFieldFullPlanPlayer(done, fixture.atreides).tanks, 0);
|
||||
assert.equal(noFieldFullPlanPlayer(done, fixture.atreides).forces[fixture.location], 5);
|
||||
});
|
||||
Reference in new issue
Block a user