Compose original Homeworld shared-benefit cancellation costs with Semuta

This commit is contained in:
Quintine committed 2026-10-13 06:52:14 +11:00
1 parent 8c5b6c8b55
commit afe3008cae
12 files changed
+851 -1

No files matched your search

@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import {
createGame, handLimit, initializeSemutaGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import { applySemutaHomeworldChoamStep } from './fixture-semuta-homeworld-choam';
export type SemutaHomeworldBgCharityFixture = {
initial: Game; setup: Game; gameBeforeBGclaim: Game; game: Game;
originalIncomeResponse: Game; originalResponse: Game | null;
actor: string; owner: string; holder: string; payer: string; target: string;
card: string; amount: number; claim: Action; action: Action; event: string | null;
actions: RicheseSkillsTechStep[]; staging: string[];
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** Native human continuation: decline markets, allow responses, and ready the
* original queues. Never use the optional paid Kaitain end-of-Bidding control. */
function advance(state: Game, until: (game: Game) => boolean, actions?: RicheseSkillsTechStep[]): Game {
let game = state;
for (let n = 0; !until(game) && n < 1600; n++) {
assert.notEqual(game.status, 'finished');
let next: RicheseSkillsTechStep;
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
if (pending && reaction?.stage === 'offer') 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') next = {
actor: game.decision.player, action: { type: 'decision', done: true },
};
else if (game.biddingEnd && clean(game)) next = {
actor: game.biddingEnd.owners.find(id => !game.biddingEnd!.ready.includes(id))!,
action: { type: 'biddingEnd', event: game.biddingEnd.event, mode: 'ready' },
};
else next = nextRicheseSkillsTechNativeStep(game);
game = applySemutaHomeworldChoamStep(game, next.actor, next.action, actions);
}
assert.ok(until(game), 'Original native controls must reach the BG charity boundary');
return game;
}
/** Five original ready participants, actual prediction/force setup and first
* CHOAM income. Only conserved physical cards and unplayed Spice order stage. */
export function createSemutaHomeworldBgCharityFixture(
advanced = true, fullHolder = false,
): SemutaHomeworldBgCharityFixture {
let game = createGame('SEMUTAHWBG', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
game = applySemutaHomeworldChoamStep(game, game.host, { type: 'homeworlds', enabled: true }, actions);
for (const seat of game.players) game = applySemutaHomeworldChoamStep(game, seat.id, { type: 'ready' }, actions);
const initial = structuredClone(game);
game = initializeSemutaGameForAudit(game);
game = advance(game, g => g.status === 'playing', actions);
const setup = structuredClone(game);
const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 2);
assert.equal(blows.length, 2);
for (const [position, card] of blows.entries())
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
staging.push('Order two original still-unplayed non-worm Spice Cards.');
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
const card = moveSemutaEmperorCard(game, semutaEmperorCardId(game, c => c.effect === 'karama'), 'e');
staging.push('Conserved original Semuta to Richese and printed Karama to Emperor.');
if (fullHolder) {
const holder = semutaEmperorPlayer(game, 'r');
while (holder.hand.length < handLimit(holder)) {
assert.ok(game.deck[0]);
moveSemutaEmperorCard(game, game.deck[0].id, holder.id);
}
staging.push('Conserved undealt stock fills the original Richese hand to four.');
}
game = advance(game, g => g.response?.kind === 'choamCharity', actions);
const originalIncomeResponse = structuredClone(game);
game = advance(game, g => g.phase === 2 && clean(g), actions);
const gameBeforeBGclaim = structuredClone(game), claim: Action = { type: 'charity' };
if (advanced) game = applySemutaHomeworldChoamStep(game, 'b', claim, actions);
assert.equal(semutaEmperorPlayer(game, 'b').spice, 5);
assert.equal(semutaEmperorPlayer(game, 'c').spice, 12);
if (advanced) {
assert.equal(game.response?.kind, 'bgCharity');
assert.equal(game.response?.amount, 2);
assert.ok(viewGame(game, 'e').responseControls?.cancelCards.includes(card));
}
return { initial, setup, gameBeforeBGclaim, originalIncomeResponse,
originalResponse: advanced ? structuredClone(game) : null, game,
actor: 'e', owner: 'b', holder: 'r', payer: 'c', target: 'a', card,
amount: advanced ? game.response!.amount! : viewGame(game, 'b').charity.amount,
claim, action: { type: 'card', mode: 'cancel', card }, event: null, actions, staging };
}
/** Spend the real printed card before the bound original BG denial. */
export function disposeSemutaHomeworldBgCharity(
f: SemutaHomeworldBgCharityFixture, state = f.game, actions?: RicheseSkillsTechStep[],
): Game {
const game = applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
f.event = game.pendingTreacheryDiscard?.batch.event ?? null;
return game;
}
export function reactSemutaHomeworldBgCharity(
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;
}
/** Continue the same original native charity close into usable Bidding. */
export function finishSemutaHomeworldBgCharity(
state: Game, actions?: RicheseSkillsTechStep[],
): Game {
assert.ok(!state.pendingTreacheryDiscard && state.response?.kind !== 'bgCharity');
return advance(state, g => g.phase === 3 && clean(g), actions);
}
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import type { Card } from '../game/cards';
import { nativeShipmentSources } from '../game/homeworld-options';
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
type SemutaEmperorShareKind } from './fixture-semuta-emperor-share';
export type SemutaHomeworldEmperorShareFixture = {
kind: SemutaEmperorShareKind; game: Game; setup: Game; beforeShipment: Game;
paidShipment: Game; beforeWeather: Game; weather: Game; death: Game; worm: Game;
alliance: Game; beforeBenefit: Game; beforeDeclaration: Game;
owner: string; holder: string; recipient: string;
canceller: string; karama: string; karamaCard: Card; weatherCard: string;
shipment: Action; weatherAction: Action; declare: Action; cancel: Action;
actions: RicheseSkillsTechStep[]; staging: string[];
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.pendingTreacheryDiscard;
export function nextSemutaHomeworldEmperorShareStep(game: Game): RicheseSkillsTechStep {
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
if (pending && reaction?.stage === 'offer') return {
actor: game.players.find(p => !reaction.passed.includes(p.id))!.id,
action: { type: 'semutaPass', event: pending.batch.event },
};
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.biddingEnd) {
const end = game.biddingEnd;
return { actor: end.owners.find(id => !end.ready.includes(id))!,
action: { type: 'biddingEnd', event: end.event, mode: 'ready' } };
}
if (game.decision?.kind === 'choamMarket') return {
actor: game.decision.player, action: { type: 'decision', done: true },
};
// The Homeworld CHOAM Mentat window is optional: decline it so its special
// effects never disturb the deployed Atreides counter before the storm.
if (game.decision?.kind === 'choamMentat') return {
actor: game.decision.player, action: { type: 'decision', done: true },
};
return nextRicheseSkillsTechNativeStep(game);
}
export function advanceSemutaHomeworldEmperorShare(state: Game, until: (game: Game) => boolean,
actions?: RicheseSkillsTechStep[]): Game {
let game = state;
for (let n = 0; !until(game) && n < 2000; n++) {
assert.notEqual(game.status, 'finished');
const next = nextSemutaHomeworldEmperorShareStep(game);
actions?.push(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(until(game), 'Original share continuation reaches the requested native boundary');
return game;
}
export function semutaHomeworldEmperorShareStock(game: Game): string[] {
return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
...game.players.map(p => p.hand)].flat().map(card => card.id).sort();
}
export function semutaHomeworldEmperorShareResources(game: Game) {
return { custody: structuredClone(game.homeworlds?.custody), emperorExtra: { ...game.emperorExtra },
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: { ...p.forces },
reserves: p.reserves, tanks: p.tanks, elites: structuredClone(p.elites),
revived: p.revived, freeForcesRevived: p.freeForcesRevived,
battleLosses: p.battleLosses, ally: p.ally, allySinceTurn: p.allySinceTurn })) };
}
function orderUnplayedSpice(game: Game, kinds: ('land' | 'worm')[]): void {
for (const [position, kind] of kinds.entries()) {
const at = game.spiceDeck.findIndex((card, i) => i >= position &&
(kind === 'land' ? 'territory' in card : 'worm' in card));
assert.ok(at >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
}
/** Original R/C/E/A setup, paid three-counter deployment, real Weather casualties
* and mutual worm Nexus alliance. Only physical cards/unplayed Spice order move. */
export function createSemutaHomeworldEmperorShareFixture(
kind: SemutaEmperorShareKind, advanced = false, fullHolder = false,
): SemutaHomeworldEmperorShareFixture {
let game = createGame('SEMUTAHWSHARE', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
game = applyAction(game, 'r', { type: 'homeworlds', enabled: true });
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
game = initializeSemutaGameForAudit(game);
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
const reach = (until: (g: Game) => boolean) => {
game = advanceSemutaHomeworldEmperorShare(game, until, actions);
};
const act = (actor: string, action: Action) => {
actions.push({ actor, action }); game = applyAction(game, actor, action);
};
reach(g => g.status === 'playing');
const setup = structuredClone(game);
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
orderUnplayedSpice(game, ['land', 'land']);
staging.push('Original physical Semuta to Richese; first two unplayed original Spice Cards are non-worm.');
reach(g => g.phase === 5 && g.active === 'a' && clean(g));
const beforeShipment = structuredClone(game);
const homeworldSources = nativeShipmentSources(viewGame(game, 'a'), 3, 0);
assert.ok(homeworldSources);
const shipment: Action = { type: 'ship', territory: 'red_chasm', sector: 7,
amount: 3, elite: 0, homeworldSources, allyPayment: 0 };
act('a', shipment);
reach(clean);
const paidShipment = structuredClone(game);
assert.equal(semutaEmperorPlayer(game, 'a').spice, semutaEmperorPlayer(beforeShipment, 'a').spice - 6);
assert.equal(semutaEmperorPlayer(game, 'a').forces['red_chasm:7'], 3);
assert.equal(semutaEmperorPlayer(game, 'a').reserves, 7);
act('a', { type: 'endMovement' });
reach(g => g.turn === 2 && g.phase === 0 && clean(g));
const weatherCard = semutaEmperorCardId(game, card => card.effect === 'weather');
moveSemutaEmperorCard(game, weatherCard, 'a');
staging.push('Conserved original printed Weather Control to Atreides before the second native Storm.');
const beforeWeather = structuredClone(game);
assert.equal(game.storm, 1);
const weatherAction: Action = { type: 'card', card: weatherCard, amount: 6 };
act('a', weatherAction);
reach(clean);
const weather = structuredClone(game);
assert.equal(game.stormPending, 6);
assert.equal(game.stormMovementSource?.kind, 'weather');
reach(g => g.phase === 1 && clean(g));
const death = structuredClone(game);
assert.equal(game.storm, 7);
assert.equal(semutaEmperorPlayer(game, 'a').forces['red_chasm:7'] ?? 0, 0);
assert.equal(semutaEmperorPlayer(game, 'a').forces['arrakeen:10'], 10);
assert.equal(semutaEmperorPlayer(game, 'a').tanks, 3);
assert.equal(semutaEmperorPlayer(game, 'e').spice, 10);
assert.ok(semutaEmperorPlayer(game, 'r').hand.some(card => card.id === SEMUTA_DRUG_ID));
orderUnplayedSpice(game, ['worm', 'land', 'land']);
staging.push('The next original unplayed worm opens the second-turn Nexus; later original land cards close Spice Blow.');
reach(g => g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g));
const worm = structuredClone(game);
act('e', { type: 'alliance', target: 'a' });
act('a', { type: 'alliance', target: 'e' });
const alliance = structuredClone(game);
reach(g => g.phase === (kind === 'emperorGift' ? 2 : 4) && clean(g));
const beforeBenefit = structuredClone(game);
// Conserved late hand positioning follows every preceding committed market pool.
for (const p of game.players) game.deck.push(...p.hand.splice(0));
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
const karama = semutaEmperorCardId(game, card => card.effect === 'karama');
moveSemutaEmperorCard(game, karama, 'a');
if (fullHolder) while (semutaEmperorPlayer(game, 'r').hand.length < handLimit(semutaEmperorPlayer(game, 'r'))) {
assert.ok(game.deck[0]); moveSemutaEmperorCard(game, game.deck[0].id, 'r');
}
const karamaCard = semutaEmperorPlayer(game, 'a').hand.find(card => card.id === karama)!;
staging.push('Conserved original printed Karama to Atreides and Semuta to Richese; optional full hand uses original undealt stock.');
assert.deepEqual(semutaHomeworldEmperorShareStock(game), semutaHomeworldEmperorShareStock(setup));
assert.equal(semutaEmperorPlayer(game, 'e').spice, 10);
assert.equal(semutaEmperorPlayer(game, 'a').tanks, 3);
assert.equal(semutaEmperorPlayer(game, 'a').revived, 0);
// Real UI entry point for the Emperor's own declaration: every conserved card
// is already positioned, and only the declared benefit response is missing.
const beforeDeclaration = structuredClone(game);
const declare: Action = { type: kind, amount: kind === 'emperorGift' ? 3 : 2 };
act('e', declare);
assert.equal(game.response?.kind, kind);
return { kind, game, setup, beforeShipment, paidShipment, beforeWeather, weather, death,
worm, alliance, beforeBenefit, beforeDeclaration,
owner: 'e', holder: 'r', recipient: 'a', canceller: 'a',
karama, karamaCard, weatherCard, shipment, weatherAction,
declare,
cancel: { type: 'card', mode: 'cancel', card: karama }, actions, staging };
}
/** Stop at the paid printed cancellation receipt, before its native denial. */
export function disposeSemutaHomeworldEmperorShare(f: SemutaHomeworldEmperorShareFixture,
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
assert.equal(state.response?.kind, f.kind);
actions?.push({ actor: f.canceller, action: f.cancel });
return applyAction(state, f.canceller, f.cancel);
}
export function reactSemutaHomeworldEmperorShare(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 p of state.players) game = applyAction(game, p.id, { type: 'semutaPass', event });
return game;
}
/** The still-legal ordinary return this fixture exercises at that revival window. */
export function semutaHomeworldEmperorOrdinaryRevive(game: Game, playerId: string): number {
const seat = semutaEmperorPlayer(game, playerId);
const limit = Math.min(forceRevivalRemaining(game, seat), seat.tanks);
return Array.from({ length: limit }, (_, index) => index + 1)
.filter(count => forceRevivalQuote(game, seat, count).cost <= seat.spice).at(-1) ?? 0;
}
/** Use the same original controls. Revival additionally exercises the still-legal
* ordinary Atreides return of whatever two-cost revivals remain that turn. */
export function finishSemutaHomeworldEmperorShare(state: Game, f: SemutaHomeworldEmperorShareFixture,
actions?: RicheseSkillsTechStep[]): Game {
let game = advanceSemutaHomeworldEmperorShare(state, clean, actions);
if (f.kind === 'emperorRevival') {
const amount = semutaHomeworldEmperorOrdinaryRevive(game, f.recipient);
assert.ok(amount > 0, 'The original Tanks still support an ordinary revival this turn');
const revive: Action = { type: 'revive', amount, elite: 0 };
actions?.push({ actor: f.recipient, action: revive });
game = applyAction(game, f.recipient, revive);
}
return advanceSemutaHomeworldEmperorShare(game,
g => g.turn === 2 && g.phase === (f.kind === 'emperorGift' ? 3 : 5) && clean(g), actions);
}
+197
View File
@@ -0,0 +1,197 @@
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 { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import { semutaHomeworldChoamMovementResources as resources,
semutaHomeworldChoamMovementStock as stock } from './fixture-semuta-homeworld-choam-movement';
import { withoutSemutaHomeworldEconomicHolder } from './fixture-semuta-homeworld-economic-karama';
import {
createSemutaHomeworldBgCharityFixture, disposeSemutaHomeworldBgCharity,
reactSemutaHomeworldBgCharity, finishSemutaHomeworldBgCharity,
type SemutaHomeworldBgCharityFixture,
} from './fixture-semuta-homeworld-bg-charity';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected wrong-owner, stale or replay actions have no effect');
}
function assertNative(f: SemutaHomeworldBgCharityFixture) {
assert.equal(f.game.turn, 1);
assert.equal(f.game.phase, 2);
assert.equal(player(f.setup, f.payer).spice, 2);
assert.equal(player(f.originalIncomeResponse, f.payer).spice, 2);
assert.equal(f.originalIncomeResponse.response?.kind, 'choamCharity');
assert.equal(player(f.game, f.payer).spice, 12, 'Actual five-player income is ten, once');
assert.deepEqual(f.game.choamCharity, { turn: 1, canceled: false });
assert.equal(player(f.setup, f.owner).spice, 5);
assert.equal(player(f.game, f.owner).spice, 5, 'BG is genuinely wealthy, never staged poor');
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);
if (seat.id !== f.payer) assert.equal(seat.spice, player(f.setup, seat.id).spice);
}
assert.deepEqual(stock(f.game), stock(f.setup), 'Native stock is physically conserved');
const own = viewGame(f.gameBeforeBGclaim, f.owner);
assert.equal(own.charity.homeworld, 0, 'High Wallach gives no fabricated low-population bonus');
assert.equal(own.charity.payer, f.payer);
if (f.game.advanced) {
assert.equal(own.charity.amount, 2);
assert.equal(f.amount, 2);
assert.equal(f.game.response?.kind, 'bgCharity');
assert.equal(f.game.response?.owner, f.owner);
assert.equal(f.game.response?.amount, 2);
assert.equal(f.game.response?.charityHomeworld, 0);
assert.notEqual(player(f.gameBeforeBGclaim, f.owner).charityTurn, 1);
assert.equal(player(f.game, f.owner).charityTurn, 1, 'Actual claim consumes the allowance before its response');
} else {
assert.equal(own.charity.amount, 0);
assert.equal(f.originalResponse, null);
assert.equal(f.game.response, null);
assert.notEqual(player(f.game, f.owner).charityTurn, 1);
}
}
function assertDenied(f: SemutaHomeworldBgCharityFixture, game: Game, claimed: boolean) {
assert.ok(!game.pendingTreacheryDiscard);
assert.notEqual(game.response?.kind, 'bgCharity');
assert.equal(game.turn, 1);
assert.equal(game.phase, 2);
assert.deepEqual(resources(game), resources(f.game), 'Denial and recovery neither pay BG nor debit CHOAM or move forces/custody');
assert.deepEqual(stock(game), stock(f.game));
assert.equal(player(game, f.owner).spice, 5);
assert.equal(player(game, f.payer).spice, 12);
assert.equal(player(game, f.owner).charityTurn, 1);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), claimed);
assert.equal(player(game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID),
!claimed && player(f.game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.equal(game.discard.filter(card => card.id === f.card).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claimed));
for (const seat of game.players)
assert.equal(seat.hand.length, player(f.game, seat.id).hand.length - Number(seat.id === f.actor));
reject(game, f.owner, f.claim);
reject(game, f.actor, f.action);
if (claimed) reject(game, f.holder, f.action);
}
function assertSuffix(f: SemutaHomeworldBgCharityFixture, game: Game) {
const next = finishSemutaHomeworldBgCharity(game);
assert.equal(next.status, 'playing');
assert.equal(next.phase, 3);
assert.equal(next.turn, 1);
assert.ok(!next.pendingTreacheryDiscard && !next.response && !next.decision && !next.phaseOpening);
assert.deepEqual(resources(next), resources(f.game), 'Original native close never restores the denied two spice');
assert.equal(player(next, f.owner).charityTurn, 1);
assert.equal(viewGame(next, f.owner).charity.payer, f.payer);
assert.equal(player(next, f.payer).spice, 12);
reject(next, f.owner, f.claim);
reject(next, f.actor, f.action);
const nextAction = nextRicheseSkillsTechNativeStep(next);
const after = applyAction(next, nextAction.actor, nextAction.action);
assert.equal(after.turn, 1);
assert.equal(after.phase, 3);
assert.equal(player(after, f.owner).spice, 5);
assert.equal(player(after, f.payer).spice, 12);
}
for (const claimed of [false, true]) void test(`Advanced native BG charity: ${claimed ? 'claim' : 'pass'} pays the printed Karama once and denies the original two spice`, () => {
const f = createSemutaHomeworldBgCharityFixture();
assertNative(f);
reject(f.game, f.holder, f.action);
const game = disposeSemutaHomeworldBgCharity(f), cost = game.pendingTreacheryDiscard;
assert.ok(cost && cost.reaction?.stage === 'offer');
assert.equal(cost.continuation.kind, 'karamaBgCharityDiscard');
assert.equal(cost.batch.cause, 'karama:bgCharity');
assert.deepEqual(cost.batch.entries.map(entry => ({ card: entry.card.id, actor: entry.discardedBy, face: entry.publicFace })),
[{ card: f.card, actor: f.actor, face: true }]);
if (cost.continuation.kind === 'karamaBgCharityDiscard') {
assert.deepEqual(cost.continuation.use.response, f.originalResponse!.response);
assert.deepEqual(cost.continuation.resume.response, f.originalResponse!.response);
}
assert.equal(game.response, null);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
assert.deepEqual(resources(game), resources(f.game));
assert.equal(player(game, f.owner).charityTurn, 1);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
reject(game, f.owner, f.claim);
reject(game, f.actor, f.action);
reject(game, f.actor, { type: 'passResponse' });
reject(game, f.target, { type: 'semutaCommit', event: cost.batch.event });
reject(game, f.holder, { type: 'semutaCommit', event: `${cost.batch.event}:stale` });
const done = reactSemutaHomeworldBgCharity(JSON.parse(JSON.stringify(game)), 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('Advanced native BG charity: full four-card Richese exchanges one card and keeps the absent-Semuta boundary private', () => {
const f = createSemutaHomeworldBgCharityFixture(true, true);
assertNative(f);
assert.equal(player(f.game, f.holder).hand.length, 4);
assert.equal(handLimit(player(f.game, f.holder)), 4);
const game = disposeSemutaHomeworldBgCharity(f);
const neutral = disposeSemutaHomeworldBgCharity(f, withoutSemutaHomeworldEconomicHolder(f.game, f.holder));
assert.equal(viewGame(game, 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 game.players.filter(p => p.id !== f.holder)) {
assert.deepEqual(viewGame(game, seat.id), viewGame(neutral, seat.id));
assert.equal(viewGame(game, seat.id).semutaReaction?.canCommit, false);
}
const done = reactSemutaHomeworldBgCharity(game, f.holder, true);
assertDenied(f, done, true);
assert.equal(player(done, f.holder).hand.length, 4);
const passed = reactSemutaHomeworldBgCharity(neutral, f.holder, false);
assertDenied({ ...f, game: withoutSemutaHomeworldEconomicHolder(f.game, f.holder) }, passed, false);
assert.deepEqual(resources(passed), resources(done));
assertSuffix(f, done);
});
void test('Basic native wealthy BG cannot create an extra charity response or spend its cancel cost', () => {
const f = createSemutaHomeworldBgCharityFixture(false);
assertNative(f);
reject(f.game, f.owner, f.claim);
reject(f.game, f.actor, f.action);
assert.equal(player(f.game, f.actor).hand.some(card => card.id === f.card), true);
assert.equal(f.game.discard.some(card => card.id === f.card), false);
assert.ok(!f.game.pendingTreacheryDiscard && !f.game.response);
const next = finishSemutaHomeworldBgCharity(f.game);
assert.equal(next.phase, 3);
assert.deepEqual(resources(next), resources(f.game));
assert.equal(player(next, f.owner).spice, 5);
assert.equal(player(next, f.payer).spice, 12);
assert.notEqual(player(next, f.owner).charityTurn, 1);
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: actual Semuta and native next BG controls preserve the canceled charity allowance`, () => {
const f = createSemutaHomeworldBgCharityFixture();
let game = disposeSemutaHomeworldBgCharity(f);
const own = viewGame(game, f.holder);
own.players.find(p => p.id === f.holder)!.bot = difficulty;
const reaction = botActions(own)[0];
assert.ok(reaction && (reaction.type === 'semutaCommit' || reaction.type === 'semutaPass'));
game = applyAction(game, f.holder, reaction);
if (game.pendingTreacheryDiscard) game = reactSemutaHomeworldBgCharity(game, f.holder, false);
assertDenied(f, game, reaction.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(player(after, f.owner).charityTurn, 1);
assert.equal(player(after, f.owner).spice, 5);
assert.equal(player(after, f.payer).spice, 12);
assertSuffix(f, after);
});
@@ -0,0 +1,203 @@
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 { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import type { SemutaEmperorShareKind } from './fixture-semuta-emperor-share';
import {
createSemutaHomeworldEmperorShareFixture, disposeSemutaHomeworldEmperorShare,
finishSemutaHomeworldEmperorShare, reactSemutaHomeworldEmperorShare,
semutaHomeworldEmperorOrdinaryRevive,
semutaHomeworldEmperorShareResources as resources,
semutaHomeworldEmperorShareStock as stock,
type SemutaHomeworldEmperorShareFixture,
} from './fixture-semuta-homeworld-emperor-share';
const kinds: SemutaEmperorShareKind[] = ['emperorGift', 'emperorRevival'];
const shareOf = (kind: SemutaEmperorShareKind) => kind === 'emperorGift' ? 'gift' : 'revival';
const endPhase = (kind: SemutaEmperorShareKind) => kind === 'emperorGift' ? 3 : 5;
/** The denied share spends the printed Karama once and moves no benefit. */
function assertConsumedDenial(game: Game, f: SemutaHomeworldEmperorShareFixture) {
assert.equal(game.discard.filter(card => card.id === f.karama).length +
player(game, f.holder).hand.filter(card => card.id === f.karama).length, 1,
'The exact spent cost is either still discarded or recovered once');
assert.equal(player(game, f.canceller).hand.some(card => card.id === f.karama), false);
assert.equal(game.emperorExtra[f.recipient] ?? 0, 0, 'No extra-revival allowance is granted');
assert.deepEqual(resources(game), resources(f.beforeBenefit),
'A denied share changes no wallet, force, Tank, custody or revival ledger');
assert.equal(game.storm, f.death.storm, 'The original storm history is retained');
assert.equal(player(game, f.recipient).allySinceTurn, player(f.alliance, f.recipient).allySinceTurn,
'The original turn-two alliance stamp is retained');
}
for (const advanced of [false, true]) {
const label = advanced ? 'Advanced' : 'Basic';
for (const kind of kinds) {
void test(`${label} Homeworld: original Emperor ${shareOf(kind)} declaration accepts the real Atreides Karama cost`, () => {
const f = createSemutaHomeworldEmperorShareFixture(kind, advanced);
assert.deepEqual(resources(f.game), resources(f.beforeBenefit),
'Declaring the share spends nothing before the printed cancellation');
assert.deepEqual(stock(f.game), stock(f.setup));
assert.equal(f.game.turn, 2);
assert.equal(f.game.phase, kind === 'emperorGift' ? 2 : 4);
assert.equal(f.beforeWeather.storm, 1);
assert.equal(f.weather.stormMovementSource?.kind, 'weather');
assert.equal(f.weather.stormMovementSource?.distance, 6);
assert.equal(f.game.storm, 7);
assert.equal(player(f.paidShipment, f.recipient).spice, player(f.beforeShipment, f.recipient).spice - 6);
assert.equal(player(f.paidShipment, f.recipient).forces['red_chasm:7'], 3);
assert.equal(player(f.death, f.recipient).forces['red_chasm:7'] ?? 0, 0);
assert.equal(player(f.death, f.recipient).tanks, 3);
assert.equal(player(f.death, f.recipient).forces['arrakeen:10'], 10);
assert.equal(f.worm.nexus, true);
assert.equal(player(f.alliance, f.owner).ally, f.recipient);
assert.equal(player(f.alliance, f.recipient).ally, f.owner);
assert.equal(Boolean(f.game.nexus), false, 'The original worm Nexus has resolved');
assert.equal(f.game.response?.kind, kind);
assert.equal(f.game.response?.owner, f.owner);
assert.equal(f.game.response?.recipient, f.recipient);
assert.equal(f.game.response?.amount, kind === 'emperorGift' ? 3 : 2);
assert.deepEqual(viewGame(f.game, f.canceller).responseControls?.cancelCards, [f.karama],
'The allied recipient holds the exact original printed cancellation');
for (const seat of f.game.players.filter(seat => seat.id !== f.canceller))
assert.equal(viewGame(f.game, seat.id).responseControls?.cancelCards.length ?? 0, 0,
'No other original seat can spend the allied printed cancellation');
assert.equal(player(f.game, f.owner).spice, 10);
const receipt = disposeSemutaHomeworldEmperorShare(f);
const pending = receipt.pendingTreacheryDiscard!;
assert.equal(pending.continuation.kind, 'karamaEmperorShareDiscard');
assert.equal(pending.batch.cause, `karama:${kind}`);
assert.deepEqual(pending.batch.entries,
[{ card: f.karamaCard, discardedBy: f.canceller, publicFace: true }]);
assert.equal(pending.reaction?.stage, 'offer');
assert.equal(receipt.response, null);
assert.equal(receipt.decision, null);
assert.equal(viewGame(receipt, f.holder).semutaReaction?.canCommit, true);
for (const seat of receipt.players.filter(seat => seat.id !== f.holder))
assert.equal(viewGame(receipt, seat.id).semutaReaction?.canCommit, false,
'Only the original Semuta holder receives the neutral offer');
assertConsumedDenial(receipt, f);
});
for (const claim of [false, true]) void test(`${label} Homeworld: Semuta ${claim ? 'claim' : 'pass'} decides the spent Karama and denies the ${shareOf(kind)}`, () => {
const f = createSemutaHomeworldEmperorShareFixture(kind, advanced);
const receipt = disposeSemutaHomeworldEmperorShare(f);
const sequence = receipt.pendingTreacheryDiscard!.sequence;
const event = receipt.pendingTreacheryDiscard!.batch.event;
assert.deepEqual(resources(receipt), resources(f.beforeBenefit));
const done = reactSemutaHomeworldEmperorShare(receipt, f.holder, claim);
assert.equal(done.resolvedTreacheryDiscardSequence, sequence);
assert.equal(done.pendingTreacheryDiscard, null);
assert.equal(player(done, f.holder).hand.some(card => card.id === f.karama), claim);
assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!claim));
assert.equal(player(done, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
assertConsumedDenial(done, f);
assert.equal(player(done, f.owner).spice, player(f.beforeBenefit, f.owner).spice);
assert.equal(player(done, f.recipient).spice, player(f.beforeBenefit, f.recipient).spice);
assert.equal(player(done, f.recipient).forces['arrakeen:10'], 10);
assert.equal(player(done, f.recipient).forces['red_chasm:7'] ?? 0, 0);
assert.equal(player(done, f.recipient).tanks, 3);
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(done, f.canceller, f.cancel),
'The spent printed Karama cannot cancel again');
assert.throws(() => applyAction(done, f.holder, { type: 'alliance', target: f.owner }),
'No second alliance is offered outside the original worm Nexus');
assert.deepEqual(stock(done), stock(f.setup));
const finished = finishSemutaHomeworldEmperorShare(structuredClone(done), f);
assert.equal(finished.turn, 2);
assert.equal(finished.phase, endPhase(kind));
assert.equal(finished.storm, 7);
assert.equal(player(finished, f.recipient).forces['arrakeen:10'], 10);
assert.equal(player(finished, f.recipient).forces['red_chasm:7'] ?? 0, 0);
assert.equal(player(finished, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.deepEqual(stock(finished), stock(f.setup));
if (kind === 'emperorRevival') {
const legal = semutaHomeworldEmperorOrdinaryRevive(done, f.recipient);
assert.equal(legal, 3);
assert.equal(player(finished, f.recipient).revived, 3,
'All three real storm casualties return through ordinary revival');
assert.equal(player(finished, f.recipient).freeForcesRevived, 2,
'Only the printed two ordinary free returns are granted');
}
});
}
void test(`${label} Homeworld: the paid Emperor share still settles natively when the allied cancellation is declined`, () => {
for (const kind of kinds) {
const f = createSemutaHomeworldEmperorShareFixture(kind, advanced);
const before = player(f.game, f.owner).spice;
const settled = applyAction(f.game, f.canceller, { type: 'passResponse' });
if (kind === 'emperorGift') {
assert.equal(player(settled, f.owner).spice, before - 3);
assert.equal(player(settled, f.recipient).spice, player(f.game, f.recipient).spice + 3);
} else {
assert.equal(settled.phase, 4);
assert.equal(settled.emperorExtra[f.recipient] ?? 0, 2,
'The accepted extra revival really grants its two-revival allowance');
}
assert.equal(player(settled, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true);
assert.equal(player(settled, f.recipient).forces['arrakeen:10'], 10);
assert.equal(settled.storm, 7);
const finished = finishSemutaHomeworldEmperorShare(settled, f);
assert.equal(finished.turn, 2);
assert.equal(finished.phase, endPhase(kind));
if (kind === 'emperorRevival')
assert.equal(player(finished, f.recipient).revived,
semutaHomeworldEmperorOrdinaryRevive(settled, f.recipient),
'Only the ordinary return the remaining Tanks and allowance permit happens');
}
});
for (const kind of kinds) void test(`${label} Homeworld: full Richese hand still exchanges the Karama privately and finishes the original ${shareOf(kind)} step`, () => {
const f = createSemutaHomeworldEmperorShareFixture(kind, advanced, true);
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
const receipt = disposeSemutaHomeworldEmperorShare(f);
assert.deepEqual(viewGame(f.game, f.canceller).responseControls?.cancelCards, [f.karama],
'The allied recipient keeps its exact printed cancellation with a full holder hand');
assert.equal(viewGame(receipt, f.holder).semutaReaction?.canCommit, true);
const claimed = reactSemutaHomeworldEmperorShare(receipt, f.holder, true);
assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder)));
assert.equal(player(claimed, f.holder).hand.filter(card => card.id === f.karama).length, 1);
assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assertConsumedDenial(claimed, f);
assert.equal(finishSemutaHomeworldEmperorShare(claimed, f).phase, endPhase(kind));
});
void test(`${label} Homeworld: every minimal bot profile legally finishes the spent Karama receipt`, () => {
for (const difficulty of DIFFICULTIES) {
const f = createSemutaHomeworldEmperorShareFixture('emperorRevival', advanced);
const receipt = disposeSemutaHomeworldEmperorShare(f);
const own = viewGame(structuredClone(receipt), f.holder);
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const action = botActions(own)[0];
assert.ok(action, `${difficulty} has an entitled receipt action`);
assert.ok(['semutaCommit', 'semutaPass'].includes(action.type));
let game: Game = structuredClone(receipt);
game = applyAction(game, f.holder, action);
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
if (reaction?.stage !== 'offer') throw Error('Missing neutral discard offer');
game = applyAction(game, game.players.find(seat => !reaction.passed.includes(seat.id))!.id,
{ type: 'semutaPass', event: pending.batch.event });
}
assert.equal(game.pendingTreacheryDiscard, null);
assertConsumedDenial(game, f);
assert.equal(player(game, f.recipient).tanks, 3);
assert.equal(player(game, f.recipient).forces['arrakeen:10'], 10);
const finished = finishSemutaHomeworldEmperorShare(game, f);
assert.equal(finished.phase, 5);
const legal = semutaHomeworldEmperorOrdinaryRevive(game, f.recipient);
assert.equal(legal, 3);
assert.equal(player(finished, f.recipient).revived, 3,
'The three ordinary returns remain legal after the denied extra revival');
assert.equal(player(finished, f.recipient).freeForcesRevived, 2);
assert.equal(finished.emperorExtra[f.recipient] ?? 0, 0);
}
});
}