Support owned Box and Kulon movement-promise preparation

Extend bounded Basic ground-movement reachability to held native CHOAM Kulon and owned paid Box recovery. Before payment, use only known current-discard identities. Preserve actual paid event, full inspection, one two-spice fee, original physical card replay and existing provisional full-hand exchange. Reject a Yes-breaking selection before the server shuffle without losing the paid search. Keep No-only searches on their normal decision before movement can end.

Record movement-relevant current-discard certainty at actual disposal, paid search, mixed claim and deck rebuild boundaries without projecting raw metadata or erasing historical memory. Use deterministic preparation on private copies, include preparation state in search memoization, protect fee-funded routes from voluntary spending and keep native Kulon counter semantics. Persistent busy-safe guidance and existing legal profiles use the actual next paid action; no strategy tuning.

Verify 17 files231/231, types/lint/build, three original-action control/table-rendering programmes and four existing profiles with40 accepted actions including neutral passes. Original public sources44/25 retain actual purchases1+1, shipment costs6/1, exhausted Hajr and native Kulon route4. Separate full-hand, legacy certainty, fee-loss and acquired-counter edges disclose conserved staging. Two prior-engine Auditor fixture failures are repaired by actual battle-result acknowledgements; test-only const lint is corrected.

Other timing, preparations/counter mitigation, unknown private possibilities, optional/Advanced, full modes, comprehensive assurance and deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
This commit is contained in:
Quintine committed 2026-10-24 14:53:11 +11:00
1 parent edbcbe28de
commit b5a53da712
16 files changed
+772 -41

No files matched your search

