Support native Advanced and No-Field movement promises

This commit is contained in:
Quintine committed 2026-10-25 18:54:19 +11:00
1 parent 2ae2b984c0
commit 724eabf99c
20 files changed
+762 -30

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { location, MOBILE_LOCATION, TERRITORIES } from '../game/board';
import { botGroundMoveAllowed, botMovementRange, groundMoveRouteDistance } from '../game/bot-mobility';
import {
actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer,
type TruthMovementStep,
} from './fixture-truth-movement';
import {
askTruthMovementAdvanced, nextTruthMovementAdvancedNativeStep,
type TruthMovementAdvancedTransition,
} from './fixture-truth-movement-advanced';
export const actTruthMovementIxian = actTruthMovement;
export const truthMovementIxianClean = truthMovementClean;
export const truthMovementIxianInventory = truthMovementInventory;
export const truthMovementIxianPlayer = (game: Game, actor = 'ixians') => truthMovementPlayer(game, actor);
export { askTruthMovementAdvanced as askTruthMovementIxian };
export type TruthMovementIxianStep = TruthMovementStep;
/** Actual native choices only. Keep the original Karama in the inspected auction
* pool by returning a different original card; no auction card is installed. */
export function nextTruthMovementIxianNativeStep(game: Game): TruthMovementStep | null {
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard &&
game.decision?.kind === 'ixAuction') {
const card = game.ixAuction?.cards.find(card => card.effect !== 'karama');
assert.ok(card, 'The original Ixian auction pool has a non-Karama card to return');
return { actor: game.decision.player, action: { type: 'decision', card: card.id, position: 'bottom' } };
}
return nextTruthMovementAdvancedNativeStep(game);
}
export function advanceTruthMovementIxian(game: Game, until: (state: Game) => boolean,
actions: TruthMovementStep[] = []): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Ixian movement source ended before its requested control');
const next = nextTruthMovementIxianNativeStep(game);
assert.ok(next, `Supply the real Truthtrance or Ixian ground movement at ${game.turn}/${game.phase}/${game.truthtrance?.stage ?? game.decision?.kind}`);
game = actTruthMovement(game, next.actor, next.action, actions);
}
throw Error('The original Ixian Truthtrance source did not reach its finite boundary');
}
/** Public Advanced preview, declared Ix expansion, and original Ixian forces.
* Only unopened deal/Spice positions and native scalar lotteries are selected.
* Actual setup, auction and shipment earn every held card and selected Cyborg. */
export function createTruthMovementIxianFixture() {
const target = 'ixians', asker = 'emperor';
let game = createGame('TRUTHMOVEMENTIXIAN', newPlayer(target, 'Ixians', 'ixians'), false, ['ix']);
joinGame(game, newPlayer(asker, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthMovementStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthMovement(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthMovementIxian(game, until, actions); };
const transition = (run: () => void): TruthMovementAdvancedTransition => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const rules = transition(() => { act(game.host, { type: 'rules', advanced: true }); });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
assert.deepEqual(actions.at(-1)?.action, { type: 'start', advancedPreview: true });
assert.deepEqual(game.expansions, ['ix']);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalCards = truthMovementInventory(game); assert.equal(originalCards.length, 47);
const targetCard = game.deck.find(card => card.kind === 'projectile' && !card.effect);
const truthtrance = game.deck.find(card => card.effect === 'truthtrance');
const karama = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
assert.ok(targetCard && truthtrance && karama);
const deal = [targetCard.id, truthtrance.id];
const dealOrder = [...deal, karama.id].map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
let ixianSetup: TruthMovementAdvancedTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextTruthMovementIxianNativeStep(game); assert.ok(next);
const before = game.decision?.kind === 'ixSetup' ? structuredClone(game) : null;
act(next.actor, next.action);
if (before) ixianSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) };
}
assert.equal(game.status, 'playing'); assert.ok(ixianSetup);
const afterSetup = structuredClone(game), originalIxians = truthMovementPlayer(game, target);
assert.deepEqual(originalIxians.hand.map(card => card.id), [targetCard.id]);
assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [truthtrance.id]);
assert.deepEqual(originalIxians.forces, { [MOBILE_LOCATION]: 6 });
assert.equal(originalIxians.elites?.forces[MOBILE_LOCATION], 3);
assert.equal(originalIxians.elites?.reserves, 4); assert.equal(originalIxians.reserves, 14);
const startingPositions = Object.fromEntries(game.players.map(player => [player.id, {
forces: structuredClone(player.forces), elites: structuredClone(player.elites),
reserves: player.reserves, spice: player.spice,
}]));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.turn === 1 && state.phase === 3 && truthMovementClean(state) &&
state.auction?.active === asker && state.auction.cards[state.auction.index]?.id === karama.id);
const karamaAcquisition = transition(() => {
act(asker, { type: 'bid', amount: 1, allyPayment: 0 });
reach(state => truthMovementClean(state) && truthMovementPlayer(state, asker).hand.some(card => card.id === karama.id));
});
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthMovementClean(state));
const beforeShipment = structuredClone(game);
assert.equal(truthMovementPlayer(game, target).shipped, false);
const shipmentStep: TruthMovementStep = { actor: target, action: {
type: 'ship', amount: 2, territory: 'polar_sink', sector: 0, elite: 2, allyPayment: 0,
} };
const shipment = transition(() => { act(shipmentStep.actor, shipmentStep.action); reach(truthMovementClean); });
const player = truthMovementPlayer(game, target), shipmentCost = truthMovementPlayer(beforeShipment, target).spice - player.spice;
assert.equal(game.turn, 1); assert.equal(game.phase, 5); assert.equal(game.active, target);
assert.equal(player.shipped, true); assert.equal(player.moved, 0);
assert.equal(player.forces['polar_sink:0'], 2); assert.equal(player.elites?.forces['polar_sink:0'], 2);
assert.equal(player.reserves, 12); assert.equal(player.elites?.reserves, 2); assert.equal(shipmentCost, 4);
assert.equal(player.forces[MOBILE_LOCATION], 6); assert.equal(player.elites?.forces[MOBILE_LOCATION], 3);
assert.equal(game.mobileStronghold?.location, 'polar_sink:0');
assert.ok(!Object.entries(player.forces).some(([key, amount]) => amount > 0 && /^(arrakeen|carthag):/.test(key)));
assert.ok(player.hand.some(card => card.id === targetCard.id));
for (const card of [truthtrance, karama]) assert.ok(truthMovementPlayer(game, asker).hand.some(held => held.id === card.id));
assert.deepEqual(truthMovementInventory(game), originalCards);
const view = viewGame(game, target), own = view.players.find(player => player.id === target)!;
assert.equal(botMovementRange(view, own, 2), 2);
const destination = TERRITORIES.filter(territory => territory.type === 'sand')
.flatMap(territory => territory.sectors.map(sector => ({ territory: territory.id, sector })))
.find(to => to.sector !== game.storm && groundMoveRouteDistance(view, own, 'polar_sink:0', location(to.territory, to.sector)) === 2 &&
botGroundMoveAllowed(view, own, 'polar_sink:0', location(to.territory, to.sector), 2));
assert.ok(destination, 'The two original shipped Cyborgs have a legal native two-step route');
const matchingMove: Action = { type: 'move', forces: { 'polar_sink:0': 2 },
eliteForces: { 'polar_sink:0': 2 }, ...destination };
const truthStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truthtrance.id } };
const questionAction: Action = { type: 'truthAsk', question: {
kind: 'movement', target, territory: destination.territory, minimum: 2,
} };
return { game, source: structuredClone(game), initial, rules, lobby, setup, beforeStartingDeal, afterSetup,
ixianSetup, beforeShipment, shipment: { ...shipment, step: shipmentStep }, shipmentStep, shipmentCost,
targetFaction: 'ixians' as const, asker, target, truth: truthtrance.id, truthtrance: truthtrance.id,
targetCard: targetCard.id, hajr: null, karama: karama.id, karamaAcquisition,
truthStep, declaration: truthStep.action, questionAction, matchingMove,
twoStepMove: matchingMove, twoStepDestination: destination.territory, destination,
typedGroup: { source: 'polar_sink:0', normal: 0, elite: 2 },
deal, dealOrder, spiceOrder, originalCards, originalInventory: originalCards, originalSpice, startingPositions,
actions, steps: actions, sourceActionCount: actions.length,
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
staging: [
'Actual public two-seat Ixians/Emperor lobby declares only the Ix expansion, selects Advanced through host rules, readies both seats and submits start advancedPreview:true. No optional module or development initializer is used.',
'Select only original scalar lotteries, three unopened Treachery positions and two unopened ordinary land Spice positions. Ixians actually choose the original ordinary projectile from their starting inspection; Emperor receives the remaining original Truthtrance. The original 47-card Ix deck is conserved.',
'Actual first Storm places the original Hidden Mobile Stronghold at Polar Sink with its untouched six native counters including three Cyborgs. The actual Ixian pre-auction inspection returns a non-Karama card to the bottom; Emperor buys the original Karama through an actual one-spice winning bid and native invoice/payment/income.',
'Both Advanced Spice blows, Charity, Bidding and Revival precede an actual four-spice two-Cyborg reserve shipment to Polar Sink. Original HMS counters remain there and the selected board group has no city ornithopter range. The returned move selects both physical Cyborgs explicitly with forces and eliteForces and has an actual legal distance-two route.',
'Stop at clean turn-one Shipment and Movement with Ixians active, shipment spent and moved zero. Truthtrance declaration/question/answer, the exact ground movement and its native Ixian counter remain caller-owned. No played hand, force, wallet, phase, allowance, promise, response or receipt is installed. This source establishes only this native Advanced expansion chronology.',
] };
}
export { createTruthMovementIxianFixture as createIxianTruthMovementAdvancedFixture };
export type TruthMovementIxianFixture = ReturnType<typeof createTruthMovementIxianFixture>;
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import type { NoFieldValue } from '../game/richese-no-field';
import {
actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer,
type TruthMovementStep,
} from './fixture-truth-movement';
import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
export type TruthMovementNoFieldStep = TruthMovementStep;
export type TruthMovementNoFieldTransition = { before: Game; steps: TruthMovementStep[]; after: Game };
export const actTruthMovementNoField = actTruthMovement;
export const truthMovementNoFieldClean = truthMovementClean;
export const truthMovementNoFieldInventory = truthMovementInventory;
export const truthMovementNoFieldPlayer = (game: Game, actor = 'richese') => truthMovementPlayer(game, actor);
export { actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer };
/** Real cache terms/pass/unbid controls only. The owned concealed revelation,
* Truthtrance and shipped target's actual ground moves remain caller choices. */
export function nextTruthMovementNoFieldNativeStep(game: Game): TruthMovementStep | null {
if (game.truthtrance) return null;
if (game.phase === 5 && game.active === 'richese' && truthMovementPlayer(game, 'richese').shipped && truthMovementClean(game)) return null;
return nextTruthMovementNativeEarlyStep(game, {
position: 'first', method: 'onceAround', direction: 'clockwise', keepUnbid: false,
card: 'richese-stone-burner',
});
}
export function advanceTruthMovementNoField(game: Game, until: (state: Game) => boolean,
actions: TruthMovementStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original No-Field movement source ended before its requested control');
const next = nextTruthMovementNoFieldNativeStep(game);
assert.ok(next, `Supply the original Truthtrance, reveal or movement choice at ${game.turn}/${game.phase}/${game.truthtrance?.stage ?? game.decision?.kind}`);
game = actTruthMovement(game, next.actor, next.action, actions);
}
throw Error('The native No-Field movement source did not reach its finite boundary');
}
/** Public native Basic/Advanced Richese+Emperor, original E2 main35/cache10.
* An original private marker really ships to Basin9 for one-marker price2.
* Stop with the token concealed and no physical board forces materialized. */
export function createTruthMovementNoFieldFixture(options: { advanced?: boolean; value?: NoFieldValue } = {}) {
const advanced = options.advanced ?? false, value = options.value ?? 5;
const target = 'richese', asker = 'emperor', territory = 'imperial_basin', sector = 9, location = 'imperial_basin:9';
let game = createGame('TRUTHMOVEMENTNOFIELD', newPlayer(target, 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer(asker, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthMovementStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthMovement(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthMovementNoField(game, until, actions); };
const transition = (run: () => void): TruthMovementNoFieldTransition => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
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);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['choam']);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => !player.hand.length));
const originalMainDeck = game.deck.map(card => card.id), originalCache = game.richeseCache!.map(card => card.id);
assert.equal(originalMainDeck.length, 35); assert.equal(originalCache.length, 10);
const originalCards = truthMovementInventory(game); assert.equal(originalCards.length, 45);
const ordinary = game.deck.find(card => card.kind === 'projectile');
const truthtrance = game.deck.find(card => card.effect === 'truthtrance'); assert.ok(ordinary && truthtrance);
const deal = [ordinary.id, truthtrance.id];
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), originalNoField = structuredClone(truthMovementPlayer(game, target).noField);
assert.ok(originalNoField); assert.deepEqual(originalNoField.tokens.map(token => token.value).sort((a, b) => a - b), [0, 3, 5]);
assert.equal(originalNoField.deployed, null); assert.equal(originalNoField.lastShipped, null);
assert.deepEqual(truthMovementPlayer(game, target).hand.map(card => card.id), [ordinary.id]);
assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [truthtrance.id]);
assert.deepEqual(truthMovementPlayer(game, target).forces, {}); assert.equal(truthMovementPlayer(game, target).reserves, 20);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
// Basic has no Black Market; both modes still enter their actual bidding
// phase and then use the native cache/auction controls below.
reach(state => state.turn === 1 && state.phase === 3);
const bidding = transition(() => {
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthMovementClean(state));
});
const beforeShipment = structuredClone(game), before = truthMovementPlayer(game, target);
const token = before.noField!.tokens.find(token => token.value === value); assert.ok(token);
assert.equal(before.shipped, false); assert.equal(before.moved, 0);
assert.deepEqual(before.hand.map(card => card.id), [ordinary.id]);
const shipmentEvent = before.noFieldEvent; assert.ok(shipmentEvent);
const shipmentStep: TruthMovementStep = { actor: target, action: {
type: 'ship', noField: token.id, event: shipmentEvent, territory, sector, allyPayment: 0,
} };
const shipment = transition(() => { act(shipmentStep.actor, shipmentStep.action); reach(truthMovementClean); });
const player = truthMovementPlayer(game, target), noFieldEvent = player.noFieldEvent; assert.ok(noFieldEvent);
assert.equal(game.phase, 5); assert.equal(game.turn, 1); assert.equal(game.active, target);
assert.equal(player.shipped, true); assert.equal(player.moved, 0);
assert.deepEqual(player.forces, {}); assert.equal(player.reserves, 20); assert.equal(player.tanks, 0);
assert.equal(before.spice - player.spice, 2);
assert.equal(player.noField!.deployed!.tokenId, token.id); assert.equal(player.noField!.deployed!.controller, target);
assert.deepEqual(player.noField!.deployed!.location, { territory, sector });
assert.equal(player.noField!.lastShipped, token.id);
assert.deepEqual(player.hand.map(card => card.id), [ordinary.id]);
assert.ok(truthMovementPlayer(game, asker).hand.some(card => card.id === truthtrance.id));
assert.deepEqual(truthMovementInventory(game), originalCards);
const revealAction: Action = { type: 'revealNoField', event: noFieldEvent, token: token.id };
const markerMove: Action = { type: 'move', forces: {}, noField: token.id, event: noFieldEvent,
territory: 'arrakeen', sector: 10 };
const matchingMove: Action | null = value ? { type: 'move', from: location, amount: value,
territory: 'arrakeen', sector: 10 } : null;
const claim = { kind: 'movement' as const, target, territory: 'arrakeen', minimum: Math.max(1, value) };
const truthStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truthtrance.id } };
const askStep: TruthMovementStep = { actor: asker, action: { type: 'truthAsk', question: claim } };
return { game, source: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup,
bidding, beforeShipment, shipment: { ...shipment, step: shipmentStep }, shipmentStep, shipmentAction: shipmentStep.action,
advanced, target, richese: target, asker, emperor: asker, targetCard: ordinary.id, hajr: null,
truth: truthtrance.id, truthtrance: truthtrance.id, card: truthtrance.id,
territory, sector, location, value, markerValue: value, token: token.id, tokenId: token.id,
event: noFieldEvent, noFieldEvent, shipmentEvent, noField: structuredClone(player.noField),
originalNoField, noFieldState: structuredClone(player.noField),
revealAction, reveal: revealAction, revealStep: { actor: target, action: revealAction } satisfies TruthMovementStep,
markerMove, matchingMove, claim, question: claim, truthStep, declarationStep: truthStep, askStep,
deal, dealOrder, spiceOrder, originalCards, originalInventory: originalCards, originalMainDeck, originalCache, originalSpice,
actions, steps: actions, sourceActionCount: actions.length, shipmentCost: 2,
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
staging: [
`Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Emperor ready/start, selected native E2 main35 plus original cache10 and no optional module. Only original scalar shuffles, two unopened starting-deal positions and two original ordinary Spice positions are chosen.`,
'Richese is actually dealt an ordinary projectile rather than Hajr; Emperor is actually dealt its original Truthtrance. Native private Traitor/setup controls create all three original No-Field identities, values and untouched twenty-force reserve pool; no card, token or hand is installed.',
`Actually complete safe first Storm, native Spice (both blows in Advanced), Charity, ${advanced ? 'real Richese Black Market decline' : 'Basic bidding without a Black Market'}, first Once Around/cache terms, all original ordinary/cache bid passes, unbid cache retirement and Revival. Every original main/cache ID remains in its actual deck/cache/retired/hand/discard slot; there is no manufactured purchase or card acquisition.`,
'During its real unused first turn Richese actually ships the selected original concealed marker to Basin9 for the ordinary one-marker two-spice price. All twenty original physical reserves remain; the marker has not become physical ground forces, and ordinary movement remains unused.',
'Stop before any Truthtrance declaration or voluntary reveal. The returned revealNoField event/token, marker-only move and post-reveal physical move are original caller choices. Zero/three/five variants use the same native setup and actual shipment; no later reserve cap, force, phase, wallet, promise, preparation or receipt is installed.',
] };
}
export type TruthMovementNoFieldFixture = ReturnType<typeof createTruthMovementNoFieldFixture>;
+3 -1
View File
@@ -144,7 +144,7 @@ void test('earlier current-round movement question profiles include declared Bas
}
});
void test('classic Advanced Movement admits active and unused waiting seats while retaining module and native-family boundaries', () => {
void test('Advanced Movement admits active and unused waiting seats with declared native families while retaining module boundaries', () => {
const advanced = movementBoundary(); advanced.advanced = true;
assert.equal(movementPromiseTargetAvailable(advanced, 'target'), true);
assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), true);
@@ -153,6 +153,8 @@ void test('classic Advanced Movement admits active and unused waiting seats whil
assert.equal(movementPromiseTargetAvailable({ ...advanced, [field]: true } as unknown as Game, 'target'), false, field);
}
const native = structuredClone(advanced); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
assert.equal(movementPromiseTargetAvailable(native, 'target'), true);
native.expansions = [];
assert.equal(movementPromiseTargetAvailable(native, 'target'), false);
advanced.players[1].shipped = true;
assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), false);
@@ -25,3 +25,14 @@ void test('spent shipment and paid Box decisions cannot start another preparatio
game.players[0].shipped = false; game.pendingNullentropy = { player: 'owner' } as Game['pendingNullentropy'];
assert.deepEqual([...movementShipmentPreparations(game, 'owner', () => assert.fail('Finish the paid decision'))], []);
});
void test('owned No-Field shipment candidates preserve marker identity, one-force tariff shares and last-use restrictions', () => {
const game = source(), owner = game.players[0]; owner.faction = 'richese';
owner.noField = { tokens: [{ id: 'zero', value: 0 }, { id: 'three', value: 3 }, { id: 'five', value: 5 }],
lastShipped: 'three', deployed: null }; owner.noFieldEvent = 'current';
const choices = [...movementShipmentPreparations(game, owner.id, (state, _actor, action) =>
action.noField && action.territory === 'imperial_basin' && action.sector === 9 && action.allyPayment === 2
? structuredClone(state) : null)].map(choice => choice.actions[0]);
assert.deepEqual(choices.map(choice => choice.noField), ['zero', 'five']);
assert.ok(choices.every(choice => choice.amount === 1 && choice.elite === 0 && choice.event === 'current'));
});
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { createTruthMovementIxianFixture as fixture, actTruthMovementIxian as act,
advanceTruthMovementIxian as advance, truthMovementIxianClean as clean,
truthMovementIxianPlayer as player, truthMovementIxianInventory as inventory,
askTruthMovementIxian as ask } from './fixture-truth-movement-ixian';
for (const cancel of [false, true]) void test(`native Advanced Ixian typed promise ${cancel ? 'retains its unused move after prevention' : 'fulfills only after its original range counter'}`, () => {
const f = fixture();
let g = ask(f.game, f.asker, f.truthtrance, f.target, 2, f.twoStepDestination);
assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes'));
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const before = structuredClone(player(g));
g = act(g, f.target, f.matchingMove);
assert.equal(g.response?.kind, 'ixMovement');
assert.deepEqual(player(g).forces, before.forces);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
if (cancel) {
g = act(g, f.asker, { type: 'card', card: f.karama, mode: 'cancel' });
assert.equal(player(g).moved, 0); assert.deepEqual(player(g).forces, before.forces);
assert.ok(!g.movementPromises![0].fulfilled);
const replacement = viewGame(g, f.target).movementPromisePolicy?.[0];
if (replacement?.type === 'move') {
g = advance(act(g, f.target, replacement), clean);
assert.equal(g.movementPromises![0].fulfilled, true);
} else assert.equal(g.movementPromises![0].released, true);
} else {
g = advance(g, clean); assert.equal(g.movementPromises![0].fulfilled, true);
const key = `${f.destination.territory}:${f.destination.sector}`;
assert.equal(player(g).forces[key], 2); assert.equal(player(g).elites!.forces[key], 2);
}
assert.deepEqual(inventory(g), f.originalCards);
});
@@ -0,0 +1,30 @@
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 { viewGame, type Action } from '../game/engine';
import { askTruthMovementAdvanced as ask } from './fixture-truth-movement-advanced';
import { createTruthMovementNoFieldFixture as fixture, actTruthMovementNoField as act,
advanceTruthMovementNoField as advance, truthMovementNoFieldClean as clean } from './fixture-truth-movement-no-field';
function callbacks(node: unknown): (() => void)[] {
if (Array.isArray(node)) return node.flatMap(callbacks);
if (!node || typeof node !== 'object' || !('props' in node)) return [];
const props = (node as ReactElement<{ children?: unknown; onClick?: () => void }>).props;
return [...(props.onClick ? [props.onClick] : []), ...callbacks(props.children)];
}
void test('persistent native No-Field guidance submits the actual current reveal then offers physical movement', () => {
const f = fixture({ advanced: true });
let g = ask(f.game, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const view = viewGame(g, f.target), actions: Action[] = [];
const props = { game: view, busy: false, act: (action: Action) => { actions.push(action); } };
const html = renderToStaticMarkup(createElement(MovementPromises, props));
assert.match(html, /Reveal your No-Field to prepare physical forces/);
callbacks(MovementPromises(props))[0](); assert.deepEqual(actions, view.movementPromisePolicy);
g = act(g, f.target, actions[0]); assert.ok(!g.movementPromises![0].fulfilled);
assert.match(renderToStaticMarkup(createElement(MovementPromises,
{ ...props, game: viewGame(g, f.target) })), /Move 5 forces to Arrakeen/);
callbacks(MovementPromises({ ...props, busy: true }))[0](); assert.equal(actions.length, 1);
});
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { askTruthMovementAdvanced as ask } from './fixture-truth-movement-advanced';
import { createTruthMovementNoFieldFixture as fixture, actTruthMovementNoField as act,
advanceTruthMovementNoField as advance, truthMovementNoFieldClean as clean,
truthMovementNoFieldPlayer as player, truthMovementNoFieldInventory as inventory } from './fixture-truth-movement-no-field';
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} No-Field reveal prepares five physical forces without fulfilling movement early`, () => {
const f = fixture({ advanced });
let g = ask(f.game, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['yes', 'no']);
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const old = structuredClone(player(g, f.target)), reveal = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.equal(reveal?.type, 'revealNoField');
g = act(g, f.target, reveal!);
assert.equal(player(g, f.target).reserves, old.reserves - 5);
assert.equal(player(g, f.target).noField?.lastShipped, old.noField?.lastShipped);
assert.equal(player(g, f.target).moved, 0); assert.ok(!g.movementPromises![0].fulfilled);
const move = viewGame(g, f.target).movementPromisePolicy?.[0]; assert.equal(move?.type, 'move');
g = act(g, f.target, move!); assert.equal(g.movementPromises![0].fulfilled, true);
assert.equal(player(g, f.target).forces['arrakeen:10'], 5);
assert.deepEqual(inventory(g), f.originalCards);
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} one-spice native No-Field shipment and reveal supply the promised five-force ground move`, () => {
const f = fixture({ advanced });
let g = act(f.beforeShipment, f.target, { type: 'bribe', target: f.asker,
amount: player(f.beforeShipment, f.target).spice - 1 });
g = ask(g, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes'));
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const shipment = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.equal(shipment?.type, 'ship'); assert.ok(shipment.noField);
g = advance(act(g, f.target, shipment), clean);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
assert.equal(player(g, f.target).spice, 0); assert.equal(player(g, f.target).reserves, 20);
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
assert.ok(!g.movementPromises![0].fulfilled);
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
assert.equal(g.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(g), f.originalCards);
});
void test('native zero marker is presence rather than a promised physical force', () => {
const f = fixture({ value: 0 });
const g = ask(f.game, f.asker, f.truthtrance, f.target, 1, 'arrakeen');
assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['no']);
});
void test('controlled scarce reserves cap owned reveal preparation without changing printed token value', () => {
const f = fixture(), source = structuredClone(f.game), own = player(source, f.target);
// Controlled original reserve-to-Tanks allocation, not a native casualty claim.
own.tanks += own.reserves - 2; own.reserves = 2;
const impossible = ask(source, f.asker, f.truthtrance, f.target, 3, 'arrakeen');
assert.deepEqual(viewGame(impossible, f.target).truthMovementAnswers, ['no']);
let g = ask(source, f.asker, f.truthtrance, f.target, 2, 'arrakeen');
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
assert.equal(player(g, f.target).reserves, 0); assert.equal(player(g, f.target).forces['imperial_basin:9'], 2);
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
assert.equal(g.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(g), f.originalCards);
});
const distance = (g: Game, from: string, to: string) => gameDistance(g, from, to,
point => splitLocation(point).sector !== 0 && splitLocation(point).sector === g.storm);
function controlledPhysical(g: Game, actor: string, amount: number) {
const own = player(g, actor); own.reserves -= amount; own.forces['imperial_basin:9'] = amount;
}
for (const advanced of [false, true]) void test(`controlled ${advanced ? 'Advanced' : 'Basic'} zero-marker city positioning prepares physical Hajr movement without counting the marker`, () => {
const f = fixture({ advanced, value: 0 }); let g = structuredClone(f.game);
// Original physical reserve counters and original Hajr are conserved controls.
controlledPhysical(g, f.target, 2);
const at = g.deck.findIndex(card => card.effect === 'hajr'); assert.ok(at >= 0);
const hajr = g.deck.splice(at, 1)[0]; player(g, f.target).hand.push(hajr);
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
.find(to => to.sector !== g.storm && distance(g, 'imperial_basin:9', `${to.territory}:${to.sector}`) === 3);
assert.ok(to);
g = ask(g, f.asker, f.truthtrance, f.target, 2, to.territory);
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const first = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.equal(first?.type, 'move'); assert.equal(first.noField, f.token); assert.deepEqual(first.forces, {});
g = advance(act(g, f.target, first), clean);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
const play = viewGame(g, f.target).movementPromisePolicy?.[0]; assert.equal(play?.card, hajr.id);
g = advance(act(g, f.target, play!), clean);
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
assert.equal(g.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(g), f.originalCards);
});
void test('a mixed physical-plus-marker move fulfills only its physical quantity', () => {
const f = fixture({ value: 0 }), source = structuredClone(f.game);
controlledPhysical(source, f.target, 3);
const impossible = ask(source, f.asker, f.truthtrance, f.target, 4, 'arrakeen');
assert.deepEqual(viewGame(impossible, f.target).truthMovementAnswers, ['no']);
let g = ask(source, f.asker, f.truthtrance, f.target, 3, 'arrakeen');
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
g = act(g, f.target, { type: 'move', forces: { 'imperial_basin:9': 3 },
noField: f.token, event: player(g, f.target).noFieldEvent, territory: 'arrakeen', sector: 10 });
assert.equal(g.movementPromises![0].fulfilled, true);
assert.equal(player(g, f.target).forces['arrakeen:10'], 3);
assert.equal(player(g, f.target).noField?.deployed?.location.territory, 'arrakeen');
});
void test('controlled original Karama prevents a native No-Field preparation before payment and releases its now impossible promise', () => {
const f = fixture(), source = structuredClone(f.beforeShipment);
const at = source.deck.findIndex(card => card.effect === 'karama'); assert.ok(at >= 0);
const karama = source.deck.splice(at, 1)[0]; player(source, f.asker).hand.push(karama);
let g = act(source, f.target, { type: 'bribe', target: f.asker, amount: player(source, f.target).spice - 1 });
g = ask(g, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const before = structuredClone(player(g, f.target)), shipment = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.ok(shipment?.noField);
g = act(g, f.target, shipment);
assert.equal(g.response?.kind, 'richeseNoField');
assert.deepEqual(player(g, f.target).noField, before.noField);
assert.equal(player(g, f.target).spice, before.spice);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
g = act(g, f.asker, { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(player(g, f.target).shipped, false); assert.equal(player(g, f.target).spice, before.spice);
assert.equal(g.movementPromises![0].released, true); assert.deepEqual(inventory(g), f.originalCards);
});
void test('controlled earlier Basic boundary cannot voluntarily reveal away the only city-range completion', () => {
const f = fixture({ value: 0 }); let g = structuredClone(f.game);
controlledPhysical(g, f.target, 2);
g = advance(act(g, f.target, f.markerMove), clean);
g = act(g, f.target, { type: 'bribe', target: f.asker, amount: player(g, f.target).spice });
// Explicit earlier-phase boundary, not a claimed native second-round trace.
g.phase = 4; g.ready = [];
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
.find(to => to.sector !== g.storm && distance(g, 'imperial_basin:9', `${to.territory}:${to.sector}`) === 3);
assert.ok(to);
g = ask(g, f.asker, f.truthtrance, f.target, 2, to.territory);
assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes'));
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const before = structuredClone(g), own = player(g, f.target);
assert.throws(() => act(g, f.target, { type: 'revealNoField', token: own.noField!.deployed!.tokenId,
event: own.noFieldEvent }));
assert.deepEqual(g, before);
});
void test('all existing profiles use the actual owned reveal then physical move legally', () => {
const f = fixture({ advanced: true });
const question = ask(f.game, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
for (const difficulty of DIFFICULTIES) {
let g = question, count = 0;
while (g.phase === 5 && g.active === f.target && count++ < 20) {
if (!clean(g) && !g.truthtrance) { g = advance(g, clean); continue; }
const view = viewGame(g, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action); g = act(g, f.target, action);
}
assert.ok(count < 20); assert.equal(g.movementPromises![0].fulfilled, true);
}
});