Support bribes during Basic movement decisions
Verify and publish checkpoint container / container (push) Successful in 10m49s

This commit is contained in:
Quintine committed 2026-10-29 10:19:31 +11:00
1 parent 9dc339e726
commit ab7a947833
10 files changed
+435 -80

No files matched your search

+12 -2
View File
@@ -1,5 +1,14 @@
# Ordinary bribe controls
## Basic ordinary movement responses — 29 October 2026
On supported Basic tables, a direct nonallied bribe may interrupt a clean
ordinary Fremen or Ixian movement response, or CHOAM's movement decision.
The declared physical move, response passes or decision, and deferred payment
remain in place; the parent then resumes normally. This extends the provisional
online ordering under the publisher's printed bribe rule cited below. Optional
modules, competing reactions and the payer's active commitments remain guarded.
## Basic native-faction auction and revival responses — 29 October 2026
The same clean auction-payment, Atreides inspection and Fremen allied-revival
@@ -81,8 +90,9 @@ sealed bids, paid support, Banker spending and other reserved costs. Existing
Truthtrance completion checks further constrain voluntary spending on isolated
copies; this projection does not commit actions or live continuations. The
target-specific Bureaucrat guard can cap unsupported combinations below five.
Supported payments use the existing saved redirect/allow decision. Other pending responses, decisions or forced continuations disable the payment
controls, except for the supported Basic opening and Extortion cases above.
Supported payments use the existing saved redirect/allow decision. Other pending
responses, decisions or forced continuations disable the payment controls,
except for the supported Basic cases above.
The client retains an invalid amount or removed recipient as a disabled draft
rather than silently changing a payment. The explicit action uses existing
+13
View File
@@ -17,6 +17,19 @@ open mode gates.
## Current checkpoint and work
**Basic movement bribes and shipment witness — 29 October. Prototyped /
Partial.** Ordinary nonallied bribes can interrupt supported clean Fremen and
Ixian movement responses and CHOAM movement decisions. The original move and
passes remain; active commitments and unsupported reactions stay guarded.
The private No-Field shipment witness also checks an earlier physical movement
promise before selecting a marker. Native-action fixtures exercise the three
movement parents, continuation and bot replies; a second fixture uses two
physical Truthtrance cards and completes a shipment, reveal and promised move.
Twelve related files pass 78 cases; types/lint and the production build pass. Other
promise combinations, full rules and deployed acceptance remain open.
[Bribe scope](BRIBE_CONTROLS.md#basic-ordinary-movement-responses--29-october-2026);
[shipment scope](TRUTHTRANCE_SHIPMENT_PROMISES.md#owned-no-field-shipment-question--29-october-2026).
**Basic native bribes and No-Field guidance — 29 October. Prototyped / Partial.**
Clean auction-payment, Atreides inspection and Fremen revival-grant windows now
allow ordinary nonallied bribes on selected Basic native-faction tables; the
+7
View File
@@ -1,5 +1,12 @@
# Rule decision index
## Basic bribes during movement responses — 29 October 2026
Ordinary nonallied bribes can interrupt supported clean Fremen and Ixian
movement responses and CHOAM movement decisions in Basic. The pending move
remains unchanged; this online priority is provisional.
[Scope](BRIBE_CONTROLS.md#basic-ordinary-movement-responses--29-october-2026).
## Basic native-faction bribe interruptions — 29 October 2026
The supported clean auction-payment, Atreides inspection and Fremen revival
+5
View File
@@ -4,6 +4,11 @@ Original checkpoint, 7 September 2026; Advanced follow-up, 13 September 2026. Th
## Owned No-Field shipment question — 29 October 2026
When an earlier ordinary ground-movement Yes is also live, the private
No-Field shipment witness now tests whether the selected token leaves a legal
physical movement. This covers the original owned marker branch; other mixed
shipment and movement combinations remain provisional.
**Prototyped / Partial.** A shipment expression addressed to Richese may now
ask whether its own concealed No-Field will be shipped from reserves this turn,
optionally to a selected printed territory or sector. The public question and
+77
View File
@@ -0,0 +1,77 @@
import { location, splitLocation, validLocation } from './board';
import type { Game } from './engine';
import { presenceAt } from './force-presence';
export type MovementPending = 'pendingFremenMove' | 'pendingIxMove' | 'pendingChoamMove';
type MovementOrder = NonNullable<Game['pendingIxMove']>;
/** Identify a single, well-formed ordinary Basic movement awaiting its native reaction. */
export function basicPendingMovement(g: Game): { key: MovementPending; player: string } | null {
if (g.status !== 'playing' || g.advanced || g.phase !== 5 ||
!Number.isSafeInteger(g.turn) || g.turn < 1 || g.phaseOpening || g.truthtrance ||
g.ornithopter || g.choamMarket || !Array.isArray(g.players)) return null;
let key: MovementPending;
let move: MovementOrder;
const response = g.response;
if (response?.kind === 'fremenMovement' && g.pendingFremenMove && !g.decision) {
key = 'pendingFremenMove';
move = g.pendingFremenMove.order;
if (g.pendingFremenMove.turn !== g.turn) return null;
} else if (response?.kind === 'ixMovement' && g.pendingIxMove && !g.decision) {
key = 'pendingIxMove';
move = g.pendingIxMove;
} else if (!response && g.decision?.kind === 'choamMovement' && g.pendingChoamMove) {
key = 'pendingChoamMove';
move = g.pendingChoamMove;
} else return null;
if (Object.entries(g).some(([name, value]) => name.startsWith('pending') && name !== key &&
(Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) return null;
if (!move || move.player !== g.active ||
move.source || move.ambassadorEvent || move.noField || move.planetologist || move.sandmaster ||
move.ambassadorSandmaster || move.ornithopterEvent || move.ornithopterRange || move.discoveryFlight ||
!Array.isArray(move.group) || move.group.length === 0 || !move.eliteGroup ||
typeof move.origin !== 'string' || typeof move.to !== 'string' ||
!Number.isSafeInteger(move.sector) || !validLocation(move.to, move.sector) ||
!Number.isSafeInteger(move.total) || move.total <= 0 ||
!Number.isSafeInteger(move.elite) || move.elite < 0 || move.elite > move.total ||
(move.origins && (move.origins.length !== 1 || move.origins[0] !== move.origin))) return null;
const mover = g.players.find(player => player.id === move.player);
if (!mover || !Number.isSafeInteger(mover.moved) || mover.moved < 0) return null;
const seen = new Set<string>();
let total = 0, elite = 0;
for (const [source, amount] of move.group) {
if (typeof source !== 'string' || seen.has(source) || splitLocation(source).territory !== move.origin ||
!validLocation(splitLocation(source).territory, splitLocation(source).sector) ||
!Number.isSafeInteger(amount) || amount <= 0 || amount > (mover.forces[source] ?? 0)) return null;
seen.add(source);
const selected = move.eliteGroup[source];
if (!Number.isSafeInteger(selected) || selected < 0 || selected > amount ||
selected > (mover.elites?.forces[source] ?? 0) ||
amount - selected > (mover.forces[source] ?? 0) - (mover.elites?.forces[source] ?? 0)) return null;
total += amount;
elite += selected;
}
if (Object.keys(move.eliteGroup).some(source => !seen.has(source)) ||
total !== move.total || elite !== move.elite) return null;
const result = { key, player: move.player };
if (key === 'pendingChoamMove') {
const decision = g.decision;
const choam = g.players.find(player => player.faction === 'choam');
return decision?.kind === 'choamMovement' && choam && presenceAt(choam, move.to) > 0 && decision.player === choam.id &&
decision.mover === move.player && decision.territory === move.to &&
decision.sector === move.sector && decision.amount === move.total ? result : null;
}
if (!response || response.owner !== move.player ||
response.location !== location(move.to, move.sector) || response.amount !== move.total ||
!Array.isArray(response.passed) || new Set(response.passed).size !== response.passed.length ||
response.passed.some(id => !g.players.some(player => player.id === id))) return null;
const keys = Object.keys(response).sort().join(',');
if (key === 'pendingIxMove')
return mover.faction === 'ixians' && keys === 'amount,elite,kind,location,owner,passed' &&
response.elite === move.elite && move.elite > 0 ? result : null;
return mover.faction === 'fremen' && keys === 'amount,kind,location,owner,passed' &&
g.pendingFremenMove?.move === mover.moved ? result : null;
}
+9 -1
View File
@@ -1,3 +1,4 @@
import { basicPendingMovement } from './basic-pending-movement';
import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validateTwoPlayerControllersComplete,
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
import { routeAuctionPayment, type AuctionPaymentLeg } from './auction-payment-routing';
@@ -17492,6 +17493,7 @@ function basicBribeInterrupt(g: Game, p: Player): boolean {
g.bureaucratPayments?.pending || g.battle || g.pendingRicheseGift || g.pendingShipment ||
g.pendingGuildTransport || g.pendingArrivalOverlap || g.pendingAmbassador || g.pendingEmperorAuctionGift ||
liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
liveTravelPromises(g.travelPromises ?? [], p.id, g.turn).length ||
hasMovementPromises(g, p.id) || hasBribePromises(g, p.id) ||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length ||
@@ -17509,6 +17511,7 @@ function basicBribeInterrupt(g: Game, p: Player): boolean {
const classic = ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'];
const nativeRoster = !g.expansions.length
? g.players.every(seat => classic.includes(seat.faction)) : selectedPhaseModuleRoster(g);
if (nativeRoster && !g.phaseOpening && !g.choamMarket && !g.ixAuction && basicPendingMovement(g)) return true;
if (nativeRoster && !g.phaseOpening && !g.choamMarket && !g.ixAuction &&
!Object.entries(g).some(([key, value]) => key.startsWith('pending') &&
(Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) {
@@ -33459,7 +33462,12 @@ function findShipmentCompletion(
const action: Action = { type: 'ship', noField: token.id, event: p.noFieldEvent,
territory: destination, sector };
try {
applyActionInner(g, p.id, action, 'movementPreparation');
const trial = applyActionInner(g, p.id, action, 'movementPreparation');
// The native declaration checks this after committing a detached
// shipment. A witness must preserve the same physical movement.
if (state.phase < 5) trial.movementRemaining = [p.id];
if (hasMovementPromises(trial, p.id, true))
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
return { early, actions: [...actions, action] };
} catch (error) {
if (!(error instanceof RuleError)) throw error;
+2 -2
View File
@@ -296,7 +296,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'Open Bribes beneath the table. Choose a non-allied faction and a positive whole amount, then use the named Pay button. The amount is public and leaves your supply immediately. A message or promise alone does not pay spice.',
'The recipient keeps paid spice separately until the next Mentat opening. A payment after the current opening waits for next turn’s opening. Your incoming bribes are shown separately from your spendable supply; they cannot fund bids, shipments or another bribe yet.',
'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. In supported Basic games with classic or selected native expansion factions, direct bribes can also interrupt a normal auction-payment decision, Atreides auction inspection or Fremen allied-revival grant. Supported Basic Ix and Moritani games also allow payments during the Amal opening or an Extortion choice. The original parent and its passes remain pending. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.',
'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. In supported Basic games with classic or selected native expansion factions, direct bribes can also interrupt a normal auction-payment decision, Atreides auction inspection, Fremen allied-revival grant, ordinary Fremen or Ixian movement response, or CHOAM movement decision. Supported Basic Ix and Moritani games also allow payments during the Amal opening or an Extortion choice. The original parent and its passes remain pending. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.',
'Your payment limit excludes reserved spending and preserves binding Truthtrance answers. If an alliance, pending decision or available supply changes, recheck the recipient and amount. A rejected payment does not change the game.',
'In the Basic group preview, group allies are excluded from ordinary bribes and nonallied deals. Existing agreements remain recorded and can be released; a deferred bribe to a newly allied recipient stays pending while it is unavailable, subject to its original deadline.',
'A qualifying Bureaucrat may divert two spice to the Bank without reducing the payer’s cost. The remainder stays deferred. Unsupported combinations remain unavailable and do not imply an official rule prohibition.',
@@ -313,7 +313,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Public deal proposals, responses and mutual release, named bribe recipient, explicit payment, and structured current- or later-turn travel with public status and an owner-only native-action guide.', evidence: ['components/public-deals.tsx', 'components/travel-promises.tsx', 'components/bribes.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Passive receipt/collection, Bureaucrat responses and legal autopilot continuation of a previously accepted travel term work. Strategic bribe initiation and negotiation wait for feature completion.' },
{ area: 'Documentation', status: 'Partial', detail: 'Public deal consent, structured travel and payment, player-managed terms, escrow, restrictions and remaining boundaries are recorded.', evidence: ['docs/PUBLIC_DEALS.md', 'docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md', 'tests/bribe-classic-auction-runtime.test.ts', 'tests/bribe-classic-response-runtime.test.ts', 'tests/bribe-classic-pending-controls.test.tsx', 'tests/public-deals-travel-runtime.test.ts', 'tests/public-deals-travel-controls.test.tsx'] },
{ area: 'Verification', status: 'Partial', detail: 'Supported Basic pending opening and Extortion payments retain their original parent and deferred collection; the actual opening and next-turn collection have focused runtime and rendered-control coverage. Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including staged group and legacy records. Group travel has actual multi-seat consent, physical travel and release coverage. Original late Mentat/next-turn receipt journeys supplement existing bribe projection, budgets and Bureaucrat coverage. Full integrated acceptance remains open.', evidence: ['tests/bribe-pending-basic.test.ts', 'tests/bribe-pending-basic-controls.test.tsx', 'tests/coalition-bribe-deals.test.ts', 'tests/coalition-bribe-truth.test.ts', 'tests/public-deals.test.ts', 'tests/public-deals-engine.test.ts', 'tests/public-deals-multiparty-engine.test.ts', 'tests/public-deals-group-travel-runtime.test.ts', 'tests/public-deals-payment-engine.test.ts', 'tests/public-deals-deferred-runtime.test.ts', 'tests/public-deals-deferred-truthtrance.test.ts', 'tests/public-deals-deferred-charity.test.ts', 'tests/public-deals-deferred-income.test.ts', 'tests/public-deals-deferred-http.test.ts', 'tests/public-deals-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-http.test.ts', 'tests/public-deals-payment-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Supported Basic movement responses, pending opening and Extortion payments retain their original parent and deferred collection; focused runtime cases cover native decisions and legal bot responses. The actual opening and next-turn collection also have runtime and rendered-control coverage. Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including staged group and legacy records. Group travel has actual multi-seat consent, physical travel and release coverage. Original late Mentat/next-turn receipt journeys supplement existing bribe projection, budgets and Bureaucrat coverage. Full integrated acceptance remains open.', evidence: ['tests/basic-bribe-movement-runtime.test.ts', 'tests/bribe-pending-basic.test.ts', 'tests/bribe-pending-basic-controls.test.tsx', 'tests/coalition-bribe-deals.test.ts', 'tests/coalition-bribe-truth.test.ts', 'tests/public-deals.test.ts', 'tests/public-deals-engine.test.ts', 'tests/public-deals-multiparty-engine.test.ts', 'tests/public-deals-group-travel-runtime.test.ts', 'tests/public-deals-payment-engine.test.ts', 'tests/public-deals-deferred-runtime.test.ts', 'tests/public-deals-deferred-truthtrance.test.ts', 'tests/public-deals-deferred-charity.test.ts', 'tests/public-deals-deferred-income.test.ts', 'tests/public-deals-deferred-http.test.ts', 'tests/public-deals-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-http.test.ts', 'tests/public-deals-payment-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] },
],
},
{
+7 -75
View File
@@ -1,85 +1,17 @@
import { location, splitLocation, validLocation } from './board';
import type { Game } from './engine';
import { presenceAt } from './force-presence';
type MovementPending = 'pendingFremenMove' | 'pendingIxMove' | 'pendingChoamMove';
type MovementOrder = NonNullable<Game['pendingIxMove']>;
import { basicPendingMovement, type MovementPending } from './basic-pending-movement';
/**
* Identify a single ordinary foreign movement awaiting its native prevention
* response. Revealing Richese's already-present marker cannot add an occupying
* faction, so another faction's declared route and destination remain legal.
* The caller retains its own common profile and Truthtrance/commitment guards.
* Revealing Richese's existing marker during a different player's ordinary
* movement cannot add an occupying faction to the declared destination. The
* caller retains its common profile and Truthtrance/commitment guards.
*/
export function richeseRevealForeignMovementPending(g: Game): MovementPending | null {
if (g.status !== 'playing' || g.advanced || g.phase !== 5 ||
!Number.isSafeInteger(g.turn) || g.turn < 1 || g.phaseOpening || g.truthtrance ||
g.ornithopter || g.choamMarket || !Array.isArray(g.players)) return null;
if (!Array.isArray(g.players)) return null;
const richese = g.players.find(player => player.faction === 'richese');
const deployed = richese?.noField?.deployed;
if (!richese || !deployed || deployed.controller !== richese.id ||
!deployed.location || !('territory' in deployed.location)) return null;
let key: MovementPending;
let move: MovementOrder;
const response = g.response;
if (response?.kind === 'fremenMovement' && g.pendingFremenMove && !g.decision) {
key = 'pendingFremenMove';
move = g.pendingFremenMove.order;
if (g.pendingFremenMove.turn !== g.turn) return null;
} else if (response?.kind === 'ixMovement' && g.pendingIxMove && !g.decision) {
key = 'pendingIxMove';
move = g.pendingIxMove;
} else if (!response && g.decision?.kind === 'choamMovement' && g.pendingChoamMove) {
key = 'pendingChoamMove';
move = g.pendingChoamMove;
} else return null;
if (Object.entries(g).some(([name, value]) => name.startsWith('pending') && name !== key &&
(Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) return null;
if (!move || move.player === richese.id || move.player !== g.active ||
move.source || move.ambassadorEvent || move.noField || move.planetologist || move.sandmaster ||
move.ambassadorSandmaster || move.ornithopterEvent || move.ornithopterRange || move.discoveryFlight ||
!Array.isArray(move.group) || move.group.length === 0 || !move.eliteGroup ||
typeof move.origin !== 'string' || typeof move.to !== 'string' ||
!Number.isSafeInteger(move.sector) || !validLocation(move.to, move.sector) ||
!Number.isSafeInteger(move.total) || move.total <= 0 ||
!Number.isSafeInteger(move.elite) || move.elite < 0 || move.elite > move.total ||
(move.origins && (move.origins.length !== 1 || move.origins[0] !== move.origin))) return null;
const mover = g.players.find(player => player.id === move.player);
if (!mover || !Number.isSafeInteger(mover.moved) || mover.moved < 0) return null;
const seen = new Set<string>();
let total = 0, elite = 0;
for (const [source, amount] of move.group) {
if (typeof source !== 'string' || seen.has(source) || splitLocation(source).territory !== move.origin ||
!validLocation(splitLocation(source).territory, splitLocation(source).sector) ||
!Number.isSafeInteger(amount) || amount <= 0 || amount > (mover.forces[source] ?? 0)) return null;
seen.add(source);
const selected = move.eliteGroup[source];
if (!Number.isSafeInteger(selected) || selected < 0 || selected > amount ||
selected > (mover.elites?.forces[source] ?? 0) ||
amount - selected > (mover.forces[source] ?? 0) - (mover.elites?.forces[source] ?? 0)) return null;
total += amount;
elite += selected;
}
if (Object.keys(move.eliteGroup).some(source => !seen.has(source)) ||
total !== move.total || elite !== move.elite) return null;
if (key === 'pendingChoamMove') {
const decision = g.decision;
const choam = g.players.find(player => player.faction === 'choam');
return decision?.kind === 'choamMovement' && choam && presenceAt(choam, move.to) > 0 && decision.player === choam.id &&
decision.mover === move.player && decision.territory === move.to &&
decision.sector === move.sector && decision.amount === move.total ? key : null;
}
if (!response || response.owner !== move.player ||
response.location !== location(move.to, move.sector) || response.amount !== move.total ||
!Array.isArray(response.passed) || new Set(response.passed).size !== response.passed.length ||
response.passed.some(id => !g.players.some(player => player.id === id))) return null;
const keys = Object.keys(response).sort().join(',');
if (key === 'pendingIxMove')
return mover.faction === 'ixians' && keys === 'amount,elite,kind,location,owner,passed' &&
response.elite === move.elite && move.elite > 0 ? key : null;
return mover.faction === 'fremen' && keys === 'amount,kind,location,owner,passed' &&
g.pendingFremenMove?.move === mover.moved ? key : null;
const movement = basicPendingMovement(g);
return movement && movement.player !== richese.id ? movement.key : null;
}
+240
View File
@@ -0,0 +1,240 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board';
import { bribeAction } from '../game/bribe-options';
import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
import { truthMovementInventory } from './fixture-truth-movement';
type Kind = 'fremenMovement' | 'ixMovement' | 'choamMovement';
const seat = (g: Game, id: string) => { const p = g.players.find(p => p.id === id); assert.ok(p); return p; };
const clean = (g: Game) => !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && !g.pendingTreacheryDiscard;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const cards = (g: Game) => truthMovementInventory(g);
/** Select an existing physical card by a custody-preserving swap after the
* actual starting deal. This controls only who can answer the test window. */
function swapHeld(g: Game, id: string, predicate: (c: Game['deck'][number]) => boolean): string {
const target = seat(g, id);
const held = target.hand.find(predicate);
if (held) return held.id;
assert.ok(target.hand.length);
const index = g.deck.findIndex(predicate);
if (index >= 0) {
[target.hand[0], g.deck[index]] = [g.deck[index], target.hand[0]];
return target.hand[0].id;
}
for (const other of g.players) {
if (other.id === id) continue;
const at = other.hand.findIndex(predicate);
if (at >= 0) {
[target.hand[0], other.hand[at]] = [other.hand[at], target.hand[0]];
return target.hand[0].id;
}
}
throw Error('The original card must exist in the native deal or deck');
}
/** All pending movement/decisions come from actual player actions. The only
* controlled inputs are unopened Spice cards, first Storm dials and conserved
* card swaps. Richese's marker is actually shipped on its own turn. */
function fixture(kind: Kind, keepMarker: boolean) {
const value = 3, markerAtDestination = false;
assert.ok(!markerAtDestination || kind === 'fremenMovement');
const mover = kind === 'fremenMovement' ? 'fremen' : kind === 'ixMovement' ? 'ixians' : 'emperor';
const expansions = kind === 'ixMovement' ? ['choam', 'ix'] : ['choam'];
let g = createGame(`REVEAL-${kind}-${value}`, newPlayer('richese', 'Richese', 'richese'), false, expansions);
if (kind === 'choamMovement') joinGame(g, newPlayer('choam', 'CHOAM', 'choam'));
else joinGame(g, newPlayer(mover, mover, mover));
joinGame(g, newPlayer('emperor', 'Emperor', 'emperor'));
for (const p of g.players) g = act(g, p.id, {type:'ready'});
g = act(g, g.host, {type:'start'});
assert.equal(g.status, 'setup');
if (kind === 'ixMovement') {
// The native Ix setup driver chooses a Shield from the inspected packet.
// Move the original unopened card to its first draw position.
const shield = g.deck.findIndex(card => card.kind === 'shield');
assert.ok(shield >= 0);
[g.deck[0], g.deck[shield]] = [g.deck[shield], g.deck[0]];
}
const next = (state: Game) => {
const fremen = state.players.find(p => p.faction === 'fremen');
if (state.status === 'setup' && state.setupStage === 'forces' && fremen?.reserves === 20 &&
viewGame(state, fremen.id).setupPending.includes(fremen.id))
return {actor:fremen.id, action:{type:'fremenSetup', placements:{'sietch_tabr:14':10}} satisfies Action};
if (state.phase === 5 && state.active === 'richese' && clean(state) && !seat(state,'richese').shipped &&
!seat(state,'richese').noField?.deployed) {
const owner = seat(state,'richese'), marker = owner.noField!.tokens.find(t => t.value === value);
assert.ok(marker && owner.noFieldEvent);
const target = markerAtDestination
? TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s)))
.find(key => splitLocation(key).sector > 7 && state.players.every(p => !(p.forces[key] > 0)) &&
gameDistance(state,'sietch_tabr:14',key,k => splitLocation(k).sector === state.storm) === 2 &&
gameDistance(state,'sietch_tabr:14',key,k => splitLocation(k).sector === (state.storm % 18) + 1) === 2)
: 'polar_sink:0';
assert.ok(target, 'The original Fremen position has a safe distance-two marker destination');
return {actor:'richese', action:{type:'ship', noField:marker.id, event:owner.noFieldEvent,
...splitLocation(target), allyPayment:0} satisfies Action};
}
if (kind === 'choamMovement' && state.turn === 1 && state.phase === 5 && state.active === 'choam' &&
clean(state) && !seat(state,'choam').shipped)
return {actor:'choam', action:{type:'ship', territory:'arrakeen', sector:10, amount:1, elite:0, allyPayment:0} satisfies Action};
if (state.phase === 5 && state.active === 'emperor' && clean(state))
return {actor:'emperor', action:{type:'endMovement'} satisfies Action};
const step = nextTruthMovementNativeEarlyStep(state);
assert.ok(step, `Missing native continuation ${state.status}/${state.turn}/${state.phase}/${state.decision?.kind}`);
if (step.action.type === 'stormDial' && state.turn === 1)
return {...step, action:{...step.action, amount:0}};
return step;
};
const reach = (until: (s: Game) => boolean) => {
for (let n = 0; n < 2400 && !until(g); n++) {
assert.notEqual(g.status, 'finished');
const step = next(g); g = act(g, step.actor, step.action);
}
assert.ok(until(g), 'The real game reached the requested native boundary');
};
reach(state => state.status === 'playing');
const karama = swapHeld(g, 'emperor', c => c.effect === 'karama');
const baliset = kind === 'choamMovement' ? swapHeld(g, 'choam', c => c.name === 'Baliset') : undefined;
for (let pos = 0; pos < 2; pos++) {
const at = g.spiceDeck.findIndex((c, index) => index >= pos && 'territory' in c && !c.discovery);
assert.ok(at >= pos); [g.spiceDeck[pos], g.spiceDeck[at]] = [g.spiceDeck[at], g.spiceDeck[pos]];
}
reach(state => state.turn === 2 && state.phase === 5 && state.active === mover && clean(state) &&
!!seat(state,'richese').noField?.deployed);
const marker = seat(g,'richese').noField!.deployed!.tokenId;
const event = seat(g,'richese').noFieldEvent; assert.ok(event);
let origin: string;
if (kind === 'fremenMovement') {
origin = 'sietch_tabr:14';
assert.ok((seat(g,mover).forces[origin] ?? 0) > 0);
} else if (kind === 'choamMovement') {
origin = 'carthag:11';
g = act(g,mover,{type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0});
reach(clean);
assert.ok((seat(g,'choam').forces['arrakeen:10'] ?? 0) > 0);
} else {
origin = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s)))
.find(key => splitLocation(key).sector !== g.storm && g.players.every(p => !(p.forces[key] > 0)) &&
TERRITORIES.some(t => t.type === 'sand' && t.sectors.some(s => s !== g.storm &&
gameDistance(g,key,location(t.id,s),k => splitLocation(k).sector === g.storm) === 2)))!;
assert.ok(origin);
g = act(g,mover,{type:'ship', ...splitLocation(origin), amount:1, elite:1, allyPayment:0});
reach(clean);
}
const markerLocation = seat(g,'richese').noField!.deployed!.location;
const destination = markerAtDestination && 'territory' in markerLocation
? location(markerLocation.territory,markerLocation.sector)
: kind === 'choamMovement' ? 'arrakeen:10' :
TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s)))
.find(key => splitLocation(key).sector !== g.storm && g.players.every(p => !(p.forces[key] > 0)) &&
gameDistance(g,origin,key,k => splitLocation(k).sector === g.storm) === 2)!;
assert.ok(destination);
// Also exercise movement without a deployed No-Field, using its real reveal.
if (!keepMarker) g = act(g,'richese',{type:'revealNoField', token:marker, event});
const beforeMove = structuredClone(g);
const move: Action = {type:'move', forces:{[origin]:1},
...(kind === 'ixMovement' ? {eliteForces:{[origin]:1}} : {}), ...splitLocation(destination)};
g = act(g,mover,move);
assert.equal(kind === 'choamMovement' ? g.decision?.kind : g.response?.kind, kind);
assert.deepEqual(seat(g,mover).forces, seat(beforeMove,mover).forces);
return {game:g, mover, marker, event, karama, baliset, value, origin, destination, beforeMove};
}
function finishPending(g: Game) {
for (let n = 0; n < 40; n++) {
if (g.pendingKull?.stage === 'offer') {
g = act(g,g.pendingKull.player,{type:'kullDecision',event:g.pendingKull.event,decline:true}); continue;
}
if (g.pendingTreacheryDiscard) {
const reaction = g.pendingTreacheryDiscard.reaction; assert.ok(reaction?.stage === 'offer');
const actor = g.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
g = act(g,actor.id,{type:'semutaPass',event:g.pendingTreacheryDiscard.batch.event}); continue;
}
if (g.response) {
const actor = g.players.find(p => !g.response!.passed.includes(p.id)); assert.ok(actor);
g = act(g,actor.id,{type:'passResponse'}); continue;
}
if (g.decision?.kind === 'choamMovement') {
g = act(g,g.decision.player,{type:'decision',decline:true}); continue;
}
return g;
}
throw Error('The original movement response did not finish');
}
const cached = new Map<string, ReturnType<typeof fixture>>();
function source(kind: Kind, keepMarker: boolean) {
const key = `${kind}:${keepMarker}`;
if (!cached.has(key)) cached.set(key,fixture(kind,keepMarker));
return cached.get(key)!;
}
function rejectUnchanged(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game,actor,action));
assert.deepEqual(game,before);
}
function pending(game: Game, kind: Kind) {
return kind === 'fremenMovement' ? game.pendingFremenMove :
kind === 'ixMovement' ? game.pendingIxMove : game.pendingChoamMove;
}
function pay(g: Game, kind: Kind) {
const payer = 'emperor', recipient = 'richese', before = structuredClone(g);
const options = viewGame(g,payer).bribeOptions;
assert.equal(options.blocked,null);
const candidate = options.targets.find(target => target.id === recipient);
assert.ok(candidate && candidate.maximum >= 1);
const action = bribeAction(options,recipient,1);
assert.deepEqual(action,{type:'bribe',target:recipient,amount:1});
rejectUnchanged(g,payer,{type:'bribe',target:payer,amount:1});
rejectUnchanged(g,payer,{type:'bribe',target:recipient,amount:options.available+1});
const after = act(g,payer,action!);
assert.deepEqual(pending(after,kind),pending(before,kind));
assert.deepEqual(after.response,before.response);
assert.deepEqual(after.decision,before.decision);
assert.equal(seat(after,payer).spice,seat(before,payer).spice-1);
assert.equal(seat(after,recipient).spice,seat(before,recipient).spice);
assert.equal(seat(after,recipient).bribes,seat(before,recipient).bribes+1);
assert.deepEqual(cards(after),cards(before));
if (candidate.maximum === options.available) {
const exhausted = act(before,payer,{type:'bribe',target:recipient,amount:candidate.maximum});
assert.equal(viewGame(exhausted,payer).bribeOptions.available,0);
rejectUnchanged(exhausted,payer,{type:'bribe',target:recipient,amount:1});
}
return after;
}
for (const kind of ['fremenMovement','ixMovement','choamMovement'] as const) {
for (const keepMarker of [false,true]) void test(`Basic ${kind} keeps the actual move through a bribe with ${keepMarker ? 'a deployed' : 'no deployed'} marker`, () => {
const f = source(kind,keepMarker), before = structuredClone(f.game);
assert.equal(!!seat(before,'richese').noField?.deployed,keepMarker);
const after = pay(before,kind);
assert.deepEqual(seat(after,f.mover).forces,seat(f.beforeMove,f.mover).forces);
const control = finishPending(reload(before)), done = finishPending(reload(after));
assert.deepEqual(seat(done,f.mover).forces,seat(control,f.mover).forces);
assert.equal(seat(done,f.mover).moved,seat(control,f.mover).moved);
assert.equal(seat(done,f.mover).forces[f.destination],(seat(f.beforeMove,f.mover).forces[f.destination] ?? 0)+1);
assert.equal(seat(done,f.mover).forces[f.origin] ?? 0,(seat(f.beforeMove,f.mover).forces[f.origin] ?? 0)-1);
assert.equal(seat(done,'richese').bribes,seat(before,'richese').bribes+1);
});
void test(`Basic ${kind} still allows the original cancellation and bot responses after a bribe`, () => {
const f = source(kind,true), before = structuredClone(f.game), after = pay(before,kind);
const actor = kind === 'choamMovement' ? 'choam' : 'emperor';
for (const difficulty of DIFFICULTIES) {
const view = viewGame(after,actor); view.players.find(p => p.id === actor)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action,difficulty);
assert.doesNotThrow(() => act(reload(after),actor,action));
}
let cancelled = kind === 'choamMovement'
? act(after,'choam',{type:'card',card:f.baliset,mode:'choam',target:f.mover,territory:splitLocation(f.destination).territory})
: act(after,'emperor',{type:'card',card:f.karama,mode:'cancel'});
cancelled = finishPending(cancelled);
assert.deepEqual(seat(cancelled,f.mover).forces,seat(f.beforeMove,f.mover).forces);
assert.equal(seat(cancelled,f.mover).moved,seat(f.beforeMove,f.mover).moved);
assert.equal(seat(cancelled,'richese').bribes,seat(before,'richese').bribes+1);
});
}
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import {
createTruthtranceRichesePromisesFixture as fixture,
actTruthtranceRichesePromises as act,
askTruthtranceRichesePromises as ask,
advanceTruthtranceRichesePromises as advance,
truthtranceRichesePromisesClean as clean,
truthtranceRichesePromisesPlayer as player,
truthtranceRichesePromisesInventory as inventory,
} from './fixture-truthtrance-richese-promises';
void test('a No-Field shipment guide preserves an earlier physical ground-movement Yes', () => {
const f = fixture({kind:'movement'}), source = structuredClone(f.game);
// Controlled conserved supplement: acquire the other original physical
// Truthtrance from the unopened deck, leaving every card in one location.
const at = source.deck.findIndex(card => card.effect === 'truthtrance');
assert.ok(at >= 0);
const extra = source.deck.splice(at, 1)[0];
player(source, f.asker).hand.push(extra);
assert.deepEqual(inventory(source), f.originalInventory);
let game = ask(source, f);
assert.ok(viewGame(game, f.target).truthMovementAnswers?.includes('yes'));
game = advance(act(game, f.target, {type:'truthAnswer', answer:'yes'}), clean);
game = act(game, f.asker, {type:'card', card:extra.id});
game = advance(game, state => state.truthtrance?.stage === 'ask');
const markerQuestion = {kind:'shipment', target:f.target,
claim:{kind:'noField', territory:'arrakeen', sector:10}} as unknown as TruthQuestion;
game = act(game, f.asker, {type:'truthAsk', question:markerQuestion});
assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes'));
game = advance(act(game, f.target, {type:'truthAnswer', answer:'yes'}), clean);
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
assert.equal(game.movementPromises?.[0].answer, true);
assert.equal(game.shipmentPromises?.[0].answer, true);
assert.equal(game.movementPromises?.[0].released, undefined);
const first = viewGame(game, f.target).shipmentCompletion?.actions[0];
assert.equal(first?.type, 'ship');
assert.equal(typeof first.noField, 'string');
const selected = player(game).noField!.tokens.find(token => token.id === first.noField);
assert.ok(selected && selected.value >= 1, 'A zero marker cannot supply the promised physical movement');
const otherView = viewGame(game, f.asker);
assert.equal(otherView.shipmentCompletion, null);
assert.equal(otherView.richeseNoField?.private, null);
assert.equal(JSON.stringify(otherView).includes(first.noField as string), false);
game = advance(act(game, f.target, first), clean);
assert.equal(game.shipmentPromises![0].fulfilled, true);
// Follow the same private legal guidance to reveal the marker and move its
// actual physical forces; neither promise is satisfied by the marker alone.
for (let step = 0; !game.movementPromises![0].fulfilled && step < 4; step++) {
const next: Action | undefined = viewGame(game, f.target).movementPromisePolicy?.[0];
assert.ok(next, 'An owned completion must remain available after the shipment');
game = advance(act(game, f.target, next), clean);
}
assert.equal(game.movementPromises![0].fulfilled, true);
assert.equal(game.movementPromises![0].released, undefined);
assert.ok(player(game).forces['carthag:11'] >= 1);
assert.deepEqual(inventory(game), f.originalInventory);
});