Compose native Homeworld Kaitain and Tupile discards with Semuta
This commit is contained in:
1 parent
d718e380b7
commit
a4f1cfc650
14 files changed
+955
-18
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user