Enforce Basic Truthtrance ground-movement promises

Add a structured future single-movement predicate and persistent authoring, feasible-answer and promise controls for bare native Basic active post-shipment turns. Yes requires one matching physical movement while possible; No survives every later Hajr/Ornithopter opportunity until actual turn closure. Matching physical commitment precedes disposal and arrival effects. Preserve compatible promises after opposing loss and reject voluntary destruction of the last owned completion.

Reuse the actual movement declaration and physical commitment in a focused owned-action search rather than a strategic shortlist. Retain typed groups, original two-group cohorts, held Hajr, both Ornithopter modes, city access and current Kulon bonus. Deterministic preparation on private clones retains physical cost semantics without inventing response/discard events in reachability. Existing native/optional/Advanced movement retains its actual controls.

Verify 17-file218/218, types/lint/build, seven original-action gameplay/control/table-rendering programmes and all four existing legal profiles. Original public classic, paid cache Ornithopter and native allowed-Kulon sources use only unopened positions and scalar lotteries; extra-card/force-loss edges are explicitly controlled. Repair nearby stale fixture continuations to acknowledge Semuta/battle outcomes and put pre-movement Hajr in Basic. Full modes, wider preparation/timing/module coverage, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
This commit is contained in:
Quintine committed 2026-10-24 14:32:16 +11:00
1 parent b4a556d5d3
commit edbcbe28de
23 files changed
+1471 -218

No files matched your search

