Offer Semuta on advisor, foresight and Face Dancer counters
This commit is contained in:
1 parent
2431370abb
commit
30c066726b
10 files changed
+1082
-9
No files matched your search
@@ -0,0 +1,211 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaRicheseCacheKarama, nextSemutaRicheseCacheKarama,
|
||||
semutaRicheseCacheKaramaClean, semutaRicheseCacheKaramaInventory,
|
||||
semutaRicheseCacheKaramaPlayer,
|
||||
} from './fixture-semuta-richese-cache-karama';
|
||||
|
||||
export type SemutaSpiritualForesightStep = { actor: string; action: Action };
|
||||
export const actSemutaSpiritualForesight = actSemutaRicheseCacheKarama;
|
||||
export const semutaSpiritualForesightInventory = semutaRicheseCacheKaramaInventory;
|
||||
export const semutaSpiritualForesightPlayer = semutaRicheseCacheKaramaPlayer;
|
||||
export const semutaSpiritualForesightClean = semutaRicheseCacheKaramaClean;
|
||||
|
||||
/** Native setup chooses either the quiet Polar source or the real Arrakeen
|
||||
* advisor-battle opportunity. No force, stance or decision is installed. */
|
||||
export function nextSemutaSpiritualForesight(game: Game,
|
||||
openingDecision?: 'advisorBattle' | 'guildTiming'): SemutaSpiritualForesightStep | null {
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
const bg = game.players.find(player => player.faction === 'beneGesserit');
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction') {
|
||||
assert.ok(bg);
|
||||
return { actor: bg.id, action: { type: 'predict', faction: 'emperor', turn: 10 } };
|
||||
}
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && game.advanced && bg && !bg.advisorSetup)
|
||||
return { actor: bg.id, action: { type: 'advisorSetup',
|
||||
territory: openingDecision === 'advisorBattle' ? 'arrakeen' : 'polar_sink',
|
||||
sector: openingDecision === 'advisorBattle' ? 10 : 0 } };
|
||||
if (game.decision?.kind === 'advisor' || game.decision?.kind === 'advisorBattle' || game.decision?.kind === 'intrusion')
|
||||
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||
}
|
||||
return nextSemutaRicheseCacheKarama(game);
|
||||
}
|
||||
export function advanceSemutaSpiritualForesight(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaSpiritualForesightStep[] = [], openingDecision?: 'advisorBattle' | 'guildTiming'): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original terminal source must reach its next native control');
|
||||
const next = nextSemutaSpiritualForesight(game, openingDecision);
|
||||
assert.ok(next, `Supply the original owned control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaSpiritualForesight(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Spiritual/foresight programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Real first-turn Semuta purchase precedes both terminal sources. The Basic
|
||||
* advisor path retains a committed Emperor shipment; foresight stops at the
|
||||
* native phase-five opening before a card can be inspected or drawn. */
|
||||
export function createSemutaSpiritualForesightFixture(options: {
|
||||
kind: 'advisor' | 'atreidesSpice'; advanced?: boolean; openingDecision?: 'advisorBattle' | 'guildTiming';
|
||||
}) {
|
||||
const { kind, openingDecision } = options, advanced = options.advanced ?? false;
|
||||
assert.ok(kind !== 'advisor' || !advanced,
|
||||
'This source is the ordinary Basic bare advisor response, not a new or legacy Advanced Spiritual frame');
|
||||
assert.ok(!openingDecision || advanced && kind === 'atreidesSpice',
|
||||
'Only actual Advanced foresight shares these native opening decisions');
|
||||
const richese = 'richese', holder = 'choam', bg = 'beneGesserit', atreides = 'atreides', spender = 'emperor';
|
||||
const fifthFaction = openingDecision === 'guildTiming' ? 'guild' as const : bg;
|
||||
const owner = kind === 'advisor' ? bg : atreides;
|
||||
let game = createGame('SEMUTASPIRITUALFORESIGHT', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of [holder, fifthFaction, atreides, spender] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaSpiritualForesightStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaSpiritualForesight(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
game = advanceSemutaSpiritualForesight(game, until, actions, openingDecision);
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
else act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: advanced ? 'original-undealt-semuta-preview' : 'public-basic-start' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); 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 = semutaSpiritualForesightInventory(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').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 { 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(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.ok(semutaSpiritualForesightPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const front: Game['spiceDeck'] = [], spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (let position = 0; position < (advanced ? 2 : 1); position++) {
|
||||
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
|
||||
const card = game.spiceDeck.splice(from, 1)[0]; front.push(card);
|
||||
spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bidStep: SemutaSpiritualForesightStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaSpiritualForesightPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaSpiritualForesight(game, openingDecision); assert.ok(next);
|
||||
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bidStep = structuredClone(next); bidAt = actions.length;
|
||||
}
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(beforeBid && bidStep && bidAt >= 0, 'CHOAM must actually buy the original Semuta from cache');
|
||||
const acquired = structuredClone(game), acquisition = {
|
||||
before: beforeBid, bid: bidStep, steps: structuredClone(actions.slice(bidAt)), after: acquired,
|
||||
};
|
||||
assert.equal(semutaSpiritualForesightPlayer(beforeBid, holder).spice - semutaSpiritualForesightPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaSpiritualForesightPlayer(game, richese).spice - semutaSpiritualForesightPlayer(beforeBid, richese).spice, 1);
|
||||
reach(state => state.phase === 4 && semutaSpiritualForesightClean(state));
|
||||
const beforeMovement = structuredClone(game);
|
||||
let beforeShipment: Game | null = null;
|
||||
let shipment: { before: Game; action: Action; after: Game; steps: SemutaSpiritualForesightStep[] } | null = null;
|
||||
let advisorChoice: { before: Game; action: Action; after: Game } | null = null;
|
||||
if (kind === 'advisor') {
|
||||
reach(state => state.phase === 5 && state.active === spender && semutaSpiritualForesightClean(state));
|
||||
beforeShipment = structuredClone(game);
|
||||
const action: Action = { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 };
|
||||
const start = actions.length;
|
||||
act(spender, action);
|
||||
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
assert.ok(game.decision?.kind === 'advisor');
|
||||
assert.equal(game.decision?.player, bg);
|
||||
assert.equal(game.decision?.shipment, spender);
|
||||
const after = structuredClone(game);
|
||||
shipment = { before: beforeShipment, action, after, steps: structuredClone(actions.slice(start)) };
|
||||
assert.equal(semutaSpiritualForesightPlayer(beforeShipment, spender).spice - semutaSpiritualForesightPlayer(game, spender).spice, 2);
|
||||
assert.equal(semutaSpiritualForesightPlayer(beforeShipment, spender).reserves - semutaSpiritualForesightPlayer(game, spender).reserves, 1);
|
||||
assert.equal(semutaSpiritualForesightPlayer(game, spender).forces['polar_sink:0'], 1);
|
||||
const accept: Action = { type: 'decision', accept: true, amount: 1 };
|
||||
act(bg, accept);
|
||||
advisorChoice = { before: after, action: accept, after: structuredClone(game) };
|
||||
assert.equal(game.response?.kind, 'advisor');
|
||||
assert.equal(game.response?.spiritualAdvisorSource, undefined);
|
||||
assert.equal(game.response?.advisorAmbassadorEvent, undefined);
|
||||
assert.equal(game.response?.advisorRevivalEvent, undefined);
|
||||
assert.ok(!game.pendingSpiritualAdvisor);
|
||||
} else {
|
||||
reach(state => state.phase === 5 && state.response?.kind === 'atreidesSpice');
|
||||
assert.equal(game.spicePeekKnown, false);
|
||||
assert.deepEqual(game.spiceDeck, beforeMovement.spiceDeck);
|
||||
assert.deepEqual(game.spiceDiscard, beforeMovement.spiceDiscard);
|
||||
}
|
||||
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.phaseOpening ?? null, null, 'The actual phase opening is complete, never cleared by staging');
|
||||
if (openingDecision) {
|
||||
assert.equal(game.decision?.kind, openingDecision, 'The original phase-five quote must genuinely create this decision');
|
||||
assert.equal(game.decision?.player, openingDecision === 'advisorBattle' ? bg : 'guild');
|
||||
if (game.decision?.kind === 'advisorBattle') assert.ok(game.decision.territories.includes('arrakeen'));
|
||||
} else assert.equal(game.decision, null, 'This actual source has no other owned decision parked alongside it');
|
||||
assert.ok(semutaSpiritualForesightPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaSpiritualForesightPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaSpiritualForesightInventory(game), originalInventory);
|
||||
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return {
|
||||
game, source, gameBeforeCost: source, action, kind, advanced, openingDecision,
|
||||
owner, holder, spender, richese, bg, atreides, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
|
||||
originalResponse: structuredClone(game.response), originalDecision: structuredClone(game.decision),
|
||||
beforeMovement, beforeShipment, shipment, advisorChoice,
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder,
|
||||
acquired, acquisition, originalInventory, inventory: originalInventory, originalSpice,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
advanced
|
||||
? openingDecision === 'guildTiming'
|
||||
? 'Existing Advanced Semuta initializer on the original ready undealt R/CHOAM/Guild/Atreides/Emperor lobby. Original Guild membership naturally creates the phase-five Guild timing decision alongside Atreides foresight.'
|
||||
: openingDecision === 'advisorBattle'
|
||||
? 'Existing Advanced Semuta initializer on the original ready undealt R/CHOAM/BG/Atreides/Emperor lobby; BG really predicts Emperor turn10 and places its initial advisor in Arrakeen10 alongside native Atreides, naturally earning the phase-five advisorBattle decision.'
|
||||
: 'Existing Advanced Semuta initializer on the original ready undealt R/CHOAM/BG/Atreides/Emperor lobby; actual BG predicts Emperor turn10 and chooses native Polar Sink setup, avoiding an unrelated advisorBattle chooser.'
|
||||
: 'Actual public bare Basic R/CHOAM/BG/Atreides/Emperor ready/start, original prediction and force setup, with existing Semuta preview initialized before play.',
|
||||
'Only unopened original starting-card positions (Emperor genuinely receives printed Karama), one Basic/two Advanced unplayed land Spice positions and original scalar lotteries are selected. CHOAM really buys Semuta in the native first cache auction for one spice paid to Richese.',
|
||||
kind === 'advisor'
|
||||
? 'Actual original phase-five foresight is allowed, earlier combined turns end, then Emperor pays two spice to send one real reserve force to Polar Sink. BG accepts its genuine free accompaniment, opening the ordinary bare Basic advisor response. The original paid shipment remains committed; no newer Advanced Spiritual/Ambassador/revival source is manufactured.'
|
||||
: 'After the paid Semuta acquisition and original Revival, stop at the genuine phase-five Atreides Spice foresight response. Native phase-opening passes complete normally. No Spice Card is drawn, revealed, reordered or refilled between the prior Revival snapshot and this offered foresight.',
|
||||
openingDecision
|
||||
? `The actual ${openingDecision} decision remains unanswered alongside the native Atreides response. Its owner, counters, territories/order and complete saved decision are captured, never manufactured or cleared by the fixture.`
|
||||
: 'The default source retains its original null opening decision.',
|
||||
'No played hand, balance, force, stance, phase, readiness, queue, response or receipt is installed.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaSpiritualForesightFixture = ReturnType<typeof createSemutaSpiritualForesightFixture>;
|
||||
|
||||
export function passSemutaSpiritualForesight(game: Game, actions: SemutaSpiritualForesightStep[] = []): Game {
|
||||
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
|
||||
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
|
||||
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard!;
|
||||
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
|
||||
const player = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(player);
|
||||
game = actSemutaSpiritualForesight(game, player.id, { type: 'semutaPass', event }, actions);
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
|
||||
}
|
||||
export function claimSemutaSpiritualForesight(game: Game, fixture: Pick<SemutaSpiritualForesightFixture, 'holder' | 'cost'>,
|
||||
actions: SemutaSpiritualForesightStep[] = []): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
|
||||
const event = pending.batch.event; assert.equal(pending.batch.entries[0].card.id, fixture.cost);
|
||||
game = actSemutaSpiritualForesight(game, fixture.holder, { type: 'semutaCommit', event }, actions);
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = actSemutaSpiritualForesight(game, fixture.holder, { type: 'semutaSelect', event, card: fixture.cost }, actions);
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { actTruthMovement } from './fixture-truth-movement';
|
||||
import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama';
|
||||
import { truthFaceDancerTraitors } from './fixture-truthtrance-face-dancer';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type SemutaStormDancerStep = { actor: string; action: Action };
|
||||
export type SemutaStormDancerKind = 'stormPeek' | 'faceDancerReplacement';
|
||||
export const actSemutaStormDancer = actTruthMovement;
|
||||
export const semutaStormDancerPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const semutaStormDancerClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function semutaStormDancerInventory(game: Game) {
|
||||
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length); return ids;
|
||||
}
|
||||
export const semutaStormDancerTraitors = truthFaceDancerTraitors;
|
||||
/** Real setup/readiness controls. A fresh reaction stays caller-owned; no
|
||||
* optional replacement or cancellation is silently chosen. */
|
||||
export function nextSemutaStormDancerStep(game: Game): SemutaStormDancerStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
|
||||
if (game.status === 'setup' && game.setupStage === 'forces') {
|
||||
const fremen = game.players.find(p => p.faction === 'fremen');
|
||||
if (fremen?.reserves === 20 && viewGame(game, fremen.id).setupPending.includes(fremen.id))
|
||||
return { actor: fremen.id, action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 } } };
|
||||
}
|
||||
return nextSemutaRicheseCacheKarama(game);
|
||||
}
|
||||
export function advanceSemutaStormDancer(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaStormDancerStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaStormDancerStep(game);
|
||||
assert.ok(next, 'Choose the original terminal-cost fresh reaction before continuing');
|
||||
game = actSemutaStormDancer(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Storm/Dancer programme did not reach its finite boundary');
|
||||
}
|
||||
export function passSemutaStormDancer(game: Game, actions: SemutaStormDancerStep[] = []): Game {
|
||||
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
|
||||
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
|
||||
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard!;
|
||||
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaStormDancer(game, actor.id, { type: 'semutaPass', event }, actions);
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
|
||||
}
|
||||
export function claimSemutaStormDancer(game: Game, holder: string,
|
||||
actions: SemutaStormDancerStep[] = []): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
|
||||
const event = pending.batch.event, card = pending.batch.entries[0].card.id;
|
||||
game = actSemutaStormDancer(game, holder, { type: 'semutaCommit', event }, actions);
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = actSemutaStormDancer(game, holder, { type: 'semutaSelect', event, card }, actions);
|
||||
return game;
|
||||
}
|
||||
/** Original dispatcher priority: finish a coexisting opening through real
|
||||
* ready actions before allowing this native response. Never initialize twice. */
|
||||
export function allowSemutaStormDancer(game: Game, actions: SemutaStormDancerStep[] = []): Game {
|
||||
const kind = game.response?.kind; assert.ok(kind === 'stormPeek' || kind === 'faceDancerReplacement');
|
||||
while (game.phaseOpening) {
|
||||
const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaStormDancer(game, actor.id, { type: 'ready' }, actions);
|
||||
}
|
||||
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 = actSemutaStormDancer(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind); return game;
|
||||
}
|
||||
|
||||
/** Actual paid first cache, original E Karama and native terminal opportunity.
|
||||
* Only unopened deal/Spice positions and original scalar lotteries are chosen.
|
||||
* No played custody, wallet, army, phase, pending frame or traitor pool is set. */
|
||||
export function createSemutaStormDancerFixture(options: { kind?: SemutaStormDancerKind; advanced?: boolean } = {}) {
|
||||
const kind = options.kind ?? 'faceDancerReplacement', advanced = options.advanced ?? kind === 'stormPeek';
|
||||
assert.ok(kind !== 'stormPeek' || advanced, 'Native next-Storm knowledge is Advanced only');
|
||||
const seller = 'richese', holder = 'choam', dancer = 'tleilaxu', fremen = 'fremen', spender = 'emperor';
|
||||
const owner = kind === 'stormPeek' ? fremen : dancer;
|
||||
let game = createGame('SEMUTASTORMDANCER', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam', 'ix']);
|
||||
for (const faction of ['choam', 'tleilaxu', 'fremen', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: SemutaStormDancerStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaStormDancer(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaStormDancer(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
else act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: advanced ? 'explicit-original-semuta-initializer' : 'public-basic-start' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => !p.hand.length));
|
||||
const originalInventory = semutaStormDancerInventory(game); assert.equal(originalInventory.length, 57);
|
||||
const originalTraitors = semutaStormDancerTraitors(game);
|
||||
const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind) && !card.effect).slice(0, 4);
|
||||
assert.ok(printed); assert.equal(ordinary.length, 4);
|
||||
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, position, from };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let faceDancerDeal: { before: Game; steps: SemutaStormDancerStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 150; count++) {
|
||||
const next = nextSemutaStormDancerStep(game); assert.ok(next);
|
||||
const before = structuredClone(game), hadDancers = !!semutaStormDancerPlayer(game, dancer).faceDancers;
|
||||
act(next.actor, next.action);
|
||||
if (!hadDancers && semutaStormDancerPlayer(game, dancer).faceDancers)
|
||||
faceDancerDeal = { before, steps: [structuredClone(next)], after: structuredClone(game) };
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(faceDancerDeal);
|
||||
const afterSetup = structuredClone(game), originalFaceDancers = structuredClone(semutaStormDancerPlayer(game, dancer).faceDancers!);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), deal);
|
||||
assert.equal(originalFaceDancers.length, 3); assert.ok(originalFaceDancers.every(card => !card.revealed));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (let position = 0; position < (advanced ? 4 : 2); position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
['hagga_basin', 'rock_outcroppings', 'broken_land', 'oh_gap'].includes(card.territory));
|
||||
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) });
|
||||
}
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaStormDancerStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaStormDancerPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaStormDancerStep(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.ok(semutaStormDancerPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.equal(semutaStormDancerPlayer(beforeBid, holder).spice - semutaStormDancerPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaStormDancerPlayer(game, seller).spice - semutaStormDancerPlayer(beforeBid, seller).spice, 1);
|
||||
let trigger: { before: Game; steps: SemutaStormDancerStep[]; after: Game };
|
||||
let opening: { before: Game; steps: SemutaStormDancerStep[]; after: Game } | null = null;
|
||||
let replacementAction: Action | null = null, candidate: string | null = null;
|
||||
if (kind === 'faceDancerReplacement') {
|
||||
reach(state => state.turn === 1 && state.phase === 8 && semutaStormDancerClean(state));
|
||||
candidate = semutaStormDancerPlayer(game, dancer).faceDancers!.find(card => !card.revealed)!.leader;
|
||||
replacementAction = { type: 'replaceFaceDancer', leader: candidate };
|
||||
trigger = transition(() => { act(dancer, replacementAction!); });
|
||||
assert.equal(semutaStormDancerPlayer(game, dancer).faceDancerReplacedTurn, game.turn);
|
||||
assert.equal(game.response?.intent, candidate);
|
||||
} else {
|
||||
reach(state => state.turn === 2 && state.phase === 0 && semutaStormDancerClean(state));
|
||||
trigger = transition(() => { reach(state => state.turn === 2 && state.response?.kind === 'stormPeek'); });
|
||||
assert.equal(game.phase, 1); assert.deepEqual(game.phaseOpening, { passed: [], initialize: false });
|
||||
const forecast = game.stormCard;
|
||||
opening = transition(() => {
|
||||
while (game.phaseOpening) {
|
||||
const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id)); assert.ok(actor);
|
||||
act(actor.id, { type: 'ready' });
|
||||
}
|
||||
});
|
||||
assert.equal(game.stormCard, forecast); assert.equal(game.stormCardKnown, false);
|
||||
assert.equal(game.response?.kind, 'stormPeek');
|
||||
}
|
||||
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
|
||||
assert.ok(!game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard && !game.pendingAmbassador && !game.battle);
|
||||
assert.ok(semutaStormDancerPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaStormDancerPlayer(game, dancer).faceDancers, originalFaceDancers);
|
||||
assert.deepEqual(semutaStormDancerTraitors(game), originalTraitors);
|
||||
assert.deepEqual(semutaStormDancerInventory(game), originalInventory);
|
||||
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return { game, source, beforeCost: source, kind, advanced, holder, spender, owner, seller, dancer, fremen,
|
||||
cost, karama: cost, card: cost, semuta: SEMUTA_DRUG_ID, action, candidate, replacementAction,
|
||||
lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder, faceDancerDeal, originalFaceDancers,
|
||||
originalSpice, spiceOrder, acquired, acquisition, trigger, opening, beforeOpening: opening?.before ?? null,
|
||||
coexistentOpening: opening?.before.phaseOpening ?? null, stormCard: game.stormCard,
|
||||
originalInventory, originalCards: originalInventory, originalTraitors,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Native Richese/CHOAM/Tleilaxu/Fremen/Emperor uses the public bare Basic start or original explicit Advanced Semuta initializer, main47/cache10. Only unopened original deal/Spice positions and scalar lotteries are selected; Emperor actually receives its printed Karama and Tleilaxu its three original unrevealed Face Dancers.',
|
||||
'Fremen actually places ten starting normals in Sietch Tabr14. CHOAM buys the real Semuta through an original cache1 bid and payment before this later opportunity. No dealt hand, force, wallet, phase, traitor pool or paused source is assigned.',
|
||||
kind === 'faceDancerReplacement' ? 'At actual first Mentat, Tleilaxu really declares replacement of one originally dealt unrevealed candidate. The attempt is used before its native response; no reserve card has been shuffled/drawn and no Face Dancer replaced yet.' : 'Actually finish first Mentat and the original second Storm, including original CHOAM storm market closure. Capture native stormPeek plus initialize:false opening coexistence, then submit all real ready acknowledgements. Stop at the same valid original forecast counter after its opening has finished; no dispatcher priority, card, opening or pending response is installed.',
|
||||
'Stop before Emperor spends the original cancellation cost. Caller owns the exact fresh-cost Semuta claim/pass and original prevention; no new knowledge, replacement, draw or once-use reset is assumed.',
|
||||
] };
|
||||
}
|
||||
export type SemutaStormDancerFixture = ReturnType<typeof createSemutaStormDancerFixture>;
|
||||
@@ -0,0 +1,232 @@
|
||||
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 { quoteMovementPhaseStart } from '../game/movement-phase-quote';
|
||||
import {
|
||||
createSemutaSpiritualForesightFixture as fixture, actSemutaSpiritualForesight as act,
|
||||
passSemutaSpiritualForesight as pass, claimSemutaSpiritualForesight as claim,
|
||||
semutaSpiritualForesightInventory as inventory, semutaSpiritualForesightPlayer as player,
|
||||
type SemutaSpiritualForesightFixture,
|
||||
} from './fixture-semuta-spiritual-foresight';
|
||||
|
||||
const resources = (game: Game) => ({
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
||||
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
|
||||
moved: player.moved, shipped: player.shipped, revived: player.revived, ally: player.ally,
|
||||
advisors: player.advisors })),
|
||||
active: game.active, order: game.order, ready: game.ready,
|
||||
turn: game.turn, phase: game.phase, movementRemaining: game.movementRemaining,
|
||||
guildTimingGranted: game.guildTimingGranted, guildTimingLocked: game.guildTimingLocked,
|
||||
advisorPreparation: game.advisorPreparation,
|
||||
});
|
||||
const variants = [
|
||||
{ kind: 'advisor' as const, advanced: false },
|
||||
{ kind: 'atreidesSpice' as const, advanced: false },
|
||||
{ kind: 'atreidesSpice' as const, advanced: true },
|
||||
];
|
||||
const openingVariants = [
|
||||
{ kind: 'atreidesSpice' as const, advanced: true, openingDecision: 'advisorBattle' as const },
|
||||
{ kind: 'atreidesSpice' as const, advanced: true, openingDecision: 'guildTiming' as const },
|
||||
];
|
||||
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: SemutaSpiritualForesightFixture) {
|
||||
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.ok(player(f.acquisition.after, f.holder).hand.some(card => card.id === f.semuta));
|
||||
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.phase, 5); assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.phaseOpening ?? null, null); assert.deepEqual(f.game.decision, f.originalDecision);
|
||||
if (f.openingDecision) assert.equal(f.originalDecision?.kind, f.openingDecision);
|
||||
else assert.equal(f.originalDecision, null);
|
||||
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.deepEqual(inventory(f.game), f.originalInventory);
|
||||
if (f.kind === 'advisor') {
|
||||
assert.ok(f.shipment && f.beforeShipment && f.advisorChoice);
|
||||
assert.equal(f.game.advanced, false);
|
||||
assert.equal(player(f.beforeShipment, f.spender).spice - player(f.game, f.spender).spice, 2);
|
||||
assert.equal(player(f.beforeShipment, f.spender).reserves - player(f.game, f.spender).reserves, 1);
|
||||
assert.equal(player(f.game, f.spender).forces['polar_sink:0'], 1);
|
||||
assert.equal(player(f.game, f.spender).shipped, true);
|
||||
assert.equal(player(f.game, f.spender).moved, 0);
|
||||
assert.equal(f.game.response?.spiritualAdvisorSource, undefined);
|
||||
assert.equal(f.game.response?.advisorAmbassadorEvent, undefined);
|
||||
assert.equal(f.game.response?.advisorRevivalEvent, undefined);
|
||||
assert.ok(!f.game.pendingSpiritualAdvisor);
|
||||
} else {
|
||||
assert.equal(f.game.spicePeekKnown, false);
|
||||
assert.deepEqual(f.game.spiceDeck, f.beforeMovement.spiceDeck);
|
||||
assert.deepEqual(f.game.spiceDiscard, f.beforeMovement.spiceDiscard);
|
||||
}
|
||||
}
|
||||
function open(f: SemutaSpiritualForesightFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.equal(pending.continuation.kind, 'karamaTerminalDiscard');
|
||||
assert.equal(pending.batch.cause, `karama:${f.kind}`);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ id: entry.card.id, by: entry.discardedBy, public: entry.publicFace })),
|
||||
[{ id: f.cost, by: f.spender, public: true }]);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.response, null, 'The original terminal response waits for its cost reaction');
|
||||
assert.equal(game.decision, null, 'The coexisting native opening decision is parked while the cost is fresh');
|
||||
assert.deepEqual(game.pendingArrivalReaction, f.game.pendingArrivalReaction,
|
||||
'The original paid shipment entry waits through the cost response');
|
||||
if (pending.continuation.kind !== 'karamaTerminalDiscard') throw Error('Use the terminal cost continuation');
|
||||
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
|
||||
assert.equal(pending.continuation.use.kind, 'cancel');
|
||||
assert.equal(pending.continuation.use.response.kind, f.kind);
|
||||
assert.equal(pending.continuation.use.response.owner, f.owner);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.decision, f.originalDecision);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
assert.deepEqual(game.spiceDeck, f.game.spiceDeck);
|
||||
assert.deepEqual(game.spiceDiscard, f.game.spiceDiscard);
|
||||
assert.equal(game.spicePeekKnown, f.game.spicePeekKnown);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
return game;
|
||||
}
|
||||
function prevented(game: Game, f: SemutaSpiritualForesightFixture, claimed: boolean) {
|
||||
assert.ok(!game.pendingTreacheryDiscard); assert.ok(!game.pendingKarama);
|
||||
assert.equal(game.response, null); assert.equal(game.phaseOpening ?? null, null);
|
||||
assert.deepEqual(game.decision, f.originalDecision);
|
||||
// The spender's physical cost is intentionally absent from its hand; all
|
||||
// native force/payment/phase source fields stay unchanged.
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
assert.deepEqual(game.spiceDeck, f.game.spiceDeck);
|
||||
assert.deepEqual(game.spiceDiscard, f.game.spiceDiscard);
|
||||
assert.equal(game.spicePeekKnown, f.game.spicePeekKnown);
|
||||
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));
|
||||
if (f.kind === 'advisor') {
|
||||
assert.ok(f.beforeShipment);
|
||||
assert.equal(f.game.pendingArrivalReaction?.length, 1);
|
||||
assert.equal(game.pendingArrivalReaction, null,
|
||||
'Native continuation consumes the original paid entry once after prevention');
|
||||
assert.deepEqual(player(game, f.owner), player(f.game, f.owner));
|
||||
assert.equal(player(game, f.spender).forces['polar_sink:0'], 1);
|
||||
assert.equal(player(game, f.spender).shipped, true);
|
||||
assert.equal(player(game, f.spender).moved, 0);
|
||||
assert.equal(player(f.beforeShipment, f.spender).spice - player(game, f.spender).spice, 2);
|
||||
assert.ok(!game.pendingSpiritualAdvisor);
|
||||
} else assert.equal(game.spicePeekKnown, false);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`${variant.advanced ? 'Advanced' : 'Basic'} genuine ${variant.kind} cancellation offers its printed cost for Semuta ${claimed ? 'claim' : 'pass'}`, () => {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
const game = prevented(claimed ? claim(pending, f) : pass(pending), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event });
|
||||
reject(game, f.holder, { type: 'semutaPass', event });
|
||||
});
|
||||
|
||||
for (const variant of openingVariants) for (const claimed of [false, true]) void test(
|
||||
`Advanced foresight ${claimed ? 'claimed' : 'passed'} cost restores the actual unanswered ${variant.openingDecision} opening`, () => {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
const original = f.originalDecision; assert.ok(original);
|
||||
assert.equal(original.kind, variant.openingDecision);
|
||||
const beforeQuote = quoteMovementPhaseStart(f.game);
|
||||
assert.equal(beforeQuote.kind, 'initialize');
|
||||
if (beforeQuote.kind !== 'initialize') throw Error('The source must be its actual movement opening');
|
||||
assert.deepEqual(beforeQuote.decision, original);
|
||||
if (original.kind === 'advisorBattle') {
|
||||
assert.equal(original.player, f.bg);
|
||||
assert.ok(original.territories.includes('arrakeen'));
|
||||
assert.equal(player(f.afterSetup, f.bg).forces['arrakeen:10'], 1);
|
||||
assert.ok(player(f.afterSetup, f.bg).advisors?.arrakeen);
|
||||
} else {
|
||||
assert.equal(original.kind, 'guildTiming');
|
||||
assert.equal(original.player, 'guild');
|
||||
assert.equal(f.game.players.some(player => player.faction === 'beneGesserit'), false);
|
||||
assert.ok(f.game.players.some(player => player.id === original.player && player.faction === 'guild'));
|
||||
}
|
||||
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
const underlying: Action = original.kind === 'advisorBattle'
|
||||
? { type: 'decision', accept: false } : { type: 'decision', take: false };
|
||||
reject(pending, original.player, underlying);
|
||||
const game = prevented(claimed ? claim(pending, f) : pass(pending), f, claimed);
|
||||
assert.deepEqual(game.decision, original, 'Neither the used-cost child nor prevention answers or regenerates the native decision');
|
||||
const afterQuote = quoteMovementPhaseStart(game);
|
||||
assert.equal(afterQuote.kind, 'initialize');
|
||||
if (afterQuote.kind !== 'initialize') throw Error('The original movement opening must remain available');
|
||||
assert.deepEqual(afterQuote.decision, original);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event });
|
||||
});
|
||||
|
||||
for (const variant of variants) void test(
|
||||
`${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.kind} allowance control earns the actual packet or peek without spending either card`, () => {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
let game = structuredClone(f.game);
|
||||
for (let count = 0; game.response?.kind === f.kind && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null); assert.ok(!game.pendingTreacheryDiscard);
|
||||
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(game.spiceDeck, f.game.spiceDeck); assert.deepEqual(game.spiceDiscard, f.game.spiceDiscard);
|
||||
if (f.kind === 'advisor') {
|
||||
assert.equal(player(game, f.owner).reserves, player(f.game, f.owner).reserves - 1);
|
||||
assert.equal(player(game, f.owner).forces['polar_sink:0'], (player(f.game, f.owner).forces['polar_sink:0'] ?? 0) + 1);
|
||||
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
|
||||
assert.deepEqual(player(game, f.spender), player(f.game, f.spender));
|
||||
} else {
|
||||
assert.equal(game.spicePeekKnown, true);
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('actual terminal-cost suspension rejects underlying replies and stale Semuta events without replaying the source', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
reject(pending, f.spender, f.action);
|
||||
reject(pending, f.owner, { type: 'passResponse' });
|
||||
reject(pending, pending.active!, { type: 'endMovement' });
|
||||
const game = prevented(claim(pending, f), f, true);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event });
|
||||
reject(game, f.holder, { type: 'semutaPass', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies resolve each real terminal-cost source without repeating shipment or drawing Spice', () => {
|
||||
for (const variant of [...variants, ...openingVariants]) {
|
||||
const f = fixture(variant), pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(pending, 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(pending), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(player => player.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);
|
||||
prevented(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
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 {
|
||||
createSemutaStormDancerFixture as fixture, actSemutaStormDancer as act,
|
||||
passSemutaStormDancer as pass, claimSemutaStormDancer as claim, allowSemutaStormDancer as allow,
|
||||
semutaStormDancerPlayer as player, semutaStormDancerInventory as inventory,
|
||||
semutaStormDancerTraitors as traitors, type SemutaStormDancerFixture,
|
||||
} from './fixture-semuta-storm-dancer';
|
||||
|
||||
/** Knowledge source and native replacement/physical state, excluding the two
|
||||
* exact hands/discard positions which the Semuta exchange intentionally moves. */
|
||||
const fixed = (game: Game) => ({ turn: game.turn, phase: game.phase, order: game.order, active: game.active,
|
||||
ready: game.ready, phaseOpening: game.phaseOpening, deck: game.deck, richeseCache: game.richeseCache,
|
||||
richeseRemoved: game.richeseRemoved, storm: game.storm, stormCard: game.stormCard, stormCardKnown: game.stormCardKnown,
|
||||
stormPending: game.stormPending, stormResolution: game.stormResolution, stormMovementSource: game.stormMovementSource,
|
||||
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard, traitorReserve: game.traitorReserve,
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves, tanks: p.tanks,
|
||||
forces: p.forces, elites: p.elites, shipped: p.shipped, moved: p.moved, revived: p.revived,
|
||||
faceDancers: p.faceDancers, faceDancerReplacedTurn: p.faceDancerReplacedTurn })) });
|
||||
function reject(game: Game, actor: string, proposed: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, proposed)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaStormDancerFixture) {
|
||||
assert.equal(f.setup.advanced, f.advanced); assert.equal(f.game.advanced, f.advanced);
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(f.faceDancerDeal); assert.equal(player(f.afterSetup, f.dancer).faceDancers!.length, 3);
|
||||
assert.deepEqual(player(f.game, f.dancer).faceDancers, f.originalFaceDancers);
|
||||
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.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.phaseOpening, null);
|
||||
if (f.kind === 'faceDancerReplacement') {
|
||||
assert.equal(f.game.phase, 8); assert.equal(f.game.turn, 1); assert.ok(f.candidate && f.replacementAction);
|
||||
assert.deepEqual(f.replacementAction, { type: 'replaceFaceDancer', leader: f.candidate });
|
||||
assert.equal(player(f.trigger.before, f.dancer).faceDancerReplacedTurn, undefined);
|
||||
assert.equal(player(f.game, f.dancer).faceDancerReplacedTurn, f.game.turn);
|
||||
assert.deepEqual(f.game.traitorReserve, f.trigger.before.traitorReserve);
|
||||
assert.equal(f.game.response?.intent, f.candidate);
|
||||
} else {
|
||||
assert.equal(f.game.phase, 1); assert.equal(f.game.turn, 2); assert.equal(f.advanced, true);
|
||||
assert.ok(f.opening && f.beforeOpening);
|
||||
assert.deepEqual(f.coexistentOpening, { passed: [], initialize: false });
|
||||
assert.equal(f.opening.steps.length, f.game.players.length);
|
||||
assert.ok(f.opening.steps.every(step => step.action.type === 'ready'));
|
||||
assert.deepEqual(new Set(f.opening.steps.map(step => step.actor)), new Set(f.game.players.map(p => p.id)));
|
||||
assert.deepEqual(f.game.response, f.beforeOpening.response);
|
||||
assert.equal(f.game.stormCard, f.beforeOpening.stormCard); assert.equal(f.game.stormCardKnown, false);
|
||||
assert.equal(viewGame(f.game, f.owner).stormForecast, null);
|
||||
}
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 57);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory); assert.deepEqual(traitors(f.game), f.originalTraitors);
|
||||
}
|
||||
function open(f: SemutaStormDancerFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
assert.equal(pending.continuation.kind, 'karamaTerminalDiscard');
|
||||
if (pending.continuation.kind !== 'karamaTerminalDiscard') throw Error('Expected the exact terminal cancellation cost');
|
||||
assert.deepEqual(pending.continuation.use.response, f.game.response);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.cost]);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => entry.discardedBy), [f.spender]);
|
||||
assert.equal(game.response, null); assert.equal(game.phaseOpening, 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.deepEqual(fixed(game), fixed(f.game)); assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); return game;
|
||||
}
|
||||
function denied(game: Game, f: SemutaStormDancerFixture, claimed: boolean) {
|
||||
assert.ok(!game.pendingTreacheryDiscard); assert.equal(game.response, null); assert.equal(game.decision, null);
|
||||
assert.deepEqual(fixed(game), fixed(f.game));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(traitors(game), f.originalTraitors);
|
||||
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));
|
||||
if (f.kind === 'faceDancerReplacement') {
|
||||
assert.equal(player(game, f.owner).faceDancerReplacedTurn, game.turn);
|
||||
assert.deepEqual(player(game, f.owner).faceDancers, f.originalFaceDancers);
|
||||
assert.deepEqual(game.traitorReserve, f.game.traitorReserve);
|
||||
for (const card of f.originalFaceDancers) reject(game, f.owner, { type: 'replaceFaceDancer', leader: card.leader });
|
||||
} else {
|
||||
assert.equal(game.stormCardKnown, false); assert.equal(game.stormCard, f.stormCard);
|
||||
assert.equal(viewGame(game, f.owner).stormForecast, null);
|
||||
}
|
||||
reject(game, f.spender, f.action); return game;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const claimed of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} native Face Dancer replacement cost Semuta ${claimed ? 'claim' : 'pass'} preserves original cards, reserve order and spent attempt`, () => {
|
||||
const f = fixture({ kind: 'faceDancerReplacement', advanced }); nativeSource(f);
|
||||
const offered = open(f), game = denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
const ready = act(game, f.game.players[0].id, { type: 'ready' });
|
||||
assert.equal(ready.phase, 8); assert.equal(ready.turn, 1);
|
||||
assert.deepEqual(player(ready, f.owner).faceDancers, f.originalFaceDancers);
|
||||
assert.deepEqual(ready.traitorReserve, f.game.traitorReserve);
|
||||
assert.equal(player(ready, f.owner).faceDancerReplacedTurn, 1);
|
||||
assert.deepEqual(ready.ready, [f.game.players[0].id]);
|
||||
},
|
||||
);
|
||||
|
||||
for (const claimed of [false, true]) void test(
|
||||
`Advanced native next-Storm cost Semuta ${claimed ? 'claim' : 'pass'} preserves the actual next card and denies its knowledge once`, () => {
|
||||
const f = fixture({ kind: 'stormPeek' }); nativeSource(f);
|
||||
const offered = open(f), game = denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
const ready = act(game, f.game.players[0].id, { type: 'ready' });
|
||||
assert.equal(ready.phase, 1); assert.equal(ready.turn, 2);
|
||||
assert.equal(ready.phaseOpening, null); assert.equal(ready.stormCard, f.stormCard); assert.equal(ready.stormCardKnown, false);
|
||||
assert.equal(viewGame(ready, f.owner).stormForecast, null);
|
||||
assert.deepEqual(ready.spiceDeck, f.game.spiceDeck); assert.deepEqual(ready.deck, f.game.deck);
|
||||
assert.deepEqual(ready.ready, [f.game.players[0].id]);
|
||||
},
|
||||
);
|
||||
|
||||
void test('the genuine next-Storm forecast retains native false-opening priority before its original counter', () => {
|
||||
const f = fixture({ kind: 'stormPeek' }); nativeSource(f); assert.ok(f.beforeOpening && f.opening);
|
||||
reject(f.beforeOpening, f.spender, f.action);
|
||||
assert.deepEqual(f.beforeOpening.phaseOpening, { passed: [], initialize: false });
|
||||
assert.ok(player(f.beforeOpening, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.ok(player(f.beforeOpening, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(f.opening.after.phaseOpening, null); assert.deepEqual(f.opening.after.response, f.beforeOpening.response);
|
||||
assert.equal(f.opening.after.stormCard, f.beforeOpening.stormCard);
|
||||
assert.deepEqual(f.opening.after.spiceDeck, f.beforeOpening.spiceDeck);
|
||||
});
|
||||
|
||||
void test('the original forecast and replacement allowance still resolve through their genuine native responses', () => {
|
||||
for (const options of [{ kind: 'stormPeek' as const },
|
||||
{ kind: 'faceDancerReplacement' as const, advanced: false }, { kind: 'faceDancerReplacement' as const, advanced: true }]) {
|
||||
const f = fixture(options), game = allow(structuredClone(f.game));
|
||||
assert.equal(game.response, null); assert.ok(!game.pendingTreacheryDiscard);
|
||||
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); assert.deepEqual(traitors(game), f.originalTraitors);
|
||||
if (f.kind === 'stormPeek') {
|
||||
assert.equal(game.stormCard, f.stormCard); assert.equal(game.stormCardKnown, true);
|
||||
assert.equal(viewGame(game, f.owner).stormForecast, f.stormCard);
|
||||
assert.deepEqual(game.deck, f.game.deck); assert.deepEqual(game.spiceDeck, f.game.spiceDeck);
|
||||
} else {
|
||||
assert.equal(player(game, f.owner).faceDancers!.filter((card, index) =>
|
||||
card.leader !== f.originalFaceDancers[index].leader).length, 1);
|
||||
assert.equal(player(game, f.owner).faceDancerReplacedTurn, game.turn);
|
||||
assert.ok(game.traitorReserve!.includes(f.candidate!));
|
||||
reject(game, f.owner, f.replacementAction!);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('terminal-cost windows reject stale, foreign and underlying replies without leaking another native opportunity', () => {
|
||||
for (const options of [{ kind: 'stormPeek' as const },
|
||||
{ kind: 'faceDancerReplacement' as const, advanced: false }, { kind: 'faceDancerReplacement' as const, advanced: true }]) {
|
||||
const f = fixture(options), 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, f.replacementAction ?? { type: 'ready' });
|
||||
const done = denied(pass(game), f, false);
|
||||
reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer native forecast and Basic or Advanced Face Dancer cancellation costs', () => {
|
||||
for (const options of [{ kind: 'stormPeek' as const },
|
||||
{ kind: 'faceDancerReplacement' as const, advanced: false }, { kind: 'faceDancerReplacement' as const, advanced: true }]) {
|
||||
const f = fixture(options), 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user