Support Basic movement promises before shipment

Allow clean active Basic ground-movement questions before or after shipment. Enumerate ordinary reserve amounts, native physical allocations, destinations and pledged payment splits through the actual declaration and shared physical commit. Detached projection stops before income/arrival effects and never counts shipment as ground fulfillment. Joint movement No and shipment Yes retain a legal next action rather than invalid End guidance.

Preserve exact pending ordinary reserve and native Guild permission/rate declarations when checking the remaining route. Voluntary wrong destination, force splitting or fee loss cannot release Yes through an opponent response. Guard unsupported sources before full speculative continuation. Existing live payment, cancellation, income and entry paths remain. Persistent busy-safe controls display reinforcement and both obligations; all four existing profiles use the same legal witness without strategy refinement.

Verify 19 files165/165, types/lint/build, six original-action gameplay/control/table-rendering programmes and four profiles with16 accepted actions. Genuine sources19/37/24/19 retain classic Arrakeen10 reinforced to11, native zero-board Emperor/CHOAM starts and original Guild5/15/5 reserve/cross transport. Second-card/counter cases explicitly transfer conserved originals. Bounded review repaired source guarding, funding completeness and exact pending-frame behavior. Stop an observed expensive search, reorder complete candidates by useful proximity and conservatively prune dead-end final moves; twelve new engine cases pass in3.2s. Correct a display-template lint issue.

Earlier phases/waiting turns, native transport/relocation preparation, No-Field/module sources, broader interactions, full modes, 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 15:10:49 +11:00
1 parent b5a53da712
commit f0b2ac82d9
18 files changed
+637 -34

No files matched your search

