Support waiting-turn Basic ground-movement promises

Allow unused respondents in the current Movement queue to answer against their current owned board and resources. Isolate foreign declarations, prices, flights and searches in detached forecasting without future income/discard assumptions. Preserve native per-player Hajr history. Other players cannot fulfill or close the promise with their own moves/end. Ordinary executable guidance waits until the real owned turn.

Keep an actual already-paid Box decision actionable while waiting. Enumerate its full entitled choice, prioritizing movement cards without excluding ordinary cards that preserve an existing route; pre-payment known-discard rules remain unchanged. Preserve the original event, fee, private inspection and source continuation. Check deferred Emperor gift and Spiritual Advisor costs on their allowed outcome before another player responds. Localize native pending-movement deferral to the actual mover so another waiting owner cannot hide voluntary Box overspending. Minimal profiles may decline optional accompaniment under a positive promise.

Verify 18 files125/125, types/lint and four original-action guidance/paid-choice/table-rendering programmes. Original source19 reverses the unopened classic deal; native44 earns Box1/Hajr1 and genuinely spends Richese Truth, with Guild's actual later question supplying a second ordinary paid candidate. Eight existing-profile runs make32 accepted proposals plus12 separate neutral acknowledgements. Controlled pact/gift, BG losses, original Ornithopter acquisition and native Fremen/Box cases disclose their staging. Paired affordable/unaffordable interruptions preserve exact frames and prevent voluntary route loss.

The production build passes at the initial freeze. Only the regression gains its affordable counterpart afterward; source identity proves every application/build/dependency and other file unchanged, while final tests/types/runtime run again. Final checkpoint/reference prose overlays are separately reviewed. Earlier phases/later rounds, remaining preparations, No-Field/modules, full modes, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
This commit is contained in:
Quintine committed 2026-10-24 15:31:17 +11:00
1 parent f0b2ac82d9
commit 49586efd2f
17 files changed
+647 -36

No files matched your search