+6
View File
@@ -673,6 +673,12 @@ for (const stage of ['response', 'payment'] as const)
});
initial = applyAction(initial, 'p', { type: 'traitorCall', call: false });
initial = applyAction(initial, 'q', { type: 'traitorCall', call: false });
while (initial.battle?.outcomeWindow) {
const window = initial.battle.outcomeWindow;
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id));
assert.ok(actor);
initial = applyAction(initial, actor, { type: 'battleOutcomeReady', event: window.event });
}
assert.equal(initial.decision?.kind, 'choamAudit');
initial = applyAction(initial, 'q', {
type: 'decision',
+17 -2
View File
@@ -51,7 +51,7 @@ export function advanceHajrReplay(game: Game, until: (state: Game) => boolean,
/** 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 } = {}) {
export function createHajrReplayFixture(options: { advanced?: boolean; truthForGuild?: 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));
@@ -62,8 +62,22 @@ export function createHajrReplayFixture(options: { advanced?: boolean } = {}) {
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;
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 });
}
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));
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]);
@@ -83,7 +97,8 @@ export function createHajrReplayFixture(options: { advanced?: boolean } = {}) {
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, afterSetup, beforePurchases, acquired, beforeShipment, shipmentStep,
return { game, lobby, setup, beforeStartingDeal, startingDealSelection, guildTruth,
afterSetup, beforePurchases, acquired, 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.'] };
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { location, splitLocation } from '../game/board';
import {
actHajrReplay, advanceHajrReplay, createHajrReplayFixture, hajrReplayClean,
hajrReplayMove, hajrReplayPlayer, nextHajrReplayStep, type HajrReplayStep,
} from './fixture-hajr-replay';
export type TruthMovementBoxStep = HajrReplayStep;
export const actTruthMovementBox = actHajrReplay;
export const truthMovementBoxPlayer = hajrReplayPlayer;
export const truthMovementBoxClean = hajrReplayClean;
export function truthMovementBoxInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(player => player.hand)];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Every original physical card has one current location');
return ids;
}
export const truthMovementBoxBalances = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice]));
/** Native source responses only; the caller owns the question, paid private
* selection and the active respondent's movement, including its ending. */
export function nextTruthMovementBoxStep(game: Game): TruthMovementBoxStep | null {
if (game.truthtrance || game.pendingNullentropy) return null;
if (game.phase === 5 && game.active === 'emperor' && hajrReplayClean(game)) return null;
return nextHajrReplayStep(game);
}
export function advanceTruthMovementBox(game: Game, until: (state: Game) => boolean,
actions: TruthMovementBoxStep[] = []): Game {
for (let count = 0; count < 600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Box/movement programme ended before its next control');
const next = nextTruthMovementBoxStep(game);
assert.ok(next, 'Supply the original Truthtrance, paid Box selection or active movement choice');
game = actHajrReplay(game, next.actor, next.action, actions);
}
throw Error('The original Box/movement programme did not reach its finite boundary');
}
/** Public Basic setup and original purchases/forces only. The first actual Hajr
* is spent and both earned movements are completed, so another ground move
* requires recovery and replay of that same physical card. No question, Box
* payment, private search, promise or third movement is submitted here. */
export function createTruthMovementBoxFixture() {
const fixture = createHajrReplayFixture({ truthForGuild: true });
const owner = fixture.owner, target = owner, asker = 'guild', truth = fixture.guildTruth;
assert.ok(truth);
let game = structuredClone(fixture.game);
const originalCards = truthMovementBoxInventory(fixture.setup);
const actions: TruthMovementBoxStep[] = structuredClone(fixture.actions);
const act = (actor: string, action: Action) => { game = actHajrReplay(game, actor, action, actions); };
const reach = () => { game = advanceHajrReplay(game, hajrReplayClean, actions); };
const balances = truthMovementBoxBalances(game);
const beforeHajr = structuredClone(game);
act(owner, { type: 'card', card: fixture.hajr }); reach();
const afterHajr = structuredClone(game);
const moves: { before: Game; step: TruthMovementBoxStep; after: Game }[] = [];
let from = fixture.source;
for (let count = 0; count < 2; count++) {
const before = structuredClone(game), step: TruthMovementBoxStep = { actor: owner, action: hajrReplayMove(game, from) };
act(step.actor, step.action); reach();
moves.push({ before, step: structuredClone(step), after: structuredClone(game) });
from = location(String(step.action.territory), Number(step.action.sector));
}
const source = structuredClone(game), own = hajrReplayPlayer(game);
const ownView = viewGame(game, owner).players.find(player => player.id === owner)!;
assert.equal(game.advanced, false); assert.equal(game.phase, 5); assert.equal(game.active, owner);
assert.equal(own.moved, 2); assert.equal(ownView.movesAllowed, 2); assert.equal(ownView.hajrPlays, 1);
assert.equal(own.spice, 2, 'The genuine purchases and shipment retain exactly the printed Box fee');
assert.ok(own.hand.some(card => card.id === fixture.box));
assert.equal(game.discard.filter(card => card.id === fixture.hajr).length, 1);
assert.ok(!own.hand.some(card => card.id === fixture.hajr));
assert.ok(hajrReplayPlayer(game, asker).hand.some(card => card.id === truth));
assert.ok(!game.truthtrance && !game.pendingNullentropy && !game.pendingTreacheryDiscard);
assert.equal(game.movementPromises, undefined);
assert.deepEqual(truthMovementBoxBalances(game), balances);
assert.deepEqual(truthMovementBoxInventory(game), originalCards);
const proposedThirdMove = hajrReplayMove(game, from);
const destination = splitLocation(location(String(proposedThirdMove.territory), Number(proposedThirdMove.sector)));
const claim = { kind: 'movement' as const, target, territory: destination.territory, minimum: 1 };
return {
game, source, fixture, lobby: fixture.lobby, setup: fixture.setup, beforeStartingDeal: fixture.beforeStartingDeal,
startingDealSelection: fixture.startingDealSelection, afterSetup: fixture.afterSetup,
acquired: fixture.acquired, beforeShipment: fixture.beforeShipment, shipmentStep: fixture.shipmentStep,
afterShipment: fixture.afterShipment, beforeHajr, afterHajr, moves,
owner, target, asker, truth, hajr: fixture.hajr, box: fixture.box, boxFee: 2,
claim, proposedThirdMove, thirdOrigin: from, destination, originalCards,
actions, sourceActionCount: actions.length, balances,
staging: [
...fixture.staging,
'One original unopened Guild starting-deal slot receives original Truthtrance. The native source actually buys Box for1 and Hajr for1 and ships three physical Emperor forces at its original six-spice desert tariff. Actual Basic Hajr plus two ordinary one-force movements exhaust the two current allowances while leaving exactly two owned spice and the held Box. The first Hajr is physically discarded once and publicly used; native Semuta acknowledgements finish that same original cost. No dealt hand, wallet, force, clock, bonus, promise or pending receipt is installed. This source stops before the Truthtrance declaration/answer and before paying for the Box. The later real definite Truthtrance answer naturally adds another eligible discard, so the paid search can have multiple actual cards without an injected distractor.',
],
};
}
export type TruthMovementBoxFixture = ReturnType<typeof createTruthMovementBoxFixture>;
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Card } from '../game/cards';
import type { FreshDiscardBatch } from '../game/semuta-drug';
import {
recordMovementDiscardKnowledge, knownMovementDiscards, recordMovementBoxKnowledge,
recordMovementClaimKnowledge, clearMovementDiscardKnowledge,
type MovementDiscardKnowledge,
} from '../game/movement-discard-knowledge';
const seats = ['owner', 'rival', 'observer'];
const hajr: Card = { id: 'physical-hajr', kind: 'special', name: 'Hajr', effect: 'hajr' };
const ornithopter: Card = { id: 'richese-ornithopter', kind: 'special', name: 'Ornithopter', effect: 'ornithopter' };
const kulon: Card = { id: 'physical-kulon', kind: 'worthless', name: 'Kulon' };
const shield: Card = { id: 'physical-shield', kind: 'shield', name: 'Shield' };
const entry = (card: Card, discardedBy = 'owner', publicFace = false): FreshDiscardBatch['entries'][number] =>
({ card, discardedBy, publicFace });
void test('a public relevant disposal is remembered by all seats without creating unrelated face knowledge', () => {
const entries = [entry(hajr, 'rival', true), entry(shield, 'rival', true)];
const before = structuredClone(entries);
const result = recordMovementDiscardKnowledge(undefined, seats, entries);
assert.deepEqual(result, { observer: [hajr.id], owner: [hajr.id], rival: [hajr.id] });
for (const player of seats) assert.deepEqual(knownMovementDiscards(result, player, [shield, hajr]), [hajr]);
assert.deepEqual(entries, before);
});
void test('a private disposal teaches only its holder and an unknown relevant face cannot leak through lookup', () => {
const result = recordMovementDiscardKnowledge(undefined, seats, [entry(hajr), entry(kulon, 'rival')]);
assert.deepEqual(result, { owner: [hajr.id], rival: [kulon.id] });
assert.deepEqual(knownMovementDiscards(result, 'owner', [hajr, kulon, ornithopter]), [hajr]);
assert.deepEqual(knownMovementDiscards(result, 'observer', [hajr, kulon, ornithopter]), []);
});
void test('canonical relevance excludes an Ornithopter lookalike, unrelated Worthless and absent state', () => {
const fake = { ...ornithopter, id: 'different-ornithopter' };
const otherWorthless: Card = { id: 'other-worthless', name: 'Baliset', kind: 'worthless' };
assert.equal(recordMovementDiscardKnowledge(undefined, seats, [entry(fake, 'owner', true), entry(otherWorthless), entry(shield)]), undefined);
assert.equal(recordMovementDiscardKnowledge(undefined, seats, []), undefined);
assert.deepEqual(knownMovementDiscards(undefined, 'owner', [hajr, ornithopter, kulon]), []);
assert.deepEqual(recordMovementDiscardKnowledge(undefined, seats, [entry(ornithopter), entry(kulon)]),
{ owner: [kulon.id, ornithopter.id].sort() });
});
void test('a paid private Box invalidates other memories and grants only its owner the actual remaining faces', () => {
const state: MovementDiscardKnowledge = { owner: [hajr.id], rival: [hajr.id, ornithopter.id], observer: [kulon.id] };
const before = structuredClone(state), pile = [shield, ornithopter, kulon], originalPile = structuredClone(pile);
const result = recordMovementBoxKnowledge(state, seats, 'owner', pile);
assert.deepEqual(result, { owner: [kulon.id, ornithopter.id].sort() });
assert.deepEqual(knownMovementDiscards(result, 'owner', pile), [ornithopter, kulon]);
assert.deepEqual(knownMovementDiscards(result, 'rival', pile), []);
assert.equal(recordMovementBoxKnowledge(state, seats, 'owner', [shield]), undefined);
assert.deepEqual(state, before); assert.deepEqual(pile, originalPile);
});
void test('a private mixed claim removes ambiguous fresh memories while preserving identities outside that batch', () => {
const state: MovementDiscardKnowledge = {
owner: [hajr.id, ornithopter.id], rival: [hajr.id, ornithopter.id], observer: [kulon.id, ornithopter.id],
};
const before = structuredClone(state), entries = [entry(hajr, 'rival'), entry(kulon, 'observer')];
const originalEntries = structuredClone(entries);
const result = recordMovementClaimKnowledge(state, seats, 'owner', entries, hajr.id);
assert.deepEqual(result, {
observer: [ornithopter.id], owner: [kulon.id, ornithopter.id].sort(), rival: [ornithopter.id],
});
assert.deepEqual(knownMovementDiscards(result, 'owner', [ornithopter, kulon]), [ornithopter, kulon]);
assert.deepEqual(knownMovementDiscards(result, 'observer', [ornithopter, kulon]), [ornithopter]);
assert.deepEqual(state, before); assert.deepEqual(entries, originalEntries);
});
void test('a full discard reshuffle clears preparation knowledge without mutating the former record', () => {
const state = { owner: [hajr.id], rival: [ornithopter.id] }, before = structuredClone(state);
assert.equal(clearMovementDiscardKnowledge(state), undefined);
assert.equal(clearMovementDiscardKnowledge(undefined), undefined);
assert.deepEqual(state, before);
});
void test('lookup filters stale identities, preserves physical pile order and returns independent card faces', () => {
const state = { owner: [hajr.id, 'removed-card', kulon.id, shield.id] };
const pile = [kulon, shield, hajr], before = structuredClone(pile);
const faces = knownMovementDiscards(state, 'owner', pile);
assert.deepEqual(faces, [kulon, hajr]);
faces[0].name = 'changed'; faces.push(ornithopter);
assert.deepEqual(pile, before);
assert.deepEqual(state.owner, [hajr.id, 'removed-card', kulon.id, shield.id]);
});
void test('repeated observations use deterministic unique identities and do not alias original state arrays', () => {
const state = { owner: [hajr.id, hajr.id] }, before = structuredClone(state);
const events = [entry(ornithopter, 'owner', true), entry(kulon), entry(hajr)];
const first = recordMovementDiscardKnowledge(state, seats, events)!;
const second = recordMovementDiscardKnowledge(first, [...seats].reverse(), [...events].reverse())!;
assert.deepEqual(first, second); assert.deepEqual(state, before);
second.owner.push('new-id');
assert.deepEqual(first.owner, [hajr.id, kulon.id, ornithopter.id].sort());
});
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, type ReactElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MovementPromises } from '../components/movement-promises';
import { NullentropySearch } from '../components/nullentropy-box';
import { viewGame, type Action } from '../game/engine';
import { createTruthMovementBoxFixture, actTruthMovementBox as act } from './fixture-truth-movement-box';
function clickables(node: unknown): { children?: unknown; onClick: () => void }[] {
if (Array.isArray(node)) return node.flatMap(clickables);
if (!node || typeof node !== 'object' || !('props' in node)) return [];
const props = (node as ReactElement<{ children?: unknown; onClick?: () => void }>).props;
return [...(props.onClick ? [{ ...props, onClick: props.onClick }] : []), ...clickables(props.children)];
}
function bound(answer = 'yes') {
const f = createTruthMovementBoxFixture(); let game = act(f.game, f.asker, { type: 'card', card: f.truth });
for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' });
game = act(game, f.asker, { type: 'truthAsk', question: f.claim });
game = act(game, f.target, { type: 'truthAnswer', answer });
if (game.pendingTreacheryDiscard?.reaction) {
const event = game.pendingTreacheryDiscard.batch.event;
for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
}
return { f, game };
}
void test('persistent Box guidance pays once then submits the original required paid selection without hiding other faces', () => {
const { f, game: source } = bound(), actions: Action[] = [];
const submit = (action: Action) => { actions.push(action); };
const before = { game: viewGame(source, f.target), act: submit, busy: false };
assert.match(renderToStaticMarkup(createElement(MovementPromises, before)), /Pay 2 spice and search with Nullentropy Box/);
clickables(MovementPromises({ ...before, busy: true }))[0].onClick(); assert.equal(actions.length, 0);
clickables(MovementPromises(before))[0].onClick(); assert.deepEqual(actions.pop(), { type: 'card', card: f.box });
const game = act(source, f.target, { type: 'card', card: f.box }), view = viewGame(game, f.target);
const paid = { game: view, act: submit, busy: false };
const html = renderToStaticMarkup(createElement(MovementPromises, paid));
assert.match(html, /Choose Hajr in your paid Box search/); assert.match(html, /fee is paid only once/);
assert.doesNotMatch(html, /Pay 2 spice and search with/);
const inspection = renderToStaticMarkup(createElement(NullentropySearch, paid));
assert.match(inspection, /Take Hajr and finish search/); assert.match(inspection, /Take Truthtrance and finish search/);
clickables(MovementPromises({ ...paid, busy: true }))[0].onClick(); assert.equal(actions.length, 0);
clickables(MovementPromises(paid))[0].onClick();
assert.deepEqual(actions, [{ type: 'decision', event: view.nullentropy!.search!.event, card: f.hajr }]);
const next = act(game, f.target, actions[0]); assert.equal(next.pendingNullentropy, null);
assert.equal(next.players.find(p => p.id === f.target)!.spice, 0);
assert.equal(MovementPromises({ ...paid, game: viewGame(game, f.asker) }), null);
});
void test('No remains readable during a paid search without an invalid End movement button', () => {
const { f, game: source } = bound('no'), game = act(source, f.target, { type: 'card', card: f.box });
const props = { game: viewGame(game, f.target), act() { assert.fail('Rendering cannot submit.'); }, busy: false };
const html = renderToStaticMarkup(createElement(MovementPromises, props));
assert.match(html, /No: you must not/); assert.match(html, /paid Box search must finish before moving/);
assert.equal(clickables(MovementPromises(props)).length, 0);
assert.match(renderToStaticMarkup(createElement(NullentropySearch, props)), /Take Hajr and finish search/);
});
@@ -0,0 +1,163 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
import { createTruthMovementChoamFixture } from './fixture-truth-movement-choam';
import { viewGame, normalizeAutomaticGame, type Game } from '../game/engine';
import { createTruthMovementBoxFixture, actTruthMovementBox as act } from './fixture-truth-movement-box';
function ask(game: Game, asker: string, card: string, question: Record<string, unknown>) {
game = act(game, asker, { type: 'card', card });
for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' });
return act(game, asker, { type: 'truthAsk', question });
}
void test('actual owned paid Box recovery makes a third Basic movement promise feasible', () => {
const f = createTruthMovementBoxFixture();
const game = ask(f.game, f.asker, f.truth, f.claim);
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
});
function passDiscard(game: Game): Game {
if (!game.pendingTreacheryDiscard?.reaction) return game;
const event = game.pendingTreacheryDiscard.batch.event;
for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
return game;
}
function bindBox(answer: 'yes' | 'no' = 'yes') {
const f = createTruthMovementBoxFixture();
let game = ask(f.game, f.asker, f.truth, f.claim);
game = act(game, f.target, { type: 'truthAnswer', answer });
game = passDiscard(game); return { f, game };
}
void test('the actual paid search selects required Hajr once and finishes the third move without a second fee', () => {
const { f, game: bound } = bindBox(); let game = bound;
const before = structuredClone(game), first = viewGame(game, f.target).movementPromisePolicy?.[0];
assert.deepEqual(first, { type: 'card', card: f.box });
assert.deepEqual(game, before, 'Private feasibility cannot pay or shuffle the live game');
game = act(game, f.target, first!);
assert.equal(game.players.find(p => p.id === f.target)!.spice, 0);
const paid = viewGame(game, f.target).nullentropy!.search!;
assert.ok(paid.cards.some(card => card.id === f.hajr)); assert.ok(paid.cards.some(card => card.id === f.truth));
const required = viewGame(game, f.target).movementPromisePolicy?.[0];
assert.deepEqual(required, { type: 'decision', event: paid.event, card: f.hajr });
const snapshot = structuredClone(game);
assert.throws(() => act(game, f.target, { type: 'decision', event: paid.event, card: f.truth }));
assert.deepEqual(game, snapshot, 'Rejected selection keeps the paid event and fee');
assert.throws(() => act(game, f.target, { type: 'decision', event: 'stale', card: f.hajr }));
game = act(game, f.target, required!); game = passDiscard(game);
assert.equal(game.pendingNullentropy, null);
const hajr = viewGame(game, f.target).movementPromisePolicy?.[0]; assert.deepEqual(hajr, { type: 'card', card: f.hajr });
game = act(game, f.target, hajr!); game = passDiscard(game);
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
assert.equal(game.movementPromises![0].fulfilled, true);
game = act(game, f.target, { type: 'endMovement' });
assert.equal(game.players.find(p => p.id === f.target)!.spice, 0);
assert.equal(game.players.find(p => p.id === f.target)!.hajrPlays, 2);
assert.equal(game.discard.filter(card => card.id === f.box).length, 1);
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
});
void test('a No-only paid search retains its full original choice and never proposes End movement while searching', () => {
const { f, game: bound } = bindBox('no'); let game = act(bound, f.target, { type: 'card', card: f.box });
assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
const search = viewGame(game, f.target).nullentropy!.search!;
assert.ok(search.cards.some(card => card.id === f.truth));
game = act(game, f.target, { type: 'decision', event: search.event, card: f.truth });
game = passDiscard(game);
assert.equal(game.movementPromises![0].fulfilled, undefined);
game = act(game, f.target, { type: 'endMovement' });
assert.equal(game.movementPromises![0].fulfilled, true);
assert.equal(game.players.find(p => p.id === f.target)!.spice, 0);
});
void test('a voluntary bribe cannot spend the Box fee reserved by the only remaining Yes route', () => {
const { f, game } = bindBox(); const before = structuredClone(game);
const view = viewGame(game, f.target);
assert.equal(view.bribeOptions.targets.find(target => target.id === f.asker)!.maximum, 0);
assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount: 1 }));
assert.deepEqual(game, before);
});
void test('controlled full-hand recovery keeps the approved atomic Box exchange and original paid event', () => {
const f = createTruthMovementBoxFixture();
const source = structuredClone(f.game), owner = source.players.find(p => p.id === f.target)!;
while (owner.hand.length < 4) owner.hand.push(source.deck.pop()!);
let game = ask(source, f.asker, f.truth, f.claim);
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
game = passDiscard(act(game, f.target, { type: 'truthAnswer', answer: 'yes' }));
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
const search = viewGame(game, f.target).nullentropy!.search!;
game = act(game, f.target, { type: 'decision', event: search.event, card: f.hajr });
assert.equal(game.players.find(p => p.id === f.target)!.hand.length, 4);
assert.equal(game.players.find(p => p.id === f.target)!.spice, 0);
});
void test('controlled legacy certainty cannot inspect hidden Box contents before payment', () => {
const f = createTruthMovementBoxFixture(), unknown = structuredClone(f.game);
delete unknown.movementDiscardKnowledge;
const changed = structuredClone(unknown), index = changed.discard.findIndex(card => card.id === f.hajr);
const alternate = changed.deck.findIndex(card => card.kind === 'shield'); assert.ok(alternate >= 0);
[changed.discard[index], changed.deck[alternate]] = [changed.deck[alternate], changed.discard[index]];
for (const source of [unknown, changed]) {
const game = ask(source, f.asker, f.truth, f.claim), view = viewGame(game, f.target);
assert.deepEqual(view.truthMovementAnswers, ['no']);
assert.equal(Object.hasOwn(view, 'movementDiscardKnowledge'), false);
assert.equal(view.nullentropy?.search, null);
}
});
void test('a controlled opposing loss of the fee releases the Box-dependent Yes while keeping its actual pieces', () => {
const { f, game: bound } = bindBox(); const harmed = structuredClone(bound);
harmed.players.find(p => p.id === f.target)!.spice--;
harmed.players.find(p => p.id === f.asker)!.spice++;
const game = normalizeAutomaticGame(harmed);
assert.equal(game.movementPromises![0].released, true);
assert.ok(game.players.find(p => p.id === f.target)!.hand.some(card => card.id === f.box));
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
});
function farChoamDestination(game: Game) {
const destination = TERRITORIES.flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))).find(to =>
gameDistance(game, 'arrakeen:10', `${to.territory}:${to.sector}`, key => {
const place = splitLocation(key); return place.sector !== 0 && place.sector === game.storm;
}) === 4);
assert.ok(destination); return destination;
}
void test('a genuinely held Kulon supplies its original native preparation before a promised four-step move', () => {
const f = createTruthMovementChoamFixture(), source = f.beforeKulon, destination = farChoamDestination(source);
let game = ask(source, f.asker, f.truth, { kind: 'movement', target: f.target, territory: destination.territory, minimum: 1 });
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'card', mode: 'choam', card: f.kulon }]);
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
assert.equal(game.choamMovement?.bonus, 1);
assert.ok(game.movementDiscardKnowledge?.[f.asker].includes(f.kulon), 'Public direct Kulon disposal establishes current-pile certainty');
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
assert.equal(game.movementPromises![0].fulfilled, true);
});
void test('a controlled real Karama counter keeps Kulon held/blocked and releases the impossible promised route', () => {
const f = createTruthMovementChoamFixture(), source = structuredClone(f.beforeKulon), destination = farChoamDestination(source);
const karama = source.deck.splice(source.deck.findIndex(card => card.effect === 'karama'), 1)[0]; assert.ok(karama);
source.players.find(p => p.id === f.asker)!.hand.push(karama);
let game = ask(source, f.asker, f.truth, { kind: 'movement', target: f.target, territory: destination.territory, minimum: 1 });
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
assert.equal(game.response?.kind, 'choamWorthless');
assert.equal(game.movementPromises![0].released, undefined, 'A pending opposing choice does not release the answer');
game = act(game, f.asker, { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(game.movementPromises![0].released, true);
assert.ok(game.players.find(p => p.id === f.target)!.hand.some(card => card.id === f.kulon));
assert.equal(game.choamMovement, undefined);
});
void test('all four minimal profiles select the required original paid event and complete real Box recovery', () => {
const { f, game: bound } = bindBox();
for (const difficulty of DIFFICULTIES) {
let game = act(bound, f.target, { type: 'card', card: f.box }), count = 0;
while (game.phase === 5 && game.active === f.target && count++ < 20) {
if (game.pendingTreacheryDiscard) { game = passDiscard(game); continue; }
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action);
if (game.pendingNullentropy) assert.deepEqual(action, { type: 'decision', event: game.pendingNullentropy.event, card: f.hajr });
game = act(game, f.target, action);
}
assert.ok(count < 20); assert.equal(game.movementPromises![0].fulfilled, true);
assert.equal(game.players.find(p => p.id === f.target)!.spice, 0);
}
});