Compose native Homeworld economic Karama costs with Semuta

This commit is contained in:
Quintine committed 2026-10-13 05:50:50 +11:00
1 parent 8bc7f7aca2
commit 8c5b6c8b55
12 files changed
+875 -2

No files matched your search

@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { quoteSpiceCollection, type CollectionQuote } from '../game/board-resolution-quote';
import { moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
export type SemutaHomeworldEconomicKaramaScope = 'charity' | 'inflation';
export type SemutaHomeworldEconomicKaramaFixture = {
scope: SemutaHomeworldEconomicKaramaScope;
setup: Game; beforeDeclaration: Game; originalResponse: Game; game: Game;
owner: string; canceller: string; holder: string; target: string;
card: string; action: Action; declaration: Action | null; event: string | null;
actions: RicheseSkillsTechStep[]; staging: string[];
afterCancel?: Game; targetSuffix?: Game; beforeNextIncome?: Game;
beforeCollection?: Game; collectionQuote?: CollectionQuote;
};
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
/** Spending-free native progression, preserving every original population and
* wallet. End-of-Bidding readiness is not the paid Kaitain disposal control. */
function advance(state: Game, until: (g: Game) => boolean, actions?: RicheseSkillsTechStep[]): Game {
let game = state;
for (let n = 0; !until(game) && n < 1600; n++) {
assert.notEqual(game.status, 'finished');
const end = game.biddingEnd;
const actor = end && clean(game) ? end.owners.find(id => !end.ready.includes(id)) : undefined;
if (actor) game = applySemutaHomeworldChoamStep(game, actor,
{ type: 'biddingEnd', event: end!.event, mode: 'ready' }, actions);
else if (game.phase === 5 && clean(game))
game = applySemutaHomeworldChoamStep(game, game.active!, { type: 'endMovement' }, actions);
else game = advanceSemutaHomeworldChoam(game,
g => until(g) || (!!g.biddingEnd && clean(g)) || (g.phase === 5 && clean(g)), actions);
}
assert.ok(until(game), 'Original native controls must reach the economic boundary');
return game;
}
/** Original completed R/C/E/A Homeworld setup, then first native phase-two
* income or first Mentat placement. Only conserved cards/unplayed Spice order
* are staged; no phase, response, attempt stamp, resource or receipt is set. */
export function createSemutaHomeworldEconomicKaramaFixture(
scope: SemutaHomeworldEconomicKaramaScope, advanced = false, fullHolder = false,
): SemutaHomeworldEconomicKaramaFixture {
const native = createSemutaHomeworldChoamSetup(advanced);
let game = native.game;
const setup = structuredClone(game), actions = native.actions;
const staging = ['Conserved original physical Semuta to Richese after completed native setup.'];
const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 4);
assert.equal(blows.length, 4);
for (const [position, card] of blows.entries())
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
staging.push('Order four original unplayed non-worm Spice Cards for the first and following native turns.');
const card = holdSemutaHomeworldChoamCard(game, native.canceller, 'Karama');
staging.push('Conserved printed Karama to original Emperor; no Worthless substitution.');
if (fullHolder) {
const holder = semutaEmperorPlayer(game, native.holder);
while (holder.hand.length < handLimit(holder)) {
assert.ok(game.deck[0]);
moveSemutaEmperorCard(game, game.deck[0].id, holder.id);
}
staging.push('Conserved undealt cards fill original Richese hand to four.');
}
let beforeCollection: Game | undefined, collectionQuote: CollectionQuote | undefined;
if (scope === 'inflation') {
// Empty Battle may auto-skip phase six when no market is entitled. Capture
// the actual last spending-free Movement control, before any Collection.
game = advance(game, g => g.phase === 5 && clean(g) && g.movementRemaining?.length === 1, actions);
beforeCollection = structuredClone(game);
collectionQuote = quoteSpiceCollection(game);
}
game = advance(game, g => scope === 'charity'
? g.response?.kind === 'choamCharity'
: g.phase === 8 && clean(g), actions);
assert.equal(game.turn, setup.turn);
const beforeDeclaration = structuredClone(game);
const declaration: Action | null = scope === 'inflation' ? { type: 'choamInflation', side: 'double' } : null;
if (declaration) game = applySemutaHomeworldChoamStep(game, native.owner, declaration, actions);
assert.equal(game.response?.kind, scope === 'charity' ? 'choamCharity' : 'choamInflation');
assert.ok(viewGame(game, native.canceller).responseControls?.cancelCards.includes(card));
return { scope, setup, beforeDeclaration, originalResponse: structuredClone(game), game,
owner: native.owner, canceller: native.canceller, holder: native.holder, target: native.target,
card, action: { type: 'card', mode: 'cancel', card }, declaration,
event: null, actions, staging, beforeCollection, collectionQuote };
}
/** The returned game pauses at the printed cost, before accepted denial. */
export function disposeSemutaHomeworldEconomicKarama(
f: SemutaHomeworldEconomicKaramaFixture, state = f.game, actions?: RicheseSkillsTechStep[],
): Game {
const game = applySemutaHomeworldChoamStep(state, f.canceller, f.action, actions);
f.event = game.pendingTreacheryDiscard?.batch.event ?? null;
if (!game.pendingTreacheryDiscard) f.afterCancel = structuredClone(game);
return game;
}
export function reactSemutaHomeworldEconomicKarama(state: Game, holder: string,
claim: boolean, actions?: RicheseSkillsTechStep[]): Game {
const pending = state.pendingTreacheryDiscard, reaction = pending?.reaction;
assert.ok(pending && reaction?.stage === 'offer');
const event = pending.batch.event;
if (claim) return applySemutaHomeworldChoamStep(state, holder, { type: 'semutaCommit', event }, actions);
let game = state;
for (const seat of state.players.filter(p => !reaction.passed.includes(p.id)))
game = applySemutaHomeworldChoamStep(game, seat.id, { type: 'semutaPass', event }, actions);
return game;
}
/** Only original queues: charity ends into native Bidding; canceled Inflation
* closes the same CHOAM Mentat and reaches ordinary next-turn opening income. */
export function finishSemutaHomeworldEconomicKarama(state: Game,
f: SemutaHomeworldEconomicKaramaFixture, actions?: RicheseSkillsTechStep[]): Game {
assert.ok(!state.pendingTreacheryDiscard &&
state.response?.kind !== f.originalResponse.response?.kind,
'The original printed Karama cost and denied response must already be retired');
// A legal next actor may have declared a different native power, such as
// Gamont. Settle that real response before following the original close.
const settled = advance(state, g => !g.pendingTreacheryDiscard && !g.response, actions);
f.afterCancel = structuredClone(settled);
if (f.scope === 'charity') {
const game = advance(settled, g => g.phase === 3 && clean(g), actions);
f.targetSuffix = structuredClone(game);
return game;
}
let game = advance(settled, g => g.decision?.kind === 'choamMentat', actions);
assert.equal(game.decision!.player, f.owner);
game = applySemutaHomeworldChoamStep(game, f.owner, { type: 'decision', done: true }, actions);
game = advance(game, g => g.turn === state.turn + 1 && g.phase === 1 && clean(g), actions);
f.beforeNextIncome = structuredClone(game);
game = advance(game, g => g.phase === 2 && clean(g), actions);
f.targetSuffix = structuredClone(game);
return game;
}
/** Conserved equal-size substitution changes only private Semuta ownership. */
export function withoutSemutaHomeworldEconomicHolder(state: Game, holder: string): Game {
const game = structuredClone(state), hand = semutaEmperorPlayer(game, holder).hand;
const at = hand.findIndex(card => card.id === SEMUTA_DRUG_ID);
const replacement = game.deck.findIndex(card => card.effect !== 'karama');
assert.ok(at >= 0 && replacement >= 0);
const semuta = hand[at];
hand[at] = game.deck.splice(replacement, 1)[0];
game.deck.push(semuta);
return game;
}
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import { handLimit, type Action, type Game } from '../game/engine';
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
import { semutaEmperorCardId, moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import { createSemutaHomeworldChoamSetup, applySemutaHomeworldChoamStep, advanceSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
export type SemutaHomeworldKaramaAuctionScope = 'purchase' | 'payment';
export type SemutaHomeworldKaramaAuctionFixture = {
scope: SemutaHomeworldKaramaAuctionScope;
setup: Game;
beforeLot: Game;
game: Game;
gameBeforeCost: Game;
actor: string;
owner: string;
holder: string;
card: string;
action: Action;
event: string;
reservedLot: string;
nativeBid: number;
payers: { player: string; spice: number }[];
custody: Game['homeworlds'];
actions: RicheseSkillsTechStep[];
staging: string[];
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** The original R/C/E/A setup and real first Bidding. Only conserved physical
* cards and two still-unplayed Spice Cards are positioned; no economy or clock
* is assigned. Richese declares last before the native normal pool is drawn. */
export function createSemutaHomeworldKaramaAuctionFixture(
scope: SemutaHomeworldKaramaAuctionScope, advanced = false, fullHolder = false,
): SemutaHomeworldKaramaAuctionFixture {
const native = createSemutaHomeworldChoamSetup(advanced);
const setup = structuredClone(native.game), actions = native.actions, staging: string[] = [];
let game = native.game;
const actor = native.target, holder = native.holder;
const physical = biddingPhysicalIds(game);
const held = semutaEmperorPlayer(game, actor).hand.find(card => card.effect === 'karama');
const card = held?.id ?? moveSemutaEmperorCard(game,
semutaEmperorCardId(game, candidate => candidate.effect === 'karama'), actor);
staging.push(`Conserved printed Karama ${card} in original Atreides ${actor} custody before Bidding.`);
assert.ok(semutaEmperorPlayer(game, actor).hand.length < handLimit(semutaEmperorPlayer(game, actor)));
assert.deepEqual(biddingPhysicalIds(game), physical);
for (let position = 0; position < 2; position++) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card);
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
staging.push('Position two original unplayed non-worm Spice Cards, retaining the original deck.');
game = advanceSemutaHomeworldChoam(game, state => state.decision?.kind === 'richeseDeclaration', actions);
game = applySemutaHomeworldChoamStep(game, game.decision!.player,
{ type: 'decision', event: game.richeseBidding!.event, position: 'last' }, actions);
game = advanceSemutaHomeworldChoam(game, state => !!state.auction && clean(state), actions);
const beforeLot = structuredClone(game);
assert.equal(game.auction!.peekKnown, true, 'The actual native Atreides inspection has completed');
assert.equal(game.richeseBidding!.position, 'last');
const reservedLot = game.auction!.cards[game.auction!.index].id;
if (fullHolder) {
const original = biddingPhysicalIds(game), receiver = semutaEmperorPlayer(game, holder);
while (receiver.hand.length < handLimit(receiver)) {
const incoming = game.deck.shift();
assert.ok(incoming, 'Original undealt stock must supply holder capacity');
receiver.hand.push(incoming);
staging.push(`After ordinary lot quantity committed, conserve undealt ${incoming.id} into ${holder} hand.`);
}
assert.equal(receiver.hand.length, 4);
assert.deepEqual(biddingPhysicalIds(game), original);
}
if (scope === 'payment') {
for (let n = 0; game.auction!.active !== actor && n < game.players.length; n++)
game = applySemutaHomeworldChoamStep(game, game.auction!.active, { type: 'passBid' }, actions);
assert.equal(game.auction!.active, actor);
const amount = semutaEmperorPlayer(game, actor).spice + 1;
game = applySemutaHomeworldChoamStep(game, actor, { type: 'bid', amount }, actions);
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < game.players.length + 2; n++)
game = applySemutaHomeworldChoamStep(game, game.auction!.active, { type: 'passBid' }, actions);
assert.equal(game.decision?.kind, 'auctionPayment');
assert.equal(game.decision!.player, actor);
assert.equal(game.auction!.bidder, actor);
assert.ok(game.auction!.bid > semutaEmperorPlayer(game, actor).spice);
}
assert.equal(game.auction!.cards[game.auction!.index].id, reservedLot);
const action: Action = scope === 'purchase'
? { type: 'card', mode: 'purchase', card }
: { type: 'decision', karama: true, card };
return { scope, setup, beforeLot, game, gameBeforeCost: structuredClone(game), actor, owner: actor,
holder, card, action, event: game.richeseBidding!.event, reservedLot, nativeBid: game.auction!.bid,
payers: game.players.map(player => ({ player: player.id, spice: player.spice })),
custody: structuredClone(game.homeworlds), actions, staging };
}
export function disposeSemutaHomeworldKaramaAuction(
fixture: SemutaHomeworldKaramaAuctionFixture, state = fixture.game, actions?: RicheseSkillsTechStep[],
): Game {
return applySemutaHomeworldChoamStep(state, fixture.actor, fixture.action, actions);
}
/** Keep every original remaining normal/cache lot and invoice. Finish with
* native no-spend human choices, including the actual Bidding-end ready gate. */
export function finishSemutaHomeworldKaramaAuction(
state: Game, fixture: SemutaHomeworldKaramaAuctionFixture, actions?: RicheseSkillsTechStep[],
): Game {
let game = state;
for (let n = 0; !(game.phase === 5 && clean(game)) && n < 1600; n++) {
assert.equal(game.turn, fixture.game.turn);
const end = game.biddingEnd;
const actor = end && clean(game) ? end.owners.find(id => !end.ready.includes(id)) : undefined;
if (actor) game = applySemutaHomeworldChoamStep(game, actor,
{ type: 'biddingEnd', event: end!.event, mode: 'ready' }, actions);
else if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
assert.ok(reaction?.stage === 'offer');
const seat = game.players.find(player => !reaction.passed.includes(player.id))!;
game = applySemutaHomeworldChoamStep(game, seat.id,
{ type: 'semutaPass', event: pending.batch.event }, actions);
} else if (game.decision?.kind === 'choamMarket')
game = applySemutaHomeworldChoamStep(game, game.decision.player, { type: 'decision', done: true }, actions);
else if (game.decision?.kind === 'caladanReinforcement')
game = applySemutaHomeworldChoamStep(game, game.decision.player,
{ type: 'decision', event: game.decision.event, decline: true }, actions);
else {
const next = nextRicheseSkillsTechNativeStep(game);
game = applySemutaHomeworldChoamStep(game, next.actor, next.action, actions);
}
}
assert.equal(game.phase, 5);
assert.ok(clean(game));
return game;
}
@@ -0,0 +1,241 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
import { semutaHomeworldChoamMovementResources as resources,
semutaHomeworldChoamMovementStock as stock } from './fixture-semuta-homeworld-choam-movement';
import {
createSemutaHomeworldEconomicKaramaFixture, disposeSemutaHomeworldEconomicKarama,
finishSemutaHomeworldEconomicKarama, reactSemutaHomeworldEconomicKarama,
withoutSemutaHomeworldEconomicHolder, type SemutaHomeworldEconomicKaramaFixture,
} from './fixture-semuta-homeworld-economic-karama';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'A rejected stale/cross-actor action has no effect');
}
function assertNativeEconomy(f: SemutaHomeworldEconomicKaramaFixture) {
assert.equal(f.game.turn, 1);
assert.equal(player(f.game, f.owner).faction, 'choam');
assert.equal(player(f.game, f.canceller).faction, 'emperor');
assert.equal(player(f.game, f.holder).faction, 'richese');
assert.equal(player(f.setup, f.owner).spice, 2);
assert.equal(player(f.game, f.canceller).spice, 10);
assert.deepEqual(f.game.homeworlds?.custody, f.setup.homeworlds?.custody);
for (const seat of f.game.players) {
assert.equal(seat.reserves, player(f.setup, seat.id).reserves);
assert.equal(seat.tanks, player(f.setup, seat.id).tanks);
assert.deepEqual(seat.forces, player(f.setup, seat.id).forces);
assert.deepEqual(seat.elites, player(f.setup, seat.id).elites);
}
const ownerView = viewGame(f.game, f.owner);
assert.equal(ownerView.homeworlds!.worlds!.find(world => world.native === f.owner)!.population, 20,
'Original ordinary Tupile population is preserved without any shipment');
assert.equal(ownerView.charity.incomeHomeworld, 0, 'Original full Tupile has no low-population bonus');
assert.equal(ownerView.charity.multiplier, 1);
if (f.scope === 'charity') {
assert.equal(f.game.phase, 2);
assert.equal(player(f.game, f.owner).spice, 2, 'Native eight-spice CHOAM opening income is still unpaid');
assert.equal(ownerView.charity.incomePending, true);
} else {
assert.equal(f.game.phase, 8);
assert.equal(player(f.game, f.owner).spice, 10, 'First ordinary native income paid exactly eight');
assert.equal(f.game.inflationAttemptTurn, 1, 'Actual placement, not Karama spending, consumes the attempt');
assert.ok(f.beforeCollection && f.collectionQuote);
for (const receipt of f.collectionQuote.receipts)
assert.equal(player(f.game, receipt.player).spice, receipt.balance,
'Native Collection credits including Advanced strongholds precede Mentat');
assert.equal(player(f.beforeCollection, f.target).spice, 10);
assert.equal(player(f.game, f.target).spice, f.game.advanced ? 12 : 10);
}
}
function assertDenied(f: SemutaHomeworldEconomicKaramaFixture, done: Game, claimed: boolean) {
assert.ok(!done.pendingTreacheryDiscard && !done.response);
assert.equal(done.phase, f.game.phase);
assert.equal(done.turn, 1);
assert.deepEqual(resources(done), resources(f.game), 'Cost and denial never transfer/refund spice or move original custody');
assert.deepEqual(stock(done), stock(f.game));
assert.equal(player(done, f.canceller).hand.some(c => c.id === f.card), false);
assert.equal(player(done, f.holder).hand.some(c => c.id === f.card), claimed);
assert.equal(player(done, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claimed);
assert.equal(done.discard.filter(c => c.id === f.card).length, Number(!claimed));
assert.equal(done.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claimed));
for (const seat of done.players)
assert.equal(seat.hand.length, player(f.game, seat.id).hand.length - Number(seat.id === f.canceller));
if (f.scope === 'charity') {
assert.deepEqual(done.choamCharity, { turn: 1, canceled: true });
assert.equal(player(done, f.owner).spice, 2);
assert.equal(viewGame(done, f.owner).charity.incomeCanceled, true);
assert.equal(viewGame(done, f.owner).charity.payer, null, 'Canceled income leaves charity bank-paid');
} else {
assert.equal(done.inflationAttemptTurn, 1);
assert.ok(!done.inflation && !done.inflationUsed);
assert.equal(player(done, f.owner).spice, 10);
reject(done, f.owner, { type: 'choamInflation', side: 'double' });
reject(done, f.owner, { type: 'choamInflation', side: 'cancel' });
}
reject(done, f.canceller, f.action);
reject(done, f.canceller, { type: 'passResponse' });
if (claimed) reject(done, f.holder, f.action);
}
function assertSuffix(f: SemutaHomeworldEconomicKaramaFixture, done: Game) {
const suffix = finishSemutaHomeworldEconomicKarama(done, f);
assert.equal(suffix.status, 'playing');
const settled = f.afterCancel!;
assert.deepEqual(suffix.homeworlds?.custody, settled.homeworlds?.custody);
for (const seat of suffix.players) {
assert.deepEqual(seat.forces, player(settled, seat.id).forces);
assert.equal(seat.reserves, player(settled, seat.id).reserves);
assert.equal(seat.tanks, player(settled, seat.id).tanks);
}
if (f.scope === 'charity') {
assert.equal(suffix.phase, 3);
assert.equal(suffix.turn, 1);
assert.deepEqual(resources(suffix), resources(done), 'The completed opening denial cannot repay its eight spice');
assert.deepEqual(suffix.choamCharity, { turn: 1, canceled: true });
} else {
assert.equal(suffix.phase, 2);
assert.equal(suffix.turn, 2);
assert.ok(!suffix.inflation && !suffix.inflationUsed);
assert.equal(suffix.choamMentatPending, false);
assert.equal(viewGame(suffix, f.owner).charity.multiplier, 1);
assert.deepEqual(suffix.choamCharity, { turn: 2, canceled: false });
assert.ok(f.beforeNextIncome);
assert.equal(player(f.beforeNextIncome, f.owner).spice, 10);
assert.equal(player(suffix, f.owner).spice, 18, 'Original next-turn income remains ordinary eight, not sixteen/zero');
for (const seat of suffix.players.filter(p => p.id !== f.owner))
assert.equal(seat.spice, player(done, seat.id).spice);
reject(suffix, f.owner, { type: 'decision', done: true });
}
}
for (const advanced of [false, true]) for (const scope of ['charity', 'inflation'] as const) {
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld ${scope} printed Karama`;
for (const claimed of [false, true]) void test(`${label}: ${claimed ? 'claim' : 'pass'} resumes exactly one denial without native economic replay`, () => {
const f = createSemutaHomeworldEconomicKaramaFixture(scope, advanced);
assertNativeEconomy(f);
const pending = disposeSemutaHomeworldEconomicKarama(f);
const cost = pending.pendingTreacheryDiscard;
assert.ok(cost);
assert.equal(cost.continuation.kind, scope === 'charity' ? 'karamaCharityDiscard' : 'karamaInflationDiscard');
assert.equal(cost.batch.cause, scope === 'charity' ? 'karama:choamCharity' : 'karama:choamInflation');
assert.deepEqual(cost.batch.entries.map(e => ({ card: e.card.id, actor: e.discardedBy, face: e.publicFace })),
[{ card: f.card, actor: f.canceller, face: true }]);
assert.equal(pending.discard.filter(c => c.id === f.card).length, 1);
assert.equal(player(pending, f.canceller).hand.some(c => c.id === f.card), false);
assert.deepEqual(resources(pending), resources(f.game));
assert.deepEqual(pending.choamCharity, f.game.choamCharity, 'Printed denial has not happened during its cost offer');
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
reject(pending, f.canceller, f.action);
reject(pending, f.owner, { type: 'ready' });
reject(pending, f.canceller, { type: 'passResponse' });
reject(pending, f.target, { type: 'semutaCommit', event: cost.batch.event });
reject(pending, f.holder, { type: 'semutaCommit', event: `${cost.batch.event}:stale` });
const done = reactSemutaHomeworldEconomicKarama(JSON.parse(JSON.stringify(pending)), f.holder, claimed);
assertDenied(f, done, claimed);
reject(done, f.holder, { type: 'semutaCommit', event: cost.batch.event });
reject(done, f.target, { type: 'semutaPass', event: cost.batch.event });
assertSuffix(f, done);
});
void test(`${label}: full four-card holder exchanges one card; equal-size absent-Semuta substitution is private`, () => {
const f = createSemutaHomeworldEconomicKaramaFixture(scope, advanced, true);
const pending = disposeSemutaHomeworldEconomicKarama(f);
const neutral = disposeSemutaHomeworldEconomicKarama(f, withoutSemutaHomeworldEconomicHolder(f.game, f.holder));
assert.equal(player(f.game, f.holder).hand.length, 4);
assert.equal(handLimit(player(f.game, f.holder)), 4);
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
reject(neutral, f.holder, { type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event });
for (const seat of pending.players.filter(p => p.id !== f.holder)) {
assert.deepEqual(viewGame(pending, seat.id), viewGame(neutral, seat.id));
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
}
const done = reactSemutaHomeworldEconomicKarama(pending, f.holder, true);
assertDenied(f, done, true);
assert.equal(player(done, f.holder).hand.length, 4);
const passed = reactSemutaHomeworldEconomicKarama(neutral, f.holder, false);
assert.deepEqual(resources(passed), resources(done));
assert.equal(passed.discard.filter(c => c.id === f.card).length, 1);
assertSuffix(f, done);
});
void test(`${label}: non-BG Worthless, absent Ix Kull and wrong-seat cancellation remain excluded`, () => {
const f = createSemutaHomeworldEconomicKaramaFixture(scope, advanced);
const game = structuredClone(f.game);
const worthless = holdSemutaHomeworldChoamCard(game, f.canceller, 'Baliset');
const kull = 'ix-kull-wahad';
assert.equal(stock(game).includes(kull), false, 'Exact CHOAM profile has no printed Ix Kull to substitute');
assert.ok(!game.players.some(p => p.faction === 'beneGesserit'));
assert.ok(!viewGame(game, f.canceller).responseControls?.cancelCards.includes(worthless));
reject(game, f.canceller, { type: 'card', card: worthless, mode: 'cancel' });
reject(game, f.owner, { type: 'card', card: kull, mode: 'cancel' });
reject(game, f.holder, f.action);
reject(game, f.canceller, { type: 'choamInflation', side: 'double' });
reject(game, f.owner, { type: 'card', card: kull, mode: 'kull', target: f.canceller });
const pending = disposeSemutaHomeworldEconomicKarama(f, game);
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.card]);
assert.equal(player(pending, f.owner).hand.some(c => c.id === kull), false);
assert.equal(player(pending, f.canceller).hand.some(c => c.id === worthless), true);
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: ${label} uses actual Semuta and the original CHOAM next-actor controls`, () => {
const f = createSemutaHomeworldEconomicKaramaFixture(scope, advanced);
let game = disposeSemutaHomeworldEconomicKarama(f);
const view = viewGame(game, f.holder);
view.players.find(p => p.id === f.holder)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action && (action.type === 'semutaCommit' || action.type === 'semutaPass'));
game = applyAction(game, f.holder, action);
if (game.pendingTreacheryDiscard) game = reactSemutaHomeworldEconomicKarama(game, f.holder, false);
assertDenied(f, game, action.type === 'semutaCommit');
const nextView = viewGame(game, f.owner);
nextView.players.find(p => p.id === f.owner)!.bot = difficulty;
const next = botActions(nextView)[0];
assert.ok(next);
const after = applyAction(game, f.owner, next);
assert.equal(after.turn, 1);
assert.equal(player(after, f.owner).spice, player(game, f.owner).spice);
assertSuffix(f, after);
});
}
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'}: original Cancel-side Inflation denial also consumes its attempt without zeroing next charity`, () => {
const base = createSemutaHomeworldEconomicKaramaFixture('inflation', advanced);
const declaration: Action = { type: 'choamInflation', side: 'cancel' };
const game = applyAction(base.beforeDeclaration, base.owner, declaration);
const f = { ...base, game, originalResponse: structuredClone(game), declaration };
assert.equal(game.response?.intent, 'cancel');
const pending = disposeSemutaHomeworldEconomicKarama(f);
const done = reactSemutaHomeworldEconomicKarama(pending, f.holder, true);
assertDenied(f, done, true);
assertSuffix(f, done);
});
void test('Advanced Medium: a legal native Gamont follow-on preserves the canceled Inflation attempt and original close', () => {
const f = createSemutaHomeworldEconomicKaramaFixture('inflation', true);
const gamont = holdSemutaHomeworldChoamCard(f.game, f.owner, 'Trip to Gamont');
const done = reactSemutaHomeworldEconomicKarama(disposeSemutaHomeworldEconomicKarama(f), f.holder, true);
const own = viewGame(done, f.owner);
own.players.find(p => p.id === f.owner)!.bot = 'Medium';
const proposal = botActions(own)[0];
assert.ok(proposal && proposal.card === gamont);
const declared = applyAction(done, f.owner, proposal);
assert.equal(declared.response?.kind, 'choamWorthless');
assert.equal(declared.inflationAttemptTurn, 1);
assert.equal(player(declared, f.target).forces['arrakeen:10'], 10);
assertSuffix(f, declared);
assert.equal(player(f.targetSuffix!, f.target).forces['arrakeen:10'], 9);
assert.equal(player(f.targetSuffix!, f.target).reserves, 11);
assert.equal(player(f.targetSuffix!, f.owner).spice, 18);
assert.equal(player(f.targetSuffix!, f.target).spice, 12);
assert.equal(f.targetSuffix!.discard.filter(c => c.id === gamont).length, 1);
});
@@ -0,0 +1,239 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
import { semutaEmperorPlayer as player, moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import {
createSemutaHomeworldKaramaAuctionFixture, disposeSemutaHomeworldKaramaAuction,
finishSemutaHomeworldKaramaAuction, type SemutaHomeworldKaramaAuctionFixture,
} from './fixture-semuta-homeworld-karama-auction';
function assertReserved(f: SemutaHomeworldKaramaAuctionFixture, pending: Game) {
const cost = pending.pendingTreacheryDiscard!;
assert.equal(cost.continuation.kind, f.scope === 'purchase' ? 'karamaPurchaseDiscard' : 'karamaPaymentDiscard');
assert.equal(cost.reaction?.stage, 'offer');
assert.deepEqual(cost.batch.entries.map(entry => ({ id: entry.card.id, owner: entry.discardedBy, public: entry.publicFace })),
[{ id: f.card, owner: f.actor, public: true }]);
assert.equal(player(pending, f.actor).hand.some(card => card.id === f.card), false);
assert.equal(pending.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(pending, f.actor).hand.some(card => card.id === f.reservedLot), false);
assert.deepEqual(pending.auction, f.game.auction, 'The same native lot and winning bid remain reserved, not delivered');
assert.deepEqual(pending.players.map(p => ({ player: p.id, spice: p.spice })), f.payers);
assert.deepEqual(pending.deck, f.game.deck);
assert.deepEqual(pending.richeseCache, f.game.richeseCache);
assert.deepEqual(pending.richeseBidding, f.game.richeseBidding);
assert.deepEqual(pending.homeworlds, f.custody);
assert.deepEqual(biddingPhysicalIds(pending), biddingPhysicalIds(f.game));
assert.equal(pending.response, null);
assert.equal(pending.decision, null);
}
function assertAcquired(f: SemutaHomeworldKaramaAuctionFixture, done: Game, claimed: boolean) {
assert.equal(done.pendingTreacheryDiscard, null);
const buyerBefore = player(f.game, f.actor), buyer = player(done, f.actor);
const lot = f.game.auction!.cards[f.game.auction!.index];
assert.deepEqual(buyer.hand, [...buyerBefore.hand.filter(card => card.id !== f.card), lot]);
assert.equal(buyer.hand.filter(card => card.id === f.reservedLot).length, 1);
assert.deepEqual(done.players.map(p => ({ player: p.id, spice: p.spice })), f.payers,
'Zero price creates neither Emperor income, foreign occupied share nor refund');
assert.deepEqual(done.deck, f.game.deck, 'No bonus or replacement card is drawn');
assert.deepEqual(done.richeseCache, f.game.richeseCache);
assert.deepEqual(done.richeseRemoved, f.game.richeseRemoved);
assert.deepEqual(done.richeseBidding, f.game.richeseBidding);
assert.deepEqual(done.homeworlds, f.custody);
assert.deepEqual(done.order, f.game.order);
assert.deepEqual(biddingPhysicalIds(done), biddingPhysicalIds(f.game));
assert.deepEqual(done.auction!.cards, f.game.auction!.cards);
assert.equal(done.auction!.index, f.game.auction!.index + 1);
assert.equal(done.auction!.bid, 0);
assert.equal(done.auction!.bidder, null);
assert.equal(player(done, f.holder).hand.filter(card => card.id === f.card).length, Number(claimed));
assert.equal(done.discard.filter(card => card.id === f.card).length, Number(!claimed));
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claimed));
assert.equal(player(done, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claimed);
}
for (const advanced of [false, true]) for (const scope of ['purchase', 'payment'] as const) {
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld printed Karama ${scope}`;
void test(`${label}: original cost reserves the lot until an exact singleton claim or pass`, () => {
const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced);
if (scope === 'payment') {
assert.equal(f.game.auction!.bidder, f.actor);
assert.ok(f.nativeBid > player(f.game, f.actor).spice);
assert.equal(f.game.decision?.kind, 'auctionPayment');
}
const pending = disposeSemutaHomeworldKaramaAuction(f);
assertReserved(f, pending);
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
assert.throws(() => applyAction(pending, f.actor, f.action));
assert.throws(() => applyAction(pending, f.holder, { type: 'semutaCommit', event: `${event}-stale` }));
assert.throws(() => applyAction(pending, f.actor, { type: 'bid', amount: f.nativeBid + 1 }));
assert.throws(() => applyAction(pending, f.actor, { type: 'decision', karama: false }));
for (const seat of pending.players.filter(seat => seat.id !== f.holder)) {
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(pending, seat.id, { type: 'semutaCommit', event }));
}
for (const claim of [false, true]) {
const done = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(pending)) as Game, f.holder, claim);
assertAcquired(f, done, claim);
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(done, f.actor, f.action));
for (const seat of done.players.filter(seat => seat.id !== f.actor))
assert.throws(() => applyAction(done, seat.id, { type: 'decision', karama: true, card: f.card }));
const finished = finishSemutaHomeworldKaramaAuction(done, f);
assert.equal(finished.phase, 5);
assert.equal(finished.turn, f.game.turn);
assert.equal(finished.richeseBidding!.stage, 'complete');
assert.equal(finished.richeseCache!.length, f.game.richeseCache!.length - 1,
'The original compulsory cache auction is completed, never cut from the continuation');
assert.deepEqual(finished.players.map(p => ({ player: p.id, spice: p.spice })), f.payers);
assert.equal(player(finished, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1);
}
});
void test(`${label}: full holder exchange is atomic and nonholder projections cannot reveal entitlement`, () => {
const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced, true);
assert.equal(player(f.game, f.holder).hand.length, 4);
assert.equal(f.game.richeseBidding!.normalCount, f.beforeLot.richeseBidding!.normalCount);
assert.deepEqual(f.game.auction!.cards, f.beforeLot.auction!.cards);
const absent = structuredClone(f.game), hand = player(absent, f.holder).hand;
const index = hand.findIndex(card => card.id === SEMUTA_DRUG_ID);
const replacement = absent.deck.findIndex(card => card.id !== SEMUTA_DRUG_ID);
[hand[index], absent.deck[replacement]] = [absent.deck[replacement], hand[index]];
assert.deepEqual(biddingPhysicalIds(absent), biddingPhysicalIds(f.game));
const pending = disposeSemutaHomeworldKaramaAuction(f), neutral = disposeSemutaHomeworldKaramaAuction(f, absent);
assertReserved(f, pending);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
for (const seat of pending.players.filter(seat => seat.id !== f.holder))
assert.deepEqual(JSON.parse(JSON.stringify(viewGame(pending, seat.id))),
JSON.parse(JSON.stringify(viewGame(neutral, seat.id))));
assert.throws(() => applyAction(neutral, f.holder,
{ type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event }));
const done = reactSemutaHomeworldChoam(pending, f.holder, true);
assertAcquired(f, done, true);
assert.equal(player(done, f.holder).hand.length, handLimit(player(done, f.holder)));
const declined = reactSemutaHomeworldChoam(neutral, f.holder, false);
assert.equal(player(declined, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1);
assert.deepEqual(declined.players.map(p => ({ player: p.id, spice: p.spice })), f.payers);
});
void test(`${label}: changed reserved native settlement rejects without effect replay across JSON`, () => {
const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced);
const pending = disposeSemutaHomeworldKaramaAuction(f), event = pending.pendingTreacheryDiscard!.batch.event;
const mutations: ((game: Game) => void)[] = [
game => { game.auction!.cards[game.auction!.index].name = 'Changed reserved face'; },
game => { game.richeseBidding!.position = 'first'; },
game => { game.auction!.bidder = f.holder; },
game => {
const continuation = game.pendingTreacheryDiscard!.continuation;
if (continuation.kind === 'karamaPurchaseDiscard' || continuation.kind === 'karamaPaymentDiscard')
continuation.stateSignature = 'retired';
},
];
for (const mutate of mutations) {
const altered = JSON.parse(JSON.stringify(pending)) as Game;
mutate(altered);
const original = JSON.stringify(altered);
assert.throws(() => applyAction(altered, f.holder, { type: 'semutaCommit', event }));
assert.equal(JSON.stringify(altered), original, 'Rejected altered continuation neither settles nor spends Semuta');
}
for (const seat of f.game.players.filter(seat => seat.id !== f.actor))
assert.throws(() => disposeSemutaHomeworldKaramaAuction({ ...f, actor: seat.id }));
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: ${label} consumes a minimal legal private reaction then native next-lot control`, () => {
const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced);
let game = disposeSemutaHomeworldKaramaAuction(f);
const own = viewGame(game, f.holder);
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const reaction = botActions(own)[0];
assert.ok(reaction?.type === 'semutaCommit' || reaction?.type === 'semutaPass');
game = applyAction(game, f.holder, reaction);
if (game.pendingTreacheryDiscard) {
const pending = game.pendingTreacheryDiscard, offer = pending.reaction;
assert.ok(offer?.stage === 'offer');
for (const seat of game.players.filter(seat => !offer.passed.includes(seat.id)))
game = applyAction(game, seat.id, { type: 'semutaPass', event: pending.batch.event });
}
assertAcquired(f, game, reaction.type === 'semutaCommit');
game = advanceSemutaHomeworldChoam(game, state => !state.response && !state.decision);
const actor = game.auction!.active, native = viewGame(game, actor);
native.players.find(seat => seat.id === actor)!.bot = difficulty;
const action = botActions(native)[0];
assert.ok(action);
const moved = applyAction(game, actor, action);
assert.equal(player(moved, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1);
const done = finishSemutaHomeworldKaramaAuction(moved, f);
assert.equal(done.phase, 5);
assert.equal(done.richeseBidding!.stage, 'complete');
assert.equal(player(done, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1);
});
}
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'}: native buyer capacity distinguishes direct purchase from spending a winning-payment Karama`, () => {
for (const scope of ['purchase', 'payment'] as const) {
const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced), game = structuredClone(f.game);
const buyer = player(game, f.actor);
while (buyer.hand.length < handLimit(buyer)) buyer.hand.push(game.deck.shift()!);
assert.equal(buyer.hand.length, handLimit(buyer));
assert.deepEqual(biddingPhysicalIds(game), biddingPhysicalIds(f.game));
if (scope === 'purchase') {
const original = JSON.stringify(game);
assert.throws(() => disposeSemutaHomeworldKaramaAuction(f, game));
assert.equal(JSON.stringify(game), original);
} else {
const cost = disposeSemutaHomeworldKaramaAuction(f, game);
assert.equal(player(cost, f.actor).hand.length, handLimit(buyer) - 1);
assert.equal(player(cost, f.actor).hand.some(card => card.id === f.reservedLot), false);
const done = reactSemutaHomeworldChoam(cost, f.holder, true);
assert.equal(player(done, f.actor).hand.length, handLimit(buyer));
assert.equal(player(done, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1);
assert.deepEqual(done.players.map(p => ({ player: p.id, spice: p.spice })), f.payers);
}
}
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'}: original printed cache lot still cannot be taken with normal-auction Karama`, () => {
const f = createSemutaHomeworldKaramaAuctionFixture('purchase', advanced);
let game = advanceSemutaHomeworldChoam(f.game, state => state.decision?.kind === 'richeseCache');
game = applyAction(game, game.decision!.player, { type: 'decision', event: game.richeseBidding!.event,
card: game.richeseCache![0].id, method: 'onceAround', direction: 'counterclockwise' });
game = advanceSemutaHomeworldChoam(game, state => !state.response && !state.decision);
assert.equal(game.richeseAuction!.source, 'cache');
assert.equal(game.auction, null);
const original = JSON.stringify(game);
assert.throws(() => disposeSemutaHomeworldKaramaAuction(f, game));
assert.equal(JSON.stringify(game), original);
assert.equal(player(game, f.actor).hand.filter(card => card.id === f.card).length, 1);
});
void test('Advanced: genuine original normal-method Black Market still cannot be taken by normal-auction Karama', () => {
const f = createSemutaHomeworldKaramaAuctionFixture('purchase', true);
let game = structuredClone(f.setup);
if (!player(game, f.actor).hand.some(card => card.id === f.card))
moveSemutaEmperorCard(game, f.card, f.actor);
// These are the same still-unplayed physical cards, before any first-turn blow.
for (let position = 0; position < 2; position++) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card);
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = advanceSemutaHomeworldChoam(game, state => state.decision?.kind === 'richeseBlackMarket');
const owner = game.decision!.player, lot = player(game, owner).hand[0];
assert.ok(lot);
game = applyAction(game, owner, { type: 'decision', event: game.richeseBidding!.event,
card: lot.id, method: 'normal' });
game = advanceSemutaHomeworldChoam(game, state => !state.response && !state.decision);
assert.equal(game.richeseAuction!.source, 'blackMarket');
assert.equal(game.richeseAuction!.method, 'normal');
assert.equal(game.auction, null);
const original = JSON.stringify(game);
assert.throws(() => disposeSemutaHomeworldKaramaAuction(f, game));
assert.equal(JSON.stringify(game), original);
assert.equal(player(game, f.actor).hand.filter(card => card.id === f.card).length, 1);
});