+3 -1
View File
@@ -1,4 +1,5 @@
import { discoveryBotActions } from './discovery-options';
import { liveMovementPromises } from './movement-promises';
import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions } from './ecaz-occupy-options';
import { quoteEcazOccupyDial } from './ecaz-occupy-battle';
import { recruitsPlayAction } from './recruits';
@@ -2724,7 +2725,8 @@ function policyActions(g: GameView): Action[] {
{ type: 'decision', accept: false },
];
}
if (d.kind === 'advisor' && liveShipmentPromises(g.shipmentPromises ?? [], me.id, g.turn).length)
if (d.kind === 'advisor' && (liveShipmentPromises(g.shipmentPromises ?? [], me.id, g.turn).length ||
liveMovementPromises(g.movementPromises ?? [], me.id, g.turn).some(promise => promise.answer)))
// A waiting promise may need these reserves. Declining is the minimal
// legal policy until optional accompaniment strategy is implemented.
return [{ type: 'decision', accept: false }];
+33 -12
View File
@@ -16431,8 +16431,20 @@ function recoverableUnsupportedShipment(shipment: PendingShipment) {
}
/** 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 {
function groundMovementProbe(source: Game, actor: string): Game {
const game = structuredClone(source);
if (game.active !== actor) {
// Forecast only this owner's unused turn on the current board. Do not
// complete a foreign declaration, price, flight or private search, and do
// not turn its potential discarded card into an owned Box candidate.
game.active = actor;
if (game.pendingShipment?.player !== actor) game.pendingShipment = null;
if (game.pendingHomeworldShipment?.player !== actor) game.pendingHomeworldShipment = null;
if (game.pendingGuildTransport?.player !== actor) game.pendingGuildTransport = null;
if (game.karamaShipping?.player !== actor) game.karamaShipping = null;
if (game.ornithopter?.player !== actor) game.ornithopter = null;
if (game.pendingNullentropy?.player !== actor) game.pendingNullentropy = null;
}
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;
@@ -16483,7 +16495,7 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
player.hand = result.ownerHand; context.discard = result.discard;
setMovementDiscardKnowledge(context, recordMovementBoxKnowledge(context.movementDiscardKnowledge,
context.players.map(p => p.id), actor, result.discard));
return groundMovementProbe(context);
return groundMovementProbe(context, actor);
} catch (error) {
if (error instanceof RuleError) return null;
throw error;
@@ -16491,7 +16503,7 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
}
function groundMovementCompletion(source: Game, actor: string,
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn)): MovementCompletion | null {
const game = groundMovementProbe(source);
const game = groundMovementProbe(source, actor);
if (!movementPromiseTargetAvailable(game.pendingNullentropy?.player === actor ? { ...game, decision: null } : game, actor)) return null;
return findMovementCompletion(game, actor, promises, {
allowance: movesAllowed,
@@ -16505,7 +16517,9 @@ function groundMovementCompletion(source: Game, actor: string,
(player.faction === 'choam' && card.kind === 'worthless' && card.name === 'Kulon');
if (paid) {
if (paid.player !== owner || nullentropyIntegrity(state)) return;
for (const card of eligibleNullentropyCards(state.discard).filter(relevant)) {
const choices = eligibleNullentropyCards(state.discard)
.sort((a, b) => Number(relevant(b)) - Number(relevant(a)));
for (const card of choices) {
const projected = projectMovementBoxRecovery(state, owner, paid.box, card.id, true);
if (projected) yield { game: projected, actions: [{ type: 'decision', event: paid.event, card: card.id }] };
}
@@ -16516,18 +16530,18 @@ function groundMovementCompletion(source: Game, actor: string,
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')); }
try { return groundMovementProbe(applyActionInner(source, actor, action, 'movementPreparation'), actor); }
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,
{ type: 'card', card: card.id }, 'movementPreparation')), actions: [{ type: 'card', card: card.id }] }; }
{ type: 'card', card: card.id }, 'movementPreparation'), owner), actions: [{ type: 'card', card: card.id }] }; }
catch (error) { if (!(error instanceof RuleError)) throw error; }
}
if (player.faction === 'choam' && card.kind === 'worthless' && card.name === 'Kulon') {
const action: Action = { type: 'card', mode: 'choam', card: card.id };
try { yield { game: groundMovementProbe(applyActionInner(state, owner, action, 'movementPreparation')), actions: [action] }; }
try { yield { game: groundMovementProbe(applyActionInner(state, owner, action, 'movementPreparation'), owner), actions: [action] }; }
catch (error) { if (!(error instanceof RuleError)) throw error; }
}
if (richeseCardDefinition(card)?.card.effect === 'nullentropyBox') {
@@ -16558,8 +16572,10 @@ function groundMovementCompletion(source: Game, actor: string,
function projectedGroundMovementPolicy(g: Game, player: Player): Action[] | null {
const live = liveMovementPromises(g.movementPromises ?? [], player.id, g.turn);
const paid = g.pendingNullentropy?.player === player.id;
if (g.truthtrance || !live.length || (paid && !live.some(promise => promise.answer)) ||
!movementPromiseTargetAvailable(paid ? { ...g, decision: null } : g, player.id)) return null;
if (g.truthtrance || !live.length || (!paid && g.active !== player.id) ||
(paid && !live.some(promise => promise.answer))) return null;
const available = paid ? groundMovementProbe(g, player.id) : g;
if (!movementPromiseTargetAvailable(paid ? { ...available, decision: null } : available, player.id)) return null;
const completion = groundMovementCompletion(g, player.id);
return completion ? completion.actions.length ? completion.actions.slice(0, 1) : [{ type: 'endMovement' }] : null;
}
@@ -16598,10 +16614,11 @@ function recordGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
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;
if (g.pendingFremenMove?.order.player === p.id || g.pendingIxMove?.player === p.id ||
g.pendingChoamMove?.player === p.id) 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;
@@ -33444,12 +33461,14 @@ function applyActionInner(
location: location(dest.territory, dest.sector),
amount,
};
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length) {
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
// Accepting accompaniment is voluntary even though a later responder
// commits its reserve withdrawal. Check that allowed outcome now.
const afterAdvisor = structuredClone(g);
finishResponse(afterAdvisor, false);
reconcileShipmentPromises(afterAdvisor, { actor: p.id, action });
reconcileGroundMovementPromises(afterAdvisor, p.id);
}
} else log(g, `${p.name} declined the free shipment.`);
} else if (decision.kind === 'wormPlacement') {
@@ -34387,13 +34406,15 @@ function applyActionInner(
p.spice >= paidForceRevivalCost(recipient, amount, elite),
'Not enough spice for these extra revivals.',
);
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length) {
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
// A later responder cannot erase the owner's voluntary gift/revival
// invoice. Check its allowed outcome before opening the response.
const afterPayment = structuredClone(g);
getPlayer(afterPayment, p.id).spice -= t === 'emperorGift'
? amount : paidForceRevivalCost(recipient, amount, elite);
reconcileShipmentPromises(afterPayment, { actor: p.id, action });
reconcileGroundMovementPromises(afterPayment, p.id);
}
g.response = {
kind: t,
+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 && (g.active === player || (!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)) &&
+6 -5
View File
@@ -2930,10 +2930,11 @@ 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 before or after shipment and finish pending decisions first. Selected native families are supported; optional modules, Advanced, earlier phases and waiting turns remain unfinished.',
'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Ask an active Basic player before or after shipment, or a still-unused waiting respondent in the current Movement queue. Finish pending decisions first. Selected native families are supported; optional modules, Advanced, earlier phases and later rounds 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.',
'Waiting answers use current board/resources without assuming foreign income, movement, cooperation or discarded cards. Another player cannot fulfill or close your promise by moving or ending. Ordinary guidance appears on your actual turn; your already-paid Box choice can finish now. Deferred gifts/accompaniment cannot voluntarily spend the last route before another player responds.',
'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.',
@@ -2941,11 +2942,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 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: 'Implementation', status: 'Partial', detail: 'The pure ledger and shared movement/shipment search now include unused waiting Basic respondents. Forecast current owned resources without foreign outcomes; ordinary policy waits until active, while the original paid Box can select any entitled card preserving the route. Deferred gift/advisor costs retain preflight; native moves defer only their owner. Earlier phases/later rounds, remaining preparation families, 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 waiting obligations, actual-turn reinforcement and paid search selection while waiting. Both ordinary paid faces remain inspectable; selecting returns to waiting with no premature movement button. Prior active/card controls remain. Browser/visual acceptance stays 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-waiting-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Eight runs across all four profiles make32 accepted waiting-answer or paid-selection/later-turn proposals, with12 separate neutral acknowledgements. Optional accompaniment may be declined under a positive promise. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-waiting-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 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'] },
{ area: 'Verification', status: 'Partial', detail: 'Current eighteen-file125/125, types/lint, retained passing app build and four original-action/control/table programmes pass. Classic source19 reverses its unopened deal; native source44 buys Box/Hajr and spends the first Truth; the actual later question supplies the second discard. Controlled pact, BG losses, foreign Ornithopter and native Fremen/Box cases disclose staging and prove rejected/affordable costs. Only the regression amended after build; app identity is verified and final tests/types/runtime rerun. Prior165/165,231/231,218/218 stay historical. No browser/visual/deployed, full-mode or comprehensive assurance claim.', evidence: ['tests/truth-movement-waiting-engine.test.ts', 'tests/truth-movement-waiting-controls.test.tsx', 'tests/fixture-truth-movement-waiting.ts', 'tests/fixture-truth-movement-waiting-box.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, with no optional modules.');
'Ground-movement promises require an active or still-waiting Basic turn this round, with pending decisions finished and 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; }
}