Compose native Homeworld Kaitain and Tupile discards with Semuta

This commit is contained in:
Quintine committed 2026-10-12 16:40:03 +11:00
1 parent d718e380b7
commit a4f1cfc650
14 files changed
+955 -18

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeHomeworldOccupationGameForAudit,
initializeSemutaGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import { treacheryDeck, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { highKaitainDiscardsAvailable } from '../game/homeworld-card-economy';
import { biddingEndDiscardChoice } from '../game/bidding-end-options';
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
export type SemutaKaitainOptions = {
initial?: Game;
advanced?: boolean;
count?: 1 | 2;
fullHolder?: boolean;
ownerReady?: boolean;
choamReady?: boolean;
/** Before-cutover proof uses the original native Homeworld entry, without Semuta. */
nativeOnly?: boolean;
};
export type SemutaKaitainFixture = {
game: Game;
paid: Game;
initial: Game;
setup: Game;
owner: string;
holder: string;
choam: string;
semuta: string;
cards: Card[];
cardIds: string[];
action: Action;
event: string;
actions: RicheseSkillsTechStep[];
staging: string[];
stock: string[];
};
/** Genuine original setup/first Storm/original auctions establish closing Bidding.
* Only later physical hand positions and unplayed original Spice order are staged.
* Kaitain population, wallets, ownership, readiness and every clock stay native. */
export function createSemutaKaitainFixture(options: SemutaKaitainOptions = {}): SemutaKaitainFixture {
let game = options.initial ? structuredClone(options.initial)
: createGame('SEMUTAKAITAIN', newPlayer('r', 'Richese', 'richese'), options.advanced ?? false, ['choam']);
if (!options.initial) {
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
}
assert.equal(game.status, 'lobby');
assert.deepEqual(game.expansions, ['choam']);
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const holder = game.players.find(p => p.faction === 'richese')!.id;
const owner = game.players.find(p => p.faction === 'emperor')!.id;
const choam = game.players.find(p => p.faction === 'choam')!.id;
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action });
game = applyAction(game, actor, action);
};
if (!game.homeworlds) act(game.host, { type: 'homeworlds', enabled: true });
for (const p of game.players) if (!p.ready) act(p.id, { type: 'ready' });
const initial = structuredClone(game);
game = options.nativeOnly ? initializeHomeworldOccupationGameForAudit(game)
: initializeSemutaGameForAudit(game);
const stock = biddingPhysicalIds(game);
assert.deepEqual(stock, [...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort(),
'The original native initializer supplies the canonical stock exactly once');
const reach = (until: () => boolean) => {
for (let step = 0; !until() && step < 1200; step++) {
assert.notEqual(game.status, 'finished', 'Native setup and Bidding must reach Kaitain');
let next: RicheseSkillsTechStep;
const pending = game.pendingTreacheryDiscard;
const reaction = pending?.reaction;
if (reaction?.stage === 'offer' && pending) {
next = { actor: game.players.find(p => !reaction.passed.includes(p.id))!.id,
action: { type: 'semutaPass', event: pending.batch.event } };
} else if (game.decision?.kind === 'choamMarket') {
// Decline the real post-Storm market, preserving original cards and wallets.
next = { actor: game.decision.player, action: { type: 'decision', done: true } };
} else next = nextRicheseSkillsTechNativeStep(game);
act(next.actor, next.action);
}
assert.ok(until(), 'Original actions must reach the requested Kaitain boundary');
};
reach(() => game.status === 'playing');
const setup = structuredClone(game);
// Two original non-worm blows avoid an unrelated alliance opportunity.
const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 2);
assert.equal(blows.length, 2);
for (const [position, card] of blows.entries()) {
const at = game.spiceDeck.indexOf(card);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
staging.push('Reorder two still-unplayed original non-worm Spice Cards; no replacement deck.');
reach(() => !!game.biddingEnd && !game.decision && !game.response && !game.pendingTreacheryDiscard);
assert.equal(game.turn, 1);
assert.equal(game.phase, 3);
assert.equal(highKaitainDiscardsAvailable(game, owner), true, 'Original Emperor reserves qualify high Kaitain');
assert.equal(semutaEmperorPlayer(game, owner).spice, 10, 'No native auction bid was paid');
assert.deepEqual(biddingPhysicalIds(game), stock);
// Conserved later hands; no card is created or treated as played/acquired by an auction.
for (const id of [owner, holder]) game.deck.push(...semutaEmperorPlayer(game, id).hand.splice(0));
const semuta = moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, holder);
const first = moveSemutaEmperorCard(game, semutaEmperorCardId(game, card => card.effect === 'karama'), owner);
const cardIds = [first];
if ((options.count ?? 2) === 2) cardIds.push(moveSemutaEmperorCard(game,
semutaEmperorCardId(game, card => card.kind === 'poisonTooth'), owner));
// An unselected native card keeps the owner's optional finish genuinely available.
moveSemutaEmperorCard(game, semutaEmperorCardId(game,
card => card.kind === 'shield' && !cardIds.includes(card.id)), owner);
if (options.fullHolder) {
while (semutaEmperorPlayer(game, holder).hand.length < handLimit(semutaEmperorPlayer(game, holder))) {
const card = game.deck[0];
assert.ok(card);
moveSemutaEmperorCard(game, card.id, holder);
}
assert.equal(semutaEmperorPlayer(game, holder).hand.length, 4);
}
staging.push(`Conserved original ${semuta} to Richese; ${cardIds.join(', ')} and one retained Shield to Emperor${options.fullHolder ? '; three original undealt cards fill Richese to four' : ''}.`);
assert.deepEqual(biddingPhysicalIds(game), stock);
const event = game.biddingEnd!.event;
if (options.ownerReady) act(owner, { type: 'biddingEnd', event, mode: 'ready' });
if (options.choamReady) act(choam, { type: 'biddingEnd', event, mode: 'ready' });
const choice = biddingEndDiscardChoice(viewGame(game, owner), cardIds);
assert.equal(choice.blocked, null);
assert.ok(choice.action);
const cards = cardIds.map(id => structuredClone(semutaEmperorPlayer(game, owner).hand.find(card => card.id === id)!));
const before = structuredClone(game);
act(owner, choice.action);
return { game: before, paid: game, initial, setup, owner, holder, choam, semuta,
cards, cardIds, action: choice.action, event, actions, staging, stock };
}
/** The actual event requires commitment before private multi-card selection. */
export function reactSemutaKaitain(state: Game, fixture: SemutaKaitainFixture, claim: boolean, selected = fixture.cardIds[0]): Game {
let game = state;
const event = game.pendingTreacheryDiscard!.batch.event;
if (!claim) {
for (const player of game.players) game = applyAction(game, player.id, { type: 'semutaPass', event });
return game;
}
game = applyAction(game, fixture.holder, { type: 'semutaCommit', event });
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = applyAction(game, fixture.holder, { type: 'semutaSelect', event, card: selected });
return game;
}
+174
View File
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeHomeworldOccupationGameForAudit,
initializeSemutaGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import {
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer as semutaTupilePlayer,
} from './fixture-semuta-emperor-share';
export const SEMUTA_TUPILE_WORLD = 'homeworld:choam';
export type SemutaTupileStep = { actor: string; action: Action };
export type SemutaTupileFixture = {
game: Game;
setup: Game;
occupied: Game;
beforeDeparture: Game;
owner: string;
holder: string;
occupier: string;
ally: string;
event: string;
action: Action;
selected: string[];
semuta: string;
sourceActions: SemutaTupileStep[];
actions: SemutaTupileStep[];
cards: string[];
};
export function semutaTupileCards(game: Game): string[] {
return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
...game.players.map(player => player.hand)].flat().map(card => card.id).sort();
}
/** Public physical totals include visitors, which are not native reserves. */
export function semutaTupileForceTotals(game: Game) {
return game.players.map(player => ({ id: player.id, total: player.reserves + player.tanks +
Object.values(player.forces).reduce((sum, amount) => sum + amount, 0) +
Object.values(game.homeworlds!.custody!.visitors).reduce((sum, visitors) =>
sum + (visitors[player.id]?.normal ?? 0) + (visitors[player.id]?.elite ?? 0), 0) }));
}
export function nextSemutaTupileStep(game: Game): SemutaTupileStep {
const pending = game.pendingTreacheryDiscard;
const reaction = pending?.reaction;
if (reaction?.stage === 'offer' && pending) return {
actor: game.players.find(player => !reaction.passed.includes(player.id))!.id,
action: { type: 'semutaPass', event: pending.batch.event },
};
if (game.decision?.kind === 'homeworldShipmentGuild') return {
actor: game.decision.player,
action: { type: 'decision', event: game.decision.event, allow: true },
};
return nextRicheseSkillsTechNativeStep(game);
}
export function semutaTupileCleanupStep(game: Game): SemutaTupileStep {
assert.equal(game.decision?.kind, 'homeworldTupileCleanup');
const actor = game.decision!.player;
const cleanup = viewGame(game, actor).homeworldTupileCleanup!;
assert.ok(cleanup && !cleanup.blocked);
return { actor, action: { type: 'decision', event: cleanup.event,
cards: cleanup.eligibleCards.slice(0, cleanup.excess) } };
}
export function reactSemutaTupile(state: Game, holder: string, claim: boolean): Game {
const event = state.pendingTreacheryDiscard!.batch.event;
if (claim) return applyAction(state, holder, { type: 'semutaCommit', event });
let game = state;
for (const player of state.players)
game = applyAction(game, player.id, { type: 'semutaPass', event });
return game;
}
/** Original setup and phase actions establish the alliance, occupation epoch,
* lease and departure. Later conserved physical positions isolate the producer;
* they never assign its clocks, alliances, wallets, visitor custody or history. */
export function createSemutaTupileFixture(options: { nativeOnly?: boolean } = {}): SemutaTupileFixture {
let game = createGame('SEMUTATUPILE', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
joinGame(game, newPlayer('g', 'Guild', 'guild'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
game = applyAction(game, 'r', { type: 'homeworlds', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = options.nativeOnly
? initializeHomeworldOccupationGameForAudit(game)
: initializeSemutaGameForAudit(game);
const actions: SemutaTupileStep[] = [], sourceActions: SemutaTupileStep[] = [];
const act = (step: SemutaTupileStep, source = false) => {
actions.push(step);
if (source) sourceActions.push(step);
game = applyAction(game, step.actor, step.action);
};
const clean = () => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
const reach = (until: () => boolean) => {
for (let n = 0; !until() && n < 2400; n++) {
assert.notEqual(game.status, 'finished', 'Original native actions must reach Tupile');
act(nextSemutaTupileStep(game));
}
assert.ok(until(), 'Original native actions must reach the requested Tupile boundary');
};
const orderUnplayedSpice = (kinds: ('land' | 'worm')[]) => {
for (const [position, kind] of kinds.entries()) {
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
(kind === 'land' ? 'territory' in card : 'worm' in card));
assert.ok(index >= position, 'An original unplayed Spice Card must remain');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
};
reach(() => game.status === 'playing');
const setup = structuredClone(game), cards = semutaTupileCards(game);
orderUnplayedSpice(['land', 'land']);
reach(() => game.phase === 5 && clean());
// Conserved later positions: remove all Arrakis encounters without changing
// native Homeworld custody, phase controls or original twenty-counter stocks.
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
player.forces = {};
}
reach(() => game.turn === 2 && game.phase === 1 && clean());
orderUnplayedSpice(['worm', 'land', 'land']);
reach(() => game.nexus && !game.spiceWindow && !game.spiceResolution && clean());
act({ actor: 'g', action: { type: 'alliance', target: 'a' } }, true);
act({ actor: 'a', action: { type: 'alliance', target: 'g' } }, true);
reach(() => game.phase === 5 && game.active === 'g' && clean());
// Conserved later CHOAM position: its original native counters are on Arrakis,
// not imaginary visitors. The ORIGINAL invasion must qualify the sole occupier.
const choam = semutaTupilePlayer(game, 'c');
choam.forces['polar_sink:0'] = (choam.forces['polar_sink:0'] ?? 0) + choam.reserves;
choam.reserves = 0;
const invade = homeworldShipmentChoice(viewGame(game, 'g'), SEMUTA_TUPILE_WORLD,
{ 'homeworld:guild': { normal: 1, elite: 0 } });
assert.ok(invade.action, invade.blocked ?? undefined);
act({ actor: 'g', action: invade.action }, true);
reach(() => clean());
assert.equal(semutaTupilePlayer(game, 'g').tupileHandSlot, true);
assert.equal(semutaTupilePlayer(game, 'a').tupileHandSlot, true);
const occupied = structuredClone(game);
// No further worm is needed: the reciprocal alliance remains native.
orderUnplayedSpice(['land', 'land', 'land', 'land']);
reach(() => game.turn === 3 && game.phase === 5 && game.active === 'g' && clean());
// Conserved original physical cards only, staged after all native auctions.
for (const player of game.players) game.deck.push(...player.hand.splice(0));
const semuta = moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
for (const [id, size] of [['g', 5], ['a', 5], ['r', 4]] as const) {
const player = semutaTupilePlayer(game, id);
while (player.hand.length < size) {
const card = semutaEmperorCardId(game, candidate => game.deck.some(held => held.id === candidate.id) &&
candidate.id !== SEMUTA_DRUG_ID && candidate.effect !== 'karama');
moveSemutaEmperorCard(game, card, id);
}
assert.equal(player.hand.length, handLimit(player));
}
const beforeDeparture = structuredClone(game);
const depart = homeworldShipmentChoice(viewGame(game, 'g'), 'homeworld:guild',
{ [SEMUTA_TUPILE_WORLD]: { normal: 1, elite: 0 } });
assert.ok(depart.action, depart.blocked ?? undefined);
act({ actor: 'g', action: depart.action }, true);
reach(() => game.decision?.kind === 'homeworldTupileCleanup');
assert.equal(game.homeworldTupileState!.current, null);
assert.equal(game.homeworldTupileState!.cleanups.filter(row => row.status === 'pending').length, 2);
const cleanup = semutaTupileCleanupStep(game);
const selected = cleanup.action.cards as string[];
assert.equal(selected.length, 1, 'Each ordinary granted-five holder owes its own original one-card cleanup');
assert.deepEqual(semutaTupileCards(game), cards);
assert.ok(semutaTupileForceTotals(game).every(row => row.total === 20));
return { game, setup, occupied, beforeDeparture, owner: cleanup.actor, holder: 'r',
occupier: 'g', ally: 'a', event: cleanup.action.event as string, action: cleanup.action,
selected, semuta, sourceActions, actions, cards };
}
+200
View File
@@ -0,0 +1,200 @@
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 { biddingEndDiscardChoice } from '../game/bidding-end-options';
import { homeworldCardEconomyPopulation } from '../game/homeworld-card-economy';
import {
createSemutaKaitainFixture, reactSemutaKaitain, type SemutaKaitainFixture,
} from './fixture-semuta-kaitain';
import { moveSemutaEmperorCard, semutaEmperorPlayer as semutaKaitainPlayer } from './fixture-semuta-emperor-share';
import { biddingPhysicalIds as semutaKaitainStock } from './fixture-homeworld-occupied-bidding';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
const wallets = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice }));
const held = (game: Game, id: string) => semutaKaitainPlayer(game, id).hand.map(card => card.id);
function assertPaidSource(f: SemutaKaitainFixture): void {
const population = homeworldCardEconomyPopulation(f.game, f.owner, 'kaitain');
assert.equal(population?.side, 'high');
assert.deepEqual(population, homeworldCardEconomyPopulation(f.setup, f.owner, 'kaitain'),
'Kaitain uses its original setup reserve population, not manufactured forces');
assert.equal(semutaKaitainPlayer(f.game, f.owner).spice, 10);
const pending = f.paid.pendingTreacheryDiscard!;
assert.ok(pending);
assert.equal(pending.batch.cause, 'kaitain');
assert.deepEqual(pending.batch.entries, f.cards.map(card => ({ card, discardedBy: f.owner, publicFace: false })));
assert.equal(pending.reaction?.stage, 'offer');
assert.deepEqual(f.paid.deck, f.game.deck, 'Paid disposal neither auctions nor draws a card');
assert.deepEqual(f.paid.richeseCache, f.game.richeseCache);
assert.equal(f.paid.phase, 3);
assert.equal(f.paid.turn, 1);
assert.ok(!f.paid.response && !f.paid.pendingKarama && !f.paid.battle,
'Discarded Karama/Poison Tooth are not played');
assert.deepEqual(held(f.paid, f.owner), held(f.game, f.owner).filter(id => !f.cardIds.includes(id)));
assert.deepEqual(wallets(f.paid), wallets(f.game).map(p => p.id === f.owner
? { ...p, spice: p.spice - 2 * f.cardIds.length } : p));
assert.deepEqual(semutaKaitainStock(f.paid), f.stock);
}
function assertResumed(f: SemutaKaitainFixture, game: Game, selected?: string): void {
assert.equal(game.resolvedTreacheryDiscardSequence, f.paid.pendingTreacheryDiscard!.sequence);
assert.equal(game.turn, 1);
assert.equal(game.phase, 3);
assert.deepEqual(game.deck, f.game.deck);
assert.deepEqual(game.richeseCache, f.game.richeseCache);
assert.deepEqual(wallets(game), wallets(f.paid), 'Semuta never refunds or repeats the printed debit');
assert.deepEqual(held(game, f.owner), held(f.paid, f.owner));
assert.deepEqual(held(game, f.holder), selected
? [...held(f.game, f.holder).filter(id => id !== f.semuta), selected]
: held(f.game, f.holder));
for (const id of f.cardIds) assert.equal(game.discard.filter(card => card.id === id).length, Number(id !== selected));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(!!selected));
assert.deepEqual(semutaKaitainStock(game), f.stock);
}
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
for (const claim of [false, true]) void test(`${mode} original high Kaitain singleton ${claim ? 'claim' : 'pass'} keeps its paid owner finish`, () => {
const f = createSemutaKaitainFixture({ advanced, count: 1, choamReady: true });
assertPaidSource(f);
const event = f.paid.pendingTreacheryDiscard!.batch.event;
assert.equal(viewGame(f.paid, f.holder).semutaReaction?.canCommit, true);
assert.deepEqual(viewGame(f.paid, f.holder).semutaReaction?.candidates, []);
assert.throws(() => applyAction(f.paid, f.owner, { type: 'biddingEnd', event: f.event, mode: 'ready' }));
assert.throws(() => applyAction(f.paid, f.holder, { type: 'semutaSelect', event, card: f.cardIds[0] }));
const resumed = reactSemutaKaitain(f.paid, f, claim);
assertResumed(f, resumed, claim ? f.cardIds[0] : undefined);
assert.equal(resumed.biddingEnd!.ready.includes(f.owner), false);
assert.throws(() => applyAction(resumed, f.holder, { type: 'biddingEnd', event: f.event, mode: 'ready' }));
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
const emperorFinished = applyAction(resumed, f.owner, { type: 'biddingEnd', event: f.event, mode: 'ready' });
assert.equal(emperorFinished.phase, 3, 'The paid action requires CHOAM to acknowledge again');
const finished = applyAction(emperorFinished, f.choam, { type: 'biddingEnd', event: f.event, mode: 'ready' });
assert.equal(finished.phase, 4);
assert.deepEqual(finished.deck, f.game.deck);
assert.deepEqual(wallets(finished), wallets(f.paid));
});
for (const selectedIndex of [0, 1]) void test(`${mode} simultaneous private Kaitain group allows actual post-commit choice ${selectedIndex + 1} at full four-card capacity`, () => {
const f = createSemutaKaitainFixture({ advanced, count: 2, fullHolder: true });
assertPaidSource(f);
assert.equal(held(f.game, f.holder).length, 4);
const event = f.paid.pendingTreacheryDiscard!.batch.event;
const offered = viewGame(f.paid, f.holder).semutaReaction!;
assert.equal(offered.canCommit, true, 'Exchanging Semuta needs no fifth slot');
assert.deepEqual(offered.candidates, []);
assert.equal(viewGame(f.paid, f.owner).biddingEnd?.canAct, false);
const committed = applyAction(f.paid, f.holder, { type: 'semutaCommit', event });
assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select');
assert.deepEqual(viewGame(committed, f.holder).semutaReaction?.candidates, f.cards);
assert.deepEqual(viewGame(committed, f.owner).semutaReaction?.candidates, []);
assert.deepEqual(held(committed, f.holder), held(f.game, f.holder), 'Commit alone has not chosen a physical card');
assert.deepEqual(wallets(committed), wallets(f.paid));
assert.throws(() => applyAction(committed, f.owner, { type: 'semutaSelect', event, card: f.cardIds[0] }));
assert.throws(() => applyAction(committed, f.holder, { type: 'semutaSelect', event, card: f.semuta }));
assert.throws(() => applyAction(committed, f.holder, { type: 'semutaPass', event }));
const selected = f.cardIds[selectedIndex];
const resumed = applyAction(committed, f.holder, { type: 'semutaSelect', event, card: selected });
assertResumed(f, resumed, selected);
assert.equal(held(resumed, f.holder).length, 4);
assert.equal(viewGame(resumed, f.owner).biddingEnd?.canAct, true);
// CHOAM shares this same native window; neither owner's finish substitutes for the other.
const emperorReady = applyAction(resumed, f.owner, { type: 'biddingEnd', event: f.event, mode: 'ready' });
assert.equal(emperorReady.phase, 3);
assert.equal(viewGame(emperorReady, f.choam).biddingEnd?.canAct, true);
const finished = applyAction(emperorReady, f.choam, { type: 'biddingEnd', event: f.event, mode: 'ready' });
assert.equal(finished.phase, 4);
assert.deepEqual(finished.deck, f.game.deck);
assert.deepEqual(wallets(finished), wallets(f.paid));
});
void test(`${mode} a new paid Kaitain action requires both original owners to acknowledge again`, () => {
const f = createSemutaKaitainFixture({ advanced, count: 2, ownerReady: true });
assertPaidSource(f);
const resumed = reactSemutaKaitain(f.paid, f, false);
assertResumed(f, resumed);
assert.equal(resumed.choamMarket?.owner, f.choam);
assert.equal(resumed.choamMarket?.resume, 'phase');
assert.equal(viewGame(resumed, f.choam).biddingEnd?.canAct, true);
const choamFinished = applyAction(resumed, f.choam, { type: 'biddingEnd', event: f.event, mode: 'ready' });
assert.equal(choamFinished.phase, 3, 'Earlier Emperor readiness cannot finish a changed closing window');
const finished = applyAction(choamFinished, f.owner, { type: 'biddingEnd', event: f.event, mode: 'ready' });
assert.equal(finished.phase, 4);
assert.deepEqual(wallets(finished), wallets(f.paid));
});
void test(`${mode} original CHOAM duplicate sale coexists with the paid Kaitain owner finish`, () => {
const f = createSemutaKaitainFixture({ advanced, count: 1 });
let game = reactSemutaKaitain(f.paid, f, true);
const spice = semutaKaitainPlayer(game, f.choam).spice;
// Explicit later conserved position: two original Shields enter CHOAM's hand,
// not an auction purchase, played card or replacement stock.
while (semutaKaitainPlayer(game, f.choam).hand.filter(card => card.name === 'Shield').length < 2) {
const card = [...game.deck, ...game.discard,
...game.players.filter(p => p.id !== f.choam && p.id !== f.owner).flatMap(p => p.hand)]
.find(card => card.name === 'Shield');
assert.ok(card);
moveSemutaEmperorCard(game, card.id, f.choam);
}
const deck = structuredClone(game.deck);
const sale = viewGame(game, f.choam).choamMarket?.sales?.find(sale => sale.price === 3);
assert.ok(sale);
game = applyAction(game, f.choam, { type: 'biddingEnd', event: f.event,
mode: 'sell', card: sale.card, witness: sale.witness });
assert.equal(game.response?.kind, 'choamSale');
assert.throws(() => applyAction(game, f.owner, { type: 'biddingEnd', event: f.event, mode: 'ready' }));
while (game.response?.kind === 'choamSale') {
const next = nextRicheseSkillsTechNativeStep(game);
game = applyAction(game, next.actor, next.action);
}
assert.equal(semutaKaitainPlayer(game, f.choam).spice, spice + 3, 'Original exact-duplicate sale price');
assert.equal(semutaKaitainPlayer(game, f.owner).spice, 8, 'Independent Kaitain debit remains paid exactly once');
assert.ok(game.discard.some(card => card.id === sale.card));
assert.ok(held(game, f.choam).includes(sale.witness!));
assert.equal(game.biddingEnd!.event, f.event);
assert.equal(game.phase, 3);
assert.deepEqual(semutaKaitainStock(game), f.stock);
game = applyAction(game, f.choam, { type: 'biddingEnd', event: f.event, mode: 'ready' });
assert.equal(game.phase, 3);
game = applyAction(game, f.owner, { type: 'biddingEnd', event: f.event, mode: 'ready' });
assert.equal(game.phase, 4);
assert.deepEqual(game.deck, deck);
});
void test(`${mode} native closing controls reject nonowner, stale and duplicate paid selections`, () => {
const f = createSemutaKaitainFixture({ advanced });
assert.equal(biddingEndDiscardChoice(viewGame(f.game, f.owner), f.cardIds).cost, 4);
assert.equal(biddingEndDiscardChoice(viewGame(f.game, f.holder), f.cardIds).action, null);
assert.throws(() => applyAction(f.game, f.holder, f.action));
assert.throws(() => applyAction(f.game, f.choam, f.action));
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, event: `${f.event}:stale` }));
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, cards: [f.cardIds[0], f.cardIds[0]] }));
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, cards: [f.semuta] }));
const resumed = reactSemutaKaitain(f.paid, f, false);
assert.throws(() => applyAction(resumed, f.owner, f.action), 'Original selected cards cannot be charged again');
assert.deepEqual(wallets(resumed), wallets(f.paid));
});
}
void test('every minimal legal Semuta bot resolves native paid Kaitain without replaying the debit', () => {
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = createSemutaKaitainFixture({ count: 2, fullHolder: true });
let game = f.paid;
const own = viewGame(game, f.holder);
own.players.find(p => p.id === f.holder)!.bot = difficulty;
const commit = botActions(own)[0];
assert.ok(commit);
assert.equal(commit.type, 'semutaCommit');
game = applyAction(game, f.holder, commit);
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'select');
const selectionView = viewGame(game, f.holder);
selectionView.players.find(p => p.id === f.holder)!.bot = difficulty;
const select = botActions(selectionView)[0];
assert.ok(select);
assert.equal(select.type, 'semutaSelect');
assert.ok(f.cardIds.includes(select.card as string));
game = applyAction(game, f.holder, select);
assertResumed(f, game, select.card as string);
}
});
+163
View File
@@ -0,0 +1,163 @@
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 { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import {
createSemutaTupileFixture, reactSemutaTupile, semutaTupileCards,
semutaTupileCleanupStep, semutaTupileForceTotals,
type SemutaTupileFixture,
} from './fixture-semuta-tupile';
import { semutaEmperorPlayer as semutaTupilePlayer } from './fixture-semuta-emperor-share';
function assertOriginalDeparture(f: SemutaTupileFixture) {
for (const id of [f.occupier, f.ally]) {
const occupied = semutaTupilePlayer(f.beforeDeparture, id);
assert.equal(occupied.hand.length, 5);
assert.equal(handLimit(occupied), 5);
assert.equal(handLimit(semutaTupilePlayer(f.game, id)), 4);
}
assert.equal(semutaTupilePlayer(f.occupied, f.occupier).ally, f.ally);
assert.equal(semutaTupilePlayer(f.occupied, f.ally).ally, f.occupier);
const lease = f.occupied.homeworldTupileState!.leases[f.occupied.homeworldTupileState!.current!];
assert.deepEqual(lease.holders, [f.occupier, f.ally]);
const source = f.occupied.homeworldOccupationHistory!.sources.find(row => row.event === lease.source.qualification)!;
assert.ok(source);
assert.notEqual(source.cause, 'setup');
assert.equal(f.game.homeworldTupileState!.current, null);
assert.deepEqual(f.game.homeworldTupileState!.leases[lease.id].lost, [f.occupier, f.ally]);
assert.equal(semutaTupilePlayer(f.game, f.occupier).spice,
semutaTupilePlayer(f.beforeDeparture, f.occupier).spice - 1);
assert.equal(semutaTupilePlayer(f.game, f.occupier).reserves,
semutaTupilePlayer(f.beforeDeparture, f.occupier).reserves + 1);
assert.equal(semutaTupilePlayer(f.game, f.occupier).shipped, true);
assert.equal(f.game.active, f.occupier);
assert.equal(f.game.phase, 5);
assert.deepEqual(semutaTupileCards(f.game), f.cards);
assert.ok(semutaTupileForceTotals(f.game).every(row => row.total === 20));
}
function openOriginalCleanup(state: Game, actor: string, action: Parameters<typeof applyAction>[2]): Game {
const selected = action.cards as string[], event = action.event as string;
const cards = selected.map(id => semutaTupilePlayer(state, actor).hand.find(card => card.id === id)!);
const pending = applyAction(state, actor, action);
const batch = pending.pendingTreacheryDiscard!;
assert.ok(batch, 'The selected original private cleanup cost must pause before its source suffix');
assert.deepEqual(batch.batch.entries, cards.map(card => ({ card, discardedBy: actor, publicFace: false })));
assert.equal(semutaTupilePlayer(pending, actor).hand.length, 4);
assert.equal(pending.homeworldTupileState!.cleanups.find(row => row.event === event)!.status, 'pending');
const holder = state.players.find(player => player.hand.some(card => card.id === SEMUTA_DRUG_ID))?.id;
for (const seat of state.players) {
const offer = viewGame(pending, seat.id).semutaReaction!;
assert.deepEqual(offer.candidates, [], 'No private cleanup faces before commitment');
assert.equal(offer.canCommit, seat.id === holder);
assert.throws(() => applyAction(pending, seat.id, {
type: 'semutaSelect', event: batch.batch.event, card: selected[0],
}));
}
assert.throws(() => applyAction(pending, actor, action));
assert.throws(() => applyAction(pending, state.active!, { type: 'endMovement' }));
return pending;
}
for (const claimAt of [null, 'first', 'second'] as const) void test(
`Advanced real Tupile departure: ${claimAt ? `claim ${claimAt}` : 'pass both'} original old-holder costs before native Movement`, () => {
const f = createSemutaTupileFixture();
assertOriginalDeparture(f);
const first = openOriginalCleanup(f.game, f.owner, f.action);
const firstEvent = first.pendingTreacheryDiscard!.batch.event;
const firstCard = f.selected[0];
assert.equal(viewGame(first, f.holder).semutaReaction!.canCommit, true);
assert.throws(() => applyAction(first, f.owner, { type: 'semutaCommit', event: firstEvent }));
let game = reactSemutaTupile(first, f.holder, claimAt === 'first');
assert.equal(game.homeworldTupileState!.cleanups.find(row => row.event === f.event)!.status, 'completed');
assert.equal(semutaTupilePlayer(game, f.holder).hand.some(card => card.id === firstCard), claimAt === 'first');
assert.equal(semutaTupilePlayer(game, f.owner).hand.length, 4);
assert.equal(handLimit(semutaTupilePlayer(game, f.owner)), 4);
assert.equal(game.decision?.kind, 'homeworldTupileCleanup');
const second = semutaTupileCleanupStep(game);
assert.notEqual(second.actor, f.owner);
assert.notEqual(second.action.event, f.event);
assert.equal(semutaTupilePlayer(game, second.actor).hand.length, 5, 'Separate old holder has not paid yet');
assert.throws(() => applyAction(game, f.owner, second.action));
assert.throws(() => applyAction(game, f.owner, f.action));
const secondCard = (second.action.cards as string[])[0];
const secondBefore = game;
if (claimAt === 'first') {
game = applyAction(game, second.actor, second.action);
// Semuta was spent on the first cost; the second ORIGINAL cleanup still
// discards its own selected card, without creating a fake pair offer.
if (game.pendingTreacheryDiscard) game = reactSemutaTupile(game, f.holder, false);
} else {
const secondPending = openOriginalCleanup(game, second.actor, second.action);
assert.notEqual(secondPending.pendingTreacheryDiscard!.batch.event, firstEvent);
assert.equal(secondPending.pendingTreacheryDiscard!.batch.entries.length, 1);
assert.equal(secondPending.pendingTreacheryDiscard!.batch.entries[0].card.id, secondCard);
game = reactSemutaTupile(secondPending, f.holder, claimAt === 'second');
}
assert.equal(semutaTupilePlayer(game, second.actor).hand.length, 4);
assert.equal(semutaTupilePlayer(game, f.holder).hand.length, 4, 'Full Richese hand exchanges Semuta rather than gaining capacity');
assert.equal(semutaTupilePlayer(game, f.holder).hand.some(card => card.id === secondCard), claimAt === 'second');
for (const row of game.homeworldTupileState!.cleanups) assert.equal(row.status, 'completed');
assert.equal(game.phase, 5);
assert.equal(game.active, f.occupier);
assert.deepEqual(game.movementRemaining, f.game.movementRemaining);
assert.deepEqual(game.players.map(player => player.spice), f.game.players.map(player => player.spice));
assert.deepEqual(semutaTupileForceTotals(game), semutaTupileForceTotals(f.game));
assert.deepEqual(semutaTupileCards(game), f.cards);
assert.equal(game.discard.filter(card => card.id === firstCard).length, Number(claimAt !== 'first'));
assert.equal(game.discard.filter(card => card.id === secondCard).length, Number(claimAt !== 'second'));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimAt !== null));
assert.throws(() => applyAction(game, second.actor, second.action));
assert.throws(() => applyAction(game, f.holder, { type: 'semutaCommit', event: firstEvent }));
// The actual unused Movement suffix remains legal exactly once: departure
// already paid and shipped, and neither cleanup can replay it.
const suffix = applyAction(game, f.occupier, { type: 'endMovement' });
assert.ok(!suffix.movementRemaining?.includes(f.occupier));
assert.notEqual(suffix.active, f.occupier);
assert.throws(() => applyAction(suffix, f.occupier, { type: 'endMovement' }));
assert.equal(semutaTupilePlayer(suffix, f.occupier).spice, semutaTupilePlayer(secondBefore, f.occupier).spice);
assert.deepEqual(semutaTupileCards(suffix), f.cards);
},
);
void test('neutral Tupile passes keep the next old holder closed until the last seat acknowledges', () => {
const f = createSemutaTupileFixture();
let game = openOriginalCleanup(f.game, f.owner, f.action);
const event = game.pendingTreacheryDiscard!.batch.event;
for (const seat of game.players.filter(player => player.id !== f.holder)) {
game = applyAction(game, seat.id, { type: 'semutaPass', event });
assert.ok(game.pendingTreacheryDiscard);
assert.equal(game.homeworldTupileState!.cleanups.find(row => row.event === f.event)!.status, 'pending');
assert.throws(() => applyAction(game, seat.id, { type: 'semutaPass', event }));
}
game = applyAction(game, f.holder, { type: 'semutaPass', event });
assert.equal(game.decision?.kind, 'homeworldTupileCleanup');
assert.notEqual(game.decision!.player, f.owner);
});
void test('minimal legal bot settles both native Tupile cleanup owners and the full-hand Semuta claim', () => {
const f = createSemutaTupileFixture();
let game = f.game;
for (let n = 0; (game.decision?.kind === 'homeworldTupileCleanup' || game.pendingTreacheryDiscard) && n < 24; n++) {
const pending = game.pendingTreacheryDiscard;
const actor = pending?.reaction?.stage === 'offer'
? f.holder
: game.decision!.player;
const view = viewGame(game, actor);
view.players.find(player => player.id === actor)!.bot = 'Easy';
const action = botActions(view)[0];
assert.ok(action, 'Actual original cleanup / Semuta owner has a legal bot action');
game = applyAction(game, actor, action);
if (game.pendingTreacheryDiscard && !semutaTupilePlayer(game, f.holder).hand.some(card => card.id === f.semuta))
game = reactSemutaTupile(game, f.holder, false);
}
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.pendingHomeworldTupileCleanup, null);
assert.equal(game.decision, null);
assert.equal(game.active, f.occupier);
assert.ok(game.homeworldTupileState!.cleanups.every(row => row.status === 'completed'));
assert.equal(semutaTupilePlayer(game, f.holder).hand.length, 4);
assert.deepEqual(semutaTupileCards(game), f.cards);
});