Preserve original Cunning fuel and suffix through Semuta
This commit is contained in:
1 parent
9afc03b347
commit
bb6835e6c3
12 files changed
+1237
-34
No files matched your search
@@ -0,0 +1,259 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { gameDistance, splitLocation, TERRITORIES } from '../game/board';
|
||||
import { stormExposesTerritory } from '../game/disaster-rules';
|
||||
import type { Card } from '../game/cards';
|
||||
import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
|
||||
import {
|
||||
advanceSemutaNexusCunning, createSemutaNexusCunningStart,
|
||||
gamePlayer, holdSemutaNexusCunningCard, semutaNexusCunningInventory,
|
||||
type SemutaNexusCunningFixture, type SemutaNexusCunningStep,
|
||||
} from './fixture-semuta-nexus-cunning';
|
||||
|
||||
export type SemutaNexusCunningReactiveEffect = 'baliset' | 'laLaLa' | 'jubba';
|
||||
export type SemutaNexusCunningReactiveStep = SemutaNexusCunningStep;
|
||||
export type SemutaNexusCunningReactiveOptions = {
|
||||
/** An already created shared start; only its live game and staging are used. */
|
||||
start?: SemutaNexusCunningFixture;
|
||||
advanced?: boolean;
|
||||
skills?: boolean;
|
||||
/** Hold one canonical Karama in the other seat for the prevention family. */
|
||||
karama?: boolean;
|
||||
};
|
||||
export type SemutaNexusCunningReactiveFixture = {
|
||||
start: SemutaNexusCunningFixture;
|
||||
/** The real reactive parent, before any Cunning declaration. */
|
||||
game: Game;
|
||||
/** The conserved state immediately before the real parent action(s). */
|
||||
before: Game;
|
||||
parent: Action;
|
||||
steps: SemutaNexusCunningReactiveStep[];
|
||||
effect: SemutaNexusCunningReactiveEffect;
|
||||
owner: string;
|
||||
holder: string;
|
||||
target: string;
|
||||
other: string;
|
||||
cost: Card;
|
||||
semuta: string;
|
||||
selection: Record<string, unknown>;
|
||||
/** The ordinary movement origin and destination of the Baliset family. */
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
/** The protected (Jubba) or restricted (Baliset) territory, when any. */
|
||||
territory: string | null;
|
||||
/** A second original crossed territory that keeps its ordinary storm losses. */
|
||||
otherTerritory: string | null;
|
||||
distance: number;
|
||||
crossed: { sector: number; key: string; territory: string }[];
|
||||
dials: { actor: string; amount: number }[];
|
||||
staging: string[];
|
||||
cards: string[];
|
||||
};
|
||||
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
|
||||
|
||||
/** Physical stocks only: no wallets, clocks, phases or leader training. */
|
||||
export function semutaNexusCunningReactiveBoard(game: Game) {
|
||||
return game.players.map(player => ({
|
||||
id: player.id, faction: player.faction, spice: player.spice,
|
||||
forces: player.forces, reserves: player.reserves, tanks: player.tanks,
|
||||
moved: player.moved, revived: player.revived,
|
||||
freeForcesRevived: player.freeForcesRevived,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Conserved later board counters, taken from the seat's own reserves. */
|
||||
function stageForces(game: Game, actor: string, key: string, count: number, staging: string[]): void {
|
||||
const player = gamePlayer(game, actor);
|
||||
assert.ok(player.reserves >= count, 'A conserved later placement needs ordinary reserves');
|
||||
player.reserves -= count;
|
||||
player.forces[key] = (player.forces[key] ?? 0) + count;
|
||||
staging.push(`Conserved later ordinary force ${count} for ${actor} at ${key} from its own reserves; clocks, wallets, allowances and training unchanged.`);
|
||||
}
|
||||
/** Conserved later Tanks counters, taken from the seat's own reserves. */
|
||||
function stageTanks(game: Game, actor: string, count: number, staging: string[]): void {
|
||||
const player = gamePlayer(game, actor);
|
||||
assert.ok(player.reserves >= count, 'A conserved later Tanks placement needs ordinary reserves');
|
||||
player.reserves -= count;
|
||||
player.tanks += count;
|
||||
staging.push(`Conserved later ${count} physical force(s) for ${actor} in its own Tanks; no revival, wallet, allowance or training change.`);
|
||||
}
|
||||
/** Existing board spice only, so a later real storm can be observed. */
|
||||
function stageSpice(game: Game, key: string, amount: number, staging: string[]): void {
|
||||
game.spice[key] = (game.spice[key] ?? 0) + amount;
|
||||
staging.push(`Conserved ${amount} existing board spice at ${key} for the later real storm; no wallet change.`);
|
||||
}
|
||||
|
||||
/** An original adjacent non-storm movement pair in ordinary sand territory. */
|
||||
function balisetPair(game: Game): { from: string; to: string } {
|
||||
const keys = TERRITORIES.filter(t => t.type === 'sand' && t.id !== 'imperial_basin')
|
||||
.flatMap(t => t.sectors.map(s => `${t.id}:${s}`));
|
||||
for (const to of keys) {
|
||||
const destination = splitLocation(to);
|
||||
if (destination.sector === game.storm) continue;
|
||||
for (const from of keys) {
|
||||
const origin = splitLocation(from);
|
||||
if (origin.territory === destination.territory || origin.sector === game.storm) continue;
|
||||
if (gameDistance(game, from, to, key => splitLocation(key).sector === game.storm) !== 1) continue;
|
||||
return { from, to };
|
||||
}
|
||||
}
|
||||
throw Error('No original adjacent non-storm movement pair remains');
|
||||
}
|
||||
|
||||
function crossedExposed(game: Game, distance: number) {
|
||||
const crossed: { sector: number; key: string; territory: string }[] = [];
|
||||
for (let step = 0; step < distance; step++) {
|
||||
const sector = ((game.storm + step) % 18) + 1;
|
||||
for (const territory of TERRITORIES)
|
||||
if (territory.sectors.includes(sector) && stormExposesTerritory(territory.id, game.shieldWallDestroyed))
|
||||
crossed.push({ sector, key: `${territory.id}:${sector}`, territory: territory.id });
|
||||
}
|
||||
return crossed;
|
||||
}
|
||||
function splitDistance(dialers: string[], distance: number) {
|
||||
const amounts: number[] = [];
|
||||
let remaining = distance;
|
||||
for (let index = 0; index < dialers.length; index++) {
|
||||
const rest = dialers.length - index - 1;
|
||||
const amount = Math.min(3, Math.max(1, remaining - rest * 3));
|
||||
amounts.push(amount); remaining -= amount;
|
||||
}
|
||||
assert.equal(remaining, 0);
|
||||
assert.ok(amounts.every(value => value >= 1 && value <= 3));
|
||||
return dialers.map((actor, index) => ({ actor, amount: amounts[index] }));
|
||||
}
|
||||
/** Basic uses real dialers; Advanced retains the already revealed original card distance. */
|
||||
function stormPlacement(game: Game) {
|
||||
const dialers = game.stormDialers;
|
||||
const minimum = game.advanced ? game.stormPending! : dialers.length;
|
||||
const maximum = game.advanced ? minimum : dialers.length * 3;
|
||||
if (!game.advanced) assert.equal(dialers.length, 2, 'The original Basic storm keeps its two real dialers');
|
||||
for (let distance = minimum; distance <= maximum; distance++) {
|
||||
const crossed = crossedExposed(game, distance);
|
||||
const first = crossed[0];
|
||||
const second = first && crossed.find(entry => entry.territory !== first.territory);
|
||||
if (first && second)
|
||||
return { distance, crossed, dials: game.advanced ? [] : splitDistance(dialers, distance),
|
||||
protected: first, other: second };
|
||||
}
|
||||
throw Error('No original later storm crosses two exposed territories');
|
||||
}
|
||||
|
||||
|
||||
export function quoteSemutaNexusCunningReactive(
|
||||
game: Game, fixture: SemutaNexusCunningReactiveFixture,
|
||||
selection: Record<string, unknown> = {},
|
||||
): Action {
|
||||
const own = viewGame(game, fixture.owner);
|
||||
const play = choamPowerPlays(own, fixture.effect).find(candidate =>
|
||||
candidate.source === 'nexus' && candidate.card.id === fixture.cost.id);
|
||||
assert.ok(play && !play.blocked, 'The original private Cunning quote must offer the actual canonical fuel');
|
||||
const action = choamPowerAction(own, play, { ...fixture.selection, ...selection });
|
||||
assert.ok(action, 'The original private Cunning quote must produce its source action');
|
||||
return action;
|
||||
}
|
||||
/** One ordinary printed-Karama prevention of the current Cunning declaration. */
|
||||
export function cancelSemutaNexusCunningReactive(state: Game, fixture: SemutaNexusCunningReactiveFixture): Game {
|
||||
const responder = state.players.find(player => player.id !== fixture.owner &&
|
||||
player.hand.some(card => card.effect === 'karama') &&
|
||||
!!viewGame(state, player.id).responseControls?.cancelCards.length);
|
||||
assert.ok(responder, 'The prevention family needs one ordinary Karama holder');
|
||||
const card = responder.hand.find(candidate => candidate.effect === 'karama');
|
||||
assert.ok(card);
|
||||
return applyAction(state, responder.id, { type: 'card', mode: 'cancel', card: card.id });
|
||||
}
|
||||
|
||||
/** Fresh real setup, then one original reactive parent: an ordinary declared
|
||||
* movement into a CHOAM-occupied territory, an ordinary free force revival, or
|
||||
* a real later dialed storm. Only conserved physical stocks are staged. */
|
||||
export function createSemutaNexusCunningReactive(
|
||||
effect: SemutaNexusCunningReactiveEffect,
|
||||
options: SemutaNexusCunningReactiveOptions = {},
|
||||
): SemutaNexusCunningReactiveFixture {
|
||||
const start = options.start ?? createSemutaNexusCunningStart({
|
||||
advanced: options.advanced ?? false, skills: options.skills ?? false,
|
||||
});
|
||||
const staging = start.staging, steps: SemutaNexusCunningReactiveStep[] = [];
|
||||
if (options.karama)
|
||||
holdSemutaNexusCunningCard(start.game, start.other, card => card.effect === 'karama', staging);
|
||||
const base = {
|
||||
start, steps, effect, owner: start.owner, holder: start.holder,
|
||||
target: start.target, other: start.other, cost: start.cost, semuta: start.semuta,
|
||||
staging, selection: {} as Record<string, unknown>, territory: null as string | null,
|
||||
otherTerritory: null as string | null, origin: null as string | null,
|
||||
destination: null as string | null,
|
||||
distance: 0, crossed: [], dials: [] as { actor: string; amount: number }[],
|
||||
};
|
||||
if (effect === 'baliset') {
|
||||
const before = advanceSemutaNexusCunning(start.game,
|
||||
game => game.phase === 5 && clean(game) && game.active === start.target, steps);
|
||||
const pair = balisetPair(before);
|
||||
stageForces(before, start.target, pair.from, 2, staging);
|
||||
stageForces(before, start.owner, pair.to, 3, staging);
|
||||
const parent: Action = { type: 'move', from: pair.from, amount: 1, ...splitLocation(pair.to) };
|
||||
const game = applyAction(before, start.target, parent);
|
||||
const movement = game.decision?.kind === 'choamMovement' ? game.decision : null;
|
||||
assert.ok(movement, 'A real move into a CHOAM territory opens the reactive parent');
|
||||
assert.equal(movement.mover, start.target);
|
||||
assert.equal(movement.territory, splitLocation(pair.to).territory);
|
||||
assert.equal(game.pendingChoamMove?.to, splitLocation(pair.to).territory);
|
||||
return {
|
||||
...base, before, game, parent, origin: pair.from, destination: pair.to,
|
||||
territory: splitLocation(pair.to).territory,
|
||||
cards: semutaNexusCunningInventory(game),
|
||||
};
|
||||
}
|
||||
if (effect === 'laLaLa') {
|
||||
const before = advanceSemutaNexusCunning(start.game,
|
||||
game => game.phase === 4 && clean(game), steps);
|
||||
assert.ok(before.revivalRules, 'The real revival phase supplies its own rules');
|
||||
stageTanks(before, start.target, 3, staging);
|
||||
const parent: Action = { type: 'revive', amount: 2 };
|
||||
const game = applyAction(before, start.target, parent);
|
||||
const revival = game.decision?.kind === 'choamFreeRevival' ? game.decision : null;
|
||||
assert.ok(revival, 'A real free revival opens the reactive parent');
|
||||
assert.equal(revival.recipient, start.target);
|
||||
assert.ok(game.pendingRevival, 'The declared free revival stays uncommitted');
|
||||
return {
|
||||
...base, before, game, parent,
|
||||
cards: semutaNexusCunningInventory(game),
|
||||
};
|
||||
}
|
||||
const before = advanceSemutaNexusCunning(start.game, game => {
|
||||
if (game.phase !== 0 || !clean(game)) return false;
|
||||
if (!game.advanced) return game.stormPending === null &&
|
||||
game.stormDialers.every(id => game.stormDials[id] === undefined);
|
||||
if (typeof game.stormPending !== 'number' || game.stormPending < 1) return false;
|
||||
const crossed = crossedExposed(game, game.stormPending);
|
||||
return !!crossed[0] && crossed.some(entry => entry.territory !== crossed[0].territory);
|
||||
}, steps);
|
||||
const placement = stormPlacement(before);
|
||||
stageForces(before, start.owner, placement.protected.key, 3, staging);
|
||||
stageForces(before, start.owner, placement.other.key, 3, staging);
|
||||
stageForces(before, start.target, placement.protected.key, 2, staging);
|
||||
stageForces(before, start.target, placement.other.key, 2, staging);
|
||||
stageSpice(before, placement.protected.key, 5, staging);
|
||||
stageSpice(before, placement.other.key, 6, staging);
|
||||
let dialed = before, parent: Action = before.advanced ? { type: 'ready' }
|
||||
: { type: 'stormDial', amount: placement.dials[0].amount };
|
||||
for (const dial of placement.dials) {
|
||||
parent = { type: 'stormDial', amount: dial.amount };
|
||||
steps.push({ actor: dial.actor, action: parent });
|
||||
dialed = applyAction(dialed, dial.actor, parent);
|
||||
}
|
||||
assert.equal(dialed.stormPending, placement.distance, 'The original storm preparation fixes its own distance');
|
||||
const game = advanceSemutaNexusCunning(dialed, state => state.decision?.kind === 'choamStorm', steps);
|
||||
if (before.advanced) parent = steps.at(-1)!.action;
|
||||
const storm = game.decision?.kind === 'choamStorm' ? game.decision : null;
|
||||
assert.ok(storm, 'The real prepared storm opens the reactive parent');
|
||||
assert.equal(storm.player, start.owner);
|
||||
assert.ok(storm.territories.some(entry => entry.territory === placement.protected.territory));
|
||||
return {
|
||||
...base, before, game, parent, distance: placement.distance, crossed: placement.crossed,
|
||||
dials: placement.dials, territory: placement.protected.territory,
|
||||
otherTerritory: placement.other.territory, selection: { territory: placement.protected.territory },
|
||||
cards: semutaNexusCunningInventory(game),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { gameDistance, splitLocation, TERRITORIES } from '../game/board';
|
||||
import type { Card } from '../game/cards';
|
||||
import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
|
||||
|
||||
export type SemutaNexusCunningStep = { actor: string; action: Action };
|
||||
export type SemutaNexusCunningOptions = {
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
skills?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
fullHand?: boolean;
|
||||
costKind?: 'projectile' | 'snooper' | 'special';
|
||||
};
|
||||
export type SemutaNexusCunningFixture = {
|
||||
game: Game;
|
||||
initial: Game;
|
||||
setup: Game;
|
||||
afterFirstStorm: Game;
|
||||
drawBefore: Game;
|
||||
drawAfter: Game;
|
||||
actions: SemutaNexusCunningStep[];
|
||||
staging: string[];
|
||||
owner: string;
|
||||
holder: string;
|
||||
target: string;
|
||||
other: string;
|
||||
cost: Card;
|
||||
semuta: string;
|
||||
cards: string[];
|
||||
};
|
||||
export type SemutaNexusCunningProactiveFixture = SemutaNexusCunningFixture & {
|
||||
effect: 'kulon' | 'gamont';
|
||||
action: Action;
|
||||
selection: Record<string, unknown>;
|
||||
from: string;
|
||||
move?: Action;
|
||||
elite: 0 | 1;
|
||||
karama: string;
|
||||
};
|
||||
export function gamePlayer(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 semutaNexusCunningInventory(game: Game): string[] {
|
||||
const cards = [...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(cards).size, cards.length, 'Each original Treachery Card keeps one physical slot');
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
return cards;
|
||||
}
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
|
||||
/** Original no-spend phase policy, actual training, and neutral acknowledgements.
|
||||
* In particular this never spends a secretly held Nexus while seeking a window. */
|
||||
export function nextSemutaNexusCunningStep(game: Game): SemutaNexusCunningStep {
|
||||
const pending = game.pendingTreacheryDiscard, 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 (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 (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 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]);
|
||||
assert.ok(skill && own.eligibleLeaders[0], 'Actual original training must have a legal offered skill and leader');
|
||||
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id } };
|
||||
}
|
||||
if (!game.phaseOpening && !game.response &&
|
||||
(game.decision?.kind === 'choamMarket' || game.decision?.kind === 'choamMentat'))
|
||||
return { actor: game.decision.player, action: { type: 'decision', done: true } };
|
||||
return nextRicheseSkillsTechNativeStep(game);
|
||||
}
|
||||
export function advanceSemutaNexusCunning(
|
||||
state: Game, until: (game: Game) => boolean, actions: SemutaNexusCunningStep[] = [],
|
||||
): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 1800; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original native path must reach its requested boundary');
|
||||
const next = nextSemutaNexusCunningStep(game);
|
||||
actions.push(next); game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
throw Error('Original Semuta Cunning actions did not reach the requested boundary');
|
||||
}
|
||||
function orderUnplayedSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): 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]);
|
||||
}
|
||||
staging.push(`Conserved original unplayed Spice order: ${kinds.join(', ')}.`);
|
||||
}
|
||||
export function reserveSemutaNexusCunningBoard(game: Game, staging: string[]): void {
|
||||
for (const player of game.players) {
|
||||
// Main force and reserve totals include the separately tracked elite subset.
|
||||
const total = Object.values(player.forces).reduce((sum, n) => sum + n, 0);
|
||||
const elites = Object.values(player.elites?.forces ?? {}).reduce((sum, n) => sum + n, 0);
|
||||
player.reserves += total;
|
||||
player.forces = {};
|
||||
if (player.elites) { player.elites.reserves += elites; player.elites.forces = {}; }
|
||||
}
|
||||
staging.push('Later original board counters returned to their own normal/elite reserves; clocks, wallets and losses unchanged.');
|
||||
}
|
||||
export function holdSemutaNexusCunningCard(
|
||||
game: Game, actor: string, predicate: (card: Card) => boolean, staging: string[] = [],
|
||||
): Card {
|
||||
const player = gamePlayer(game, actor), held = player.hand.find(predicate);
|
||||
if (held) return held;
|
||||
assert.ok(player.hand.length < handLimit(player));
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.filter(p => p.id !== actor).map(p => p.hand)]) {
|
||||
const index = zone.findIndex(predicate);
|
||||
if (index < 0) continue;
|
||||
const card = zone.splice(index, 1)[0]; player.hand.push(card);
|
||||
staging.push(`Conserved canonical ${card.id} into ${actor}'s hand.`);
|
||||
return card;
|
||||
}
|
||||
throw Error('The requested original physical Treachery Card is unavailable');
|
||||
}
|
||||
function swapDrawnChoam(game: Game, owner: string): void {
|
||||
const cards = game.nexusCards!.cards!, previous = cards.hands[owner];
|
||||
assert.ok(previous, 'Native CHOAM must earn its actual closing Nexus draw before any identity staging');
|
||||
if (previous === 'choam') return;
|
||||
const donor = Object.keys(cards.hands).find(id => cards.hands[id] === 'choam');
|
||||
if (donor) cards.hands[donor] = previous;
|
||||
else {
|
||||
const zone = cards.deck.includes('choam') ? cards.deck : cards.discard;
|
||||
const index = zone.indexOf('choam'); assert.ok(index >= 0);
|
||||
zone[index] = previous;
|
||||
}
|
||||
cards.hands[owner] = 'choam';
|
||||
validateNexusCards(cards, game.players);
|
||||
}
|
||||
|
||||
/** Fresh paired native setup, first Storm/Mentat and real turn-two worm. Atreides
|
||||
* and Emperor ally; unallied CHOAM draws and Richese declines. Only after that
|
||||
* earned draw do original Nexus/Treachery identities move between physical slots. */
|
||||
export function createSemutaNexusCunningStart(options: SemutaNexusCunningOptions = {}): SemutaNexusCunningFixture {
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('SEMUTANEXUSCUNNING', 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'); 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); return player.id;
|
||||
};
|
||||
const owner = idFor('choam'), holder = idFor('richese'), target = idFor('atreides'), other = idFor('emperor');
|
||||
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' });
|
||||
// Existing fresh lobby module selection: the initializer, not this fixture, deals the packet.
|
||||
game.nexusCards ??= { cards: null, phase: null };
|
||||
assert.equal(game.nexusCards.cards, null); assert.equal(game.nexusCards.phase, null);
|
||||
const initial = structuredClone(game), actions: SemutaNexusCunningStep[] = [], staging: string[] = [];
|
||||
game = initializeSemutaGameForAudit(game, options.skills ?? false);
|
||||
const setup = structuredClone(game);
|
||||
game = advanceSemutaNexusCunning(game, g => g.status === 'playing', actions);
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null), 'No starting Nexus hand');
|
||||
const cards = semutaNexusCunningInventory(game);
|
||||
game = advanceSemutaNexusCunning(game, g => g.phase === 1 && clean(g), actions);
|
||||
const afterFirstStorm = structuredClone(game);
|
||||
orderUnplayedSpice(game, ['land', 'land'], staging);
|
||||
game = advanceSemutaNexusCunning(game, g => g.phase === 5 && clean(g), actions);
|
||||
reserveSemutaNexusCunningBoard(game, staging);
|
||||
game = advanceSemutaNexusCunning(game, g => g.turn === 2 && g.phase === 1 && clean(g), actions);
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
|
||||
orderUnplayedSpice(game, ['worm', 'land', 'land'], staging);
|
||||
game = advanceSemutaNexusCunning(game, g => g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g), actions);
|
||||
for (const [actor, targetId] of [[target, other], [other, target]]) {
|
||||
const action: Action = { type: 'alliance', target: targetId };
|
||||
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
||||
}
|
||||
game = advanceSemutaNexusCunning(game, g => g.nexusCards?.phase?.stage === 'drawing', actions);
|
||||
assert.deepEqual([...game.nexusCards!.phase!.eligible].sort(), [owner, holder].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: actor === owner ? 'draw' : 'keep', ownRedraws: 0 };
|
||||
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
||||
}
|
||||
assert.equal(game.nexusCards!.phase!.stage, 'complete');
|
||||
const drawAfter = structuredClone(game);
|
||||
swapDrawnChoam(game, owner);
|
||||
staging.push('Only after real turn-two qualifying draw, exchanged its identity with the existing CHOAM Nexus slot.');
|
||||
game = advanceSemutaNexusCunning(game, g => g.phase === 2 && clean(g), actions);
|
||||
const wallets = game.players.map(p => p.spice);
|
||||
for (const player of game.players) game.deck.push(...player.hand.splice(0));
|
||||
staging.push('At real Charity, returned original held Treachery Cards to deck before conserving later Semuta/fuel positions.');
|
||||
const semuta = holdSemutaNexusCunningCard(game, holder, c => c.id === SEMUTA_DRUG_ID, staging).id;
|
||||
const kind = options.costKind ?? 'projectile';
|
||||
const cost = holdSemutaNexusCunningCard(game, owner,
|
||||
c => c.kind === kind && (kind !== 'special' || c.effect === 'hajr'), staging);
|
||||
if (options.fullHand) {
|
||||
const player = gamePlayer(game, holder);
|
||||
while (player.hand.length < handLimit(player))
|
||||
holdSemutaNexusCunningCard(game, holder, c => c.kind !== 'worthless' && !player.hand.some(h => h.id === c.id), staging);
|
||||
}
|
||||
assert.deepEqual(semutaNexusCunningInventory(game), cards);
|
||||
assert.deepEqual(game.players.map(p => p.spice), wallets);
|
||||
return { game, initial, setup, afterFirstStorm, drawBefore, drawAfter, actions, staging,
|
||||
owner, holder, target, other, cost: structuredClone(cost), semuta, cards };
|
||||
}
|
||||
export function reactSemutaNexusCunning(state: Game, fixture: SemutaNexusCunningFixture, claim: boolean): Game {
|
||||
const event = state.pendingTreacheryDiscard!.batch.event;
|
||||
if (claim) return applyAction(state, fixture.holder, { type: 'semutaCommit', event });
|
||||
let game = state;
|
||||
for (const player of state.players) game = applyAction(game, player.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
export function quoteSemutaNexusCunningAction(
|
||||
game: Game, fixture: SemutaNexusCunningFixture, effect: 'kulon' | 'gamont', selection: Record<string, unknown> = {},
|
||||
): Action {
|
||||
const own = viewGame(game, fixture.owner);
|
||||
const play = choamPowerPlays(own, effect).find(p => p.source === 'nexus' && p.card.id === fixture.cost.id);
|
||||
assert.ok(play && !play.blocked, 'Original private Cunning quote must offer the actual canonical fuel');
|
||||
const action = choamPowerAction(own, play, selection); assert.ok(action);
|
||||
return action;
|
||||
}
|
||||
export function allowSemutaNexusCunning(state: Game, actions: SemutaNexusCunningStep[] = []): Game {
|
||||
let game = state;
|
||||
for (let n = 0; game.response && n < 30; n++) {
|
||||
const actor = game.players.find(p => {
|
||||
const controls = viewGame(game, p.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
|
||||
})?.id;
|
||||
assert.ok(actor, 'Original ordinary CHOAM response needs a legal remaining responder');
|
||||
const action: Action = { type: 'passResponse' };
|
||||
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
export function createSemutaNexusCunningProactive(
|
||||
effect: 'kulon' | 'gamont', options: SemutaNexusCunningOptions & { elite?: boolean } = {},
|
||||
): SemutaNexusCunningProactiveFixture {
|
||||
const f = createSemutaNexusCunningStart(options);
|
||||
let game = advanceSemutaNexusCunning(f.game, g => g.phase === 5 && clean(g), f.actions);
|
||||
reserveSemutaNexusCunningBoard(game, f.staging);
|
||||
let from: string, move: Action | undefined, elite: 0 | 1 = 0;
|
||||
let selection: Record<string, unknown> = {};
|
||||
if (effect === 'kulon') {
|
||||
game = advanceSemutaNexusCunning(game, g => g.phase === 5 && g.active === f.owner && clean(g), f.actions);
|
||||
const keys = TERRITORIES.filter(t => t.type !== 'stronghold').flatMap(t => t.sectors.map(s => `${t.id}:${s}`));
|
||||
const source = keys.find(key => splitLocation(key).sector !== game.storm && keys.some(to =>
|
||||
splitLocation(to).territory !== splitLocation(key).territory && splitLocation(to).sector !== game.storm &&
|
||||
gameDistance(game, key, to, k => splitLocation(k).sector === game.storm) === 2));
|
||||
assert.ok(source); from = source;
|
||||
const to = keys.find(key => splitLocation(key).territory !== splitLocation(from).territory &&
|
||||
splitLocation(key).sector !== game.storm && gameDistance(game, from, key, k => splitLocation(k).sector === game.storm) === 2)!;
|
||||
const owner = gamePlayer(game, f.owner); assert.ok(owner.reserves >= 2);
|
||||
owner.reserves -= 2; owner.forces[from] = 2;
|
||||
move = { type: 'move', forces: { [from]: 1 }, ...splitLocation(to) };
|
||||
f.staging.push('At original CHOAM Movement, conserved two normal reserve forces into one desert origin, with a genuine distance-two destination.');
|
||||
} else {
|
||||
game = advanceSemutaNexusCunning(game, g => g.phase === 7 && clean(g), f.actions);
|
||||
const territory = TERRITORIES.find(t => t.id === 'arrakeen')!;
|
||||
from = `${territory.id}:${territory.sectors[0]}`;
|
||||
const target = gamePlayer(game, options.elite ? f.other : f.target);
|
||||
elite = options.elite ? 1 : 0;
|
||||
assert.ok(target.reserves > 0); target.reserves--;
|
||||
if (elite) { assert.ok(target.elites && target.elites.reserves > 0); target.elites.reserves--; target.elites.forces[from] = 1; }
|
||||
target.forces[from] = 1;
|
||||
f.staging.push(`At real Collection, conserved one original ${elite ? 'Emperor elite' : 'Atreides normal'} force at Arrakeen before the closing Mentat window.`);
|
||||
game = advanceSemutaNexusCunning(game, g => g.phase === 8 && g.decision?.kind === 'choamMentat', f.actions);
|
||||
assert.equal(game.decision!.player, f.owner);
|
||||
selection = { target: target.id, from, elite };
|
||||
}
|
||||
const karama = holdSemutaNexusCunningCard(game, f.other, c => c.effect === 'karama', f.staging).id;
|
||||
const action = quoteSemutaNexusCunningAction(game, f, effect, selection);
|
||||
return { ...f, game, effect, action, selection, from, move, elite, karama };
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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 { choamPowerPlays } from '../game/choam-power-options';
|
||||
import {
|
||||
allowSemutaNexusCunning, createSemutaNexusCunningProactive,
|
||||
gamePlayer, nextSemutaNexusCunningStep, reactSemutaNexusCunning,
|
||||
semutaNexusCunningInventory,
|
||||
} from './fixture-semuta-nexus-cunning';
|
||||
|
||||
for (const advanced of [false, true]) for (const claim of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} original Cunning Kulon ${claim ? 'claim' : 'pass'} preserves private projectile fuel and grants one native move bonus`, () => {
|
||||
const f = createSemutaNexusCunningProactive('kulon', { advanced, fullHand: claim });
|
||||
assert.equal(f.setup.status, 'setup');
|
||||
assert.ok(Object.values(f.setup.nexusCards!.cards!.hands).every(card => card === null));
|
||||
assert.equal(f.afterFirstStorm.turn, 1); assert.equal(f.afterFirstStorm.phase, 1);
|
||||
assert.equal(f.drawBefore.turn, 2); assert.equal(f.drawBefore.phase, 1);
|
||||
assert.equal(f.drawBefore.nexusCards!.phase!.occurred, true);
|
||||
assert.equal(gamePlayer(f.drawBefore, f.target).ally, f.other);
|
||||
assert.equal(gamePlayer(f.drawBefore, f.other).ally, f.target);
|
||||
assert.equal(gamePlayer(f.drawBefore, f.owner).ally, null);
|
||||
assert.deepEqual([...f.drawBefore.nexusCards!.phase!.eligible].sort(), [f.owner, f.holder].sort());
|
||||
assert.equal(f.drawAfter.nexusCards!.phase!.stage, 'complete');
|
||||
assert.ok(f.drawAfter.nexusCards!.cards!.hands[f.owner]);
|
||||
assert.equal(f.drawAfter.nexusCards!.cards!.hands[f.holder], null);
|
||||
assert.equal(f.drawAfter.nexusCards!.cards!.deck.length, 11);
|
||||
assert.equal(f.game.active, f.owner); assert.equal(f.game.phase, 5);
|
||||
assert.equal(f.cost.kind, 'projectile');
|
||||
assert.throws(() => applyAction(f.game, f.owner, f.move!), /more than 1/);
|
||||
const wallets = f.game.players.map(p => p.spice), handBefore = gamePlayer(f.game, f.holder).hand.length;
|
||||
const declared = applyAction(f.game, f.owner, f.action);
|
||||
assert.ok(declared.response, 'The original printed Karama may prevent Cunning');
|
||||
assert.deepEqual(gamePlayer(declared, f.owner).hand.find(c => c.id === f.cost.id), f.cost);
|
||||
assert.equal(declared.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(declared.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
|
||||
assert.equal(declared.nexusChoamHistory!.at(-1)!.stage, 'pending');
|
||||
const pending = allowSemutaNexusCunning(declared);
|
||||
const fresh = pending.pendingTreacheryDiscard!;
|
||||
assert.equal(fresh.continuation.kind, 'choamKulonDiscard');
|
||||
assert.deepEqual(fresh.batch.entries, [{ card: f.cost, discardedBy: f.owner, publicFace: false }]);
|
||||
assert.equal(fresh.reaction?.stage, 'offer');
|
||||
assert.equal(pending.nexusChoamHistory!.length, 1);
|
||||
assert.equal(pending.nexusChoamHistory![0].stage, 'complete', 'Allowance retires the spent Nexus before Semuta reacts');
|
||||
assert.equal(pending.nexusChoamHistory![0].receipt.card, f.cost.id);
|
||||
assert.equal(pending.nexusChoamHistory![0].receipt.effect, 'kulon');
|
||||
assert.equal(pending.discard.filter(c => c.id === f.cost.id).length, 1);
|
||||
assert.deepEqual(viewGame(pending, f.holder).semutaReaction!.candidates, [], 'No cost inspection before committing Semuta');
|
||||
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true);
|
||||
assert.throws(() => applyAction(pending, f.owner, f.move!));
|
||||
const done = reactSemutaNexusCunning(pending, f, claim);
|
||||
assert.equal(done.pendingTreacheryDiscard, null, 'The only foreign candidate completes directly at Commit');
|
||||
assert.equal(done.resolvedTreacheryDiscardSequence, fresh.sequence);
|
||||
assert.equal(done.active, f.owner); assert.equal(gamePlayer(done, f.owner).moved, 0);
|
||||
assert.equal(gamePlayer(done, f.holder).hand.length, handBefore);
|
||||
assert.equal(gamePlayer(done, f.holder).hand.some(c => c.id === f.semuta), !claim);
|
||||
assert.equal(done.discard.filter(c => c.id === f.semuta).length, Number(claim));
|
||||
assert.equal(done.discard.filter(c => c.id === f.cost.id).length, Number(!claim));
|
||||
assert.deepEqual(gamePlayer(done, f.holder).hand.find(c => c.id === f.cost.id), claim ? f.cost : undefined);
|
||||
assert.deepEqual(done.players.map(p => p.spice), wallets);
|
||||
assert.equal(done.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
|
||||
assert.equal(done.nexusChoamHistory!.length, 1);
|
||||
assert.throws(() => applyAction(done, f.owner, f.action));
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: fresh.batch.event }));
|
||||
const moved = applyAction(done, f.owner, f.move!);
|
||||
assert.equal(gamePlayer(moved, f.owner).moved, 1);
|
||||
assert.equal(gamePlayer(moved, f.owner).forces[f.from], 1);
|
||||
assert.throws(() => applyAction(moved, f.owner, f.move!), /movement is not available/);
|
||||
assert.deepEqual(semutaNexusCunningInventory(moved), f.cards);
|
||||
if (claim) assert.equal(handBefore, handLimit(gamePlayer(f.game, f.holder)));
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} original closing Cunning Gamont ${claim ? 'claim' : 'pass'} returns one ${advanced && claim ? 'elite' : 'normal'} force before its original owner closes Mentat`, () => {
|
||||
const optional = advanced && claim;
|
||||
const f = createSemutaNexusCunningProactive('gamont', {
|
||||
advanced, elite: optional, fullHand: claim,
|
||||
costKind: optional ? 'special' : 'snooper',
|
||||
skills: optional, tech: optional, strongholds: optional,
|
||||
});
|
||||
const target = f.selection.target as string, beforeTarget = structuredClone(gamePlayer(f.game, target));
|
||||
const wallets = f.game.players.map(p => p.spice), hands = gamePlayer(f.game, f.holder).hand.length;
|
||||
assert.equal(f.cost.kind, optional ? 'special' : 'snooper');
|
||||
if (optional) {
|
||||
assert.equal(f.cost.effect, 'hajr');
|
||||
assert.equal(f.game.leaderSkills!.assignments.length, 4);
|
||||
assert.ok(Object.values(f.game.techTokens!).every(t => f.game.players.some(p => p.id === t.owner)));
|
||||
assert.equal(f.game.strongholdCards!.claimedTurn, 1);
|
||||
}
|
||||
assert.equal(f.game.phase, 8);
|
||||
assert.deepEqual(f.game.decision, { kind: 'choamMentat', player: f.owner });
|
||||
assert.equal(viewGame(f.game, f.target).mentatVictoryPending, true);
|
||||
const declared = applyAction(f.game, f.owner, f.action);
|
||||
assert.equal(declared.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.deepEqual(gamePlayer(declared, f.owner).hand.find(c => c.id === f.cost.id), f.cost);
|
||||
const pending = allowSemutaNexusCunning(declared), fresh = pending.pendingTreacheryDiscard!;
|
||||
assert.equal(fresh.continuation.kind, 'choamGamontDiscard');
|
||||
assert.deepEqual(fresh.batch.entries, [{ card: f.cost, discardedBy: f.owner, publicFace: false }]);
|
||||
assert.equal(gamePlayer(pending, target).forces[f.from], 1);
|
||||
assert.equal(gamePlayer(pending, target).reserves, beforeTarget.reserves);
|
||||
assert.equal(pending.status, 'playing'); assert.deepEqual(pending.winner, []);
|
||||
assert.equal(pending.nexusChoamHistory![0].stage, 'complete');
|
||||
assert.equal(pending.nexusChoamHistory![0].receipt.effect, 'gamont');
|
||||
assert.throws(() => applyAction(pending, f.owner, { type: 'decision', done: true }));
|
||||
if (optional) assert.equal(pending.strongholdCards!.claimedTurn, 1);
|
||||
const done = reactSemutaNexusCunning(pending, f, claim), returned = gamePlayer(done, target);
|
||||
assert.equal(done.pendingTreacheryDiscard, null);
|
||||
assert.equal(done.resolvedTreacheryDiscardSequence, fresh.sequence);
|
||||
assert.equal(returned.forces[f.from], undefined);
|
||||
assert.equal(returned.reserves, beforeTarget.reserves + 1);
|
||||
assert.equal(returned.tanks, beforeTarget.tanks);
|
||||
assert.equal(returned.battleLosses, beforeTarget.battleLosses);
|
||||
if (f.elite) {
|
||||
assert.equal(returned.elites!.forces[f.from], undefined);
|
||||
assert.equal(returned.elites!.reserves, beforeTarget.elites!.reserves + 1);
|
||||
assert.equal(returned.elites!.tanks, beforeTarget.elites!.tanks);
|
||||
}
|
||||
assert.equal(done.status, 'playing'); assert.equal(done.turn, 2); assert.deepEqual(done.winner, []);
|
||||
assert.deepEqual(done.decision, { kind: 'choamMentat', player: f.owner });
|
||||
assert.equal(done.choamMentatPending, true);
|
||||
assert.equal(gamePlayer(done, f.holder).hand.length, hands);
|
||||
assert.deepEqual(gamePlayer(done, f.holder).hand.find(c => c.id === f.cost.id), claim ? f.cost : undefined);
|
||||
assert.equal(done.discard.filter(c => c.id === f.cost.id).length, Number(!claim));
|
||||
assert.equal(done.discard.filter(c => c.id === f.semuta).length, Number(claim));
|
||||
assert.deepEqual(done.players.map(p => p.spice), wallets, 'Returning a force grants no spice or replayed Special fuel power');
|
||||
assert.equal(done.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
|
||||
assert.throws(() => applyAction(done, f.owner, f.action));
|
||||
assert.throws(() => applyAction(done, f.target, { type: 'decision', done: true }));
|
||||
if (optional) assert.equal(done.strongholdCards!.claimedTurn, 1);
|
||||
const closed = applyAction(done, f.owner, { type: 'decision', done: true });
|
||||
assert.equal(closed.turn, 3); assert.equal(closed.status, 'playing');
|
||||
assert.equal(gamePlayer(closed, target).reserves, returned.reserves, 'Closing cannot repeat the return');
|
||||
assert.equal(closed.nexusChoamHistory!.length, 1);
|
||||
if (optional) {
|
||||
assert.equal(closed.strongholdCards!.claimedTurn, 2);
|
||||
assert.equal(closed.strongholdCards!.owners.arrakeen, null, 'The returned sole force cannot claim Arrakeen');
|
||||
}
|
||||
assert.deepEqual(semutaNexusCunningInventory(closed), f.cards);
|
||||
});
|
||||
}
|
||||
|
||||
for (const effect of ['kulon', 'gamont'] as const) void test(`Original printed Karama cancellation retains ${effect} fuel and spends Cunning without offering that fuel`, () => {
|
||||
const f = createSemutaNexusCunningProactive(effect);
|
||||
let game = applyAction(f.game, f.owner, f.action);
|
||||
game = applyAction(game, f.other, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
// A separately discarded printed Karama may have its own ordinary reaction.
|
||||
for (let n = 0; (game.pendingTreacheryDiscard || game.response) && n < 30; n++) {
|
||||
assert.ok(!game.pendingTreacheryDiscard?.batch.entries.some(e => e.card.id === f.cost.id));
|
||||
const step = nextSemutaNexusCunningStep(game);
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
assert.deepEqual(gamePlayer(game, f.owner).hand.find(c => c.id === f.cost.id), f.cost);
|
||||
assert.equal(game.discard.some(c => c.id === f.cost.id), false);
|
||||
assert.equal(gamePlayer(game, f.holder).hand.some(c => c.id === f.cost.id), false);
|
||||
assert.equal(gamePlayer(game, f.holder).hand.some(c => c.id === f.semuta), true);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
|
||||
assert.equal(game.nexusChoamHistory!.length, 1); assert.equal(game.nexusChoamHistory![0].stage, 'canceled');
|
||||
if (effect === 'kulon') {
|
||||
assert.throws(() => applyAction(game, f.owner, f.move!), /more than 1/);
|
||||
} else {
|
||||
assert.equal(gamePlayer(game, f.target).forces[f.from], 1);
|
||||
assert.deepEqual(game.decision, { kind: 'choamMentat', player: f.owner });
|
||||
}
|
||||
assert.throws(() => applyAction(game, f.owner, f.action));
|
||||
assert.deepEqual(semutaNexusCunningInventory(game), f.cards);
|
||||
});
|
||||
|
||||
void test('Rivals cannot activate native Cunning and the Semuta profile cannot spend its Nexus on Kull', () => {
|
||||
const f = createSemutaNexusCunningProactive('kulon'), own = viewGame(f.game, f.owner);
|
||||
assert.throws(() => applyAction(f.game, f.holder, f.action));
|
||||
const kull = choamPowerPlays(own, 'kull').find(p => p.source === 'nexus')!;
|
||||
assert.throws(() => applyAction(f.game, f.owner,
|
||||
{ type: 'card', mode: 'choam', card: kull.card.id, nexus: kull.event, effect: 'kull' }));
|
||||
assert.equal(f.game.nexusCards!.cards!.hands[f.owner], 'choam');
|
||||
assert.deepEqual(gamePlayer(f.game, f.owner).hand.find(c => c.id === f.cost.id), f.cost);
|
||||
assert.deepEqual(semutaNexusCunningInventory(f.game), f.cards);
|
||||
});
|
||||
|
||||
void test('Minimal native Cunning and Semuta bot actions use the original cost and finish singleton commitment legally', () => {
|
||||
const f = createSemutaNexusCunningProactive('kulon');
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const own = viewGame(f.game, f.owner); own.players.find(p => p.id === f.owner)!.bot = difficulty;
|
||||
const action = botActions(own)[0]; assert.ok(action);
|
||||
assert.equal(action.card, f.cost.id); assert.equal(action.nexus, f.action.nexus); assert.equal(action.effect, 'kulon');
|
||||
const pending = allowSemutaNexusCunning(applyAction(f.game, f.owner, action));
|
||||
const richese = viewGame(pending, f.holder); richese.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const commit = botActions(richese)[0]; assert.ok(commit); assert.equal(commit.type, 'semutaCommit');
|
||||
const done = applyAction(pending, f.holder, commit);
|
||||
assert.equal(done.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(gamePlayer(done, f.holder).hand.find(c => c.id === f.cost.id), f.cost);
|
||||
const moved = applyAction(done, f.owner, f.move!);
|
||||
assert.equal(gamePlayer(moved, f.owner).forces[f.from], 1);
|
||||
assert.equal(gamePlayer(moved, f.owner).moved, 1);
|
||||
assert.deepEqual(semutaNexusCunningInventory(moved), f.cards);
|
||||
}
|
||||
let game: Game = allowSemutaNexusCunning(applyAction(f.game, f.owner, f.action));
|
||||
for (const player of game.players) {
|
||||
const own = viewGame(game, player.id); own.players.find(p => p.id === player.id)!.bot = 'Easy';
|
||||
const action = player.id === f.holder
|
||||
? { type: 'semutaPass', event: game.pendingTreacheryDiscard!.batch.event }
|
||||
: botActions(own)[0];
|
||||
assert.equal(action?.type, 'semutaPass');
|
||||
game = applyAction(game, player.id, action!);
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
const moved = applyAction(game, f.owner, f.move!);
|
||||
assert.equal(gamePlayer(moved, f.owner).forces[f.from], 1);
|
||||
assert.equal(gamePlayer(moved, f.owner).moved, 1);
|
||||
assert.deepEqual(semutaNexusCunningInventory(moved), f.cards);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { choamPowerPlays } from '../game/choam-power-options';
|
||||
import {
|
||||
allowSemutaNexusCunning, gamePlayer, reactSemutaNexusCunning,
|
||||
semutaNexusCunningInventory,
|
||||
} from './fixture-semuta-nexus-cunning';
|
||||
import {
|
||||
cancelSemutaNexusCunningReactive, createSemutaNexusCunningReactive,
|
||||
quoteSemutaNexusCunningReactive, semutaNexusCunningReactiveBoard,
|
||||
type SemutaNexusCunningReactiveEffect, type SemutaNexusCunningReactiveFixture,
|
||||
} from './fixture-semuta-nexus-cunning-reactive';
|
||||
|
||||
const holds = (game: Game, id: string, card: string) =>
|
||||
gamePlayer(game, id).hand.some(candidate => candidate.id === card);
|
||||
const nexusSpent = (game: Game, owner: string) =>
|
||||
game.nexusCards?.cards?.discard.filter(id => id === 'choam').length === 1 &&
|
||||
game.nexusCards?.cards?.hands[owner] === null;
|
||||
/** Declare the original private Cunning quote and resolve its ordinary response. */
|
||||
function declareAndAllow(f: SemutaNexusCunningReactiveFixture) {
|
||||
const request = quoteSemutaNexusCunningReactive(f.game, f);
|
||||
const declared = applyAction(f.game, f.owner, request);
|
||||
return { request, declared, offered: allowSemutaNexusCunning(declared) };
|
||||
}
|
||||
function assertPrivateFuel(f: SemutaNexusCunningReactiveFixture, declared: Game, request: Action) {
|
||||
assert.equal(f.cost.kind, 'projectile');
|
||||
const pending = declared.pendingChoamWorthless;
|
||||
assert.equal(declared.response?.kind, 'choamWorthless');
|
||||
assert.equal(pending?.card, f.cost.id);
|
||||
assert.equal(pending?.nexusEvent, request.nexus);
|
||||
assert.equal(pending?.owner, f.owner);
|
||||
assert.equal(nexusSpent(declared, f.owner), true);
|
||||
// The other seats see neither the private cost list nor the chosen fuel.
|
||||
assert.equal(viewGame(declared, f.target).choamWorthless, null);
|
||||
assert.equal(viewGame(declared, f.other).choamWorthless, null);
|
||||
assert.equal(JSON.stringify(viewGame(declared, f.target)).includes(f.cost.id), false);
|
||||
assert.equal(viewGame(declared, f.holder).semutaReaction, null);
|
||||
}
|
||||
|
||||
void test('reactive Baliset Cunning keeps canonical fuel private until Commit and stops the declared move', () => {
|
||||
const f = createSemutaNexusCunningReactive('baliset', { karama: true });
|
||||
const mover = gamePlayer(f.game, f.target);
|
||||
const source = Object.keys(mover.forces)[0];
|
||||
assert.equal(mover.forces[source], 2);
|
||||
assert.equal(mover.moved, 0);
|
||||
const plays = choamPowerPlays(viewGame(f.game, f.owner), 'baliset');
|
||||
const nexus = plays.find(play => play.source === 'nexus' && play.card.id === f.cost.id);
|
||||
assert.ok(nexus && !nexus.blocked);
|
||||
assert.equal(nexus.card.kind, 'projectile');
|
||||
assert.equal(plays.some(play => play.source === 'printed'), false);
|
||||
const { request, declared, offered } = declareAndAllow(f);
|
||||
assertPrivateFuel(f, declared, request);
|
||||
assert.equal(declared.pendingChoamWorthless?.movement, true);
|
||||
assert.equal(offered.response, null);
|
||||
assert.equal(offered.decision, null);
|
||||
assert.equal(offered.pendingChoamMove, null);
|
||||
assert.equal(offered.nexusChoamLast?.stage, 'complete');
|
||||
assert.equal(offered.pendingTreacheryDiscard?.batch.entries.length, 1);
|
||||
assert.equal(offered.pendingTreacheryDiscard?.batch.entries[0].card.id, f.cost.id);
|
||||
assert.equal(offered.pendingTreacheryDiscard?.batch.entries[0].publicFace, false);
|
||||
assert.equal(offered.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
// The private cost face remains unavailable before commitment.
|
||||
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(viewGame(offered, f.holder).semutaReaction?.candidates, []);
|
||||
const cards = semutaNexusCunningInventory(offered);
|
||||
const claimed = reactSemutaNexusCunning(offered, f.start, true);
|
||||
const passed = reactSemutaNexusCunning(offered, f.start, false);
|
||||
for (const settled of [claimed, passed]) {
|
||||
assert.equal(settled.pendingTreacheryDiscard, null);
|
||||
assert.equal(settled.pendingChoamMove, null);
|
||||
assert.equal(settled.nexusChoamLast?.stage, 'complete');
|
||||
assert.deepEqual(settled.choamBaliset, [{ turn: settled.turn, player: f.target, territory: f.territory }]);
|
||||
assert.equal(viewGame(settled, f.target).balisetRestrictions.length, 1);
|
||||
assert.deepEqual(semutaNexusCunningInventory(settled), cards);
|
||||
// The stopped movement spends no force, movement allowance or spice.
|
||||
assert.deepEqual(semutaNexusCunningReactiveBoard(settled), semutaNexusCunningReactiveBoard(f.game));
|
||||
assert.equal(gamePlayer(settled, f.target).forces[source], 2);
|
||||
assert.equal(gamePlayer(settled, f.target).moved, 0);
|
||||
assert.equal(nexusSpent(settled, f.owner), true);
|
||||
assert.equal(holds(settled, f.owner, f.cost.id), false);
|
||||
assert.throws(() => applyAction(settled, f.target, f.parent), /Baliset prevents/);
|
||||
assert.throws(() => reactSemutaNexusCunning(settled, f.start, true));
|
||||
}
|
||||
// The single foreign claim takes the actual discarded fuel; the decline does not.
|
||||
assert.equal(holds(claimed, f.holder, f.cost.id), true);
|
||||
assert.equal(claimed.discard.some(card => card.id === f.semuta), true);
|
||||
assert.equal(holds(claimed, f.holder, f.semuta), false);
|
||||
assert.equal(passed.discard.some(card => card.id === f.cost.id), true);
|
||||
assert.equal(holds(passed, f.holder, f.semuta), true);
|
||||
});
|
||||
|
||||
void test('reactive La La La Cunning stops the free revival and leaves tanks, allowances and payment untouched', () => {
|
||||
const f = createSemutaNexusCunningReactive('laLaLa', { karama: true });
|
||||
const target = gamePlayer(f.game, f.target);
|
||||
assert.equal(target.tanks, 3);
|
||||
assert.equal(target.reserves, 17);
|
||||
assert.ok(f.game.pendingRevival);
|
||||
const { request, declared, offered } = declareAndAllow(f);
|
||||
assertPrivateFuel(f, declared, request);
|
||||
assert.equal(declared.pendingChoamWorthless?.revival, true);
|
||||
assert.equal(declared.pendingChoamWorthless?.target, f.target);
|
||||
assert.equal(offered.nexusChoamLast?.stage, 'complete');
|
||||
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(viewGame(offered, f.holder).semutaReaction?.candidates, []);
|
||||
const cards = semutaNexusCunningInventory(offered);
|
||||
const claimed = reactSemutaNexusCunning(offered, f.start, true);
|
||||
const passed = reactSemutaNexusCunning(offered, f.start, false);
|
||||
for (const settled of [claimed, passed]) {
|
||||
assert.equal(settled.pendingTreacheryDiscard, null);
|
||||
assert.equal(settled.pendingRevival, null);
|
||||
assert.equal(settled.decision, null);
|
||||
assert.equal(settled.nexusChoamLast?.stage, 'complete');
|
||||
assert.deepEqual(semutaNexusCunningReactiveBoard(settled), semutaNexusCunningReactiveBoard(f.game));
|
||||
assert.equal(viewGame(settled, f.target).revivalRules?.freeBlocked?.includes(f.target), true);
|
||||
assert.deepEqual(semutaNexusCunningInventory(settled), cards);
|
||||
assert.equal(nexusSpent(settled, f.owner), true);
|
||||
assert.throws(() => reactSemutaNexusCunning(settled, f.start, true));
|
||||
// The original parent continues once: the ordinary paid revival still works.
|
||||
const paid = applyAction(settled, f.target, { type: 'revive', amount: 2 });
|
||||
assert.equal(paid.response, null);
|
||||
assert.equal(gamePlayer(paid, f.target).spice, target.spice - 4);
|
||||
assert.equal(gamePlayer(paid, f.target).tanks, 1);
|
||||
assert.equal(gamePlayer(paid, f.target).reserves, 19);
|
||||
assert.equal(gamePlayer(paid, f.target).freeForcesRevived, 0);
|
||||
}
|
||||
assert.equal(holds(claimed, f.holder, f.cost.id), true);
|
||||
assert.equal(claimed.discard.some(card => card.id === f.semuta), true);
|
||||
assert.equal(passed.discard.some(card => card.id === f.cost.id), true);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced card' : 'Basic dialed'} reactive Jubba Cunning protects forces in one crossed territory without protecting spice`, () => {
|
||||
const f = createSemutaNexusCunningReactive('jubba', { advanced, karama: true });
|
||||
const protectedKey = f.crossed.find(entry => entry.territory === f.territory)!.key;
|
||||
const otherKey = f.crossed.find(entry => entry.territory === f.otherTerritory)!.key;
|
||||
const owner = gamePlayer(f.game, f.owner);
|
||||
const target = gamePlayer(f.game, f.target);
|
||||
assert.equal(owner.forces[protectedKey], 3);
|
||||
assert.equal(owner.forces[otherKey], 3);
|
||||
assert.equal(target.forces[protectedKey], 2);
|
||||
assert.equal(target.forces[otherKey], 2);
|
||||
assert.equal(f.game.stormPending, f.distance);
|
||||
const { request, declared, offered } = declareAndAllow(f);
|
||||
assertPrivateFuel(f, declared, request);
|
||||
assert.equal(declared.pendingChoamWorthless?.storm, true);
|
||||
assert.equal(declared.pendingChoamWorthless?.location, f.territory);
|
||||
assert.equal(offered.nexusChoamLast?.stage, 'complete');
|
||||
assert.ok(offered.stormResolution, 'The real storm stays untraversed behind its cost offer');
|
||||
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(viewGame(offered, f.holder).semutaReaction?.candidates, []);
|
||||
const cards = semutaNexusCunningInventory(offered);
|
||||
const claimed = reactSemutaNexusCunning(offered, f.start, true);
|
||||
const passed = reactSemutaNexusCunning(offered, f.start, false);
|
||||
const arrived = ((f.before.storm + f.distance - 1) % 18) + 1;
|
||||
for (const settled of [claimed, passed]) {
|
||||
assert.equal(settled.pendingTreacheryDiscard, null);
|
||||
assert.equal(settled.nexusChoamLast?.stage, 'complete');
|
||||
// The one protected territory is recorded and the real storm pauses for the
|
||||
// remaining original CHOAM choice rather than resolving early.
|
||||
assert.equal(settled.stormResolution?.choamProtected?.includes(f.territory!), true);
|
||||
const remaining = settled.decision?.kind === 'choamStorm'
|
||||
? settled.decision.territories.map(entry => entry.territory) : [];
|
||||
assert.deepEqual(remaining, [f.otherTerritory]);
|
||||
const continued = settled.decision?.kind === 'choamStorm'
|
||||
? applyAction(settled, f.owner, { type: 'decision', decline: true })
|
||||
: settled;
|
||||
assert.equal(continued.stormResolution, null);
|
||||
assert.equal(continued.storm, arrived);
|
||||
assert.equal(gamePlayer(continued, f.owner).forces[protectedKey], 3);
|
||||
assert.equal(gamePlayer(continued, f.owner).forces[otherKey], undefined);
|
||||
assert.equal(gamePlayer(continued, f.owner).tanks, 3);
|
||||
assert.equal(gamePlayer(continued, f.target).forces[protectedKey], undefined);
|
||||
assert.equal(gamePlayer(continued, f.target).forces[otherKey], undefined);
|
||||
assert.equal(gamePlayer(continued, f.target).tanks, 4);
|
||||
assert.equal(continued.spice[protectedKey], undefined, 'Jubba protects CHOAM forces, not spice under the storm');
|
||||
assert.equal(continued.spice[otherKey], undefined);
|
||||
assert.deepEqual(semutaNexusCunningInventory(continued), cards);
|
||||
assert.equal(nexusSpent(continued, f.owner), true);
|
||||
assert.throws(() => reactSemutaNexusCunning(settled, f.start, true));
|
||||
}
|
||||
assert.equal(holds(claimed, f.holder, f.cost.id), true);
|
||||
assert.equal(claimed.discard.some(card => card.id === f.semuta), true);
|
||||
assert.equal(passed.discard.some(card => card.id === f.cost.id), true);
|
||||
});
|
||||
|
||||
void test('canceling reactive Cunning retains the canonical fuel and resumes its original parent without offering that fuel', () => {
|
||||
for (const effect of ['baliset', 'laLaLa', 'jubba'] as SemutaNexusCunningReactiveEffect[]) {
|
||||
const f = createSemutaNexusCunningReactive(effect, { karama: true });
|
||||
const request = quoteSemutaNexusCunningReactive(f.game, f);
|
||||
const declared = applyAction(f.game, f.owner, request);
|
||||
assert.equal(declared.pendingChoamWorthless?.nexusEvent, request.nexus);
|
||||
let canceled = cancelSemutaNexusCunningReactive(declared, f);
|
||||
for (let n = 0; canceled.pendingTreacheryDiscard && n < 30; n++) {
|
||||
assert.ok(!canceled.pendingTreacheryDiscard.batch.entries.some(entry => entry.card.id === f.cost.id));
|
||||
canceled = reactSemutaNexusCunning(canceled, f.start, false);
|
||||
}
|
||||
assert.equal(canceled.response, null);
|
||||
assert.equal(canceled.pendingChoamWorthless, null);
|
||||
assert.equal(canceled.nexusChoamLast?.stage, 'canceled');
|
||||
assert.equal(canceled.choamWorthlessBlocked?.cards.includes(f.cost.id), true);
|
||||
assert.equal(holds(canceled, f.owner, f.cost.id), true);
|
||||
assert.equal(canceled.discard.some(card => card.id === f.cost.id), false);
|
||||
assert.equal(nexusSpent(canceled, f.owner), true);
|
||||
assert.equal(choamPowerPlays(viewGame(canceled, f.owner), effect).some(play => play.source === 'nexus'), false);
|
||||
if (effect === 'baliset') {
|
||||
const moved = gamePlayer(canceled, f.target);
|
||||
assert.equal(moved.moved, 1);
|
||||
assert.equal(moved.forces[f.destination!], 1);
|
||||
assert.equal(moved.forces[f.origin!], 1);
|
||||
assert.equal(canceled.pendingChoamMove, null);
|
||||
} else if (effect === 'laLaLa') {
|
||||
const revived = gamePlayer(canceled, f.target);
|
||||
assert.equal(canceled.pendingRevival, null);
|
||||
assert.equal(revived.tanks, 1);
|
||||
assert.equal(revived.reserves, 19);
|
||||
assert.equal(revived.revived, 2);
|
||||
assert.equal(revived.spice, gamePlayer(f.game, f.target).spice);
|
||||
} else {
|
||||
const storm = canceled.decision?.kind === 'choamStorm' ? canceled.decision : null;
|
||||
assert.ok(storm, 'The prevented Jubba still leaves the original storm choice');
|
||||
const traversed = applyAction(canceled, f.owner, { type: 'decision', decline: true });
|
||||
assert.equal(traversed.stormResolution, null);
|
||||
const protectedKey = f.crossed.find(entry => entry.territory === f.territory)!.key;
|
||||
const otherKey = f.crossed.find(entry => entry.territory === f.otherTerritory)!.key;
|
||||
assert.equal(gamePlayer(traversed, f.owner).forces[protectedKey], undefined);
|
||||
assert.equal(gamePlayer(traversed, f.owner).forces[otherKey], undefined);
|
||||
assert.equal(gamePlayer(traversed, f.owner).tanks, 6);
|
||||
assert.equal(traversed.spice[protectedKey], undefined);
|
||||
assert.equal(traversed.spice[otherKey], undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user