Files
Dune/tests/fixture-hajr-replay.ts
T
Quintine 49586efd2f Support waiting-turn Basic ground-movement promises
Allow unused respondents in the current Movement queue to answer against their current owned board and resources. Isolate foreign declarations, prices, flights and searches in detached forecasting without future income/discard assumptions. Preserve native per-player Hajr history. Other players cannot fulfill or close the promise with their own moves/end. Ordinary executable guidance waits until the real owned turn.

Keep an actual already-paid Box decision actionable while waiting. Enumerate its full entitled choice, prioritizing movement cards without excluding ordinary cards that preserve an existing route; pre-payment known-discard rules remain unchanged. Preserve the original event, fee, private inspection and source continuation. Check deferred Emperor gift and Spiritual Advisor costs on their allowed outcome before another player responds. Localize native pending-movement deferral to the actual mover so another waiting owner cannot hide voluntary Box overspending. Minimal profiles may decline optional accompaniment under a positive promise.

Verify 18 files125/125, types/lint and four original-action guidance/paid-choice/table-rendering programmes. Original source19 reverses the unopened classic deal; native44 earns Box1/Hajr1 and genuinely spends Richese Truth, with Guild's actual later question supplying a second ordinary paid candidate. Eight existing-profile runs make32 accepted proposals plus12 separate neutral acknowledgements. Controlled pact/gift, BG losses, original Ornithopter acquisition and native Fremen/Box cases disclose their staging. Paired affordable/unaffordable interruptions preserve exact frames and prevent voluntary route loss.

The production build passes at the initial freeze. Only the regression gains its affordable counterpart afterward; source identity proves every application/build/dependency and other file unchanged, while final tests/types/runtime run again. Final checkpoint/reference prose overlays are separately reviewed. Earlier phases/later rounds, remaining preparations, No-Field/modules, full modes, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
2026-10-24 15:31:17 +11:00

