Enable Kull during Basic battle preparation
Verify and publish checkpoint container / container (push) Failing after 7m45s

This commit is contained in:
Quintine committed 2026-10-28 20:47:15 +11:00
1 parent 6bf165a6aa
commit 91ce096818
8 files changed
+371 -8

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { advance as advanceKullSemuta, inventory, player } from './fixture-kull-richese-semuta';
import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama';
export { inventory, player };
export const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
/** Public Basic setup with native CHOAM/Richese. Original unopened starting
* cards and a safe unopened Spice position are selected. Richese purchases the
* physical Semuta for one spice; Emperor and BG or Atreides actually ship into
* the same unoccupied stronghold. No played force, purse, phase or battle is installed. */
export function createKullBattleSemutaFixture(options: { power?: 'voice' | 'prescience'; preReady?: boolean } = {}) {
const power = options.power ?? 'voice';
const owner = power === 'voice' ? 'beneGesserit' : 'atreides';
let game = createGame('KULLBATTLESEMUTA', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']);
for (const faction of ['richese', 'emperor', owner, 'harkonnen'] as const)
joinGame(game, newPlayer(faction, faction, faction));
game = act(game, 'choam', { type: 'moduleOption', module: 'kullPreview', enabled: true });
for (const seat of game.players) game = act(game, seat.id, { type: 'ready' });
game = act(game, game.host, { type: 'start' });
assert.equal(game.status, 'setup');
assert.ok(game.semutaPreview && game.kullPreview);
const originalCards = inventory(game), selected: Game['deck'] = [];
for (const kind of ['kull', 'filler', 'karama', 'filler', 'karama'] as const) {
const index = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' :
kind === 'karama' ? card.effect === 'karama' : card.kind === 'worthless' && card.id !== 'ix-kull-wahad');
assert.ok(index >= 0, `The original unopened ${kind} must exist.`);
selected.push(game.deck.splice(index, 1)[0]);
}
game.deck.unshift(...selected);
game = advanceKullSemuta(game, state => state.status === 'playing');
const [kull, , karama, , counter] = selected.map(card => card.id);
assert.ok(player(game, 'choam').hand.some(card => card.id === kull));
assert.ok(player(game, 'emperor').hand.some(card => card.id === karama));
assert.ok(player(game, 'harkonnen').hand.some(card => card.id === counter));
const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(land >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(land, 1)[0]);
game = advanceKullSemuta(game, state => player(state, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID) &&
!state.response && !state.decision && !state.phaseOpening && !state.pendingTreacheryDiscard);
assert.ok(player(game, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID));
let target: { territory: string; sector: number } | null = null;
for (let count = 0; count < 1000 && game.phase !== 6; count++) {
if (game.phase === 5 && !game.response && !game.decision && !game.phaseOpening &&
!game.pendingTreacheryDiscard && !game.pendingKull && game.active) {
const actor = game.active, seat = player(game, actor);
if ((actor === 'emperor' || actor === owner) && !seat.shipped) {
if (!target) {
const territory = TERRITORIES.find(t => t.type === 'stronghold' && t.sectors.some(s => s !== game.storm) &&
game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(`${t.id}:`))));
assert.ok(territory, 'A safe empty printed stronghold must be available.');
target = { territory: territory.id, sector: territory.sectors.find(sector => sector !== game.storm)! };
}
game = act(game, actor, { type: 'ship', territory: target.territory, sector: target.sector,
amount: 1, elite: 0, allyPayment: 0 });
} else game = act(game, actor, { type: 'endMovement' });
continue;
}
const next = nextSemutaRicheseCacheKarama(game);
assert.ok(next, `Complete the actual source control at ${game.phase}/${game.decision?.kind}.`);
game = act(game, next.actor, next.action);
}
assert.equal(game.phase, 6); assert.ok(target);
assert.equal(player(game, 'emperor').forces[`${target.territory}:${target.sector}`], 1);
assert.equal(player(game, owner).forces[`${target.territory}:${target.sector}`], 1);
// A native chooser owns this real battle; other seats' unspent choices are
// resolved by the same production policy before making the selection.
for (let count = 0; count < 30 && !game.battle; count++) {
const view = game.active ? viewGame(game, game.active) : null;
const targets = view?.battleChoices?.find(choice => choice.territory === target!.territory &&
(choice.chooser ?? choice.attacker) === game.active &&
[choice.attacker, choice.defender].includes(game.active!));
if (targets) {
const opponent = game.active === 'emperor' ? owner : 'emperor';
game = act(game, game.active!, { type: 'chooseBattle', territory: target.territory, target: opponent });
break;
}
const next = nextSemutaRicheseCacheKarama(game); assert.ok(next);
game = act(game, next.actor, next.action);
}
assert.ok(game.battle);
for (let count = 0; count < 30 && game.battle?.preparation?.kind !== power; count++) {
const next = nextSemutaRicheseCacheKarama(game); assert.ok(next);
game = act(game, next.actor, next.action);
}
assert.equal(game.battle?.preparation?.kind, power);
if (options.preReady) {
const pre = game.battle!.preLeader; assert.ok(pre && !pre.closed);
game = act(game, 'emperor', { type: 'battlePreparationReady', event: pre.event });
assert.deepEqual(game.battle!.preLeader!.ready, ['emperor']);
assert.equal(game.battle!.preLeader!.closed, false);
}
game = act(game, owner, power === 'voice'
? { type: 'voice', kind: 'shield', must: false }
: { type: 'prescience', field: 'dial' });
assert.equal(game.response?.kind, power);
assert.deepEqual(inventory(game), originalCards);
return { game, originalCards, target, kull, karama, counter, semuta: SEMUTA_DRUG_ID,
holder: 'richese', reactor: 'choam', owner, actor: 'emperor', counterOwner: 'harkonnen', power };
}
+172
View File
@@ -0,0 +1,172 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { act, createKullBattleSemutaFixture, inventory, player } from './fixture-kull-battle-semuta';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
type Fixture = ReturnType<typeof createKullBattleSemutaFixture>;
const held = (game: Game, id: string, card: string) => player(game, id).hand.some(item => item.id === card);
function attempt(f: Fixture) {
const game = act(reload(f.game), f.actor, { type: 'card', mode: 'cancel', card: f.karama });
assert.equal(game.pendingKull?.stage, 'offer');
const choice = viewGame(game, f.reactor).kullReaction; assert.ok(choice);
assert.ok(choice.plays.some(row => row.source === 'printed' && row.card.id === f.kull && !row.blocked),
`Kull is missing in the actual Voice frame: ${JSON.stringify({ preLeader: game.battle?.preLeader,
preparation: game.battle?.preparation, frame: game.pendingKull?.intent, resume: game.pendingKull?.resume })}`);
assert.ok(held(game, f.actor, f.karama));
return game;
}
function declare(f: Fixture, game: Game) {
const choice = viewGame(game, f.reactor).kullReaction; assert.ok(choice);
const next = act(game, f.reactor, { type: 'kullDecision', event: choice.event, source: 'printed', card: f.kull });
assert.equal(next.pendingKull?.stage, 'counter');
return next;
}
function passResponsesUntilDiscard(game: Game) {
for (let count = 0; count < 16 && !game.pendingTreacheryDiscard; count++) {
assert.ok(game.response, 'The native Kull counter must finish before its discard.');
const actor = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(actor);
game = act(game, actor.id, { type: 'passResponse' });
}
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
return game;
}
function react(game: Game, f: Fixture, expectedCard: string, claim: boolean) {
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
const event = pending.batch.event;
const hadSemuta = held(game, f.holder, f.semuta);
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [expectedCard]);
if (claim) {
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
game = act(game, f.holder, { type: 'semutaCommit', event });
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, f.holder, { type: 'semutaSelect', event, card: expectedCard });
} else {
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
const reaction = game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage === 'offer');
const actor = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(actor);
game = act(game, actor.id, { type: 'semutaPass', event });
}
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
assert.equal(held(game, f.holder, expectedCard), claim);
assert.equal(held(game, f.holder, f.semuta), claim ? false : hadSemuta);
assert.deepEqual(inventory(game), f.originalCards);
assert.throws(() => act(game, f.holder, { type: 'semutaCommit', event }));
return game;
}
/** Finish only native responses and readiness, then seal real no-card plans.
* This proves the same battle remains actionable without replaying its setup. */
function continueBattle(game: Game, f: Fixture, powerApplies: boolean) {
for (let count = 0; game.response && count < 16; count++) {
const actor = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(actor);
game = act(game, actor.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
assert.ok(game.battle);
assert.equal(game.battle.territory, f.target.territory);
if (f.power === 'voice') assert.equal(!!game.battle.voice, powerApplies);
else assert.equal(!!game.battle.prescience, powerApplies);
for (let count = 0; game.battle?.preparation && count < 4; count++) {
const preparation = game.battle.preparation;
game = act(game, preparation.owner, preparation.kind === 'prescienceAnswer'
? { type: 'prescienceAnswer', value: 0 }
: { type: 'declineBattlePower' });
}
const event = game.battle!.event;
for (const actor of [f.actor, f.owner]) {
if (game.battle?.preLeader && !game.battle.preLeader.closed && !game.battle.preLeader.ready.includes(actor))
game = act(game, actor, { type: 'battlePreparationReady', event });
}
for (const actor of [f.actor, f.owner]) {
const leader = player(game, actor).leaders.find(row => !row.dead && !row.capturedBy); assert.ok(leader);
game = act(game, actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0 });
}
assert.ok(game.battle?.plans[f.actor] && game.battle.plans[f.owner]);
assert.equal(game.battle.event, event);
for (const actor of [f.actor, f.owner]) game = act(game, actor, { type: 'traitorCall', call: false });
assert.equal(game.battle?.revealed, true);
assert.deepEqual(inventory(game), f.originalCards);
return game;
}
for (const claim of [false, true]) {
void test(`Basic Kull stops Prescience Karama with one combatant ready and Semuta ${claim ? 'claims' : 'passes'} Kull`, () => {
const f = createKullBattleSemutaFixture({ power: 'prescience', preReady: true });
assert.deepEqual(f.game.battle!.preLeader!.ready, [f.actor]);
const pending = passResponsesUntilDiscard(declare(f, attempt(f)));
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'kullSuccessDiscard');
assert.deepEqual(pending.battle!.preLeader!.ready, [f.actor]);
const game = react(pending, f, f.kull, claim);
assert.ok(held(game, f.actor, f.karama));
assert.deepEqual(game.battle!.preLeader!.ready, [f.actor]);
continueBattle(game, f, true);
});
}
for (const claim of [false, true]) {
void test(`Basic Prescience Kull counter preserves readiness and Semuta ${claim ? 'claims' : 'passes'} the counter`, () => {
const f = createKullBattleSemutaFixture({ power: 'prescience', preReady: true });
const declared = declare(f, attempt(f));
let game = act(declared, f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter });
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'kullCounterDiscard');
assert.deepEqual(game.battle!.preLeader!.ready, [f.actor]);
game = react(game, f, f.counter, claim);
if (game.pendingTreacheryDiscard) game = react(game, f, f.karama, !claim);
assert.equal(game.pendingKull, null);
assert.equal(held(game, f.actor, f.karama), false);
assert.deepEqual(game.battle!.preLeader!.ready, [f.actor]);
continueBattle(game, f, false);
});
}
for (const claim of [false, true]) {
void test(`Basic Kull stops battle Karama and Semuta ${claim ? 'claims' : 'passes'} Kull`, () => {
const f = createKullBattleSemutaFixture();
const pending = passResponsesUntilDiscard(declare(f, attempt(f)));
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'kullSuccessDiscard');
assert.ok(held(pending, f.actor, f.karama));
const game = react(pending, f, f.kull, claim);
assert.ok(held(game, f.actor, f.karama));
assert.equal(game.pendingKull, null);
assert.ok(viewGame(game, f.actor).karamaBlocked);
continueBattle(game, f, true);
});
}
void test('declining Kull permits the original battle Karama and its fresh Semuta reaction', () => {
const f = createKullBattleSemutaFixture();
let game = attempt(f);
const event = game.pendingKull!.event;
game = act(game, f.reactor, { type: 'kullDecision', event, decline: true });
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'karamaCombatDiscard');
game = react(game, f, f.karama, true);
assert.ok(held(game, f.reactor, f.kull));
assert.equal(game.pendingKull, null);
continueBattle(game, f, false);
});
for (const claim of [false, true]) {
void test(`a distinct counter stops battle Kull, then Semuta ${claim ? 'claims' : 'passes'} the counter`, () => {
const f = createKullBattleSemutaFixture();
const declared = declare(f, attempt(f));
assert.ok(viewGame(declared, f.counterOwner).responseControls?.cancelCards.includes(f.counter));
let game = act(declared, f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter });
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'kullCounterDiscard');
assert.ok(held(game, f.reactor, f.kull));
game = react(game, f, f.counter, claim);
assert.ok(held(game, f.reactor, f.kull));
// The original attempted Karama is a separate fresh discard if its native
// combat cost resumes. It cannot reuse the already-spent Semuta card.
if (game.pendingTreacheryDiscard) game = react(game, f, f.karama, !claim);
assert.equal(game.pendingKull, null);
assert.equal(held(game, f.actor, f.karama), false);
continueBattle(game, f, false);
});
}