Support native Advanced and No-Field movement promises

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

No files matched your search

+4
View File
@@ -33,9 +33,13 @@ export function MovementPromises({ game, act, busy }: { game: GameView; act: (ac
: next?.type === 'card' && card?.effect === 'ghola' ? `Play Ghola to return ${amount} ${amount === 1 ? 'force' : 'forces'} to reserves for the required movement`
: next?.type === 'card' && next.mode === 'choam' ? 'Play Kulon to extend the required movement'
: next?.type === 'prepareAdvisors' ? `Prepare advisors in ${destinationLabel} for the required movement`
: next?.type === 'revealNoField' ? 'Reveal your No-Field to prepare physical forces for the required movement'
: next?.type === 'card' ? 'Play Hajr to prepare the required movement'
: next?.type === 'ship' && next.noField ? `Ship a concealed No-Field to ${destinationLabel}, sector ${sector}, before the required movement`
: next?.type === 'ship' ? `Ship ${amount} forces to ${destinationLabel}, sector ${sector}, before the required movement`
: next?.type === 'endMovement' ? 'Finish movement while honoring No'
: next?.noField && !amount ? `Move your concealed No-Field to ${destinationLabel}, sector ${sector}, to prepare the required movement`
: next?.noField ? `Move ${amount} physical forces with your No-Field to ${destinationLabel}, sector ${sector}`
: `${next?.movementCard ? 'Use Ornithopter and move' : 'Move'} ${amount} forces to ${destinationLabel}, sector ${sector}`;
return <section className="notice space-y-3" aria-label="Your ground-movement promises">
<h4>Your ground-movement promises</h4>
+3 -2
View File
@@ -350,8 +350,9 @@ export function Truthtrance({
<MovementClaimFields id={`${factId}-movement`} value={movement} onChange={setMovement} />
<p className="fine">One ground movement of the chosen physical minimum, from now until the respondent’s Movement turn ends. Shipment, HMS relocation and Ambassador relocation do not count. Owned Hajr, Ornithopter, native Kulon and paid Box recovery of a known discard can prepare a later move. No stays binding after earlier nonmatching moves.</p>
{g.phase < 5 && <p className="notice">Movement comes later this round. Keep the forces, spice and cards needed by your answer; available Charity or Revival may prepare the route. Current auction commitments reserve their actual funding. Current preparation appears when it is available.</p>}
{g.advanced && <p className="fine">Classic Advanced movement counts physical forces, including each elite once. Hajr remains available only immediately after a completed movement and its required arrival decisions.</p>}
{!g.truthMovementTargets.includes(target) && <p className="notice">Choose a supported Basic respondent this round or a classic Advanced respondent during Movement, and finish pending decisions. Advanced expansion and optional-module movement promises remain unfinished.</p>}
{g.advanced && <p className="fine">Advanced movement counts physical forces, including each elite once. Hajr remains available only immediately after a completed movement and its required arrival decisions.</p>}
<p className="fine">An owned No-Field may prepare a route through its normal shipment, movement or voluntary reveal. The concealed marker does not count toward the promised physical minimum; revelation uses only available reserves.</p>
{!g.truthMovementTargets.includes(target) && <p className="notice">Choose a supported Basic respondent this round or an Advanced respondent with declared native families during Movement, and finish pending decisions. Optional modules and earlier Advanced movement forecasts remain unfinished.</p>}
<p className="notice" aria-label="Public movement question preview">{truthQuestionText(question, leaderName, locationName)}</p>
</>
) : kind === 'shipment' || kind === 'mixedShipment' ? (
+27 -1
View File
@@ -16,6 +16,32 @@ open mode gates.
## Current checkpoint and work
**Native Advanced and No-Field movement promises — 25 October. Prototyped / Partial.**
Declared native Advanced families now share phase5 movement questions. Owned
No-Field shipment, reserve-capped reveal, concealed positioning and mixed moves
can prepare a route; the marker contributes zero toward the physical minimum.
Original tariff, token/event/history and Karama remain; a detached exact
preflight protects a new shipment without committing it early. Own optional
reveal cannot evade a promise. City preparation is tried earlier without
removing candidates. Earlier Advanced, modules and broader commitments remain.
[Contract and labelled source boundaries](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-advanced-and-no-field-preparation--25-october-2026).
Disjoint fixture work, bounded source review and root integration pass the
**24-file163/163** related union. Fixture-only lint repairs (numeric comparator
and typed location) then pass all **3-file15/15** affected consumers, types/lint,
build and six native original-control/GameTable SSR journeys. Hash comparison
confirms application/other tests unchanged across those repairs. Genuine Basic24/
Advanced27-action Richese sources retain original35+10 cards and paid marker;
43-action Ixian source earns original47 cards, paid Karama, HMS and two Cyborgs.
Actual reveal callbacks and one-spice shipment/reveal/physical moves complete;
Ixian allowance fulfills after its original counter, while prevention keeps the
move unused. Controlled reserve caps, Hajr/card acquisition, physical placement
and earlier phase are explicit. Four profiles use legal owned reveal guidance.
Fixture feedback corrects a Basic Black Market wait, native preview-marker
assumption, original token shape and real discard priority. Concealed city cases
pass after trying useful city preparation first. No AI tuning, complete-mode/
final-assurance claim or saved-room/D1/server/reset/restart/deployment operation.
**Classic Advanced movement promises — 25 October. Prototyped / Partial.**
Classic Advanced phase5 now accepts structured movement Truthtrance for active
or unused waiting seats. Shared typed legality counts each elite once; owned
@@ -6997,7 +7023,7 @@ These links define bounded working behavior, not complete module certification.
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md) and [worm rides](SANDMASTER_WORM.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md) and [bounded retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation). Remaining bands and combinations are explicit in those contracts. |
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Orgiz transfers, Jacurutu income, Testing Station and Shrine. Orgiz's per-deposit interpretation, contested/shared benefits and mixed physical allocation remain provisional or pending. The Orgiz path in current source is not yet deployed. |
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Secret Ally trade and bounded after-victory inspection](NEXUS_CHOAM_SECRET_ALLY.md), [Moritani Secret Ally losing-card retention](NEXUS_MORITANI_RULES.md#secret-ally-losing-card-retention-prototype), [Ecaz private yes/no inquiry and bounded native Duke Cunning](NEXUS_ECAZ_RULES.md#bounded-native-cunning-duke-vidal), [Emperor extra revival and bounded bank-auction purchase](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected. Uniform CHOAM winner/Moritani loser windows, Ecaz inquiry scope/audience and Ecaz Cunning's Battle-only window are product inferences, not publisher rulings. Source audits or helpers alone do not certify full module games. |
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), [base Basic/Advanced reserve-shipment promises, including Guild](TRUTHTRANCE_SHIPMENT_PROMISES.md), and [Basic ground-movement promises](TRUTHTRANCE_MOVEMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), [base Basic/Advanced reserve-shipment promises, including Guild](TRUTHTRANCE_SHIPMENT_PROMISES.md), and [Basic and native Advanced movement promises](TRUTHTRANCE_MOVEMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play; [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) and [Reinforcements](REINFORCEMENTS_RUNTIME.md) connect bounded battle effects in the same independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. |
| Multiplayer | [Public and private discussion](TABLE_DISCUSSION.md), saved rooms/seats, [recovery](../README.md#saved-seats-and-reconnecting), uncertain-request retry, [own-seat AI](AUTOPILOT.md), [named-player AI permission](SEAT_AI_PERMISSION.md) and [voluntary seat handover](SEAT_HANDOVER.md) have connected controls and recovery evidence. |
+39
View File
@@ -1,5 +1,44 @@
# Dune implementation status
## 25 October 2026 — Native Advanced and owned No-Field movement preparation
Advanced phase5 now reuses the declared native-family admission. Richese's
owned No-Field shipment, reveal, marker-only positioning and physical-plus-marker
movement enter the actual route witness. Physical minima exclude the marker;
reveal is reserve capped and preserves native history/Ornithopter custody.
Exact quiet allowed-commit preflight protects a new live shipment before its
original counter. Prevention retains payment/pieces and can release impossible
Yes; an optional own reveal cannot evade a commitment. Memo retains original
No-Field/event and Ixian cancellation. City preparation is ordered early without
removing candidates. Human guidance names each preparation; minimal AI reuses it.
Disjoint native fixtures, bounded source review and root integration pass
**24-file163/163** related cases. Three fixture lint findings are repaired in
exactly two test/fixture files: numeric sort comparator and typed location.
All application/other files remain unchanged; final **3-file15/15** consumers,
types/lint/build and six native original-control/GameTable SSR journeys pass.
Genuine Richese Basic24/Advanced27 sources earn actual35+10 setup/deal, bidding
and paid marker. Ixian43 earns native47 cards/HMS, original one-spice Karama
purchase and actual four-spice two-Cyborg shipment. Real reveal callbacks,
one-spice shipment/reveal/physical fulfillment and original Ixian range outcomes
pass. Controlled reserve/Tanks, physical/card and earlier-clock boundaries remain
explicit. Fixture repairs preserve Basic's absent Black Market, expansion start
markers, string token identity and original Semuta discard priority.
Private proof/HTML:
`/home/quintine/.local/share/dune/checkpoints/20261025-truth-movement-native-no-field/`.
`frozen-integrated/report.json` retains the initial related pass/lint failure;
`fixture-lint-repair.json` binds the two repairs; final
`frozen-fixture-lint-repair/report.json` binds tree
`6963f2074dbcc48f56447963f4e6064d4344ebbd28abcea6dfc67429aab41609`,
2778files on base `2ae2b984c05c918c7df34be7c6234524c52f1c65`.
Final four prose/reference overlays are reviewed separately. Existing printed
No-Field and general current-turn sources compose this software predicate; no
new publisher FAQ is claimed. Earlier Advanced, modules, arbitrary commitments,
full modes and final assurance/browser/deployed acceptance remain open. No AI
refinement or saved-room/D1/server/reset/restart/deployment operation.
[Canonical contract](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-advanced-and-no-field-preparation--25-october-2026).
## 25 October 2026 — Classic Advanced structured ground-movement Truthtrance
Classic Advanced without modules now accepts structured movement questions in
+10
View File
@@ -2,6 +2,16 @@
Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode.
## Owned movement-promise preparation — 25 October 2026
The [movement contract](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-advanced-and-no-field-preparation--25-october-2026)
now includes native owned shipment, reserve-capped voluntary reveal and concealed
marker positioning/carrying as preparations. The marker contributes zero to the
promised physical quantity. Original token identity, no-repeat history, tariff,
Karama and movement allowances remain authoritative; an optional reveal cannot
erase the last route. Basic and declared native Advanced phase5 are supported;
optional modules and complete-mode acceptance remain separate.
## Basic native coalition composition — 24 October 2026
Bare Basic Ecaz/native E1/E2 tables now keep allied marker presence and the
+13
View File
@@ -1,5 +1,18 @@
# Rule decision index
## Native Advanced and No-Field movement preparation — 25 October 2026
Reuse the existing physical-movement/current-turn composition and printed
No-Field lifecycle. Admit declared Advanced native families during phase5,
retaining module/earlier-phase boundaries. Owned shipment/reveal and concealed
movement may prepare a route, while only physical counters fulfill its minimum.
Materialization is reserve capped and preserves native token history/counters.
A detached exact allowed commitment checks a new declaration before its real
counter; prevention retains payment/pieces and may release impossible Yes.
Own optional revelation cannot evade a promise. This is software composition,
not a new No-Field-specific publisher FAQ or full-mode certification.
[Contract and labelled native/controlled evidence](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-advanced-and-no-field-preparation--25-october-2026).
## Classic Advanced ground-movement promises — 25 October 2026
Extend the existing general current-turn Truthtrance composition to clean
+62
View File
@@ -1,5 +1,67 @@
# Truthtrance ground-movement promises
## Native Advanced and No-Field preparation — 25 October 2026
**Prototyped / Partial.** Advanced phase5 now accepts declared native expansion
families through the same roster checks as Basic. Optional modules and earlier
Advanced forecasts remain separate. This supersedes the preceding classic-only
admission below, not complete faction/mode acceptance.
Richese's owned route can use its original No-Field shipment, voluntary reveal,
marker-only positioning or a physical group carrying the marker. The native
one-force tariff, exact event/token, last-used restriction, funding and original
Karama responses remain. Materialization uses only actual remaining reserves;
an owned Ghola may prepare those reserves through its existing physical path.
The marker itself contributes zero toward a movement promise. Reveal and shipment
prepare but do not fulfill it; only actual physical movement does. A concealed
zero can still supply city presence/range for a later physical Hajr move.
Reuse the [printed lifecycle](RICHESE_NO_FIELD_ENGINE_AUDIT.md#source-boundary-and-model-contract)
and existing general current-turn composition below; no new publisher ruling is
claimed. The witness includes reserve-capped reveal potential before pruning
marker-only armies, keeps original marker/event and Ixian cancellation in its
memo, and tries city preparation early without dropping other legal candidates.
Before saving a live No-Field declaration, check its allowed exact commitment
on a detached copy. The real original counter still decides the shipment;
prevention can release an impossible Yes without charging or deploying it.
An optional own reveal, including in earlier Basic phases, cannot erase the last
required route and then claim opposing impossibility.
The [Richese source](../tests/fixture-truth-movement-no-field.ts) earns public
Basic/Advanced main35/cache10, original dealt Truthtrance, actual native bidding
and paid concealed Basin shipment without installed played state. Its zero/3/5
choices use original custody. The [Ixian source](../tests/fixture-truth-movement-ixian.ts)
earns actual Advanced setup/HMS, original Truthtrance, Emperor's paid Karama and
a paid two-Cyborg shipment before its original range counter. Controlled reserve
caps, physical placements, Hajr/Karama acquisition and an earlier phase boundary
are explicitly labelled in [separate regressions](../tests/truth-movement-no-field-engine.test.ts).
They are not genuine full-game traces. Human controls give distinct persistent
shipment/reveal/marker/mixed-group guidance; existing minimal policies follow
the same owned route. No strategy/calibration or complete-mode claim is added.
The24-file163/163 related union passes. Fixture-only lint repairs in two files
then pass all3-file15/15 affected consumers, types/lint/build and six native
original-control/GameTable SSR journeys. An exact hash comparison retains all
application/other files across repairs. Basic24/Advanced27 Richese and Ixian43
source prefixes earn original cards, payments and typed pieces. Real reveal
callbacks and one-spice shipment/reveal/movement complete. Four existing profiles
follow legal owned reveal guidance. Controlled cap/zero/mixed/Hajr/counter/early
boundaries retain their staging labels. Trying city preparation earlier makes
the concealed-positioning cases finite promptly without pruning legal choices.
Private proof/HTML:
`/home/quintine/.local/share/dune/checkpoints/20261025-truth-movement-native-no-field/`.
`frozen-integrated/report.json` keeps initial related success and fixture lint
failure; `fixture-lint-repair.json` records exactly two changed test/fixture paths.
`frozen-fixture-lint-repair/report.json` binds tree
`6963f2074dbcc48f56447963f4e6064d4344ebbd28abcea6dfc67429aab41609`,
2778files on base `2ae2b984c05c918c7df34be7c6234524c52f1c65`.
Final prose/reference overlays are separately reviewed. These callbacks/SSR are
not hydration, browser, mobile or deployed acceptance.
Earlier Advanced, modules, arbitrary commitments and final assurance/deployed
acceptance remain unfinished. No saved-room, database, server restart or
deployment operation is part of this work.
## Classic Advanced Movement — 25 October 2026
**Prototyped / Partial.** Classic Advanced tables without expansions or optional
+20 -4
View File
@@ -16745,6 +16745,12 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
}
if (player.faction === 'richese' && player.noField?.deployed && !noFieldRevealBlock(state, player)) {
const action: Action = { type: 'revealNoField', token: player.noField.deployed.tokenId, event: player.noFieldEvent };
try {
yield { game: groundMovementProbe(applyActionInner(state, owner, action, 'movementPreparation'), owner), actions: [action] };
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
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) },
@@ -16790,7 +16796,10 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
// 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 &&
const marker = player.faction === 'richese' ? player.noField?.deployed : null;
const sources = [...Object.keys(player.forces).filter(key => player.forces[key] > 0),
...(marker ? [location(marker.location.territory, marker.location.sector)] : [])];
return sources.some(key => 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) {
@@ -16881,13 +16890,14 @@ function reconcileGroundMovementPromises(g: Game, actor?: string, action?: Actio
if (!live.some(promise => promise.answer)) continue;
if (g.pendingFremenMove?.order.player === p.id || g.pendingIxMove?.player === p.id ||
g.pendingChoamMove?.player === p.id ||
g.pendingShipment?.player === p.id && !!g.pendingShipment.noField ||
g.pendingAdvisorMatching?.intent.kind === 'movement' && g.pendingAdvisorMatching.intent.order.player === p.id ||
g.response?.kind === 'advisorFlip' && g.response.owner === p.id ||
g.pendingAutomaticAdvisorFlips?.some(source => source.owner === p.id ||
source.producer === 'groundMovement' && source.mover === p.id)) continue;
const early = g.phase < 5 || sourcePhase < 5;
const voluntary = actor === p.id && action?.type !== 'passResponse' && (!early || !!action &&
(['card', 'bribe', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) ||
(['card', 'bribe', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) ||
action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 ||
action.type === 'ready' && [2, 4].includes(sourcePhase)));
const busy = !!(g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
@@ -30755,8 +30765,9 @@ function offerShipment(g: Game, shipment: PendingShipment, matchingApproved = fa
} else if (!openGuildRate(g, shipment)) commitShipment(g, shipment);
}
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 (movementPreparation) requireRule(recoverableUnsupportedShipment(shipment) ||
shipment.noField && !shipment.richesePair && recoverableUnsupportedShipment({ ...shipment, noField: undefined }),
'Movement preparation requires an ordinary physical or owned No-Field shipment.');
if (shipment.source === 'ambassador') {
commitAmbassadorShipment(g, shipment);
return;
@@ -36705,6 +36716,11 @@ function applyActionInner(
// unsupported arrival combinations before saving its private intent.
validateShipmentArrival(g, shipment);
checkShipmentIncomeFunding(g, p, cost, allyPayment);
if (liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
const afterShipment = structuredClone(g);
commitShipment(afterShipment, shipment, undefined, true);
reconcileGroundMovementPromises(afterShipment, p.id, action, g.phase);
}
if (richesePair) recordRichesePairShipment(g, shipment);
g.pendingShipment = shipment;
g.response = { kind: 'richeseNoField', owner: p.id, passed: [],
+18 -9
View File
@@ -49,10 +49,15 @@ export function findMovementCompletion(source: Game, actor: string,
// or Box reuse. Tanks are only an optimistic bound; the adapter still
// validates every owned card and never inspects unknown Box faces here.
const recovery = player.hand.some(card => card.effect === 'ghola' || card.effect === 'nullentropyBox') ? player.tanks : 0;
if (live.some(promise => promise.answer && promise.minimum > physical + (player.shipped ? 0 : player.reserves + recovery))) return null;
const marker = player.faction === 'richese' ? player.noField?.deployed : null;
const markerValue = marker ? player.noField!.tokens.find(token => token.id === marker.tokenId)?.value ?? 0 : 0;
const revealable = Math.min(markerValue, player.reserves + recovery);
if (live.some(promise => promise.answer && promise.minimum > physical +
(player.shipped ? revealable : player.reserves + recovery))) return null;
const key = JSON.stringify([player.forces, player.elites, player.advisors, player.moved, player.shipped, player.reserves, player.tanks, player.hand.map(card => card.id),
player.hajrPlays, player.hajrWindow, player.ornithopterExtraMoves, player.spice, game.choamMovement,
player.fremenMovementBlocked, player.advisorFlipMovementBlocked, game.advisorMatchingDenied,
player.fremenMovementBlocked, player.ixMovementBlocked, player.advisorFlipMovementBlocked, game.advisorMatchingDenied,
player.noField, player.noFieldEvent,
game.advisorPreparation,
game.aid, game.karamaShipping, game.guildRateBlocked, game.shipmentPromises,
game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy,
@@ -61,11 +66,12 @@ export function findMovementCompletion(source: Game, actor: string,
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 (needsMove && !live.some(promise => promise.answer && promise.minimum > physical) &&
Number(live.some(p => p.answer && p.territory === a.id)) ||
(marker ? Number(['arrakeen', 'carthag'].includes(b.id)) - Number(['arrakeen', 'carthag'].includes(a.id)) : 0));
if (needsMove && (marker || !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 origins = [...new Set([...Object.keys(player.forces).filter(key => player.forces[key] > 0)
.map(key => splitLocation(key).territory), ...(marker ? [marker.location.territory] : [])])];
const card = !game.ornithopter ? player.hand.find(card => card.effect === 'ornithopter') : null;
const modes: Partial<Action>[] = game.ornithopter
? [{ ornithopterEvent: game.ornithopter.event }]
@@ -82,15 +88,18 @@ export function findMovementCompletion(source: Game, actor: string,
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);
const carriesMarker = marker?.location.territory === origin && !!player.noFieldEvent;
const amounts = [...(carriesMarker ? [0] : []), ...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;
if (!mayMoveAgain && live.some(promise => promise.answer && !movementMatches(promise, event))) continue;
for (const group of groups(player, keys, amount)) for (const mode of modes) for (const stance of stances) {
const action = { ...group, ...mode, ...stance, territory: destination.id, sector };
const markers: Partial<Action>[] = carriesMarker
? [...(amount ? [{}] : []), { noField: marker!.tokenId, event: player.noFieldEvent }] : [{}];
for (const group of groups(player, keys, amount)) for (const mode of modes) for (const stance of stances) for (const selected of markers) {
const action = { ...group, ...mode, ...stance, ...selected, territory: destination.id, sector };
const next = adapter.project(game, actor, action);
if (!next) continue;
const settled = settleMovementEvent(pending, actor, game.turn, next.event);
+3 -4
View File
@@ -84,11 +84,10 @@ export function finishMovementPromises<T extends MovementPromise>(
/** Prototype profile/timing only. Actual route feasibility stays authoritative
* in the engine. Truthtrance and an ordinary active Ornithopter may remain. */
export function movementPromiseModeSupported(g: Game): boolean {
// Basic retains its native-family/early-phase prototype. Advanced starts
// with classic factions during Movement; earlier forecasts and expansion
// preparations are separate scope, not implied by this admission.
// Basic retains early-phase admission. Advanced native families use the same
// declared roster during Movement; earlier forecasts/modules stay separate.
return (g.advanced === false ||
g.advanced === true && g.phase === 5 && g.expansions.length === 0) &&
g.advanced === true && g.phase === 5) &&
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 &&
+12 -1
View File
@@ -21,7 +21,17 @@ export function* movementShipmentPreparations(game: Game, actor: string,
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 (const destination of destinations) for (const sector of destination.sectors) {
if (player.faction === 'richese' && player.noField && !player.noField.deployed && player.noFieldEvent) {
for (const token of player.noField.tokens.filter(token => token.id !== player.noField!.lastShipped)) {
for (let allyPayment = Math.min(2, game.aid[player.ally ?? '']?.amount ?? 0); allyPayment >= 0; allyPayment--) {
const action: Action = { type: 'ship', territory: destination.id, sector, amount: 1, elite: 0,
allyPayment, noField: token.id, event: player.noFieldEvent };
const next = project(game, actor, action);
if (next) yield { game: next, actions: [action] };
}
}
}
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++) {
@@ -36,4 +46,5 @@ export function* movementShipmentPreparations(game: Game, actor: string,
}
}
}
}
}
+8 -7
View File
@@ -3003,9 +3003,9 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Bind one future qualifying physical ground movement before a supported Basic or classic Advanced respondent finishes their current-round turn.',
summary: 'Bind one future qualifying physical ground movement before a supported Basic or Advanced respondent with declared native families finishes their current-round turn.',
steps: [
'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Basic with declared native families supports clean phases0–4 before Movement. During Movement ask an active player before or after shipment, or a still-unused waiting respondent. Finish pending decisions first. Classic Advanced without modules also supports its current Movement phase. Advanced earlier phases/expansions, optional modules, later rounds and wider owned preparations remain unfinished.',
'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Basic with declared native families supports clean phases0–4 before Movement. During Movement ask an active player before or after shipment, or a still-unused waiting respondent. Finish pending decisions first. Advanced with declared native families and no modules supports its current Movement phase. Earlier Advanced phases, optional modules, later rounds and wider owned preparations 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.',
@@ -3015,16 +3015,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'Ghola returns up to five eligible forces to reserves using the physical card and current typed allowance, without using normal revival slots. It does not fulfill ground movement: an unused affordable shipment and later matching move remain necessary. A known spent Ghola can be recovered and replayed through Box. Possible Tleilaxu income remains conditional and supplies no forecast cash.',
'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.',
'Classic Advanced counts each physical elite once and retains Hajr’s immediate post-movement timing. Owned native advisor preparation and an eligible arrival fighter flip may prepare city range; the original Karama response still decides the real advantage. Matching/Fremen/conversion counters keep Yes pending until transfer or completed impossibility. A mandatory response pass cannot evade the obligation or deadlock release.',
'Native Advanced counts each physical elite once and retains Hajr’s immediate post-movement timing. Owned native advisor preparation and an eligible arrival fighter flip may prepare city range; the original Karama response still decides the real advantage. Matching/Fremen/conversion counters keep Yes pending until transfer or completed impossibility. A mandatory response pass cannot evade the obligation or deadlock release.',
'Richese may prepare the route with its actual one-force-price No-Field shipment, voluntary reserve-capped reveal, marker-only city positioning or a physical group carrying the token. Neither shipment nor reveal fulfills movement, and the concealed marker adds zero to the promised minimum. Native Karama, exact token events, no-repeat history and Hajr timing remain. Own optional revelation cannot evade the last completion; a completed opposing prevention may release it.',
'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: 'Classic Advanced phase5 now reuses the typed route witness, immediate Hajr timing and owned advisor preparation/arrival flips with real counters. Basic declared native families join classic phases0–4. Exact Richese own/donor invoices, full submitted-ally Silent locks and already-paid boundaries prepare the owned turn without speculative bought cards/income. Fixed non-CHOAM preopening Charity and shared ordinary force-revival accounting preserve real native counter decisions. Mandatory Ix phase5 opening retains the future turn/reset. Held Ghola and known/paid Box recovery now add exact force returns and later shipment; normal revival allowances stay separate and native income is not precredited. CHOAM own unresolved opening, later rounds, wider preparations/modules remain unfinished.', evidence: ['game/ghola-force-preparation.ts', 'game/early-auction-funding.ts', '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: 'Classic Advanced questions and persistent guidance now connect an actual legal movement callback; native advisor preparation has a named action. Physical Ghola force-return guidance now precedes actual reserve shipment and ground movement, with income/disposal controls retaining priority. Persistent busy-safe guidance includes current Charity, Revival, Ready, ordinary bid-pass/payment and event-bearing native Once Around pass/Silent zero. Foreign pending funding cannot force a speculative positive bid or expose future movement. Actual native pass/zero handlers preserve unread obligations. Existing waiting/card controls remain; browser/visual acceptance stays open.', evidence: ['tests/truth-movement-advanced-controls.test.tsx', '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', 'tests/truth-movement-early-controls.test.tsx', 'tests/truth-movement-native-early-controls.test.tsx', 'tests/truth-movement-ghola-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles finish a genuine classic Advanced movement question legally without strategy changes. Eight earlier existing-profile held-Ghola/known-Box runs accept40 proposals plus24 separate native continuations. Physical return, exact paid recovery, unused shipment and qualifying ground move use the same private legal guidance. Earlier native funding paths remain. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-ghola-engine.test.ts', 'tests/truth-movement-native-early-engine.test.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Declared native Advanced phase5 now reuses the typed route witness, immediate Hajr timing and owned advisor preparation/arrival flips with real counters. Owned No-Field shipment/reveal/marker movement uses native events, reserve caps and physical-only fulfillment. Basic declared native families join classic phases0–4. Exact Richese own/donor invoices, full submitted-ally Silent locks and already-paid boundaries prepare the owned turn without speculative bought cards/income. Fixed non-CHOAM preopening Charity and shared ordinary force-revival accounting preserve real native counter decisions. Mandatory Ix phase5 opening retains the future turn/reset. Held Ghola and known/paid Box recovery now add exact force returns and later shipment; normal revival allowances stay separate and native income is not precredited. CHOAM own unresolved opening, later rounds, wider preparations/modules remain unfinished.', evidence: ['game/ghola-force-preparation.ts', 'game/early-auction-funding.ts', '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: 'Native No-Field preparation now has distinct persistent reveal/shipment/marker/mixed labels and actual reveal callbacks. Classic Advanced questions and persistent guidance now connect an actual legal movement callback; native advisor preparation has a named action. Physical Ghola force-return guidance now precedes actual reserve shipment and ground movement, with income/disposal controls retaining priority. Persistent busy-safe guidance includes current Charity, Revival, Ready, ordinary bid-pass/payment and event-bearing native Once Around pass/Silent zero. Foreign pending funding cannot force a speculative positive bid or expose future movement. Actual native pass/zero handlers preserve unread obligations. Existing waiting/card controls remain; browser/visual acceptance stays open.', evidence: ['tests/truth-movement-advanced-controls.test.tsx', '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', 'tests/truth-movement-early-controls.test.tsx', 'tests/truth-movement-native-early-controls.test.tsx', 'tests/truth-movement-ghola-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles use actual native Advanced Richese reveal/physical movement legally. All four existing profiles finish a genuine classic Advanced movement question legally without strategy changes. Eight earlier existing-profile held-Ghola/known-Box runs accept40 proposals plus24 separate native continuations. Physical return, exact paid recovery, unused shipment and qualifying ground move use the same private legal guidance. Earlier native funding paths remain. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-ghola-engine.test.ts', 'tests/truth-movement-native-early-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: '25 October classic Advanced:26 files238/238, types/lint/build and four actual-control/GameTable SSR journeys, including a real guidance callback, pass. Genuine23/27-action Atreides/Fremen sources earn original setup/cards, paid/free shipment and actual Karama auction. BG acquisition/stance/reserve overlays are explicitly controlled. Hajr, typed counter outcomes, preparation/matching and city-range arrival flips pass. Earlier22-file182/182 Ghola evidence follows. Original held/Box prefixes42/104/75/151 use native setup, actual cache purchase, paid desert shipments, Storm casualties and settled bribes without Advanced city income. Eight minimal-profile runs accept40 proposals plus24 separate native continuations. Controlled acquisitions/losses/knowledge and counter-enabled income disclose staging. Ghola discard relevance and a fixture field collision are repaired before final frozen proof. Earlier counts remain historical. Full modes, wider preparations, browser/deployed and comprehensive assurance remain open.', evidence: ['tests/truth-movement-advanced-engine.test.ts', 'tests/truth-movement-advanced-controls.test.tsx', 'tests/fixture-truth-movement-advanced.ts', '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', 'tests/truth-movement-early-engine.test.ts', 'tests/truth-movement-early-controls.test.tsx', 'tests/fixture-truth-movement-early.ts', 'tests/early-auction-funding.test.ts', 'tests/truth-movement-native-early-engine.test.ts', 'tests/truth-movement-native-early-controls.test.tsx', 'tests/fixture-truth-movement-native-early.ts', 'tests/fixture-truth-movement-ghola.ts', 'tests/truth-movement-ghola-engine.test.ts', 'tests/truth-movement-ghola-controls.test.tsx', 'tests/movement-discard-knowledge.test.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
{ area: 'Verification', status: 'Partial', detail: '25 October native No-Field/Ixian:24 files163/163 pass; two fixture-only lint repairs then pass3 files15/15 consumers, types/lint/build and six native control/GameTable SSR journeys. Hashes retain all application/other files. Original Richese Basic24/Advanced27 and Ixian43 source actions earn cards, payments and typed groups. Controlled cap/card/force/earlier-clock boundaries stay labelled. Earlier classic Advanced:26 files238/238, types/lint/build and four actual-control/GameTable SSR journeys, including a real guidance callback, pass. Genuine23/27-action Atreides/Fremen sources earn original setup/cards, paid/free shipment and actual Karama auction. BG acquisition/stance/reserve overlays are explicitly controlled. Hajr, typed counter outcomes, preparation/matching and city-range arrival flips pass. Earlier22-file182/182 Ghola evidence follows. Original held/Box prefixes42/104/75/151 use native setup, actual cache purchase, paid desert shipments, Storm casualties and settled bribes without Advanced city income. Eight minimal-profile runs accept40 proposals plus24 separate native continuations. Controlled acquisitions/losses/knowledge and counter-enabled income disclose staging. Ghola discard relevance and a fixture field collision are repaired before final frozen proof. Earlier counts remain historical. Full modes, wider preparations, browser/deployed and comprehensive assurance remain open.', evidence: ['tests/truth-movement-no-field-engine.test.ts', 'tests/truth-movement-no-field-controls.test.tsx', 'tests/fixture-truth-movement-no-field.ts', 'tests/truth-movement-ixian-engine.test.ts', 'tests/fixture-truth-movement-ixian.ts', 'tests/truth-movement-advanced-engine.test.ts', 'tests/truth-movement-advanced-controls.test.tsx', 'tests/fixture-truth-movement-advanced.ts', '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', 'tests/truth-movement-early-engine.test.ts', 'tests/truth-movement-early-controls.test.tsx', 'tests/fixture-truth-movement-early.ts', 'tests/early-auction-funding.test.ts', 'tests/truth-movement-native-early-engine.test.ts', 'tests/truth-movement-native-early-controls.test.tsx', 'tests/fixture-truth-movement-native-early.ts', 'tests/fixture-truth-movement-ghola.ts', 'tests/truth-movement-ghola-engine.test.ts', 'tests/truth-movement-ghola-controls.test.tsx', 'tests/movement-discard-knowledge.test.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
],
},
{
+1 -1
View File
@@ -209,7 +209,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 need a supported Basic opportunity this round or a classic Advanced Movement turn, with pending decisions finished. Optional modules and Advanced expansion preparations remain unfinished.');
'Ground-movement promises need a supported Basic opportunity this round or an Advanced Movement turn with declared native families and pending decisions finished. Optional modules and earlier Advanced forecasts remain unfinished.');
try { return { kind: 'movement', target: v.target as string, ...parseMovementClaim(v) }; }
catch (error) { if (error instanceof MovementClaimError) throw new TruthError(error.message); throw error; }
}
+164
View File
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { location, MOBILE_LOCATION, TERRITORIES } from '../game/board';
import { botGroundMoveAllowed, botMovementRange, groundMoveRouteDistance } from '../game/bot-mobility';
import {
actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer,
type TruthMovementStep,
} from './fixture-truth-movement';
import {
askTruthMovementAdvanced, nextTruthMovementAdvancedNativeStep,
type TruthMovementAdvancedTransition,
} from './fixture-truth-movement-advanced';
export const actTruthMovementIxian = actTruthMovement;
export const truthMovementIxianClean = truthMovementClean;
export const truthMovementIxianInventory = truthMovementInventory;
export const truthMovementIxianPlayer = (game: Game, actor = 'ixians') => truthMovementPlayer(game, actor);
export { askTruthMovementAdvanced as askTruthMovementIxian };
export type TruthMovementIxianStep = TruthMovementStep;
/** Actual native choices only. Keep the original Karama in the inspected auction
* pool by returning a different original card; no auction card is installed. */
export function nextTruthMovementIxianNativeStep(game: Game): TruthMovementStep | null {
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard &&
game.decision?.kind === 'ixAuction') {
const card = game.ixAuction?.cards.find(card => card.effect !== 'karama');
assert.ok(card, 'The original Ixian auction pool has a non-Karama card to return');
return { actor: game.decision.player, action: { type: 'decision', card: card.id, position: 'bottom' } };
}
return nextTruthMovementAdvancedNativeStep(game);
}
export function advanceTruthMovementIxian(game: Game, until: (state: Game) => boolean,
actions: TruthMovementStep[] = []): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Ixian movement source ended before its requested control');
const next = nextTruthMovementIxianNativeStep(game);
assert.ok(next, `Supply the real Truthtrance or Ixian ground movement at ${game.turn}/${game.phase}/${game.truthtrance?.stage ?? game.decision?.kind}`);
game = actTruthMovement(game, next.actor, next.action, actions);
}
throw Error('The original Ixian Truthtrance source did not reach its finite boundary');
}
/** Public Advanced preview, declared Ix expansion, and original Ixian forces.
* Only unopened deal/Spice positions and native scalar lotteries are selected.
* Actual setup, auction and shipment earn every held card and selected Cyborg. */
export function createTruthMovementIxianFixture() {
const target = 'ixians', asker = 'emperor';
let game = createGame('TRUTHMOVEMENTIXIAN', newPlayer(target, 'Ixians', 'ixians'), false, ['ix']);
joinGame(game, newPlayer(asker, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthMovementStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthMovement(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthMovementIxian(game, until, actions); };
const transition = (run: () => void): TruthMovementAdvancedTransition => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const rules = transition(() => { act(game.host, { type: 'rules', advanced: true }); });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
assert.deepEqual(actions.at(-1)?.action, { type: 'start', advancedPreview: true });
assert.deepEqual(game.expansions, ['ix']);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalCards = truthMovementInventory(game); assert.equal(originalCards.length, 47);
const targetCard = game.deck.find(card => card.kind === 'projectile' && !card.effect);
const truthtrance = game.deck.find(card => card.effect === 'truthtrance');
const karama = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
assert.ok(targetCard && truthtrance && karama);
const deal = [targetCard.id, truthtrance.id];
const dealOrder = [...deal, karama.id].map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
let ixianSetup: TruthMovementAdvancedTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextTruthMovementIxianNativeStep(game); assert.ok(next);
const before = game.decision?.kind === 'ixSetup' ? structuredClone(game) : null;
act(next.actor, next.action);
if (before) ixianSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) };
}
assert.equal(game.status, 'playing'); assert.ok(ixianSetup);
const afterSetup = structuredClone(game), originalIxians = truthMovementPlayer(game, target);
assert.deepEqual(originalIxians.hand.map(card => card.id), [targetCard.id]);
assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [truthtrance.id]);
assert.deepEqual(originalIxians.forces, { [MOBILE_LOCATION]: 6 });
assert.equal(originalIxians.elites?.forces[MOBILE_LOCATION], 3);
assert.equal(originalIxians.elites?.reserves, 4); assert.equal(originalIxians.reserves, 14);
const startingPositions = Object.fromEntries(game.players.map(player => [player.id, {
forces: structuredClone(player.forces), elites: structuredClone(player.elites),
reserves: player.reserves, spice: player.spice,
}]));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.turn === 1 && state.phase === 3 && truthMovementClean(state) &&
state.auction?.active === asker && state.auction.cards[state.auction.index]?.id === karama.id);
const karamaAcquisition = transition(() => {
act(asker, { type: 'bid', amount: 1, allyPayment: 0 });
reach(state => truthMovementClean(state) && truthMovementPlayer(state, asker).hand.some(card => card.id === karama.id));
});
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthMovementClean(state));
const beforeShipment = structuredClone(game);
assert.equal(truthMovementPlayer(game, target).shipped, false);
const shipmentStep: TruthMovementStep = { actor: target, action: {
type: 'ship', amount: 2, territory: 'polar_sink', sector: 0, elite: 2, allyPayment: 0,
} };
const shipment = transition(() => { act(shipmentStep.actor, shipmentStep.action); reach(truthMovementClean); });
const player = truthMovementPlayer(game, target), shipmentCost = truthMovementPlayer(beforeShipment, target).spice - player.spice;
assert.equal(game.turn, 1); assert.equal(game.phase, 5); assert.equal(game.active, target);
assert.equal(player.shipped, true); assert.equal(player.moved, 0);
assert.equal(player.forces['polar_sink:0'], 2); assert.equal(player.elites?.forces['polar_sink:0'], 2);
assert.equal(player.reserves, 12); assert.equal(player.elites?.reserves, 2); assert.equal(shipmentCost, 4);
assert.equal(player.forces[MOBILE_LOCATION], 6); assert.equal(player.elites?.forces[MOBILE_LOCATION], 3);
assert.equal(game.mobileStronghold?.location, 'polar_sink:0');
assert.ok(!Object.entries(player.forces).some(([key, amount]) => amount > 0 && /^(arrakeen|carthag):/.test(key)));
assert.ok(player.hand.some(card => card.id === targetCard.id));
for (const card of [truthtrance, karama]) assert.ok(truthMovementPlayer(game, asker).hand.some(held => held.id === card.id));
assert.deepEqual(truthMovementInventory(game), originalCards);
const view = viewGame(game, target), own = view.players.find(player => player.id === target)!;
assert.equal(botMovementRange(view, own, 2), 2);
const destination = TERRITORIES.filter(territory => territory.type === 'sand')
.flatMap(territory => territory.sectors.map(sector => ({ territory: territory.id, sector })))
.find(to => to.sector !== game.storm && groundMoveRouteDistance(view, own, 'polar_sink:0', location(to.territory, to.sector)) === 2 &&
botGroundMoveAllowed(view, own, 'polar_sink:0', location(to.territory, to.sector), 2));
assert.ok(destination, 'The two original shipped Cyborgs have a legal native two-step route');
const matchingMove: Action = { type: 'move', forces: { 'polar_sink:0': 2 },
eliteForces: { 'polar_sink:0': 2 }, ...destination };
const truthStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truthtrance.id } };
const questionAction: Action = { type: 'truthAsk', question: {
kind: 'movement', target, territory: destination.territory, minimum: 2,
} };
return { game, source: structuredClone(game), initial, rules, lobby, setup, beforeStartingDeal, afterSetup,
ixianSetup, beforeShipment, shipment: { ...shipment, step: shipmentStep }, shipmentStep, shipmentCost,
targetFaction: 'ixians' as const, asker, target, truth: truthtrance.id, truthtrance: truthtrance.id,
targetCard: targetCard.id, hajr: null, karama: karama.id, karamaAcquisition,
truthStep, declaration: truthStep.action, questionAction, matchingMove,
twoStepMove: matchingMove, twoStepDestination: destination.territory, destination,
typedGroup: { source: 'polar_sink:0', normal: 0, elite: 2 },
deal, dealOrder, spiceOrder, originalCards, originalInventory: originalCards, originalSpice, startingPositions,
actions, steps: actions, sourceActionCount: actions.length,
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
staging: [
'Actual public two-seat Ixians/Emperor lobby declares only the Ix expansion, selects Advanced through host rules, readies both seats and submits start advancedPreview:true. No optional module or development initializer is used.',
'Select only original scalar lotteries, three unopened Treachery positions and two unopened ordinary land Spice positions. Ixians actually choose the original ordinary projectile from their starting inspection; Emperor receives the remaining original Truthtrance. The original 47-card Ix deck is conserved.',
'Actual first Storm places the original Hidden Mobile Stronghold at Polar Sink with its untouched six native counters including three Cyborgs. The actual Ixian pre-auction inspection returns a non-Karama card to the bottom; Emperor buys the original Karama through an actual one-spice winning bid and native invoice/payment/income.',
'Both Advanced Spice blows, Charity, Bidding and Revival precede an actual four-spice two-Cyborg reserve shipment to Polar Sink. Original HMS counters remain there and the selected board group has no city ornithopter range. The returned move selects both physical Cyborgs explicitly with forces and eliteForces and has an actual legal distance-two route.',
'Stop at clean turn-one Shipment and Movement with Ixians active, shipment spent and moved zero. Truthtrance declaration/question/answer, the exact ground movement and its native Ixian counter remain caller-owned. No played hand, force, wallet, phase, allowance, promise, response or receipt is installed. This source establishes only this native Advanced expansion chronology.',
] };
}
export { createTruthMovementIxianFixture as createIxianTruthMovementAdvancedFixture };
export type TruthMovementIxianFixture = ReturnType<typeof createTruthMovementIxianFixture>;
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import type { NoFieldValue } from '../game/richese-no-field';
import {
actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer,
type TruthMovementStep,
} from './fixture-truth-movement';
import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
export type TruthMovementNoFieldStep = TruthMovementStep;
export type TruthMovementNoFieldTransition = { before: Game; steps: TruthMovementStep[]; after: Game };
export const actTruthMovementNoField = actTruthMovement;
export const truthMovementNoFieldClean = truthMovementClean;
export const truthMovementNoFieldInventory = truthMovementInventory;
export const truthMovementNoFieldPlayer = (game: Game, actor = 'richese') => truthMovementPlayer(game, actor);
export { actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer };
/** Real cache terms/pass/unbid controls only. The owned concealed revelation,
* Truthtrance and shipped target's actual ground moves remain caller choices. */
export function nextTruthMovementNoFieldNativeStep(game: Game): TruthMovementStep | null {
if (game.truthtrance) return null;
if (game.phase === 5 && game.active === 'richese' && truthMovementPlayer(game, 'richese').shipped && truthMovementClean(game)) return null;
return nextTruthMovementNativeEarlyStep(game, {
position: 'first', method: 'onceAround', direction: 'clockwise', keepUnbid: false,
card: 'richese-stone-burner',
});
}
export function advanceTruthMovementNoField(game: Game, until: (state: Game) => boolean,
actions: TruthMovementStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original No-Field movement source ended before its requested control');
const next = nextTruthMovementNoFieldNativeStep(game);
assert.ok(next, `Supply the original Truthtrance, reveal or movement choice at ${game.turn}/${game.phase}/${game.truthtrance?.stage ?? game.decision?.kind}`);
game = actTruthMovement(game, next.actor, next.action, actions);
}
throw Error('The native No-Field movement source did not reach its finite boundary');
}
/** Public native Basic/Advanced Richese+Emperor, original E2 main35/cache10.
* An original private marker really ships to Basin9 for one-marker price2.
* Stop with the token concealed and no physical board forces materialized. */
export function createTruthMovementNoFieldFixture(options: { advanced?: boolean; value?: NoFieldValue } = {}) {
const advanced = options.advanced ?? false, value = options.value ?? 5;
const target = 'richese', asker = 'emperor', territory = 'imperial_basin', sector = 9, location = 'imperial_basin:9';
let game = createGame('TRUTHMOVEMENTNOFIELD', newPlayer(target, 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer(asker, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthMovementStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthMovement(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthMovementNoField(game, until, actions); };
const transition = (run: () => void): TruthMovementNoFieldTransition => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['choam']);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => !player.hand.length));
const originalMainDeck = game.deck.map(card => card.id), originalCache = game.richeseCache!.map(card => card.id);
assert.equal(originalMainDeck.length, 35); assert.equal(originalCache.length, 10);
const originalCards = truthMovementInventory(game); assert.equal(originalCards.length, 45);
const ordinary = game.deck.find(card => card.kind === 'projectile');
const truthtrance = game.deck.find(card => card.effect === 'truthtrance'); assert.ok(ordinary && truthtrance);
const deal = [ordinary.id, truthtrance.id];
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), originalNoField = structuredClone(truthMovementPlayer(game, target).noField);
assert.ok(originalNoField); assert.deepEqual(originalNoField.tokens.map(token => token.value).sort((a, b) => a - b), [0, 3, 5]);
assert.equal(originalNoField.deployed, null); assert.equal(originalNoField.lastShipped, null);
assert.deepEqual(truthMovementPlayer(game, target).hand.map(card => card.id), [ordinary.id]);
assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [truthtrance.id]);
assert.deepEqual(truthMovementPlayer(game, target).forces, {}); assert.equal(truthMovementPlayer(game, target).reserves, 20);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
// Basic has no Black Market; both modes still enter their actual bidding
// phase and then use the native cache/auction controls below.
reach(state => state.turn === 1 && state.phase === 3);
const bidding = transition(() => {
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthMovementClean(state));
});
const beforeShipment = structuredClone(game), before = truthMovementPlayer(game, target);
const token = before.noField!.tokens.find(token => token.value === value); assert.ok(token);
assert.equal(before.shipped, false); assert.equal(before.moved, 0);
assert.deepEqual(before.hand.map(card => card.id), [ordinary.id]);
const shipmentEvent = before.noFieldEvent; assert.ok(shipmentEvent);
const shipmentStep: TruthMovementStep = { actor: target, action: {
type: 'ship', noField: token.id, event: shipmentEvent, territory, sector, allyPayment: 0,
} };
const shipment = transition(() => { act(shipmentStep.actor, shipmentStep.action); reach(truthMovementClean); });
const player = truthMovementPlayer(game, target), noFieldEvent = player.noFieldEvent; assert.ok(noFieldEvent);
assert.equal(game.phase, 5); assert.equal(game.turn, 1); assert.equal(game.active, target);
assert.equal(player.shipped, true); assert.equal(player.moved, 0);
assert.deepEqual(player.forces, {}); assert.equal(player.reserves, 20); assert.equal(player.tanks, 0);
assert.equal(before.spice - player.spice, 2);
assert.equal(player.noField!.deployed!.tokenId, token.id); assert.equal(player.noField!.deployed!.controller, target);
assert.deepEqual(player.noField!.deployed!.location, { territory, sector });
assert.equal(player.noField!.lastShipped, token.id);
assert.deepEqual(player.hand.map(card => card.id), [ordinary.id]);
assert.ok(truthMovementPlayer(game, asker).hand.some(card => card.id === truthtrance.id));
assert.deepEqual(truthMovementInventory(game), originalCards);
const revealAction: Action = { type: 'revealNoField', event: noFieldEvent, token: token.id };
const markerMove: Action = { type: 'move', forces: {}, noField: token.id, event: noFieldEvent,
territory: 'arrakeen', sector: 10 };
const matchingMove: Action | null = value ? { type: 'move', from: location, amount: value,
territory: 'arrakeen', sector: 10 } : null;
const claim = { kind: 'movement' as const, target, territory: 'arrakeen', minimum: Math.max(1, value) };
const truthStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truthtrance.id } };
const askStep: TruthMovementStep = { actor: asker, action: { type: 'truthAsk', question: claim } };
return { game, source: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup,
bidding, beforeShipment, shipment: { ...shipment, step: shipmentStep }, shipmentStep, shipmentAction: shipmentStep.action,
advanced, target, richese: target, asker, emperor: asker, targetCard: ordinary.id, hajr: null,
truth: truthtrance.id, truthtrance: truthtrance.id, card: truthtrance.id,
territory, sector, location, value, markerValue: value, token: token.id, tokenId: token.id,
event: noFieldEvent, noFieldEvent, shipmentEvent, noField: structuredClone(player.noField),
originalNoField, noFieldState: structuredClone(player.noField),
revealAction, reveal: revealAction, revealStep: { actor: target, action: revealAction } satisfies TruthMovementStep,
markerMove, matchingMove, claim, question: claim, truthStep, declarationStep: truthStep, askStep,
deal, dealOrder, spiceOrder, originalCards, originalInventory: originalCards, originalMainDeck, originalCache, originalSpice,
actions, steps: actions, sourceActionCount: actions.length, shipmentCost: 2,
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
staging: [
`Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Emperor ready/start, selected native E2 main35 plus original cache10 and no optional module. Only original scalar shuffles, two unopened starting-deal positions and two original ordinary Spice positions are chosen.`,
'Richese is actually dealt an ordinary projectile rather than Hajr; Emperor is actually dealt its original Truthtrance. Native private Traitor/setup controls create all three original No-Field identities, values and untouched twenty-force reserve pool; no card, token or hand is installed.',
`Actually complete safe first Storm, native Spice (both blows in Advanced), Charity, ${advanced ? 'real Richese Black Market decline' : 'Basic bidding without a Black Market'}, first Once Around/cache terms, all original ordinary/cache bid passes, unbid cache retirement and Revival. Every original main/cache ID remains in its actual deck/cache/retired/hand/discard slot; there is no manufactured purchase or card acquisition.`,
'During its real unused first turn Richese actually ships the selected original concealed marker to Basin9 for the ordinary one-marker two-spice price. All twenty original physical reserves remain; the marker has not become physical ground forces, and ordinary movement remains unused.',
'Stop before any Truthtrance declaration or voluntary reveal. The returned revealNoField event/token, marker-only move and post-reveal physical move are original caller choices. Zero/three/five variants use the same native setup and actual shipment; no later reserve cap, force, phase, wallet, promise, preparation or receipt is installed.',
] };
}
export type TruthMovementNoFieldFixture = ReturnType<typeof createTruthMovementNoFieldFixture>;
+3 -1
View File
@@ -144,7 +144,7 @@ void test('earlier current-round movement question profiles include declared Bas
}
});
void test('classic Advanced Movement admits active and unused waiting seats while retaining module and native-family boundaries', () => {
void test('Advanced Movement admits active and unused waiting seats with declared native families while retaining module boundaries', () => {
const advanced = movementBoundary(); advanced.advanced = true;
assert.equal(movementPromiseTargetAvailable(advanced, 'target'), true);
assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), true);
@@ -153,6 +153,8 @@ void test('classic Advanced Movement admits active and unused waiting seats whil
assert.equal(movementPromiseTargetAvailable({ ...advanced, [field]: true } as unknown as Game, 'target'), false, field);
}
const native = structuredClone(advanced); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
assert.equal(movementPromiseTargetAvailable(native, 'target'), true);
native.expansions = [];
assert.equal(movementPromiseTargetAvailable(native, 'target'), false);
advanced.players[1].shipped = true;
assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), false);
@@ -25,3 +25,14 @@ void test('spent shipment and paid Box decisions cannot start another preparatio
game.players[0].shipped = false; game.pendingNullentropy = { player: 'owner' } as Game['pendingNullentropy'];
assert.deepEqual([...movementShipmentPreparations(game, 'owner', () => assert.fail('Finish the paid decision'))], []);
});
void test('owned No-Field shipment candidates preserve marker identity, one-force tariff shares and last-use restrictions', () => {
const game = source(), owner = game.players[0]; owner.faction = 'richese';
owner.noField = { tokens: [{ id: 'zero', value: 0 }, { id: 'three', value: 3 }, { id: 'five', value: 5 }],
lastShipped: 'three', deployed: null }; owner.noFieldEvent = 'current';
const choices = [...movementShipmentPreparations(game, owner.id, (state, _actor, action) =>
action.noField && action.territory === 'imperial_basin' && action.sector === 9 && action.allyPayment === 2
? structuredClone(state) : null)].map(choice => choice.actions[0]);
assert.deepEqual(choices.map(choice => choice.noField), ['zero', 'five']);
assert.ok(choices.every(choice => choice.amount === 1 && choice.elite === 0 && choice.event === 'current'));
});
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { createTruthMovementIxianFixture as fixture, actTruthMovementIxian as act,
advanceTruthMovementIxian as advance, truthMovementIxianClean as clean,
truthMovementIxianPlayer as player, truthMovementIxianInventory as inventory,
askTruthMovementIxian as ask } from './fixture-truth-movement-ixian';
for (const cancel of [false, true]) void test(`native Advanced Ixian typed promise ${cancel ? 'retains its unused move after prevention' : 'fulfills only after its original range counter'}`, () => {
const f = fixture();
let g = ask(f.game, f.asker, f.truthtrance, f.target, 2, f.twoStepDestination);
assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes'));
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const before = structuredClone(player(g));
g = act(g, f.target, f.matchingMove);
assert.equal(g.response?.kind, 'ixMovement');
assert.deepEqual(player(g).forces, before.forces);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
if (cancel) {
g = act(g, f.asker, { type: 'card', card: f.karama, mode: 'cancel' });
assert.equal(player(g).moved, 0); assert.deepEqual(player(g).forces, before.forces);
assert.ok(!g.movementPromises![0].fulfilled);
const replacement = viewGame(g, f.target).movementPromisePolicy?.[0];
if (replacement?.type === 'move') {
g = advance(act(g, f.target, replacement), clean);
assert.equal(g.movementPromises![0].fulfilled, true);
} else assert.equal(g.movementPromises![0].released, true);
} else {
g = advance(g, clean); assert.equal(g.movementPromises![0].fulfilled, true);
const key = `${f.destination.territory}:${f.destination.sector}`;
assert.equal(player(g).forces[key], 2); assert.equal(player(g).elites!.forces[key], 2);
}
assert.deepEqual(inventory(g), f.originalCards);
});
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, type ReactElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MovementPromises } from '../components/movement-promises';
import { viewGame, type Action } from '../game/engine';
import { askTruthMovementAdvanced as ask } from './fixture-truth-movement-advanced';
import { createTruthMovementNoFieldFixture as fixture, actTruthMovementNoField as act,
advanceTruthMovementNoField as advance, truthMovementNoFieldClean as clean } from './fixture-truth-movement-no-field';
function callbacks(node: unknown): (() => void)[] {
if (Array.isArray(node)) return node.flatMap(callbacks);
if (!node || typeof node !== 'object' || !('props' in node)) return [];
const props = (node as ReactElement<{ children?: unknown; onClick?: () => void }>).props;
return [...(props.onClick ? [props.onClick] : []), ...callbacks(props.children)];
}
void test('persistent native No-Field guidance submits the actual current reveal then offers physical movement', () => {
const f = fixture({ advanced: true });
let g = ask(f.game, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const view = viewGame(g, f.target), actions: Action[] = [];
const props = { game: view, busy: false, act: (action: Action) => { actions.push(action); } };
const html = renderToStaticMarkup(createElement(MovementPromises, props));
assert.match(html, /Reveal your No-Field to prepare physical forces/);
callbacks(MovementPromises(props))[0](); assert.deepEqual(actions, view.movementPromisePolicy);
g = act(g, f.target, actions[0]); assert.ok(!g.movementPromises![0].fulfilled);
assert.match(renderToStaticMarkup(createElement(MovementPromises,
{ ...props, game: viewGame(g, f.target) })), /Move 5 forces to Arrakeen/);
callbacks(MovementPromises({ ...props, busy: true }))[0](); assert.equal(actions.length, 1);
});
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { askTruthMovementAdvanced as ask } from './fixture-truth-movement-advanced';
import { createTruthMovementNoFieldFixture as fixture, actTruthMovementNoField as act,
advanceTruthMovementNoField as advance, truthMovementNoFieldClean as clean,
truthMovementNoFieldPlayer as player, truthMovementNoFieldInventory as inventory } from './fixture-truth-movement-no-field';
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} No-Field reveal prepares five physical forces without fulfilling movement early`, () => {
const f = fixture({ advanced });
let g = ask(f.game, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['yes', 'no']);
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const old = structuredClone(player(g, f.target)), reveal = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.equal(reveal?.type, 'revealNoField');
g = act(g, f.target, reveal!);
assert.equal(player(g, f.target).reserves, old.reserves - 5);
assert.equal(player(g, f.target).noField?.lastShipped, old.noField?.lastShipped);
assert.equal(player(g, f.target).moved, 0); assert.ok(!g.movementPromises![0].fulfilled);
const move = viewGame(g, f.target).movementPromisePolicy?.[0]; assert.equal(move?.type, 'move');
g = act(g, f.target, move!); assert.equal(g.movementPromises![0].fulfilled, true);
assert.equal(player(g, f.target).forces['arrakeen:10'], 5);
assert.deepEqual(inventory(g), f.originalCards);
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} one-spice native No-Field shipment and reveal supply the promised five-force ground move`, () => {
const f = fixture({ advanced });
let g = act(f.beforeShipment, f.target, { type: 'bribe', target: f.asker,
amount: player(f.beforeShipment, f.target).spice - 1 });
g = ask(g, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes'));
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const shipment = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.equal(shipment?.type, 'ship'); assert.ok(shipment.noField);
g = advance(act(g, f.target, shipment), clean);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
assert.equal(player(g, f.target).spice, 0); assert.equal(player(g, f.target).reserves, 20);
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
assert.ok(!g.movementPromises![0].fulfilled);
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
assert.equal(g.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(g), f.originalCards);
});
void test('native zero marker is presence rather than a promised physical force', () => {
const f = fixture({ value: 0 });
const g = ask(f.game, f.asker, f.truthtrance, f.target, 1, 'arrakeen');
assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['no']);
});
void test('controlled scarce reserves cap owned reveal preparation without changing printed token value', () => {
const f = fixture(), source = structuredClone(f.game), own = player(source, f.target);
// Controlled original reserve-to-Tanks allocation, not a native casualty claim.
own.tanks += own.reserves - 2; own.reserves = 2;
const impossible = ask(source, f.asker, f.truthtrance, f.target, 3, 'arrakeen');
assert.deepEqual(viewGame(impossible, f.target).truthMovementAnswers, ['no']);
let g = ask(source, f.asker, f.truthtrance, f.target, 2, 'arrakeen');
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
assert.equal(player(g, f.target).reserves, 0); assert.equal(player(g, f.target).forces['imperial_basin:9'], 2);
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
assert.equal(g.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(g), f.originalCards);
});
const distance = (g: Game, from: string, to: string) => gameDistance(g, from, to,
point => splitLocation(point).sector !== 0 && splitLocation(point).sector === g.storm);
function controlledPhysical(g: Game, actor: string, amount: number) {
const own = player(g, actor); own.reserves -= amount; own.forces['imperial_basin:9'] = amount;
}
for (const advanced of [false, true]) void test(`controlled ${advanced ? 'Advanced' : 'Basic'} zero-marker city positioning prepares physical Hajr movement without counting the marker`, () => {
const f = fixture({ advanced, value: 0 }); let g = structuredClone(f.game);
// Original physical reserve counters and original Hajr are conserved controls.
controlledPhysical(g, f.target, 2);
const at = g.deck.findIndex(card => card.effect === 'hajr'); assert.ok(at >= 0);
const hajr = g.deck.splice(at, 1)[0]; player(g, f.target).hand.push(hajr);
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
.find(to => to.sector !== g.storm && distance(g, 'imperial_basin:9', `${to.territory}:${to.sector}`) === 3);
assert.ok(to);
g = ask(g, f.asker, f.truthtrance, f.target, 2, to.territory);
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const first = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.equal(first?.type, 'move'); assert.equal(first.noField, f.token); assert.deepEqual(first.forces, {});
g = advance(act(g, f.target, first), clean);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
const play = viewGame(g, f.target).movementPromisePolicy?.[0]; assert.equal(play?.card, hajr.id);
g = advance(act(g, f.target, play!), clean);
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
assert.equal(g.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(g), f.originalCards);
});
void test('a mixed physical-plus-marker move fulfills only its physical quantity', () => {
const f = fixture({ value: 0 }), source = structuredClone(f.game);
controlledPhysical(source, f.target, 3);
const impossible = ask(source, f.asker, f.truthtrance, f.target, 4, 'arrakeen');
assert.deepEqual(viewGame(impossible, f.target).truthMovementAnswers, ['no']);
let g = ask(source, f.asker, f.truthtrance, f.target, 3, 'arrakeen');
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
g = act(g, f.target, { type: 'move', forces: { 'imperial_basin:9': 3 },
noField: f.token, event: player(g, f.target).noFieldEvent, territory: 'arrakeen', sector: 10 });
assert.equal(g.movementPromises![0].fulfilled, true);
assert.equal(player(g, f.target).forces['arrakeen:10'], 3);
assert.equal(player(g, f.target).noField?.deployed?.location.territory, 'arrakeen');
});
void test('controlled original Karama prevents a native No-Field preparation before payment and releases its now impossible promise', () => {
const f = fixture(), source = structuredClone(f.beforeShipment);
const at = source.deck.findIndex(card => card.effect === 'karama'); assert.ok(at >= 0);
const karama = source.deck.splice(at, 1)[0]; player(source, f.asker).hand.push(karama);
let g = act(source, f.target, { type: 'bribe', target: f.asker, amount: player(source, f.target).spice - 1 });
g = ask(g, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const before = structuredClone(player(g, f.target)), shipment = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.ok(shipment?.noField);
g = act(g, f.target, shipment);
assert.equal(g.response?.kind, 'richeseNoField');
assert.deepEqual(player(g, f.target).noField, before.noField);
assert.equal(player(g, f.target).spice, before.spice);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
g = act(g, f.asker, { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(player(g, f.target).shipped, false); assert.equal(player(g, f.target).spice, before.spice);
assert.equal(g.movementPromises![0].released, true); assert.deepEqual(inventory(g), f.originalCards);
});
void test('controlled earlier Basic boundary cannot voluntarily reveal away the only city-range completion', () => {
const f = fixture({ value: 0 }); let g = structuredClone(f.game);
controlledPhysical(g, f.target, 2);
g = advance(act(g, f.target, f.markerMove), clean);
g = act(g, f.target, { type: 'bribe', target: f.asker, amount: player(g, f.target).spice });
// Explicit earlier-phase boundary, not a claimed native second-round trace.
g.phase = 4; g.ready = [];
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
.find(to => to.sector !== g.storm && distance(g, 'imperial_basin:9', `${to.territory}:${to.sector}`) === 3);
assert.ok(to);
g = ask(g, f.asker, f.truthtrance, f.target, 2, to.territory);
assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes'));
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
const before = structuredClone(g), own = player(g, f.target);
assert.throws(() => act(g, f.target, { type: 'revealNoField', token: own.noField!.deployed!.tokenId,
event: own.noFieldEvent }));
assert.deepEqual(g, before);
});
void test('all existing profiles use the actual owned reveal then physical move legally', () => {
const f = fixture({ advanced: true });
const question = ask(f.game, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
for (const difficulty of DIFFICULTIES) {
let g = question, count = 0;
while (g.phase === 5 && g.active === f.target && count++ < 20) {
if (!clean(g) && !g.truthtrance) { g = advance(g, clean); continue; }
const view = viewGame(g, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action); g = act(g, f.target, action);
}
assert.ok(count < 20); assert.equal(g.movementPromises![0].fulfilled, true);
}
});