Compose Semuta with original native Nexus discard receipts

This commit is contained in:
Quintine committed 2026-10-12 09:26:49 +11:00
1 parent 5b3729e2db
commit 9afc03b347
17 files changed
+959 -23

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
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;
}
+241
View File
@@ -0,0 +1,241 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game, type Player,
} from '../game/engine';
import type { Card } from '../game/cards';
import { botSemutaActions } from '../game/bot-semuta';
import { validateNexusCards } from '../game/nexus-cards';
import { nexusChoamTradeAction } from '../game/nexus-choam-trade-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
export type SemutaNexusTradeStep = { actor: string; action: Action };
export type SemutaNexusTradeOptions = {
/** Fresh undealt Richese/Atreides/Emperor/Guild lobby; retain authenticated IDs. */
initial?: Game;
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
skills?: boolean;
fullHand?: boolean;
};
export type SemutaNexusTradeFixture = {
game: Game;
initial: Game;
afterSetup: Game;
afterFirstStorm: Game;
beforeAlliance: Game;
beforeDraw: Game;
afterDraw: Game;
actions: SemutaNexusTradeStep[];
staging: string[];
holder: string;
trader: string;
allies: [string, string];
cost: Card;
spare: Card;
semuta: string;
action: Action;
};
export function semutaNexusTradePlayer(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 semutaNexusTradeInventory(game: Game): string[] {
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []),
...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
assert.equal(new Set(ids).size, ids.length, 'One physical slot per original Treachery Card');
validateNexusCards(game.nexusCards!.cards!, game.players);
return ids;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Existing no-spend native phase policy, with the actual closing Nexus choice
* and neutral Semuta acknowledgements. No clock, receipt or payment is staged. */
export function nextSemutaNexusTradeNativeStep(game: Game): SemutaNexusTradeStep {
if (game.pendingTreacheryDiscard?.reaction) {
for (const player of game.players) {
const action = botSemutaActions(viewGame(game, player.id))[0];
if (action) return { actor: player.id, action };
}
throw Error('No legal original Semuta acknowledgement remains');
}
if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') {
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
if (clean(game) && game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor], own = viewGame(game, actor).leaderSkills!;
const skill = offer.cards.find(id => !own.unavailableSkills?.[id]);
assert.ok(skill && own.eligibleLeaders[0], 'Train an actual original offer on an eligible living disc');
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id } };
}
return nextRicheseSkillsTechNativeStep(game);
}
export function advanceSemutaNexusTrade(
state: Game, until: (game: Game) => boolean, actions: SemutaNexusTradeStep[] = [],
): Game {
let game = state;
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'Original phase path must remain playable');
const next = nextSemutaNexusTradeNativeStep(game);
actions.push(next);
game = applyAction(game, next.actor, next.action);
}
throw Error('Native Semuta/Nexus path did not reach the requested boundary');
}
function orderUndealtSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
const remainder = [...game.spiceDeck];
const front = kinds.map(kind => {
const index = remainder.findIndex(c => kind === 'land' ? 'territory' in c : 'worm' in c);
assert.ok(index >= 0, `Original undealt ${kind} must remain available`);
return remainder.splice(index, 1)[0];
});
game.spiceDeck = [...front, ...remainder];
staging.push(`Conserved undealt Spice order: ${kinds.join(', ')}; discard history unchanged.`);
}
function reserveBoard(game: Game, staging: string[]): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0);
player.elites.forces = {};
}
}
staging.push('After native setup/first Storm, original board counters return to their own reserves; no battle, wallet or clock is invented.');
}
/** Only an already-drawn holder can swap identity with another original slot. */
export function swapSemutaNexusTradeIdentity(game: Game, owner: string, desired: Player['faction']): void {
const cards = game.nexusCards!.cards!;
validateNexusCards(cards, game.players);
const previous = cards.hands[owner];
assert.ok(previous, 'The owner must earn a real qualifying Nexus draw first');
if (previous === desired) return;
const donor = Object.keys(cards.hands).find(id => cards.hands[id] === desired);
if (donor) cards.hands[donor] = previous;
else {
const zone = cards.deck.includes(desired) ? cards.deck : cards.discard;
const index = zone.indexOf(desired);
assert.ok(index >= 0, 'Requested Nexus identity must have its original physical slot');
zone[index] = previous;
}
cards.hands[owner] = desired;
validateNexusCards(cards, game.players);
}
function holdOriginal(game: Game, owner: string, matches: (card: Card) => boolean, staging: string[]): Card {
const player = semutaNexusTradePlayer(game, owner);
const held = player.hand.find(matches);
if (held) return held;
assert.ok(player.hand.length < handLimit(player));
const zones: [string, Card[]][] = [['deck', game.deck], ['discard', game.discard],
['Richese cache', game.richeseCache!], ...game.players.filter(p => p.id !== owner)
.map(p => [p.id, p.hand] as [string, Card[]])];
for (const [name, zone] of zones) {
const index = zone.findIndex(matches);
if (index < 0) continue;
const card = zone.splice(index, 1)[0];
player.hand.push(card);
staging.push(`Conserved physical ${card.id}: ${name} → ${owner}'s hand.`);
return card;
}
throw Error('Required original physical Treachery Card is unavailable');
}
/** Real fresh native setup, first Storm, first Mentat, second-turn worm, reciprocal
* alliance and end-Spice draw. Later controlled positions use existing physical
* slots only. No starting Nexus hand, fabricated phase/payment or cloned stock. */
export function createSemutaNexusTradeFixture(options: SemutaNexusTradeOptions = {}): SemutaNexusTradeFixture {
let game = options.initial ? structuredClone(options.initial)
: createGame('SEMUTANEXUSTRADE', newPlayer('r', 'Richese', 'richese'), options.advanced ?? false, ['choam']);
if (!options.initial) {
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('g', 'Guild', 'guild'));
}
assert.equal(game.status, 'lobby');
assert.deepEqual(game.expansions, ['choam']);
assert.equal(game.players.length, 4);
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const id = (faction: Player['faction']) => {
const player = game.players.find(p => p.faction === faction); assert.ok(player); return player.id;
};
const holder = id('richese'), trader = id('atreides'), allies: [string, string] = [id('emperor'), id('guild')];
assert.ok(game.nexusCards == null || (game.nexusCards.cards === null && game.nexusCards.phase === null));
// The original development-only lobby selection has no player-action switch.
// Both physical packet and phase remain undealt until the original initializer.
game.nexusCards ??= { cards: null, phase: null };
if (options.tech !== undefined) game = applyAction(game, game.host, { type: 'techTokens', enabled: options.tech });
if (options.strongholds !== undefined) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: options.strongholds });
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
const initial = structuredClone(game), actions: SemutaNexusTradeStep[] = [], staging: string[] = [];
game = initializeSemutaGameForAudit(game, options.skills ?? false);
game = advanceSemutaNexusTrade(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null), 'Nexus has no starting hand');
const inventory = semutaNexusTradeInventory(game);
game = advanceSemutaNexusTrade(game, state => state.phase === 1 && clean(state), actions);
const afterFirstStorm = structuredClone(game);
orderUndealtSpice(game, ['land', 'land'], staging);
game = advanceSemutaNexusTrade(game, state => state.phase === 5 && clean(state), actions);
reserveBoard(game, staging);
game = advanceSemutaNexusTrade(game, state => state.turn === 2 && state.phase === 1 && clean(state), actions);
orderUndealtSpice(game, ['worm', 'land', 'land'], staging);
game = advanceSemutaNexusTrade(game, state => state.nexus && !state.spiceWindow && clean(state), actions);
const beforeAlliance = structuredClone(game);
for (const [actor, target] of [allies, [allies[1], allies[0]]] as [string, string][]) {
const next: SemutaNexusTradeStep = { actor, action: { type: 'alliance', target } };
actions.push(next); game = applyAction(game, actor, next.action);
}
game = advanceSemutaNexusTrade(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
const beforeDraw = structuredClone(game);
const draw: SemutaNexusTradeStep = { actor: trader, action: {
type: 'nexusCardChoice', turn: game.turn, card: game.nexusCards!.cards!.hands[trader], choice: 'draw', ownRedraws: 0,
} };
actions.push(draw); game = applyAction(game, draw.actor, draw.action);
const afterDraw = structuredClone(game);
swapSemutaNexusTradeIdentity(game, trader, 'choam');
staging.push('Only after actual qualifying closing draw, exchange its existing Nexus identity with the original CHOAM slot.');
game = advanceSemutaNexusTrade(game, state => state.phase === 7 && clean(state), actions);
// This is explicit later hand-position staging, not a new deal or acquisition rule.
for (const actor of [holder, trader]) {
const player = semutaNexusTradePlayer(game, actor);
game.deck.push(...player.hand); player.hand = [];
}
staging.push('At real second-turn Collection, return holder/trader original hand cards to the original deck before conserved cost/Semuta staging.');
const cost = holdOriginal(game, trader, card => card.kind === 'worthless', staging);
const spare = holdOriginal(game, trader, card => card.kind === 'worthless' && card.id !== cost.id, staging);
const semuta = holdOriginal(game, holder, card => card.id === SEMUTA_DRUG_ID, staging).id;
if (options.fullHand) {
const player = semutaNexusTradePlayer(game, holder);
while (player.hand.length < handLimit(player))
holdOriginal(game, holder, card => !player.hand.some(held => held.id === card.id) && card.kind !== 'worthless', staging);
}
assert.deepEqual(semutaNexusTradeInventory(game), inventory);
const action = nexusChoamTradeAction(viewGame(game, trader), cost.id);
assert.ok(action, 'Original CHOAM Secret Ally must quote the actual held Worthless at real Collection');
return { game, initial, afterSetup, afterFirstStorm, beforeAlliance, beforeDraw, afterDraw,
actions, staging, holder, trader, allies, cost, spare, semuta, action };
}
export function passSemutaNexusTrade(state: Game): Game {
let game = state;
const event = game.pendingTreacheryDiscard!.batch.event;
for (const player of game.players) game = applyAction(game, player.id, { type: 'semutaPass', event });
return game;
}
export function claimSemutaNexusTrade(state: Game, fixture: SemutaNexusTradeFixture): Game {
const event = state.pendingTreacheryDiscard!.batch.event;
return applyAction(state, fixture.holder, { type: 'semutaCommit', event });
}
export function finishSemutaNexusTradeCollection(state: Game): Game {
assert.equal(state.pendingTreacheryDiscard, null);
assert.equal(state.phase, 7);
return advanceSemutaNexusTrade(state, game => game.phase === 8 && clean(game));
}
+153
View File
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
commitSemutaNexusBetrayal, createSemutaNexusBetrayalFixture,
nextSemutaNexusBetrayalStep, reactSemutaNexusBetrayal,
semutaNexusBetrayalInventory, semutaNexusBetrayalPlayer,
} from './fixture-semuta-nexus-betrayal';
for (const advanced of [false, true]) {
for (const claim of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} Semuta ${claim ? 'claims' : 'passes'} the committed random CHOAM Betrayal discard before its receipt completes`, () => {
const f = createSemutaNexusBetrayalFixture({ advanced });
assert.equal(f.setup.status, 'setup');
assert.ok(Object.values(f.setup.nexusCards!.cards!.hands).every(card => card === null));
assert.equal(f.drawBefore.phase, 1);
assert.equal(f.drawBefore.turn, 2);
assert.equal(f.drawBefore.nexusCards!.phase!.occurred, true);
assert.equal(f.drawBefore.nexusCards!.phase!.stage, 'drawing');
assert.equal(semutaNexusBetrayalPlayer(f.drawBefore, f.owner).ally, f.target);
assert.equal(semutaNexusBetrayalPlayer(f.drawBefore, f.target).ally, f.owner);
assert.ok(!semutaNexusBetrayalPlayer(f.drawBefore, f.spender).ally);
assert.equal(f.drawAfter.nexusCards!.phase!.stage, 'complete');
assert.ok(f.drawAfter.nexusCards!.cards!.hands[f.spender]);
assert.equal(f.drawAfter.nexusCards!.cards!.deck.length, 10);
assert.equal(viewGame(f.game, f.spender).nexusChoamBetrayal!.target.id, f.target);
assert.equal(viewGame(f.game, f.spender).nexusChoamBetrayal!.blocked, null);
const wallets = f.game.players.map(p => p.spice);
const targetBefore = structuredClone(semutaNexusBetrayalPlayer(f.game, f.target).hand);
const logStart = f.game.log.length;
const pending = commitSemutaNexusBetrayal(f);
const batch = pending.pendingTreacheryDiscard!.batch;
const receipt = pending.nexusChoamBetrayals!.at(-1)!;
const fresh = targetBefore.filter(card => !semutaNexusBetrayalPlayer(pending, f.target).hand.some(c => c.id === card.id));
assert.equal(fresh.length, 1, 'The server must randomly remove exactly one actual held card');
assert.deepEqual(semutaNexusBetrayalPlayer(pending, f.target).hand,
targetBefore.filter(card => card.id !== fresh[0].id));
assert.equal(batch.cause, 'nexus:choamBetrayal');
assert.deepEqual(batch.entries, [{ card: fresh[0], discardedBy: f.target, publicFace: false }]);
assert.equal(pending.pendingTreacheryDiscard!.reaction?.stage, 'offer');
assert.equal(receipt.stage, 'discard');
assert.equal(receipt.card, fresh[0].id);
assert.equal(pending.discard.filter(c => c.id === fresh[0].id).length, 1);
assert.equal(pending.nexusCards!.cards!.hands[f.spender], null);
assert.equal(pending.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
assert.deepEqual(pending.players.map(p => p.spice), wallets, 'Random discard gives no compensation');
const offer = viewGame(pending, f.owner).semutaReaction!;
assert.equal(offer.canCommit, true, 'Semuta may recover another player’s card even from its own ally');
assert.deepEqual(offer.candidates, [], 'The discard offer precedes any committed inspection');
assert.equal(pending.log.slice(logStart).some(row => row.text.includes(fresh[0].name)), false);
const done = reactSemutaNexusBetrayal(pending, f, claim);
assert.equal(done.pendingTreacheryDiscard, null);
assert.equal(done.resolvedTreacheryDiscardSequence, pending.pendingTreacheryDiscard!.sequence);
assert.equal(done.nexusChoamBetrayals!.length, 1);
assert.equal(done.nexusChoamBetrayals![0].stage, 'complete');
assert.equal(done.nexusChoamBetrayals![0].card, fresh[0].id, 'The original random identity remains committed');
assert.equal(semutaNexusBetrayalPlayer(done, f.owner).hand.filter(c => c.id === fresh[0].id).length, Number(claim));
assert.equal(semutaNexusBetrayalPlayer(done, f.owner).hand.some(c => c.id === f.semuta), !claim);
assert.equal(done.discard.filter(c => c.id === fresh[0].id).length, Number(!claim));
assert.equal(done.discard.filter(c => c.id === f.semuta).length, Number(claim));
assert.deepEqual(semutaNexusBetrayalPlayer(done, f.target).hand,
semutaNexusBetrayalPlayer(pending, f.target).hand, 'Reaction cannot resample or repeat the discard');
assert.deepEqual(done.players.map(p => p.spice), wallets);
assert.equal(done.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
assert.deepEqual(semutaNexusBetrayalInventory(done), f.cards);
assert.equal(viewGame(done, f.owner).semutaReaction, null);
assert.equal(viewGame(done, f.spender).nexusChoamBetrayal, null);
assert.throws(() => applyAction(done, f.spender, f.action));
assert.throws(() => applyAction(done, f.owner, { type: 'semutaCommit', event: batch.event }));
const next = nextSemutaNexusBetrayalStep(done);
assert.equal(next.action.type, 'ready', 'The original Charity acknowledgement is available after retirement');
const resumed = applyAction(done, next.actor, next.action);
assert.ok(resumed.ready.includes(next.actor));
assert.equal(resumed.nexusChoamBetrayals!.length, 1);
assert.equal(resumed.nexusChoamBetrayals![0].stage, 'complete');
assert.deepEqual(semutaNexusBetrayalPlayer(resumed, f.target).hand,
semutaNexusBetrayalPlayer(done, f.target).hand);
assert.deepEqual(semutaNexusBetrayalInventory(resumed), f.cards);
},
);
void test(`${advanced ? 'Advanced' : 'Basic'} full-hand Semuta exchanges the exact random Betrayal discard without overflow or payment`, () => {
const f = createSemutaNexusBetrayalFixture({ advanced, fullHand: true });
assert.equal(semutaNexusBetrayalPlayer(f.game, f.owner).hand.length, 4);
const pending = commitSemutaNexusBetrayal(f), fresh = pending.pendingTreacheryDiscard!.batch.entries[0].card;
assert.equal(viewGame(pending, f.owner).semutaReaction!.canCommit, true);
const done = reactSemutaNexusBetrayal(pending, f, true);
assert.equal(semutaNexusBetrayalPlayer(done, f.owner).hand.length, 4);
assert.ok(semutaNexusBetrayalPlayer(done, f.owner).hand.some(c => c.id === fresh.id));
assert.ok(!semutaNexusBetrayalPlayer(done, f.owner).hand.some(c => c.id === f.semuta));
assert.equal(done.decision, null);
assert.deepEqual(done.players.map(p => p.spice), pending.players.map(p => p.spice));
assert.deepEqual(semutaNexusBetrayalInventory(done), f.cards);
});
}
void test('CHOAM Betrayal rejects a client-picked card, and its pending Semuta reaction blocks original phase actions until resolved', () => {
const f = createSemutaNexusBetrayalFixture();
assert.throws(() => applyAction(f.game, f.spender, { ...f.action, card: f.targetCards[0].id }));
assert.equal(f.game.nexusCards!.cards!.hands[f.spender], 'choam');
assert.deepEqual(semutaNexusBetrayalPlayer(f.game, f.target).hand, f.targetCards);
const pending = commitSemutaNexusBetrayal(f), event = pending.pendingTreacheryDiscard!.batch.event;
assert.throws(() => applyAction(pending, f.spender, { type: 'ready' }));
assert.throws(() => applyAction(pending, f.owner, { type: 'semutaSelect', event, card: f.targetCards[0].id }));
assert.equal(pending.nexusChoamBetrayals![0].stage, 'discard');
const done = reactSemutaNexusBetrayal(pending, f, false);
assert.equal(done.nexusChoamBetrayals![0].stage, 'complete');
assert.deepEqual(semutaNexusBetrayalInventory(done), f.cards);
});
void test('existing minimal bot reactions settle the random Betrayal claim and neutral passes through the original receipt', () => {
const f = createSemutaNexusBetrayalFixture();
const pending = commitSemutaNexusBetrayal(f), fresh = pending.pendingTreacheryDiscard!.batch.entries[0].card.id;
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const own = viewGame(pending, f.owner);
own.players.find(p => p.id === f.owner)!.bot = difficulty;
const action = botActions(own)[0];
assert.ok(action);
assert.equal(action.type, 'semutaCommit');
const claimed = applyAction(pending, f.owner, action);
assert.ok(semutaNexusBetrayalPlayer(claimed, f.owner).hand.some(c => c.id === fresh));
assert.equal(claimed.nexusChoamBetrayals![0].stage, 'complete');
assert.deepEqual(semutaNexusBetrayalInventory(claimed), f.cards);
}
let passed: Game = pending;
for (const seat of pending.players) {
const own = viewGame(passed, seat.id);
own.players.find(p => p.id === seat.id)!.bot = 'Easy';
const action = seat.id === f.owner
? { type: 'semutaPass', event: passed.pendingTreacheryDiscard!.batch.event }
: botActions(own)[0];
assert.ok(action);
assert.equal(action.type, 'semutaPass');
passed = applyAction(passed, seat.id, action);
}
assert.equal(passed.nexusChoamBetrayals![0].stage, 'complete');
assert.equal(passed.discard.filter(c => c.id === fresh).length, 1);
assert.deepEqual(semutaNexusBetrayalInventory(passed), f.cards);
});
for (const options of [
{ advanced: false, tech: true },
{ advanced: true, tech: true, strongholds: true, skills: true },
]) void test(`Semuta random Betrayal remains playable with original ${options.skills ? 'Advanced Skills/Tech/Strongholds' : 'Basic Tech'} setup`, () => {
const f = createSemutaNexusBetrayalFixture(options);
assert.ok(Object.values(f.game.techTokens!).every(token => f.game.players.some(p => p.id === token.owner)));
const pending = commitSemutaNexusBetrayal(f), fresh = pending.pendingTreacheryDiscard!.batch.entries[0].card.id;
const done = reactSemutaNexusBetrayal(pending, f, true);
assert.ok(semutaNexusBetrayalPlayer(done, f.owner).hand.some(c => c.id === fresh));
assert.equal(done.nexusChoamBetrayals![0].stage, 'complete');
assert.deepEqual(semutaNexusBetrayalInventory(done), f.cards);
});
+155
View File
@@ -0,0 +1,155 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createSemutaNexusTradeFixture, claimSemutaNexusTrade, finishSemutaNexusTradeCollection,
nextSemutaNexusTradeNativeStep, passSemutaNexusTrade, semutaNexusTradeInventory,
semutaNexusTradePlayer, type SemutaNexusTradeFixture,
} from './fixture-semuta-nexus-trade';
function openTrade(f: SemutaNexusTradeFixture): Game {
assert.equal(f.afterFirstStorm.phase, 1);
assert.ok(Object.values(f.afterSetup.nexusCards!.cards!.hands).every(card => card === null));
assert.equal(f.beforeAlliance.turn, 2);
assert.equal(f.beforeAlliance.nexus, true);
assert.equal(f.beforeDraw.nexusCards!.phase!.occurred, true);
assert.equal(f.beforeDraw.nexusCards!.phase!.stage, 'drawing');
assert.deepEqual([...f.beforeDraw.nexusCards!.phase!.eligible].sort(), [f.holder, f.trader].sort());
assert.equal(semutaNexusTradePlayer(f.beforeDraw, f.allies[0]).ally, f.allies[1]);
assert.equal(semutaNexusTradePlayer(f.beforeDraw, f.allies[1]).ally, f.allies[0]);
assert.equal(f.beforeDraw.nexusCards!.cards!.hands[f.trader], null);
assert.ok(f.afterDraw.nexusCards!.cards!.hands[f.trader], 'Actual closing draw precedes identity staging');
assert.ok(f.afterDraw.nexusCards!.phase!.done.includes(f.trader));
assert.equal(semutaNexusTradePlayer(f.game, f.trader).ally, null);
assert.ok(!f.game.players.some(p => p.faction === 'choam'));
assert.equal(f.game.nexusCards!.cards!.hands[f.trader], 'choam');
assert.equal(f.game.phase, 7);
const own = viewGame(f.game, f.trader);
assert.equal(own.nexusChoamTrade!.blocked, null);
assert.equal(own.nexusChoamTrade!.spice, 2);
assert.deepEqual(own.nexusChoamTrade!.cards.map(card => card.id), [f.cost.id, f.spare.id]);
const pending = applyAction(f.game, f.trader, f.action);
assert.equal(semutaNexusTradePlayer(pending, f.trader).spice, semutaNexusTradePlayer(f.game, f.trader).spice + 2);
assert.equal(pending.nexusCards!.cards!.hands[f.trader], null);
assert.deepEqual(pending.nexusCards!.cards!.discard.filter(card => card === 'choam'), ['choam']);
assert.equal(pending.nexusChoamTrades!.length, 1);
assert.equal(pending.nexusChoamTrades![0].stage, 'discard');
assert.equal(pending.pendingTreacheryDiscard!.continuation.kind, 'nexusChoamTrade');
assert.equal(pending.pendingTreacheryDiscard!.batch.cause, 'nexus:choamTrade');
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries,
[{ card: f.cost, discardedBy: f.trader, publicFace: true }]);
assert.equal(pending.pendingTreacheryDiscard!.reaction!.stage, 'offer');
for (const player of pending.players) {
const reaction = viewGame(pending, player.id).semutaReaction!;
assert.equal(reaction.event, pending.pendingTreacheryDiscard!.batch.event);
assert.equal(reaction.canCommit, player.id === f.holder);
assert.equal(reaction.stage, 'offer');
}
return pending;
}
function assertCompletedTrade(f: SemutaNexusTradeFixture, completed: Game, claim: boolean): void {
const before = semutaNexusTradePlayer(f.game, f.trader), after = semutaNexusTradePlayer(completed, f.trader);
assert.equal(after.spice, before.spice + 2);
assert.deepEqual(after.hand.map(card => card.id), [f.spare.id]);
for (const player of completed.players.filter(p => p.id !== f.trader))
assert.equal(player.spice, semutaNexusTradePlayer(f.game, player.id).spice, 'Semuta and neutral acknowledgements pay no spice');
assert.equal(completed.pendingTreacheryDiscard, null);
assert.equal(completed.nexusChoamTrades!.length, 1);
assert.equal(completed.nexusChoamTrades![0].stage, 'complete');
assert.deepEqual(completed.nexusChoamTradeLast, { event: f.action.event, stage: 'complete' });
assert.equal(completed.resolvedTreacheryDiscardSequence, completed.treacheryDiscardSequence);
const held = semutaNexusTradePlayer(completed, f.holder).hand;
assert.equal(held.some(card => card.id === f.cost.id), claim);
assert.equal(held.some(card => card.id === f.semuta), !claim);
assert.equal(completed.discard.filter(card => card.id === f.cost.id).length, claim ? 0 : 1);
assert.equal(completed.discard.filter(card => card.id === f.semuta).length, claim ? 1 : 0);
assert.equal(completed.nexusCards!.cards!.hands[f.trader], null);
assert.deepEqual(completed.nexusCards!.cards!.discard.filter(card => card === 'choam'), ['choam']);
assert.deepEqual(semutaNexusTradeInventory(completed), semutaNexusTradeInventory(f.game));
assert.ok(![...completed.deck, ...completed.discard, ...completed.players.flatMap(p => p.hand)]
.some(card => card.id === 'choam'), 'A spent Nexus identity is never a Treachery Card');
assert.throws(() => applyAction(completed, f.trader, f.action), /current CHOAM Nexus trade|Worthless|trade/i);
const continued = finishSemutaNexusTradeCollection(completed);
assert.equal(continued.phase, 8, 'The original Collection readiness completes into Mentat');
assert.equal(continued.nexusChoamTrades!.length, 1);
assert.equal(continued.nexusChoamTrades![0].stage, 'complete');
assert.equal(semutaNexusTradePlayer(continued, f.trader).spice, before.spice + 2);
const next = nextSemutaNexusTradeNativeStep(continued);
const moved = applyAction(continued, next.actor, next.action);
assert.equal(moved.nexusChoamTrades!.length, 1);
assert.equal(moved.nexusChoamTrades![0].stage, 'complete');
assert.deepEqual(semutaNexusTradeInventory(moved), semutaNexusTradeInventory(f.game));
}
for (const advanced of [false, true]) {
for (const claim of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native Secret Ally public Worthless trade ${claim ? 'claim' : 'pass'} resumes its paid receipt once`, () => {
const f = createSemutaNexusTradeFixture({ advanced }), pending = openTrade(f);
const completed = claim ? claimSemutaNexusTrade(pending, f) : passSemutaNexusTrade(pending);
assertCompletedTrade(f, completed, claim);
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} full Richese hand exchanges Semuta for the exact spent Worthless without overflow`, () => {
const f = createSemutaNexusTradeFixture({ advanced, fullHand: true }), pending = openTrade(f);
const holder = semutaNexusTradePlayer(f.game, f.holder);
assert.equal(holder.hand.length, handLimit(holder));
const completed = claimSemutaNexusTrade(pending, f);
assert.equal(semutaNexusTradePlayer(completed, f.holder).hand.length, handLimit(holder));
assert.deepEqual(semutaNexusTradePlayer(completed, f.holder).hand.map(card => card.id).sort(),
[...holder.hand.filter(card => card.id !== f.semuta).map(card => card.id), f.cost.id].sort());
assertCompletedTrade(f, completed, true);
});
void test(`${advanced ? 'Advanced Tech/Strongholds' : 'Basic Tech'} and explicit original Skills retain native Semuta/Nexus trade behavior`, () => {
const f = createSemutaNexusTradeFixture({ advanced, tech: true, strongholds: advanced, skills: true });
const pending = openTrade(f);
assert.ok(Object.values(f.afterFirstStorm.techTokens!).every(token => token.owner &&
f.afterFirstStorm.players.some(player => player.id === token.owner)), 'Original first Storm earns token assignment');
assert.equal(f.game.leaderSkills!.assignments.length, 4);
const completed = claimSemutaNexusTrade(pending, f);
assert.deepEqual(completed.leaderSkills, f.game.leaderSkills);
assert.deepEqual(completed.techTokens, f.game.techTokens);
assert.deepEqual(completed.strongholdCards, f.game.strongholdCards);
assertCompletedTrade(f, completed, true);
});
}
void test('neutral passes retain the public cost and paid original receipt until the final acknowledgement', () => {
const f = createSemutaNexusTradeFixture(), pending = openTrade(f);
let game = pending;
const event = pending.pendingTreacheryDiscard!.batch.event;
for (const actor of [f.trader, ...f.allies]) {
game = applyAction(game, actor, { type: 'semutaPass', event });
assert.equal(game.nexusChoamTrades![0].stage, 'discard');
assert.equal(game.pendingTreacheryDiscard!.reaction!.stage, 'offer');
assert.equal(game.discard.filter(card => card.id === f.cost.id).length, 1);
assert.equal(semutaNexusTradePlayer(game, f.trader).spice, semutaNexusTradePlayer(f.game, f.trader).spice + 2);
}
assert.throws(() => applyAction(game, f.trader, { type: 'ready' }), /discard|Semuta|reaction/i);
game = applyAction(game, f.holder, { type: 'semutaPass', event });
assertCompletedTrade(f, game, false);
});
for (const difficulty of DIFFICULTIES) {
void test(`${difficulty} uses the original trade and exact Semuta decisions through the existing bot API`, () => {
const f = createSemutaNexusTradeFixture({ fullHand: true });
const tradeView = viewGame(f.game, f.trader);
tradeView.players.find(p => p.id === f.trader)!.bot = difficulty;
let game = applyAction(f.game, f.trader, botActions(tradeView)[0]);
const event = game.pendingTreacheryDiscard!.batch.event;
for (const actor of [f.trader, ...f.allies]) {
const view = viewGame(game, actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
const action = botActions(view)[0];
assert.deepEqual(action, { type: 'semutaPass', event });
game = applyAction(game, actor, action);
}
const own = viewGame(game, f.holder);
own.players.find(p => p.id === f.holder)!.bot = difficulty;
const commit = botActions(own)[0];
assert.deepEqual(commit, { type: 'semutaCommit', event });
game = applyAction(game, f.holder, commit);
assertCompletedTrade(f, game, true);
});
}