+7
View File
@@ -1280,6 +1280,10 @@ function policyActions(g: GameView): Action[] {
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
if (w.stage === 'answer') {
if (w.question?.target !== me.id) return [];
if (w.question.kind === 'movement') {
const answer = g.truthMovementAnswers?.find(answer => answer !== 'unknown') ?? g.truthMovementAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'mixedShipment') {
const choices = g.truthShipmentAnswers ?? [];
const answer = choices.find(choice => choice === 'yes' || choice === 'no') ?? choices[0];
@@ -4073,6 +4077,9 @@ export function botActions(g: GameView): Action[] {
if (g.shipmentPromisePolicy && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
return g.shipmentPromisePolicy;
if (g.movementPromisePolicy && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
return g.movementPromisePolicy;
if (g.nexusHarkonnenBetrayalReaction) {
if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed ||
g.automaticContinuationPending) return [];
+364 -201
View File
@@ -1,3 +1,5 @@
import { MovementClaimError, liveMovementPromises, movementEventAllowed, movementPromiseTargetAvailable, settleMovementEvent, finishMovementPromises, validMovementClaim, type MovementClaim, type MovementPromise } from './movement-promises';
import { findMovementCompletion, type MovementCompletion } from './movement-promise-search';
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile } from './faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
@@ -1571,6 +1573,7 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
promise:
| { kind: 'shipment'; index: number; value: ShipmentPromise }
| { kind: 'movement'; index: number; value: MovementPromise }
| { kind: 'battle'; index: number; value: BattlePromise }
| null;
parentSignature: string;
@@ -1969,6 +1972,7 @@ export type Game = {
truthtrance?: TruthWindow | null;
truthHistory?: TruthRecord[];
shipmentPromises?: ShipmentPromise[];
movementPromises?: MovementPromise[];
ixSetupCards?: Card[] | null;
ixAuction?: { count: number; cards: Card[]; authority?: string } | null;
ixAuctionKnown?: { turn: number; cards: Card[]; controller?: string } | null;
@@ -6026,6 +6030,7 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
history: g.truthHistory,
battle: g.battle,
shipmentPromises: g.shipmentPromises,
movementPromises: g.movementPromises,
active: g.active,
order: g.order,
status: g.status,
@@ -6048,7 +6053,10 @@ function finishTruthtranceAnswer(g: Game) {
const record = g.truthHistory![historyIndex];
const card = discard(g, getPlayer(g, consumed.player), consumed.card);
let promise: TruthDiscardContinuation['promise'] = null;
if (record.question.kind === 'shipment' || record.question.kind === 'mixedShipment')
if (record.question.kind === 'movement') promise = {
kind: 'movement', index: g.movementPromises!.length - 1, value: g.movementPromises!.at(-1)!,
};
else if (record.question.kind === 'shipment' || record.question.kind === 'mixedShipment')
promise = {
kind: 'shipment',
index: g.shipmentPromises!.length - 1,
@@ -6930,7 +6938,7 @@ function treacheryDiscardIntegrity(g: Game) {
try {
validateTruthQuestionReceipt(context, c.consumed.player, record.question);
} catch (error) {
if (error instanceof TruthError || error instanceof PlanClaimError)
if (error instanceof TruthError || error instanceof PlanClaimError || error instanceof MovementClaimError)
throw new RuleError(error.message);
throw error;
}
@@ -6942,15 +6950,16 @@ function treacheryDiscardIntegrity(g: Game) {
g.battle &&
!g.battle.revealed &&
!g.battle.plans[q.target];
if (expectedShipment || expectedBattle) {
const actual = expectedShipment
const expectedMovement = q.kind === 'movement';
if (expectedShipment || expectedBattle || expectedMovement) {
const actual = expectedMovement ? g.movementPromises : expectedShipment
? g.shipmentPromises
: g.battle?.truthPromises;
requireRule(
promise &&
promise.value &&
typeof promise.value === 'object' &&
promise.kind === (expectedShipment ? 'shipment' : 'battle') &&
promise.kind === (expectedMovement ? 'movement' : expectedShipment ? 'shipment' : 'battle') &&
Array.isArray(actual) &&
Number.isSafeInteger(promise.index) &&
promise.index >= 0 &&
@@ -6963,7 +6972,11 @@ function treacheryDiscardIntegrity(g: Game) {
!promise.value.released,
'The completed Truthtrance is missing its newly bound promise.',
);
if (promise.kind === 'shipment')
if (promise.kind === 'movement')
requireRule(q.kind === 'movement' && promise.value.turn === g.turn && !promise.value.fulfilled &&
promise.value.territory === q.territory && promise.value.minimum === q.minimum,
'The completed Truthtrance ground-movement promise differs from its answer.');
else if (promise.kind === 'shipment')
requireRule(
(q.kind === 'shipment' || q.kind === 'mixedShipment') &&
promise.value.turn === g.turn &&
@@ -7595,6 +7608,7 @@ function semutaOfferSupported(
(continuation.consumed.source === undefined || continuation.consumed.source === 'shrine') &&
(continuation.promise === null ||
continuation.promise.kind === 'shipment' ||
continuation.promise.kind === 'movement' ||
continuation.promise.kind === 'battle'))) return false;
if (g.homeworlds && !bgDiscard && continuation.kind !== 'kaitainDiscard' &&
continuation.kind !== 'tupileCleanupDiscard' &&
@@ -16384,29 +16398,319 @@ function recoverableUnsupportedShipment(shipment: PendingShipment) {
shipment.guildSecretEvent === undefined
);
}
function completeMove(g: Game, move: MovementOrder) {
if (move.noField) {
const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, move.player));
/** Keep source controls in the real game. A detached witness answers only
* owned movement reachability; unresolved opposing outcomes are never replayed. */
function groundMovementProbe(source: Game): Game {
const game = structuredClone(source);
game.truthtrance = null; game.response = null; game.decision = null; game.phaseOpening = null;
game.pendingKarama = null; game.pendingTreacheryDiscard = null;
game.pendingFremenMove = null; game.pendingIxMove = null; game.pendingChoamMove = null;
game.pendingAmbassador = null; game.pendingTerrorEntry = null; game.pendingArrivalOverlap = null;
game.pendingArrivalReaction = []; game.pendingExchange = null; game.pendingNullentropy = null;
return game;
}
function groundMovementEvent(move: MovementOrder) {
return { territory: move.to, amount: move.total - Number(!!move.noField) };
}
function projectGroundMovement(source: Game, move: MovementOrder): Game {
const game = structuredClone(source), player = getPlayer(game, move.player);
commitGroundMovementPieces(game, move);
const flight = game.ornithopter;
if (flight?.player === player.id) {
flight.completed++;
if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) {
if (flight.mode === 'twoGroups') player.ornithopterExtraMoves = (player.ornithopterExtraMoves ?? 0) + 1;
game.discard.push(flight.card); game.ornithopter = null;
}
}
return game;
}
function groundMovementCompletion(source: Game, actor: string,
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn)): MovementCompletion | null {
const game = groundMovementProbe(source);
if (!movementPromiseTargetAvailable(game, actor)) return null;
return findMovementCompletion(game, actor, promises, {
allowance: movesAllowed,
prepareHajr(state, owner) {
const player = getPlayer(state, owner);
const card = player.hand.find(card => card.effect === 'hajr');
if (!card || !hajrAvailable(state, player) || transferCardBlock(state, player, card)) return null;
try { return groundMovementProbe(applyActionInner(state, owner, { type: 'card', card: card.id }, 'movementPreparation')); }
catch (error) { if (error instanceof RuleError) return null; throw error; }
},
reachable(state, player, origin, destination, sector) {
// Include current native range (notably CHOAM Kulon) and the card's
// explicit range-three option. This only prunes impossible geometry;
// the actual declaration still validates exact speed/groups/custody.
return Object.keys(player.forces).some(key => player.forces[key] > 0 && splitLocation(key).territory === origin &&
gameDistance(state, key, location(destination, sector), point => pathBlocked(state, player, point, isAdvisor(player, origin))) <= Math.max(3, movementRange(state, player, 1)));
},
project(state, owner, action) {
const copy = structuredClone(state), player = getPlayer(copy, owner);
try {
const move = declareGroundMovement(copy, player, action, true);
return { game: projectGroundMovement(copy, move), event: groundMovementEvent(move) };
} catch (error) { if (error instanceof RuleError) return null; throw error; }
},
});
}
function projectedGroundMovementPolicy(g: Game, player: Player): Action[] | null {
if (g.truthtrance || !movementPromiseTargetAvailable(g, player.id) ||
!liveMovementPromises(g.movementPromises ?? [], player.id, g.turn).length) return null;
const completion = groundMovementCompletion(g, player.id);
return completion ? completion.actions.length ? completion.actions.slice(0, 1) : [{ type: 'endMovement' }] : null;
}
function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAnswer[] {
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn);
const answers: TruthAnswer[] = [];
for (const answer of [true, false]) {
const promise = { ...claim, turn: g.turn, player: p.id, asker: g.players.find(other => other.id !== p.id)!.id, answer };
if (groundMovementCompletion(g, p.id, [...live, promise])) answers.push(answer ? 'yes' : 'no');
}
return answers.length ? answers : ['unknown'];
}
function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) {
(g.movementPromises ??= []).push({ ...claim, turn: g.turn, player: p.id, asker, answer });
}
function checkGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn);
if (!live.length) return;
const event = groundMovementEvent(move);
requireRule(movementEventAllowed(live, event), 'This movement would break your accepted No answer.');
const projected = projectGroundMovement(g, move);
projected.movementPromises = settleMovementEvent(g.movementPromises ?? [], p.id, g.turn, event);
requireRule(groundMovementCompletion(projected, p.id) !== null,
'Leave a legal completion for your accepted ground-movement promises.');
}
function recordGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
if (move.source === 'ambassador') return;
if (!g.movementPromises) return;
const old = g.movementPromises;
g.movementPromises = settleMovementEvent(old, p.id, g.turn, groundMovementEvent(move));
g.movementPromises.forEach((promise, index) => {
if (promise.fulfilled && !old[index].fulfilled)
log(g, `${p.name} fulfilled a Truthtrance ground-movement promise with this committed move.`);
});
}
function reconcileGroundMovementPromises(g: Game, actor?: string) {
// A declared native movement is awaiting a real opponent choice. It is not
// impossible and must not be fulfilled before physical commitment.
if (g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove) return;
for (const p of g.players) {
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn);
if (!live.some(promise => promise.answer)) continue;
const busy = !!(g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
g.pendingAmbassador || g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length);
if (busy && actor !== p.id) continue;
if (groundMovementCompletion(g, p.id, live)) continue;
requireRule(actor !== p.id, 'Your action would remove the last legal completion of an accepted ground-movement promise.');
const kept = live.filter(promise => !promise.answer);
// Keep still-feasible earlier answers and every No constraint; an opposing
// loss does not erase independent promises that remain possible.
for (const promise of live.filter(promise => promise.answer)) {
if (groundMovementCompletion(g, p.id, [...kept, promise])) { kept.push(promise); continue; }
promise.released = true;
log(g, `${p.name}'s Truthtrance ground-movement promise is released because no owned legal completion remains possible.`);
}
}
}
/** One shared declaration validates human actions and private promise witnesses.
* Quote callers supply a detached game; card preparation stays in that copy. */
function declareGroundMovement(g: Game, p: Player, action: Action, quoteOnly = false): MovementOrder {
const id = p.id;
requireRule(action.discoveryFlight === undefined,
'Choose a carried Discovery Ornithopter token; saved flight receipts are internal.');
if (action.noField !== undefined) {
const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, id));
requireRule(!blocked, blocked ?? 'The No-Field cannot move.');
}
if (!p.shipped) checkShipmentPromises(g, p, null);
requireRule(
g.phase === 5 && g.active === id && p.moved < movesAllowed(g, p),
'Your movement is not available.',
);
const skillLeader = planetologistLeader(g, id);
requireRule(action.origins === undefined &&
(action.planetologist === undefined || (skillLeader &&
(action.planetologist === 'range' || action.planetologist === 'gather'))),
'Choose an available Planetologist movement mode.');
const planetologist: PlanetologistMovement | undefined = action.planetologist === undefined
? undefined : {leader: skillLeader!, mode: action.planetologist as PlanetologistMovement['mode']};
const {
group,
eliteGroup,
elite,
origin,
origins,
total: n,
noField,
sourceKeys,
} = forceGroup(g, p, action, planetologist?.mode === 'gather' ? 2 : 1);
const to = stringField(action.territory),
s = integer(action.sector, 0, 18, 'Sector');
const {advisors, lockedTurn, wantsFighters, advisorGather} = movementStance(g, p, origins, to, action.fighters === true);
requireRule(
origins.every(source => !balisetPrevents(g, id, source, to)),
'Baliset prevents movement into this CHOAM territory; shipment remains possible.',
);
allowedEntry(g, p, to, s, false, advisors && !wantsFighters);
requireRule(
sourceKeys.every((key) => key !== location(to, s)),
'Choose a different destination.',
);
// Reject known unsupported arrivals before any movement-card event or
// native movement-prevention window can commit this declaration.
validateMovementArrival(g, {
player: id, group, eliteGroup, elite, origin, origins, total: n,
to, sector: s, advisors, wantsFighters, lockedTurn,
});
let flight = g.ornithopter?.player === id ? g.ornithopter : null;
if (action.movementCard !== undefined) {
const mode = action.ornithopter;
requireRule(
mode === 'range3' || mode === 'twoGroups',
'Choose an Ornithopter movement mode.',
);
const blocked = ornithopterBlock(g, p);
requireRule(!blocked, blocked ?? 'Ornithopter cannot be played.');
const card = p.hand.find((c) => c.id === action.movementCard);
requireRule(
card && richeseCardDefinition(card)?.card.effect === 'ornithopter',
'Choose the canonical Ornithopter in your hand.',
);
const reserved = transferCardBlock(g, p, card);
requireRule(!reserved, reserved ?? 'The card is committed.');
requireRule(
action.ornithopterEvent === undefined,
'A new card movement cannot name an old event.',
);
flight = {
event: crypto.randomUUID(),
player: id,
card,
turn: g.turn,
mode,
startingMove: p.moved,
completed: 0,
};
g.ornithopter = flight;
p.hand = p.hand.filter((c) => c.id !== card.id);
log(
g,
`${p.name} played Ornithopter: ${mode === 'range3' ? 'one group with a maximum three-territory route' : 'two different groups using normal movement'}. The card remains on the table until its use finishes.`,
);
} else if (flight)
requireRule(
action.ornithopterEvent === flight.event,
'Use the current Ornithopter movement event.',
);
else
requireRule(
action.ornithopterEvent === undefined &&
action.ornithopter === undefined,
'No matching Ornithopter movement is active.',
);
const move: MovementOrder = {
player: id,
group,
eliteGroup,
elite,
origin,
...(planetologist ? {planetologist} : {}),
...(planetologist?.mode === 'gather' ? {origins} : {}),
total: n,
to,
sector: s,
advisors,
wantsFighters,
lockedTurn,
player: id,
} = move;
const p = getPlayer(g, id);
...(noField ? { noField } : {}),
...(flight
? {
ornithopterEvent: flight.event,
ornithopterRange: flight.mode === 'range3',
}
: {}),
};
if (action.discoveryOrnithopter !== undefined)
move.discoveryFlight = nexusRule(() => quoteDiscoveryFlight(g, action.discoveryOrnithopter, move));
validatePlanetologistMove(g, p, move);
const speed = movementOrderRange(g, p, move);
if (action.sandmaster !== undefined) {
move.sandmaster = nexusRule(() => quoteSandmasterMovement(g, id, sandmasterOrder(g, p, move), action.sandmaster));
validateSandmasterOrder(g, p, move);
}
requireRule(
sourceKeys.every(
(key) =>
gameDistance(g, key, location(to, s), (k) =>
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
) <= movementOrderRange(g, p, move, splitLocation(key).territory),
),
planetologist?.mode === 'gather'
? 'Each selected origin must have an unblocked route within its own movement range.'
: `That destination is blocked or more than ${speed} territories away.`,
);
validateFlightSelection(g, p, move);
validateMovementArrival(g, move);
validateSandmasterOrder(g, p, move);
if (quoteOnly) return move;
checkGroundMovementPromises(g, p, move);
if (advisorGather) {
g.response = {
kind: 'advisorFlip', owner: id, location: to, advisors: false, amount: n, passed: [],
advisorResume: 'planetologistGather', advisorGatherMove: { turn: g.turn, move: p.moved, order: move },
};
} else if (
!move.ornithopterRange && !move.discoveryFlight &&
p.faction === 'ixians' &&
elite > 0 &&
!(p.ixMovementBlocked?.turn === g.turn && p.ixMovementBlocked.move === p.moved) &&
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
(move.sandmaster
? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1)
: group.some(([key]) =>
selectedOriginElites(group, eliteGroup, splitLocation(key).territory) > 0 &&
gameDistance(g, key, location(to, s), (k) =>
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
) > planetologistRange(1, planetologist?.mode)))
) {
g.pendingIxMove = move;
g.response = {
kind: 'ixMovement',
owner: id,
location: location(to, s),
amount: n,
elite,
passed: [],
};
} else if (
!move.ornithopterRange && !move.discoveryFlight &&
p.faction === 'fremen' &&
p.fremenNexusMovementBlockedTurn !== g.turn &&
!(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) &&
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
(move.sandmaster
? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1)
: group.some(
([key]) => gameDistance(g, key, location(to, s), (k) =>
pathBlocked(g, p, k, isAdvisor(p, origin))) > planetologistRange(1, planetologist?.mode)))
) {
g.pendingFremenMove = { turn: g.turn, move: p.moved, order: move };
g.response = {
kind: 'fremenMovement',
...(move.sandmaster ? {sandmasterProof: JSON.stringify(move.sandmaster)} : {}),
owner: id,
location: location(to, s),
amount: n,
passed: [],
};
} else offerChoamMovement(g, move);
return move;
}
/** Commit only the selected physical group and native movement counters. Arrival
* decisions and card disposal follow separately in the actual continuation. */
function commitGroundMovementPieces(g: Game, move: MovementOrder) {
const {group, eliteGroup, elite, total:n, to, sector:s, advisors, wantsFighters, lockedTurn, player:id} = move;
const p = getPlayer(g, id);
const flight = g.ornithopter;
if (
flight?.player === id &&
@@ -16474,6 +16778,21 @@ function completeMove(g: Game, move: MovementOrder) {
g.discoveries = nexusRule(() => completeDiscoveryFlight(g, move.discoveryFlight!, move));
log(g, `${p.name} used the carried Discovery Ornithopter for this movement and removed its token.`);
}
}
function completeMove(g: Game, move: MovementOrder) {
if (move.noField) {
const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, move.player));
requireRule(!blocked, blocked ?? 'The No-Field cannot move.');
}
const { elite, origin, total: n, to, sector: s, player: id } = move;
const p = getPlayer(g, id);
validatePlanetologistMove(g, p, move);
validateFlightSelection(g, p, move);
validateMovementArrival(g, move);
validateSandmasterOrder(g, p, move);
const flight = g.ornithopter;
commitGroundMovementPieces(g, move);
recordGroundMovementPromises(g, p, move);
const cunning = currentGuildCunning(g,id);
if (cunning) { requireRule(cunning.stage === 'extraMove', 'Guild Cunning permits no movement before the second shipment.'); cunning.movesAfter++; saveGuildCunning(g,cunning); }
const completed: CompletedMovement = {
@@ -16599,6 +16918,12 @@ function finishBasicAlliedShipment(g: Game, p: Player) {
/** A played movement card is already retired before queue advancement. */
function finishMovementTurn(g: Game, id: string) {
const p = getPlayer(g, id);
try {
if (g.movementPromises) g.movementPromises = finishMovementPromises(g.movementPromises, p.id, g.turn);
} catch (error) {
if (error instanceof MovementClaimError) throw new RuleError(error.message);
throw error;
}
requireAlliedSeparationCustody(g, p);
const cunning = currentGuildCunning(g,id);
if (cunning) { requireRule(cunning.stage !== 'pending' && cunning.shipment?.stage !== 'pending', 'Finish the Guild Cunning response or shipment first.'); cunning.stage = 'completed'; saveGuildCunning(g,cunning); }
@@ -22771,6 +23096,14 @@ function shipmentPromiseIntegrity(g: Game) {
if (error instanceof TruthError) throw new RuleError(error.message);
throw error;
}
requireRule(g.movementPromises === undefined || (Array.isArray(g.movementPromises) &&
g.movementPromises.every(promise => validMovementClaim(promise) &&
Number.isSafeInteger(promise.turn) && promise.turn >= 1 && promise.turn <= g.turn &&
g.players.some(p => p.id === promise.player) && g.players.some(p => p.id === promise.asker && p.id !== promise.player) &&
typeof promise.answer === 'boolean' &&
(promise.fulfilled === undefined || typeof promise.fulfilled === 'boolean') &&
(promise.released === undefined || typeof promise.released === 'boolean') &&
!(promise.fulfilled && promise.released))), 'The saved ground-movement promises are invalid.');
const promises = g.shipmentPromises;
requireRule(
promises === undefined || Array.isArray(promises),
@@ -30787,6 +31120,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
reconcileGroundMovementPromises(g, id);
harkonnenNexusBetrayalIntegrity(g);
if (g.pendingNexusHarkonnenBetrayal) return g;
ixianNexusBetrayalIntegrity(g);
@@ -31088,6 +31422,7 @@ function settleAutomaticContinuations(g: Game) {
// they are not the original actor voluntarily spending a promised resource.
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
reconcileGroundMovementPromises(g);
requireRule(
JSON.stringify(g) !== before,
'Automatic response continuation made no progress.',
@@ -31177,6 +31512,7 @@ export function normalizeAutomaticGame(state: Game): Game {
}
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
reconcileGroundMovementPromises(g);
settleAutomaticContinuations(g);
observeOccupation(g);
discoveryIntegrity(g);
@@ -31211,7 +31547,7 @@ function applyActionInner(
state: Game,
id: string,
action: Action,
execution: 'live' | 'shipmentPreparation' = 'live',
execution: 'live' | 'shipmentPreparation' | 'movementPreparation' = 'live',
): Game {
tupileSlotsIntegrity(state);
const tupileBlock = tupileSourceBlock(state);
@@ -31409,6 +31745,8 @@ function applyActionInner(
log,
shipmentAnswers: shipmentTruthAnswers,
bindShipment: bindShipmentTruth,
movementAnswers: movementTruthAnswers,
bindMovement: bindMovementTruth,
battleAnswers: battleTruthAnswers,
bindBattle: bindBattleTruth,
cardBlock: transferCardBlock,
@@ -31416,7 +31754,7 @@ function applyActionInner(
)
return g;
} catch (error) {
if (error instanceof TruthError || error instanceof PlanClaimError)
if (error instanceof TruthError || error instanceof PlanClaimError || error instanceof MovementClaimError)
throw new RuleError(error.message);
throw error;
}
@@ -34656,190 +34994,7 @@ function applyActionInner(
return g;
}
if (t === 'move') {
requireRule(action.discoveryFlight === undefined,
'Choose a carried Discovery Ornithopter token; saved flight receipts are internal.');
if (action.noField !== undefined) {
const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, id));
requireRule(!blocked, blocked ?? 'The No-Field cannot move.');
}
if (!p.shipped) checkShipmentPromises(g, p, null);
requireRule(
g.phase === 5 && g.active === id && p.moved < movesAllowed(g, p),
'Your movement is not available.',
);
const skillLeader = planetologistLeader(g, id);
requireRule(action.origins === undefined &&
(action.planetologist === undefined || (skillLeader &&
(action.planetologist === 'range' || action.planetologist === 'gather'))),
'Choose an available Planetologist movement mode.');
const planetologist: PlanetologistMovement | undefined = action.planetologist === undefined
? undefined : {leader: skillLeader!, mode: action.planetologist as PlanetologistMovement['mode']};
const {
group,
eliteGroup,
elite,
origin,
origins,
total: n,
noField,
sourceKeys,
} = forceGroup(g, p, action, planetologist?.mode === 'gather' ? 2 : 1);
const to = stringField(action.territory),
s = integer(action.sector, 0, 18, 'Sector');
const {advisors, lockedTurn, wantsFighters, advisorGather} = movementStance(g, p, origins, to, action.fighters === true);
requireRule(
origins.every(source => !balisetPrevents(g, id, source, to)),
'Baliset prevents movement into this CHOAM territory; shipment remains possible.',
);
allowedEntry(g, p, to, s, false, advisors && !wantsFighters);
requireRule(
sourceKeys.every((key) => key !== location(to, s)),
'Choose a different destination.',
);
// Reject known unsupported arrivals before any movement-card event or
// native movement-prevention window can commit this declaration.
validateMovementArrival(g, {
player: id, group, eliteGroup, elite, origin, origins, total: n,
to, sector: s, advisors, wantsFighters, lockedTurn,
});
let flight = g.ornithopter?.player === id ? g.ornithopter : null;
if (action.movementCard !== undefined) {
const mode = action.ornithopter;
requireRule(
mode === 'range3' || mode === 'twoGroups',
'Choose an Ornithopter movement mode.',
);
const blocked = ornithopterBlock(g, p);
requireRule(!blocked, blocked ?? 'Ornithopter cannot be played.');
const card = p.hand.find((c) => c.id === action.movementCard);
requireRule(
card && richeseCardDefinition(card)?.card.effect === 'ornithopter',
'Choose the canonical Ornithopter in your hand.',
);
const reserved = transferCardBlock(g, p, card);
requireRule(!reserved, reserved ?? 'The card is committed.');
requireRule(
action.ornithopterEvent === undefined,
'A new card movement cannot name an old event.',
);
flight = {
event: crypto.randomUUID(),
player: id,
card,
turn: g.turn,
mode,
startingMove: p.moved,
completed: 0,
};
g.ornithopter = flight;
p.hand = p.hand.filter((c) => c.id !== card.id);
log(
g,
`${p.name} played Ornithopter: ${mode === 'range3' ? 'one group with a maximum three-territory route' : 'two different groups using normal movement'}. The card remains on the table until its use finishes.`,
);
} else if (flight)
requireRule(
action.ornithopterEvent === flight.event,
'Use the current Ornithopter movement event.',
);
else
requireRule(
action.ornithopterEvent === undefined &&
action.ornithopter === undefined,
'No matching Ornithopter movement is active.',
);
const move: MovementOrder = {
player: id,
group,
eliteGroup,
elite,
origin,
...(planetologist ? {planetologist} : {}),
...(planetologist?.mode === 'gather' ? {origins} : {}),
total: n,
to,
sector: s,
advisors,
wantsFighters,
lockedTurn,
...(noField ? { noField } : {}),
...(flight
? {
ornithopterEvent: flight.event,
ornithopterRange: flight.mode === 'range3',
}
: {}),
};
if (action.discoveryOrnithopter !== undefined)
move.discoveryFlight = nexusRule(() => quoteDiscoveryFlight(g, action.discoveryOrnithopter, move));
validatePlanetologistMove(g, p, move);
const speed = movementOrderRange(g, p, move);
if (action.sandmaster !== undefined) {
move.sandmaster = nexusRule(() => quoteSandmasterMovement(g, id, sandmasterOrder(g, p, move), action.sandmaster));
validateSandmasterOrder(g, p, move);
}
requireRule(
sourceKeys.every(
(key) =>
gameDistance(g, key, location(to, s), (k) =>
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
) <= movementOrderRange(g, p, move, splitLocation(key).territory),
),
planetologist?.mode === 'gather'
? 'Each selected origin must have an unblocked route within its own movement range.'
: `That destination is blocked or more than ${speed} territories away.`,
);
validateFlightSelection(g, p, move);
if (advisorGather) {
g.response = {
kind: 'advisorFlip', owner: id, location: to, advisors: false, amount: n, passed: [],
advisorResume: 'planetologistGather', advisorGatherMove: { turn: g.turn, move: p.moved, order: move },
};
} else if (
!move.ornithopterRange && !move.discoveryFlight &&
p.faction === 'ixians' &&
elite > 0 &&
!(p.ixMovementBlocked?.turn === g.turn && p.ixMovementBlocked.move === p.moved) &&
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
(move.sandmaster
? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1)
: group.some(([key]) =>
selectedOriginElites(group, eliteGroup, splitLocation(key).territory) > 0 &&
gameDistance(g, key, location(to, s), (k) =>
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
) > planetologistRange(1, planetologist?.mode)))
) {
g.pendingIxMove = move;
g.response = {
kind: 'ixMovement',
owner: id,
location: location(to, s),
amount: n,
elite,
passed: [],
};
} else if (
!move.ornithopterRange && !move.discoveryFlight &&
p.faction === 'fremen' &&
p.fremenNexusMovementBlockedTurn !== g.turn &&
!(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) &&
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
(move.sandmaster
? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1)
: group.some(
([key]) => gameDistance(g, key, location(to, s), (k) =>
pathBlocked(g, p, k, isAdvisor(p, origin))) > planetologistRange(1, planetologist?.mode)))
) {
g.pendingFremenMove = { turn: g.turn, move: p.moved, order: move };
g.response = {
kind: 'fremenMovement',
...(move.sandmaster ? {sandmasterProof: JSON.stringify(move.sandmaster)} : {}),
owner: id,
location: location(to, s),
amount: n,
passed: [],
};
} else offerChoamMovement(g, move);
declareGroundMovement(g, p, action);
return g;
}
if (t === 'nexusGuildSkipShipment') {
@@ -34854,6 +35009,8 @@ function applyActionInner(
return g;
}
if (t === 'endMovement') {
requireRule(!liveMovementPromises(g.movementPromises ?? [], id, g.turn).some(promise => promise.answer),
'Complete your accepted ground-movement promises before ending movement.');
finishShipmentPromises(g, p, null);
requireRule(
g.phase === 5 && g.active === id,
@@ -35286,7 +35443,7 @@ function applyActionInner(
? { faction: p.faction, name: 'Ghola revival' }
: undefined,
);
// Shipment feasibility executes deterministic preparations on private clones.
// Shipment/movement feasibility executes deterministic preparations on private clones.
// Its internal mode never comes from the submitted action or player state.
if (execution === 'live') stageOrdinaryCardDiscard(g, p, used);
return g;
@@ -35423,6 +35580,12 @@ export function viewGame(state: Game, id: string) {
battleChoices: g.status === 'playing' && g.phase === 6 ? battleOrderQuote(g).choices : [],
battleOrder: projectedBattleOrder(g),
guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id),
truthMovementTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id),
truthMovementAnswers: g.truthtrance?.stage === 'answer' &&
g.truthtrance.question?.kind === 'movement' && g.truthtrance.question.target === id
? movementTruthAnswers(g, me, g.truthtrance.question) : null,
movementPromises: g.movementPromises ?? [],
movementPromisePolicy: projectedGroundMovementPolicy(g, me),
truthShipmentSupported: g.players.some(p => shipmentPromiseModeSupported(g, p.id)),
truthShipmentTargets: !g.response && !g.decision && !g.phaseOpening && !g.pendingShipment
? g.players.filter(p => shipmentPromiseModeSupported(g, p.id) &&
+93
View File
@@ -0,0 +1,93 @@
import type { Action, Game, Player } from './engine';
import { gameTerritories, splitLocation } from './board';
import { liveMovementPromises, movementEventAllowed, settleMovementEvent, type MovementEvent, type MovementPromise } from './movement-promises';
export type MovementPromiseAdapter = {
allowance: (game: Game, player: Player) => number;
prepareHajr: (game: Game, player: string) => Game | null;
reachable: (game: Game, player: Player, origin: string, destination: string, sector: number) => boolean;
project: (game: Game, player: string, action: Action) => { game: Game; event: MovementEvent } | null;
};
export type MovementCompletion = { actions: Action[] };
/** Enumerate actual physical subgroups from one territory, including native
* elite allocations and several sectors. No strategic shortlist proves legality. */
function* groups(player: Player, keys: string[], amount: number, index = 0,
forces: Record<string, number> = {}, elites: Record<string, number> = {}): Generator<Action> {
if (index === keys.length) {
if (amount === 0) yield { type: 'move', forces: { ...forces }, eliteForces: { ...elites } };
return;
}
const key = keys[index], special = player.elites?.forces[key] ?? 0;
const ordinary = player.forces[key] - special;
for (let elite = 0; elite <= Math.min(amount, special); elite++)
for (let normal = Math.min(ordinary, amount - elite); normal >= 0; normal--) {
const count = elite + normal;
if (count) forces[key] = count; else delete forces[key];
if (elite) elites[key] = elite; else delete elites[key];
yield* groups(player, keys, amount - count, index + 1, forces, elites);
}
delete forces[key]; delete elites[key];
}
/** Finite owned-action reachability. Each move consumes a real entitlement;
* Hajr and Ornithopter are existing physical cards, never invented future draws.
* The adapter shares authoritative movement validation/physical commitment.
* Opposing arrival outcomes are not simulated or assumed. */
export function findMovementCompletion(source: Game, actor: string,
promises: readonly MovementPromise[], adapter: MovementPromiseAdapter): MovementCompletion | null {
const failed = new Set<string>();
const search = (game: Game, pending: readonly MovementPromise[], actions: Action[]): MovementCompletion | null => {
const live = liveMovementPromises(pending, actor, game.turn);
if (!live.some(promise => promise.answer)) return { actions };
const player = game.players.find(candidate => candidate.id === actor)!;
const physical = Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
if (live.some(promise => promise.answer && promise.minimum > physical)) return null;
const key = JSON.stringify([player.forces, player.elites, player.moved, player.hand.map(card => card.id),
player.hajrPlays, player.ornithopterExtraMoves, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null,
live.map(promise => [promise.territory, promise.minimum, promise.answer])]);
if (failed.has(key)) return null;
const destinations = gameTerritories(game).filter(destination => !destination.id.startsWith('homeworld:'));
destinations.sort((a, b) => Number(live.some(p => p.answer && p.territory === b.id)) -
Number(live.some(p => p.answer && p.territory === a.id)));
if (player.moved < adapter.allowance(game, player)) {
const origins = [...new Set(Object.keys(player.forces).filter(key => player.forces[key] > 0)
.map(key => splitLocation(key).territory))];
const card = !game.ornithopter ? player.hand.find(card => card.effect === 'ornithopter') : null;
const modes: Partial<Action>[] = game.ornithopter
? [{ ornithopterEvent: game.ornithopter.event }]
: [{}, ...(card ? [{ movementCard: card.id, ornithopter: 'range3' },
{ movementCard: card.id, ornithopter: 'twoGroups' }] : [])];
for (const destination of destinations) for (const sector of destination.sectors) for (const origin of origins) {
if (!adapter.reachable(game, player, origin, destination.id, sector)) continue;
const keys = Object.keys(player.forces).filter(key => player.forces[key] > 0 && splitLocation(key).territory === origin);
const total = keys.reduce((sum, key) => sum + player.forces[key], 0);
const amounts = Array.from({ length: total }, (_, i) => i + 1);
amounts.sort((a, b) => Number(live.some(p => p.answer && p.territory === destination.id && b >= p.minimum)) -
Number(live.some(p => p.answer && p.territory === destination.id && a >= p.minimum)) || a - b);
for (const amount of amounts) {
const event = { territory: destination.id, amount };
if (!movementEventAllowed(live, event)) continue;
for (const group of groups(player, keys, amount)) for (const mode of modes) {
const action = { ...group, ...mode, territory: destination.id, sector };
const next = adapter.project(game, actor, action);
if (!next) continue;
const settled = settleMovementEvent(pending, actor, game.turn, next.event);
next.game.movementPromises = structuredClone(settled);
const result = search(next.game, settled, [...actions, action]);
if (result) return result;
}
}
}
}
const prepared = adapter.prepareHajr(game, actor);
if (prepared) {
const card = player.hand.find(card => card.effect === 'hajr')!;
const result = search(prepared, live, [...actions, { type: 'card', card: card.id }]);
if (result) return result;
}
failed.add(key); return null;
};
const initial = structuredClone(source); initial.movementPromises = structuredClone([...promises]);
return search(initial, promises, []);
}
+106
View File
@@ -0,0 +1,106 @@
import { TERRITORIES, territory } from './board';
import { FACTIONS } from './catalog';
import type { Game } from './engine';
/** One qualifying ground movement, never the sum of several smaller groups. */
export type MovementClaim = { territory: string; minimum: number };
export type MovementEvent = { territory: string; amount: number };
export type MovementPromise = MovementClaim & {
turn: number;
player: string;
asker: string;
answer: boolean;
fulfilled?: boolean;
released?: boolean;
};
export class MovementClaimError extends Error {}
function check(value: unknown, message: string): asserts value {
if (!value) throw new MovementClaimError(message);
}
/** Extract claim fields; the caller validates surrounding question/ledger keys. */
export function parseMovementClaim(value: unknown): MovementClaim {
check(value && typeof value === 'object' && !Array.isArray(value),
'Choose a valid ground-movement statement.');
const claim = value as Record<string, unknown>;
check(typeof claim.territory === 'string' &&
TERRITORIES.some(destination => destination.id === claim.territory),
'Choose a printed ground-movement destination.');
check(typeof claim.minimum === 'number' && Number.isSafeInteger(claim.minimum) &&
claim.minimum >= 1 && claim.minimum <= 20,
'Choose a minimum of one to twenty physical forces.');
return { territory: claim.territory, minimum: claim.minimum };
}
export function validMovementClaim(value: unknown): value is MovementClaim {
try { parseMovementClaim(value); return true; }
catch (error) {
if (error instanceof MovementClaimError) return false;
throw error;
}
}
export function movementClaimText(claim: MovementClaim): string {
return `make a ground movement of at least ${claim.minimum} physical forces to ${territory(claim.territory).name}`;
}
export function movementMatches(claim: MovementClaim, event: MovementEvent | null): boolean {
return event !== null && event.territory === claim.territory &&
Number.isSafeInteger(event.amount) && event.amount >= claim.minimum && event.amount <= 20;
}
export function liveMovementPromises<T extends MovementPromise>(
promises: readonly T[], player: string, turn: number,
): T[] {
return promises.filter(promise => promise.player === player && promise.turn === turn &&
!promise.fulfilled && !promise.released);
}
/** The caller supplies the relevant current promises. Yes is existential: a
* nonmatching event may be followed by another ordinary movement opportunity. */
export function movementEventAllowed(
promises: readonly MovementPromise[], event: MovementEvent | null,
): boolean {
return promises.every(promise => promise.fulfilled || promise.released ||
promise.answer || !movementMatches(promise, event));
}
export function settleMovementEvent<T extends MovementPromise>(
promises: readonly T[], player: string, turn: number, event: MovementEvent | null,
): T[] {
const live = liveMovementPromises(promises, player, turn);
check(movementEventAllowed(live, event),
'This ground movement would break an accepted No answer.');
return promises.map(promise => live.includes(promise) && promise.answer && movementMatches(promise, event)
? { ...promise, fulfilled: true } : promise);
}
/** Close only the actual player's movement turn. No stays binding until here;
* ending early cannot evade an unfulfilled Yes, even if this event was skipped. */
export function finishMovementPromises<T extends MovementPromise>(
promises: readonly T[], player: string, turn: number,
): T[] {
const live = liveMovementPromises(promises, player, turn);
check(live.every(promise => !promise.answer),
'Complete your accepted ground-movement promise before ending movement.');
return promises.map(promise => live.includes(promise) ? { ...promise, fulfilled: true } : promise);
}
/** Prototype profile/timing only. Actual route feasibility stays authoritative
* in the engine. Truthtrance and an ordinary active Ornithopter may remain. */
export function movementPromiseTargetAvailable(g: Game, player: string): boolean {
const target = g.players.find(candidate => candidate.id === player);
return g.status === 'playing' && g.advanced === false && g.phase === 5 &&
g.active === player && !!target && (target.shipped || target.moved > 0) &&
!!g.movementRemaining?.includes(player) &&
g.players.length >= 2 && g.players.length <= 6 &&
g.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) &&
new Set(g.expansions).size === g.expansions.length &&
g.players.every(candidate => {
const family = FACTIONS.find(entry => entry.id === candidate.faction)?.expansion;
return family === 'base' || (!!family && g.expansions.includes(family));
}) &&
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens &&
!g.strongholdCards && !g.discoveryEnabled && !g.discoveries &&
!g.discoveryStash && !g.greatMaker && !g.ecazTreachery &&
!g.response && !g.decision && !g.phaseOpening &&
!g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingGuildTransport &&
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingArrivalOverlap &&
!g.pendingArrivalReaction?.length && !g.pendingFremenMove && !g.pendingIxMove &&
!g.pendingChoamMove && !g.pendingTreacheryDiscard;
}
+27 -2
View File
@@ -2922,6 +2922,30 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, UI, compound privacy, bots, JSON and production SQLite restoration; broader Truthtrance acceptance remains open.', evidence: ['tests/truthtrance-knowledge.test.ts', 'tests/truthtrance-knowledge-controls.test.tsx', 'tests/truthtrance-knowledge-recovery.test.ts'] },
],
},
{
id: 'truthtrance-movement',
title: 'Truthtrance: ground-movement promises',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Bind one future qualifying physical ground movement before an active Basic respondent finishes their current turn.',
steps: [
'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Ask the active Basic player after shipment and finish pending decisions first. Selected native families are supported; optional modules, Advanced and earlier or waiting turns remain unfinished.',
'Yes requires one qualifying ground movement from now until that player’s Shipment and Movement turn ends, while possible. Smaller groups do not add together and earlier moves do not count. Shipment, HMS relocation and Ambassador relocation do not fulfill this statement; a concealed No-Field marker itself is not a physical on-board force counter.',
'A nonmatching move leaves Yes pending when an owned later route remains. One matching physical commitment fulfills it before card disposal and later Intrusion, Ambassador or Terror effects. You are promising the move, not survival afterward. No forbids every later matching move, including Hajr and both Ornithopter groups, until the actual turn closes.',
'Private feasible answers and the next owned step use actual movement legality, typed groups, held Hajr, both Ornithopter modes and original cohorts, current city access and native bonuses such as Kulon. Unknown future draws/gifts are not invented. Other preparation families, including Box recovery and native relocation preparation, remain unfinished.',
'Voluntary actions cannot erase the last required route. Native counter responses finish before impossibility is decided. An opposing loss releases only incompatible answers, retaining No constraints and still-possible Yes answers in accepted order. This owned-route and release priority is a provisional software composition of the general current-turn rule, not a publisher movement-specific FAQ.',
'Persistent answer and next-step controls do not time out. All four profiles use the same private legal guidance. Broader Truthtrance questions, complete modes, full combinations and final acceptance remain unfinished.',
],
related: ['card-truthtrance', 'truthtrance-shipment', 'movement', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'A pure ledger and finite owned-action search reuse authoritative declaration and physical commitment. Yes settles before arrivals/disposal; No closes only at actual turn end. Shared native movement, current Kulon and original card cohorts remain. Earlier timing, other preparations and optional/Advanced profiles stay unfinished.', evidence: ['game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Dedicated destination/minimum, exact public preview, private feasible answers and persistent busy-safe next actions have current Basic controls. Browser/visual acceptance remains open.', evidence: ['components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-movement-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles make twelve accepted answer/move/end proposals from real public Basic source views. This is minimal legal participation without strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-engine.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'General FAQ authority, existential single-event meaning, explicit software composition, source selection and unfinished scope are documented.', evidence: ['docs/TRUTHTRANCE_MOVEMENT_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Seventeen-file218/218, types/lint/build and seven original-action promise-handler/table-rendering programmes pass. Public classic source20, native paid Ornithopter source58 and genuine Kulon source26 retain actual pieces/cards/payments; only unopened positions and scalar lotteries are selected. Separate extra-card/force-loss edges disclose conserved staging. Review corrected Kulon reachability/selective release; private preparation and old fixture feedback were repaired. No browser/visual/deployed, full-mode or comprehensive assurance claim.', evidence: ['tests/movement-promises.test.ts', 'tests/truth-movement-engine.test.ts', 'tests/truth-movement-controls.test.tsx', 'tests/fixture-truth-movement.ts', 'tests/fixture-truth-movement-choam.ts', 'tests/fixture-basic-semuta-ambassador.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
],
},
{
id: 'truthtrance-shipment',
title: 'Truthtrance: shipment promises',
@@ -3007,7 +3031,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
summary:
'Ask another player a public yes/no question. Verified facts, supported shipment promises, current-battle plan commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
'Ask another player a public yes/no question. Verified facts, supported shipment and ground-movement promises, current-battle plan commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
steps: [
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
'Ask a game-related yes/no question. Named-card, selected-traitor and current personal-spice questions are checked by the server. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
@@ -3016,7 +3040,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The questioned player answers publicly. A definite answer discards the card. If the answer cannot be known, the holder may ask a different question or save Truthtrance for later.',
'A saved definite answer resumes automatically: its public answer, binding promise and consumed card are recorded once, then the next queued question or interrupted action resumes. Private answer and preparation guidance is unavailable during this automatic recovery.',
'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.',
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide.',
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Active post-shipment Basic native turns also support one future ground-movement minimum: Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.',
'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.',
@@ -3030,6 +3054,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-card-count',
'truthtrance-hand-inventory',
'truthtrance-shipment',
'truthtrance-movement',
'richese-no-field',
'card-reinforcements',
],
+19 -1
View File
@@ -1,3 +1,4 @@
import { parseMovementClaim, movementClaimText, movementPromiseTargetAvailable, MovementClaimError, type MovementClaim } from './movement-promises';
import { shipmentAvailable } from './shipment-opportunity';
import type { ShipmentClaim } from './shipment-promises';
import {
@@ -52,6 +53,7 @@ export type TruthFact =
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type TruthQuestion =
| ({ kind: 'movement'; target: string } & MovementClaim)
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
| { kind: 'mixedShipment'; target: string; mixed: MixedShipmentExpression }
| { kind: 'battlePlan'; target: string; territory: string; claim: PlanClaim }
@@ -191,6 +193,14 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
g.players.some((p) => p.id === v.target),
'Ask one other player.',
);
if (v.kind === 'movement') {
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,territory',
'Supply one ground-movement destination, minimum and respondent.');
check(movementPromiseTargetAvailable(g, v.target as string),
'Ground-movement promises require a clean active Basic turn after shipment, with no optional modules.');
try { return { kind: 'movement', target: v.target as string, ...parseMovementClaim(v) }; }
catch (error) { if (error instanceof MovementClaimError) throw new TruthError(error.message); throw error; }
}
if (v.kind === 'shipment' || v.kind === 'mixedShipment') {
check(
shipmentPromiseModeSupported(g, v.target) &&
@@ -295,7 +305,7 @@ export function validateSavedTruthtrance(g: Game) {
'The saved Truthtrance must retain each committed physical card in its holder’s hand.',
);
}
if (window?.question?.kind === 'fact') {
if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement') {
check((window.stage === 'answer' || window.stage === 'unknown') && window.queue.length > 0,
'The saved fact question must retain its asking player and answer stage.');
check(JSON.stringify(parseQuestion(g, window.queue[0].player, window.question)) === JSON.stringify(window.question),
@@ -397,6 +407,7 @@ export function truthQuestionText(
locationName: (id: string) => string,
): string {
if (q.kind === 'freeform') return q.text;
if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
if (q.kind === 'shipment')
return `Will you ${shipmentClaimText(q)} this turn?`;
if (q.kind === 'battlePlan')
@@ -437,6 +448,8 @@ export function resolveTruthAction(
asker: string,
mixed?: MixedShipmentExpression,
) => void;
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
battleAnswers: (g: Game, p: Player, claim: PlanClaim) => TruthAnswer[];
bindBattle: (
g: Game,
@@ -595,6 +608,11 @@ export function resolveTruthAction(
effects.bindShipment(g, p, claim, a.answer === 'yes', current.player,
q.kind === 'mixedShipment' ? q.mixed : undefined);
}
if (q.kind === 'movement') {
check(effects.movementAnswers && effects.bindMovement && effects.movementAnswers(g, p, q).includes(a.answer),
'Choose an answer consistent with legal ground movement and earlier promises.');
if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'battlePlan') {
check(
effects.battleAnswers(g, p, q.claim).includes(a.answer),