Offer Semuta before native paid-income Karama cancellations

This commit is contained in:
Quintine committed 2026-10-12 15:36:48 +11:00
1 parent adb054023d
commit d718e380b7
10 files changed
+799 -15

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
type Action, type Game,
} from '../game/engine';
import type { Card } from '../game/cards';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import {
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
} from './fixture-semuta-emperor-share';
export type SemutaEmperorIncomeKind = 'emperorIncome' | 'richesePurchaseIncome';
export type SemutaEmperorIncomeFixture = {
game: Game;
beforePayment: Game;
sourceActions: { actor: string; action: Action }[];
owner: string;
holder: string;
buyer: string;
canceler: string;
karama: string;
karamaCard: Card;
acquired: string;
special?: string;
amount: number;
kind: SemutaEmperorIncomeKind;
cancel: Action;
originalCards: string[];
staging: string[];
};
/** Original ownership zones only; the paid auction prefix is a reference, not another card. */
export function semutaEmperorIncomeCards(game: Game): string[] {
return [
...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? []),
].map(card => card.id).sort();
}
export function nextSemutaEmperorIncomeStep(game: Game): { actor: string; action: Action } {
const pending = game.pendingTreacheryDiscard;
const reaction = pending?.reaction;
if (reaction?.stage === 'offer' && pending) {
const actor = game.players.find(player => !reaction.passed.includes(player.id))!.id;
return { actor, action: { type: 'semutaPass', event: pending.batch.event } };
}
return nextRicheseSkillsTechNativeStep(game);
}
function reach(state: Game, until: (game: Game) => boolean): Game {
let game = state;
for (let n = 0; !until(game) && n < 1400; n++) {
assert.notEqual(game.status, 'finished', 'Native actions must reach the original income source');
const next = nextSemutaEmperorIncomeStep(game);
game = applyAction(game, next.actor, next.action);
}
assert.ok(until(game), 'Native actions must reach the requested source boundary');
return game;
}
/** Move an original physical card to the next native draw, without replacing the deck. */
function nextOriginalDraw(game: Game, id: string): void {
for (const zone of [game.deck, game.discard, game.richeseCache ?? [],
game.richeseRemoved ?? [], ...game.players.map(player => player.hand)]) {
const index = zone.findIndex(card => card.id === id);
if (index < 0) continue;
const card = zone.splice(index, 1)[0];
game.deck.unshift(card);
return;
}
throw new Error(`Missing original draw ${id}`);
}
function fillHolder(game: Game): void {
const holder = semutaEmperorPlayer(game, 'r');
while (holder.hand.length < 4) {
const card = game.deck.find(candidate => candidate.effect !== 'karama');
assert.ok(card);
moveSemutaEmperorCard(game, card.id, holder.id);
}
assert.equal(holder.hand.length, 4);
}
/** Fresh original setup and first-turn phase actions. Only unplayed Spice order and
* conserved later physical cards are staged; no clocks, wallets, allies or stocks are minted.
* Purchase is made inside the genuine Richese declaration, so its original owner must return.
* The auction optionally buys the very printed Karama that the winner then spends on income. */
export function createSemutaEmperorIncomeFixture(
kind: SemutaEmperorIncomeKind,
options: { acquiredKarama?: boolean } = {},
): SemutaEmperorIncomeFixture {
const advanced = kind === 'richesePurchaseIncome';
let game = createGame('SEMUTAINCOME', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = initializeSemutaGameForAudit(game);
const originalCards = semutaEmperorIncomeCards(game);
const staging: string[] = [];
game = reach(game, state => state.status === 'playing');
// Control only the two still-unplayed original first-turn blows.
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('Two unplayed original non-worm Spice Cards precede native first-turn Charity/Bidding.');
game = reach(game, state => state.phase === 2 && !state.phaseOpening && !state.response && !state.decision);
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
let special: string | undefined;
if (advanced) {
special = moveSemutaEmperorCard(game,
semutaEmperorCardId(game, card => card.effect === 'karama'), 'r');
}
const karama = semutaEmperorCardId(game,
card => card.effect === 'karama' && card.id !== special);
if (options.acquiredKarama) nextOriginalDraw(game, karama);
else moveSemutaEmperorCard(game, karama, 'a');
staging.push('Original Semuta moves from cache to Richese; original printed Karama custody is conserved.');
if (!advanced) {
const inspectionCounter = semutaEmperorCardId(game,
card => card.effect === 'karama' && card.id !== karama);
moveSemutaEmperorCard(game, inspectionCounter, 'e');
staging.push('The other original printed Karama is held by Emperor, qualifying the next Atreides inspection response.');
}
const sourceActions: { actor: string; action: Action }[] = [];
let beforePayment: Game;
let acquired: string;
if (advanced) {
fillHolder(game);
staging.push('Original later deck cards fill Richese to its four-card limit, including its separate activation.');
game = reach(game, state => state.decision?.kind === 'richeseDeclaration' && !state.response && !state.phaseOpening);
assert.equal(game.decision!.player, 'r');
beforePayment = structuredClone(game);
acquired = game.richeseCache![0].id;
const purchase: Action = { type: 'card', mode: 'special', card: special!, acquire: acquired };
sourceActions.push({ actor: 'r', action: purchase });
game = applyAction(game, 'r', purchase);
} else {
if (!options.acquiredKarama) {
const first = game.deck.find(card => card.effect !== 'karama');
assert.ok(first);
acquired = first.id;
nextOriginalDraw(game, acquired);
} else acquired = karama;
staging.push(`Original ${acquired} is moved to the next native ordinary-pool draw, not a replacement deck.`);
game = reach(game, state => !!state.auction && state.phase === 3 &&
!state.phaseOpening && !state.response && !state.decision);
assert.equal(game.auction!.cards[game.auction!.index].id, acquired);
assert.ok(game.auction!.cards.length >= 2, 'The native ordinary pool must have a successor lot');
fillHolder(game);
staging.push('Conserved later deck cards bring Semuta holder to hand4 after the original pool is drawn.');
for (let n = 0; game.auction!.active !== 'a' && n < 6; n++)
game = applyAction(game, game.auction!.active, { type: 'passBid' });
assert.equal(game.auction!.active, 'a');
beforePayment = structuredClone(game);
sourceActions.push({ actor: 'a', action: { type: 'bid', amount: 1 } });
game = applyAction(game, 'a', sourceActions[0].action);
for (let n = 0; !game.response && n < 8; n++) {
const next = nextSemutaEmperorIncomeStep(game);
sourceActions.push(next);
game = applyAction(game, next.actor, next.action);
}
}
assert.equal(game.response?.kind, kind);
assert.equal(game.response!.owner, 'e');
assert.notEqual(game.response!.source, 'ambassador');
assert.equal(game.turn, 1);
assert.equal(game.phase, 3);
assert.deepEqual(semutaEmperorIncomeCards(game), originalCards);
const karamaCard = semutaEmperorPlayer(game, 'a').hand.find(card => card.id === karama)!;
assert.ok(karamaCard);
if (special) assert.notEqual(special, karama);
return {
game, beforePayment, sourceActions, owner: 'e', holder: 'r', buyer: advanced ? 'r' : 'a', canceler: 'a',
karama, karamaCard, acquired, special, amount: advanced ? 3 : 1, kind,
cancel: { type: 'card', mode: 'cancel', card: karama }, originalCards, staging,
};
}
export function reactSemutaEmperorIncome(
state: Game, fixture: SemutaEmperorIncomeFixture, claim: boolean,
): Game {
const event = state.pendingTreacheryDiscard!.batch.event;
if (claim) return applyAction(state, fixture.holder, { type: 'semutaCommit', event });
let game = state;
for (const player of state.players)
game = applyAction(game, player.id, { type: 'semutaPass', event });
return game;
}
+187
View File
@@ -0,0 +1,187 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { botEntryAllowed } from '../game/bot-mobility';
import { reserveShipmentCost } from '../game/shipment-price';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import type { Card } from '../game/cards';
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
import {
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
} from './fixture-semuta-emperor-share';
export type SemutaGuildIncomeOptions = { advanced?: boolean; fullHand?: boolean };
export type SemutaGuildIncomeFixture = {
game: Game;
income: Game;
initial: Game;
setup: Game;
afterSetup: Game;
afterFirstStorm: Game;
beforeStaging: Game;
actions: RicheseSkillsTechStep[];
producerActions: RicheseSkillsTechStep[];
staging: string[];
owner: string;
payer: string;
holder: string;
canceller: string;
karama: Card;
semuta: string;
territory: string;
key: string;
amount: number;
cost: number;
ship: Action;
cancel: Action;
};
export function semutaGuildIncomeInventory(game: Game): string[] {
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []),
...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'One physical slot per original Treachery identity');
return ids;
}
export function semutaGuildIncomeCounters(game: Game) {
return game.players.map(player => ({
id: player.id,
total: player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0),
elite: player.elites ? player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0) : 0,
}));
}
/** Exclude only hands from the already-paid shipment snapshot. Semuta changes
* custody, never wallets, force pools, shipment flags or Movement ownership. */
export function semutaGuildIncomePosition(game: Game) {
return {
status: game.status, turn: game.turn, phase: game.phase, active: game.active,
order: game.order, ready: game.ready, movementRemaining: game.movementRemaining,
pendingShipment: game.pendingShipment, guildRateBlocked: game.guildRateBlocked,
players: game.players.map(player => ({
id: player.id, faction: player.faction, ally: player.ally, spice: player.spice,
forces: player.forces, reserves: player.reserves, tanks: player.tanks,
elites: player.elites, leaders: player.leaders,
shipped: player.shipped, moved: player.moved, revived: player.revived,
})),
};
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** Existing no-spend native setup/phase policy, with neutral passes for any
* fresh discard encountered on the way. No clock, wallet or turn is staged. */
export function nextSemutaGuildIncomeNativeStep(game: Game): RicheseSkillsTechStep {
const pending = game.pendingTreacheryDiscard;
const reaction = pending?.reaction;
if (reaction?.stage === 'offer' && pending) {
const actor = game.players.find(player => !reaction.passed.includes(player.id));
assert.ok(actor);
return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } };
}
return nextRicheseSkillsTechNativeStep(game);
}
/** Fresh Richese/Guild/classic setup, original first Storm and actual first-turn
* Movement. Only still-unplayed Spice order and conserved later hands change.
* Original board, leaders, wallets, reserve stocks and native order stay intact. */
export function createSemutaGuildIncomeFixture(options: SemutaGuildIncomeOptions = {}): SemutaGuildIncomeFixture {
let game = createGame('SEMUTAGUILDINCOME', newPlayer('r', 'Richese', 'richese'), options.advanced ?? false, ['choam']);
joinGame(game, newPlayer('g', 'Guild', 'guild'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
const initial = structuredClone(game), actions: RicheseSkillsTechStep[] = [];
game = initializeSemutaGameForAudit(game);
const setup = structuredClone(game), inventory = semutaGuildIncomeInventory(game);
const reach = (until: () => boolean) => {
for (let n = 0; !until() && n < 1200; n++) {
assert.notEqual(game.status, 'finished', 'Original native actions must reach Movement');
const next = nextSemutaGuildIncomeNativeStep(game);
actions.push(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(until(), 'The genuine native phase path must reach its requested boundary');
};
reach(() => game.status === 'playing');
const afterSetup = structuredClone(game);
const counters = semutaGuildIncomeCounters(game);
// Prevent an incidental first-turn worm by reordering original unplayed cards.
for (const position of [0, 1]) {
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]);
}
reach(() => game.phase === 1 && clean(game));
const afterFirstStorm = structuredClone(game);
reach(() => game.phase === 5 && game.active === 'a' && clean(game));
assert.equal(game.turn, 1);
const beforeStaging = structuredClone(game), position = semutaGuildIncomePosition(game);
for (const player of game.players) game.deck.push(...player.hand.splice(0));
const staging = [
'Reordered only the first two original unplayed Spice Cards to non-worm blows.',
'At actual first-turn Atreides Movement, returned original held Treachery Cards to the same deck; no board, reserve, leader or wallet staging.',
];
const karamaId = moveSemutaEmperorCard(game, semutaEmperorCardId(game, card => card.effect === 'karama'), 'e');
const semuta = moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
const karama = semutaEmperorPlayer(game, 'e').hand.find(card => card.id === karamaId)!;
staging.push(`Conserved original ${karamaId} to Emperor and canonical ${semuta} to Richese; neither is a replacement card.`);
if (options.fullHand) {
const holder = semutaEmperorPlayer(game, 'r');
while (holder.hand.length < handLimit(holder)) {
const id = semutaEmperorCardId(game, card => card.id !== semuta && card.id !== karamaId &&
card.effect !== 'karama' && !holder.hand.some(held => held.id === card.id));
moveSemutaEmperorCard(game, id, holder.id);
staging.push(`Conserved original ${id} into Richese's full-hand exchange position.`);
}
}
assert.deepEqual(semutaGuildIncomePosition(game), position);
assert.deepEqual(semutaGuildIncomeInventory(game), inventory);
assert.deepEqual(semutaGuildIncomeCounters(game), counters);
const own = viewGame(game, 'a'), payer = own.players.find(player => player.id === 'a')!;
// Empty ordinary sand isolates the paid shipment from battle/arrival parents.
// The consumer's original entry and tariff helpers choose the actual action.
const destination = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm) &&
!game.players.some(player => Object.keys(player.forces).some(key => key.startsWith(`${t.id}:`))) &&
botEntryAllowed(own, payer, t.id, t.sectors[0], 'ship'));
assert.ok(destination, 'An original legal empty sand destination must remain');
const amount = 3, sector = destination.sectors[0];
const cost = reserveShipmentCost({ faction: payer.faction, halfRate: false }, destination.type, amount);
assert.ok(payer.reserves >= amount && (payer.spice ?? 0) >= cost);
const ship: Action = { type: 'ship', territory: destination.id, sector, amount, elite: 0, allyPayment: 0 };
const producerActions: RicheseSkillsTechStep[] = [{ actor: 'a', action: ship }];
if (game.advanced) producerActions.push({ actor: 'g', action: { type: 'decision', allow: true } });
const fixture: Omit<SemutaGuildIncomeFixture, 'income'> = {
game, initial, setup, afterSetup, afterFirstStorm, beforeStaging, actions, producerActions, staging,
owner: 'g', payer: 'a', holder: 'r', canceller: 'e', karama, semuta,
territory: destination.id, key: `${destination.id}:${sector}`, amount, cost, ship,
cancel: { type: 'card', mode: 'cancel', card: karamaId },
};
return { ...fixture, income: shipSemutaGuildIncome(fixture) };
}
/** Issue the real reserve shipment; Advanced's original Guild special-stop
* decision must explicitly allow it before any payment or force arrival. */
export function shipSemutaGuildIncome(
fixture: Pick<SemutaGuildIncomeFixture, 'game' | 'payer' | 'owner' | 'ship' | 'cost'>,
): Game {
let game = applyAction(fixture.game, fixture.payer, fixture.ship);
if (fixture.game.advanced) {
assert.equal(game.decision?.kind, 'guildShipment');
assert.equal(game.decision?.player, fixture.owner);
assert.deepEqual(semutaGuildIncomePosition(game), semutaGuildIncomePosition({
...fixture.game, pendingShipment: game.pendingShipment,
}));
game = applyAction(game, fixture.owner, { type: 'decision', allow: true });
}
assert.equal(game.response?.kind, 'guildIncome', 'This is paid Guild income, not a rate or shipment-stop cancellation');
assert.equal(game.response.owner, fixture.owner);
assert.equal(game.response.amount, fixture.cost);
assert.equal(semutaEmperorPlayer(game, fixture.payer).shipped, true);
return game;
}
export function reactSemutaGuildIncome(state: Game, fixture: SemutaGuildIncomeFixture, claim: boolean): Game {
const event = state.pendingTreacheryDiscard!.batch.event;
if (claim) return applyAction(state, fixture.holder, { type: 'semutaCommit', event });
let game = state;
for (const seat of state.players) game = applyAction(game, seat.id, { type: 'semutaPass', event });
return game;
}
+137
View File
@@ -0,0 +1,137 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import {
createSemutaEmperorIncomeFixture, nextSemutaEmperorIncomeStep,
reactSemutaEmperorIncome, semutaEmperorIncomeCards,
type SemutaEmperorIncomeKind,
} from './fixture-semuta-emperor-income';
for (const kind of ['emperorIncome', 'richesePurchaseIncome'] as SemutaEmperorIncomeKind[]) {
for (const claim of [false, true]) void test(
`Semuta ${claim ? 'claims' : 'passes'} the foreign Karama canceling native ${kind} without repeating its paid source`, () => {
const f = createSemutaEmperorIncomeFixture(kind);
const originalBuyer = semutaEmperorPlayer(f.beforePayment, f.buyer);
const originalOwner = semutaEmperorPlayer(f.beforePayment, f.owner);
assert.equal(semutaEmperorPlayer(f.game, f.buyer).spice, originalBuyer.spice - f.amount);
assert.equal(semutaEmperorPlayer(f.game, f.owner).spice, originalOwner.spice);
assert.equal(semutaEmperorPlayer(f.game, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
assert.equal(semutaEmperorPlayer(f.game, f.holder).hand.length, 4);
const wallets = f.game.players.map(player => ({ id: player.id, spice: player.spice }));
const pending = applyAction(f.game, f.canceler, f.cancel);
const cost = pending.pendingTreacheryDiscard!;
assert.ok(cost, 'The actual paid income cancellation must first offer its printed Karama cost');
assert.equal(cost.batch.cause, `karama:${kind}`);
assert.deepEqual(cost.batch.entries, [{ card: f.karamaCard, discardedBy: f.canceler, publicFace: true }]);
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true,
'A genuine full hand can exchange Semuta for the foreign cancellation cost');
assert.deepEqual(viewGame(pending, f.holder).semutaReaction!.candidates, []);
assert.equal(semutaEmperorPlayer(pending, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
assert.deepEqual(pending.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
if (kind === 'emperorIncome') {
assert.equal(pending.auction!.index, f.game.auction!.index, 'The original next lot must wait for Semuta');
assert.equal(pending.currentAuctionSale!.winner, f.buyer);
} else {
assert.equal(pending.richeseCache!.some(card => card.id === f.acquired), false);
assert.equal(pending.discard.filter(card => card.id === f.special).length, 1);
assert.equal(cost.batch.entries.some(entry => entry.card.id === f.special), false,
'Richese never claims its own separately spent activation');
assert.equal(semutaEmperorPlayer(pending, f.buyer).specialKaramaUsed, true);
}
const done = reactSemutaEmperorIncome(pending, f, claim);
assert.equal(done.resolvedTreacheryDiscardSequence, cost.sequence);
assert.deepEqual(done.players.map(player => ({ id: player.id, spice: player.spice })), wallets,
'No refund, repeated debit or canceled Emperor credit occurs');
assert.equal(semutaEmperorPlayer(done, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.length, 4);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.karama), claim);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!claim));
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.deepEqual(semutaEmperorIncomeCards(done), f.originalCards);
assert.throws(() => applyAction(done, f.canceler, f.cancel));
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event }));
if (kind === 'emperorIncome') {
assert.equal(done.auction!.index, f.game.auction!.index + 1);
assert.deepEqual(done.auction!.cards.map(card => card.id), f.game.auction!.cards.map(card => card.id));
assert.equal(done.response!.kind, 'atreidesAuction');
assert.equal(done.response!.owner, 'a', 'Original Atreides inspection owns the next paid-auction suffix');
let resumed = done;
while (resumed.response?.kind === 'atreidesAuction') {
const player = resumed.players.find(seat => !resumed.response!.passed.includes(seat.id))!;
resumed = applyAction(resumed, player.id, { type: 'passResponse' });
}
assert.equal(resumed.auction!.index, done.auction!.index, 'Inspection must not advance a second lot');
const bidder = resumed.auction!.active;
resumed = applyAction(resumed, bidder, { type: 'bid', amount: 1 });
assert.equal(resumed.auction!.bidder, bidder, 'The original next lot is usable by its actual active bidder');
assert.equal(resumed.auction!.index, done.auction!.index);
assert.deepEqual(resumed.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
} else {
assert.deepEqual(done.decision, f.beforePayment.decision,
'The native Richese declaration, including its original owner, is restored');
assert.equal(done.richeseCache!.length, f.beforePayment.richeseCache!.length - 1);
assert.equal(done.discard.filter(card => card.id === f.special).length, 1);
const next = nextSemutaEmperorIncomeStep(done);
assert.equal(next.actor, f.buyer);
assert.throws(() => applyAction(done, f.owner, next.action), 'Emperor cannot answer the original Richese decision');
const resumed = applyAction(done, next.actor, next.action);
assert.equal(resumed.decision!.kind, 'richeseCache');
assert.equal(resumed.decision!.player, f.buyer,
'Native declaration opens its actual cache choice, after automatic uncanceled acknowledgements');
assert.deepEqual(resumed.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
assert.equal(semutaEmperorPlayer(resumed, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
assert.throws(() => applyAction(resumed, next.actor, next.action), 'Declaration is not replayable');
}
},
);
}
for (const claim of [false, true]) void test(
`a real paid-auction winner spends its newly purchased Karama and Semuta ${claim ? 'claims' : 'passes'} that same physical card`, () => {
const f = createSemutaEmperorIncomeFixture('emperorIncome', { acquiredKarama: true });
assert.equal(f.acquired, f.karama);
assert.equal(semutaEmperorPlayer(f.game, f.buyer).spice,
semutaEmperorPlayer(f.beforePayment, f.buyer).spice - 1);
const pending = applyAction(f.game, f.canceler, f.cancel);
assert.ok(pending.pendingTreacheryDiscard);
assert.equal(pending.pendingTreacheryDiscard.batch.entries[0].discardedBy, f.buyer);
assert.equal(semutaEmperorPlayer(pending, f.buyer).hand.some(card => card.id === f.acquired), false);
const done = reactSemutaEmperorIncome(pending, f, claim);
assert.equal(semutaEmperorPlayer(done, f.buyer).spice, semutaEmperorPlayer(f.game, f.buyer).spice,
'Buying then spending the card does not refund its paid auction');
assert.equal(semutaEmperorPlayer(done, f.owner).spice, semutaEmperorPlayer(f.beforePayment, f.owner).spice);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.acquired), claim);
assert.equal(done.discard.filter(card => card.id === f.acquired).length, Number(!claim));
assert.equal(done.auction!.index, f.game.auction!.index + 1);
assert.equal(done.response!.kind, 'atreidesAuction');
assert.deepEqual(semutaEmperorIncomeCards(done), f.originalCards);
},
);
for (const kind of ['emperorIncome', 'richesePurchaseIncome'] as SemutaEmperorIncomeKind[]) void test(
`a legal Easy bot completes the full-hand native ${kind} Semuta cost and returns its original controls`, () => {
const f = createSemutaEmperorIncomeFixture(kind);
const pending = applyAction(f.game, f.canceler, f.cancel);
const view = viewGame(pending, f.holder);
view.players.find(player => player.id === f.holder)!.bot = 'Easy';
const action = botActions(view)[0];
assert.ok(action);
assert.equal(action.type, 'semutaCommit');
const done = applyAction(pending, f.holder, action);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.length, 4);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.karama), true);
assert.equal(semutaEmperorPlayer(done, f.buyer).spice, semutaEmperorPlayer(f.game, f.buyer).spice);
assert.equal(semutaEmperorPlayer(done, f.owner).spice, semutaEmperorPlayer(f.beforePayment, f.owner).spice);
if (kind === 'emperorIncome') {
assert.equal(done.auction!.index, f.game.auction!.index + 1);
assert.equal(done.response!.owner, 'a');
} else {
assert.deepEqual(done.decision, f.beforePayment.decision);
assert.equal(semutaEmperorPlayer(done, f.buyer).specialKaramaUsed, true);
}
},
);
+130
View File
@@ -0,0 +1,130 @@
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 {
createSemutaGuildIncomeFixture, reactSemutaGuildIncome,
semutaGuildIncomeInventory,
semutaGuildIncomePosition, type SemutaGuildIncomeFixture,
} from './fixture-semuta-guild-income';
import { semutaEmperorPlayer as semutaGuildIncomePlayer } from './fixture-semuta-emperor-share';
function openPaid(f: SemutaGuildIncomeFixture): Game {
const paid = f.income;
const payerBefore = semutaGuildIncomePlayer(f.game, f.payer), payerAfter = semutaGuildIncomePlayer(paid, f.payer);
assert.equal(f.cost, 6, 'Three ordinary sand arrivals pay full price, not the Guild half rate');
assert.equal(payerAfter.spice, payerBefore.spice - f.cost);
assert.equal(payerAfter.reserves, payerBefore.reserves - f.amount);
assert.equal(payerAfter.forces[f.key], (payerBefore.forces[f.key] ?? 0) + f.amount);
assert.equal(payerAfter.shipped, true);
assert.equal(payerAfter.moved, payerBefore.moved);
assert.equal(paid.active, f.payer);
assert.deepEqual(paid.movementRemaining, f.game.movementRemaining);
assert.equal(semutaGuildIncomePlayer(paid, f.owner).spice, semutaGuildIncomePlayer(f.game, f.owner).spice);
return paid;
}
function openCost(f: SemutaGuildIncomeFixture, paid: Game): Game {
assert.ok(viewGame(paid, f.canceller).responseControls!.cancelCards.includes(f.karama.id));
assert.deepEqual(viewGame(paid, f.owner).responseControls!.cancelCards, [], 'Guild is income owner, not foreign canceller');
assert.throws(() => applyAction(paid, f.payer, f.cancel),
'The shipper cannot spend the foreign Emperor-controlled cost');
const pending = applyAction(paid, f.canceller, f.cancel);
const discard = pending.pendingTreacheryDiscard;
assert.ok(discard, 'Printed Karama needs its immediate public Semuta opportunity');
assert.equal(discard.batch.cause, 'karama:guildIncome');
assert.deepEqual(discard.batch.entries, [{ card: f.karama, discardedBy: f.canceller, publicFace: true }]);
assert.equal(discard.reaction?.stage, 'offer');
assert.deepEqual(semutaGuildIncomePosition(pending), semutaGuildIncomePosition(paid));
assert.equal(semutaGuildIncomePlayer(pending, f.canceller).hand.some(card => card.id === f.karama.id), false);
for (const seat of pending.players) {
const offer = viewGame(pending, seat.id).semutaReaction!;
assert.equal(offer.canCommit, seat.id === f.holder);
}
assert.throws(() => applyAction(pending, f.payer, { type: 'endMovement' }));
assert.throws(() => applyAction(pending, f.payer, { type: 'semutaCommit', event: discard.batch.event }));
return pending;
}
function assertCompletion(f: SemutaGuildIncomeFixture, paid: Game, pending: Game, done: Game, claim: boolean): void {
const event = pending.pendingTreacheryDiscard!.batch.event;
const holder = semutaGuildIncomePlayer(done, f.holder);
assert.equal(holder.hand.some(card => card.id === f.karama.id), claim);
assert.equal(holder.hand.some(card => card.id === f.semuta), !claim);
assert.equal(done.discard.filter(card => card.id === f.karama.id).length, Number(!claim));
assert.equal(done.discard.filter(card => card.id === f.semuta).length, Number(claim));
assert.equal(done.resolvedTreacheryDiscardSequence, pending.pendingTreacheryDiscard!.sequence);
assert.deepEqual(semutaGuildIncomePosition(done), semutaGuildIncomePosition(paid),
'Canceling Guild income never refunds, reprices, re-ships, moves or advances the native owner');
assert.deepEqual(semutaGuildIncomeInventory(done), semutaGuildIncomeInventory(f.setup));
assert.throws(() => applyAction(done, f.payer, f.ship));
assert.throws(() => applyAction(done, f.canceller, f.cancel));
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event }));
// Compare the next native Movement scheduling with the original allowed
// income branch. Only Guild's collected spice differs; no owner/suffix is invented.
const allowed = applyAction(paid, f.canceller, { type: 'passResponse' });
assert.equal(semutaGuildIncomePlayer(allowed, f.owner).spice,
semutaGuildIncomePlayer(f.game, f.owner).spice + f.cost);
const ordinarySuffix = applyAction(allowed, f.payer, { type: 'endMovement' });
const canceledSuffix = applyAction(done, f.payer, { type: 'endMovement' });
assert.equal(canceledSuffix.turn, ordinarySuffix.turn);
assert.equal(canceledSuffix.phase, ordinarySuffix.phase);
assert.equal(canceledSuffix.active, ordinarySuffix.active);
assert.deepEqual(canceledSuffix.movementRemaining, ordinarySuffix.movementRemaining);
assert.deepEqual(canceledSuffix.decision, ordinarySuffix.decision);
assert.deepEqual(canceledSuffix.response, ordinarySuffix.response);
assert.ok(!canceledSuffix.movementRemaining?.includes(f.payer));
const payer = semutaGuildIncomePlayer(canceledSuffix, f.payer);
assert.equal(payer.spice, semutaGuildIncomePlayer(f.game, f.payer).spice - f.cost);
assert.equal(payer.reserves, semutaGuildIncomePlayer(f.game, f.payer).reserves - f.amount);
assert.equal(payer.forces[f.key], f.amount);
assert.equal(payer.shipped, true);
assert.equal(semutaGuildIncomePlayer(canceledSuffix, f.owner).spice, semutaGuildIncomePlayer(f.game, f.owner).spice);
assert.deepEqual(semutaGuildIncomeInventory(canceledSuffix), semutaGuildIncomeInventory(f.setup));
for (const seat of canceledSuffix.players) viewGame(canceledSuffix, seat.id);
}
for (const advanced of [false, true]) {
for (const claim of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} native paid reserve shipment Semuta ${claim ? 'claims' : 'passes'} income-canceling Karama without refund or repeat arrival`, () => {
const f = createSemutaGuildIncomeFixture({ advanced }), paid = openPaid(f), pending = openCost(f, paid);
const done = reactSemutaGuildIncome(pending, f, claim);
assertCompletion(f, paid, pending, done, claim);
},
);
void test(`${advanced ? 'Advanced' : 'Basic'} full-hand Richese exchanges Semuta for the exact foreign Guild-income cost`, () => {
const f = createSemutaGuildIncomeFixture({ advanced, fullHand: true });
const held = semutaGuildIncomePlayer(f.game, f.holder);
assert.equal(held.hand.length, handLimit(held));
const paid = openPaid(f), pending = openCost(f, paid);
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true);
const done = reactSemutaGuildIncome(pending, f, true);
assert.equal(semutaGuildIncomePlayer(done, f.holder).hand.length, handLimit(held));
assert.deepEqual(semutaGuildIncomePlayer(done, f.holder).hand.map(card => card.id).sort(),
[...held.hand.filter(card => card.id !== f.semuta).map(card => card.id), f.karama.id].sort());
assertCompletion(f, paid, pending, done, true);
});
}
void test('native Guild-income denial waits through neutral passes and the original paid Movement stays owned', () => {
const f = createSemutaGuildIncomeFixture(), paid = openPaid(f), pending = openCost(f, paid);
const event = pending.pendingTreacheryDiscard!.batch.event;
let game = pending;
for (const actor of [f.owner, f.payer, f.canceller]) {
game = applyAction(game, actor, { type: 'semutaPass', event });
assert.equal(game.pendingTreacheryDiscard!.reaction!.stage, 'offer');
assert.deepEqual(semutaGuildIncomePosition(game), semutaGuildIncomePosition(paid));
assert.equal(game.discard.filter(card => card.id === f.karama.id).length, 1);
}
game = applyAction(game, f.holder, { type: 'semutaPass', event });
assertCompletion(f, paid, pending, game, false);
});
void test('legal Advanced Semuta bot finishes the full-hand Guild-income cancellation and resumes native Movement', () => {
const f = createSemutaGuildIncomeFixture({ advanced: true, fullHand: true });
const paid = openPaid(f), pending = openCost(f, paid);
const own = viewGame(pending, f.holder);
own.players.find(player => player.id === f.holder)!.bot = 'Easy';
const action = botActions(own)[0];
assert.ok(action);
const done = applyAction(pending, f.holder, action);
assertCompletion(f, paid, pending, done, true);
});