Compose Homeworld carried-flight and finite-order disposal with Semuta

This commit is contained in:
Quintine committed 2026-10-13 04:03:21 +11:00
1 parent b8a66a864b
commit 4966b1d062
12 files changed
+752 -2

No files matched your search

@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board';
import { nativeShipmentSources } from '../game/homeworld-options';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import type { OrnithopterMode } from '../game/ornithopter';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
export type SemutaHomeworldOrnithopterFixture = {
setup: Game; beforeShipment: Game; afterShipment: Game; beforeFirstMove: Game; game: Game;
owner: string; holder: string; card: string; mode: OrnithopterMode; earlyFinish: boolean;
origin: string; destination: string; secondDestination: string; tooFarDestination: string;
shipment: Action; firstMove: Action; action: Action; moves: number; amount: number;
sources: NativeReserveSelections; originalForces: Record<string, number>;
actions: RicheseSkillsTechStep[]; staging: string[];
};
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
export function semutaHomeworldOrnithopterStock(game: Game): string[] {
return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
...game.players.map(p => p.hand), ...(game.ornithopter ? [[game.ornithopter.card]] : [])]
.flat().map(card => card.id).sort();
}
export function semutaHomeworldOrnithopterResources(game: Game) {
return { custody: structuredClone(game.homeworlds?.custody), spice: structuredClone(game.spice),
players: game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves,
tanks: p.tanks, forces: structuredClone(p.forces), elites: structuredClone(p.elites),
shipped: p.shipped, moved: p.moved, ally: p.ally })) };
}
function destination(game: Game, origin: string, range: number, excluded: string[] = []) {
const key = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id, s)))
.find(key => !excluded.includes(key) && splitLocation(key).sector !== game.storm &&
game.players.every(p => !(p.forces[key] > 0)) &&
gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === range);
assert.ok(key, `Original board needs an empty sand destination at range ${range}`);
return key;
}
/** Fresh original setup and paid native CHOAM source counters. Only original
* unplayed Spice Card order and conserved physical hand positions are staged. */
export function createSemutaHomeworldOrnithopterFixture(
mode: OrnithopterMode, advanced = false, earlyFinish = false, fullHolder = false,
): SemutaHomeworldOrnithopterFixture {
assert.ok(!earlyFinish || mode === 'twoGroups');
const native = createSemutaHomeworldChoamSetup(advanced);
let game = native.game;
const setup = structuredClone(game), actions = native.actions;
const staging = ['Conserved original physical Semuta in Richese hand.'];
const blows = game.spiceDeck.filter(c => 'territory' in c).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('Reorder two original unplayed non-worm Spice Cards; no replacement deck.');
game = advanceSemutaHomeworldChoam(game,
g => g.turn === 1 && g.phase === 5 && g.active === native.owner && clean(g), actions);
const card = holdSemutaHomeworldChoamCard(game, native.owner, 'Ornithopter');
assert.equal(card, 'richese-ornithopter');
assert.ok(player(game, native.owner).hand.length <= handLimit(player(game, native.owner)));
staging.push('Conserved original Richese cache Ornithopter to CHOAM; acquisition history unchanged.');
if (fullHolder) {
const holder = player(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 original undealt cards fill Richese to its native hand limit.');
}
const origin = 'red_chasm:7', amount = mode === 'twoGroups' && !earlyFinish ? 2 : 1;
const sources = nativeShipmentSources(viewGame(game, native.owner), amount, 0);
assert.ok(sources, 'Native Homeworld reserves must supply the actual counters');
const shipment: Action = { type: 'ship', ...splitLocation(origin), amount, elite: 0,
homeworldSources: sources, allyPayment: 0 };
const beforeShipment = structuredClone(game);
game = applySemutaHomeworldChoamStep(game, native.owner, shipment, actions);
game = advanceSemutaHomeworldChoam(game, clean, actions);
const afterShipment = structuredClone(game), beforeFirstMove = structuredClone(game);
const originalForces = structuredClone(player(game, native.owner).forces);
const target = destination(game, origin, mode === 'range3' ? 3 : 1);
const secondDestination = destination(game, origin, 1, [target]);
const tooFarDestination = destination(game, origin, 4);
const option = viewGame(game, native.owner).ornithopter;
assert.equal(option?.card.id, card);
assert.ok(option?.modes.some(m => m.mode === mode && !m.blocked));
const firstMove: Action = { type: 'move', from: origin, amount: 1, elite: 0,
...splitLocation(target), movementCard: card, ornithopter: mode };
let action = firstMove;
if (mode === 'twoGroups') {
game = applySemutaHomeworldChoamStep(game, native.owner, firstMove, actions);
assert.equal(game.ornithopter?.completed, 1);
action = earlyFinish ? { type: 'endMovement' } : { type: 'move', from: origin, amount: 1,
elite: 0, ...splitLocation(secondDestination), ornithopterEvent: game.ornithopter!.event };
}
return { setup, beforeShipment, afterShipment, beforeFirstMove, game,
owner: native.owner, holder: native.holder, card, mode, earlyFinish, origin,
destination: target, secondDestination, tooFarDestination, shipment, firstMove, action,
amount, moves: mode === 'twoGroups' && !earlyFinish ? 2 : 1, sources: structuredClone(sources),
originalForces, actions, staging };
}
export function disposeSemutaHomeworldOrnithopter(f: SemutaHomeworldOrnithopterFixture,
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
return applySemutaHomeworldChoamStep(state, f.owner, f.action, actions);
}
/** Real original movement queue continuation; never another CHOAM ground move. */
export function finishSemutaHomeworldOrnithopter(state: Game, actions?: RicheseSkillsTechStep[]): Game {
return advanceSemutaHomeworldChoam(state, g => g.phase > 5 && clean(g), actions);
}
+134
View File
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board';
import { nativeShipmentSources } from '../game/homeworld-options';
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
export type SemutaHomeworldSaphoScope = 'onceAround' | 'movement';
export type SemutaHomeworldSaphoMode = 'first' | 'last';
export type SemutaHomeworldSaphoFixture = {
setup: Game; game: Game; scope: SemutaHomeworldSaphoScope; mode: SemutaHomeworldSaphoMode;
holder: string; owner: string; card: string; action: Action; event: string;
nextActor: string; suffix: string[]; prefix: string[]; originalQueue: string[];
lotCard: string | null; actions: RicheseSkillsTechStep[]; staging: string[];
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** Original completed R/E/C/A Homeworld setup, native finite lot or unstarted
* Movement queue. Only original unplayed Spice order and physical cards move. */
export function createSemutaHomeworldSaphoFixture(
scope: SemutaHomeworldSaphoScope, mode: SemutaHomeworldSaphoMode,
advanced = false, fullHolder = false,
): SemutaHomeworldSaphoFixture {
const native = createSemutaHomeworldChoamSetup(advanced);
let game = native.game;
const setup = structuredClone(game), actions = native.actions;
const staging = ['Conserved original physical Semuta in Richese hand.'];
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('Reorder two original unplayed non-worm Spice Cards; no replacement stock.');
let lotCard: string | null = null;
if (scope === 'onceAround') {
game = advanceSemutaHomeworldChoam(game, g => g.decision?.kind === 'richeseDeclaration', actions);
game = applySemutaHomeworldChoamStep(game, native.holder,
{ type: 'decision', event: game.richeseBidding!.event, position: 'first' }, actions);
game = advanceSemutaHomeworldChoam(game, g => g.decision?.kind === 'richeseCache', actions);
assert.equal(game.decision?.kind, 'richeseCache');
const selected = game.richeseCache!.find(card => card.id !== 'richese-juice-of-sapho')!;
lotCard = selected.id;
game = applySemutaHomeworldChoamStep(game, native.holder,
{ type: 'decision', event: game.richeseBidding!.event, card: selected.id,
method: 'onceAround', direction: 'counterclockwise' }, actions);
game = advanceSemutaHomeworldChoam(game, g => !!g.richeseAuction && clean(g), actions);
assert.equal(game.richeseAuction!.source, 'cache');
assert.equal(game.richeseAuction!.method, 'onceAround');
const actor = game.richeseAuction!.active!;
assert.ok(player(game, actor).spice >= 1);
game = applySemutaHomeworldChoamStep(game, actor,
{ type: 'richeseBid', event: game.richeseAuction!.event, amount: 1, allyPayment: 0 }, actions);
} else {
game = advanceSemutaHomeworldChoam(game, g => g.phase === 5 && clean(g), actions);
assert.ok(game.players.every(p => !p.shipped && p.moved === 0));
assert.deepEqual(game.movementRemaining, game.order);
}
assert.equal(game.turn, 1);
const originalQueue = scope === 'onceAround' ? [...game.richeseAuction!.order] : [...game.movementRemaining!];
const prefix = scope === 'onceAround' ? [...game.richeseAuction!.acted] : [];
const remaining = originalQueue.filter(id => !prefix.includes(id));
const owner = remaining.find(id => id !== native.holder &&
(mode === 'first' ? id !== remaining[0] : id !== remaining.at(-1)));
assert.ok(owner, 'A real non-Richese unacted actor changes its selected position');
const card = holdSemutaHomeworldChoamCard(game, owner, 'Juice of Sapho');
staging.push(`Conserved original printed Juice of Sapho to ${owner}; not an auction acquisition.`);
assert.ok(player(game, owner).hand.length <= handLimit(player(game, owner)));
if (fullHolder) {
const holder = player(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 original undealt cards fill Richese to its native hand limit.');
}
const option = viewGame(game, owner).saphoOptions.find(option => option.scope === scope && option.mode === mode);
assert.ok(option);
const action: Action = { type: 'card', card, scope, mode, event: option.event };
const suffix = mode === 'first' ? [owner, ...remaining.filter(id => id !== owner)]
: [...remaining.filter(id => id !== owner), owner];
assert.deepEqual(biddingPhysicalIds(game), biddingPhysicalIds(setup));
return { setup, game, scope, mode, holder: native.holder, owner, card, action,
event: option.event!, nextActor: suffix[0], suffix, prefix, originalQueue, lotCard, actions, staging };
}
export function disposeSemutaHomeworldSapho(fixture: SemutaHomeworldSaphoFixture, state = fixture.game): Game {
return applyAction(state, fixture.owner, fixture.action);
}
/** Finish only this same finite lot: each remaining bidder passes once, leaving
* the real earlier one-spice invoice to native quote/payment/acquisition. */
export function finishSemutaHomeworldSaphoAuction(state: Game, fixture: SemutaHomeworldSaphoFixture,
actions: RicheseSkillsTechStep[] = []): Game {
let game = state;
while (game.richeseAuction?.event === fixture.event && !game.richeseAuction.outcome) {
assert.ok(clean(game));
const actor = game.richeseAuction.active!;
game = applySemutaHomeworldChoamStep(game, actor,
{ type: 'richeseBid', event: fixture.event, amount: null, allyPayment: 0 }, actions);
}
return game;
}
/** One actual paid original reserve shipment and one ground move, then each
* original remaining combined turn ends once through public native controls. */
export function finishSemutaHomeworldSaphoMovement(state: Game, fixture: SemutaHomeworldSaphoFixture,
actions: RicheseSkillsTechStep[] = []): { game: Game; beforeShipment: Game; afterShipment: Game;
origin: string; destination: string; shipment: Action; move: Action } {
let game = state;
assert.ok(clean(game));
assert.equal(game.active, fixture.nextActor);
const origin = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id, s)))
.find(key => splitLocation(key).sector !== game.storm && game.players.every(p => !(p.forces[key] > 0)));
assert.ok(origin);
const destination = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id, s)))
.find(key => splitLocation(key).sector !== game.storm && game.players.every(p => !(p.forces[key] > 0)) &&
gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === 1);
assert.ok(destination);
const sources = nativeShipmentSources(viewGame(game, fixture.nextActor), 1, 0);
assert.ok(sources);
const shipment: Action = { type: 'ship', ...splitLocation(origin), amount: 1, elite: 0,
homeworldSources: sources, allyPayment: 0 };
const beforeShipment = structuredClone(game);
game = applySemutaHomeworldChoamStep(game, fixture.nextActor, shipment, actions);
game = advanceSemutaHomeworldChoam(game, clean, actions);
const afterShipment = structuredClone(game);
const move: Action = { type: 'move', from: origin, ...splitLocation(destination), amount: 1, elite: 0 };
game = applySemutaHomeworldChoamStep(game, fixture.nextActor, move, actions);
game = advanceSemutaHomeworldChoam(game, clean, actions);
game = advanceSemutaHomeworldChoam(game, g => g.phase > 5 && clean(g), actions);
return { game, beforeShipment, afterShipment, origin, destination, shipment, move };
}
+187
View File
@@ -0,0 +1,187 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { gameDistance, splitLocation } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { OrnithopterMode } from '../game/ornithopter';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { advanceSemutaHomeworldChoam, holdSemutaHomeworldChoamCard,
reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
import { createSemutaHomeworldOrnithopterFixture, disposeSemutaHomeworldOrnithopter,
finishSemutaHomeworldOrnithopter, semutaHomeworldOrnithopterResources as resources,
semutaHomeworldOrnithopterStock as stock } from './fixture-semuta-homeworld-ornithopter';
function assertCounters(game: Game, original: Game) {
for (const p of game.players) {
const before = player(original, p.id);
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, x) => n + x, 0),
before.reserves + before.tanks + Object.values(before.forces).reduce((n, x) => n + x, 0));
if (p.elites && before.elites) {
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((n, x) => n + x, 0),
before.elites.reserves + before.elites.tanks + Object.values(before.elites.forces).reduce((n, x) => n + x, 0));
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
}
}
assert.deepEqual(game.homeworlds?.custody, original.homeworlds?.custody);
}
const paths: { mode: OrnithopterMode; early: boolean }[] = [
{ mode: 'range3', early: false }, { mode: 'twoGroups', early: false }, { mode: 'twoGroups', early: true },
];
for (const advanced of [false, true]) for (const { mode, early } of paths) {
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Ornithopter ${mode}${early ? ' early finish' : ''}`;
for (const claim of [false, true]) void test(`${label}: ${claim ? 'claim' : 'pass'} retires once before the original arrival or turn continuation`, () => {
const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early);
const inventory = stock(f.beforeFirstMove), owner = player(f.afterShipment, f.owner);
assert.equal(f.afterShipment.turn, 1);
assert.equal(f.afterShipment.phase, 5);
assert.equal(owner.spice, player(f.beforeShipment, f.owner).spice - f.amount * 2);
assert.equal(owner.reserves, player(f.beforeShipment, f.owner).reserves - f.amount);
assert.equal(owner.forces[f.origin], f.amount);
assertCounters(f.afterShipment, f.setup);
const distance = gameDistance(f.beforeFirstMove, f.origin, f.destination,
key => splitLocation(key).sector === f.beforeFirstMove.storm);
assert.equal(distance, mode === 'range3' ? 3 : 1);
if (mode === 'range3') assert.throws(() => applyAction(f.beforeFirstMove, f.owner,
{ ...f.firstMove, movementCard: undefined, ornithopter: undefined }));
if (mode === 'twoGroups') {
assert.equal(player(f.game, f.owner).forces[f.destination], 1);
assert.equal(player(f.game, f.owner).moved, 1);
assert.equal(player(f.game, f.owner).hand.some(c => c.id === f.card), false);
assert.equal(f.game.discard.some(c => c.id === f.card), false);
assert.equal(viewGame(f.game, f.owner).ornithopter?.active?.remaining, 1);
assert.deepEqual(viewGame(f.game, f.owner).ornithopter?.active?.cohort?.forces,
early ? {} : { [f.origin]: 1 });
assert.throws(() => applyAction(f.game, f.owner, { type: 'move', from: f.destination,
amount: 1, elite: 0, ...splitLocation(f.origin), ornithopterEvent: f.game.ornithopter!.event }));
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, type: 'move',
from: f.origin, amount: 1, ...splitLocation(f.secondDestination), ornithopterEvent: 'stale' }));
}
const uninterrupted = reactSemutaHomeworldChoam(disposeSemutaHomeworldOrnithopter(f,
withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder)), f.holder, false);
const offered = disposeSemutaHomeworldOrnithopter(f);
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
assert.equal(offered.discard.filter(c => c.id === f.card).length, 1);
assert.equal(player(offered, f.owner).hand.some(c => c.id === f.card), false);
assert.equal(player(offered, f.owner).moved, f.moves);
assert.equal(player(offered, f.owner).forces[f.destination], 1);
if (mode === 'twoGroups' && !early) assert.equal(player(offered, f.owner).forces[f.secondDestination], 1);
assert.equal(player(offered, f.owner).forces[f.origin] ?? 0, 0);
assert.equal(offered.active, f.owner, 'Retirement suspends the same original turn, not its successor');
assert.deepEqual(offered.movementRemaining, f.game.movementRemaining);
assert.deepEqual(stock(offered), inventory);
assertCounters(offered, f.setup);
assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered);
const reloaded = normalizeAutomaticGame(JSON.parse(JSON.stringify(offered)));
assert.deepEqual(resources(reloaded), resources(offered));
assert.deepEqual(stock(reloaded), inventory);
assert.equal(reloaded.active, offered.active);
assert.deepEqual(reloaded.movementRemaining, offered.movementRemaining);
for (const p of offered.players) assert.deepEqual(viewGame(reloaded, p.id), viewGame(offered, p.id));
const frozen = resources(offered), event = offered.pendingTreacheryDiscard!.batch.event;
assert.throws(() => applyAction(offered, f.owner, { type: 'endMovement' }));
assert.throws(() => applyAction(offered, f.owner, f.action));
const resumed = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(offered)), f.holder, claim);
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.deepEqual(resources(resumed), frozen, 'Resume cannot replay a paid shipment or ground move');
assert.deepEqual(resources(resumed), resources(uninterrupted));
assert.equal(resumed.active, uninterrupted.active);
assert.deepEqual(resumed.movementRemaining, uninterrupted.movementRemaining);
assert.equal(player(resumed, f.holder).hand.filter(c => c.id === f.card).length, claim ? 1 : 0);
assert.equal(resumed.discard.filter(c => c.id === f.card).length, claim ? 0 : 1);
assert.equal(resumed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0);
assert.deepEqual(stock(resumed), inventory);
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(resumed, f.owner, f.firstMove));
assert.throws(() => applyAction(resumed, f.owner, { type: 'move', from: f.destination,
amount: 1, ...splitLocation(f.origin) }));
const replay = structuredClone(resumed);
replay.pendingTreacheryDiscard = structuredClone(offered.pendingTreacheryDiscard);
assert.throws(() => normalizeAutomaticGame(replay));
assert.deepEqual(normalizeAutomaticGame(structuredClone(resumed)), resumed);
const completed = finishSemutaHomeworldOrnithopter(resumed);
assert.equal(completed.turn, 1);
assert.equal(completed.phase, 7);
assert.equal(player(completed, f.owner).moved, f.moves);
assert.equal(player(completed, f.owner).reserves, owner.reserves);
assert.deepEqual(player(completed, f.owner).forces, player(offered, f.owner).forces);
assert.deepEqual(stock(completed), inventory);
assertCounters(completed, f.setup);
});
void test(`${label}: full-hand singleton entitlement is private and original forces cannot be borrowed by another player`, () => {
const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early, true);
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
assert.throws(() => applyAction(f.game, f.holder, f.action));
const offered = disposeSemutaHomeworldOrnithopter(f);
const neutral = disposeSemutaHomeworldOrnithopter(f, withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder));
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
const event = offered.pendingTreacheryDiscard!.batch.event;
for (const p of offered.players.filter(p => p.id !== f.holder)) {
assert.deepEqual(viewGame(offered, p.id), viewGame(neutral, p.id));
assert.equal(viewGame(offered, p.id).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(offered, p.id, { type: 'semutaCommit', event }));
}
assert.throws(() => applyAction(offered, f.holder, { type: 'semutaCommit', event: 'stale' }));
const claimed = reactSemutaHomeworldChoam(offered, f.holder, true);
assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder)));
assert.equal(player(claimed, f.holder).hand.some(c => c.id === f.card), true);
assert.equal(player(claimed, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), false);
const done = finishSemutaHomeworldOrnithopter(claimed);
assert.equal(player(done, f.owner).moved, f.moves);
assert.deepEqual(stock(done), stock(f.beforeFirstMove));
});
void test(`${label}: all four bot profiles legally claim or pass without a second shipment or repeated group`, () => {
const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early);
const offered = disposeSemutaHomeworldOrnithopter(f), frozen = resources(offered);
for (const difficulty of DIFFICULTIES) {
let game: Game = structuredClone(offered);
const own = viewGame(game, f.holder);
own.players.find(p => p.id === f.holder)!.bot = difficulty;
const action = botActions(own)[0];
assert.ok(action);
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass');
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 original retirement offer');
const actor = game.players.find(p => !reaction.passed.includes(p.id))!.id;
game = applyAction(game, actor, { type: 'semutaPass', event: pending.batch.event });
}
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(player(game, f.holder).hand.some(c => c.id === f.card), action.type === 'semutaCommit');
assert.deepEqual(resources(game), frozen);
const done = finishSemutaHomeworldOrnithopter(game);
assert.equal(player(done, f.owner).moved, f.moves);
assert.deepEqual(stock(done), stock(f.beforeFirstMove));
assertCounters(done, f.setup);
}
});
}
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld original prior-move, Hajr and fixed maximum-three guards remain real`, () => {
const f = createSemutaHomeworldOrnithopterFixture('range3', advanced);
const before = structuredClone(f.beforeFirstMove);
assert.throws(() => applyAction(before, f.owner, { ...f.firstMove, ...splitLocation(f.tooFarDestination) }));
assert.deepEqual(before, f.beforeFirstMove);
const moved = applyAction(before, f.owner, { ...f.firstMove, movementCard: undefined,
ornithopter: undefined, ...splitLocation(f.secondDestination) });
assert.equal(player(moved, f.owner).moved, 1);
assert.equal(player(moved, f.owner).hand.some(c => c.id === f.card), true);
assert.throws(() => applyAction(moved, f.owner, { ...f.firstMove, from: f.secondDestination,
...splitLocation(f.origin) }));
const hajrGame = structuredClone(before);
const hajr = holdSemutaHomeworldChoamCard(hajrGame, f.owner, 'Hajr');
const played = applyAction(hajrGame, f.owner, { type: 'card', card: hajr });
const allowed = advanceSemutaHomeworldChoam(played,
g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening);
assert.equal(player(allowed, f.owner).moved, 0);
assert.ok(allowed.hajr.includes(f.owner));
assert.throws(() => applyAction(allowed, f.owner, f.firstMove));
assert.equal(player(allowed, f.owner).hand.some(c => c.id === f.card), true);
assertCounters(allowed, f.setup);
});
+208
View File
@@ -0,0 +1,208 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, normalizeAutomaticGame, 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 { nativeShipmentSources } from '../game/homeworld-options';
import { splitLocation } from '../game/board';
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { semutaHomeworldChoamMovementResources as resources,
withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
import { biddingPhysicalIds as stock } from './fixture-homeworld-occupied-bidding';
import {
createSemutaHomeworldSaphoFixture, disposeSemutaHomeworldSapho,
finishSemutaHomeworldSaphoAuction, finishSemutaHomeworldSaphoMovement,
type SemutaHomeworldSaphoFixture, type SemutaHomeworldSaphoMode, type SemutaHomeworldSaphoScope,
} from './fixture-semuta-homeworld-sapho';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
const scopes: SemutaHomeworldSaphoScope[] = ['onceAround', 'movement'];
const modes: SemutaHomeworldSaphoMode[] = ['first', 'last'];
/** Assert the original consumer opportunities/payment, not receipt wiring. */
function finishOriginal(state: Game, f: SemutaHomeworldSaphoFixture) {
const actions: RicheseSkillsTechStep[] = [];
if (f.scope === 'onceAround') {
const lot = f.game.richeseAuction!, buyer = lot.bidder!;
const funding = viewGame(f.game, buyer).richeseAuction!;
assert.equal(lot.bid, 1);
assert.deepEqual(state.richeseAuction!.acted, f.prefix);
assert.equal(state.richeseAuction!.bidder, buyer);
assert.equal(state.richeseAuction!.bid, lot.bid);
assert.equal(viewGame(state, buyer).richeseAuction!.ownAvailable, funding.ownAvailable);
assert.equal(viewGame(state, buyer).richeseAuction!.ownAllyPayment, funding.ownAllyPayment);
assert.throws(() => applyAction(state, buyer,
{ type: 'richeseBid', event: f.event, amount: 2, allyPayment: 0 }),
'The earlier bidder never receives a second opportunity');
const done = finishSemutaHomeworldSaphoAuction(state, f, actions);
const expected = f.suffix.filter(id => player(state, id).hand.length < handLimit(player(state, id)));
assert.deepEqual(actions.map(step => step.actor), expected);
assert.equal(new Set([...f.prefix, ...actions.map(step => step.actor)]).size,
f.prefix.length + actions.length);
assert.equal(player(done, buyer).hand.filter(card => card.id === f.lotCard).length, 1);
assert.equal(player(done, buyer).spice, player(f.game, buyer).spice - 1);
assert.equal(player(done, f.holder).spice, player(f.game, f.holder).spice + 1,
'Original unoccupied Richese receives exactly the native cache-lot invoice');
for (const seat of done.players.filter(seat => ![buyer, f.holder].includes(seat.id)))
assert.equal(seat.spice, player(f.game, seat.id).spice);
assert.deepEqual(done.homeworlds?.custody, f.setup.homeworlds?.custody);
assert.deepEqual(stock(done), stock(f.game));
assert.throws(() => applyAction(done, buyer,
{ type: 'richeseBid', event: f.event, amount: 2, allyPayment: 0 }));
return done;
}
const result = finishSemutaHomeworldSaphoMovement(state, f, actions), done = result.game;
assert.deepEqual(actions.filter(step => step.action.type === 'endMovement').map(step => step.actor), f.suffix,
'Every original combined turn finishes exactly once in the reordered public queue');
assert.equal(player(result.afterShipment, f.nextActor).spice,
player(result.beforeShipment, f.nextActor).spice - 2);
assert.equal(player(result.afterShipment, f.nextActor).reserves,
player(result.beforeShipment, f.nextActor).reserves - 1);
assert.equal(player(result.afterShipment, f.nextActor).forces[result.origin], 1);
assert.equal(player(done, f.nextActor).forces[result.origin] ?? 0, 0);
assert.equal(player(done, f.nextActor).forces[result.destination], 1);
assert.equal(player(done, f.nextActor).moved, 1);
assert.equal(done.turn, 1);
assert.equal(done.phase, 7);
assert.deepEqual(done.homeworlds?.custody, f.setup.homeworlds?.custody);
assert.deepEqual(stock(done), stock(f.game));
assert.throws(() => applyAction(done, f.nextActor, result.shipment));
assert.throws(() => applyAction(done, f.nextActor, result.move));
return done;
}
for (const advanced of [false, true]) for (const scope of scopes) for (const mode of modes) {
const label = `${advanced ? 'Advanced' : 'Basic'} original Homeworld Sapho ${scope} ${mode}`;
for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claim' : 'pass'} retains reordered unacted opportunities and original funded continuation`, () => {
const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced);
const original = stock(f.game), paid = resources(f.game);
const offered = disposeSemutaHomeworldSapho(f);
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
assert.equal(player(offered, f.owner).hand.some(card => card.id === f.card), false);
assert.equal(offered.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(stock(offered), original);
assert.deepEqual(resources(offered), paid, 'Sapho and its disposal cannot spend the paid prefix or start a turn');
assert.deepEqual(offered.order, f.game.order, 'Physical storm order is never rewritten');
assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered);
if (scope === 'onceAround') {
assert.deepEqual(offered.richeseAuction!.order.filter(id => !offered.richeseAuction!.acted.includes(id)), f.suffix);
assert.deepEqual(offered.richeseAuction!.acted, f.prefix);
assert.deepEqual(offered.richeseAuction!.tieOrder, f.game.richeseAuction!.tieOrder);
assert.equal(viewGame(offered, 'a').richeseAuction!.card!.id, f.lotCard,
'Atreides retains the real public cache card knowledge');
assert.throws(() => applyAction(offered, f.nextActor,
{ type: 'richeseBid', event: f.event, amount: null, allyPayment: 0 }));
} else {
assert.deepEqual(offered.movementRemaining, f.suffix);
assert.equal(offered.active, f.game.active, 'The original unstarted turn stays frozen until disposal resolves');
assert.ok(offered.players.every(seat => !seat.shipped && seat.moved === 0));
assert.throws(() => applyAction(offered, f.game.active!, { type: 'endMovement' }));
}
const event = offered.pendingTreacheryDiscard!.batch.event;
const resumed = reactSemutaHomeworldChoam(structuredClone(offered), f.holder, claim);
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.equal(resumed.active, f.nextActor);
assert.deepEqual(resources(resumed), paid);
assert.deepEqual(stock(resumed), original);
assert.equal(player(resumed, f.holder).hand.filter(card => card.id === f.card).length, claim ? 1 : 0);
assert.equal(resumed.discard.filter(card => card.id === f.card).length, claim ? 0 : 1);
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0);
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(resumed, f.owner, f.action), 'The same physical Sapho cannot be spent twice');
const done = finishOriginal(resumed, f);
assert.deepEqual(done.order, f.game.order);
});
void test(`${label}: original full-hand singleton exchange and private entitlement`, () => {
const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced, true);
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
const offered = disposeSemutaHomeworldSapho(f);
const neutral = disposeSemutaHomeworldSapho(f, absent);
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(neutral, f.holder,
{ type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event }));
for (const seat of offered.players.filter(seat => seat.id !== f.holder)) {
assert.deepEqual(viewGame(offered, seat.id), viewGame(neutral, seat.id));
assert.equal(viewGame(offered, seat.id).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(offered, seat.id,
{ type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event }));
}
const claimed = reactSemutaHomeworldChoam(offered, f.holder, true);
assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder)));
assert.equal(player(claimed, f.holder).hand.some(card => card.id === f.card), true);
assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
finishOriginal(claimed, f);
});
void test(`${label}: every minimal bot profile legally claims or passes before the native continuation`, () => {
const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced);
const offered = disposeSemutaHomeworldSapho(f);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(offered);
const own = viewGame(game, f.holder);
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const proposal = botActions(own)[0];
assert.ok(proposal);
assert.ok(['semutaCommit', 'semutaPass'].includes(proposal.type));
game = applyAction(game, f.holder, proposal);
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
if (reaction?.stage !== 'offer') throw Error('Missing Semuta offer');
const actor = game.players.find(seat => !reaction.passed.includes(seat.id))!.id;
game = applyAction(game, actor, { type: 'semutaPass', event: pending.batch.event });
}
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), proposal.type === 'semutaCommit');
const nextView = viewGame(game, f.nextActor);
nextView.players.find(seat => seat.id === f.nextActor)!.bot = difficulty;
const nextAction = botActions(nextView)[0];
assert.ok(nextAction, 'The resumed original actor has a real legal bot continuation');
const botContinued = applyAction(structuredClone(game), f.nextActor, nextAction);
assert.deepEqual(stock(botContinued), stock(game));
finishOriginal(game, f);
}
});
void test(`${label}: stale source events and altered committed queue reject without card/resource loss`, () => {
const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced);
const before = structuredClone(f.game);
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, event: 'expired' }));
assert.deepEqual(f.game, before);
const offered = disposeSemutaHomeworldSapho(f), tampered = structuredClone(offered);
if (scope === 'onceAround') tampered.richeseAuction!.order.reverse();
else tampered.movementRemaining!.reverse();
const saved = structuredClone(tampered);
assert.throws(() => applyAction(tampered, f.holder,
{ type: 'semutaCommit', event: tampered.pendingTreacheryDiscard!.batch.event }));
assert.deepEqual(tampered, saved);
const passed = applyAction(offered, f.holder,
{ type: 'semutaPass', event: offered.pendingTreacheryDiscard!.batch.event });
assert.throws(() => applyAction(passed, f.holder,
{ type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event }));
});
}
for (const advanced of [false, true]) for (const mode of modes) void test(
`${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho ${mode}: actual paid current shipment closes the unstarted Movement window`, () => {
const f = createSemutaHomeworldSaphoFixture('movement', mode, advanced);
const origin = 'red_chasm:7';
const actor = f.game.active!;
const sources = nativeShipmentSources(viewGame(f.game, actor), 1, 0);
assert.ok(sources);
let started = applyAction(f.game, actor, { type: 'ship', ...splitLocation(origin), amount: 1, elite: 0,
homeworldSources: sources, allyPayment: 0 });
started = advanceSemutaHomeworldChoam(started,
game => !game.response && !game.decision && !game.pendingTreacheryDiscard && !game.phaseOpening);
assert.equal(player(started, actor).shipped, true);
const paid = resources(started), original = stock(started);
assert.ok(!viewGame(started, f.owner).saphoOptions.some(option => option.scope === 'movement'));
assert.throws(() => applyAction(started, f.owner, f.action));
assert.deepEqual(resources(started), paid);
assert.deepEqual(stock(started), original);
assert.equal(player(started, f.owner).hand.some(card => card.id === f.card), true);
},
);