+44 -11
View File
@@ -1,4 +1,5 @@
import { recordMovementDiscardKnowledge, knownMovementDiscards, recordMovementBoxKnowledge, recordMovementClaimKnowledge, clearMovementDiscardKnowledge, type MovementDiscardKnowledge } from './movement-discard-knowledge';
import { movementShipmentPreparations } from './movement-shipment-preparation';
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';
@@ -16437,6 +16438,15 @@ function groundMovementProbe(source: Game): Game {
game.pendingFremenMove = null; game.pendingIxMove = null; game.pendingChoamMove = null;
game.pendingAmbassador = null; game.pendingTerrorEntry = null; game.pendingArrivalOverlap = null;
game.pendingArrivalReaction = []; game.pendingExchange = null;
if (game.pendingShipment?.player === game.active && recoverableUnsupportedShipment(game.pendingShipment)) {
const shipment = game.pendingShipment; game.pendingShipment = null;
commitShipment(game, shipment, undefined, true);
}
if (game.pendingGuildTransport?.player === game.active) {
const intent = game.pendingGuildTransport; game.pendingGuildTransport = null;
commitGuildTransport(game, getPlayer(game, intent.player), guildTransportOrder(intent), intent.fromReserves,
undefined, undefined, true);
}
if (game.pendingNullentropy && source.decision?.kind === 'nullentropy') game.decision = structuredClone(source.decision);
return game;
}
@@ -16485,6 +16495,10 @@ function groundMovementCompletion(source: Game, actor: string,
if (!movementPromiseTargetAvailable(game.pendingNullentropy?.player === actor ? { ...game, decision: null } : game, actor)) return null;
return findMovementCompletion(game, actor, promises, {
allowance: movesAllowed,
canFinish(state, owner) {
return !state.pendingNullentropy && liveShipmentPromises(state.shipmentPromises ?? [], owner, state.turn)
.every(promise => matchesShipment(promise, null) === promise.answer);
},
*preparations(state, owner) {
const player = getPlayer(state, owner), paid = state.pendingNullentropy;
const relevant = (card: Card) => card.effect === 'hajr' || card.effect === 'ornithopter' ||
@@ -16497,6 +16511,14 @@ function groundMovementCompletion(source: Game, actor: string,
}
return;
}
yield* movementShipmentPreparations(state, owner, (source, actor, action) => {
const payer = getPlayer(source, actor), share = Number(action.allyPayment);
const cost = reserveShipmentCost({ faction: payer.faction, halfRate: shipmentHalfRate(source, payer) },
territory(String(action.territory)).type, Number(action.amount));
if (share > cost || share > (aidFor(source, payer)?.amount ?? 0) || cost - share > payer.spice) return null;
try { return groundMovementProbe(applyActionInner(source, actor, action, 'movementPreparation')); }
catch (error) { if (error instanceof RuleError) return null; throw error; }
});
for (const card of player.hand) {
if (card.effect === 'hajr' && hajrAvailable(state, player) && !transferCardBlock(state, player, card)) {
try { yield { game: groundMovementProbe(applyActionInner(state, owner,
@@ -28951,6 +28973,7 @@ function commitGuildTransport(
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean,
guildSecretEvent?: string,
settlement?: {recipient: string | null},
movementPreparation = false,
) {
requireRule(!g.guildBetrayalPreview || !guildSecretEvent,
'Guild Betrayal native transport does not combine with a Guild Secret Ally producer.');
@@ -28963,16 +28986,18 @@ function commitGuildTransport(
bindGuildCunningShipment(g, p, 'guild', frame);
payWithAlly(g, p, cost, allyPayment);
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += cost;
const guild = byFaction(g, 'guild');
const guildPayment = guildShipmentIncome({
guild: guild?.id, shipper: p.id, ally: p.ally, cost, allyPayment,
bankOnly: g.karamaShipping?.player === p.id,
});
if (!settlement?.recipient && guild && guildPayment > 0)
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),
allyPayment ? undefined : p.id, [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment },
...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]);
settleBankerShipmentIncome(g, p, cost, allyPayment);
if (!movementPreparation) {
const guild = byFaction(g, 'guild');
const guildPayment = guildShipmentIncome({
guild: guild?.id, shipper: p.id, ally: p.ally, cost, allyPayment,
bankOnly: g.karamaShipping?.player === p.id,
});
if (!settlement?.recipient && guild && guildPayment > 0)
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),
allyPayment ? undefined : p.id, [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment },
...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]);
settleBankerShipmentIncome(g, p, cost, allyPayment);
}
if (fromReserves) {
p.reserves -= amount;
if (p.elites) p.elites.reserves -= elite;
@@ -28993,6 +29018,7 @@ function commitGuildTransport(
finishGuildCunningShipment(g, p.id, 'guild', frame);
p.shipped = true;
g.karamaShipping = null;
if (movementPreparation) return;
log(g, `${p.name} used Guild transport for ${amount} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
guildSecretEvent
? { faction: 'guild', name: 'Secret Ally transport' }
@@ -29055,7 +29081,9 @@ function offerShipment(g: Game, shipment: PendingShipment) {
);
} else if (!openGuildRate(g, shipment)) commitShipment(g, shipment);
}
function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipient: string | null}) {
function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipient: string | null}, movementPreparation = false) {
if (movementPreparation) requireRule(recoverableUnsupportedShipment(shipment),
'Movement preparation requires an ordinary physical reserve shipment.');
if (shipment.source === 'ambassador') {
commitAmbassadorShipment(g, shipment);
return;
@@ -29182,6 +29210,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
}
if (advisors && n > 0) (p.advisors ??= {})[to] ??= {};
p.shipped = true;
if (movementPreparation) { g.karamaShipping = null; return; }
if (p.faction !== 'fremen') techIncome(g, 'heighliners', p);
const guild = byFaction(g, 'guild');
const guildPayment = guildShipmentIncome({
@@ -34908,6 +34937,10 @@ function applyActionInner(
}
if (nexusEvent) recordNexusRicheseShipment(g,shipment);
bindGuildCunningShipment(g,p,'reserve',shipment);
if (execution === 'movementPreparation') {
commitShipment(g, shipment, undefined, true);
return g;
}
if (shipment.noField) {
// This cancellation window bypasses offerShipment, so reject known
// unsupported arrival combinations before saving its private intent.
+15 -5
View File
@@ -1,9 +1,10 @@
import type { Action, Game, Player } from './engine';
import { gameTerritories, splitLocation } from './board';
import { liveMovementPromises, movementEventAllowed, settleMovementEvent, type MovementEvent, type MovementPromise } from './movement-promises';
import { liveMovementPromises, movementEventAllowed, movementMatches, settleMovementEvent, type MovementEvent, type MovementPromise } from './movement-promises';
export type MovementPromiseAdapter = {
allowance: (game: Game, player: Player) => number;
canFinish: (game: Game, player: string) => boolean;
preparations: (game: Game, player: string) => Iterable<{ game: Game; actions: Action[] }>;
reachable: (game: Game, player: Player, origin: string, destination: string, sector: number) => boolean;
project: (game: Game, player: string, action: Action) => { game: Game; event: MovementEvent } | null;
@@ -40,12 +41,14 @@ export function findMovementCompletion(source: Game, actor: string,
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 needsMove = live.some(promise => promise.answer);
if (!needsMove && adapter.canFinish(game, actor)) 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),
if (live.some(promise => promise.answer && promise.minimum > physical + (player.shipped ? 0 : player.reserves))) return null;
const key = JSON.stringify([player.forces, player.elites, player.moved, player.shipped, player.reserves, player.hand.map(card => card.id),
player.hajrPlays, player.ornithopterExtraMoves, player.spice, game.choamMovement,
game.aid, game.karamaShipping, game.guildRateBlocked, game.shipmentPromises,
game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy,
game.choamWorthlessBlocked, 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])]);
@@ -53,7 +56,8 @@ export function findMovementCompletion(source: Game, actor: string,
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 (!game.pendingNullentropy && player.moved < adapter.allowance(game, player)) {
if (needsMove && !live.some(promise => promise.answer && promise.minimum > physical) &&
!game.pendingNullentropy && 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;
@@ -61,6 +65,11 @@ export function findMovementCompletion(source: Game, actor: string,
? [{ ornithopterEvent: game.ornithopter.event }]
: [{}, ...(card ? [{ movementCard: card.id, ornithopter: 'range3' },
{ movementCard: card.id, ornithopter: 'twoGroups' }] : [])];
// An exhausted ordinary move cannot leave Yes pending without an owned
// card that could grant another move. Treat held Box/Hajr/Ornithopter
// conservatively; their actual availability remains the adapter's job.
const mayMoveAgain = player.moved + 1 < adapter.allowance(game, player) || !!game.ornithopter ||
player.hand.some(card => ['hajr', 'ornithopter', 'nullentropyBox'].includes(card.effect ?? ''));
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);
@@ -71,6 +80,7 @@ export function findMovementCompletion(source: Game, actor: string,
for (const amount of amounts) {
const event = { territory: destination.id, amount };
if (!movementEventAllowed(live, event)) continue;
if (!mayMoveAgain && live.some(promise => promise.answer && !movementMatches(promise, 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);
+1 -1
View File
@@ -86,7 +86,7 @@ export function finishMovementPromises<T extends MovementPromise>(
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.active === player && !!target &&
!!g.movementRemaining?.includes(player) &&
g.players.length >= 2 && g.players.length <= 6 &&
g.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) &&
+39
View File
@@ -0,0 +1,39 @@
import { gameDistance, gameTerritories, splitLocation, territory } from './board';
import type { Action, Game } from './engine';
import { liveMovementPromises } from './movement-promises';
/** Ordinary physical reserve shipments, not an AI shortlist. The authoritative
* dispatcher supplies cost, funding, route and arrival legality. */
export function* movementShipmentPreparations(game: Game, actor: string,
project: (game: Game, actor: string, action: Action) => Game | null,
): Generator<{ game: Game; actions: Action[] }> {
const player = game.players.find(player => player.id === actor);
if (!player || player.shipped || game.pendingNullentropy) return;
const present = (territory: string) => Object.entries(player.forces)
.filter(([key]) => splitLocation(key).territory === territory)
.reduce((sum, [, amount]) => sum + amount, 0);
const wanted = liveMovementPromises(game.movementPromises ?? [], actor, game.turn)
.filter(promise => promise.answer).map(promise => territory(promise.territory));
const destinations = [...gameTerritories(game)];
const proximity = new Map(destinations.map(destination => [destination.id, wanted.length
? Math.min(...wanted.flatMap(target => destination.id === target.id ? [Number.MAX_SAFE_INTEGER]
: destination.sectors.flatMap(from => target.sectors.map(to =>
gameDistance(game, `${destination.id}:${from}`, `${target.id}:${to}`))))) : 0]));
destinations.sort((a, b) => present(b.id) - present(a.id) ||
Number(b.type === 'stronghold') - Number(a.type === 'stronghold') || proximity.get(a.id)! - proximity.get(b.id)!);
for (const destination of destinations) for (const sector of destination.sectors)
for (let amount = 1; amount <= player.reserves; amount++) {
const special = player.elites?.reserves ?? 0;
for (let elite = Math.max(0, amount - (player.reserves - special)); elite <= Math.min(amount, special); elite++) {
// The bare ordinary desert tariff is at most two per force. Enumerate
// funding splits as well: retaining owned spice can preserve a later
// Box fee. The dispatcher rejects stale/ineligible/over-price pledges.
const maximum = Math.min(2 * amount, game.aid[player.ally ?? '']?.amount ?? 0);
for (let allyPayment = maximum; allyPayment >= 0; allyPayment--) {
const action: Action = { type: 'ship', territory: destination.id, sector, amount, elite, allyPayment };
const next = project(game, actor, action);
if (next) yield { game: next, actions: [action] };
}
}
}
}
+6 -5
View File
@@ -2930,9 +2930,10 @@ export const RULE_TOPICS: RuleTopic[] = [
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.',
'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Ask the active Basic player before or after shipment and finish pending decisions first. Selected native families are supported; optional modules, Advanced, earlier phases and 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.',
'Ordinary reserve shipment may prepare or reinforce a later ground group using its actual cost, native allocation and pledged payment split. Shipment itself neither fulfills movement Yes nor violates movement No. Joint guidance honors a required shipment before End; an already declared reserve or native Guild transport keeps its exact source, destination and funding while responses finish.',
'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, held native Kulon and owned Box recovery. Before paying the two-spice Box fee, only a known card still in discard supplies a route; unknown future draws/gifts are not invented. Native relocation and wider preparation remain unfinished.',
'A paid Box search retains its full private choice and original event. A guided choice preserves Yes; an incompatible selection rejects before shuffle and keeps that paid search available without another fee. No-only promises retain ordinary selection before movement can end. The existing provisional atomic full-hand exchange remains.',
'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.',
@@ -2940,11 +2941,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
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. Add held native Kulon and paid Box recovery using known current-discard identities before payment. Original paid event, fee, full choice, physical replay and provisional full-hand policy remain; wrong Yes selections reject before shuffle. Earlier timing, unknown private possibilities, wider preparations/counter mitigation and optional/Advanced stay unfinished.', evidence: ['game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/movement-discard-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Dedicated destination/minimum, public preview, private feasible answers and persistent busy-safe next actions include Box payment, original paid selection and held Kulon. Full paid inspection remains; No-only searches cannot offer an invalid End action. Browser/visual acceptance remains open.', evidence: ['components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/truthtrance.tsx', 'components/nullentropy-box.tsx', 'components/game-table.tsx', 'tests/truth-movement-controls.test.tsx', 'tests/truth-movement-preparation-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles prioritize the compliant original paid selection and finish real Box/Hajr/move/end continuations with40 accepted actions including neutral passes. Earlier twelve answer/move/end proposals remain historical. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-engine.test.ts', 'tests/truth-movement-preparation-engine.test.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'A pure ledger and finite search reuse authoritative declaration/physical commitment. Active Basic questions now include ordinary reserve shipment, typed allocations, pledged splits and joint shipment predicates. Exact pending reserve/Guild declarations project without invented income/arrival effects; unsupported sources stay guarded. Held Kulon and known-discard paid Box retain prior semantics. Earlier phases/waiting turns, native transport/relocation preparation, No-Field/modules and wider interactions remain unfinished.', evidence: ['game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/movement-shipment-preparation.ts', 'game/movement-discard-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Dedicated public question/private answers and persistent busy-safe guidance include reserve reinforcement before movement, joint obligations, Box payment/selection and held Kulon. Shipment does not fulfill movement, and No cannot bypass a shipment Yes. Browser/visual acceptance remains open.', evidence: ['components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/shipment-promises.tsx', 'components/truthtrance.tsx', 'components/nullentropy-box.tsx', 'components/game-table.tsx', 'tests/truth-movement-shipment-controls.test.tsx', 'tests/truth-movement-preparation-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles make16 accepted answer/reinforce/move/end actions from the genuine ten-force pre-shipment source. Prior40 paid-Box actions remain historical. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-shipment-engine.test.ts', 'tests/truth-movement-preparation-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: 'Current seventeen-file231/231, types/lint/build and three original-action promise/paid-choice-handler/table-rendering programmes pass. Public Box source44 retains purchases1+1, shipment6 and exhausted real Hajr; held-Kulon source25 precedes route4. Separate full-hand/legacy-knowledge/fee-loss/counter edges disclose controlled conservation. Two old Auditor fixtures reproduce on the previous engine and now acknowledge actual battle results. Earlier218/218 movement evidence remains historical. No browser/visual/deployed, full-mode or comprehensive assurance claim.', evidence: ['tests/movement-discard-knowledge.test.ts', 'tests/truth-movement-preparation-engine.test.ts', 'tests/truth-movement-preparation-controls.test.tsx', 'tests/fixture-truth-movement-box.ts', 'tests/fixture-hajr-replay.ts', 'tests/fixture-truth-movement-choam.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Current nineteen-file165/165, types/lint/build and six actual-control/table-rendering programmes pass. Original sources19/37/24/19 retain classic reinforcement10→11, native Emperor/CHOAM zero-board starts and Guild5/15/5 reserve/cross transport. Controlled second-card/counter edges are separately labelled. Source/funding review findings and expensive search ordering were repaired; twelve new engine cases pass in3.2s. Prior231/231 and218/218 evidence stays historical. No browser/visual/deployed, full-mode or comprehensive assurance claim.', evidence: ['tests/movement-shipment-preparation.test.ts', 'tests/truth-movement-shipment-engine.test.ts', 'tests/truth-movement-shipment-controls.test.tsx', 'tests/fixture-truth-movement-shipment.ts', 'tests/fixture-truth-movement.ts', 'tests/fixture-truth-movement-choam.ts', 'tests/fixture-hajr-replay.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
],
},
{
+1 -1
View File
@@ -197,7 +197,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
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.');
'Ground-movement promises require a clean active Basic turn, 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; }
}