Files
Dune/tests/fixture-kull-purpose.ts
T
Quintine 884a09fa92 Scope Advanced Kull to the attempted Karama effect
Adopt the supplied Advanced specific-effect wording, preserving Basic and legacy phase-wide semantics. Bind signed semantic purposes through ordinary/special activations, counters, feasibility and settlement; retain unrelated human controls and minimal bot actions. Phase expiry remains provisional; prior counter, BG and deferred-overbid choices stay.

Validation: frozen12-file111/111, types/lint/build and two original-action/rendered-table programmes. Explicit unopened deal/auction/spice order supports real paid Baliset and shipments; Advanced retains Karama for Baliset cancellation while Basic stays blocked. Four minimal policies remain legal. Correct stale exchange/phase-market/Fremen fixture assumptions. Recovery schema expectations maintained without running deferred SQL assurance. No full modes, browser/deployed acceptance, saved-game/reset/restart or deployment operation.
2026-10-18 12:49:23 +11:00

152 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeKullGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type KullPurposeStep = { actor: string; action: Action };
export const kullPurposePlayer = (game: Game, actor = 'emperor') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const kullPurposeClean = (game: Game) => !game.pendingKull && !game.response &&
!game.decision && !game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard;
export function actKullPurpose(game: Game, actor: string, action: Action, actions: KullPurposeStep[] = []): Game {
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) });
return next;
}
/** Neutral native choices only. No card is granted and no optional power or
* sealed plan is selected by this finite continuation driver. */
export function nextKullPurposeStep(game: Game): KullPurposeStep | null {
if (game.pendingKull?.stage === 'offer') return { actor: game.pendingKull.player,
action: { type: 'kullDecision', event: game.pendingKull.event, decline: true } };
if (!game.response && !game.phaseOpening && game.decision?.kind === 'choamMovement')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
if (kullPurposeClean(game) && game.auction?.active === 'choam' && game.auction.bid === 0 &&
game.auction.cards[game.auction.index].name === 'Baliset')
return { actor: 'choam', action: { type: 'bid', amount: 1, allyPayment: 0 } };
return nextStrongholdFactionsNativeStep(game);
}
export function advanceKullPurpose(game: Game, until: (state: Game) => boolean,
actions: KullPurposeStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Kull purpose programme ended before its next control');
const step = nextKullPurposeStep(game); assert.ok(step, 'Complete the real private Kull purpose choice before advancing');
game = actKullPurpose(game, step.actor, step.action, actions);
}
throw Error('The original Kull purpose programme did not reach its finite boundary');
}
/** Explicit original Kull preview entry from a ready native lobby. The exact
* initial deal is controlled while every card remains in the unopened deck:
* CHOAM gets physical Kull and the two classic seats each get one physical
* Karama. This is a selected deck order, not a naturally discovered random seed.
* Baliset is subsequently bought in an actual ordinary auction. */
export function createKullPurposeFixture(options: { advanced?: boolean } = {}) {
const advanced = options.advanced ?? true;
let game = createGame('KULLPURPOSE', newPlayer('choam', 'CHOAM', 'choam'), advanced, ['choam', 'ix']);
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: KullPurposeStep[] = [];
const act = (actor: string, action: Action) => { game = actKullPurpose(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceKullPurpose(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
game = withClassicDiscoveryNexusLottery(() => initializeKullGameForAudit(game));
const setup = structuredClone(game);
assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0), 'Select only the unopened original starting deal');
const front: Game['deck'] = [];
for (const kind of ['kull', 'karama', 'karama']) {
const at = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' : card.effect === 'karama');
assert.ok(at >= 0); front.push(game.deck.splice(at, 1)[0]);
}
const unopenedDeal = front.map((card, index) => ({ actor: game.players[index].id, card: card.id }));
game.deck.unshift(...front);
const beforeInitialDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), karama = unopenedDeal[1].card, counter = unopenedDeal[2].card;
assert.ok(kullPurposePlayer(game, 'choam').hand.some(card => card.id === 'ix-kull-wahad'));
assert.ok(kullPurposePlayer(game).hand.some(card => card.id === karama));
assert.ok(kullPurposePlayer(game, 'guild').hand.some(card => card.id === counter));
const land: Game['spiceDeck'] = [];
for (let count = 0; count < (advanced ? 2 : 1); count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
land.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...land);
reach(state => state.phase === 2 && kullPurposeClean(state));
const at = game.deck.findIndex(card => card.name === 'Baliset' && card.kind === 'worthless'); assert.ok(at >= 0);
const baliset = game.deck[at].id;
game.deck.unshift(game.deck.splice(at, 1)[0]);
const beforePurchase = structuredClone(game);
reach(state => kullPurposePlayer(state, 'choam').hand.some(card => card.id === baliset));
const acquired = structuredClone(game);
reach(state => state.phase === 5 && state.active === 'choam' && kullPurposeClean(state));
const beforeChoamShipment = structuredClone(game), choamShipment: KullPurposeStep = {
actor: 'choam', action: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 },
};
act(choamShipment.actor, choamShipment.action); reach(kullPurposeClean);
const afterChoamShipment = structuredClone(game);
act('choam', { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === 'emperor' && kullPurposeClean(state));
return { game, lobby, setup, beforeInitialDeal, unopenedDeal, afterSetup, beforePurchase, acquired,
beforeChoamShipment, choamShipment, afterChoamShipment, karama, counter, baliset, kull: 'ix-kull-wahad',
actor: 'emperor', reactor: 'choam', source: 'imperial_basin:10', destination: 'arrakeen:10',
entry: 'initializeKullGameForAudit' as const, sourceActionCount: actions.length, actions,
staging: ['Exact original unopened starting deal and unopened Baliset/Spice positions are selected, with scalar lottery order. Every subsequent card, wallet and force comes from native setup, actual paid auction and actual paid shipment; no playing-state inventory or clock is installed.'] };
}
export type KullPurposeFixture = ReturnType<typeof createKullPurposeFixture>;
export const kullPurposeShipmentAction = (fixture: KullPurposeFixture): Action => ({
type: 'card', mode: 'shipment', card: fixture.karama, target: fixture.actor,
});
/** Success prevents the original price attempt before cost. A normal funded
* shipment and real ground move then produce CHOAM's native Baliset response. */
export function reachKullPurposeBaliset(fixture: KullPurposeFixture) {
let game = structuredClone(fixture.game);
const actions: KullPurposeStep[] = [];
const act = (actor: string, action: Action) => { game = actKullPurpose(game, actor, action, actions); };
act(fixture.actor, kullPurposeShipmentAction(fixture));
const offered = structuredClone(game), reaction = viewGame(game, fixture.reactor).kullReaction;
assert.ok(reaction && reaction.plays.some(play => play.source === 'printed' && play.card.id === fixture.kull && !play.blocked));
act(fixture.reactor, { type: 'kullDecision', event: reaction.event, source: 'printed', card: fixture.kull });
const declared = structuredClone(game);
game = advanceKullPurpose(game, kullPurposeClean, actions);
const restricted = structuredClone(game), shipmentStep: KullPurposeStep = { actor: fixture.actor, action: {
type: 'ship', territory: 'imperial_basin', sector: 10, amount: 1, elite: game.advanced ? 1 : 0, allyPayment: 0,
} };
act(shipmentStep.actor, shipmentStep.action);
game = advanceKullPurpose(game, kullPurposeClean, actions);
const shipped = structuredClone(game), moveStep: KullPurposeStep = { actor: fixture.actor, action: {
type: 'move', from: fixture.source, territory: 'arrakeen', sector: 10, amount: 1, elite: game.advanced ? 1 : 0,
} };
act(moveStep.actor, moveStep.action);
const beforeBaliset = structuredClone(game);
assert.equal(game.decision?.kind, 'choamMovement');
act(fixture.reactor, { type: 'card', mode: 'choam', card: fixture.baliset, target: fixture.actor, territory: 'arrakeen' });
const baliset = structuredClone(game);
assert.equal(game.response?.kind, 'choamWorthless');
assert.equal(game.response.intent, 'Baliset');
return { game, offered, declared, restricted, shipped, shipmentStep, beforeBaliset, moveStep, baliset, actions };
}
/** The retained original Karama may cancel a different purpose in Advanced.
* Its second neutral Kull offer is actually declined after the cost was spent;
* the native Baliset cancellation resumes exactly the declared typed move. */
export function completeKullPurpose(fixture: KullPurposeFixture) {
assert.equal(fixture.game.advanced, true, 'Basic retains its original whole-phase restriction');
const programme = reachKullPurposeBaliset(fixture), actions = [...programme.actions];
let game = actKullPurpose(programme.game, fixture.actor,
{ type: 'card', mode: 'cancel', card: fixture.karama }, actions);
const differentOffer = structuredClone(game), reaction = viewGame(game, fixture.reactor).kullReaction;
assert.ok(reaction && reaction.plays.every(play => play.card.id !== fixture.kull));
game = actKullPurpose(game, fixture.reactor, { type: 'kullDecision', event: reaction.event, decline: true }, actions);
game = advanceKullPurpose(game, kullPurposeClean, actions);
const completed = structuredClone(game);
game = actKullPurpose(game, fixture.actor, { type: 'endMovement' }, actions);
return { ...programme, game, completed, differentOffer, actions };
}