205 lines
11 KiB
TypeScript
205 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
|
|
viewGame, type Action, type Game, type Player,
|
|
} from '../game/engine';
|
|
import type { Card } from '../game/cards';
|
|
import { validateNexusCards } from '../game/nexus-cards';
|
|
import { nexusChoamBetrayalAction } from '../game/nexus-choam-betrayal-options';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
|
|
|
|
export type SemutaNexusBetrayalStep = { actor: string; action: Action };
|
|
export type SemutaNexusBetrayalOptions = {
|
|
initial?: Game;
|
|
advanced?: boolean;
|
|
tech?: boolean;
|
|
strongholds?: boolean;
|
|
skills?: boolean;
|
|
fullHand?: boolean;
|
|
};
|
|
export type SemutaNexusBetrayalFixture = {
|
|
game: Game;
|
|
initial: Game;
|
|
setup: Game;
|
|
drawBefore: Game;
|
|
drawAfter: Game;
|
|
actions: SemutaNexusBetrayalStep[];
|
|
staging: string[];
|
|
owner: string;
|
|
target: string;
|
|
spender: string;
|
|
observer: string;
|
|
semuta: string;
|
|
targetCards: Card[];
|
|
cards: string[];
|
|
action: Action;
|
|
};
|
|
export function semutaNexusBetrayalPlayer(game: Game, id: string): Player {
|
|
const player = game.players.find(p => p.id === id);
|
|
assert.ok(player, `Missing original ${id} seat`);
|
|
return player;
|
|
}
|
|
export function semutaNexusBetrayalInventory(game: Game): string[] {
|
|
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
|
...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
|
|
assert.equal(new Set(cards).size, cards.length, 'Original Treachery Cards retain one physical slot');
|
|
validateNexusCards(game.nexusCards!.cards!, game.players);
|
|
return cards;
|
|
}
|
|
/** The existing no-spend native policy, with actual optional training and neutral
|
|
* Semuta acknowledgements. No Nexus card is played while reaching the draw. */
|
|
export function nextSemutaNexusBetrayalStep(game: Game): SemutaNexusBetrayalStep {
|
|
const pending = game.pendingTreacheryDiscard;
|
|
const reaction = pending?.reaction;
|
|
if (reaction?.stage === 'offer' && pending) {
|
|
const actor = game.players.find(p => !reaction.passed.includes(p.id))!.id;
|
|
return { actor, action: { type: 'semutaPass', event: pending.batch.event } };
|
|
}
|
|
if (game.setupStage === 'leaderSkills' && game.status === 'setup') {
|
|
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
|
const offer = game.leaderSkills!.offers[actor], own = viewGame(game, actor).leaderSkills!;
|
|
const safe = ['warmaster', 'swordmaster-of-ginaz', 'killer-medic', 'prana-bindu-adept'];
|
|
const skill = offer.cards.find(c => safe.includes(c) && !own.unavailableSkills?.[c])
|
|
?? offer.cards.find(c => !own.unavailableSkills?.[c]);
|
|
const eligible = new Set(own.eligibleLeaders.map(l => l.id));
|
|
const leader = semutaNexusBetrayalPlayer(game, actor).leaders.find(l => eligible.has(l.id));
|
|
assert.ok(skill && leader, 'An actual original offer must have an eligible training choice');
|
|
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
|
|
}
|
|
return nextRicheseSkillsTechNativeStep(game);
|
|
}
|
|
function orderUnplayedSpice(game: Game, kinds: ('land' | 'worm')[]): void {
|
|
for (const [position, kind] of kinds.entries()) {
|
|
const index = game.spiceDeck.findIndex((c, i) => i >= position &&
|
|
(kind === 'land' ? 'territory' in c : 'worm' in c));
|
|
assert.ok(index >= position, `An original unplayed ${kind} Spice Card must remain`);
|
|
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
|
}
|
|
}
|
|
function takePhysicalCard(game: Game, actor: string, predicate: (card: Card) => boolean): Card {
|
|
for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(p => p.hand)]) {
|
|
const index = zone.findIndex(predicate);
|
|
if (index < 0) continue;
|
|
const card = zone.splice(index, 1)[0];
|
|
semutaNexusBetrayalPlayer(game, actor).hand.push(card);
|
|
return card;
|
|
}
|
|
throw new Error('The requested original physical Treachery Card is unavailable');
|
|
}
|
|
/** Exchange identities only after an original qualifying closing draw has
|
|
* supplied a real held card. Never add a hand, draw directly, or rewrite phase. */
|
|
function exchangeDrawnChoam(game: Game, owner: string): void {
|
|
const cards = game.nexusCards!.cards!, returned = cards.hands[owner];
|
|
assert.ok(returned, 'The owner must first receive an actual closing Nexus draw');
|
|
if (returned === 'choam') return;
|
|
const donor = Object.keys(cards.hands).find(id => cards.hands[id] === 'choam');
|
|
if (donor) cards.hands[donor] = returned;
|
|
else {
|
|
const zone = [cards.deck, cards.discard].find(z => z.includes('choam'));
|
|
assert.ok(zone, 'The original CHOAM Nexus identity must have a physical custody slot');
|
|
zone[zone.indexOf('choam')] = returned;
|
|
}
|
|
cards.hands[owner] = 'choam';
|
|
validateNexusCards(cards, game.players);
|
|
}
|
|
|
|
/** Genuine four-seat paired CHOAM/Richese setup, first Storm and turn-two
|
|
* worm/alliance/closing draws. Only still-unplayed Spice order and conserved
|
|
* later Treachery/Nexus identities are staged. Wallets, turn, phase, original
|
|
* training, Tech/Stronghold custody and discard receipts are never assigned. */
|
|
export function createSemutaNexusBetrayalFixture(options: SemutaNexusBetrayalOptions = {}): SemutaNexusBetrayalFixture {
|
|
let game = options.initial ? structuredClone(options.initial)
|
|
: createGame('SEMUTANEXUSBETRAYAL', newPlayer('r', 'Richese', 'richese'), options.advanced ?? false, ['choam']);
|
|
if (!options.initial) {
|
|
joinGame(game, newPlayer('e', 'CHOAM', 'choam'));
|
|
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
|
joinGame(game, newPlayer('b', 'Emperor', 'emperor'));
|
|
}
|
|
assert.equal(game.status, 'lobby', 'Only a fresh undealt lobby may enter');
|
|
assert.deepEqual(game.expansions, ['choam']);
|
|
assert.equal(game.players.length, 4);
|
|
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
|
const idFor = (faction: Player['faction']) => {
|
|
const player = game.players.find(p => p.faction === faction);
|
|
assert.ok(player, `The original ${faction} seat is required`);
|
|
return player.id;
|
|
};
|
|
const owner = idFor('richese'), target = idFor('choam'), spender = idFor('atreides'), observer = idFor('emperor');
|
|
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
|
if (options.strongholds && !game.strongholdCards)
|
|
game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
|
|
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
|
// Fresh module selection only; the original initializer creates all custody.
|
|
game.nexusCards ??= { cards: null, phase: null };
|
|
assert.equal(game.nexusCards.cards, null);
|
|
assert.equal(game.nexusCards.phase, null);
|
|
const initial = structuredClone(game), actions: SemutaNexusBetrayalStep[] = [];
|
|
const staging = ['Reordered only original unplayed Spice Cards: first-turn land blows, turn-two worm then land blows.'];
|
|
game = initializeSemutaGameForAudit(game, options.skills ?? false);
|
|
const setup = structuredClone(game);
|
|
const step = () => {
|
|
const next = nextSemutaNexusBetrayalStep(game);
|
|
actions.push(next); game = applyAction(game, next.actor, next.action);
|
|
};
|
|
const reach = (predicate: () => boolean, label: string) => {
|
|
for (let n = 0; !predicate() && n < 1800; n++) {
|
|
assert.notEqual(game.status, 'finished', `Original native game ended before ${label}`);
|
|
step();
|
|
}
|
|
assert.ok(predicate(), `Original native actions must reach ${label}`);
|
|
};
|
|
reach(() => game.status === 'playing', 'original setup completion');
|
|
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(c => c === null), 'Nexus has no starting hand');
|
|
orderUnplayedSpice(game, ['land', 'land']);
|
|
reach(() => game.turn === 2 && game.phase === 1 && !game.phaseOpening && !game.response && !game.decision,
|
|
'actual second Spice Blow after original first Storm and Mentat');
|
|
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(c => c === null));
|
|
orderUnplayedSpice(game, ['worm', 'land', 'land']);
|
|
reach(() => game.nexus && !game.spiceWindow && !game.spiceResolution, 'actual worm Nexus negotiations');
|
|
for (const [actor, ally] of [[owner, target], [target, owner]]) {
|
|
const action: Action = { type: 'alliance', target: ally };
|
|
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
|
}
|
|
reach(() => game.nexusCards!.phase?.stage === 'drawing', 'qualifying end-Spice Nexus opportunity');
|
|
assert.equal(game.nexusCards!.phase!.occurred, true);
|
|
assert.deepEqual([...game.nexusCards!.phase!.eligible].sort(), [spender, observer].sort());
|
|
const drawBefore = structuredClone(game);
|
|
for (const actor of game.nexusCards!.phase!.eligible) {
|
|
const action: Action = { type: 'nexusCardChoice', turn: game.turn,
|
|
card: game.nexusCards!.cards!.hands[actor], choice: 'draw', ownRedraws: 0 };
|
|
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
|
}
|
|
assert.equal(game.nexusCards!.phase!.stage, 'complete');
|
|
const drawAfter = structuredClone(game);
|
|
exchangeDrawnChoam(game, spender);
|
|
staging.push('Exchanged the actual drawn Nexus identity with CHOAM in an existing hand/deck/discard slot.');
|
|
reach(() => game.phase === 2 && !game.phaseOpening && !game.response && !game.decision,
|
|
'original clean Charity boundary');
|
|
const cards = semutaNexusBetrayalInventory(game), wallets = game.players.map(p => p.spice);
|
|
for (const player of game.players) game.deck.push(...player.hand.splice(0));
|
|
const semuta = takePhysicalCard(game, owner, c => c.id === SEMUTA_DRUG_ID).id;
|
|
const targetCards = ['projectile', 'snooper', 'worthless'].map(kind =>
|
|
takePhysicalCard(game, target, c => c.kind === kind));
|
|
if (options.fullHand) semutaNexusBetrayalPlayer(game, owner).hand.push(...game.deck.splice(0, 3));
|
|
staging.push('Moved canonical Semuta to Richese and three existing Treachery Cards to CHOAM; returned other held cards to the original deck.');
|
|
assert.deepEqual(semutaNexusBetrayalInventory(game), cards);
|
|
assert.deepEqual(game.players.map(p => p.spice), wallets);
|
|
const action = nexusChoamBetrayalAction(viewGame(game, spender));
|
|
assert.ok(action, 'The original unallied CHOAM Betrayal quote must supply a legal action');
|
|
return { game, initial, setup, drawBefore, drawAfter, actions, staging,
|
|
owner, target, spender, observer, semuta, targetCards: structuredClone(targetCards), cards, action };
|
|
}
|
|
/** No target-card input: the original server producer performs its own random
|
|
* sample and commits that exact physical card before Semuta can react. */
|
|
export function commitSemutaNexusBetrayal(fixture: SemutaNexusBetrayalFixture, game = fixture.game): Game {
|
|
return applyAction(game, fixture.spender, fixture.action);
|
|
}
|
|
export function reactSemutaNexusBetrayal(state: Game, fixture: SemutaNexusBetrayalFixture, claim: boolean): Game {
|
|
const event = state.pendingTreacheryDiscard!.batch.event;
|
|
if (claim) return applyAction(state, fixture.owner, { type: 'semutaCommit', event });
|
|
let game = state;
|
|
for (const player of state.players) game = applyAction(game, player.id, { type: 'semutaPass', event });
|
|
return game;
|
|
}
|