Offer Semuta on native Ecaz and Moritani cancellation costs
This commit is contained in:
1 parent
12e3a448c6
commit
56d55a1565
11 files changed
+1669
-11
No files matched your search
@@ -0,0 +1,203 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { quotePlacementCancellation } from '../game/placement-cancellation';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import {
|
||||
actSemutaRicheseCacheKarama, nextSemutaRicheseCacheKarama,
|
||||
semutaRicheseCacheKaramaClean, semutaRicheseCacheKaramaInventory, semutaRicheseCacheKaramaPlayer,
|
||||
} from './fixture-semuta-richese-cache-karama';
|
||||
import { passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement } from './fixture-semuta-fremen-guild-movement';
|
||||
|
||||
export type SemutaE3PlacementKind = 'ecazPlacement' | 'moritaniPlacement' | 'moritaniDuke';
|
||||
export type SemutaE3PlacementStep = { actor: string; action: Action };
|
||||
export const actSemutaE3Placement = actSemutaRicheseCacheKarama;
|
||||
export const passSemutaE3Placement = passSemutaFremenGuildMovement;
|
||||
export const claimSemutaE3Placement = claimSemutaFremenGuildMovement;
|
||||
export const semutaE3PlacementPlayer = semutaRicheseCacheKaramaPlayer;
|
||||
export const semutaE3PlacementInventory = semutaRicheseCacheKaramaInventory;
|
||||
export const semutaE3PlacementClean = semutaRicheseCacheKaramaClean;
|
||||
|
||||
/** Fresh declarations stay explicit. Mori's Duke source really starts in an
|
||||
* empty Arrakeen; its other source uses the native ordinary Oh Gap placement. */
|
||||
export function nextSemutaE3Placement(game: Game,
|
||||
kind: SemutaE3PlacementKind = 'ecazPlacement'): SemutaE3PlacementStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance ||
|
||||
['ecazPlacement', 'moritaniPlacement', 'moritaniDuke'].includes(game.response?.kind ?? '')) return null;
|
||||
if (!game.response && !game.phaseOpening && game.status === 'setup' && !game.setupStage &&
|
||||
game.decision?.kind === 'moritaniSetup')
|
||||
return { actor: game.decision.player, action: { type: 'decision',
|
||||
territory: kind === 'moritaniDuke' ? 'arrakeen' : 'oh_gap', sector: kind === 'moritaniDuke' ? 10 : 9 } };
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'moritaniPlacement') return null;
|
||||
const step = nextSemutaRicheseCacheKarama(game);
|
||||
if (step?.action.type === 'stormDial' && game.turn === 1)
|
||||
return { actor: step.actor, action: { ...step.action, amount: 0 } };
|
||||
return step;
|
||||
}
|
||||
export function advanceSemutaE3Placement(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaE3PlacementStep[] = [], kind: SemutaE3PlacementKind = 'ecazPlacement'): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaE3Placement(game, kind); assert.ok(step, 'Choose the actual E3 declaration or fresh-cost reaction');
|
||||
game = actSemutaE3Placement(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The genuine E3 placement source did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaE3Placement(game: Game, actions: SemutaE3PlacementStep[] = []): Game {
|
||||
const kind = game.response?.kind;
|
||||
assert.ok(kind === 'ecazPlacement' || kind === 'moritaniPlacement' || kind === 'moritaniDuke');
|
||||
for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actSemutaE3Placement(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind); return game;
|
||||
}
|
||||
/** Close only the still-owned positive Ecaz chooser or the Duke suffix's real
|
||||
* CHOAM phase-closing market. No new movement or battle choice is made. */
|
||||
export function finishSemutaE3Placement(game: Game, kind: SemutaE3PlacementKind,
|
||||
actions: SemutaE3PlacementStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.notEqual(game.response?.kind, kind);
|
||||
if (kind === 'ecazPlacement' && game.decision?.kind === 'ecazPlacement')
|
||||
game = actSemutaE3Placement(game, game.decision.player, { type: 'decision', decline: true }, actions);
|
||||
if (kind === 'moritaniDuke' && game.phase === 5) {
|
||||
assert.equal(game.response ?? null, null);
|
||||
assert.equal(game.decision?.kind, 'choamMarket');
|
||||
assert.equal(game.choamMarket?.resume, 'phase');
|
||||
assert.equal(game.choamMarket?.owner, game.decision!.player);
|
||||
game = actSemutaE3Placement(game, game.decision!.player, { type: 'decision', done: true }, actions);
|
||||
}
|
||||
assert.equal(game.phase, kind === 'ecazPlacement' ? 5 : kind === 'moritaniPlacement' ? 8 : 6);
|
||||
assert.equal(game.turn, 1);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Bare public Basic E3 source. All setup, cache purchase, placement, shipment,
|
||||
* ready/end controls are actual native actions, without played-state staging. */
|
||||
export function createSemutaE3PlacementFixture(options: { kind: SemutaE3PlacementKind; second?: boolean }) {
|
||||
const kind = options.kind, second = options.second ?? false;
|
||||
assert.ok(!second || kind === 'ecazPlacement');
|
||||
const richese = 'richese', holder = 'choam', owner = kind === 'ecazPlacement' ? 'ecaz' : 'moritani', spender = 'emperor';
|
||||
let game = createGame('SEMUTAE3PLACEMENT', newPlayer(richese, 'Richese', 'richese'), false, ['choam', 'ecaz']);
|
||||
for (const faction of [holder, owner, spender] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaE3PlacementStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaE3Placement(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaE3Placement(game, until, actions, kind); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup, entry: 'public-bare-basic-native-start' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaE3PlacementInventory(game); assert.equal(originalInventory.length, 45);
|
||||
assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 3);
|
||||
assert.equal(ordinary.length, 3);
|
||||
const deal = [...ordinary.map(card => card.id), printed.id], cost = printed.id;
|
||||
const dealOrder = deal.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { card, from, position };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
|
||||
assert.ok(semutaE3PlacementPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery &&
|
||||
['hagga_basin', 'rock_outcroppings', 'broken_land'].includes(card.territory)); assert.ok(from >= 0);
|
||||
const firstSpice = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(firstSpice);
|
||||
const spiceOrder = [{ position: 0, from, card: structuredClone(firstSpice) }];
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaE3PlacementStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaE3PlacementPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaE3Placement(game, kind); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaE3PlacementPlayer(beforeBid, holder).spice - semutaE3PlacementPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaE3PlacementPlayer(game, richese).spice - semutaE3PlacementPlayer(beforeBid, richese).spice, 1);
|
||||
let firstPlacement: ReturnType<typeof transition> | null = null;
|
||||
const shipments: ReturnType<typeof transition>[] = [];
|
||||
let nativeAction: Action, declaration: ReturnType<typeof transition>, token: string | null = null;
|
||||
let territory: string | null = null;
|
||||
if (kind === 'ecazPlacement') {
|
||||
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
if (second) {
|
||||
const first = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz' && token.zone === 'supply'); assert.ok(first);
|
||||
firstPlacement = transition(() => {
|
||||
act(owner, { type: 'decision', token: first.id, territory: 'sietch_tabr' });
|
||||
assert.equal(game.pendingEcazPlacement?.cost, 1);
|
||||
game = allowSemutaE3Placement(game, actions);
|
||||
});
|
||||
assert.equal(semutaE3PlacementPlayer(firstPlacement.before, owner).spice - semutaE3PlacementPlayer(game, owner).spice, 1);
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(candidate => candidate.id === first.id)?.location, 'sietch_tabr');
|
||||
assert.equal(game.decision?.kind, 'ecazPlacement');
|
||||
}
|
||||
const selected = game.ecazAmbassadors!.tokens.find(token => token.zone === 'supply' &&
|
||||
(second ? token.effect !== 'ecaz' : token.effect === 'ecaz')); assert.ok(selected);
|
||||
token = selected.id; territory = second ? 'habbanya_ridge_sietch' : 'sietch_tabr';
|
||||
nativeAction = { type: 'decision', token, territory };
|
||||
declaration = transition(() => { act(owner, nativeAction); });
|
||||
assert.equal(game.pendingEcazPlacement?.cost, second ? 2 : 1);
|
||||
} else if (kind === 'moritaniPlacement') {
|
||||
reach(state => state.phase === 8 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
|
||||
const selected = game.moritaniTerror!.tokens.find(token => token.kind === 'sabotage' && token.status === 'available'); assert.ok(selected);
|
||||
token = selected.id; territory = 'carthag';
|
||||
nativeAction = { type: 'decision', token, territory };
|
||||
declaration = transition(() => { act(owner, nativeAction); });
|
||||
} else {
|
||||
assert.equal(semutaE3PlacementPlayer(game, owner).forces['arrakeen:10'], 6);
|
||||
for (const [actor, destination, sector] of [[richese, 'carthag', 11], [owner, 'carthag', 11], [spender, 'arrakeen', 10]] as const) {
|
||||
reach(state => state.phase === 5 && state.active === actor && semutaE3PlacementClean(state));
|
||||
shipments.push(transition(() => {
|
||||
act(actor, { type: 'ship', territory: destination, sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(semutaE3PlacementClean);
|
||||
}));
|
||||
if (actor !== spender) act(actor, { type: 'endMovement' });
|
||||
}
|
||||
nativeAction = { type: 'endMovement' };
|
||||
declaration = transition(() => { act(spender, nativeAction); });
|
||||
assert.deepEqual(game.movementRemaining, []); assert.equal(game.dukeAcquisitionTurn, game.turn);
|
||||
}
|
||||
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response!);
|
||||
const originalPending = structuredClone(kind === 'ecazPlacement' ? game.pendingEcazPlacement : game.pendingMoritaniPlacement ?? null);
|
||||
const originalAmbassadors = structuredClone(game.ecazAmbassadors ?? null), originalTerror = structuredClone(game.moritaniTerror ?? null);
|
||||
const originalDuke = structuredClone(game.dukeVidal ?? null), expected = quotePlacementCancellation(game, game.response!);
|
||||
assert.ok(expected);
|
||||
assert.ok(semutaE3PlacementPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaE3PlacementPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaE3PlacementInventory(game), originalInventory);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return { game, source, beforeCost: source, kind, second, advanced: false, richese, holder, spender, owner,
|
||||
cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, token, territory,
|
||||
originalResponse, originalPending, originalAmbassadors, originalTerror, originalDuke, expected,
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder,
|
||||
originalSpice, spiceOrder, acquired, acquisition, firstPlacement, shipments, declaration,
|
||||
originalInventory, actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Public bare Basic R/C/Ecaz/E or R/C/Moritani/E ready/start uses its existing Semuta E3 gate. Original main35/cache10 remain physical; only unopened starting/Spice positions and scalar lotteries are selected. Emperor really receives printed Karama and CHOAM buys cache Semuta for1. Advanced E3 admission is unchanged.',
|
||||
kind === 'ecazPlacement' ? second
|
||||
? 'At native end-Revival Ecaz really declares/permits reusable Ecaz at Tabr14 and pays1. It then declares a real remaining supply token at Habbanya17 for next native price2. Stop before pending second cost/payment/placement; the first physical placement stays committed.'
|
||||
: 'At native end-Revival Ecaz declares its actual reusable supply token at Tabr14 for1. Stop before pending payment/physical placement.' :
|
||||
kind === 'moritaniPlacement' ? 'Moritani really chooses Oh Gap9 for its six setup counters. Actual first-turn phases finish without a battle. At native Mentat it declares its actual available Sabotage token at Carthag; no Terror token has moved.' :
|
||||
'Moritani really chooses empty Arrakeen10 for its six setup counters. During original combined turns Richese ships1 to Carthag, Moritani ships1 there, then Emperor ships1 to Arrakeen. Actual final endMovement earns the native Duke opportunity from two unresolved stronghold battles; no board, Duke or qualification is installed.',
|
||||
'Stop before separate printed cancellation cost. Native pure placement quote supplies denial receipts; original continuation closes Revival, finishes Mentat placement, or opens the real CHOAM closing market at completed Movement. Its actual Done action then advances to Battle once. No prior payment is refunded and no pending placement fee is charged on denial.',
|
||||
] };
|
||||
}
|
||||
export type SemutaE3PlacementFixture = ReturnType<typeof createSemutaE3PlacementFixture>;
|
||||
@@ -0,0 +1,341 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from '../game/ecaz-occupy-battle';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaDisasterWorm, nextSemutaDisasterWorm, passSemutaDisasterWorm, claimSemutaDisasterWorm,
|
||||
semutaDisasterWormPlayer, semutaDisasterWormInventory, semutaDisasterWormClean,
|
||||
} from './fixture-semuta-disaster-worm';
|
||||
|
||||
export type SemutaEcazEconomyKind = 'ecazCollection' | 'ecazOccupy';
|
||||
export type SemutaEcazEconomyStep = { actor: string; action: Action };
|
||||
export const actSemutaEcazEconomy = actSemutaDisasterWorm;
|
||||
export const passSemutaEcazEconomy = passSemutaDisasterWorm;
|
||||
export const claimSemutaEcazEconomy = claimSemutaDisasterWorm;
|
||||
export const semutaEcazEconomyPlayer = semutaDisasterWormPlayer;
|
||||
export const semutaEcazEconomyInventory = semutaDisasterWormInventory;
|
||||
export const semutaEcazEconomyClean = semutaDisasterWormClean;
|
||||
export const semutaEcazEconomyPhysical = (game: Game) => ({
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, elites: p.elites,
|
||||
reserves: p.reserves, tanks: p.tanks, shipped: p.shipped, moved: p.moved, ally: p.ally })),
|
||||
spice: game.spice, aid: game.aid, ambassadors: game.ecazAmbassadors,
|
||||
});
|
||||
|
||||
/** Original declarations and private plans remain caller-owned. */
|
||||
export function nextSemutaEcazEconomy(game: Game): SemutaEcazEconomyStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const ecaz = game.players.find(p => p.faction === 'ecaz');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20 &&
|
||||
viewGame(game, ecaz.id).setupPending.includes(ecaz.id))
|
||||
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'richeseAllyOpportunity')
|
||||
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'ecazPlacement' || decision?.kind === 'ecazAmbassador' ||
|
||||
decision?.kind === 'ecazBattleLead' || decision?.kind === 'ecazSpice') return null;
|
||||
}
|
||||
return nextSemutaDisasterWorm(game);
|
||||
}
|
||||
export const nextSemutaEcazEconomyStep = nextSemutaEcazEconomy;
|
||||
export function advanceSemutaEcazEconomy(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaEcazEconomyStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaEcazEconomy(game);
|
||||
assert.ok(step, `Choose the original Ecaz/private control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaEcazEconomy(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The native Ecaz economy programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaEcazEconomy(game: Game, actions: SemutaEcazEconomyStep[] = []): Game {
|
||||
const kind = game.response?.kind; assert.ok(kind === 'ecazOccupy' || kind === 'ecazCollection');
|
||||
for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaEcazEconomy(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind); return game;
|
||||
}
|
||||
/** Finish only original checks/preparation; no private plan is sealed. A
|
||||
* Collection caller must explicitly settle any earned shared allocation. */
|
||||
export function finishSemutaEcazEconomy(game: Game, actions: SemutaEcazEconomyStep[] = []): Game {
|
||||
return advanceSemutaEcazEconomy(game, state => state.phase === 6 && !!state.battle &&
|
||||
!state.battle.revealed && semutaEcazEconomyClean(state) && !state.battle.preparation &&
|
||||
state.battle.preLeader?.closed !== false || state.phase === 7 && semutaEcazEconomyClean(state) &&
|
||||
state.ecazCollection?.stage === 'complete', actions);
|
||||
}
|
||||
|
||||
/** Native physical profile for this fixed single-sector, untyped Basic source.
|
||||
* It reads the earned original groups, never merges or installs an army. */
|
||||
export function semutaEcazEconomyProfile(game: Game): EcazOccupyBattleProfile {
|
||||
const b = game.battle, occupy = b?.ecazOccupy; assert.ok(b && occupy?.lead);
|
||||
const forces = (id: string) => ({ normal: Object.entries(semutaEcazEconomyPlayer(game, id).forces)
|
||||
.filter(([key]) => key.startsWith(`${b.territory}:`)).reduce((sum, [, amount]) => sum + amount, 0),
|
||||
elite: 0, eliteStrength: 1 as const, freeSupport: true });
|
||||
const ecaz = semutaEcazEconomyPlayer(game, occupy.ecaz), ally = semutaEcazEconomyPlayer(game, occupy.ally);
|
||||
return quoteEcazOccupyBattle({ advanced: false, battleOrderActor: b.chooser ?? b.attacker,
|
||||
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: forces(ecaz.id) },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: forces(ally.id) },
|
||||
lead: occupy.lead, canceled: occupy.canceled });
|
||||
}
|
||||
|
||||
/** Actual Basic Ecaz/Richese shared stronghold and chosen Occupy lead. The
|
||||
* Advanced-only Collection bonus cannot produce a Basic cancellation source. */
|
||||
export function createSemutaEcazOccupyFixture(options: {
|
||||
lead?: 'ecaz' | 'ally';
|
||||
} = {}) {
|
||||
const kind = 'ecazOccupy' as const, leadChoice = options.lead ?? 'ecaz';
|
||||
const owner = 'ecaz', ally = 'richese', holder = 'choam', opponent = 'emperor', spender = 'guild';
|
||||
const lead = leadChoice === 'ecaz' ? owner : ally, territory = 'carthag', sector = 11, location = `${territory}:${sector}`;
|
||||
let game = createGame('SEMUTAECAZECONOMY', newPlayer(owner, 'Ecaz', 'ecaz'), false, ['ecaz', 'choam']);
|
||||
for (const faction of ['richese', 'choam', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaEcazEconomyStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaEcazEconomy(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaEcazEconomy(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
|
||||
assert.equal(game.semutaPreview, true); assert.equal(game.ecazOccupyPreview, true);
|
||||
assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaEcazEconomyInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'), shield = game.deck.find(card => card.kind === 'shield');
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3);
|
||||
assert.ok(printed && shield); assert.equal(ordinary.length, 3);
|
||||
const selected = [...ordinary, shield, printed];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), cost = printed.id;
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.deepEqual(semutaEcazEconomyPlayer(game, owner).forces, { 'imperial_basin:9': 6 });
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const from = game.spiceDeck.findIndex(card => 'territory' in card && card.territory === 'funeral_plain' && !card.discovery);
|
||||
assert.ok(from >= 0); const spiceCard = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(spiceCard);
|
||||
const spiceOrder = [{ position: 0, from, card: structuredClone(spiceCard) }];
|
||||
let beforeBid: Game | undefined, bid: SemutaEcazEconomyStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaEcazEconomyPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaEcazEconomy(game); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaEcazEconomyPlayer(beforeBid, holder).spice - semutaEcazEconomyPlayer(game, holder).spice, 1);
|
||||
reach(state => state.phase === 4 && state.decision?.kind === 'ecazPlacement' && !state.response && !state.phaseOpening);
|
||||
const selfToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!;
|
||||
const placement = transition(() => {
|
||||
act(owner, { type: 'decision', token: selfToken.id, territory });
|
||||
reach(state => state.decision?.kind === 'ecazPlacement' && !state.response && !state.phaseOpening);
|
||||
act(owner, { type: 'decision', decline: true });
|
||||
});
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaEcazEconomyClean(state));
|
||||
assert.equal(game.storm, 1);
|
||||
const ecazShipment = transition(() => {
|
||||
act(owner, { type: 'ship', territory, sector, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(semutaEcazEconomyClean); act(owner, { type: 'endMovement' });
|
||||
});
|
||||
reach(state => state.phase === 5 && state.active === ally && semutaEcazEconomyClean(state));
|
||||
const alliance = transition(() => {
|
||||
act(ally, { type: 'ship', territory, sector, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
act(owner, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: owner, choice: 'alliance' });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'allianceReply');
|
||||
act(ally, { type: 'decision', event: game.pendingAmbassador!.event, accept: true });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'loan');
|
||||
act(owner, { type: 'decision', event: game.pendingAmbassador!.event, loan: false });
|
||||
reach(semutaEcazEconomyClean);
|
||||
});
|
||||
assert.equal(semutaEcazEconomyPlayer(game, owner).ally, ally); assert.equal(semutaEcazEconomyPlayer(game, ally).ally, owner);
|
||||
assert.equal(semutaEcazEconomyPlayer(game, owner).forces[location], 2); assert.equal(semutaEcazEconomyPlayer(game, ally).forces[location], 2);
|
||||
const sharedStronghold = structuredClone(game), basicCollectionQuote = quoteSpiceCollection(game);
|
||||
assert.equal(basicCollectionQuote.collectionBonus, null, 'Native Basic has no Advanced shared-stronghold bank income');
|
||||
act(ally, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === opponent && semutaEcazEconomyClean(state));
|
||||
const opponentShipment = transition(() => {
|
||||
act(opponent, { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(semutaEcazEconomyClean); act(opponent, { type: 'endMovement' });
|
||||
});
|
||||
reach(state => state.phase === 6 && semutaEcazEconomyClean(state) && !!state.active);
|
||||
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(choice =>
|
||||
choice.territory === territory && [choice.attacker, choice.defender].includes(opponent)); assert.ok(choice);
|
||||
const battleAction: Action = { type: 'chooseBattle', territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker };
|
||||
act(choice.chooser, battleAction);
|
||||
assert.equal(game.decision?.kind, 'ecazBattleLead'); assert.equal(game.decision.player, owner);
|
||||
const beforeLead = structuredClone(game), leadAction: Action = { type: 'decision', event: game.decision.event, lead };
|
||||
act(game.decision.player, leadAction);
|
||||
assert.ok(game.response && game.battle); assert.equal(game.response.kind, 'ecazOccupy');
|
||||
assert.equal(game.battle.ecazOccupy?.lead, lead); assert.equal(game.battle.ecazOccupy?.canceled, false);
|
||||
assert.deepEqual(game.battle.plans, {}); assert.equal(game.battle.revealed, false);
|
||||
const allowedProfile = semutaEcazEconomyProfile(game), canceledProfile = quoteEcazOccupyBattle({
|
||||
advanced: false, battleOrderActor: allowedProfile.battleOrderActor,
|
||||
ecaz: { id: owner, faction: 'ecaz', ally, forces: allowedProfile.ecazForces },
|
||||
ally: { id: ally, faction: 'richese', ally: owner, forces: allowedProfile.allyForces }, lead, canceled: true });
|
||||
const ownLeader = semutaEcazEconomyPlayer(game, lead).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
|
||||
const otherLeader = semutaEcazEconomyPlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(ownLeader && otherLeader);
|
||||
const allowedPlan: Action = { type: 'battlePlan', leader: ownLeader.id, dial: allowedProfile.maxDial, support: 0, weapon: null, defense: null };
|
||||
const canceledPlan: Action = { ...allowedPlan, dial: canceledProfile.maxDial };
|
||||
const opponentPlan: Action = { type: 'battlePlan', leader: otherLeader.id, dial: 0, support: 0, weapon: null, defense: null };
|
||||
const source = structuredClone(game), originalBattle = structuredClone(game.battle), originalResponse = structuredClone(game.response);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
assert.deepEqual(semutaEcazEconomyInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, kind, advanced: false as const, owner, ecaz: owner, ally, richese: ally,
|
||||
holder, spender, guild: spender, opponent, recipient: lead, lead, leadChoice, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
|
||||
action, originalResponse, originalBattle, collection: structuredClone(game.ecazCollection ?? null),
|
||||
originalPhysical: structuredClone(semutaEcazEconomyPhysical(game)), allowedProfile, canceledProfile,
|
||||
allowedPlan, canceledPlan, opponentPlan, territory, sector, location, basicCollectionQuote,
|
||||
initial, lobby, setup, beforeStartingDeal, afterSetup, deal: selected.map(card => card.id), dealOrder,
|
||||
originalInventory, originalSpice, spiceOrder, acquisition, acquired, placement, selfToken: selfToken.id,
|
||||
ecazShipment, alliance, sharedStronghold, opponentShipment, beforeBattle, choice, battleAction, beforeLead, leadAction,
|
||||
quoteHistory: [{ stage: 'shared-stronghold-basic', quote: basicCollectionQuote },
|
||||
{ stage: 'occupy-allowed', quote: allowedProfile }, { stage: 'occupy-canceled', quote: canceledProfile }],
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Actual public Basic Ecaz/Richese/CHOAM/Emperor/Guild ready/start with original main35/cache10 and no modules. Only unopened starting-card/Spice positions and scalar lotteries are selected. Guild really receives printed Karama; CHOAM really buys cache Semuta for one.',
|
||||
'Ecaz really places its native six in Imperial Basin9, pays for its reusable self-Ambassador at Carthag, then pays two to ship two original reserves to Carthag11. Richese really pays two for its own two-counter entry. Ecaz triggers its printed alliance option, Richese actually accepts, and Ecaz declines the optional Duke loan. The real alliance leaves both native groups in the shared stronghold.',
|
||||
'Emperor really pays one to enter that shared coalition stronghold. Original chooseBattle creates the native lead chooser; Ecaz actually selects the requested Ecaz/Richese lead. Stop at its original Occupy counter before Guild spends printed Karama; no plan is sealed and no combined dial is pre-applied.',
|
||||
'No played hand, wallet, forces, alliance, token, battle, clock or response is installed. The native Basic Collection quote has no Advanced bank bonus. The separate Advanced Collection source uses its explicit original fresh development entry; this Basic source gains no Advanced income.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaEcazOccupyFixture = ReturnType<typeof createSemutaEcazOccupyFixture>;
|
||||
|
||||
/** Explicit fresh Advanced development source, never a public Semuta start or
|
||||
* played Basic conversion. Original R/C/Ecaz/E setup, paid cache, paid city
|
||||
* entries and Ambassador alliance earn the actual shared bank-income source. */
|
||||
export function createSemutaEcazCollectionFixture() {
|
||||
const owner = 'ecaz', ally = 'richese', holder = 'choam', spender = 'emperor';
|
||||
const territory = 'carthag', sector = 11, location = `${territory}:${sector}`;
|
||||
let game = createGame('SEMUTAECAZCOLLECTION', newPlayer(ally, 'Richese', 'richese'), true, ['choam', 'ecaz']);
|
||||
for (const faction of ['choam', 'ecaz', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaEcazEconomyStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaEcazEconomy(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaEcazEconomy(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: 'explicit-original-advanced-ecaz-semuta-initializer' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.equal(game.ecazOccupyPreview, true); assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaEcazEconomyInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama');
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3);
|
||||
assert.ok(printed); assert.equal(ordinary.length, 3);
|
||||
const selected = [...ordinary, printed], dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), cost = printed.id;
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (const [position, face] of ['funeral_plain', 'hagga_basin'].entries()) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && card.territory === face && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
let beforeBid: Game | undefined, bid: SemutaEcazEconomyStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaEcazEconomyPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaEcazEconomy(game); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaEcazEconomyPlayer(beforeBid, holder).spice - semutaEcazEconomyPlayer(game, holder).spice, 1);
|
||||
reach(state => state.phase === 4 && state.decision?.kind === 'ecazPlacement' && !state.response && !state.phaseOpening);
|
||||
const selfToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!;
|
||||
const placement = transition(() => {
|
||||
act(owner, { type: 'decision', token: selfToken.id, territory });
|
||||
reach(state => state.decision?.kind === 'ecazPlacement' && !state.response && !state.phaseOpening);
|
||||
act(owner, { type: 'decision', decline: true });
|
||||
});
|
||||
reach(state => state.phase === 5 && state.active === ally && semutaEcazEconomyClean(state));
|
||||
const alliance = transition(() => {
|
||||
act(ally, { type: 'ship', territory, sector, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
act(owner, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: owner, choice: 'alliance' });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'allianceReply');
|
||||
act(ally, { type: 'decision', event: game.pendingAmbassador!.event, accept: true });
|
||||
reach(semutaEcazEconomyClean);
|
||||
});
|
||||
assert.equal(semutaEcazEconomyPlayer(game, owner).ally, ally); assert.equal(semutaEcazEconomyPlayer(game, ally).ally, owner);
|
||||
const afterAlliance = structuredClone(game); act(ally, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === holder && semutaEcazEconomyClean(state));
|
||||
const ordinaryShipment = transition(() => {
|
||||
act(holder, { type: 'ship', territory: 'funeral_plain', sector: 15, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(semutaEcazEconomyClean); act(holder, { type: 'endMovement' });
|
||||
});
|
||||
assert.equal(semutaEcazEconomyPlayer(ordinaryShipment.before, holder).spice - semutaEcazEconomyPlayer(game, holder).spice, 2);
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaEcazEconomyClean(state));
|
||||
const ecazShipment = transition(() => {
|
||||
act(owner, { type: 'ship', territory, sector, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(semutaEcazEconomyClean); act(owner, { type: 'endMovement' });
|
||||
});
|
||||
const sharedStronghold = structuredClone(game);
|
||||
assert.equal(semutaEcazEconomyPlayer(game, owner).forces[location], 2); assert.equal(semutaEcazEconomyPlayer(game, ally).forces[location], 2);
|
||||
reach(state => state.phase === 7 && state.response?.kind === 'ecazCollection');
|
||||
assert.ok(game.ecazCollection && game.response); assert.equal(game.ecazCollection.stage, 'response');
|
||||
assert.equal(game.response.owner, owner); assert.ok(!game.battle && !game.pendingAmbassador && !game.decision && !game.phaseOpening);
|
||||
const allowedQuote = quoteSpiceCollection(game), canceledQuote = quoteSpiceCollection(game, true);
|
||||
assert.ok(allowedQuote.collectionBonus); assert.equal(allowedQuote.collectionBonus.owner, owner);
|
||||
assert.equal(allowedQuote.collectionBonus.ally, ally);
|
||||
assert.ok(allowedQuote.collectionBonus.amount > 0); assert.deepEqual(allowedQuote.collectionBonus.strongholds, [territory]);
|
||||
assert.deepEqual(allowedQuote.shared, []); assert.deepEqual(canceledQuote.shared, []);
|
||||
assert.ok(canceledQuote.receipts.find(receipt => receipt.player === holder)!.desert > 0);
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response), collection = structuredClone(game.ecazCollection);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
assert.deepEqual(semutaEcazEconomyInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, kind: 'ecazCollection' as const, advanced: true as const, owner, ecaz: owner,
|
||||
ally, richese: ally, holder, spender, opponent: spender, recipient: owner, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
|
||||
action, originalResponse, originalBattle: null, collection, originalCollection: collection,
|
||||
originalPhysical: structuredClone(semutaEcazEconomyPhysical(game)), allowedQuote, canceledQuote,
|
||||
territory, sector, location, initial, lobby, setup, initialization, beforeStartingDeal, afterSetup,
|
||||
deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder, acquisition, acquired,
|
||||
placement, selfToken: selfToken.id, alliance, afterAlliance, ordinaryShipment, ecazShipment, sharedStronghold,
|
||||
quoteHistory: [{ stage: 'collection-allowed', quote: allowedQuote }, { stage: 'collection-canceled', quote: canceledQuote }],
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Explicit original fresh Advanced Ecaz Semuta development initializer on the ready undealt R/C/Ecaz/E lobby, exact CHOAM+Ecaz, original main35/cache10 and no modules. Public start/publication gates remain separate; no played Basic conversion occurs.',
|
||||
'Only unopened original starting-card/Spice positions and scalar lotteries are selected. Emperor really receives printed Karama; CHOAM really purchases cache Semuta for one. Ecaz actually places native six in Basin9 and pays for its self-Ambassador at Carthag.',
|
||||
'Richese really pays two for two original counters entering Carthag11. Ecaz actually triggers its alliance option and Richese accepts; native Advanced logic automatically grants the available Duke without a fabricated Basic loan reply. CHOAM really pays two for one counter at the original Funeral Plain15 spice blow, then Ecaz really pays two for its own two-counter city entry. Their actual shared stronghold earns the special bonus; CHOAM’s real desert cohort separately earns positive ordinary Collection.',
|
||||
'Other real movement and empty Battle controls complete. The native Advanced Collection quote opens its positive shared-stronghold response before credit. Both allowed/canceled quotes are read from that original source; ordinary Collection receipts stay native. No hand, wallet, force, token, ally, leader, phase, income or pending frame is installed.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaEcazCollectionFixture = ReturnType<typeof createSemutaEcazCollectionFixture>;
|
||||
export type SemutaEcazEconomyFixture = SemutaEcazOccupyFixture | SemutaEcazCollectionFixture;
|
||||
export function createSemutaEcazEconomyFixture(options: { kind: 'ecazCollection'; lead?: 'ecaz' | 'ally' }): SemutaEcazCollectionFixture;
|
||||
export function createSemutaEcazEconomyFixture(options?: { kind?: 'ecazOccupy'; lead?: 'ecaz' | 'ally' }): SemutaEcazOccupyFixture;
|
||||
export function createSemutaEcazEconomyFixture(options: { kind?: SemutaEcazEconomyKind; lead?: 'ecaz' | 'ally' }): SemutaEcazEconomyFixture;
|
||||
export function createSemutaEcazEconomyFixture(options: { kind?: SemutaEcazEconomyKind; lead?: 'ecaz' | 'ally' } = {}): SemutaEcazEconomyFixture {
|
||||
return options.kind === 'ecazCollection' ? createSemutaEcazCollectionFixture() : createSemutaEcazOccupyFixture(options);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { territory as boardTerritory } from '../game/board';
|
||||
import { reserveShipmentCost } from '../game/shipment-price';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import {
|
||||
actSemutaCombatCapabilities, nextSemutaCombatCapabilities,
|
||||
semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean,
|
||||
passSemutaCombatCapabilities, claimSemutaCombatCapabilities,
|
||||
} from './fixture-semuta-combat-capabilities';
|
||||
|
||||
export type SemutaMoritaniAllianceStep = { actor: string; action: Action };
|
||||
export const actSemutaMoritaniAlliance = actSemutaCombatCapabilities;
|
||||
export const semutaMoritaniAlliancePlayer = semutaCombatCapabilitiesPlayer;
|
||||
export const semutaMoritaniAllianceInventory = semutaCombatCapabilitiesInventory;
|
||||
export const semutaMoritaniAllianceClean = semutaCombatCapabilitiesClean;
|
||||
export const passSemutaMoritaniAlliance = passSemutaCombatCapabilities;
|
||||
export const claimSemutaMoritaniAlliance = claimSemutaCombatCapabilities;
|
||||
|
||||
/** Placement, Terror revelation and the entrant's owned group remain explicit
|
||||
* caller actions; other controls finish only through real native choices. */
|
||||
export function nextSemutaMoritaniAlliance(game: Game): SemutaMoritaniAllianceStep | null {
|
||||
if (!game.response && !game.phaseOpening &&
|
||||
(game.decision?.kind === 'moritaniPlacement' || game.decision?.kind === 'moritaniTerror')) return null;
|
||||
return nextSemutaCombatCapabilities(game);
|
||||
}
|
||||
export const nextSemutaMoritaniAllianceStep = nextSemutaMoritaniAlliance;
|
||||
export function advanceSemutaMoritaniAlliance(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaMoritaniAllianceStep[] = []): Game {
|
||||
for (let count = 0; count < 2400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaMoritaniAlliance(game);
|
||||
assert.ok(next, `Supply the native Moritani placement/entry choice at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaMoritaniAlliance(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Enemy of My Enemy programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaMoritaniAlliance(game: Game, actions: SemutaMoritaniAllianceStep[] = []): Game {
|
||||
assert.equal(game.response?.kind, 'moritaniAlliance');
|
||||
for (let count = 0; game.response?.kind === 'moritaniAlliance' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaMoritaniAlliance(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'moritaniAlliance'); return game;
|
||||
}
|
||||
/** Cancellation restores the original hidden-token chooser. Leave that actual
|
||||
* revelation/decline, or an allowed offer's entrant reply, to the caller. */
|
||||
export function finishSemutaMoritaniAlliance(game: Game, actions: SemutaMoritaniAllianceStep[] = []): Game {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
return advanceSemutaMoritaniAlliance(game, state => !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'moritaniTerror', actions);
|
||||
}
|
||||
|
||||
/** Original public Basic R/C/M/E/G setup, paid cache1, real turn1 Mentat
|
||||
* Robbery placement and committed turn2 entry. No played state is installed. */
|
||||
export function createSemutaMoritaniAllianceFixture(options: { entry?: 'shipment' | 'movement' } = {}) {
|
||||
const entryKind = options.entry ?? 'shipment';
|
||||
const richese = 'richese', holder = 'choam', owner = 'moritani', entrant = 'emperor', spender = 'guild';
|
||||
const territory = 'arrakeen', sector = 10, location = `${territory}:${sector}`, origin = 'imperial_basin:10';
|
||||
let game = createGame('SEMUTAMORITANIALLIANCE', newPlayer(richese, 'Richese', 'richese'), false, ['choam', 'ecaz']);
|
||||
for (const faction of ['choam', 'moritani', 'emperor', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaMoritaniAllianceStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaMoritaniAlliance(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaMoritaniAlliance(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaMoritaniAllianceInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 4);
|
||||
assert.equal(ordinary.length, 4);
|
||||
const selected = [...ordinary, printed];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { card: card.id, position, from };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game), cost = printed.id;
|
||||
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.ok(semutaMoritaniAlliancePlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.equal(semutaMoritaniAlliancePlayer(game, owner).forces['oh_gap:9'], 6);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1, 2, 3].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
|
||||
!card.discovery && !['oh_gap', 'arrakeen', 'imperial_basin', 'tueks_sietch'].includes(card.territory));
|
||||
assert.ok(from >= position);
|
||||
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaMoritaniAllianceStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 1000 && !semutaMoritaniAlliancePlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaMoritaniAlliance(game); assert.ok(next);
|
||||
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length;
|
||||
}
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaMoritaniAlliancePlayer(beforeBid, holder).spice - semutaMoritaniAlliancePlayer(game, holder).spice, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 8 && !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'moritaniPlacement');
|
||||
const robbery = game.moritaniTerror!.tokens.find(token => token.kind === 'robbery' && token.status === 'available');
|
||||
assert.ok(robbery); const token = robbery.id;
|
||||
const placementAction: Action = { type: 'decision', token, territory };
|
||||
const placement = transition(() => {
|
||||
act(owner, placementAction);
|
||||
reach(state => !state.response && state.moritaniTerror!.tokens.some(row => row.id === token && row.status === 'placed'));
|
||||
});
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant && semutaMoritaniAllianceClean(state));
|
||||
assert.ok(!semutaMoritaniAlliancePlayer(game, owner).ally && !semutaMoritaniAlliancePlayer(game, entrant).ally);
|
||||
const shipmentTerritory = entryKind === 'shipment' ? territory : 'imperial_basin';
|
||||
const price = reserveShipmentCost({ faction: 'emperor', halfRate: false }, boardTerritory(shipmentTerritory).type, 1);
|
||||
const shipmentAction: Action = { type: 'ship', territory: shipmentTerritory, sector, amount: 1, elite: 0, allyPayment: 0 };
|
||||
let paidShipment: { before: Game; steps: SemutaMoritaniAllianceStep[]; after: Game };
|
||||
let entry: { before: Game; steps: SemutaMoritaniAllianceStep[]; after: Game };
|
||||
const chooser = (state: Game) => !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniTerror' &&
|
||||
state.pendingTerrorEntry?.stage === 'offer';
|
||||
let entryAction: Action;
|
||||
if (entryKind === 'shipment') {
|
||||
entryAction = shipmentAction;
|
||||
entry = transition(() => { act(entrant, shipmentAction); reach(chooser); });
|
||||
paidShipment = entry;
|
||||
} else {
|
||||
paidShipment = transition(() => { act(entrant, shipmentAction); reach(semutaMoritaniAllianceClean); });
|
||||
entryAction = { type: 'move', from: origin, amount: 1, elite: 0, territory, sector };
|
||||
entry = transition(() => { act(entrant, entryAction); reach(chooser); });
|
||||
}
|
||||
assert.equal(semutaMoritaniAlliancePlayer(paidShipment.before, entrant).spice -
|
||||
semutaMoritaniAlliancePlayer(paidShipment.after, entrant).spice, price);
|
||||
assert.equal(semutaMoritaniAlliancePlayer(game, entrant).forces[location], 1);
|
||||
assert.equal(semutaMoritaniAlliancePlayer(game, entrant).shipped, true);
|
||||
assert.equal(semutaMoritaniAlliancePlayer(game, entrant).moved, entryKind === 'movement' ? 1 : 0);
|
||||
const offerAction: Action = { type: 'decision', alliance: true };
|
||||
const offer = transition(() => { act(owner, offerAction); });
|
||||
assert.ok(game.response?.kind === 'moritaniAlliance'); assert.equal(game.response.owner, owner);
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'allianceResponse');
|
||||
assert.equal(game.pendingTerrorEntry?.cause, entryKind); assert.equal(game.pendingTerrorEntry?.token, token);
|
||||
assert.equal(game.decision ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(semutaMoritaniAllianceInventory(game), originalInventory);
|
||||
return { game, kind: 'moritaniAlliance' as const, advanced: false as const, entryKind,
|
||||
owner, recipient: entrant, entrant, holder, spender, richese, guild: spender, cost, karama: cost,
|
||||
semuta: SEMUTA_DRUG_ID, action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
originalResponse: structuredClone(game.response), originalEntry: structuredClone(game.pendingTerrorEntry!),
|
||||
originalTerror: structuredClone(game.moritaniTerror!), originalOffers: structuredClone(game.allianceOffers),
|
||||
token, territory, sector, location, origin, price, shipmentAction, entryAction, offerAction,
|
||||
revealAction: { type: 'decision', reveal: true } as Action, revealActor: owner,
|
||||
declineAction: { type: 'decision', decline: true } as Action, declineActor: owner,
|
||||
acceptAction: { type: 'decision', accept: true } as Action, acceptActor: entrant,
|
||||
robberyAction: { type: 'decision', choice: 'spice' } as Action, robberyActor: owner,
|
||||
initial, lobby, setup, beforeStartingDeal, afterSetup, deal: selected.map(card => card.id), dealOrder,
|
||||
originalInventory, originalSpice, spiceOrder, acquisition, acquired, cache: acquisition,
|
||||
placement, paidShipment, entry, offer, actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original public Basic R/C/M/E/G E2/E3 setup, unopened main/Spice positions and scalar lotteries only. Guild is actually dealt the printed Karama; CHOAM buys physical cache Semuta for one spice; Moritani places its original six in Oh Gap.',
|
||||
'Native turn1 Mentat actually places the available Robbery token at empty Arrakeen. Original legal Storm dials and ordinary controls reach turn2. Emperor genuinely pays the printed city1 or desert2 shipment tariff and Guild collects its native income.',
|
||||
entryKind === 'shipment' ? 'The actual one-counter Arrakeen shipment opens the hidden-token entry.'
|
||||
: 'The actual paid Imperial Basin10 cohort then makes its ordinary one-territory ground move into Arrakeen10, consuming its real movement index before the entry.',
|
||||
'Moritani actually offers Enemy of My Enemy while both parties are unallied. Stop before the real used cancellation cost. No played hand, force, payment, token, alliance, clock, Terror entry or pending control is installed.',
|
||||
] };
|
||||
}
|
||||
export type SemutaMoritaniAllianceFixture = ReturnType<typeof createSemutaMoritaniAllianceFixture>;
|
||||
@@ -0,0 +1,239 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaE3PlacementFixture as fixture, actSemutaE3Placement as act,
|
||||
passSemutaE3Placement as pass, claimSemutaE3Placement as claim,
|
||||
allowSemutaE3Placement as allow, finishSemutaE3Placement as finish,
|
||||
semutaE3PlacementPlayer as player, semutaE3PlacementInventory as inventory,
|
||||
type SemutaE3PlacementFixture,
|
||||
} from './fixture-semuta-e3-placement';
|
||||
|
||||
const variants = [
|
||||
{ kind: 'ecazPlacement' as const },
|
||||
{ kind: 'ecazPlacement' as const, second: true },
|
||||
{ kind: 'moritaniPlacement' as const },
|
||||
{ kind: 'moritaniDuke' as const },
|
||||
];
|
||||
const physical = (game: Game) => ({ turn: game.turn, phase: game.phase, storm: game.storm,
|
||||
order: game.order, active: game.active, ready: game.ready, movementRemaining: game.movementRemaining,
|
||||
ecazAmbassadors: game.ecazAmbassadors, ecazPlacementTurn: game.ecazPlacementTurn,
|
||||
pendingEcazPlacement: game.pendingEcazPlacement, moritaniTerror: game.moritaniTerror,
|
||||
pendingMoritaniPlacement: game.pendingMoritaniPlacement, dukeVidal: game.dukeVidal,
|
||||
dukeAcquisitionTurn: game.dukeAcquisitionTurn, freeRevival: game.freeRevival,
|
||||
spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
||||
forces: player.forces, reserves: player.reserves, tanks: player.tanks, elites: player.elites,
|
||||
leaders: player.leaders, ally: player.ally, shipped: player.shipped, moved: player.moved, revived: player.revived })) });
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaE3PlacementFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.game.advanced, false); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', f.owner, 'emperor']);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.equal(f.acquisition.price, 1);
|
||||
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(f.originalDuke?.leader.dead ?? false, false);
|
||||
if (f.kind === 'ecazPlacement') {
|
||||
assert.equal(f.game.phase, 4); assert.ok(f.game.pendingEcazPlacement && f.token && f.territory);
|
||||
assert.equal(f.game.pendingEcazPlacement.cost, f.second ? 2 : 1);
|
||||
assert.equal(f.game.pendingEcazPlacement.token, f.token); assert.equal(f.game.pendingEcazPlacement.territory, f.territory);
|
||||
assert.equal(f.game.ecazAmbassadors?.tokens.find(token => token.id === f.token)?.zone, 'supply');
|
||||
assert.deepEqual(player(f.game, f.owner), player(f.declaration.before, f.owner), 'The pending placement is unpaid');
|
||||
if (f.second) {
|
||||
assert.ok(f.firstPlacement);
|
||||
assert.ok(f.firstPlacement.steps.some(step => step.action.type === 'passResponse'));
|
||||
assert.equal(player(f.firstPlacement.before, f.owner).spice - player(f.firstPlacement.after, f.owner).spice, 1);
|
||||
assert.equal(f.game.ecazAmbassadors?.placement?.count, 1);
|
||||
assert.equal(f.game.ecazAmbassadors?.tokens.find(token => token.effect === 'ecaz')?.location, 'sietch_tabr');
|
||||
} else assert.equal(f.firstPlacement, null);
|
||||
} else if (f.kind === 'moritaniPlacement') {
|
||||
assert.equal(f.game.phase, 8); assert.ok(f.game.pendingMoritaniPlacement && f.token);
|
||||
assert.equal(player(f.afterSetup, f.owner).forces['oh_gap:9'], 6);
|
||||
assert.equal(f.game.pendingMoritaniPlacement.token, f.token);
|
||||
assert.equal(f.game.moritaniTerror?.tokens.find(token => token.id === f.token)?.status, 'available');
|
||||
assert.equal(f.game.moritaniTerror?.tokens.find(token => token.id === f.token)?.location, null);
|
||||
assert.deepEqual(player(f.game, f.owner), player(f.declaration.before, f.owner));
|
||||
} else {
|
||||
assert.equal(f.game.phase, 5); assert.equal(player(f.afterSetup, f.owner).forces['arrakeen:10'], 6);
|
||||
assert.equal(f.shipments.length, 3);
|
||||
for (const shipment of f.shipments) {
|
||||
const step = shipment.steps.find(step => step.action.type === 'ship'); assert.ok(step);
|
||||
assert.equal(step.action.amount, 1);
|
||||
assert.equal(player(shipment.before, step.actor).spice - player(shipment.after, step.actor).spice, 1);
|
||||
}
|
||||
assert.equal(player(f.game, f.richese).forces['carthag:11'], 1);
|
||||
assert.equal(player(f.game, f.owner).forces['carthag:11'], 1);
|
||||
assert.equal(player(f.game, f.owner).forces['arrakeen:10'], 6);
|
||||
assert.equal(player(f.game, f.spender).forces['arrakeen:10'], 1);
|
||||
assert.deepEqual(f.game.movementRemaining, []); assert.equal(f.game.dukeAcquisitionTurn, 1);
|
||||
assert.equal(f.originalDuke?.controller, null); assert.equal(f.originalDuke?.source, null);
|
||||
assert.equal(f.originalDuke?.leader.strength, 6);
|
||||
assert.deepEqual(f.declaration.steps, [{ actor: f.spender, action: { type: 'endMovement' } }]);
|
||||
}
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaE3PlacementFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaEcazMoritaniDiscard');
|
||||
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, `karama:${f.kind}`);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
if (pending.continuation.kind !== 'karamaEcazMoritaniDiscard') throw Error('Use the original native E3 cost');
|
||||
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function unchangedForcesAndFunds(game: Game, f: SemutaE3PlacementFixture, paid = 0) {
|
||||
for (const current of game.players) {
|
||||
const original = player(f.game, current.id);
|
||||
assert.equal(current.spice, original.spice - (current.id === f.owner ? paid : 0));
|
||||
assert.deepEqual(current.forces, original.forces); assert.deepEqual(current.elites, original.elites);
|
||||
assert.equal(current.reserves, original.reserves); assert.equal(current.tanks, original.tanks);
|
||||
assert.equal(current.revived, original.revived); assert.deepEqual(current.leaders, original.leaders);
|
||||
assert.equal(current.ally, original.ally);
|
||||
}
|
||||
assert.deepEqual(game.freeRevival, f.game.freeRevival); assert.equal(game.pendingRevival ?? null, null);
|
||||
assert.deepEqual(game.spiceDeck, f.game.spiceDeck); assert.deepEqual(game.spiceDiscard, f.game.spiceDiscard);
|
||||
}
|
||||
function nativeBoundary(game: Game, f: SemutaE3PlacementFixture) {
|
||||
assert.equal(game.phase, f.kind === 'ecazPlacement' ? 5 : f.kind === 'moritaniPlacement' ? 8 : 6);
|
||||
assert.equal(game.turn, 1); assert.equal(game.status, 'playing');
|
||||
if (f.kind === 'ecazPlacement') {
|
||||
assert.equal(game.ecazPlacementTurn, 1); assert.equal(game.pendingEcazPlacement ?? null, null);
|
||||
assert.deepEqual(game.movementRemaining, game.order); assert.equal(game.active, game.order[0]);
|
||||
assert.ok(game.players.every(player => player.shipped === false && player.moved === 0));
|
||||
if (f.second) assert.equal(game.ecazAmbassadors?.tokens.find(token => token.effect === 'ecaz')?.location, 'sietch_tabr');
|
||||
} else if (f.kind === 'moritaniPlacement') {
|
||||
assert.equal(game.moritaniTerror?.placementTurn, 1); assert.equal(game.pendingMoritaniPlacement ?? null, null);
|
||||
assert.deepEqual(game.ready, f.game.ready);
|
||||
} else {
|
||||
assert.equal(game.dukeAcquisitionTurn, 1); assert.deepEqual(game.movementRemaining, []);
|
||||
assert.equal(game.choamMarket ?? null, null);
|
||||
assert.equal(game.battle ?? null, null);
|
||||
assert.equal(player(game, f.owner).forces['arrakeen:10'], 6); assert.equal(player(game, f.owner).forces['carthag:11'], 1);
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.notEqual(game.response?.kind, f.kind);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
function dukeClosingMarket(game: Game, f: SemutaE3PlacementFixture) {
|
||||
assert.equal(game.phase, 5); assert.equal(game.turn, 1);
|
||||
assert.deepEqual(game.movementRemaining, []); assert.equal(game.dukeAcquisitionTurn, 1);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision?.kind, 'choamMarket');
|
||||
assert.equal(game.decision?.player, f.holder); assert.equal(game.choamMarket?.owner, f.holder);
|
||||
assert.equal(game.choamMarket?.resume, 'phase');
|
||||
}
|
||||
function prevented(game: Game, f: SemutaE3PlacementFixture, claimed: boolean) {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
unchangedForcesAndFunds(game, f);
|
||||
if (f.expected.kind === 'ecazPlacement') {
|
||||
assert.deepEqual(game.ecazAmbassadors, f.expected.ambassadors);
|
||||
assert.equal(game.ecazAmbassadors?.placement?.blocked, true);
|
||||
assert.equal(game.ecazAmbassadors?.tokens.find(token => token.id === f.token)?.zone, 'supply');
|
||||
} else if (f.expected.kind === 'moritaniPlacement') {
|
||||
assert.deepEqual(game.moritaniTerror, f.expected.terror);
|
||||
assert.deepEqual(game.moritaniTerror?.tokens, f.originalTerror?.tokens);
|
||||
} else {
|
||||
assert.deepEqual(game.dukeVidal, f.originalDuke);
|
||||
dukeClosingMarket(game, f);
|
||||
}
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
const steps: { actor: string; action: Action }[] = [];
|
||||
game = finish(game, f.kind, steps);
|
||||
assert.deepEqual(steps, f.kind === 'moritaniDuke'
|
||||
? [{ actor: f.holder, action: { type: 'decision', done: true } }] : []);
|
||||
nativeBoundary(game, f);
|
||||
assert.deepEqual(finish(game, f.kind), game);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`Basic native ${variant.kind}${'second' in variant ? ' second paid opportunity' : ''} cost ${claimed ? 'claim' : 'pass'} resumes original denial and phase once`, () => {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
reject(game, f.kind === 'moritaniDuke' ? f.spender : f.owner, f.nativeAction);
|
||||
});
|
||||
|
||||
void test('native E3 placement and Duke allowance controls commit only the original quoted payment, token or shared disc', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
let game = allow(structuredClone(f.game));
|
||||
const paid = f.kind === 'ecazPlacement' ? f.game.pendingEcazPlacement!.cost : 0;
|
||||
unchangedForcesAndFunds(game, f, paid);
|
||||
if (f.kind === 'ecazPlacement') {
|
||||
assert.equal(game.decision?.kind, 'ecazPlacement'); assert.equal(game.pendingEcazPlacement ?? null, null);
|
||||
const token = game.ecazAmbassadors?.tokens.find(token => token.id === f.token); assert.ok(token);
|
||||
assert.equal(token.zone, 'placed'); assert.equal(token.location, f.territory);
|
||||
assert.equal(game.ecazAmbassadors?.placement?.count, f.second ? 2 : 1);
|
||||
} else if (f.kind === 'moritaniPlacement') {
|
||||
const token = game.moritaniTerror?.tokens.find(token => token.id === f.token); assert.ok(token);
|
||||
assert.equal(token.status, 'placed'); assert.equal(token.location, f.territory);
|
||||
assert.equal(game.moritaniTerror?.placementTurn, 1);
|
||||
} else {
|
||||
assert.equal(game.dukeVidal?.controller, f.owner); assert.equal(game.dukeVidal?.source, 'moritani');
|
||||
assert.equal(game.dukeVidal?.acquiredTurn, 1); assert.deepEqual(game.dukeVidal?.leader, f.originalDuke?.leader);
|
||||
dukeClosingMarket(game, f);
|
||||
}
|
||||
game = finish(game, f.kind); nativeBoundary(game, f);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh E3 costs reject stale and underlying replies while original token, prior payment and Duke sources stay parked', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event });
|
||||
reject(game, f.spender, f.action); reject(game, f.owner, { type: 'passResponse' });
|
||||
reject(game, f.kind === 'moritaniDuke' ? f.spender : f.owner, f.nativeAction);
|
||||
reject(game, f.owner, { type: 'decision', decline: true });
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
prevented(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer each original Basic E3 placement and acquisition cancellation cost legally', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(own, before);
|
||||
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(choosing)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
prevented(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, initializeSemutaGameForAudit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaEcazEconomyFixture as fixture, actSemutaEcazEconomy as act,
|
||||
passSemutaEcazEconomy as pass, claimSemutaEcazEconomy as claim,
|
||||
allowSemutaEcazEconomy as allow, finishSemutaEcazEconomy as finish,
|
||||
semutaEcazEconomyPlayer as player, semutaEcazEconomyInventory as inventory,
|
||||
semutaEcazEconomyPhysical as physical, semutaEcazEconomyProfile as profile,
|
||||
type SemutaEcazOccupyFixture as SemutaEcazEconomyFixture,
|
||||
type SemutaEcazEconomyFixture as SemutaEcazEconomySource,
|
||||
type SemutaEcazCollectionFixture,
|
||||
} from './fixture-semuta-ecaz-economy';
|
||||
|
||||
const leads = ['ecaz', 'ally'] as const;
|
||||
const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
|
||||
order: game.order, movementRemaining: game.movementRemaining, storm: game.storm,
|
||||
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard });
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaEcazEconomyFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, false); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.equal(f.setup.ecazOccupyPreview, true); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.deepEqual(player(f.afterSetup, f.owner).forces, { 'imperial_basin:9': 6 });
|
||||
assert.equal(player(f.afterSetup, f.owner).reserves, 14);
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquired, f.ally).spice - player(f.acquisition.before, f.ally).spice, 1);
|
||||
assert.equal(f.placement.steps[0].action.token, f.selfToken); assert.equal(f.placement.steps[0].action.territory, f.territory);
|
||||
assert.equal(player(f.ecazShipment.before, f.owner).spice - player(f.ecazShipment.after, f.owner).spice, 2);
|
||||
assert.equal(player(f.alliance.before, f.ally).spice - player(f.alliance.after, f.ally).spice, 2);
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.owner && step.action.trigger === true && step.action.choice === 'alliance'));
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.ally && step.action.accept === true));
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.owner && step.action.loan === false));
|
||||
assert.equal(player(f.sharedStronghold, f.owner).ally, f.ally); assert.equal(player(f.sharedStronghold, f.ally).ally, f.owner);
|
||||
assert.equal(player(f.sharedStronghold, f.owner).forces[f.location], 2);
|
||||
assert.equal(player(f.sharedStronghold, f.ally).forces[f.location], 2);
|
||||
assert.equal(f.basicCollectionQuote.collectionBonus, null, 'Basic does not gain the Advanced bank-income source');
|
||||
assert.equal(player(f.opponentShipment.before, f.opponent).spice - player(f.opponentShipment.after, f.opponent).spice, 1);
|
||||
assert.equal(player(f.game, f.opponent).forces[f.location], 1);
|
||||
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead'); assert.equal(f.beforeLead.decision?.player, f.owner);
|
||||
assert.equal(f.leadAction.lead, f.lead); assert.equal(f.game.battle?.ecazOccupy?.lead, f.lead);
|
||||
assert.equal(f.game.battle?.ecazOccupy?.canceled, false); assert.equal(f.game.battle?.revealed, false);
|
||||
assert.deepEqual(f.game.battle?.plans, {}); assert.deepEqual(f.game.battle, f.originalBattle);
|
||||
assert.deepEqual(f.game.response, f.originalResponse); assert.equal(f.game.response?.kind, 'ecazOccupy');
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 6); assert.equal(f.game.pendingAmbassador ?? null, null);
|
||||
assert.equal(f.allowedProfile.fixedEcazDial, 1); assert.equal(f.allowedProfile.forceOwner, f.ally);
|
||||
assert.equal(f.allowedProfile.maxDial, 3); assert.equal(f.canceledProfile.maxDial, 2);
|
||||
assert.equal(f.canceledProfile.forceOwner, f.lead); assert.equal(f.canceledProfile.fixedEcazDial, 0);
|
||||
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaEcazEconomySource) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaEcazMoritaniDiscard');
|
||||
if (pending.continuation.kind !== 'karamaEcazMoritaniDiscard') throw Error('Expected the original Ecaz cost');
|
||||
assert.equal(pending.batch.cause, `karama:${f.kind}`); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(game.ecazCollection ?? null, f.collection);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function planEntry(state: Game, f: SemutaEcazEconomyFixture, canceled: boolean) {
|
||||
assert.equal(state.pendingTreacheryDiscard ?? null, null); assert.equal(state.battle?.ecazOccupy?.lead, f.lead);
|
||||
assert.equal(state.battle?.ecazOccupy?.canceled, canceled); assert.deepEqual(state.battle?.plans, {});
|
||||
assert.equal(state.battle?.revealed, false); assert.deepEqual(physical(state), f.originalPhysical);
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
assert.equal(game.phase, 6); assert.equal(game.turn, 1); assert.equal(game.battle?.event, f.originalBattle.event);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.battle?.ecazOccupy?.lead, f.lead); assert.equal(game.battle?.ecazOccupy?.canceled, canceled);
|
||||
assert.equal(game.battle?.revealed, false); assert.deepEqual(game.battle?.plans, {});
|
||||
assert.deepEqual(profile(game), canceled ? f.canceledProfile : f.allowedProfile);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.deepEqual(new Set(game.battle?.preLeader?.ready), new Set([f.lead, f.opponent]));
|
||||
assert.ok(steps.every(step => step.action.type !== 'battlePlan' && step.action.type !== 'card'));
|
||||
const otherAlly = f.lead === f.owner ? f.ally : f.owner;
|
||||
reject(game, otherAlly, f.canceledPlan);
|
||||
if (canceled) reject(game, f.lead, f.allowedPlan);
|
||||
const plan = canceled ? f.canceledPlan : f.allowedPlan;
|
||||
const sealed = act(structuredClone(game), f.lead, plan);
|
||||
assert.equal(sealed.battle?.plans[f.lead]?.dial, plan.dial); assert.equal(sealed.battle?.revealed, false);
|
||||
assert.deepEqual(physical(sealed), f.originalPhysical); assert.deepEqual(inventory(sealed), f.originalInventory);
|
||||
return game;
|
||||
}
|
||||
function denied(state: Game, f: SemutaEcazEconomyFixture, claimed: boolean) {
|
||||
const game = planEntry(state, f, true);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.spender, f.action); assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const lead of leads) for (const claimed of [false, true]) void test(
|
||||
`Basic native Occupy ${lead} lead cost Semuta ${claimed ? 'claim' : 'pass'} retains the lead and only disables the combined dial`, () => {
|
||||
const f = fixture({ kind: 'ecazOccupy', lead }); nativeSource(f); const offered = open(f);
|
||||
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Basic Occupy allowance keeps its mandatory free Ecaz contribution and actual Richese variable pool', () => {
|
||||
for (const lead of leads) {
|
||||
const f = fixture({ kind: 'ecazOccupy', lead }); nativeSource(f);
|
||||
const game = planEntry(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Occupy cost reactions reject stale foreign and underlying lead or plan replies before native preparation', () => {
|
||||
for (const lead of leads) {
|
||||
const f = fixture({ kind: 'ecazOccupy', lead }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.owner, f.leadAction); reject(game, f.owner, { type: 'passResponse' });
|
||||
reject(game, f.lead, f.allowedPlan); reject(game, f.opponent, f.opponentPlan);
|
||||
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally claim or pass Basic native Occupy costs for either earned lead', () => {
|
||||
for (const lead of leads) {
|
||||
const f = fixture({ kind: 'ecazOccupy', lead }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const proposed = botActions(own)[0]; assert.ok(proposed, difficulty);
|
||||
assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, proposed);
|
||||
const claimed = proposed.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
denied(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function nativeCollectionSource(f: SemutaEcazCollectionFixture) {
|
||||
assert.equal(f.lobby.advanced, true); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.equal(f.setup.status, 'setup'); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'ecaz', 'emperor']);
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.deepEqual(player(f.afterSetup, f.owner).forces, { 'imperial_basin:9': 6 });
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquired, f.ally).spice - player(f.acquisition.before, f.ally).spice, 1);
|
||||
assert.equal(player(f.alliance.before, f.ally).spice - player(f.alliance.after, f.ally).spice, 2);
|
||||
assert.equal(player(f.ecazShipment.before, f.owner).spice - player(f.ecazShipment.after, f.owner).spice, 2);
|
||||
assert.equal(player(f.ordinaryShipment.before, f.holder).spice - player(f.ordinaryShipment.after, f.holder).spice, 2);
|
||||
assert.ok(f.ordinaryShipment.steps.some(step => step.actor === f.holder && step.action.type === 'ship' && step.action.amount === 1));
|
||||
assert.equal(player(f.game, f.holder).forces['funeral_plain:15'], 1);
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.owner && step.action.trigger === true && step.action.choice === 'alliance'));
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.ally && step.action.accept === true));
|
||||
assert.ok(f.alliance.steps.every(step => step.action.loan === undefined), 'Advanced alliance needs no fabricated Basic loan reply');
|
||||
assert.equal(player(f.game, f.owner).ally, f.ally); assert.equal(player(f.game, f.ally).ally, f.owner);
|
||||
assert.equal(player(f.game, f.owner).forces[f.location], 2); assert.equal(player(f.game, f.ally).forces[f.location], 2);
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 7); assert.equal(f.game.battle ?? null, null);
|
||||
assert.equal(f.game.pendingAmbassador ?? null, null); assert.equal(f.game.phaseOpening ?? null, null);
|
||||
assert.equal(f.game.response?.kind, 'ecazCollection'); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.deepEqual(f.game.ecazCollection, f.collection); assert.equal(f.collection.stage, 'response');
|
||||
assert.deepEqual(f.allowedQuote.collectionBonus?.strongholds, [f.territory]);
|
||||
assert.ok(f.allowedQuote.collectionBonus && f.allowedQuote.collectionBonus.amount > 0);
|
||||
const allowed = f.allowedQuote.receipts.find(receipt => receipt.player === f.owner)!;
|
||||
const canceled = f.canceledQuote.receipts.find(receipt => receipt.player === f.owner)!;
|
||||
assert.equal(allowed.balance - canceled.balance, f.allowedQuote.collectionBonus.amount);
|
||||
assert.ok(f.canceledQuote.receipts.find(receipt => receipt.player === f.ally)!.strongholds > 0,
|
||||
'The canceled special benefit still has native ordinary allied city income');
|
||||
assert.ok(f.canceledQuote.receipts.find(receipt => receipt.player === f.holder)!.desert > 0,
|
||||
'An actual paid CHOAM desert cohort gives nonzero ordinary Collection independent of the bank bonus');
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
const collectionForces = (game: Game) => game.players.map(p => ({ id: p.id, forces: p.forces, elites: p.elites,
|
||||
reserves: p.reserves, tanks: p.tanks, shipped: p.shipped, moved: p.moved, ally: p.ally }));
|
||||
function collectionOutcome(state: Game, f: SemutaEcazCollectionFixture, canceled: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
const quote = canceled ? f.canceledQuote : f.allowedQuote;
|
||||
assert.equal(game.phase, 7); assert.equal(game.turn, 1); assert.equal(game.response ?? null, null);
|
||||
assert.equal(game.decision ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(game.battle ?? null, null); assert.equal(game.ecazCollection?.event, f.collection.event);
|
||||
assert.equal(game.ecazCollection?.stage, 'complete'); assert.equal(game.ecazCollection?.canceled, canceled);
|
||||
assert.equal(game.ecazCollection?.allocation, null); assert.deepEqual(game.ecazCollection?.settled, []);
|
||||
assert.deepEqual(game.spice, quote.spice); assert.deepEqual(collectionForces(game), collectionForces(f.game));
|
||||
assert.deepEqual(game.ecazAmbassadors, f.game.ecazAmbassadors); assert.deepEqual(game.dukeVidal, f.game.dukeVidal);
|
||||
for (const receipt of quote.receipts) assert.equal(player(game, receipt.player).spice, receipt.balance);
|
||||
assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.ok(steps.every(step => step.action.type !== 'ship' && step.action.type !== 'card'));
|
||||
const balances = game.players.map(p => [p.id, p.spice]), ready = act(structuredClone(game), f.owner, { type: 'ready' });
|
||||
assert.equal(ready.phase, 7); assert.deepEqual(ready.players.map(p => [p.id, p.spice]), balances,
|
||||
'An original Collection acknowledgement cannot collect or initialize income again');
|
||||
assert.deepEqual(ready.ecazCollection, game.ecazCollection);
|
||||
reject(game, f.owner, { type: 'passResponse' }); assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function collectionDenied(state: Game, f: SemutaEcazCollectionFixture, claimed: boolean) {
|
||||
const game = collectionOutcome(state, f, true);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.spender, f.action); return game;
|
||||
}
|
||||
|
||||
for (const claimed of [false, true]) void test(
|
||||
`Advanced native Ecaz Collection cost Semuta ${claimed ? 'claim' : 'pass'} credits only the native ordinary collection once`, () => {
|
||||
const f = fixture({ kind: 'ecazCollection' }); nativeCollectionSource(f); const offered = open(f);
|
||||
collectionDenied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Advanced Ecaz Collection allowance credits the quoted real shared-stronghold bonus once', () => {
|
||||
const f = fixture({ kind: 'ecazCollection' }); nativeCollectionSource(f);
|
||||
const game = collectionOutcome(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
});
|
||||
|
||||
void test('native Advanced Ecaz Collection cost rejects stale foreign and underlying acknowledgements before income', () => {
|
||||
const f = fixture({ kind: 'ecazCollection' }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, { type: 'ready' });
|
||||
const done = collectionDenied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally claim or pass native Advanced Ecaz Collection costs', () => {
|
||||
const f = fixture({ kind: 'ecazCollection' }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const proposed = botActions(own)[0]; assert.ok(proposed, difficulty);
|
||||
assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, proposed);
|
||||
const claimed = proposed.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
collectionDenied(game, f, claimed);
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh Advanced Ecaz Semuta initialization preserves original undealt inventories and public preview behavior', () => {
|
||||
const f = fixture({ kind: 'ecazCollection' }); nativeCollectionSource(f);
|
||||
reject(f.lobby, f.lobby.host, { type: 'start' });
|
||||
const ordinaryPreview = act(structuredClone(f.lobby), f.lobby.host, { type: 'start', advancedPreview: true });
|
||||
assert.equal(ordinaryPreview.advanced, true); assert.equal(ordinaryPreview.status, 'setup');
|
||||
assert.equal(ordinaryPreview.semutaPreview, undefined, 'Normal Advanced preview does not silently gain the private Semuta profile');
|
||||
assert.deepEqual(inventory(ordinaryPreview), f.originalInventory);
|
||||
const before = structuredClone(f.setup);
|
||||
assert.throws(() => initializeSemutaGameForAudit(f.setup)); assert.deepEqual(f.setup, before,
|
||||
'The original development initializer cannot overwrite an initialized inventory');
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { quoteMoritaniAllianceCancellation } from '../game/moritani-alliance-cancellation';
|
||||
import {
|
||||
createSemutaMoritaniAllianceFixture as fixture, actSemutaMoritaniAlliance as act,
|
||||
passSemutaMoritaniAlliance as pass, claimSemutaMoritaniAlliance as claim,
|
||||
allowSemutaMoritaniAlliance as allow, finishSemutaMoritaniAlliance as finish,
|
||||
semutaMoritaniAlliancePlayer as player, semutaMoritaniAllianceInventory as inventory,
|
||||
type SemutaMoritaniAllianceFixture,
|
||||
} from './fixture-semuta-moritani-alliance';
|
||||
|
||||
const variants = [{ entry: 'shipment' as const }, { entry: 'movement' as const }];
|
||||
const pieces = (game: Game) => game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves,
|
||||
elites: p.elites, tanks: p.tanks, leaders: p.leaders, shipped: p.shipped, moved: p.moved }));
|
||||
const physical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, active: game.active, order: game.order, ready: game.ready,
|
||||
movementRemaining: game.movementRemaining, spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
pieces: pieces(game), balances: game.players.map(p => [p.id, p.spice]),
|
||||
allies: game.players.map(p => [p.id, p.ally, p.allySinceTurn]), offers: game.allianceOffers, aid: game.aid,
|
||||
terror: game.moritaniTerror, arrival: game.pendingArrivalReaction,
|
||||
});
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaMoritaniAllianceFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, false); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'moritani', 'emperor', 'guild']);
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.equal(player(f.afterSetup, f.owner).forces['oh_gap:9'], 6);
|
||||
assert.equal(Object.keys(player(f.afterSetup, f.entrant).forces).length, 0);
|
||||
assert.equal(Object.keys(player(f.afterSetup, f.richese).forces).length, 0);
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.equal(f.placement.before.turn, 1); assert.equal(f.placement.before.phase, 8);
|
||||
assert.ok(f.placement.steps.some(step => step.actor === f.owner && step.action.type === 'decision' &&
|
||||
step.action.token === f.token && step.action.territory === f.territory));
|
||||
const placed = f.placement.after.moritaniTerror!.tokens.find(token => token.id === f.token);
|
||||
assert.equal(placed?.kind, 'robbery'); assert.equal(placed?.status, 'placed'); assert.equal(placed?.location, f.territory);
|
||||
assert.equal(f.originalTerror.placementTurn, 1);
|
||||
assert.ok(f.paidShipment.steps.some(step => step.actor === f.entrant && step.action.type === 'ship' && step.action.amount === 1));
|
||||
assert.equal(player(f.paidShipment.before, f.entrant).spice - player(f.paidShipment.after, f.entrant).spice, f.price);
|
||||
assert.equal(player(f.paidShipment.after, f.guild).spice - player(f.paidShipment.before, f.guild).spice, f.price,
|
||||
'The ordinary shipment pays its actual tariff to the native Guild');
|
||||
assert.equal(player(f.paidShipment.before, f.entrant).reserves - player(f.paidShipment.after, f.entrant).reserves, 1);
|
||||
assert.equal(f.price, f.entryKind === 'shipment' ? 1 : 2);
|
||||
assert.equal(f.entry.steps[0].actor, f.entrant); assert.deepEqual(f.entry.steps[0].action, f.entryAction);
|
||||
if (f.entryKind === 'movement') {
|
||||
assert.equal(player(f.entry.before, f.entrant).forces[f.origin], 1);
|
||||
assert.equal(player(f.game, f.entrant).forces[f.origin] ?? 0, 0);
|
||||
assert.ok(f.entry.steps.some(step => step.action.type === 'move'));
|
||||
}
|
||||
assert.equal(player(f.game, f.entrant).forces[f.location], 1);
|
||||
assert.equal(player(f.game, f.entrant).shipped, true); assert.equal(player(f.game, f.entrant).moved, Number(f.entryKind === 'movement'));
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.advanced, false);
|
||||
assert.deepEqual(f.offer.steps, [{ actor: f.owner, action: f.offerAction }]);
|
||||
assert.equal(f.game.response?.kind, 'moritaniAlliance'); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.originalEntry.stage, 'allianceResponse'); assert.equal(f.originalEntry.cause, f.entryKind);
|
||||
assert.equal(f.originalEntry.amount, 1); assert.equal(f.originalEntry.elite, 0); assert.equal(f.originalEntry.entrant, f.entrant);
|
||||
assert.equal(f.originalEntry.territory, f.territory); assert.equal(f.originalEntry.token, f.token);
|
||||
assert.deepEqual(f.game.pendingTerrorEntry, f.originalEntry); assert.deepEqual(f.game.moritaniTerror, f.originalTerror);
|
||||
assert.equal(player(f.game, f.owner).ally, null); assert.equal(player(f.game, f.entrant).ally, null);
|
||||
assert.deepEqual(f.game.allianceOffers, f.originalOffers);
|
||||
assert.equal(f.game.decision ?? null, null); assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaMoritaniAllianceFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaEcazMoritaniDiscard');
|
||||
if (pending.continuation.kind !== 'karamaEcazMoritaniDiscard') throw Error('Use the actual Moritani alliance cost');
|
||||
assert.equal(pending.batch.cause, 'karama:moritaniAlliance'); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(game.pendingTerrorEntry, f.originalEntry); assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function canceled(state: Game, f: SemutaMoritaniAllianceFixture, claimed: boolean) {
|
||||
const quote = quoteMoritaniAllianceCancellation(f.game, f.originalResponse); assert.ok(quote);
|
||||
assert.equal(state.pendingTreacheryDiscard ?? null, null); assert.equal(state.response ?? null, null);
|
||||
assert.deepEqual(state.pendingTerrorEntry, quote.entry); assert.deepEqual(state.decision, quote.decision);
|
||||
assert.deepEqual(physical(state), physical(f.game)); assert.deepEqual(state.moritaniTerror, f.originalTerror);
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
assert.deepEqual(steps, []); assert.deepEqual(game.decision, quote.decision);
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'offer'); assert.equal(game.pendingTerrorEntry?.allianceBlocked, true);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.owner, f.offerAction); reject(game, f.spender, f.action);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function revealOnce(source: Game, f: SemutaMoritaniAllianceFixture) {
|
||||
let game = act(source, f.revealActor, f.revealAction);
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'robbery'); assert.equal(game.decision?.player, f.owner);
|
||||
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.token)?.status, 'removed');
|
||||
assert.deepEqual(pieces(game), pieces(f.game));
|
||||
const amount = Math.ceil(player(game, f.entrant).spice / 2), before = structuredClone(game);
|
||||
assert.ok(amount > 0); game = act(game, f.robberyActor, f.robberyAction);
|
||||
assert.equal(player(game, f.entrant).spice, player(before, f.entrant).spice - amount);
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice + amount);
|
||||
for (const p of game.players.filter(p => ![f.owner, f.entrant].includes(p.id)))
|
||||
assert.equal(p.spice, player(before, p.id).spice);
|
||||
assert.deepEqual(pieces(game), pieces(f.game)); assert.equal(game.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(game.turn, f.game.turn); assert.equal(game.phase, f.game.phase); assert.equal(game.active, f.entrant);
|
||||
assert.equal(player(game, f.owner).ally, null); assert.equal(player(game, f.entrant).ally, null);
|
||||
assert.deepEqual(game.allianceOffers, f.originalOffers);
|
||||
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.token)?.status, 'removed');
|
||||
reject(game, f.owner, f.revealAction); reject(game, f.owner, f.robberyAction);
|
||||
reject(game, f.entrant, f.entryAction); assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`Basic native Enemy of My Enemy after actual ${variant.entry} cost Semuta ${claimed ? 'claim' : 'pass'} restores the original reveal choice and resolves Robbery once`, () => {
|
||||
const f = fixture(variant); nativeSource(f); const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = canceled(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
revealOnce(game, f);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Enemy of My Enemy allowance leaves the committed entry in place and lets the entrant actually accept the alliance', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant); nativeSource(f); let game = allow(structuredClone(f.game));
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'allianceReply'); assert.equal(game.decision?.player, f.entrant);
|
||||
assert.deepEqual(physical(game), physical(f.game)); assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
game = act(game, f.acceptActor, f.acceptAction);
|
||||
assert.equal(player(game, f.owner).ally, f.entrant); assert.equal(player(game, f.entrant).ally, f.owner);
|
||||
assert.equal(player(game, f.owner).allySinceTurn, 2); assert.equal(player(game, f.entrant).allySinceTurn, 2);
|
||||
assert.equal(game.pendingTerrorEntry ?? null, null); assert.deepEqual(pieces(game), pieces(f.game));
|
||||
assert.deepEqual(game.players.map(p => [p.id, p.spice]), f.game.players.map(p => [p.id, p.spice]));
|
||||
assert.equal(game.moritaniTerror!.tokens.find(token => token.kind === 'robbery')?.status, 'available');
|
||||
assert.equal(game.moritaniTerror!.tokens.filter(token => token.status === 'placed' && token.location === f.territory).length, 0);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.acceptActor, f.acceptAction);
|
||||
}
|
||||
});
|
||||
|
||||
void test('after prevented Enemy of My Enemy Moritani may really decline and retain the original hidden token', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), state = canceled(pass(open(f)), f, false);
|
||||
const game = act(state, f.declineActor, f.declineAction);
|
||||
assert.equal(game.pendingTerrorEntry ?? null, null); assert.deepEqual(game.moritaniTerror, f.originalTerror);
|
||||
assert.deepEqual(physical(game), physical(f.game)); assert.deepEqual(inventory(game), f.originalInventory);
|
||||
reject(game, f.owner, f.revealAction); reject(game, f.entrant, f.entryAction);
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh Enemy of My Enemy cost rejects stale and underlying choices while the original hidden-token offer stays parked', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.holder, { type: 'semutaSelect', event, card: f.token });
|
||||
reject(game, f.owner, f.offerAction); reject(game, f.revealActor, f.revealAction);
|
||||
reject(game, f.declineActor, f.declineAction); reject(game, f.acceptActor, f.acceptAction);
|
||||
reject(game, f.spender, { type: 'passResponse' }); reject(game, f.entrant, { type: 'endMovement' });
|
||||
assert.deepEqual(game.pendingTerrorEntry, f.originalEntry); assert.deepEqual(physical(game), physical(f.game));
|
||||
canceled(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer native committed-shipment and ground-entry alliance costs', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(own, before); assert.ok(action.type === 'semutaPass' || action.type === 'semutaCommit');
|
||||
assert.equal(action.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
canceled(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user