181 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { location } from '../game/board';
import { richeseCards } from '../game/richese-cards';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import {
actOrnithopterHajr, ornithopterHajrClean, ornithopterHajrMove,
ornithopterHajrPlayer, type OrnithopterHajrStep,
} from './fixture-ornithopter-hajr';
const canonicalBox = richeseCards().find(card => card.effect === 'nullentropyBox');
assert.ok(canonicalBox);
export const HAJR_REPLAY_BOX = canonicalBox.id;
export type HajrReplayStep = OrnithopterHajrStep;
export const hajrReplayPlayer = ornithopterHajrPlayer;
export const hajrReplayClean = ornithopterHajrClean;
export const actHajrReplay = actOrnithopterHajr;
export const hajrReplayMove = ornithopterHajrMove;
/** Only live original controls are consumed. The cache card is bought in its
* ordinary native auction; the single Hajr is bought in the normal auction. */
export function nextHajrReplayStep(game: Game): HajrReplayStep | null {
if (!game.response && !game.phaseOpening && game.decision) {
const decision = game.decision;
if (decision.kind === 'richeseCache') return { actor: decision.player, action: {
type: 'decision', event: game.richeseBidding!.event, card: HAJR_REPLAY_BOX,
method: 'onceAround', direction: 'clockwise',
} };
if (decision.kind === 'richeseUnbid') return { actor: decision.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
}
if (hajrReplayClean(game) && game.richeseAuction?.cardId === HAJR_REPLAY_BOX &&
!game.richeseAuction.outcome && game.richeseAuction.active === 'emperor' && game.richeseAuction.bid === 0)
return { actor: 'emperor', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
if (hajrReplayClean(game) && game.auction?.active === 'emperor' && game.auction.bid === 0 &&
game.auction.cards[game.auction.index].effect === 'hajr')
return { actor: 'emperor', action: { type: 'bid', amount: 1, allyPayment: 0 } };
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceHajrReplay(game: Game, until: (state: Game) => boolean,
actions: HajrReplayStep[] = []): Game {
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Hajr/Box programme ended before its next control');
const next = nextHajrReplayStep(game); assert.ok(next, 'Finish the real owned Hajr/Box choice before continuing');
game = actHajrReplay(game, next.actor, next.action, actions);
}
throw Error('The original Hajr/Box programme did not reach its finite boundary');
}
/** Genuine original host, setup, paid Box/Hajr lots and paid desert shipment.
* Only unopened Hajr/Spice deck positions and scalar lotteries are selected.
* No hand, wallet, force, phase or completed-effect receipt is installed. */
export function createHajrReplayFixture(options: { advanced?: boolean; truthForGuild?: boolean; truthForRichese?: boolean } = {}) {
const advanced = options.advanced ?? false;
let game = createGame('HAJRREPLAY', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: HajrReplayStep[] = [];
const act = (actor: string, action: Action) => { game = actHajrReplay(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceHajrReplay(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
const startingDealSelection: { position: number; from: number; card: string }[] = [];
let guildTruth: string | null = null;
let richeseTruth: string | null = null;
if (options.truthForGuild) {
assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0), 'Select only an unopened starting deal');
const position = game.players.findIndex(player => player.id === 'guild');
const from = game.deck.findIndex(card => card.effect === 'truthtrance');
assert.ok(position >= 0 && from >= position);
guildTruth = game.deck[from].id;
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
startingDealSelection.push({ position, from, card: guildTruth });
}
if (options.truthForRichese) {
assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0), 'Select only an unopened starting deal');
const position = game.players.findIndex(player => player.id === 'richese');
const from = game.deck.findIndex(card => card.effect === 'truthtrance' && card.id !== guildTruth);
assert.ok(position >= 0 && from >= position);
richeseTruth = game.deck[from].id;
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
startingDealSelection.push({ position, from, card: richeseTruth });
}
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
if (guildTruth) assert.ok(hajrReplayPlayer(game, 'guild').hand.some(card => card.id === guildTruth));
if (richeseTruth) assert.ok(hajrReplayPlayer(game, 'richese').hand.some(card => card.id === richeseTruth));
for (let count = 0; count < (advanced ? 2 : 1); count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
reach(state => state.phase === 2 && hajrReplayClean(state));
const at = game.deck.findIndex(card => card.effect === 'hajr');
assert.ok(at >= 0, 'The one original Hajr must still be undealt');
const hajr = game.deck[at].id;
game.deck.unshift(game.deck.splice(at, 1)[0]);
const beforePurchases = structuredClone(game);
reach(state => hajrReplayPlayer(state).hand.some(card => card.id === HAJR_REPLAY_BOX) &&
hajrReplayPlayer(state).hand.some(card => card.id === hajr));
const acquired = structuredClone(game), acquiredActionCount = actions.length;
reach(state => state.phase === 5 && state.active === 'emperor' && hajrReplayClean(state));
const beforeShipment = structuredClone(game), shipmentStep: HajrReplayStep = { actor: 'emperor', action: {
type: 'ship', territory: 'imperial_basin', sector: 9, amount: 3, elite: advanced ? 2 : 0, allyPayment: 0,
} };
act(shipmentStep.actor, shipmentStep.action); reach(hajrReplayClean);
return { game, lobby, setup, beforeStartingDeal, startingDealSelection, guildTruth, richeseTruth,
afterSetup, beforePurchases, acquired, acquiredActionCount, beforeShipment, shipmentStep,
afterShipment: structuredClone(game), hajr, box: HAJR_REPLAY_BOX, owner: 'emperor',
source: 'imperial_basin:9', actions,
staging: ['Original native setup and paid cache1/normal1 lots acquire Box and the single Hajr. An original paid3-counter desert shipment leaves the two-spice Box fee. Only unopened deck positions and scalar lotteries are selected.'] };
}
export type HajrReplayFixture = ReturnType<typeof createHajrReplayFixture>;
/** The real Box pays once and either exposes its actual private search or
* automatically takes the only eligible discard, as the existing rule does. */
export function recoverHajrWithBox(game: Game, fixture: HajrReplayFixture, actions: HajrReplayStep[] = []) {
assert.ok(game.discard.some(card => card.id === fixture.hajr));
const before = structuredClone(game), step: HajrReplayStep = {
actor: fixture.owner, action: { type: 'card', card: fixture.box },
};
game = actHajrReplay(game, step.actor, step.action, actions);
const paid = structuredClone(game);
let selectionStep: HajrReplayStep | null = null;
if (game.pendingNullentropy) {
const search = viewGame(game, fixture.owner).nullentropy?.search;
assert.ok(search && search.cards.some(card => card.id === fixture.hajr), 'Select the genuinely discarded Hajr from the real paid owner view');
selectionStep = { actor: fixture.owner,
action: { type: 'decision', event: search.event, card: fixture.hajr } };
game = actHajrReplay(game, selectionStep.actor, selectionStep.action, actions);
}
game = advanceHajrReplay(game, hajrReplayClean, actions);
assert.ok(hajrReplayPlayer(game).hand.some(card => card.id === fixture.hajr));
return { game, before, step, paid, selectionStep };
}
/** Basic: Hajr, move, Box, recovered Hajr, move, move. Advanced: move,
* Hajr, Box, move, recovered Hajr, move. A Box action supplies no movement
* and no Advanced timing opportunity; the second actual move opens that. */
export function completeHajrReplay(fixture: HajrReplayFixture) {
let game = structuredClone(fixture.game);
const actions: HajrReplayStep[] = [];
const moves: { before: Game; step: HajrReplayStep; after: Game }[] = [];
const play = () => {
game = actHajrReplay(game, fixture.owner, { type: 'card', card: fixture.hajr }, actions);
game = advanceHajrReplay(game, hajrReplayClean, actions);
};
const move = (from: string) => {
const before = structuredClone(game), step: HajrReplayStep = {
actor: fixture.owner, action: hajrReplayMove(game, from),
};
game = actHajrReplay(game, step.actor, step.action, actions);
game = advanceHajrReplay(game, hajrReplayClean, actions);
moves.push({ before, step, after: structuredClone(game) });
return location(String(step.action.territory), Number(step.action.sector));
};
if (!game.advanced) play();
const beforeFirstMove = structuredClone(game), firstDestination = move(fixture.source);
if (game.advanced) play();
const firstHajr = structuredClone(game), recovery = recoverHajrWithBox(game, fixture, actions);
game = recovery.game;
const recovered = structuredClone(game);
let secondDestination = firstDestination;
if (game.advanced) secondDestination = move(firstDestination);
const beforeSecondHajr = structuredClone(game);
play();
const secondHajr = structuredClone(game);
if (!game.advanced) secondDestination = move(firstDestination);
const beforeThirdMove = structuredClone(game);
move(secondDestination);
const completed = structuredClone(game);
game = actHajrReplay(game, fixture.owner, { type: 'endMovement' }, actions);
return { game, completed, beforeFirstMove, firstHajr, recovery, recovered, beforeSecondHajr,
secondHajr, beforeThirdMove, firstDestination, secondDestination, moves, actions };
}