Offer native CHOAM Homeworld discard costs to Semuta
This commit is contained in:
1 parent
68f7a101a3
commit
506bf25bc6
13 files changed
+762
-10
No files matched your search
@@ -0,0 +1,154 @@
|
||||
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 { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
|
||||
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
|
||||
import { moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
|
||||
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
|
||||
export type SemutaHomeworldChoamMovementEffect = 'kulon' | 'baliset';
|
||||
export type SemutaHomeworldChoamMovementFixture = {
|
||||
setup: Game; game: Game; beforeShipment: Game; afterShipment: Game; beforeMove: Game;
|
||||
owner: string; holder: string; target: string; canceller: string; card: string;
|
||||
effect: SemutaHomeworldChoamMovementEffect; action: Action; shipment: Action;
|
||||
move: Action; tooFar: Action | null; alternateMove: Action | null;
|
||||
origin: string; destination: string; actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
};
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
export function semutaHomeworldChoamMovementStock(game: Game): string[] {
|
||||
return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
|
||||
...game.players.map(player => player.hand)].flat().map(card => card.id).sort();
|
||||
}
|
||||
export function semutaHomeworldChoamMovementResources(game: Game) {
|
||||
return { custody: structuredClone(game.homeworlds?.custody),
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
||||
reserves: player.reserves, tanks: player.tanks, forces: structuredClone(player.forces),
|
||||
elites: structuredClone(player.elites), shipped: player.shipped, moved: player.moved,
|
||||
ally: player.ally })), spice: structuredClone(game.spice) };
|
||||
}
|
||||
function destinationAtRange(game: Game, origin: string, range: number, excluded: string[] = []) {
|
||||
const destination = TERRITORIES.filter(t => t.type === 'sand' && !excluded.includes(t.id))
|
||||
.flatMap(t => t.sectors.map(sector => location(t.id, sector)))
|
||||
.find(key => splitLocation(key).sector !== game.storm &&
|
||||
game.players.every(player => !(player.forces[key] > 0)) &&
|
||||
gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === range);
|
||||
assert.ok(destination, `Original board has a free sand destination at range ${range}`);
|
||||
return destination;
|
||||
}
|
||||
|
||||
/** Original setup, first-turn order and paid native reserves shipment. Only
|
||||
* conserved physical hands and two still-unplayed Spice Cards are positioned. */
|
||||
export function createSemutaHomeworldChoamMovementFixture(
|
||||
effect: SemutaHomeworldChoamMovementEffect, advanced = false, fullHolder = false,
|
||||
): SemutaHomeworldChoamMovementFixture {
|
||||
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 new deck.');
|
||||
game = advanceSemutaHomeworldChoam(game,
|
||||
g => g.phase === 5 && g.active === native.owner && clean(g), actions);
|
||||
assert.equal(game.turn, 1);
|
||||
const card = holdSemutaHomeworldChoamCard(game, native.owner, effect === 'kulon' ? 'Kulon' : 'Baliset');
|
||||
holdSemutaHomeworldChoamCard(game, native.canceller, 'Karama');
|
||||
staging.push(`Conserved printed ${effect} to CHOAM and original Karama to Emperor.`);
|
||||
assert.ok(semutaEmperorPlayer(game, native.owner).hand.length <= handLimit(semutaEmperorPlayer(game, native.owner)));
|
||||
if (fullHolder) {
|
||||
const holder = semutaEmperorPlayer(game, native.holder);
|
||||
while (holder.hand.length < handLimit(holder)) {
|
||||
assert.ok(game.deck[0]);
|
||||
moveSemutaEmperorCard(game, game.deck[0].id, holder.id);
|
||||
}
|
||||
staging.push('Conserved original undealt cards fill Richese to its native hand limit.');
|
||||
}
|
||||
const origin = effect === 'kulon' ? 'red_chasm:7'
|
||||
: Object.keys(semutaEmperorPlayer(game, native.target).forces).find(key => semutaEmperorPlayer(game, native.target).forces[key] > 0)!;
|
||||
assert.ok(origin);
|
||||
const destination = destinationAtRange(game, origin, effect === 'kulon' ? 2 : 1);
|
||||
const shipTo = effect === 'kulon' ? origin : destination;
|
||||
const sources = nativeShipmentSources(viewGame(game, native.owner), 1, 0);
|
||||
assert.ok(sources);
|
||||
const shipment: Action = { type: 'ship', ...splitLocation(shipTo), amount: 1, elite: 0,
|
||||
homeworldSources: sources, allyPayment: 0 };
|
||||
const beforeShipment = structuredClone(game);
|
||||
game = applySemutaHomeworldChoamStep(game, native.owner, shipment, actions);
|
||||
game = advanceSemutaHomeworldChoam(game, g => clean(g), actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
const move: Action = { type: 'move', from: origin, amount: 1, elite: 0, ...splitLocation(destination) };
|
||||
let beforeMove = structuredClone(game);
|
||||
let tooFar: Action | null = null, alternateMove: Action | null = null;
|
||||
if (effect === 'kulon') {
|
||||
tooFar = { type: 'move', from: origin, amount: 1, elite: 0,
|
||||
...splitLocation(destinationAtRange(game, origin, 3)) };
|
||||
} else {
|
||||
game = applySemutaHomeworldChoamStep(game, native.owner, { type: 'endMovement' }, actions);
|
||||
game = advanceSemutaHomeworldChoam(game,
|
||||
g => g.phase === 5 && g.active === native.target && clean(g), actions);
|
||||
alternateMove = { type: 'move', from: origin, amount: 1, elite: 0,
|
||||
...splitLocation(destinationAtRange(game, origin, 1, [splitLocation(destination).territory])) };
|
||||
beforeMove = structuredClone(game);
|
||||
game = applySemutaHomeworldChoamStep(game, native.target, move, actions);
|
||||
assert.equal(game.decision?.kind, 'choamMovement');
|
||||
assert.deepEqual(semutaEmperorPlayer(game, native.target).forces, semutaEmperorPlayer(beforeMove, native.target).forces);
|
||||
assert.equal(semutaEmperorPlayer(game, native.target).moved, 0);
|
||||
}
|
||||
const view = viewGame(game, native.owner);
|
||||
const printed = choamPowerPlays(view, effect).find(play => play.source === 'printed' && play.card.id === card);
|
||||
assert.ok(printed);
|
||||
const action = choamPowerAction(view, printed, effect === 'baliset'
|
||||
? { target: native.target, territory: splitLocation(destination).territory } : {});
|
||||
assert.ok(action);
|
||||
return { setup, game, beforeShipment, afterShipment, beforeMove,
|
||||
owner: native.owner, holder: native.holder, target: effect === 'kulon' ? native.owner : native.target,
|
||||
canceller: native.canceller, card, effect, action, shipment, move, tooFar, alternateMove,
|
||||
origin, destination, actions, staging };
|
||||
}
|
||||
|
||||
/** Pass only actual entitled native Worthless cancellation controls. */
|
||||
export function allowSemutaHomeworldChoamMovement(state: Game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = state;
|
||||
for (let count = 0; game.response && count < 32; count++) {
|
||||
assert.equal(game.response.kind, 'choamWorthless');
|
||||
const entitled = game.players.find(player => {
|
||||
const controls = viewGame(game, player.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
|
||||
});
|
||||
assert.ok(entitled, 'An original entitled cancellation control must allow the power');
|
||||
game = applySemutaHomeworldChoamStep(game, entitled.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
export function disposeSemutaHomeworldChoamMovement(fixture: SemutaHomeworldChoamMovementFixture,
|
||||
state = fixture.game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
return allowSemutaHomeworldChoamMovement(
|
||||
applySemutaHomeworldChoamStep(state, fixture.owner, fixture.action, actions), actions);
|
||||
}
|
||||
|
||||
/** Execute a useful original ground move, then finish the original phase by
|
||||
* native controls. No phase, active player, wallet or custody is reassigned. */
|
||||
export function finishSemutaHomeworldChoamMovement(state: Game,
|
||||
fixture: SemutaHomeworldChoamMovementFixture, actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = applySemutaHomeworldChoamStep(state, fixture.target,
|
||||
fixture.effect === 'kulon' ? fixture.move : fixture.alternateMove!, actions);
|
||||
game = advanceSemutaHomeworldChoam(game, g => g.phase > 5 && clean(g), actions);
|
||||
assert.equal(game.turn, 1);
|
||||
return game;
|
||||
}
|
||||
export function withoutSemutaHomeworldChoamMovementHolder(state: Game, holder: string): Game {
|
||||
const game = structuredClone(state), hand = semutaEmperorPlayer(game, holder).hand;
|
||||
const index = hand.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
||||
const replacement = game.deck.findIndex(card => card.effect !== 'karama');
|
||||
assert.ok(index >= 0 && replacement >= 0);
|
||||
const semuta = hand[index];
|
||||
hand[index] = game.deck.splice(replacement, 1)[0];
|
||||
game.deck.push(semuta);
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
|
||||
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
|
||||
import { semutaEmperorPlayer } from './fixture-semuta-emperor-share';
|
||||
import {
|
||||
advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
|
||||
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard,
|
||||
} from './fixture-semuta-homeworld-choam';
|
||||
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
|
||||
export type SemutaHomeworldChoamReturnPower = 'laLaLa' | 'gamont';
|
||||
export type SemutaHomeworldChoamReturnsFixture = {
|
||||
game: Game;
|
||||
setup: Game;
|
||||
owner: string;
|
||||
holder: string;
|
||||
target: string;
|
||||
card: string;
|
||||
power: SemutaHomeworldChoamReturnPower;
|
||||
action: Action;
|
||||
from: string;
|
||||
actions: RicheseSkillsTechStep[];
|
||||
originalCards: string[];
|
||||
};
|
||||
|
||||
/** Original native setup, first Revival or closing Mentat, no assigned clocks,
|
||||
* wallets, Tanks, force positions or Homeworld custody. The selected Atreides
|
||||
* counter is still on its original starting board sector. Only the two physical
|
||||
* card positions (Semuta and the printed power) are conserved staging. */
|
||||
export function createSemutaHomeworldChoamReturnsFixture(
|
||||
power: SemutaHomeworldChoamReturnPower, advanced = false,
|
||||
): SemutaHomeworldChoamReturnsFixture {
|
||||
const native = createSemutaHomeworldChoamSetup(advanced);
|
||||
const { owner, holder, target, actions } = native;
|
||||
let game = native.game;
|
||||
const card = holdSemutaHomeworldChoamCard(game, owner,
|
||||
power === 'laLaLa' ? 'La La La' : 'Trip to Gamont');
|
||||
const setup = structuredClone(game);
|
||||
const originalCards = biddingPhysicalIds(game);
|
||||
const from = Object.keys(semutaEmperorPlayer(setup, target).forces)[0];
|
||||
assert.ok(from, 'The original Atreides setup has an on-board force');
|
||||
game = advanceSemutaHomeworldChoam(game, state => power === 'laLaLa'
|
||||
? state.phase === 4 && !state.phaseOpening && !state.response && !state.decision && !state.pendingTreacheryDiscard
|
||||
: state.decision?.kind === 'choamMentat', actions);
|
||||
assert.equal(game.turn, setup.turn, 'Never advance to a replacement source turn');
|
||||
assert.equal(game.phase, power === 'laLaLa' ? 4 : 8);
|
||||
assert.deepEqual(semutaEmperorPlayer(game, target).forces,
|
||||
semutaEmperorPlayer(setup, target).forces, 'The recalled force is original, not a staged shipment or casualty');
|
||||
assert.deepEqual(biddingPhysicalIds(game), originalCards);
|
||||
if (power === 'gamont') {
|
||||
assert.equal(game.decision!.player, owner);
|
||||
assert.equal(game.choamMentatPending, true);
|
||||
}
|
||||
const view = viewGame(game, owner);
|
||||
const play = choamPowerPlays(view, power).find(play => play.source === 'printed' && play.card.id === card);
|
||||
assert.ok(play);
|
||||
const action = choamPowerAction(view, play, { target, ...(power === 'gamont' ? { from, elite: 0 } : {}) });
|
||||
assert.ok(action, 'The original private controls admit the printed power');
|
||||
return { game, setup, owner, holder, target, card, power, action, from, actions, originalCards };
|
||||
}
|
||||
|
||||
/** Complete the real Karama response queue, stopping at the cost offer. */
|
||||
export function playSemutaHomeworldChoamReturns(
|
||||
fixture: SemutaHomeworldChoamReturnsFixture, state = fixture.game,
|
||||
): Game {
|
||||
let game = applySemutaHomeworldChoamStep(state, fixture.owner, fixture.action);
|
||||
for (let n = 0; game.response?.kind === 'choamWorthless' && n < game.players.length; n++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id))!.id;
|
||||
game = applyAction(game, actor, { type: 'passResponse' });
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'choamWorthless');
|
||||
return game;
|
||||
}
|
||||
|
||||
export function semutaHomeworldChoamReturnsForces(game: Game) {
|
||||
const worlds = viewGame(game, game.host).homeworlds!.worlds!;
|
||||
return game.players.map(player => {
|
||||
const away = worlds.filter(home => home.native !== player.id)
|
||||
.map(home => home.forces[player.id]).filter(Boolean);
|
||||
return {
|
||||
id: player.id,
|
||||
total: player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0)
|
||||
+ away.reduce((sum, group) => sum + group.normal + group.elite, 0),
|
||||
elite: (player.elites?.reserves ?? 0) + (player.elites?.tanks ?? 0)
|
||||
+ Object.values(player.elites?.forces ?? {}).reduce((sum, n) => sum + n, 0)
|
||||
+ away.reduce((sum, group) => sum + group.elite, 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, type Action, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { createSemutaKaitainFixture } from './fixture-semuta-kaitain';
|
||||
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
import { moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
|
||||
|
||||
export function createSemutaHomeworldChoamSetup(advanced = false) {
|
||||
const native = createSemutaKaitainFixture({ advanced, count: 1 });
|
||||
// This exposed snapshot is the original completed native setup, before that
|
||||
// fixture's later Kaitain hand staging or paid disposal. Not a clock rewind.
|
||||
const game = structuredClone(native.setup);
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.ok(game.homeworlds?.custody && game.semutaPreview);
|
||||
assert.ok(game.players.some(p => p.faction === 'choam'));
|
||||
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, native.holder);
|
||||
assert.ok(semutaEmperorPlayer(game, native.holder).hand.length <= handLimit(semutaEmperorPlayer(game, native.holder)));
|
||||
return { game, owner: native.choam, holder: native.holder,
|
||||
target: game.players.find(p => p.faction === 'atreides')!.id,
|
||||
canceller: native.owner, actions: [] as RicheseSkillsTechStep[] };
|
||||
}
|
||||
|
||||
/** Conserved physical card only; never mint a face or rewrite the whole deck. */
|
||||
export function holdSemutaHomeworldChoamCard(game: Game, owner: string, name: string): string {
|
||||
const card = [...treacheryDeck(game.expansions), ...richeseCards()].find(card => card.name === name);
|
||||
assert.ok(card, `Missing printed card ${name}`);
|
||||
return moveSemutaEmperorCard(game, card.id, owner);
|
||||
}
|
||||
|
||||
/** Caller tests the requested boundary before each real native action. */
|
||||
export function advanceSemutaHomeworldChoam(
|
||||
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', 'The original Homeworld game must reach the requested boundary');
|
||||
let next: RicheseSkillsTechStep;
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
|
||||
if (pending && reaction?.stage === 'offer') {
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id))!.id;
|
||||
next = { actor, action: { type: 'semutaPass', event: pending.batch.event } };
|
||||
} else if (game.decision?.kind === 'choamMarket') {
|
||||
next = { actor: game.decision.player, action: { type: 'decision', done: true } };
|
||||
} else next = nextRicheseSkillsTechNativeStep(game);
|
||||
actions?.push(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game), 'The native Homeworld continuation must reach its boundary');
|
||||
return game;
|
||||
}
|
||||
|
||||
export function reactSemutaHomeworldChoam(state: Game, holder: string, claim: boolean): Game {
|
||||
const event = state.pendingTreacheryDiscard!.batch.event;
|
||||
if (claim) return applyAction(state, holder, { type: 'semutaCommit', event });
|
||||
let game = state;
|
||||
for (const player of state.players) game = applyAction(game, player.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
|
||||
export function applySemutaHomeworldChoamStep(game: Game, actor: string, action: Action,
|
||||
actions?: RicheseSkillsTechStep[]): Game {
|
||||
actions?.push({ actor, action });
|
||||
return applyAction(game, actor, action);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
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 { splitLocation } from '../game/board';
|
||||
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
|
||||
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import {
|
||||
createSemutaHomeworldChoamMovementFixture, disposeSemutaHomeworldChoamMovement,
|
||||
finishSemutaHomeworldChoamMovement,
|
||||
semutaHomeworldChoamMovementResources as resources, semutaHomeworldChoamMovementStock as stock,
|
||||
withoutSemutaHomeworldChoamMovementHolder,
|
||||
type SemutaHomeworldChoamMovementEffect,
|
||||
} from './fixture-semuta-homeworld-choam-movement';
|
||||
|
||||
const effects: SemutaHomeworldChoamMovementEffect[] = ['kulon', 'baliset'];
|
||||
for (const advanced of [false, true]) for (const effect of effects) {
|
||||
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld ${effect}`;
|
||||
for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claim' : 'pass'} preserves paid native shipment and applies the printed movement power once`, () => {
|
||||
const f = createSemutaHomeworldChoamMovementFixture(effect, advanced);
|
||||
const inventory = stock(f.game), paid = resources(f.game);
|
||||
assert.equal(player(f.afterShipment, f.owner).spice, player(f.beforeShipment, f.owner).spice - 2,
|
||||
'One ordinary off-planet force into desert pays exactly two spice');
|
||||
assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 1);
|
||||
assert.equal(player(f.afterShipment, f.owner).forces[effect === 'kulon' ? f.origin : f.destination], 1);
|
||||
assert.deepEqual(f.afterShipment.homeworlds?.custody, f.setup.homeworlds?.custody);
|
||||
if (effect === 'kulon') assert.throws(() => applyAction(f.game, f.owner, f.move));
|
||||
else {
|
||||
assert.equal(f.game.decision?.kind, 'choamMovement');
|
||||
assert.deepEqual(player(f.game, f.target).forces, player(f.beforeMove, f.target).forces);
|
||||
assert.equal(player(f.game, f.target).moved, 0, 'Declared move remains wholly uncommitted');
|
||||
}
|
||||
const offered = disposeSemutaHomeworldChoamMovement(f);
|
||||
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.equal(offered.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(player(offered, f.owner).hand.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(resources(offered), paid, 'Paid prefix, original force custody and wallets are frozen during the offer');
|
||||
assert.deepEqual(stock(offered), inventory);
|
||||
assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered);
|
||||
assert.throws(() => applyAction(offered, f.target, f.move));
|
||||
assert.equal(viewGame(offered, f.owner).choamMovementBonus, 0);
|
||||
assert.deepEqual(viewGame(offered, f.target).balisetRestrictions, []);
|
||||
const event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const resumed = reactSemutaHomeworldChoam(structuredClone(offered), f.holder, claim);
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(resources(resumed), paid);
|
||||
assert.deepEqual(stock(resumed), inventory);
|
||||
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 }));
|
||||
if (effect === 'kulon') {
|
||||
assert.equal(viewGame(resumed, f.owner).choamMovementBonus, 1);
|
||||
assert.throws(() => applyAction(resumed, f.owner, f.tooFar!),
|
||||
'Exactly one extra range, not two');
|
||||
const moved = applyAction(resumed, f.owner, f.move);
|
||||
assert.equal(player(moved, f.owner).moved, 1);
|
||||
assert.equal(player(moved, f.owner).forces[f.origin] ?? 0, 0);
|
||||
assert.equal(player(moved, f.owner).forces[f.destination], 1);
|
||||
assert.throws(() => applyAction(moved, f.owner, f.move),
|
||||
'Kulon does not grant a second move');
|
||||
} else {
|
||||
const restrictions = viewGame(resumed, f.target).balisetRestrictions;
|
||||
assert.equal(restrictions.length, 1);
|
||||
assert.equal(restrictions[0].territory, splitLocation(f.destination).territory);
|
||||
assert.equal(resumed.pendingChoamMove, null);
|
||||
assert.equal(player(resumed, f.target).moved, 0);
|
||||
assert.deepEqual(player(resumed, f.target).forces, player(f.beforeMove, f.target).forces);
|
||||
assert.throws(() => applyAction(resumed, f.target, f.move));
|
||||
assert.throws(() => applyAction(resumed, f.owner, f.action),
|
||||
'The used printed physical card cannot apply a second restriction');
|
||||
}
|
||||
const completed = finishSemutaHomeworldChoamMovement(resumed, f);
|
||||
assert.equal(completed.phase, 7, 'Real endMovement and CHOAM phase controls reach Spice Collection');
|
||||
assert.equal(completed.turn, 1);
|
||||
assert.equal(player(completed, f.target).moved, 1);
|
||||
assert.equal(player(completed, f.owner).reserves, player(f.afterShipment, f.owner).reserves,
|
||||
'A response and its suffix never ship the original reserve twice');
|
||||
assert.deepEqual(completed.homeworlds?.custody, f.setup.homeworlds?.custody);
|
||||
assert.deepEqual(stock(completed), inventory);
|
||||
});
|
||||
|
||||
void test(`${label}: singleton exchange is legal at the original full hand limit and entitlement remains private`, () => {
|
||||
const f = createSemutaHomeworldChoamMovementFixture(effect, 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 = disposeSemutaHomeworldChoamMovement(f);
|
||||
const neutral = disposeSemutaHomeworldChoamMovement(f, absent);
|
||||
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true,
|
||||
'Exchanging physical Semuta for the singleton needs no fifth slot');
|
||||
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
|
||||
for (const seat of offered.players.filter(seat => seat.id !== f.holder)) {
|
||||
assert.deepEqual(viewGame(offered, seat.id), viewGame(neutral, seat.id),
|
||||
'An uninvolved player cannot infer whether Richese owns Semuta');
|
||||
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);
|
||||
assert.deepEqual(stock(claimed), stock(f.game));
|
||||
assert.equal(player(finishSemutaHomeworldChoamMovement(claimed, f), f.target).moved, 1);
|
||||
});
|
||||
|
||||
void test(`${label}: all four minimal bot profiles legally claim or pass then finish the native move`, () => {
|
||||
const f = createSemutaHomeworldChoamMovementFixture(effect, advanced);
|
||||
const offered = disposeSemutaHomeworldChoamMovement(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game: Game = structuredClone(offered);
|
||||
const own = viewGame(game, f.holder);
|
||||
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(own)[0];
|
||||
assert.ok(action, `${difficulty} has an entitled neutral receipt action`);
|
||||
assert.ok(['semutaCommit', 'semutaPass'].includes(action.type));
|
||||
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');
|
||||
const actor = game.players.find(seat => !reaction.passed.includes(seat.id))!;
|
||||
game = applyAction(game, actor.id, { type: 'semutaPass', event: pending.batch.event });
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), action.type === 'semutaCommit');
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
const completed = finishSemutaHomeworldChoamMovement(game, f);
|
||||
assert.equal(player(completed, f.target).moved, 1);
|
||||
assert.deepEqual(stock(completed), stock(f.game));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} native Baliset decline and Karama allow the original declared move exactly once`, () => {
|
||||
const f = createSemutaHomeworldChoamMovementFixture('baliset', advanced);
|
||||
const declined = applyAction(f.game, f.owner, { type: 'decision', decline: true });
|
||||
const declared = applyAction(f.game, f.owner, f.action);
|
||||
const karama = player(declared, f.canceller).hand.find(card => card.name === 'Karama');
|
||||
assert.ok(karama);
|
||||
const cancellation = applyAction(declared, f.canceller, { type: 'card', mode: 'cancel', card: karama.id });
|
||||
const allowed = advanceSemutaHomeworldChoam(cancellation,
|
||||
game => !game.pendingTreacheryDiscard && !game.response && !game.decision && !game.phaseOpening);
|
||||
for (const game of [declined, allowed]) {
|
||||
assert.equal(player(game, f.target).moved, 1);
|
||||
assert.equal(player(game, f.target).forces[f.origin], player(f.beforeMove, f.target).forces[f.origin] - 1);
|
||||
assert.equal(player(game, f.target).forces[f.destination], 1);
|
||||
assert.equal(player(game, f.owner).hand.some(card => card.id === f.card), true);
|
||||
assert.equal(game.discard.some(card => card.id === f.card), false);
|
||||
assert.equal(game.pendingChoamMove, null);
|
||||
assert.deepEqual(viewGame(game, f.target).balisetRestrictions, []);
|
||||
assert.equal(player(game, f.owner).reserves, player(f.afterShipment, f.owner).reserves);
|
||||
assert.deepEqual(game.homeworlds?.custody, f.setup.homeworlds?.custody);
|
||||
assert.deepEqual(stock(game), stock(f.game));
|
||||
assert.throws(() => applyAction(game, f.owner, { type: 'decision', decline: true }));
|
||||
assert.throws(() => applyAction(game, f.target, f.move));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
|
||||
import { semutaEmperorPlayer } from './fixture-semuta-emperor-share';
|
||||
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
|
||||
import {
|
||||
createSemutaHomeworldChoamReturnsFixture, playSemutaHomeworldChoamReturns,
|
||||
semutaHomeworldChoamReturnsForces, type SemutaHomeworldChoamReturnsFixture,
|
||||
} from './fixture-semuta-homeworld-choam-returns';
|
||||
|
||||
const wallets = (game: Game) => game.players.map(player => ({ id: player.id, spice: player.spice }));
|
||||
const counters = (game: Game, id: string) => {
|
||||
const p = semutaEmperorPlayer(game, id);
|
||||
return { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
elites: p.elites, revived: p.revived, freeForcesRevived: p.freeForcesRevived, battleLosses: p.battleLosses };
|
||||
};
|
||||
const homeForces = (game: Game) => viewGame(game, game.host).homeworlds!.worlds!
|
||||
.map(home => ({ id: home.id, native: home.native, forces: home.forces }));
|
||||
|
||||
/** Shared observable contract: exactly one printed effect follows the recovery,
|
||||
* and recovery changes card ownership only, not the source's native economics. */
|
||||
function assertSettled(f: SemutaHomeworldChoamReturnsFixture, done: Game, claim: boolean) {
|
||||
assert.equal(done.pendingTreacheryDiscard, null);
|
||||
const holder = semutaEmperorPlayer(done, f.holder);
|
||||
assert.equal(holder.hand.some(card => card.id === f.card), claim);
|
||||
assert.equal(holder.hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
|
||||
assert.equal(done.discard.filter(card => card.id === f.card).length, Number(!claim));
|
||||
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
|
||||
assert.equal(semutaEmperorPlayer(done, f.owner).hand.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(biddingPhysicalIds(done), f.originalCards);
|
||||
assert.deepEqual(semutaHomeworldChoamReturnsForces(done), semutaHomeworldChoamReturnsForces(f.setup));
|
||||
assert.deepEqual(wallets(done), wallets(f.game));
|
||||
assert.equal(done.turn, f.game.turn);
|
||||
assert.equal(done.phase, f.game.phase);
|
||||
for (const player of done.players.filter(player => player.id !== f.target))
|
||||
assert.deepEqual(counters(done, player.id), counters(f.game, player.id));
|
||||
|
||||
if (f.power === 'laLaLa') {
|
||||
const before = viewGame(f.game, f.target).revival;
|
||||
const after = viewGame(done, f.target).revival;
|
||||
assert.ok(before.freeRemaining > 0, 'Original Atreides has a genuine free allowance');
|
||||
assert.equal(before.freeBlocked, false);
|
||||
assert.equal(after.freeRemaining, 0, 'The printed power removes the actual free-revival allowance');
|
||||
assert.equal(after.freeBlocked, true);
|
||||
assert.equal(after.limit, before.limit, 'Paid revival allowance is not canceled');
|
||||
assert.equal(viewGame(done, f.owner).choamWorthless!.targets.includes(f.target), false);
|
||||
for (const player of done.players.filter(player => player.id !== f.target))
|
||||
assert.equal(viewGame(done, player.id).revival.freeRemaining, viewGame(f.game, player.id).revival.freeRemaining);
|
||||
assert.deepEqual(counters(done, f.target), counters(f.game, f.target),
|
||||
'The original Revival has no Tanks: proactive denial must not invent or revive a casualty');
|
||||
assert.deepEqual(homeForces(done), homeForces(f.game));
|
||||
} else {
|
||||
const before = semutaEmperorPlayer(f.game, f.target), after = semutaEmperorPlayer(done, f.target);
|
||||
assert.equal(after.forces[f.from], before.forces[f.from] - 1);
|
||||
assert.equal(after.reserves, before.reserves + 1, 'One ORIGINAL board token returns to native reserves');
|
||||
assert.equal(after.tanks, before.tanks, 'Trip is not a resurrection from Tanks');
|
||||
assert.equal(after.revived, before.revived);
|
||||
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
|
||||
assert.equal(after.battleLosses, before.battleLosses);
|
||||
assert.deepEqual(after.elites, before.elites);
|
||||
assert.deepEqual(Object.entries(after.forces).filter(([key]) => key !== f.from),
|
||||
Object.entries(before.forces).filter(([key]) => key !== f.from));
|
||||
const expectedHomes = structuredClone(homeForces(f.game));
|
||||
const nativeHome = expectedHomes.find(home => home.native === f.target)!;
|
||||
nativeHome.forces[f.target].normal++;
|
||||
assert.deepEqual(homeForces(done), expectedHomes, 'Public Caladan custody gains exactly that one ordinary token');
|
||||
assert.equal(done.decision?.kind, 'choamMentat');
|
||||
assert.equal(done.decision!.player, f.owner, 'The original closing owner resumes before victory');
|
||||
assert.equal(viewGame(done, f.target).mentatVictoryPending, true);
|
||||
assert.equal(done.status, 'playing');
|
||||
assert.throws(() => applyAction(done, f.target, { type: 'decision', done: true }));
|
||||
}
|
||||
assert.throws(() => applyAction(done, f.owner, f.action), 'The original printed power cannot replay');
|
||||
if (claim) assert.throws(() => applyAction(done, f.holder, f.action),
|
||||
'Recovering a Worthless card does not grant the non-CHOAM holder its faction power');
|
||||
}
|
||||
|
||||
function consumeOriginalSuffix(f: SemutaHomeworldChoamReturnsFixture, state: Game) {
|
||||
const before = structuredClone(counters(state, f.target));
|
||||
const homes = homeForces(state);
|
||||
const next = f.power === 'gamont'
|
||||
? applyAction(state, f.owner, { type: 'decision', done: true })
|
||||
: advanceSemutaHomeworldChoam(state, game => game.phase === 5 && !game.phaseOpening && !game.response && !game.decision);
|
||||
assert.equal(next.turn, f.game.turn + Number(f.power === 'gamont'));
|
||||
assert.equal(next.phase, f.power === 'gamont' ? 0 : 5);
|
||||
assert.equal(next.status, 'playing');
|
||||
assert.deepEqual(counters(next, f.target), before, 'The real next stage must not run the power a second time');
|
||||
assert.deepEqual(homeForces(next), homes);
|
||||
assert.deepEqual(biddingPhysicalIds(next), f.originalCards);
|
||||
if (f.power === 'gamont') {
|
||||
assert.equal(next.choamMentatPending, false);
|
||||
assert.throws(() => applyAction(next, f.owner, { type: 'decision', done: true }),
|
||||
'The original closing decision is consumed once');
|
||||
}
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const power of ['laLaLa', 'gamont'] as const) {
|
||||
for (const claim of [false, true]) void test(
|
||||
`Homeworld ${advanced ? 'Advanced' : 'Basic'} native ${power}: Semuta ${claim ? 'claims' : 'passes'} before the original effect`, () => {
|
||||
const f = createSemutaHomeworldChoamReturnsFixture(power, advanced);
|
||||
const pending = playSemutaHomeworldChoamReturns(f);
|
||||
const cost = pending.pendingTreacheryDiscard;
|
||||
assert.ok(cost, 'The actual printed disposal pauses before its native effect');
|
||||
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true);
|
||||
assert.equal(pending.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(counters(pending, f.target), counters(f.game, f.target));
|
||||
assert.deepEqual(homeForces(pending), homeForces(f.game));
|
||||
assert.deepEqual(wallets(pending), wallets(f.game));
|
||||
assert.equal(viewGame(pending, f.target).revival.freeRemaining, viewGame(f.game, f.target).revival.freeRemaining);
|
||||
assert.throws(() => applyAction(pending, f.owner, f.action));
|
||||
assert.throws(() => applyAction(pending, f.owner, { type: 'decision', done: true }),
|
||||
'Closing/Revival cannot skip the actual recovery window');
|
||||
assert.throws(() => applyAction(pending, f.target, { type: 'ready' }));
|
||||
assert.throws(() => applyAction(pending, f.target, { type: 'semutaCommit', event: cost.batch.event }));
|
||||
const done = reactSemutaHomeworldChoam(pending, f.holder, claim);
|
||||
assertSettled(f, done, claim);
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event }));
|
||||
assert.throws(() => applyAction(done, f.target, { type: 'semutaPass', event: cost.batch.event }));
|
||||
consumeOriginalSuffix(f, done);
|
||||
},
|
||||
);
|
||||
|
||||
for (const difficulty of DIFFICULTIES) void test(
|
||||
`${difficulty} legally completes Homeworld ${advanced ? 'Advanced' : 'Basic'} native ${power} recovery and original controls`, () => {
|
||||
const f = createSemutaHomeworldChoamReturnsFixture(power, advanced);
|
||||
let game = playSemutaHomeworldChoamReturns(f);
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
const view = viewGame(game, f.holder);
|
||||
view.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action);
|
||||
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass');
|
||||
game = applyAction(game, f.holder, action);
|
||||
for (const player of game.players) {
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer'
|
||||
&& !game.pendingTreacheryDiscard.reaction.passed.includes(player.id))
|
||||
game = applyAction(game, player.id, { type: 'semutaPass', event });
|
||||
}
|
||||
const claimed = semutaEmperorPlayer(game, f.holder).hand.some(card => card.id === f.card);
|
||||
assertSettled(f, game, claimed);
|
||||
if (power === 'gamont') {
|
||||
const closingView = viewGame(game, f.owner);
|
||||
closingView.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const close = botActions(closingView)[0];
|
||||
assert.ok(close);
|
||||
const after = applyAction(game, f.owner, close);
|
||||
assert.equal(after.turn, game.turn + 1);
|
||||
assert.equal(after.phase, 0);
|
||||
assert.equal(after.choamMentatPending, false);
|
||||
assert.deepEqual(counters(after, f.target), counters(game, f.target));
|
||||
assert.deepEqual(homeForces(after), homeForces(game));
|
||||
assert.deepEqual(biddingPhysicalIds(after), f.originalCards);
|
||||
} else consumeOriginalSuffix(f, game);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user