Compose Semuta with original native Nexus discard receipts
This commit is contained in:
1 parent
5b3729e2db
commit
9afc03b347
17 files changed
+959
-23
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user