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.
This commit is contained in:
1 parent
f0b2ac82d9
commit
49586efd2f
17 files changed
+647
-36
No files matched your search
@@ -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; truthForGuild?: boolean } = {}) {
|
||||
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));
|
||||
@@ -64,6 +64,7 @@ export function createHajrReplayFixture(options: { advanced?: boolean; truthForG
|
||||
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');
|
||||
@@ -74,10 +75,21 @@ export function createHajrReplayFixture(options: { advanced?: boolean; truthForG
|
||||
[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]);
|
||||
@@ -91,14 +103,14 @@ export function createHajrReplayFixture(options: { advanced?: boolean; truthForG
|
||||
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);
|
||||
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,
|
||||
afterSetup, beforePurchases, acquired, beforeShipment, shipmentStep,
|
||||
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.'] };
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Action, Game } from '../game/engine';
|
||||
import {
|
||||
actHajrReplay, advanceHajrReplay, createHajrReplayFixture,
|
||||
hajrReplayClean, hajrReplayPlayer, type HajrReplayStep,
|
||||
} from './fixture-hajr-replay';
|
||||
import { truthMovementBoxInventory } from './fixture-truth-movement-box';
|
||||
|
||||
export const actTruthMovementWaitingBox = actHajrReplay;
|
||||
export const truthMovementWaitingBoxClean = hajrReplayClean;
|
||||
export const truthMovementWaitingBoxPlayer = hajrReplayPlayer;
|
||||
export const truthMovementWaitingBoxInventory = truthMovementBoxInventory;
|
||||
|
||||
/** Genuine public purchases, first movement turn and an actual spent fact
|
||||
* question create an ordinary discard before a later waiting-owner promise.
|
||||
* The caller still owns the Guild question/answer, Box payment/choice, Richese
|
||||
* turn completion and Emperor's later shipment and ground movement. */
|
||||
export function createTruthMovementWaitingBoxFixture() {
|
||||
const base = createHajrReplayFixture({ truthForGuild: true, truthForRichese: true });
|
||||
const target = base.owner, asker = 'guild', active = 'richese';
|
||||
const truth = base.guildTruth, richeseTruth = base.richeseTruth;
|
||||
assert.ok(truth && richeseTruth); assert.notEqual(truth, richeseTruth);
|
||||
let game = structuredClone(base.acquired);
|
||||
const actions: HajrReplayStep[] = structuredClone(base.actions.slice(0, base.acquiredActionCount));
|
||||
const act = (actor: string, action: Action) => { game = actHajrReplay(game, actor, action, actions); };
|
||||
const reach = (until: (game: Game) => boolean) => { game = advanceHajrReplay(game, until, actions); };
|
||||
reach(state => state.phase === 5 && state.active === active && hajrReplayClean(state));
|
||||
const beforeFact = structuredClone(game), factStart = actions.length;
|
||||
const factQuestion = { kind: 'fact' as const, target,
|
||||
fact: { kind: 'spice' as const, compare: 'gte' as const, value: 0 } };
|
||||
assert.ok(hajrReplayPlayer(game, active).hand.some(card => card.id === richeseTruth));
|
||||
act(active, { type: 'card', card: richeseTruth });
|
||||
for (const player of game.players)
|
||||
if (!game.truthtrance!.passed.includes(player.id)) act(player.id, { type: 'truthPass' });
|
||||
act(active, { type: 'truthAsk', question: factQuestion });
|
||||
act(target, { type: 'truthAnswer', answer: 'yes' });
|
||||
reach(hajrReplayClean);
|
||||
const factActions = structuredClone(actions.slice(factStart));
|
||||
const owner = hajrReplayPlayer(game, target), originalCards = truthMovementBoxInventory(base.setup);
|
||||
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, active);
|
||||
assert.ok(game.movementRemaining?.includes(target)); assert.notEqual(game.active, target);
|
||||
assert.equal(owner.shipped, false); assert.equal(owner.moved, 0);
|
||||
assert.deepEqual(owner.forces, {}); assert.equal(owner.reserves, 20);
|
||||
assert.equal(owner.spice, 8, 'The original Box1/Hajr1 purchases leave the native eight-spice wallet');
|
||||
assert.ok(owner.hand.some(card => card.id === base.box));
|
||||
assert.ok(owner.hand.some(card => card.id === base.hajr));
|
||||
assert.ok(hajrReplayPlayer(game, asker).hand.some(card => card.id === truth));
|
||||
assert.equal(game.discard.filter(card => card.id === richeseTruth).length, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === truth).length, 0);
|
||||
assert.equal(game.movementPromises, undefined); assert.ok(!game.pendingNullentropy && !game.truthtrance);
|
||||
assert.deepEqual(truthMovementBoxInventory(game), originalCards);
|
||||
return {
|
||||
game, source: structuredClone(game), base, lobby: base.lobby, setup: base.setup,
|
||||
beforeStartingDeal: base.beforeStartingDeal, startingDealSelection: base.startingDealSelection,
|
||||
afterSetup: base.afterSetup, beforePurchases: base.beforePurchases, acquired: base.acquired,
|
||||
beforeFact, factQuestion, factActions, actions, sourceActionCount: actions.length,
|
||||
target, owner: target, asker, active, truth, richeseTruth, box: base.box, hajr: base.hajr,
|
||||
originalCards, balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
|
||||
boxFee: 2, shipmentCost: 1, expectedPaidChoices: [richeseTruth, truth],
|
||||
claim: { kind: 'movement' as const, target, territory: 'carthag', minimum: 1 },
|
||||
currentEnd: { type: 'endMovement' } satisfies Action,
|
||||
shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action,
|
||||
staging: [
|
||||
'Original public bare Basic Richese/Emperor/Guild setup selects the two distinct original unopened Truthtrance deals for Richese and Guild, ordinary Spice/Hajr positions and native scalar lotteries only. Emperor actually buys Box1 and Hajr1. The recorded prefix stops at those acquisitions and continues through actual phase controls to Richese\'s first movement turn, before Emperor acts; the original factory\'s later movement/shipment suffix is excluded. Active Richese genuinely plays its original Truth on the current nonnegative-spice fact, Emperor answers Yes and the original neutral discard continuation completes. That physical Truth remains in discard; Guild still holds its distinct original Truth, and Emperor retains bought Box/Hajr, all twenty reserves, zero board forces and its native eight-spice wallet. No hand, wallet, force, queue, active player, phase, promise or pending receipt is installed. The caller\'s later definite Guild answer naturally supplies the second ordinary paid-search candidate. The source stops before that question/answer, any Box fee/choice or Richese turn ending. Neither Truth candidate is a movement card: a valid ordinary paid selection can preserve the future ordinary shipment and ground move.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type TruthMovementWaitingBoxFixture = ReturnType<typeof createTruthMovementWaitingBoxFixture>;
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Action } from '../game/engine';
|
||||
import {
|
||||
actTruthMovement, advanceTruthMovement, createTruthMovementFixture,
|
||||
truthMovementClean, truthMovementInventory, truthMovementPlayer,
|
||||
type TruthMovementStep,
|
||||
} from './fixture-truth-movement';
|
||||
|
||||
export const actTruthMovementWaiting = actTruthMovement;
|
||||
export const truthMovementWaitingClean = truthMovementClean;
|
||||
export const truthMovementWaitingInventory = truthMovementInventory;
|
||||
export const truthMovementWaitingPlayer = truthMovementPlayer;
|
||||
|
||||
/** Original public setup with the two unopened deal positions reversed. The
|
||||
* active Atreides owns Truthtrance; Emperor has not taken its movement turn.
|
||||
* The original factory's later setup/phase/shipment suffix is not reused or
|
||||
* counted: every continuation below is performed from this unopened snapshot. */
|
||||
export function createTruthMovementWaitingFixture(options: { hajr?: boolean } = {}) {
|
||||
const base = createTruthMovementFixture(options);
|
||||
const asker = 'atreides', target = 'emperor';
|
||||
let game = structuredClone(base.beforeStartingDeal);
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
assert.equal(game.deck[0].id, base.targetCard);
|
||||
assert.equal(game.deck[1].id, base.truth);
|
||||
[game.deck[0], game.deck[1]] = [game.deck[1], game.deck[0]];
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
const dealOrder = [
|
||||
...structuredClone(base.dealOrder),
|
||||
{ position: 0, from: 1, card: base.truth },
|
||||
];
|
||||
const start = base.actions.findIndex(step => step.action.type === 'start');
|
||||
assert.ok(start >= 0);
|
||||
const actions: TruthMovementStep[] = structuredClone(base.actions.slice(0, start + 1));
|
||||
game = advanceTruthMovement(game, state => state.status === 'playing', actions);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [base.truth]);
|
||||
assert.deepEqual(truthMovementPlayer(game, target).hand.map(card => card.id), [base.targetCard]);
|
||||
assert.deepEqual(truthMovementInventory(game), base.originalCards);
|
||||
const startingPositions = Object.fromEntries(game.players.map(player => [player.id, {
|
||||
forces: structuredClone(player.forces), reserves: player.reserves, spice: player.spice,
|
||||
}]));
|
||||
const spiceOrder = base.spiceOrder.map(({ position, card }) => {
|
||||
const from = game.spiceDeck.findIndex((candidate, index) => index >= position &&
|
||||
JSON.stringify(candidate) === JSON.stringify(card));
|
||||
assert.ok(from >= position, 'Choose the same original unopened ordinary Spice card');
|
||||
const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, selected);
|
||||
return { position, from, card: structuredClone(selected) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), base.originalSpice);
|
||||
game = advanceTruthMovement(game, state => state.turn === 1 && state.phase === 5 &&
|
||||
state.active === asker && truthMovementClean(state), actions);
|
||||
const current = truthMovementPlayer(game, asker), waiting = truthMovementPlayer(game, target);
|
||||
assert.equal(game.advanced, false); assert.deepEqual(game.expansions, []);
|
||||
assert.ok(game.movementRemaining?.includes(asker)); assert.ok(game.movementRemaining?.includes(target));
|
||||
assert.equal(current.shipped, false); assert.equal(current.moved, 0);
|
||||
assert.deepEqual(current.forces, { 'arrakeen:10': 10 }); assert.equal(current.reserves, 10);
|
||||
assert.equal(waiting.shipped, false); assert.equal(waiting.moved, 0);
|
||||
assert.deepEqual(waiting.forces, {}); assert.equal(waiting.reserves, 20);
|
||||
assert.equal(current.spice, startingPositions[asker].spice);
|
||||
assert.equal(waiting.spice, startingPositions[target].spice);
|
||||
assert.equal(game.movementPromises, undefined); assert.ok(!game.truthtrance);
|
||||
assert.deepEqual(truthMovementInventory(game), base.originalCards);
|
||||
return {
|
||||
game, source: structuredClone(game), base, lobby: base.lobby, setup: base.setup,
|
||||
beforeStartingDeal, afterSetup, actions, sourceActionCount: actions.length,
|
||||
asker, target, truth: base.truth, hajr: base.hajr, targetCard: base.targetCard,
|
||||
dealOrder, spiceOrder, originalCards: base.originalCards, originalSpice: base.originalSpice,
|
||||
startingPositions, balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
|
||||
claim: { kind: 'movement' as const, target, territory: 'carthag', minimum: 1 },
|
||||
currentMove: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 10 } satisfies Action,
|
||||
currentEnd: { type: 'endMovement' } satisfies Action,
|
||||
shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
shipmentCost: 1,
|
||||
move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action,
|
||||
staging: [
|
||||
'Original public Basic Atreides/Emperor create/join/readiness/start precede an unopened two-position deal reversal: Atreides receives original Truthtrance and Emperor original Hajr (or the explicitly requested original projectile). Actual setup and native Storm/Spice/Charity/Bidding/Revival continue from that snapshot, with only the same two unopened ordinary Spice positions and native scalar lotteries selected. The recorded action prefix includes only that real start and these new continuations, excluding the original factory suffix. Atreides retains its native ten Arrakeen forces and ten reserves; waiting Emperor retains zero board forces, twenty reserves and its native wallet. No dealt hand, wallet, force, phase clock, pending receipt, question or promise is installed. This source stops before any question, answer, active-player shipment/move or waiting-player turn. The suggested active move/end and later Emperor city1 shipment/ground1 remain actual caller actions.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type TruthMovementWaitingFixture = ReturnType<typeof createTruthMovementWaitingFixture>;
|
||||
@@ -93,7 +93,7 @@ function movementBoundary(): Game {
|
||||
g.movementRemaining = ['target', 'asker']; g.players[0].shipped = true;
|
||||
return g;
|
||||
}
|
||||
void test('the Basic profile permits a clean active post-shipment respondent with Truthtrance or ordinary flight open', () => {
|
||||
void test('the Basic profile permits an active respondent or a still-unused waiting turn with Truthtrance or ordinary flight open', () => {
|
||||
const g = movementBoundary(); assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
|
||||
g.truthtrance = { stage: 'ask', queue: [], passed: [], question: null };
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
|
||||
@@ -102,6 +102,10 @@ void test('the Basic profile permits a clean active post-shipment respondent wit
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
|
||||
g.players[0].shipped = false; g.players[0].moved = 1;
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'asker'), true);
|
||||
g.players[1].shipped = true;
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'asker'), false);
|
||||
g.players[1].shipped = false; g.players[1].moved = 1;
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'asker'), false);
|
||||
});
|
||||
void test('movement question profile permits an unspent shipment but rejects an exhausted turn and active opposing response', () => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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, type Game } from '../game/engine';
|
||||
import { createTruthMovementWaitingFixture, actTruthMovementWaiting as act } from './fixture-truth-movement-waiting';
|
||||
import { createTruthMovementWaitingBoxFixture } from './fixture-truth-movement-waiting-box';
|
||||
|
||||
function buttons(node: unknown): { onClick: () => void }[] {
|
||||
if (Array.isArray(node)) return node.flatMap(buttons);
|
||||
if (!node || typeof node !== 'object' || !('props' in node)) return [];
|
||||
const props = (node as ReactElement<{ children?: unknown; onClick?: () => void }>).props;
|
||||
return [...(props.onClick ? [{ onClick: props.onClick }] : []), ...buttons(props.children)];
|
||||
}
|
||||
function pass(game: Game) {
|
||||
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 game;
|
||||
}
|
||||
function bind(f: { game: Game; asker: string; target: string; truth: string; claim: Record<string, unknown> }) {
|
||||
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 });
|
||||
return pass(act(game, f.target, { type: 'truthAnswer', answer: 'yes' }));
|
||||
}
|
||||
void test('persistent waiting guidance has no premature movement button and becomes executable on the real turn', () => {
|
||||
const f = createTruthMovementWaitingFixture({ hajr: false }); let game = bind(f);
|
||||
const actions: Action[] = [], submit = (action: Action) => { actions.push(action); };
|
||||
const props = { game: viewGame(game, f.target), act: submit, busy: false };
|
||||
assert.match(renderToStaticMarkup(createElement(MovementPromises, props)), /Your Movement turn is still ahead/);
|
||||
assert.equal(buttons(MovementPromises(props)).length, 0);
|
||||
game = act(game, f.asker, f.currentMove); game = act(game, f.asker, f.currentEnd);
|
||||
const own = { ...props, game: viewGame(game, f.target) };
|
||||
assert.doesNotMatch(renderToStaticMarkup(createElement(MovementPromises, own)), /turn is still ahead/);
|
||||
buttons(MovementPromises({ ...own, busy: true }))[0].onClick(); assert.equal(actions.length, 0);
|
||||
buttons(MovementPromises(own))[0].onClick(); assert.equal(actions[0].type, 'ship');
|
||||
game = act(game, f.target, actions[0]); assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
});
|
||||
void test('a waiting paid owner gets only its original paid choice; after selection the future action button disappears', () => {
|
||||
const f = createTruthMovementWaitingBoxFixture(); let game = bind(f);
|
||||
game = act(game, f.target, { type: 'card', card: f.box });
|
||||
const actions: Action[] = [], props = { game: viewGame(game, f.target), act(action: Action) { actions.push(action); }, busy: false };
|
||||
const html = renderToStaticMarkup(createElement(MovementPromises, props));
|
||||
assert.match(html, /already-paid Box search can finish now/); assert.match(html, /Choose Truthtrance in your paid Box search/);
|
||||
const search = renderToStaticMarkup(createElement(NullentropySearch, props));
|
||||
assert.equal((search.match(/Take Truthtrance and finish search/g) ?? []).length, 2);
|
||||
buttons(MovementPromises({ ...props, busy: true }))[0].onClick(); assert.equal(actions.length, 0);
|
||||
buttons(MovementPromises(props))[0].onClick();
|
||||
assert.equal(actions[0].type, 'decision'); assert.equal(actions[0].event, game.pendingNullentropy!.event);
|
||||
game = pass(act(game, f.target, actions[0])); assert.equal(game.active, f.active);
|
||||
const waiting = { ...props, game: viewGame(game, f.target) };
|
||||
assert.equal(buttons(MovementPromises(waiting)).length, 0);
|
||||
assert.match(renderToStaticMarkup(createElement(MovementPromises, waiting)), /turn is still ahead/);
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, normalizeAutomaticGame, createGame, joinGame, newPlayer, type Game } from '../game/engine';
|
||||
import { baseDeck, treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createTruthMovementWaitingFixture as fixture, actTruthMovementWaiting as act,
|
||||
truthMovementWaitingInventory as inventory } from './fixture-truth-movement-waiting';
|
||||
import { createTruthMovementWaitingBoxFixture as boxFixture } from './fixture-truth-movement-waiting-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('a genuinely waiting Basic respondent can answer a same-round future ground-movement question', () => {
|
||||
const f = fixture({ hajr: false }); const game = ask(f.game, f.asker, f.truth, f.claim);
|
||||
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
||||
});
|
||||
void test('controlled past BG losses retain an owned future route when accompaniment would consume its last reserve', () => {
|
||||
for (const reserves of [1, 2]) {
|
||||
// Structural timing/physical-loss fixture; separate public sources prove
|
||||
// natural setup. Native faction/leader identities and all twenty counters
|
||||
// are retained, with modeled past losses and bank payments explicit here.
|
||||
const source = createGame('WAITINGMOVEMENTADVISOR', newPlayer('current', 'Current', 'atreides'), false);
|
||||
joinGame(source, newPlayer('waiting', 'Waiting BG', 'beneGesserit'));
|
||||
Object.assign(source, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'current',
|
||||
order: ['current', 'waiting'], movementRemaining: ['current', 'waiting'], deck: baseDeck() });
|
||||
const current = source.players[0], waiting = source.players[1];
|
||||
current.forces = { 'arrakeen:10': 10 }; current.reserves = 10; current.spice = 10;
|
||||
waiting.forces = { 'polar_sink:0': 1 }; waiting.reserves = reserves; waiting.tanks = 19 - reserves; waiting.spice = 1;
|
||||
const index = source.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
|
||||
const card = source.deck.splice(index, 1)[0]; current.hand.push(card);
|
||||
let game = ask(source, current.id, card.id, { kind: 'movement', target: waiting.id, territory: 'carthag', minimum: 1 });
|
||||
game = act(game, waiting.id, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = act(game, current.id, { type: 'ship', territory: 'carthag', sector: 11, amount: 1 });
|
||||
assert.equal(game.decision?.kind, 'advisor');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, waiting.id); view.players.find(p => p.id === waiting.id)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [{ type: 'decision', accept: false }]);
|
||||
}
|
||||
if (reserves === 1) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, waiting.id, { type: 'decision', accept: true })); assert.deepEqual(game, before);
|
||||
game = act(game, waiting.id, { type: 'decision', accept: false });
|
||||
} else game = act(game, waiting.id, { type: 'decision', accept: true });
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
game = act(game, current.id, { type: 'endMovement' });
|
||||
game = act(game, waiting.id, viewGame(game, waiting.id).movementPromisePolicy![0]);
|
||||
game = act(game, waiting.id, viewGame(game, waiting.id).movementPromisePolicy![0]);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
}
|
||||
});
|
||||
function bind(answer = 'yes') {
|
||||
const f = fixture({ hajr: false });
|
||||
const game = act(ask(f.game, f.asker, f.truth, f.claim), f.target, { type: 'truthAnswer', answer });
|
||||
return { f, game };
|
||||
}
|
||||
function pass(game: Game) {
|
||||
if (game.pendingTreacheryDiscard?.reaction) {
|
||||
const event = game.pendingTreacheryDiscard.batch.event;
|
||||
for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
|
||||
}
|
||||
while (game.response) {
|
||||
const player = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(player);
|
||||
game = act(game, player.id, { type: 'passResponse' });
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function paidWaitingBox() {
|
||||
const f = boxFixture(); let game = ask(f.game, f.asker, f.truth, f.claim);
|
||||
game = pass(act(game, f.target, { type: 'truthAnswer', answer: 'yes' }));
|
||||
game = act(game, f.target, { type: 'card', card: f.box });
|
||||
return { f, game };
|
||||
}
|
||||
void test('a genuinely waiting owner can finish an ordinary paid Box choice while future movement controls stay unavailable', () => {
|
||||
const { f, game: paid } = paidWaitingBox(), before = structuredClone(paid);
|
||||
const search = viewGame(paid, f.target).nullentropy!.search!;
|
||||
assert.deepEqual(search.cards.map(card => card.id).sort(), [...f.expectedPaidChoices].sort());
|
||||
const next = viewGame(paid, f.target).movementPromisePolicy![0];
|
||||
assert.equal(next.type, 'decision'); assert.equal(next.event, search.event);
|
||||
assert.ok(f.expectedPaidChoices.includes(String(next.card))); assert.deepEqual(paid, before);
|
||||
let game = pass(act(paid, f.target, next));
|
||||
assert.equal(game.active, f.active); assert.equal(game.pendingNullentropy, null);
|
||||
assert.equal(game.players.find(p => p.id === f.target)!.spice, f.balances[f.target] - 2);
|
||||
assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
|
||||
game = act(game, f.active, f.currentEnd); assert.equal(game.active, f.target);
|
||||
game = pass(act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]));
|
||||
game = pass(act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]));
|
||||
assert.equal(game.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
void test('all four minimal profiles select the original paid waiting decision, then wait until their real movement turn', () => {
|
||||
const { f, game: paid } = paidWaitingBox();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(paid, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
const choice = botActions(view)[0]; assert.equal(choice.type, 'decision'); assert.equal(choice.event, paid.pendingNullentropy!.event);
|
||||
let game = pass(act(paid, f.target, choice)); assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
|
||||
game = act(game, f.active, f.currentEnd);
|
||||
let count = 0;
|
||||
while (game.phase === 5 && game.active === f.target && count++ < 14) {
|
||||
const own = viewGame(game, f.target); own.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(own)[0]; assert.ok(action); game = pass(act(game, f.target, action));
|
||||
}
|
||||
assert.ok(count < 14); assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.equal(game.players.find(p => p.id === f.target)!.spice, 5); assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
void test('a controlled original Ornithopter acquisition leaves the foreign flight intact during a waiting-owner forecast', () => {
|
||||
const f = boxFixture(), source = structuredClone(f.game);
|
||||
const index = source.richeseCache!.findIndex(card => card.effect === 'ornithopter'); assert.ok(index >= 0);
|
||||
const card = source.richeseCache!.splice(index, 1)[0]; source.players.find(p => p.id === f.active)!.hand.push(card);
|
||||
let game = pass(act(source, f.active, { type: 'ship', territory: 'tueks_sietch', sector: 5, amount: 2 }));
|
||||
game = pass(act(game, f.active, { type: 'move', from: 'tueks_sietch:5', territory: 'south_mesa', sector: 5,
|
||||
amount: 1, movementCard: card.id, ornithopter: 'twoGroups' }));
|
||||
const flight = structuredClone(game.ornithopter); assert.equal(flight?.player, f.active); assert.equal(flight.completed, 1);
|
||||
game = ask(game, f.asker, f.truth, f.claim); assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
||||
const before = structuredClone(game); viewGame(game, f.target); assert.deepEqual(game, before);
|
||||
game = pass(act(game, f.target, { type: 'truthAnswer', answer: 'yes' }));
|
||||
assert.deepEqual(game.ornithopter, flight); assert.equal(game.discard.filter(c => c.id === card.id).length, 0);
|
||||
assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
|
||||
game = pass(act(game, f.active, f.currentEnd)); assert.equal(game.active, f.target);
|
||||
assert.equal(game.discard.filter(c => c.id === card.id).length, 1);
|
||||
game = pass(act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]));
|
||||
game = pass(act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]));
|
||||
assert.equal(game.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
void test('a controlled native Fremen response cannot hide a waiting Box owner spending its last shipment money', () => {
|
||||
for (const spice of [2, 3]) {
|
||||
const source = createGame('WAITINGBOXFOREIGNMOVE', newPlayer('f', 'Current Fremen', 'fremen'), false, ['choam']);
|
||||
joinGame(source, newPlayer('e', 'Waiting Emperor', 'emperor')); joinGame(source, newPlayer('r', 'Richese counter', 'richese'));
|
||||
Object.assign(source, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'f', order: ['f', 'e', 'r'],
|
||||
movementRemaining: ['f', 'e', 'r'], deck: treacheryDeck(['choam']), richeseCache: richeseCards() });
|
||||
const [fremen, emperor, richese] = source.players;
|
||||
fremen.forces = { 'red_chasm:7': 4 }; fremen.reserves = 16; fremen.shipped = true;
|
||||
emperor.spice = spice;
|
||||
const take = (effect: string, owner: typeof emperor) => {
|
||||
const index = source.deck.findIndex(card => card.effect === effect); assert.ok(index >= 0);
|
||||
const card = source.deck.splice(index, 1)[0]; owner.hand.push(card); return card.id;
|
||||
};
|
||||
const truth = take('truthtrance', fremen); take('karama', richese);
|
||||
const boxIndex = source.richeseCache!.findIndex(card => card.effect === 'nullentropyBox'); assert.ok(boxIndex >= 0);
|
||||
const box = source.richeseCache!.splice(boxIndex, 1)[0]; emperor.hand.push(box);
|
||||
const old = source.deck.findIndex(card => card.kind === 'shield'); assert.ok(old >= 0);
|
||||
source.discard.push(source.deck.splice(old, 1)[0]);
|
||||
// Modeled past payment/discard and conserved cache acquisition. The native
|
||||
// movement declaration/response and attempted paid Box are actual actions.
|
||||
let game = ask(source, fremen.id, truth, { kind: 'movement', target: emperor.id, territory: 'carthag', minimum: 1 });
|
||||
game = act(game, emperor.id, { type: 'truthAnswer', answer: 'yes' });
|
||||
const destination = TERRITORIES.flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))).find(to =>
|
||||
to.sector !== 18 && gameDistance(game, 'red_chasm:7', `${to.territory}:${to.sector}`, key => splitLocation(key).sector === 18) === 2);
|
||||
assert.ok(destination);
|
||||
game = act(game, fremen.id, { type: 'move', from: 'red_chasm:7', amount: 4, ...destination });
|
||||
assert.equal(game.response?.kind, 'fremenMovement'); assert.ok(game.pendingFremenMove);
|
||||
const before = structuredClone(game);
|
||||
if (spice === 2) {
|
||||
assert.throws(() => act(game, emperor.id, { type: 'card', card: box.id })); assert.deepEqual(game, before);
|
||||
} else {
|
||||
const paid = act(game, emperor.id, { type: 'card', card: box.id });
|
||||
const choice = viewGame(paid, emperor.id).movementPromisePolicy![0];
|
||||
assert.equal(choice.type, 'decision'); assert.equal(choice.event, paid.pendingNullentropy!.event);
|
||||
const restored = act(paid, emperor.id, choice);
|
||||
assert.equal(restored.active, fremen.id); assert.equal(restored.response?.kind, 'fremenMovement');
|
||||
assert.deepEqual(restored.pendingFremenMove, before.pendingFremenMove);
|
||||
assert.equal(restored.players.find(p => p.id === emperor.id)!.spice, 1);
|
||||
assert.equal(restored.movementPromises![0].released, undefined);
|
||||
assert.equal(viewGame(restored, emperor.id).movementPromisePolicy, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
void test('a genuine waiting Yes survives another player matching and ending, then executes only on its actual turn', () => {
|
||||
const { f, game: bound } = bind(); let game = bound;
|
||||
assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
|
||||
const before = structuredClone(game); assert.throws(() => act(game, f.target, f.shipment)); assert.deepEqual(game, before);
|
||||
game = act(game, f.asker, f.currentMove); assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
assert.equal(game.movementPromises![0].released, undefined); assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
|
||||
game = act(game, f.asker, f.currentEnd); assert.equal(game.active, f.target);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
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.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
void test('a genuine waiting No ignores another player matching and remains binding until its own actual ending', () => {
|
||||
const { f, game: bound } = bind('no'); let game = act(bound, f.asker, f.currentMove);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
game = act(game, f.asker, f.currentEnd); assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
game = act(game, f.target, f.shipment);
|
||||
assert.throws(() => act(game, f.target, f.move));
|
||||
game = act(game, f.target, { type: 'endMovement' }); assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
});
|
||||
void test('waiting-owner bribes retain the real later shipment fee while actual opposing loss can release Yes', () => {
|
||||
const { f, game } = bind(), before = structuredClone(game);
|
||||
assert.equal(viewGame(game, f.target).bribeOptions.targets.find(p => p.id === f.asker)!.maximum, f.balances[f.target] - 1);
|
||||
assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount: f.balances[f.target] }));
|
||||
assert.deepEqual(game, before);
|
||||
const funded = act(game, f.target, { type: 'bribe', target: f.asker, amount: f.balances[f.target] - 1 });
|
||||
assert.equal(funded.movementPromises![0].released, undefined);
|
||||
// Controlled conserved opposing-loss edge, not a claimed natural card effect.
|
||||
const harmed = structuredClone(funded); harmed.players.find(p => p.id === f.target)!.spice--;
|
||||
harmed.players.find(p => p.id === f.asker)!.spice++;
|
||||
assert.equal(normalizeAutomaticGame(harmed).movementPromises![0].released, true);
|
||||
});
|
||||
void test('a controlled mutual alliance cannot open an Emperor gift that spends the waiting movement route', () => {
|
||||
const { f, game: bound } = bind(), game = structuredClone(bound);
|
||||
const funded = act(bound, f.target, { type: 'bribe', target: f.asker, amount: f.balances[f.target] - 1 });
|
||||
for (const source of [game, funded]) {
|
||||
source.players.find(p => p.id === f.target)!.ally = f.asker;
|
||||
source.players.find(p => p.id === f.asker)!.ally = f.target;
|
||||
}
|
||||
const before = structuredClone(funded);
|
||||
assert.throws(() => act(funded, f.target, { type: 'emperorGift', amount: 1 })); assert.deepEqual(funded, before);
|
||||
const affordable = act(game, f.target, { type: 'emperorGift', amount: 1 });
|
||||
assert.equal(affordable.movementPromises![0].released, undefined);
|
||||
});
|
||||
void test('all four minimal profiles answer while waiting and finish their actual later turn without premature witness actions', () => {
|
||||
const f = fixture({ hajr: false }), asked = ask(f.game, f.asker, f.truth, f.claim);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = asked; const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
const answer = botActions(view)[0]; assert.equal(answer.type, 'truthAnswer');
|
||||
game = act(game, f.target, answer); assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
|
||||
game = act(game, f.asker, f.currentMove); game = act(game, f.asker, f.currentEnd);
|
||||
let count = 0;
|
||||
while (game.phase === 5 && game.active === f.target && count++ < 10) {
|
||||
const own = viewGame(game, f.target); own.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
const next = botActions(own)[0]; assert.ok(next); game = act(game, f.target, next);
|
||||
}
|
||||
assert.ok(count < 10); assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user