Enforce Basic Truthtrance ground-movement promises
Add a structured future single-movement predicate and persistent authoring, feasible-answer and promise controls for bare native Basic active post-shipment turns. Yes requires one matching physical movement while possible; No survives every later Hajr/Ornithopter opportunity until actual turn closure. Matching physical commitment precedes disposal and arrival effects. Preserve compatible promises after opposing loss and reject voluntary destruction of the last owned completion. Reuse the actual movement declaration and physical commitment in a focused owned-action search rather than a strategic shortlist. Retain typed groups, original two-group cohorts, held Hajr, both Ornithopter modes, city access and current Kulon bonus. Deterministic preparation on private clones retains physical cost semantics without inventing response/discard events in reachability. Existing native/optional/Advanced movement retains its actual controls. Verify 17-file218/218, types/lint/build, seven original-action gameplay/control/table-rendering programmes and all four existing legal profiles. Original public classic, paid cache Ornithopter and native allowed-Kulon sources use only unopened positions and scalar lotteries; extra-card/force-loss edges are explicitly controlled. Repair nearby stale fixture continuations to acknowledge Semuta/battle outcomes and put pre-movement Hajr in Basic. Full modes, wider preparation/timing/module coverage, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
This commit is contained in:
1 parent
b4a556d5d3
commit
edbcbe28de
23 files changed
+1471
-218
No files matched your search
@@ -1,4 +1,5 @@
|
||||
'use client';
|
||||
import { MovementPromises } from './movement-promises';
|
||||
import { integerInputValue } from '../lib/integer-input';
|
||||
import { EcazSetup } from './ecaz-setup';
|
||||
import { StormCardInspector, StormCardLogInspector } from './storm-cards';
|
||||
@@ -1959,6 +1960,7 @@ export function GameTable({
|
||||
</div>
|
||||
{g.battle?.revealed && <a className="battle-display-link" href="#revealed-battle-plans">Compare revealed battle plans</a>}
|
||||
<PrivateBattlePlan game={g} />
|
||||
<MovementPromises game={g} act={act} busy={reactionBusy} />
|
||||
{!reactionOwnsControls && g.semutaReaction && <SemutaReaction game={g} act={act} busy={reactionBusy} />}
|
||||
{g.karamaBlocked && <p className="notice" role="status">{g.karamaBlocked}</p>}
|
||||
{g.karamaPurposeBlocks?.map(({purpose, message}) => <p key={purpose} className="notice" role="status">{message}</p>)}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
import { TERRITORIES } from '@/game/board';
|
||||
import { validMovementClaim, type MovementClaim } from '@/game/movement-promises';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
export function MovementClaimFields({ id, value, onChange }: {
|
||||
id: string; value: MovementClaim; onChange: (value: MovementClaim) => void;
|
||||
}) {
|
||||
return <fieldset className="grid gap-2">
|
||||
<legend>Ground-movement condition</legend>
|
||||
<label htmlFor={`${id}-territory`}>Ground-movement destination</label>
|
||||
<select id={`${id}-territory`} value={value.territory}
|
||||
onChange={event => onChange({ ...value, territory: event.target.value })}>
|
||||
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`${id}-minimum`}>Minimum physical forces in one ground move</label>
|
||||
<Input id={`${id}-minimum`} type="number" min={1} max={20} step={1} required
|
||||
value={Number.isNaN(value.minimum) ? '' : value.minimum} aria-invalid={!validMovementClaim(value)}
|
||||
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { liveMovementPromises, movementClaimText } from '@/game/movement-promises';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function MovementPromises({ game, act, busy }: { game: GameView; act: (action: Action) => void; busy: boolean }) {
|
||||
const own = liveMovementPromises(game.movementPromises ?? [], game.me, game.turn);
|
||||
if (!own.length) return null;
|
||||
const next = game.movementPromisePolicy?.[0];
|
||||
const amount = next?.forces && typeof next.forces === 'object'
|
||||
? Object.values(next.forces as Record<string, number>).reduce((sum, count) => sum + count, 0) : typeof next?.amount === 'number' ? next.amount : 0;
|
||||
const destination = next && game.combatLocations.find(location => location.id === next.territory)?.name;
|
||||
const destinationLabel = destination ?? (typeof next?.territory === 'string' ? next.territory : 'the chosen destination');
|
||||
const sector = typeof next?.sector === 'number' ? next.sector : 0;
|
||||
const label = next?.type === 'card' ? 'Play Hajr to prepare the required movement'
|
||||
: next?.type === 'endMovement' ? 'Finish movement while honoring No'
|
||||
: `${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>
|
||||
{own.map((promise, index) => <p key={index}>{promise.answer ? 'Yes: you must' : 'No: you must not'} {movementClaimText(promise)} before this Movement turn ends.</p>)}
|
||||
<p className="fine">One qualifying move fulfills Yes. No remains binding through every later Hajr or Ornithopter move. Opposing effects release an answer only if it becomes impossible.</p>
|
||||
{next && <Button className="game-action min-h-11 whitespace-normal" disabled={busy} onClick={() => { if (!busy) act(next); }}>{label}</Button>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
'use client';
|
||||
import { MovementClaimFields } from './movement-claim-fields';
|
||||
import { validMovementClaim, type MovementClaim } from '@/game/movement-promises';
|
||||
import { ShipmentClaimFields, invalidShipmentClause, type ShipmentClauseInput } from './shipment-claim-fields';
|
||||
import { BattleClaimFields } from './battle-promises';
|
||||
import {
|
||||
@@ -50,8 +52,9 @@ export function Truthtrance({
|
||||
g.players.find((p) => p.id !== me.id)!.id,
|
||||
);
|
||||
const [kind, setKind] = useState<
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment'
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement'
|
||||
>('fact');
|
||||
const [movement, setMovement] = useState<MovementClaim>({ territory: 'carthag', minimum: 6 });
|
||||
const [shipmentJoin, setShipmentJoin] = useState<'single' | 'and' | 'or'>('single');
|
||||
const [shipmentClauses, setShipmentClauses] = useState<ShipmentClauseInput[]>([
|
||||
{ territory: 'carthag', minimum: 6 },
|
||||
@@ -105,6 +108,7 @@ export function Truthtrance({
|
||||
? planClauses[0]
|
||||
: { kind: join, terms: planClauses },
|
||||
}
|
||||
: kind === 'movement' ? { kind, target, ...movement }
|
||||
: kind === 'shipment'
|
||||
? {
|
||||
kind,
|
||||
@@ -212,6 +216,13 @@ export function Truthtrance({
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : w.question!.kind === 'movement' ? (
|
||||
<>
|
||||
<p className="notice">Yes requires one matching ground movement before your turn ends, while possible. No forbids every matching move through later Hajr or Ornithopter opportunities. Separate smaller groups do not add together. The No-Field marker itself is not a physical force counter.</p>
|
||||
{(g.truthMovementAnswers ?? []).map(answer => (
|
||||
<div key={answer}>{button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}</div>
|
||||
))}
|
||||
</>
|
||||
) : w.question!.kind === 'battlePlan' ? (
|
||||
<>
|
||||
<p className="notice">
|
||||
@@ -319,6 +330,7 @@ export function Truthtrance({
|
||||
<option value="fact">
|
||||
Verified current facts
|
||||
</option>
|
||||
<option value="movement">Bind ground movement this turn</option>
|
||||
<option value="shipment">
|
||||
Bind a shipment from reserves
|
||||
</option>
|
||||
@@ -333,7 +345,15 @@ export function Truthtrance({
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{kind === 'shipment' || kind === 'mixedShipment' ? (
|
||||
{kind === 'movement' ? (
|
||||
<>
|
||||
<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 and Ornithopter can prepare a later move. No stays binding after earlier nonmatching moves.</p>
|
||||
{!g.truthMovementTargets.includes(target) && <p className="notice">Choose the active Basic player after shipment and finish pending decisions. Optional-module and Advanced movement promises remain unfinished.</p>}
|
||||
<p className="notice" aria-label="Public movement question preview">{truthQuestionText(question, leaderName, locationName)}</p>
|
||||
</>
|
||||
) : kind === 'shipment' || kind === 'mixedShipment' ? (
|
||||
|
||||
<>
|
||||
{kind === 'mixedShipment' ? (
|
||||
<MixedShipmentFields id={`${factId}-mixed`} value={mixed}
|
||||
@@ -472,6 +492,7 @@ export function Truthtrance({
|
||||
{button(
|
||||
'Ask publicly',
|
||||
{ type: 'truthAsk', question },
|
||||
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
|
||||
(kind === 'shipment' &&
|
||||
(invalidShipment || !shipmentAvailable)) ||
|
||||
(kind === 'mixedShipment' &&
|
||||
|
||||
+26
-1
@@ -16,6 +16,31 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Basic Truthtrance ground-movement promises — 24 October. Prototyped / Partial.**
|
||||
Structured future ground-movement answers now bind a clean active post-shipment
|
||||
Basic native turn. One qualifying physical move fulfills Yes before arrival or
|
||||
card disposal; No survives later Hajr/Ornithopter groups until actual closure.
|
||||
Shared movement declaration/piece validation supplies private owned witnesses,
|
||||
including held cards, typed cohorts, city access and current Kulon. Voluntary
|
||||
loss of the last route rejects; opposing loss retains compatible promises in
|
||||
accepted order. Other timing, preparations, optional/Advanced profiles and full
|
||||
arbitrary Truthtrance enforcement remain unfinished.
|
||||
|
||||
A pure-ledger lane, original public source lane, bounded review and owner
|
||||
integration pass **17 files218/218**, types/lint/build, seven original-action
|
||||
control/table-rendering programmes and four existing profiles with12 accepted
|
||||
answer/move/end actions. Sources20/58/26 use real classic setup/paid shipment,
|
||||
native cache Ornithopter purchase/entry and actual Kulon/city range4. Only
|
||||
unopened positions and native scalar/packet lotteries are selected. Additional
|
||||
second-card/force-loss edges disclose controlled conserved outcomes. Review
|
||||
repaired current Kulon pruning and selective release; multiple-promise feedback
|
||||
keeps detached ledger settlement and deterministic preparation coherent.
|
||||
Representative old fixture failures reproduced on the previous engine; native
|
||||
Semuta/battle acknowledgements and Basic pre-movement Hajr scope are corrected.
|
||||
A notice-label lint issue was repaired. [Source, exact proof and remaining scope](TRUTHTRANCE_MOVEMENT_PROMISES.md).
|
||||
Full modes, broader rules, final assurance and deployed acceptance remain open.
|
||||
No strategy calibration or saved-room/reset/restart/deployment operation.
|
||||
|
||||
**Fresh Basic Semuta and Ambassador discards — 24 October. Prototyped / Partial.**
|
||||
Fresh bare public Basic Richese previews initialize Semuta before genuine setup
|
||||
with selected native families. Optional/Advanced profiles and older games keep
|
||||
@@ -6204,7 +6229,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), and [base Basic/Advanced reserve-shipment promises, including Guild](TRUTHTRANCE_SHIPMENT_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 [active Basic ground-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. |
|
||||
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 24 October 2026 — Basic Truthtrance ground-movement commitments
|
||||
|
||||
Add the missing structured future single-movement predicate for clean active
|
||||
post-shipment Basic native turns. Matching physical commitment fulfills Yes
|
||||
before disposal/arrival; No remains binding through later Hajr/Ornithopter until
|
||||
actual closure. Private witnesses reuse actual declaration and piece commitment,
|
||||
including typed/cohort limits and current city/Kulon range. No strategic shortlist
|
||||
proves feasibility. Voluntary loss of the last route rejects; opposing loss
|
||||
retains compatible promises in accepted order. Persistent authoring, answers
|
||||
and owned continuation controls retain busy handling and minimum legal AI.
|
||||
|
||||
A pure-ledger/source/review wave and owner integration pass17 files218/218,
|
||||
types/lint/build, seven original-action gameplay/control/table-rendering
|
||||
programmes and four existing profiles with12 accepted actions. Sources20/58/26
|
||||
retain original33 or35+10 inventories, actual paid shipment/cache/placement and
|
||||
actual allowed Kulon. Only unopened positions and scalar/packet lotteries are
|
||||
selected; extra-card/force-loss edges explicitly stage conserved outcomes.
|
||||
Review corrected current Kulon pruning and selective release. Multiple-promise
|
||||
feedback synchronizes detached settlement and uses deterministic private
|
||||
preparation without creating extra cost events. Old fixture failures reproduced
|
||||
on the prior engine: Semuta/battle acknowledgements and Basic pre-movement Hajr
|
||||
scope now match current controls. Notice-label type lint was repaired.
|
||||
[Authority, exact source and limits](TRUTHTRANCE_MOVEMENT_PROMISES.md).
|
||||
Full modes, wider timing/preparations/modules, comprehensive assurance and
|
||||
deployed acceptance remain open. No tuning or saved-room/reset/restart/deploy.
|
||||
|
||||
## 24 October 2026 — Fresh Basic Semuta and Ambassador discards
|
||||
|
||||
Initialize the existing Semuta protocol before genuine fresh public bare Basic
|
||||
|
||||
@@ -907,6 +907,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
||||
| Advanced Kull specific effect | Supplied physical29/logical27 restricts the attempted Karama for a specific effect, unlike publisher Basic phase-wide wording. New signed Advanced attempts bind semantic purpose; purchase/payment and native special variants normalize, as do enhanced Prescience/advisor conversion. CHOAM effects and special-force faction powers stay distinct. Retaining turn/phase expiry is explicit provisional composition under the 14 October policy, not supplied wording. Legacy unstamped pending/restriction frames retain broad scope. Prior distinct counter, BG-before-conversion and deferred overbid selections stay. | [Source cutover and keys](CHOAM_KULL_SOURCE_UPDATE.md#advanced-specific-effect-scope--18-october-2026), [connected implementation and limits](CHOAM_KULL_DESIGN.md#advanced-effect-scope--18-october-2026) |
|
||||
| Kull Wahad preview timing | User selected a **distinct eligible counter** while the interrupted physical card stays reserved, and BG Worthless interception **before conversion/discard**. Pending declaration is not a ban; only success stamps turn/phase and retains original/special once-use. Decline or prevention resumes the original once. Old `kull` stays printed-only; fresh `nexus-kull` admits native CHOAM plus classic Basic/Advanced seats, CHOAM/Ix Treachery and Nexus alone, without physical Sandtrout. Nexus Use needs actual unallied CHOAM Nexus and canonical eligible own fuel: accepted declaration spends Nexus even if prevented, fuel stays held until success. Use requires source/event/card, with no old-source action shim or proactive Kull. Verified prior printed counter saves migrate only their selection binding; no new-choice retrofit. Reservations, promises and remaining winning-payment coverage constrain fuel/counters; ordinary retained Karama payment is not a canceled Cunning effect. Priority/custody/overbid are user policy; neutral timing/spend conventions are application policy. Bounded checks/CLI/runtime/phone cases pass; broader verification is Partial and no full/public/deployed gate opens. | [Selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026), [source/cost boundary](NEXUS_CHOAM_RULES.md#kull-source-cost-and-action-boundary), [runtime and remaining limits](CHOAM_KULL_DESIGN.md), [new evidence](NEXUS_CHOAM_RUNTIME.md#five-part-status-for-the-new-interaction) |
|
||||
| Semuta full-hand exchange | User selected **Allow exchange**: acquisition followed by Semuta disposal is an atomic net-zero hand exchange at the faction limit. Existing incoming reservations still consume capacity; this does not permit an already overfull hand or settle Box/Richese purchase timing. | [Selected capacity policy](SEMUTA_SOURCE_UPDATE_20260907.md#user-selected-full-hand-exchange--30-september-2026) |
|
||||
| Truthtrance / Basic ground movement | Compose the general FAQ binding-while-possible rule with one future qualifying physical ground movement before the active post-shipment turn ends. Yes survives nonmatches while an owned completion remains; No forbids every later match through Hajr/Ornithopter until actual closure. Commit before arrival/disposal, preserve native controls, and retain compatible promises in accepted order after an opposing loss. Shipment/HMS/Ambassador relocation and the No-Field marker itself do not count as ordinary physical ground movement. This bounded predicate and release priority are software compositions, not a movement-specific publisher FAQ. Earlier timing, wider owned preparations, optional/Advanced and full acceptance remain unfinished. | [Authority, behavior and bounded evidence](TRUTHTRANCE_MOVEMENT_PROMISES.md) |
|
||||
| Semuta / native Basic entries | Fresh bare public Basic Richese previews initialize the existing protocol before genuine setup with selected native E1/E2/E3 families; no optional/Advanced inheritance or old-game retrofit. Clean source-witnessed shipment/movement CHOAM and Ixian Ambassador private discards, including BG copies, park the original entry: keep CHOAM's committed credit and draw the Ixian replacement only after the reaction. This composes printed component rules, not a new timing FAQ. Existing Basic Terror overflow retains the neutral all-seat response, committed-only face access, original victim/draw, removed token, payment, forces and optional gift. Nested parents, completed movement-card provenance and wider module/full-game coverage remain unfinished implementation boundaries, not printed Semuta prohibitions. | [Fresh Basic and Ambassador scope](SEMUTA_SOURCE_UPDATE_20260907.md#fresh-basic-starts-and-ambassador-discards--24-october-2026), [native Terror suffix](TERROR_DISCARD_CONTINUATIONS.md) |
|
||||
|
||||
Shipment and mixed current-fact/shipment AND/OR follow the FAQ's whole-question
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Truthtrance ground-movement promises
|
||||
|
||||
## Basic prototype — 24 October 2026
|
||||
|
||||
**Prototyped / Partial.** The structured question asks whether the respondent
|
||||
will make **one ground movement of at least N physical forces to a printed
|
||||
territory before their current Shipment and Movement turn ends**. It concerns
|
||||
future events after the answer, not earlier moves or sums of smaller groups.
|
||||
Shipment, HMS relocation, free accompaniment and Ambassador relocation do not
|
||||
count. A concealed No-Field marker itself is not a physical on-board force;
|
||||
ordinary counters moving with it retain their actual count.
|
||||
|
||||
The general binding-while-possible/current-turn authority is the GF9 November
|
||||
2020 FAQ printed p8, recorded in the [shipment source contract](TRUTHTRANCE_SHIPMENT_PROMISES.md#authority-and-supported-meaning).
|
||||
Its worked example concerns shipment. Applying that rule to this existential
|
||||
movement statement, the owned-action witness and accepted-order release policy
|
||||
below are explicit software compositions, not a publisher movement-specific FAQ
|
||||
or Basic adoption of the unofficial Advanced revision.
|
||||
|
||||
Fresh or existing Basic native tables can ask at a clean **active post-shipment
|
||||
turn**, including selected E1/E2/E3 factions. No optional modules or Advanced
|
||||
inherit this entry. The actual native roster, physical inventory and route
|
||||
validation remain authoritative; no mode or publication gate opens. Earlier
|
||||
phase/waiting-turn movement questions, compound movement/fact questions and wider
|
||||
owned preparations/interactions remain unfinished. Existing saves are not
|
||||
reconfigured and absent promise fields remain absent until a real answer binds.
|
||||
|
||||
## Behavior and controls
|
||||
|
||||
- **Yes** stays pending across nonmatching moves while a legal owned completion
|
||||
remains. One matching physical commitment fulfills it permanently, before card
|
||||
disposal, Intrusion, Ambassador/Terror or other later arrival effects. It is
|
||||
not a promise to keep those forces alive afterward.
|
||||
- **No** forbids every matching ordinary/card ground movement until the actual
|
||||
turn ends, including later Hajr and both Ornithopter groups. A first nonmatch
|
||||
or card-disposal pause does not fulfill it. Ending early cannot evade Yes.
|
||||
- Feasible answers use actual board/typed groups and the shared human movement
|
||||
declaration validator, not the strategic bot shortlist. The finite search
|
||||
considers ordinary moves, held Hajr, both held/active Ornithopter modes and
|
||||
their original cohort, current city access and current native bonuses such as
|
||||
CHOAM Kulon. Preparations use private detached execution; actual cards retain
|
||||
their ordinary effects, counters, discard/response and arrival continuations.
|
||||
Unknown future draws/gifts and unresolved opposing outcomes are not invented.
|
||||
- Voluntary moves/actions cannot remove the last legal completion. A declared
|
||||
native movement waits for its real counter response before fulfillment or
|
||||
impossibility. Opposing impossibility releases only the answers that cannot
|
||||
remain compatible: retain every No and still-feasible Yes in accepted order.
|
||||
An impossible large promise does not erase an independent smaller one.
|
||||
- Authoring has dedicated movement labels, an exact public preview and a
|
||||
one-to-twenty input. Only the respondent sees feasible answers and the next
|
||||
owned completion action. Persistent promise/answer controls have no timer;
|
||||
busy controls submit nothing. Four existing profiles follow those same legal
|
||||
views without strategy changes or calibration.
|
||||
|
||||
This is bounded reachability, not complete arbitrary Truthtrance enforcement.
|
||||
Other owned preparation families (including Box recovery and native relocation
|
||||
preparation), earlier timing, optional/Advanced profiles and comprehensive
|
||||
combination/recovery/privacy/custody acceptance remain due.
|
||||
|
||||
## Source programmes and current verification
|
||||
|
||||
`tests/fixture-truth-movement.ts` uses genuine public Basic Atreides/Emperor
|
||||
setup, original33-card inventory and actual dealt Truthtrance/Hajr. Only unopened
|
||||
deal/Spice positions and native scalar lotteries are selected. Actual readiness,
|
||||
setup, phases and a **two-spice** one-force Polar Sink shipment precede the
|
||||
question; ten original Arrakeen forces, wallets and allowances are unchanged.
|
||||
|
||||
The original public Ecaz/CHOAM/Richese/Guild source also buys physical
|
||||
Ornithopter for one in the native cache auction, deals original Truthtrance to
|
||||
Ecaz, buys one normal card, pays for placement and Guild shipment, then finishes
|
||||
an actual empty CHOAM Ambassador grant. Two-group Yes/No and range-three paths
|
||||
use those real cards/forces. Its existing native Ecaz final-move Semuta boundary
|
||||
remains; ending disposal and the earlier Truthtrance cost still use their actual
|
||||
neutral continuation. No force, wallet, clock, bonus or played card is installed.
|
||||
|
||||
`tests/fixture-truth-movement-choam.ts` uses public native CHOAM/Emperor setup,
|
||||
actual dealt Kulon/Truthtrance, one-spice Arrakeen shipment and actual allowed
|
||||
Kulon. City access plus its current bonus proves a four-territory route.
|
||||
|
||||
Separate multiple-question and impossibility edges explicitly stage a conserved
|
||||
second original Truthtrance acquisition and conserved force-loss outcomes. They
|
||||
are not natural second-purchase or native Atomics/Terror source evidence.
|
||||
|
||||
The seventeen-file affected union passes **218/218**, with types/lint and the
|
||||
production build passing. Seven actual gameplay/control/table-rendering
|
||||
programmes cover direct Yes, later Hajr Yes, No surviving Hajr, both actual
|
||||
Ornithopter groups, its range-three option and native current-Kulon route4.
|
||||
Source programmes use20/58/26 accepted actions. Actual persistent promise
|
||||
handlers execute the private witness and submit nothing when busy; actual
|
||||
GameTable rendering covers question, answer, remaining promise, Truthtrance/
|
||||
ending-disposal pauses and original turn completion. Authoring/answer states
|
||||
are rendered and submitted as real actions, not browser clicks. Four existing
|
||||
profiles make12 legal answer/move/end actions, without tuning or calibration.
|
||||
|
||||
Bounded review found current CHOAM range must include Kulon rather than a
|
||||
three-territory cap, and an opposing loss must release only incompatible answers.
|
||||
Both have regressions, including genuine Kulon play. Multiple-promise feedback
|
||||
keeps detached settlement coherent; deterministic owned preparation does not
|
||||
invent extra fresh-discard events while a real cost is suspended. Initial fixture
|
||||
feedback corrected the actual two-spice Polar Sink tariff and automatically
|
||||
allowed Kulon. Three representative older failures reproduced against the prior
|
||||
committed engine: shared native fixtures now acknowledge neutral Semuta, two
|
||||
battle fixtures acknowledge the actual result, and the broad pre-movement Hajr
|
||||
case correctly uses Basic. Native Advanced Hajr still requires its genuine
|
||||
post-movement opportunity. A notice-label scalar lint issue was repaired.
|
||||
|
||||
Frozen base `b4a556d5d345c9ff3056b01af0076ae86bdf5579`, tree
|
||||
`990c92fd368f07e1be91dcc0c677a7c029ecfcd6f747b99943e07cae28f8cfc3`,
|
||||
2631 files. Private logs, original-state/control HTML and receipts:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261024-truth-movement/`,
|
||||
`frozen-integrated-3/report.json`. Final checkpoint/reference prose overlays
|
||||
are separately recorded; engine, controls, helpers and tests match the proof.
|
||||
Framework aliases/CSS stubbing make this server-rendered evidence, not browser,
|
||||
visual or deployed acceptance. Full modes, wider preparations/timing/modules,
|
||||
comprehensive assurance and deployed acceptance remain open. No saved-room/D1
|
||||
operation, reset, restart or deployment.
|
||||
@@ -1280,6 +1280,10 @@ function policyActions(g: GameView): Action[] {
|
||||
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
|
||||
if (w.stage === 'answer') {
|
||||
if (w.question?.target !== me.id) return [];
|
||||
if (w.question.kind === 'movement') {
|
||||
const answer = g.truthMovementAnswers?.find(answer => answer !== 'unknown') ?? g.truthMovementAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'mixedShipment') {
|
||||
const choices = g.truthShipmentAnswers ?? [];
|
||||
const answer = choices.find(choice => choice === 'yes' || choice === 'no') ?? choices[0];
|
||||
@@ -4073,6 +4077,9 @@ export function botActions(g: GameView): Action[] {
|
||||
if (g.shipmentPromisePolicy && g.status === 'playing' &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
|
||||
return g.shipmentPromisePolicy;
|
||||
if (g.movementPromisePolicy && g.status === 'playing' &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
|
||||
return g.movementPromisePolicy;
|
||||
if (g.nexusHarkonnenBetrayalReaction) {
|
||||
if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed ||
|
||||
g.automaticContinuationPending) return [];
|
||||
|
||||
+364
-201
@@ -1,3 +1,5 @@
|
||||
import { MovementClaimError, liveMovementPromises, movementEventAllowed, movementPromiseTargetAvailable, settleMovementEvent, finishMovementPromises, validMovementClaim, type MovementClaim, type MovementPromise } from './movement-promises';
|
||||
import { findMovementCompletion, type MovementCompletion } from './movement-promise-search';
|
||||
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile } from './faction-module-profile';
|
||||
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
|
||||
@@ -1571,6 +1573,7 @@ export type Game = {
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
promise:
|
||||
| { kind: 'shipment'; index: number; value: ShipmentPromise }
|
||||
| { kind: 'movement'; index: number; value: MovementPromise }
|
||||
| { kind: 'battle'; index: number; value: BattlePromise }
|
||||
| null;
|
||||
parentSignature: string;
|
||||
@@ -1969,6 +1972,7 @@ export type Game = {
|
||||
truthtrance?: TruthWindow | null;
|
||||
truthHistory?: TruthRecord[];
|
||||
shipmentPromises?: ShipmentPromise[];
|
||||
movementPromises?: MovementPromise[];
|
||||
ixSetupCards?: Card[] | null;
|
||||
ixAuction?: { count: number; cards: Card[]; authority?: string } | null;
|
||||
ixAuctionKnown?: { turn: number; cards: Card[]; controller?: string } | null;
|
||||
@@ -6026,6 +6030,7 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
|
||||
history: g.truthHistory,
|
||||
battle: g.battle,
|
||||
shipmentPromises: g.shipmentPromises,
|
||||
movementPromises: g.movementPromises,
|
||||
active: g.active,
|
||||
order: g.order,
|
||||
status: g.status,
|
||||
@@ -6048,7 +6053,10 @@ function finishTruthtranceAnswer(g: Game) {
|
||||
const record = g.truthHistory![historyIndex];
|
||||
const card = discard(g, getPlayer(g, consumed.player), consumed.card);
|
||||
let promise: TruthDiscardContinuation['promise'] = null;
|
||||
if (record.question.kind === 'shipment' || record.question.kind === 'mixedShipment')
|
||||
if (record.question.kind === 'movement') promise = {
|
||||
kind: 'movement', index: g.movementPromises!.length - 1, value: g.movementPromises!.at(-1)!,
|
||||
};
|
||||
else if (record.question.kind === 'shipment' || record.question.kind === 'mixedShipment')
|
||||
promise = {
|
||||
kind: 'shipment',
|
||||
index: g.shipmentPromises!.length - 1,
|
||||
@@ -6930,7 +6938,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
try {
|
||||
validateTruthQuestionReceipt(context, c.consumed.player, record.question);
|
||||
} catch (error) {
|
||||
if (error instanceof TruthError || error instanceof PlanClaimError)
|
||||
if (error instanceof TruthError || error instanceof PlanClaimError || error instanceof MovementClaimError)
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
@@ -6942,15 +6950,16 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
g.battle &&
|
||||
!g.battle.revealed &&
|
||||
!g.battle.plans[q.target];
|
||||
if (expectedShipment || expectedBattle) {
|
||||
const actual = expectedShipment
|
||||
const expectedMovement = q.kind === 'movement';
|
||||
if (expectedShipment || expectedBattle || expectedMovement) {
|
||||
const actual = expectedMovement ? g.movementPromises : expectedShipment
|
||||
? g.shipmentPromises
|
||||
: g.battle?.truthPromises;
|
||||
requireRule(
|
||||
promise &&
|
||||
promise.value &&
|
||||
typeof promise.value === 'object' &&
|
||||
promise.kind === (expectedShipment ? 'shipment' : 'battle') &&
|
||||
promise.kind === (expectedMovement ? 'movement' : expectedShipment ? 'shipment' : 'battle') &&
|
||||
Array.isArray(actual) &&
|
||||
Number.isSafeInteger(promise.index) &&
|
||||
promise.index >= 0 &&
|
||||
@@ -6963,7 +6972,11 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
!promise.value.released,
|
||||
'The completed Truthtrance is missing its newly bound promise.',
|
||||
);
|
||||
if (promise.kind === 'shipment')
|
||||
if (promise.kind === 'movement')
|
||||
requireRule(q.kind === 'movement' && promise.value.turn === g.turn && !promise.value.fulfilled &&
|
||||
promise.value.territory === q.territory && promise.value.minimum === q.minimum,
|
||||
'The completed Truthtrance ground-movement promise differs from its answer.');
|
||||
else if (promise.kind === 'shipment')
|
||||
requireRule(
|
||||
(q.kind === 'shipment' || q.kind === 'mixedShipment') &&
|
||||
promise.value.turn === g.turn &&
|
||||
@@ -7595,6 +7608,7 @@ function semutaOfferSupported(
|
||||
(continuation.consumed.source === undefined || continuation.consumed.source === 'shrine') &&
|
||||
(continuation.promise === null ||
|
||||
continuation.promise.kind === 'shipment' ||
|
||||
continuation.promise.kind === 'movement' ||
|
||||
continuation.promise.kind === 'battle'))) return false;
|
||||
if (g.homeworlds && !bgDiscard && continuation.kind !== 'kaitainDiscard' &&
|
||||
continuation.kind !== 'tupileCleanupDiscard' &&
|
||||
@@ -16384,29 +16398,319 @@ function recoverableUnsupportedShipment(shipment: PendingShipment) {
|
||||
shipment.guildSecretEvent === undefined
|
||||
);
|
||||
}
|
||||
function completeMove(g: Game, move: MovementOrder) {
|
||||
if (move.noField) {
|
||||
const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, move.player));
|
||||
/** Keep source controls in the real game. A detached witness answers only
|
||||
* owned movement reachability; unresolved opposing outcomes are never replayed. */
|
||||
function groundMovementProbe(source: Game): Game {
|
||||
const game = structuredClone(source);
|
||||
game.truthtrance = null; game.response = null; game.decision = null; game.phaseOpening = null;
|
||||
game.pendingKarama = null; game.pendingTreacheryDiscard = null;
|
||||
game.pendingFremenMove = null; game.pendingIxMove = null; game.pendingChoamMove = null;
|
||||
game.pendingAmbassador = null; game.pendingTerrorEntry = null; game.pendingArrivalOverlap = null;
|
||||
game.pendingArrivalReaction = []; game.pendingExchange = null; game.pendingNullentropy = null;
|
||||
return game;
|
||||
}
|
||||
function groundMovementEvent(move: MovementOrder) {
|
||||
return { territory: move.to, amount: move.total - Number(!!move.noField) };
|
||||
}
|
||||
function projectGroundMovement(source: Game, move: MovementOrder): Game {
|
||||
const game = structuredClone(source), player = getPlayer(game, move.player);
|
||||
commitGroundMovementPieces(game, move);
|
||||
const flight = game.ornithopter;
|
||||
if (flight?.player === player.id) {
|
||||
flight.completed++;
|
||||
if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) {
|
||||
if (flight.mode === 'twoGroups') player.ornithopterExtraMoves = (player.ornithopterExtraMoves ?? 0) + 1;
|
||||
game.discard.push(flight.card); game.ornithopter = null;
|
||||
}
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function groundMovementCompletion(source: Game, actor: string,
|
||||
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn)): MovementCompletion | null {
|
||||
const game = groundMovementProbe(source);
|
||||
if (!movementPromiseTargetAvailable(game, actor)) return null;
|
||||
return findMovementCompletion(game, actor, promises, {
|
||||
allowance: movesAllowed,
|
||||
prepareHajr(state, owner) {
|
||||
const player = getPlayer(state, owner);
|
||||
const card = player.hand.find(card => card.effect === 'hajr');
|
||||
if (!card || !hajrAvailable(state, player) || transferCardBlock(state, player, card)) return null;
|
||||
try { return groundMovementProbe(applyActionInner(state, owner, { type: 'card', card: card.id }, 'movementPreparation')); }
|
||||
catch (error) { if (error instanceof RuleError) return null; throw error; }
|
||||
},
|
||||
reachable(state, player, origin, destination, sector) {
|
||||
// Include current native range (notably CHOAM Kulon) and the card's
|
||||
// explicit range-three option. This only prunes impossible geometry;
|
||||
// the actual declaration still validates exact speed/groups/custody.
|
||||
return Object.keys(player.forces).some(key => player.forces[key] > 0 && splitLocation(key).territory === origin &&
|
||||
gameDistance(state, key, location(destination, sector), point => pathBlocked(state, player, point, isAdvisor(player, origin))) <= Math.max(3, movementRange(state, player, 1)));
|
||||
},
|
||||
project(state, owner, action) {
|
||||
const copy = structuredClone(state), player = getPlayer(copy, owner);
|
||||
try {
|
||||
const move = declareGroundMovement(copy, player, action, true);
|
||||
return { game: projectGroundMovement(copy, move), event: groundMovementEvent(move) };
|
||||
} catch (error) { if (error instanceof RuleError) return null; throw error; }
|
||||
},
|
||||
});
|
||||
}
|
||||
function projectedGroundMovementPolicy(g: Game, player: Player): Action[] | null {
|
||||
if (g.truthtrance || !movementPromiseTargetAvailable(g, player.id) ||
|
||||
!liveMovementPromises(g.movementPromises ?? [], player.id, g.turn).length) return null;
|
||||
const completion = groundMovementCompletion(g, player.id);
|
||||
return completion ? completion.actions.length ? completion.actions.slice(0, 1) : [{ type: 'endMovement' }] : null;
|
||||
}
|
||||
function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAnswer[] {
|
||||
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn);
|
||||
const answers: TruthAnswer[] = [];
|
||||
for (const answer of [true, false]) {
|
||||
const promise = { ...claim, turn: g.turn, player: p.id, asker: g.players.find(other => other.id !== p.id)!.id, answer };
|
||||
if (groundMovementCompletion(g, p.id, [...live, promise])) answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
}
|
||||
function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) {
|
||||
(g.movementPromises ??= []).push({ ...claim, turn: g.turn, player: p.id, asker, answer });
|
||||
}
|
||||
function checkGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
|
||||
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn);
|
||||
if (!live.length) return;
|
||||
const event = groundMovementEvent(move);
|
||||
requireRule(movementEventAllowed(live, event), 'This movement would break your accepted No answer.');
|
||||
const projected = projectGroundMovement(g, move);
|
||||
projected.movementPromises = settleMovementEvent(g.movementPromises ?? [], p.id, g.turn, event);
|
||||
requireRule(groundMovementCompletion(projected, p.id) !== null,
|
||||
'Leave a legal completion for your accepted ground-movement promises.');
|
||||
}
|
||||
function recordGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
|
||||
if (move.source === 'ambassador') return;
|
||||
if (!g.movementPromises) return;
|
||||
const old = g.movementPromises;
|
||||
g.movementPromises = settleMovementEvent(old, p.id, g.turn, groundMovementEvent(move));
|
||||
g.movementPromises.forEach((promise, index) => {
|
||||
if (promise.fulfilled && !old[index].fulfilled)
|
||||
log(g, `${p.name} fulfilled a Truthtrance ground-movement promise with this committed move.`);
|
||||
});
|
||||
}
|
||||
function reconcileGroundMovementPromises(g: Game, actor?: string) {
|
||||
// A declared native movement is awaiting a real opponent choice. It is not
|
||||
// impossible and must not be fulfilled before physical commitment.
|
||||
if (g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove) return;
|
||||
for (const p of g.players) {
|
||||
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn);
|
||||
if (!live.some(promise => promise.answer)) continue;
|
||||
const busy = !!(g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
|
||||
g.pendingAmbassador || g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length);
|
||||
if (busy && actor !== p.id) continue;
|
||||
if (groundMovementCompletion(g, p.id, live)) continue;
|
||||
requireRule(actor !== p.id, 'Your action would remove the last legal completion of an accepted ground-movement promise.');
|
||||
const kept = live.filter(promise => !promise.answer);
|
||||
// Keep still-feasible earlier answers and every No constraint; an opposing
|
||||
// loss does not erase independent promises that remain possible.
|
||||
for (const promise of live.filter(promise => promise.answer)) {
|
||||
if (groundMovementCompletion(g, p.id, [...kept, promise])) { kept.push(promise); continue; }
|
||||
promise.released = true;
|
||||
log(g, `${p.name}'s Truthtrance ground-movement promise is released because no owned legal completion remains possible.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
/** One shared declaration validates human actions and private promise witnesses.
|
||||
* Quote callers supply a detached game; card preparation stays in that copy. */
|
||||
function declareGroundMovement(g: Game, p: Player, action: Action, quoteOnly = false): MovementOrder {
|
||||
const id = p.id;
|
||||
requireRule(action.discoveryFlight === undefined,
|
||||
'Choose a carried Discovery Ornithopter token; saved flight receipts are internal.');
|
||||
if (action.noField !== undefined) {
|
||||
const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, id));
|
||||
requireRule(!blocked, blocked ?? 'The No-Field cannot move.');
|
||||
}
|
||||
if (!p.shipped) checkShipmentPromises(g, p, null);
|
||||
requireRule(
|
||||
g.phase === 5 && g.active === id && p.moved < movesAllowed(g, p),
|
||||
'Your movement is not available.',
|
||||
);
|
||||
const skillLeader = planetologistLeader(g, id);
|
||||
requireRule(action.origins === undefined &&
|
||||
(action.planetologist === undefined || (skillLeader &&
|
||||
(action.planetologist === 'range' || action.planetologist === 'gather'))),
|
||||
'Choose an available Planetologist movement mode.');
|
||||
const planetologist: PlanetologistMovement | undefined = action.planetologist === undefined
|
||||
? undefined : {leader: skillLeader!, mode: action.planetologist as PlanetologistMovement['mode']};
|
||||
const {
|
||||
group,
|
||||
eliteGroup,
|
||||
elite,
|
||||
origin,
|
||||
origins,
|
||||
total: n,
|
||||
noField,
|
||||
sourceKeys,
|
||||
} = forceGroup(g, p, action, planetologist?.mode === 'gather' ? 2 : 1);
|
||||
const to = stringField(action.territory),
|
||||
s = integer(action.sector, 0, 18, 'Sector');
|
||||
const {advisors, lockedTurn, wantsFighters, advisorGather} = movementStance(g, p, origins, to, action.fighters === true);
|
||||
requireRule(
|
||||
origins.every(source => !balisetPrevents(g, id, source, to)),
|
||||
'Baliset prevents movement into this CHOAM territory; shipment remains possible.',
|
||||
);
|
||||
allowedEntry(g, p, to, s, false, advisors && !wantsFighters);
|
||||
requireRule(
|
||||
sourceKeys.every((key) => key !== location(to, s)),
|
||||
'Choose a different destination.',
|
||||
);
|
||||
// Reject known unsupported arrivals before any movement-card event or
|
||||
// native movement-prevention window can commit this declaration.
|
||||
validateMovementArrival(g, {
|
||||
player: id, group, eliteGroup, elite, origin, origins, total: n,
|
||||
to, sector: s, advisors, wantsFighters, lockedTurn,
|
||||
});
|
||||
let flight = g.ornithopter?.player === id ? g.ornithopter : null;
|
||||
if (action.movementCard !== undefined) {
|
||||
const mode = action.ornithopter;
|
||||
requireRule(
|
||||
mode === 'range3' || mode === 'twoGroups',
|
||||
'Choose an Ornithopter movement mode.',
|
||||
);
|
||||
const blocked = ornithopterBlock(g, p);
|
||||
requireRule(!blocked, blocked ?? 'Ornithopter cannot be played.');
|
||||
const card = p.hand.find((c) => c.id === action.movementCard);
|
||||
requireRule(
|
||||
card && richeseCardDefinition(card)?.card.effect === 'ornithopter',
|
||||
'Choose the canonical Ornithopter in your hand.',
|
||||
);
|
||||
const reserved = transferCardBlock(g, p, card);
|
||||
requireRule(!reserved, reserved ?? 'The card is committed.');
|
||||
requireRule(
|
||||
action.ornithopterEvent === undefined,
|
||||
'A new card movement cannot name an old event.',
|
||||
);
|
||||
flight = {
|
||||
event: crypto.randomUUID(),
|
||||
player: id,
|
||||
card,
|
||||
turn: g.turn,
|
||||
mode,
|
||||
startingMove: p.moved,
|
||||
completed: 0,
|
||||
};
|
||||
g.ornithopter = flight;
|
||||
p.hand = p.hand.filter((c) => c.id !== card.id);
|
||||
log(
|
||||
g,
|
||||
`${p.name} played Ornithopter: ${mode === 'range3' ? 'one group with a maximum three-territory route' : 'two different groups using normal movement'}. The card remains on the table until its use finishes.`,
|
||||
);
|
||||
} else if (flight)
|
||||
requireRule(
|
||||
action.ornithopterEvent === flight.event,
|
||||
'Use the current Ornithopter movement event.',
|
||||
);
|
||||
else
|
||||
requireRule(
|
||||
action.ornithopterEvent === undefined &&
|
||||
action.ornithopter === undefined,
|
||||
'No matching Ornithopter movement is active.',
|
||||
);
|
||||
const move: MovementOrder = {
|
||||
player: id,
|
||||
group,
|
||||
eliteGroup,
|
||||
elite,
|
||||
origin,
|
||||
...(planetologist ? {planetologist} : {}),
|
||||
...(planetologist?.mode === 'gather' ? {origins} : {}),
|
||||
total: n,
|
||||
to,
|
||||
sector: s,
|
||||
advisors,
|
||||
wantsFighters,
|
||||
lockedTurn,
|
||||
player: id,
|
||||
} = move;
|
||||
const p = getPlayer(g, id);
|
||||
...(noField ? { noField } : {}),
|
||||
...(flight
|
||||
? {
|
||||
ornithopterEvent: flight.event,
|
||||
ornithopterRange: flight.mode === 'range3',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (action.discoveryOrnithopter !== undefined)
|
||||
move.discoveryFlight = nexusRule(() => quoteDiscoveryFlight(g, action.discoveryOrnithopter, move));
|
||||
validatePlanetologistMove(g, p, move);
|
||||
const speed = movementOrderRange(g, p, move);
|
||||
if (action.sandmaster !== undefined) {
|
||||
move.sandmaster = nexusRule(() => quoteSandmasterMovement(g, id, sandmasterOrder(g, p, move), action.sandmaster));
|
||||
validateSandmasterOrder(g, p, move);
|
||||
}
|
||||
requireRule(
|
||||
sourceKeys.every(
|
||||
(key) =>
|
||||
gameDistance(g, key, location(to, s), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
|
||||
) <= movementOrderRange(g, p, move, splitLocation(key).territory),
|
||||
),
|
||||
planetologist?.mode === 'gather'
|
||||
? 'Each selected origin must have an unblocked route within its own movement range.'
|
||||
: `That destination is blocked or more than ${speed} territories away.`,
|
||||
);
|
||||
validateFlightSelection(g, p, move);
|
||||
validateMovementArrival(g, move);
|
||||
validateSandmasterOrder(g, p, move);
|
||||
if (quoteOnly) return move;
|
||||
checkGroundMovementPromises(g, p, move);
|
||||
if (advisorGather) {
|
||||
g.response = {
|
||||
kind: 'advisorFlip', owner: id, location: to, advisors: false, amount: n, passed: [],
|
||||
advisorResume: 'planetologistGather', advisorGatherMove: { turn: g.turn, move: p.moved, order: move },
|
||||
};
|
||||
} else if (
|
||||
!move.ornithopterRange && !move.discoveryFlight &&
|
||||
p.faction === 'ixians' &&
|
||||
elite > 0 &&
|
||||
!(p.ixMovementBlocked?.turn === g.turn && p.ixMovementBlocked.move === p.moved) &&
|
||||
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
|
||||
(move.sandmaster
|
||||
? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1)
|
||||
: group.some(([key]) =>
|
||||
selectedOriginElites(group, eliteGroup, splitLocation(key).territory) > 0 &&
|
||||
gameDistance(g, key, location(to, s), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
|
||||
) > planetologistRange(1, planetologist?.mode)))
|
||||
) {
|
||||
g.pendingIxMove = move;
|
||||
g.response = {
|
||||
kind: 'ixMovement',
|
||||
owner: id,
|
||||
location: location(to, s),
|
||||
amount: n,
|
||||
elite,
|
||||
passed: [],
|
||||
};
|
||||
} else if (
|
||||
!move.ornithopterRange && !move.discoveryFlight &&
|
||||
p.faction === 'fremen' &&
|
||||
p.fremenNexusMovementBlockedTurn !== g.turn &&
|
||||
!(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) &&
|
||||
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
|
||||
(move.sandmaster
|
||||
? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1)
|
||||
: group.some(
|
||||
([key]) => gameDistance(g, key, location(to, s), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, origin))) > planetologistRange(1, planetologist?.mode)))
|
||||
) {
|
||||
g.pendingFremenMove = { turn: g.turn, move: p.moved, order: move };
|
||||
g.response = {
|
||||
kind: 'fremenMovement',
|
||||
...(move.sandmaster ? {sandmasterProof: JSON.stringify(move.sandmaster)} : {}),
|
||||
owner: id,
|
||||
location: location(to, s),
|
||||
amount: n,
|
||||
passed: [],
|
||||
};
|
||||
} else offerChoamMovement(g, move);
|
||||
return move;
|
||||
}
|
||||
/** Commit only the selected physical group and native movement counters. Arrival
|
||||
* decisions and card disposal follow separately in the actual continuation. */
|
||||
function commitGroundMovementPieces(g: Game, move: MovementOrder) {
|
||||
const {group, eliteGroup, elite, total:n, to, sector:s, advisors, wantsFighters, lockedTurn, player:id} = move;
|
||||
const p = getPlayer(g, id);
|
||||
const flight = g.ornithopter;
|
||||
if (
|
||||
flight?.player === id &&
|
||||
@@ -16474,6 +16778,21 @@ function completeMove(g: Game, move: MovementOrder) {
|
||||
g.discoveries = nexusRule(() => completeDiscoveryFlight(g, move.discoveryFlight!, move));
|
||||
log(g, `${p.name} used the carried Discovery Ornithopter for this movement and removed its token.`);
|
||||
}
|
||||
}
|
||||
function completeMove(g: Game, move: MovementOrder) {
|
||||
if (move.noField) {
|
||||
const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, move.player));
|
||||
requireRule(!blocked, blocked ?? 'The No-Field cannot move.');
|
||||
}
|
||||
const { elite, origin, total: n, to, sector: s, player: id } = move;
|
||||
const p = getPlayer(g, id);
|
||||
validatePlanetologistMove(g, p, move);
|
||||
validateFlightSelection(g, p, move);
|
||||
validateMovementArrival(g, move);
|
||||
validateSandmasterOrder(g, p, move);
|
||||
const flight = g.ornithopter;
|
||||
commitGroundMovementPieces(g, move);
|
||||
recordGroundMovementPromises(g, p, move);
|
||||
const cunning = currentGuildCunning(g,id);
|
||||
if (cunning) { requireRule(cunning.stage === 'extraMove', 'Guild Cunning permits no movement before the second shipment.'); cunning.movesAfter++; saveGuildCunning(g,cunning); }
|
||||
const completed: CompletedMovement = {
|
||||
@@ -16599,6 +16918,12 @@ function finishBasicAlliedShipment(g: Game, p: Player) {
|
||||
/** A played movement card is already retired before queue advancement. */
|
||||
function finishMovementTurn(g: Game, id: string) {
|
||||
const p = getPlayer(g, id);
|
||||
try {
|
||||
if (g.movementPromises) g.movementPromises = finishMovementPromises(g.movementPromises, p.id, g.turn);
|
||||
} catch (error) {
|
||||
if (error instanceof MovementClaimError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
requireAlliedSeparationCustody(g, p);
|
||||
const cunning = currentGuildCunning(g,id);
|
||||
if (cunning) { requireRule(cunning.stage !== 'pending' && cunning.shipment?.stage !== 'pending', 'Finish the Guild Cunning response or shipment first.'); cunning.stage = 'completed'; saveGuildCunning(g,cunning); }
|
||||
@@ -22771,6 +23096,14 @@ function shipmentPromiseIntegrity(g: Game) {
|
||||
if (error instanceof TruthError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
requireRule(g.movementPromises === undefined || (Array.isArray(g.movementPromises) &&
|
||||
g.movementPromises.every(promise => validMovementClaim(promise) &&
|
||||
Number.isSafeInteger(promise.turn) && promise.turn >= 1 && promise.turn <= g.turn &&
|
||||
g.players.some(p => p.id === promise.player) && g.players.some(p => p.id === promise.asker && p.id !== promise.player) &&
|
||||
typeof promise.answer === 'boolean' &&
|
||||
(promise.fulfilled === undefined || typeof promise.fulfilled === 'boolean') &&
|
||||
(promise.released === undefined || typeof promise.released === 'boolean') &&
|
||||
!(promise.fulfilled && promise.released))), 'The saved ground-movement promises are invalid.');
|
||||
const promises = g.shipmentPromises;
|
||||
requireRule(
|
||||
promises === undefined || Array.isArray(promises),
|
||||
@@ -30787,6 +31120,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
return normalizeAutomaticGame(state);
|
||||
}
|
||||
const g = applyActionInner(state, id, action);
|
||||
reconcileGroundMovementPromises(g, id);
|
||||
harkonnenNexusBetrayalIntegrity(g);
|
||||
if (g.pendingNexusHarkonnenBetrayal) return g;
|
||||
ixianNexusBetrayalIntegrity(g);
|
||||
@@ -31088,6 +31422,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
// they are not the original actor voluntarily spending a promised resource.
|
||||
reconcileBattlePromises(g);
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
requireRule(
|
||||
JSON.stringify(g) !== before,
|
||||
'Automatic response continuation made no progress.',
|
||||
@@ -31177,6 +31512,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
}
|
||||
reconcileBattlePromises(g);
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
discoveryIntegrity(g);
|
||||
@@ -31211,7 +31547,7 @@ function applyActionInner(
|
||||
state: Game,
|
||||
id: string,
|
||||
action: Action,
|
||||
execution: 'live' | 'shipmentPreparation' = 'live',
|
||||
execution: 'live' | 'shipmentPreparation' | 'movementPreparation' = 'live',
|
||||
): Game {
|
||||
tupileSlotsIntegrity(state);
|
||||
const tupileBlock = tupileSourceBlock(state);
|
||||
@@ -31409,6 +31745,8 @@ function applyActionInner(
|
||||
log,
|
||||
shipmentAnswers: shipmentTruthAnswers,
|
||||
bindShipment: bindShipmentTruth,
|
||||
movementAnswers: movementTruthAnswers,
|
||||
bindMovement: bindMovementTruth,
|
||||
battleAnswers: battleTruthAnswers,
|
||||
bindBattle: bindBattleTruth,
|
||||
cardBlock: transferCardBlock,
|
||||
@@ -31416,7 +31754,7 @@ function applyActionInner(
|
||||
)
|
||||
return g;
|
||||
} catch (error) {
|
||||
if (error instanceof TruthError || error instanceof PlanClaimError)
|
||||
if (error instanceof TruthError || error instanceof PlanClaimError || error instanceof MovementClaimError)
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
@@ -34656,190 +34994,7 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (t === 'move') {
|
||||
requireRule(action.discoveryFlight === undefined,
|
||||
'Choose a carried Discovery Ornithopter token; saved flight receipts are internal.');
|
||||
if (action.noField !== undefined) {
|
||||
const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, id));
|
||||
requireRule(!blocked, blocked ?? 'The No-Field cannot move.');
|
||||
}
|
||||
if (!p.shipped) checkShipmentPromises(g, p, null);
|
||||
requireRule(
|
||||
g.phase === 5 && g.active === id && p.moved < movesAllowed(g, p),
|
||||
'Your movement is not available.',
|
||||
);
|
||||
const skillLeader = planetologistLeader(g, id);
|
||||
requireRule(action.origins === undefined &&
|
||||
(action.planetologist === undefined || (skillLeader &&
|
||||
(action.planetologist === 'range' || action.planetologist === 'gather'))),
|
||||
'Choose an available Planetologist movement mode.');
|
||||
const planetologist: PlanetologistMovement | undefined = action.planetologist === undefined
|
||||
? undefined : {leader: skillLeader!, mode: action.planetologist as PlanetologistMovement['mode']};
|
||||
const {
|
||||
group,
|
||||
eliteGroup,
|
||||
elite,
|
||||
origin,
|
||||
origins,
|
||||
total: n,
|
||||
noField,
|
||||
sourceKeys,
|
||||
} = forceGroup(g, p, action, planetologist?.mode === 'gather' ? 2 : 1);
|
||||
const to = stringField(action.territory),
|
||||
s = integer(action.sector, 0, 18, 'Sector');
|
||||
const {advisors, lockedTurn, wantsFighters, advisorGather} = movementStance(g, p, origins, to, action.fighters === true);
|
||||
requireRule(
|
||||
origins.every(source => !balisetPrevents(g, id, source, to)),
|
||||
'Baliset prevents movement into this CHOAM territory; shipment remains possible.',
|
||||
);
|
||||
allowedEntry(g, p, to, s, false, advisors && !wantsFighters);
|
||||
requireRule(
|
||||
sourceKeys.every((key) => key !== location(to, s)),
|
||||
'Choose a different destination.',
|
||||
);
|
||||
// Reject known unsupported arrivals before any movement-card event or
|
||||
// native movement-prevention window can commit this declaration.
|
||||
validateMovementArrival(g, {
|
||||
player: id, group, eliteGroup, elite, origin, origins, total: n,
|
||||
to, sector: s, advisors, wantsFighters, lockedTurn,
|
||||
});
|
||||
let flight = g.ornithopter?.player === id ? g.ornithopter : null;
|
||||
if (action.movementCard !== undefined) {
|
||||
const mode = action.ornithopter;
|
||||
requireRule(
|
||||
mode === 'range3' || mode === 'twoGroups',
|
||||
'Choose an Ornithopter movement mode.',
|
||||
);
|
||||
const blocked = ornithopterBlock(g, p);
|
||||
requireRule(!blocked, blocked ?? 'Ornithopter cannot be played.');
|
||||
const card = p.hand.find((c) => c.id === action.movementCard);
|
||||
requireRule(
|
||||
card && richeseCardDefinition(card)?.card.effect === 'ornithopter',
|
||||
'Choose the canonical Ornithopter in your hand.',
|
||||
);
|
||||
const reserved = transferCardBlock(g, p, card);
|
||||
requireRule(!reserved, reserved ?? 'The card is committed.');
|
||||
requireRule(
|
||||
action.ornithopterEvent === undefined,
|
||||
'A new card movement cannot name an old event.',
|
||||
);
|
||||
flight = {
|
||||
event: crypto.randomUUID(),
|
||||
player: id,
|
||||
card,
|
||||
turn: g.turn,
|
||||
mode,
|
||||
startingMove: p.moved,
|
||||
completed: 0,
|
||||
};
|
||||
g.ornithopter = flight;
|
||||
p.hand = p.hand.filter((c) => c.id !== card.id);
|
||||
log(
|
||||
g,
|
||||
`${p.name} played Ornithopter: ${mode === 'range3' ? 'one group with a maximum three-territory route' : 'two different groups using normal movement'}. The card remains on the table until its use finishes.`,
|
||||
);
|
||||
} else if (flight)
|
||||
requireRule(
|
||||
action.ornithopterEvent === flight.event,
|
||||
'Use the current Ornithopter movement event.',
|
||||
);
|
||||
else
|
||||
requireRule(
|
||||
action.ornithopterEvent === undefined &&
|
||||
action.ornithopter === undefined,
|
||||
'No matching Ornithopter movement is active.',
|
||||
);
|
||||
const move: MovementOrder = {
|
||||
player: id,
|
||||
group,
|
||||
eliteGroup,
|
||||
elite,
|
||||
origin,
|
||||
...(planetologist ? {planetologist} : {}),
|
||||
...(planetologist?.mode === 'gather' ? {origins} : {}),
|
||||
total: n,
|
||||
to,
|
||||
sector: s,
|
||||
advisors,
|
||||
wantsFighters,
|
||||
lockedTurn,
|
||||
...(noField ? { noField } : {}),
|
||||
...(flight
|
||||
? {
|
||||
ornithopterEvent: flight.event,
|
||||
ornithopterRange: flight.mode === 'range3',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (action.discoveryOrnithopter !== undefined)
|
||||
move.discoveryFlight = nexusRule(() => quoteDiscoveryFlight(g, action.discoveryOrnithopter, move));
|
||||
validatePlanetologistMove(g, p, move);
|
||||
const speed = movementOrderRange(g, p, move);
|
||||
if (action.sandmaster !== undefined) {
|
||||
move.sandmaster = nexusRule(() => quoteSandmasterMovement(g, id, sandmasterOrder(g, p, move), action.sandmaster));
|
||||
validateSandmasterOrder(g, p, move);
|
||||
}
|
||||
requireRule(
|
||||
sourceKeys.every(
|
||||
(key) =>
|
||||
gameDistance(g, key, location(to, s), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
|
||||
) <= movementOrderRange(g, p, move, splitLocation(key).territory),
|
||||
),
|
||||
planetologist?.mode === 'gather'
|
||||
? 'Each selected origin must have an unblocked route within its own movement range.'
|
||||
: `That destination is blocked or more than ${speed} territories away.`,
|
||||
);
|
||||
validateFlightSelection(g, p, move);
|
||||
if (advisorGather) {
|
||||
g.response = {
|
||||
kind: 'advisorFlip', owner: id, location: to, advisors: false, amount: n, passed: [],
|
||||
advisorResume: 'planetologistGather', advisorGatherMove: { turn: g.turn, move: p.moved, order: move },
|
||||
};
|
||||
} else if (
|
||||
!move.ornithopterRange && !move.discoveryFlight &&
|
||||
p.faction === 'ixians' &&
|
||||
elite > 0 &&
|
||||
!(p.ixMovementBlocked?.turn === g.turn && p.ixMovementBlocked.move === p.moved) &&
|
||||
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
|
||||
(move.sandmaster
|
||||
? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1)
|
||||
: group.some(([key]) =>
|
||||
selectedOriginElites(group, eliteGroup, splitLocation(key).territory) > 0 &&
|
||||
gameDistance(g, key, location(to, s), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
|
||||
) > planetologistRange(1, planetologist?.mode)))
|
||||
) {
|
||||
g.pendingIxMove = move;
|
||||
g.response = {
|
||||
kind: 'ixMovement',
|
||||
owner: id,
|
||||
location: location(to, s),
|
||||
amount: n,
|
||||
elite,
|
||||
passed: [],
|
||||
};
|
||||
} else if (
|
||||
!move.ornithopterRange && !move.discoveryFlight &&
|
||||
p.faction === 'fremen' &&
|
||||
p.fremenNexusMovementBlockedTurn !== g.turn &&
|
||||
!(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) &&
|
||||
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
|
||||
(move.sandmaster
|
||||
? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1)
|
||||
: group.some(
|
||||
([key]) => gameDistance(g, key, location(to, s), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, origin))) > planetologistRange(1, planetologist?.mode)))
|
||||
) {
|
||||
g.pendingFremenMove = { turn: g.turn, move: p.moved, order: move };
|
||||
g.response = {
|
||||
kind: 'fremenMovement',
|
||||
...(move.sandmaster ? {sandmasterProof: JSON.stringify(move.sandmaster)} : {}),
|
||||
owner: id,
|
||||
location: location(to, s),
|
||||
amount: n,
|
||||
passed: [],
|
||||
};
|
||||
} else offerChoamMovement(g, move);
|
||||
declareGroundMovement(g, p, action);
|
||||
return g;
|
||||
}
|
||||
if (t === 'nexusGuildSkipShipment') {
|
||||
@@ -34854,6 +35009,8 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (t === 'endMovement') {
|
||||
requireRule(!liveMovementPromises(g.movementPromises ?? [], id, g.turn).some(promise => promise.answer),
|
||||
'Complete your accepted ground-movement promises before ending movement.');
|
||||
finishShipmentPromises(g, p, null);
|
||||
requireRule(
|
||||
g.phase === 5 && g.active === id,
|
||||
@@ -35286,7 +35443,7 @@ function applyActionInner(
|
||||
? { faction: p.faction, name: 'Ghola revival' }
|
||||
: undefined,
|
||||
);
|
||||
// Shipment feasibility executes deterministic preparations on private clones.
|
||||
// Shipment/movement feasibility executes deterministic preparations on private clones.
|
||||
// Its internal mode never comes from the submitted action or player state.
|
||||
if (execution === 'live') stageOrdinaryCardDiscard(g, p, used);
|
||||
return g;
|
||||
@@ -35423,6 +35580,12 @@ export function viewGame(state: Game, id: string) {
|
||||
battleChoices: g.status === 'playing' && g.phase === 6 ? battleOrderQuote(g).choices : [],
|
||||
battleOrder: projectedBattleOrder(g),
|
||||
guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id),
|
||||
truthMovementTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id),
|
||||
truthMovementAnswers: g.truthtrance?.stage === 'answer' &&
|
||||
g.truthtrance.question?.kind === 'movement' && g.truthtrance.question.target === id
|
||||
? movementTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
movementPromises: g.movementPromises ?? [],
|
||||
movementPromisePolicy: projectedGroundMovementPolicy(g, me),
|
||||
truthShipmentSupported: g.players.some(p => shipmentPromiseModeSupported(g, p.id)),
|
||||
truthShipmentTargets: !g.response && !g.decision && !g.phaseOpening && !g.pendingShipment
|
||||
? g.players.filter(p => shipmentPromiseModeSupported(g, p.id) &&
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Action, Game, Player } from './engine';
|
||||
import { gameTerritories, splitLocation } from './board';
|
||||
import { liveMovementPromises, movementEventAllowed, settleMovementEvent, type MovementEvent, type MovementPromise } from './movement-promises';
|
||||
|
||||
export type MovementPromiseAdapter = {
|
||||
allowance: (game: Game, player: Player) => number;
|
||||
prepareHajr: (game: Game, player: string) => Game | null;
|
||||
reachable: (game: Game, player: Player, origin: string, destination: string, sector: number) => boolean;
|
||||
project: (game: Game, player: string, action: Action) => { game: Game; event: MovementEvent } | null;
|
||||
};
|
||||
export type MovementCompletion = { actions: Action[] };
|
||||
|
||||
/** Enumerate actual physical subgroups from one territory, including native
|
||||
* elite allocations and several sectors. No strategic shortlist proves legality. */
|
||||
function* groups(player: Player, keys: string[], amount: number, index = 0,
|
||||
forces: Record<string, number> = {}, elites: Record<string, number> = {}): Generator<Action> {
|
||||
if (index === keys.length) {
|
||||
if (amount === 0) yield { type: 'move', forces: { ...forces }, eliteForces: { ...elites } };
|
||||
return;
|
||||
}
|
||||
const key = keys[index], special = player.elites?.forces[key] ?? 0;
|
||||
const ordinary = player.forces[key] - special;
|
||||
for (let elite = 0; elite <= Math.min(amount, special); elite++)
|
||||
for (let normal = Math.min(ordinary, amount - elite); normal >= 0; normal--) {
|
||||
const count = elite + normal;
|
||||
if (count) forces[key] = count; else delete forces[key];
|
||||
if (elite) elites[key] = elite; else delete elites[key];
|
||||
yield* groups(player, keys, amount - count, index + 1, forces, elites);
|
||||
}
|
||||
delete forces[key]; delete elites[key];
|
||||
}
|
||||
|
||||
/** Finite owned-action reachability. Each move consumes a real entitlement;
|
||||
* Hajr and Ornithopter are existing physical cards, never invented future draws.
|
||||
* The adapter shares authoritative movement validation/physical commitment.
|
||||
* Opposing arrival outcomes are not simulated or assumed. */
|
||||
export function findMovementCompletion(source: Game, actor: string,
|
||||
promises: readonly MovementPromise[], adapter: MovementPromiseAdapter): MovementCompletion | null {
|
||||
const failed = new Set<string>();
|
||||
const search = (game: Game, pending: readonly MovementPromise[], actions: Action[]): MovementCompletion | null => {
|
||||
const live = liveMovementPromises(pending, actor, game.turn);
|
||||
if (!live.some(promise => promise.answer)) return { actions };
|
||||
const player = game.players.find(candidate => candidate.id === actor)!;
|
||||
const physical = Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
|
||||
if (live.some(promise => promise.answer && promise.minimum > physical)) return null;
|
||||
const key = JSON.stringify([player.forces, player.elites, player.moved, player.hand.map(card => card.id),
|
||||
player.hajrPlays, player.ornithopterExtraMoves, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null,
|
||||
live.map(promise => [promise.territory, promise.minimum, promise.answer])]);
|
||||
if (failed.has(key)) return null;
|
||||
const destinations = gameTerritories(game).filter(destination => !destination.id.startsWith('homeworld:'));
|
||||
destinations.sort((a, b) => Number(live.some(p => p.answer && p.territory === b.id)) -
|
||||
Number(live.some(p => p.answer && p.territory === a.id)));
|
||||
if (player.moved < adapter.allowance(game, player)) {
|
||||
const origins = [...new Set(Object.keys(player.forces).filter(key => player.forces[key] > 0)
|
||||
.map(key => splitLocation(key).territory))];
|
||||
const card = !game.ornithopter ? player.hand.find(card => card.effect === 'ornithopter') : null;
|
||||
const modes: Partial<Action>[] = game.ornithopter
|
||||
? [{ ornithopterEvent: game.ornithopter.event }]
|
||||
: [{}, ...(card ? [{ movementCard: card.id, ornithopter: 'range3' },
|
||||
{ movementCard: card.id, ornithopter: 'twoGroups' }] : [])];
|
||||
for (const destination of destinations) for (const sector of destination.sectors) for (const origin of origins) {
|
||||
if (!adapter.reachable(game, player, origin, destination.id, sector)) continue;
|
||||
const keys = Object.keys(player.forces).filter(key => player.forces[key] > 0 && splitLocation(key).territory === origin);
|
||||
const total = keys.reduce((sum, key) => sum + player.forces[key], 0);
|
||||
const amounts = Array.from({ length: total }, (_, i) => i + 1);
|
||||
amounts.sort((a, b) => Number(live.some(p => p.answer && p.territory === destination.id && b >= p.minimum)) -
|
||||
Number(live.some(p => p.answer && p.territory === destination.id && a >= p.minimum)) || a - b);
|
||||
for (const amount of amounts) {
|
||||
const event = { territory: destination.id, amount };
|
||||
if (!movementEventAllowed(live, event)) continue;
|
||||
for (const group of groups(player, keys, amount)) for (const mode of modes) {
|
||||
const action = { ...group, ...mode, territory: destination.id, sector };
|
||||
const next = adapter.project(game, actor, action);
|
||||
if (!next) continue;
|
||||
const settled = settleMovementEvent(pending, actor, game.turn, next.event);
|
||||
next.game.movementPromises = structuredClone(settled);
|
||||
const result = search(next.game, settled, [...actions, action]);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const prepared = adapter.prepareHajr(game, actor);
|
||||
if (prepared) {
|
||||
const card = player.hand.find(card => card.effect === 'hajr')!;
|
||||
const result = search(prepared, live, [...actions, { type: 'card', card: card.id }]);
|
||||
if (result) return result;
|
||||
}
|
||||
failed.add(key); return null;
|
||||
};
|
||||
const initial = structuredClone(source); initial.movementPromises = structuredClone([...promises]);
|
||||
return search(initial, promises, []);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { TERRITORIES, territory } from './board';
|
||||
import { FACTIONS } from './catalog';
|
||||
import type { Game } from './engine';
|
||||
|
||||
/** One qualifying ground movement, never the sum of several smaller groups. */
|
||||
export type MovementClaim = { territory: string; minimum: number };
|
||||
export type MovementEvent = { territory: string; amount: number };
|
||||
export type MovementPromise = MovementClaim & {
|
||||
turn: number;
|
||||
player: string;
|
||||
asker: string;
|
||||
answer: boolean;
|
||||
fulfilled?: boolean;
|
||||
released?: boolean;
|
||||
};
|
||||
export class MovementClaimError extends Error {}
|
||||
function check(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new MovementClaimError(message);
|
||||
}
|
||||
|
||||
/** Extract claim fields; the caller validates surrounding question/ledger keys. */
|
||||
export function parseMovementClaim(value: unknown): MovementClaim {
|
||||
check(value && typeof value === 'object' && !Array.isArray(value),
|
||||
'Choose a valid ground-movement statement.');
|
||||
const claim = value as Record<string, unknown>;
|
||||
check(typeof claim.territory === 'string' &&
|
||||
TERRITORIES.some(destination => destination.id === claim.territory),
|
||||
'Choose a printed ground-movement destination.');
|
||||
check(typeof claim.minimum === 'number' && Number.isSafeInteger(claim.minimum) &&
|
||||
claim.minimum >= 1 && claim.minimum <= 20,
|
||||
'Choose a minimum of one to twenty physical forces.');
|
||||
return { territory: claim.territory, minimum: claim.minimum };
|
||||
}
|
||||
export function validMovementClaim(value: unknown): value is MovementClaim {
|
||||
try { parseMovementClaim(value); return true; }
|
||||
catch (error) {
|
||||
if (error instanceof MovementClaimError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export function movementClaimText(claim: MovementClaim): string {
|
||||
return `make a ground movement of at least ${claim.minimum} physical forces to ${territory(claim.territory).name}`;
|
||||
}
|
||||
export function movementMatches(claim: MovementClaim, event: MovementEvent | null): boolean {
|
||||
return event !== null && event.territory === claim.territory &&
|
||||
Number.isSafeInteger(event.amount) && event.amount >= claim.minimum && event.amount <= 20;
|
||||
}
|
||||
export function liveMovementPromises<T extends MovementPromise>(
|
||||
promises: readonly T[], player: string, turn: number,
|
||||
): T[] {
|
||||
return promises.filter(promise => promise.player === player && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released);
|
||||
}
|
||||
|
||||
/** The caller supplies the relevant current promises. Yes is existential: a
|
||||
* nonmatching event may be followed by another ordinary movement opportunity. */
|
||||
export function movementEventAllowed(
|
||||
promises: readonly MovementPromise[], event: MovementEvent | null,
|
||||
): boolean {
|
||||
return promises.every(promise => promise.fulfilled || promise.released ||
|
||||
promise.answer || !movementMatches(promise, event));
|
||||
}
|
||||
export function settleMovementEvent<T extends MovementPromise>(
|
||||
promises: readonly T[], player: string, turn: number, event: MovementEvent | null,
|
||||
): T[] {
|
||||
const live = liveMovementPromises(promises, player, turn);
|
||||
check(movementEventAllowed(live, event),
|
||||
'This ground movement would break an accepted No answer.');
|
||||
return promises.map(promise => live.includes(promise) && promise.answer && movementMatches(promise, event)
|
||||
? { ...promise, fulfilled: true } : promise);
|
||||
}
|
||||
|
||||
/** Close only the actual player's movement turn. No stays binding until here;
|
||||
* ending early cannot evade an unfulfilled Yes, even if this event was skipped. */
|
||||
export function finishMovementPromises<T extends MovementPromise>(
|
||||
promises: readonly T[], player: string, turn: number,
|
||||
): T[] {
|
||||
const live = liveMovementPromises(promises, player, turn);
|
||||
check(live.every(promise => !promise.answer),
|
||||
'Complete your accepted ground-movement promise before ending movement.');
|
||||
return promises.map(promise => live.includes(promise) ? { ...promise, fulfilled: true } : promise);
|
||||
}
|
||||
|
||||
/** Prototype profile/timing only. Actual route feasibility stays authoritative
|
||||
* in the engine. Truthtrance and an ordinary active Ornithopter may remain. */
|
||||
export function movementPromiseTargetAvailable(g: Game, player: string): boolean {
|
||||
const target = g.players.find(candidate => candidate.id === player);
|
||||
return g.status === 'playing' && g.advanced === false && g.phase === 5 &&
|
||||
g.active === player && !!target && (target.shipped || target.moved > 0) &&
|
||||
!!g.movementRemaining?.includes(player) &&
|
||||
g.players.length >= 2 && g.players.length <= 6 &&
|
||||
g.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) &&
|
||||
new Set(g.expansions).size === g.expansions.length &&
|
||||
g.players.every(candidate => {
|
||||
const family = FACTIONS.find(entry => entry.id === candidate.faction)?.expansion;
|
||||
return family === 'base' || (!!family && g.expansions.includes(family));
|
||||
}) &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens &&
|
||||
!g.strongholdCards && !g.discoveryEnabled && !g.discoveries &&
|
||||
!g.discoveryStash && !g.greatMaker && !g.ecazTreachery &&
|
||||
!g.response && !g.decision && !g.phaseOpening &&
|
||||
!g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingGuildTransport &&
|
||||
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingArrivalOverlap &&
|
||||
!g.pendingArrivalReaction?.length && !g.pendingFremenMove && !g.pendingIxMove &&
|
||||
!g.pendingChoamMove && !g.pendingTreacheryDiscard;
|
||||
}
|
||||
+27
-2
@@ -2922,6 +2922,30 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, UI, compound privacy, bots, JSON and production SQLite restoration; broader Truthtrance acceptance remains open.', evidence: ['tests/truthtrance-knowledge.test.ts', 'tests/truthtrance-knowledge-controls.test.tsx', 'tests/truthtrance-knowledge-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-movement',
|
||||
title: 'Truthtrance: ground-movement promises',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Bind one future qualifying physical ground movement before an active Basic respondent finishes their current turn.',
|
||||
steps: [
|
||||
'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Ask the active Basic player after shipment and finish pending decisions first. Selected native families are supported; optional modules, Advanced and earlier or waiting turns remain unfinished.',
|
||||
'Yes requires one qualifying ground movement from now until that player’s Shipment and Movement turn ends, while possible. Smaller groups do not add together and earlier moves do not count. Shipment, HMS relocation and Ambassador relocation do not fulfill this statement; a concealed No-Field marker itself is not a physical on-board force counter.',
|
||||
'A nonmatching move leaves Yes pending when an owned later route remains. One matching physical commitment fulfills it before card disposal and later Intrusion, Ambassador or Terror effects. You are promising the move, not survival afterward. No forbids every later matching move, including Hajr and both Ornithopter groups, until the actual turn closes.',
|
||||
'Private feasible answers and the next owned step use actual movement legality, typed groups, held Hajr, both Ornithopter modes and original cohorts, current city access and native bonuses such as Kulon. Unknown future draws/gifts are not invented. Other preparation families, including Box recovery and native relocation preparation, remain unfinished.',
|
||||
'Voluntary actions cannot erase the last required route. Native counter responses finish before impossibility is decided. An opposing loss releases only incompatible answers, retaining No constraints and still-possible Yes answers in accepted order. This owned-route and release priority is a provisional software composition of the general current-turn rule, not a publisher movement-specific FAQ.',
|
||||
'Persistent answer and next-step controls do not time out. All four profiles use the same private legal guidance. Broader Truthtrance questions, complete modes, full combinations and final acceptance remain unfinished.',
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-shipment', 'movement', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'A pure ledger and finite owned-action search reuse authoritative declaration and physical commitment. Yes settles before arrivals/disposal; No closes only at actual turn end. Shared native movement, current Kulon and original card cohorts remain. Earlier timing, other preparations and optional/Advanced profiles stay unfinished.', evidence: ['game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Dedicated destination/minimum, exact public preview, private feasible answers and persistent busy-safe next actions have current Basic controls. Browser/visual acceptance remains open.', evidence: ['components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-movement-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles make twelve accepted answer/move/end proposals from real public Basic source views. This is minimal legal participation without strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-engine.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'General FAQ authority, existential single-event meaning, explicit software composition, source selection and unfinished scope are documented.', evidence: ['docs/TRUTHTRANCE_MOVEMENT_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Seventeen-file218/218, types/lint/build and seven original-action promise-handler/table-rendering programmes pass. Public classic source20, native paid Ornithopter source58 and genuine Kulon source26 retain actual pieces/cards/payments; only unopened positions and scalar lotteries are selected. Separate extra-card/force-loss edges disclose conserved staging. Review corrected Kulon reachability/selective release; private preparation and old fixture feedback were repaired. No browser/visual/deployed, full-mode or comprehensive assurance claim.', evidence: ['tests/movement-promises.test.ts', 'tests/truth-movement-engine.test.ts', 'tests/truth-movement-controls.test.tsx', 'tests/fixture-truth-movement.ts', 'tests/fixture-truth-movement-choam.ts', 'tests/fixture-basic-semuta-ambassador.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-shipment',
|
||||
title: 'Truthtrance: shipment promises',
|
||||
@@ -3007,7 +3031,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'Ask another player a public yes/no question. Verified facts, supported shipment promises, current-battle plan commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
|
||||
'Ask another player a public yes/no question. Verified facts, supported shipment and ground-movement promises, current-battle plan commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
|
||||
steps: [
|
||||
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
|
||||
'Ask a game-related yes/no question. Named-card, selected-traitor and current personal-spice questions are checked by the server. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
|
||||
@@ -3016,7 +3040,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The questioned player answers publicly. A definite answer discards the card. If the answer cannot be known, the holder may ask a different question or save Truthtrance for later.',
|
||||
'A saved definite answer resumes automatically: its public answer, binding promise and consumed card are recorded once, then the next queued question or interrupted action resumes. Private answer and preparation guidance is unavailable during this automatic recovery.',
|
||||
'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.',
|
||||
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide.',
|
||||
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Active post-shipment Basic native turns also support one future ground-movement minimum: Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
|
||||
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
|
||||
'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.',
|
||||
'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.',
|
||||
@@ -3030,6 +3054,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'truthtrance-card-count',
|
||||
'truthtrance-hand-inventory',
|
||||
'truthtrance-shipment',
|
||||
'truthtrance-movement',
|
||||
'richese-no-field',
|
||||
'card-reinforcements',
|
||||
],
|
||||
|
||||
+19
-1
@@ -1,3 +1,4 @@
|
||||
import { parseMovementClaim, movementClaimText, movementPromiseTargetAvailable, MovementClaimError, type MovementClaim } from './movement-promises';
|
||||
import { shipmentAvailable } from './shipment-opportunity';
|
||||
import type { ShipmentClaim } from './shipment-promises';
|
||||
import {
|
||||
@@ -52,6 +53,7 @@ export type TruthFact =
|
||||
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
|
||||
| { kind: 'and' | 'or'; terms: TruthFact[] };
|
||||
export type TruthQuestion =
|
||||
| ({ kind: 'movement'; target: string } & MovementClaim)
|
||||
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
|
||||
| { kind: 'mixedShipment'; target: string; mixed: MixedShipmentExpression }
|
||||
| { kind: 'battlePlan'; target: string; territory: string; claim: PlanClaim }
|
||||
@@ -191,6 +193,14 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
g.players.some((p) => p.id === v.target),
|
||||
'Ask one other player.',
|
||||
);
|
||||
if (v.kind === 'movement') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,territory',
|
||||
'Supply one ground-movement destination, minimum and respondent.');
|
||||
check(movementPromiseTargetAvailable(g, v.target as string),
|
||||
'Ground-movement promises require a clean active Basic turn after shipment, with no optional modules.');
|
||||
try { return { kind: 'movement', target: v.target as string, ...parseMovementClaim(v) }; }
|
||||
catch (error) { if (error instanceof MovementClaimError) throw new TruthError(error.message); throw error; }
|
||||
}
|
||||
if (v.kind === 'shipment' || v.kind === 'mixedShipment') {
|
||||
check(
|
||||
shipmentPromiseModeSupported(g, v.target) &&
|
||||
@@ -295,7 +305,7 @@ export function validateSavedTruthtrance(g: Game) {
|
||||
'The saved Truthtrance must retain each committed physical card in its holder’s hand.',
|
||||
);
|
||||
}
|
||||
if (window?.question?.kind === 'fact') {
|
||||
if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement') {
|
||||
check((window.stage === 'answer' || window.stage === 'unknown') && window.queue.length > 0,
|
||||
'The saved fact question must retain its asking player and answer stage.');
|
||||
check(JSON.stringify(parseQuestion(g, window.queue[0].player, window.question)) === JSON.stringify(window.question),
|
||||
@@ -397,6 +407,7 @@ export function truthQuestionText(
|
||||
locationName: (id: string) => string,
|
||||
): string {
|
||||
if (q.kind === 'freeform') return q.text;
|
||||
if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
|
||||
if (q.kind === 'shipment')
|
||||
return `Will you ${shipmentClaimText(q)} this turn?`;
|
||||
if (q.kind === 'battlePlan')
|
||||
@@ -437,6 +448,8 @@ export function resolveTruthAction(
|
||||
asker: string,
|
||||
mixed?: MixedShipmentExpression,
|
||||
) => void;
|
||||
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
|
||||
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
|
||||
battleAnswers: (g: Game, p: Player, claim: PlanClaim) => TruthAnswer[];
|
||||
bindBattle: (
|
||||
g: Game,
|
||||
@@ -595,6 +608,11 @@ export function resolveTruthAction(
|
||||
effects.bindShipment(g, p, claim, a.answer === 'yes', current.player,
|
||||
q.kind === 'mixedShipment' ? q.mixed : undefined);
|
||||
}
|
||||
if (q.kind === 'movement') {
|
||||
check(effects.movementAnswers && effects.bindMovement && effects.movementAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer consistent with legal ground movement and earlier promises.');
|
||||
if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'battlePlan') {
|
||||
check(
|
||||
effects.battleAnswers(g, p, q.claim).includes(a.answer),
|
||||
|
||||
@@ -107,9 +107,11 @@ function originalPacket(effect: BasicSemutaAmbassadorEffect, copied: boolean) {
|
||||
* grant BEFORE any discard, payout, replacement draw or Semuta commitment. */
|
||||
export function createBasicSemutaAmbassadorFixture(options: {
|
||||
effect: BasicSemutaAmbassadorEffect; copied?: boolean; arrival?: 'shipment' | 'movement';
|
||||
cacheCard?: 'semuta' | 'ornithopter'; ecazTruth?: boolean;
|
||||
}) {
|
||||
const effect = options.effect, copied = options.copied ?? false, packet = originalPacket(effect, copied);
|
||||
const arrivalKind = options.arrival ?? 'shipment';
|
||||
const cacheCardId = options.cacheCard === 'ornithopter' ? 'richese-ornithopter' : SEMUTA_DRUG_ID;
|
||||
let game = createGame('BASICSEMUTAAMBASSADOR', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'choam']);
|
||||
for (const faction of ['choam', 'richese', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
@@ -131,7 +133,7 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
assert.equal(game.deck.length, 35); assert.equal(game.richeseCache?.length, 10);
|
||||
assert.ok(game.richeseCache!.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(game.richeseCache!.some(card => card.id === cacheCardId));
|
||||
const originalMainDeck = game.deck.map(card => card.id).sort();
|
||||
const originalCache = game.richeseCache!.map(card => card.id).sort();
|
||||
const originalTreachery = basicSemutaAmbassadorInventory(game);
|
||||
@@ -139,6 +141,7 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
// Ordinary cards avoid optional card-use controls during the source path.
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 5);
|
||||
assert.equal(ordinary.length, 5);
|
||||
if (options.ecazTruth) ordinary[0] = game.deck.find(card => card.effect === 'truthtrance')!;
|
||||
const deal = ordinary.map(card => card.id);
|
||||
const dealOrder = deal.map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
@@ -177,13 +180,13 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
|
||||
const beforeCacheOffer = structuredClone(game), acquisitionStart = actions.length;
|
||||
const cacheOffer: BasicSemutaAmbassadorStep = { actor: 'richese', action: { type: 'decision',
|
||||
event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID, method: 'onceAround', direction: 'counterclockwise' } };
|
||||
event: game.richeseBidding!.event, card: cacheCardId, method: 'onceAround', direction: 'counterclockwise' } };
|
||||
act(cacheOffer.actor, cacheOffer.action);
|
||||
reach(state => basicSemutaAmbassadorClean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
|
||||
const cacheAuction = structuredClone(game);
|
||||
const cacheBidSteps: BasicSemutaAmbassadorStep[] = [];
|
||||
let semutaBid: BasicSemutaAmbassadorTransition | null = null;
|
||||
for (let count = 0; !basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
|
||||
for (let count = 0; !basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === cacheCardId) && count < 8; count++) {
|
||||
const lot = game.richeseAuction; assert.ok(lot && !lot.outcome && lot.active);
|
||||
const bid: BasicSemutaAmbassadorStep = { actor: lot.active, action: { type: 'richeseBid', event: lot.event,
|
||||
amount: lot.active === holder ? 1 : null, allyPayment: 0 } };
|
||||
@@ -191,7 +194,7 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
const result = transition(bid, state => basicSemutaAmbassadorClean(state));
|
||||
if (bid.actor === holder) semutaBid = result;
|
||||
}
|
||||
assert.ok(semutaBid); assert.ok(basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaBid); assert.ok(basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === cacheCardId));
|
||||
assert.equal(game.richeseCache!.length, 9);
|
||||
assert.equal(basicSemutaAmbassadorPlayer(game, holder).spice,
|
||||
basicSemutaAmbassadorPlayer(beforeCacheOffer, holder).spice - 1);
|
||||
@@ -239,7 +242,7 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
assert.equal(game.pendingAmbassador!.beneficiary, beneficiary); assert.equal(game.pendingAmbassador!.entrant, entrant);
|
||||
assert.equal(game.decision?.player, beneficiary); assert.equal(game.active, entrant);
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, 5); assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.ok(basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === cacheCardId));
|
||||
assert.deepEqual(basicSemutaAmbassadorInventory(game), originalTreachery);
|
||||
const currentGrant = structuredClone(game), eligibleChosenIds = basicSemutaAmbassadorPlayer(game, beneficiary).hand
|
||||
.slice(0, effect === 'choam' ? 2 : 1).map(card => card.id);
|
||||
@@ -251,11 +254,11 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
cacheAuction, cacheBidSteps, semutaBid, acquisition, normalPurchase, placement, beforeArrival, shipment, arrival, arrivalKind, offer,
|
||||
triggerStep, beforeCopy, copyStep, currentGrant, ownerView: viewGame(game, owner), holderView: viewGame(game, holder),
|
||||
entrantView: viewGame(game, entrant), effect, copied, advanced: false as const,
|
||||
owner, beneficiary, holder, entrant, token: token.id, semuta: SEMUTA_DRUG_ID, event, territory, sector,
|
||||
location: `${territory}:${sector}`, eligibleChosenIds, chosenCards: eligibleChosenIds, discardStep,
|
||||
owner, beneficiary, holder, entrant, token: token.id, semuta: cacheCardId, event, territory, sector,
|
||||
location: `${territory}:${sector}`, cacheCard: cacheCardId, eligibleChosenIds, chosenCards: eligibleChosenIds, discardStep,
|
||||
preReplacementDeckTop, balances: basicSemutaAmbassadorBalances(game),
|
||||
entrantContinuation: { actor: entrant, action: { type: 'endMovement' } } as BasicSemutaAmbassadorStep,
|
||||
actions, sourceActionCount: actions.length,
|
||||
staging: ['Only five unopened original main-deck positions (four starts and the first normal purchase), two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine explicitly recorded draws select the original Ambassador packet on the final original traitor choice. Other scalar draws retain Fisher–Yates order; UUID/byte entropy remains native. Public Basic Ecaz/CHOAM/Richese/Guild setup retains the selected Ecaz+CHOAM decks (35 main plus 10 genuine native cache cards) and no optional modules. Guild buys the physical Semuta Drug in the real Once Around cache auction for 1; Ecaz pays 1 for the original first ordinary card and 1 for its actual placement. Guild then pays 1 for the non-allied original reserve entry. Native/BG-copy choices stop at the original cards grant. No dealt hands, resources, armies, clocks, alliances, deaths, token custody, pending reactions or receipts are installed. No discard, CHOAM payout, Ixian replacement or Semuta commitment has happened in this source snapshot.'] };
|
||||
staging: [`Only five unopened original main-deck positions (four starts and the first normal purchase), two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine explicitly recorded draws select the original Ambassador packet on the final original traitor choice. Other scalar draws retain Fisher–Yates order; UUID/byte entropy remains native. Public Basic Ecaz/CHOAM/Richese/Guild setup retains the selected Ecaz+CHOAM decks (35 main plus 10 genuine native cache cards) and no optional modules. Guild buys the physical Semuta Drug in the real Once Around cache auction for 1; Ecaz pays 1 for the original first ordinary card and 1 for its actual placement. Guild then pays 1 for the non-allied original reserve entry. Native/BG-copy choices stop at the original cards grant. No dealt hands, resources, armies, clocks, alliances, deaths, token custody, pending reactions or receipts are installed. No discard, CHOAM payout, Ixian replacement or Semuta commitment has happened in this source snapshot. Optional source selection buys ${cacheCardId} instead and deals original Truthtrance to Ecaz=${!!options.ecazTruth}.`] };
|
||||
}
|
||||
export type BasicSemutaAmbassadorFixture = ReturnType<typeof createBasicSemutaAmbassadorFixture>;
|
||||
@@ -76,7 +76,18 @@ const key = (id: StrongholdId) => id === MOBILE_STRONGHOLD ? MOBILE_LOCATION : `
|
||||
* a human must seal a plan; the helper never reads or invents that private plan. */
|
||||
export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactionsNativeStep | null {
|
||||
if (game.status === 'finished') return null;
|
||||
if (game.pendingTreacheryDiscard) return { actor: game.host, action: { type: 'advanceBots' } };
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
|
||||
if (reaction?.stage === 'offer') {
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
|
||||
return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } };
|
||||
}
|
||||
if (reaction?.stage === 'select') {
|
||||
const action = botActions(viewGame(game, reaction.player))[0]; assert.ok(action);
|
||||
return { actor: reaction.player, action };
|
||||
}
|
||||
return { actor: game.host, action: { type: 'advanceBots' } };
|
||||
}
|
||||
if (game.phaseOpening) {
|
||||
const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id))?.id;
|
||||
assert.ok(actor);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Game } from '../game/engine';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { actTruthMovement as apply, truthMovementClean as clean, truthMovementInventory as inventory,
|
||||
truthMovementPlayer as player, type TruthMovementStep } from './fixture-truth-movement';
|
||||
|
||||
/** Genuine public native Basic CHOAM, actual dealt Kulon/Truthtrance, actual
|
||||
* paid Arrakeen shipment and native allowed Kulon. No movement state is staged. */
|
||||
export function createTruthMovementChoamFixture() {
|
||||
let game = createGame('TRUTHMOVEMENTCHOAM', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam']);
|
||||
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
|
||||
const actions: TruthMovementStep[] = [];
|
||||
const act = (actor: string, action: TruthMovementStep['action']) => { game = apply(game, actor, action, actions); };
|
||||
const reach = (until: (game: Game) => boolean) => {
|
||||
for (let count = 0; !until(game) && count < 500; count++) {
|
||||
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next); act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game));
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), originalCards = inventory(game);
|
||||
assert.ok(game.players.every(p => !p.hand.length));
|
||||
const kulon = game.deck.find(card => card.name === 'Kulon')!, truth = game.deck.find(card => card.effect === 'truthtrance')!;
|
||||
assert.ok(kulon && truth);
|
||||
const dealOrder = [kulon, truth].map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
reach(state => state.status === 'playing');
|
||||
assert.ok(player(game, 'choam').hand.some(card => card.id === kulon.id));
|
||||
assert.ok(player(game, 'emperor').hand.some(card => card.id === truth.id));
|
||||
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) };
|
||||
});
|
||||
reach(state => clean(state) && state.phase === 5 && state.active === 'choam');
|
||||
const beforeShipment = structuredClone(game);
|
||||
act('choam', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 }); reach(clean);
|
||||
assert.equal(player(game, 'choam').spice, player(beforeShipment, 'choam').spice - 1);
|
||||
const beforeKulon = structuredClone(game); act('choam', { type: 'card', mode: 'choam', card: kulon.id });
|
||||
assert.ok(game.response?.kind === 'choamWorthless' || game.choamMovement?.bonus === 1); reach(clean);
|
||||
assert.equal(game.choamMovement?.bonus, 1); assert.equal(player(game, 'choam').forces['arrakeen:10'], 1);
|
||||
assert.deepEqual(inventory(game), originalCards);
|
||||
return { game, lobby, setup, beforeShipment, beforeKulon, actions, sourceActionCount: actions.length,
|
||||
originalCards, dealOrder, spiceOrder, target: 'choam', asker: 'emperor', truth: truth.id, kulon: kulon.id,
|
||||
staging: ['Original unopened two-card deal and two ordinary Spice positions plus native scalar lotteries only. Public Basic CHOAM/Emperor setup, actual one-spice Arrakeen shipment and actual allowed/discarded native Kulon. No hand, wallet, force, clock, allowance, bonus or promise is installed.'] };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type TruthMovementStep = { actor: string; action: Action };
|
||||
export type TruthMovementTransition = { before: Game; step: TruthMovementStep; after: Game };
|
||||
export const truthMovementPlayer = (game: Game, actor = 'atreides') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const truthMovementClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function truthMovementInventory(game: Game) {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Each original physical card has one current location');
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Select original scalar lotteries only. UUID/byte entropy remains native. */
|
||||
function withOriginalScalarLottery<T>(run: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actTruthMovement(game: Game, actor: string, action: Action,
|
||||
actions: TruthMovementStep[] = []): Game {
|
||||
const next = withOriginalScalarLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
|
||||
/** Native windows only. The caller owns Truthtrance and the shipped target's
|
||||
* actual movement choices; this helper cannot silently finish those promises. */
|
||||
export function nextTruthMovementNativeStep(game: Game): TruthMovementStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
if (game.phase === 5 && game.active === 'atreides' &&
|
||||
truthMovementPlayer(game).shipped && truthMovementClean(game)) return null;
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceTruthMovement(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthMovementStep[] = []): Game {
|
||||
for (let count = 0; count < 800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Basic programme ended before its next control');
|
||||
const next = nextTruthMovementNativeStep(game);
|
||||
assert.ok(next, `Supply the original Truthtrance or movement choice: ${JSON.stringify({
|
||||
status: game.status, turn: game.turn, phase: game.phase, active: game.active,
|
||||
truth: game.truthtrance?.stage, decision: game.decision,
|
||||
})}`);
|
||||
game = actTruthMovement(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Basic Truthtrance movement source did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Genuine public Basic source with no installed hand, wallet, force, clock or
|
||||
* pending receipt. Choose only unopened starting-deal/Spice positions and native
|
||||
* scalar lotteries. The target's optional Hajr is its actually dealt card. */
|
||||
export function createTruthMovementFixture(options: { hajr?: boolean } = {}) {
|
||||
const asker = 'emperor', target = 'atreides', withHajr = options.hajr ?? true;
|
||||
let game = createGame('TRUTHMOVEMENTSOURCE', newPlayer(target, 'Atreides', 'atreides'), false);
|
||||
joinGame(game, newPlayer(asker, 'Emperor', 'emperor'));
|
||||
const actions: TruthMovementStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTruthMovement(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTruthMovement(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
|
||||
assert.deepEqual(game.expansions, []); assert.equal(game.deck.length, 33);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalCards = truthMovementInventory(game);
|
||||
const targetCard = game.deck.find(card => withHajr ? card.effect === 'hajr' : card.kind === 'projectile');
|
||||
const truthtrance = game.deck.find(card => card.effect === 'truthtrance');
|
||||
assert.ok(targetCard && truthtrance); assert.notEqual(targetCard.id, truthtrance.id);
|
||||
const deal = [targetCard.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);
|
||||
assert.deepEqual(truthMovementPlayer(game, target).hand.map(card => card.id), [targetCard.id]);
|
||||
assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [truthtrance.id]);
|
||||
assert.deepEqual(truthMovementPlayer(game, target).forces, { 'arrakeen:10': 10 });
|
||||
assert.equal(truthMovementPlayer(game, target).reserves, 10);
|
||||
const startingPositions = Object.fromEntries(game.players.map(player => [player.id, {
|
||||
forces: structuredClone(player.forces), 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 === 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: 1, territory: 'polar_sink', sector: 0, elite: 0, allyPayment: 0,
|
||||
} };
|
||||
act(shipmentStep.actor, shipmentStep.action);
|
||||
reach(truthMovementClean);
|
||||
const shipment: TruthMovementTransition = { before: beforeShipment, step: shipmentStep, after: structuredClone(game) };
|
||||
const targetNow = truthMovementPlayer(game, target);
|
||||
assert.equal(game.active, target); assert.equal(game.phase, 5); assert.equal(game.turn, 1);
|
||||
assert.equal(targetNow.shipped, true); assert.equal(targetNow.moved, 0);
|
||||
assert.deepEqual(targetNow.forces, { 'arrakeen:10': 10, 'polar_sink:0': 1 });
|
||||
assert.equal(targetNow.reserves, 9);
|
||||
assert.equal(targetNow.spice, truthMovementPlayer(beforeShipment, target).spice - 2);
|
||||
assert.deepEqual(truthMovementInventory(game), originalCards);
|
||||
const source = structuredClone(game);
|
||||
const truthStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truthtrance.id } };
|
||||
return {
|
||||
game, source, lobby, setup, beforeStartingDeal, afterSetup, beforeShipment, shipment, shipmentStep,
|
||||
asker, target, truth: truthtrance.id, truthtrance: truthtrance.id,
|
||||
hajr: withHajr ? targetCard.id : null, targetCard: targetCard.id,
|
||||
deal, dealOrder, spiceOrder, originalCards, originalSpice, startingPositions,
|
||||
truthStep, actions, sourceActionCount: actions.length,
|
||||
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
|
||||
staging: [
|
||||
'Only two unopened original starting-deal positions, two unopened ordinary land Spice positions and native scalar lotteries are selected. Public Basic Atreides/Emperor setup retains the original 33-card deck. Emperor receives its original dealt Truthtrance; Atreides receives original dealt Hajr (or an ordinary projectile when explicitly requested). Real readiness, setup, Storm/Spice/Charity/Bidding/Revival precede a real two-spice one-force reserve shipment to Polar Sink. The original ten Arrakeen forces, native balances, real bought/dealt cards and all allowances remain. No hand, wallet, force, clock, pending decision, movement promise or receipt is installed. The source stops before any Truthtrance declaration or actual ground movement.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type TruthMovementFixture = ReturnType<typeof createTruthMovementFixture>;
|
||||
@@ -150,8 +150,8 @@ void test('no-card and owner-only Karama tables apply Fremen movement automatica
|
||||
}
|
||||
});
|
||||
|
||||
void test('Karama preserves forces and movement allowance, while only the current Fremen group move loses range two', () => {
|
||||
const initial = fixture(true);
|
||||
void test('Basic pre-movement Hajr preserves forces and allowance when Karama reduces only the current Fremen group range', () => {
|
||||
const initial = fixture();
|
||||
initial.players[1].hand = [karamas[0]];
|
||||
initial.players[0].hand = [baseDeck().find((c) => c.effect === 'hajr')!];
|
||||
const hajr = applyAction(initial, 'f', {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
MovementClaimError, parseMovementClaim, validMovementClaim, movementClaimText,
|
||||
movementMatches, liveMovementPromises, movementEventAllowed,
|
||||
settleMovementEvent, finishMovementPromises, movementPromiseTargetAvailable,
|
||||
type MovementPromise,
|
||||
} from '../game/movement-promises';
|
||||
import { createGame, joinGame, newPlayer, type Game } from '../game/engine';
|
||||
|
||||
const claim = { territory: 'carthag', minimum: 6 };
|
||||
const promise = (overrides: Partial<MovementPromise> = {}): MovementPromise => ({
|
||||
...claim, turn: 2, player: 'target', asker: 'asker', answer: true, ...overrides,
|
||||
});
|
||||
|
||||
void test('movement claims validate printed destinations and safe physical minima while extracting only claim fields', () => {
|
||||
assert.deepEqual(parseMovementClaim({ kind: 'movement', target: 'target', ...claim }), claim);
|
||||
assert.equal(validMovementClaim(promise()), true);
|
||||
assert.equal(movementClaimText(claim), 'make a ground movement of at least 6 physical forces to Carthag');
|
||||
for (const minimum of [0, -1, 1.5, 21, Infinity, NaN, '6', null, undefined])
|
||||
assert.throws(() => parseMovementClaim({ ...claim, minimum }), MovementClaimError);
|
||||
for (const territory of ['', 'homeworld:caladan', 'mobile_stronghold', 'missing', null])
|
||||
assert.equal(validMovementClaim({ ...claim, territory }), false);
|
||||
for (const value of [null, [], 'carthag', 6])
|
||||
assert.throws(() => parseMovementClaim(value), MovementClaimError);
|
||||
assert.deepEqual(parseMovementClaim({ territory: 'polar_sink', minimum: 1 }), { territory: 'polar_sink', minimum: 1 });
|
||||
assert.equal(validMovementClaim({ ...claim, minimum: 20 }), true);
|
||||
});
|
||||
|
||||
void test('a movement promise needs one matching event rather than an aggregate of smaller arrivals', () => {
|
||||
let ledger = [promise()];
|
||||
const original = structuredClone(ledger);
|
||||
for (const amount of [2, 4, 5]) {
|
||||
ledger = settleMovementEvent(ledger, 'target', 2, { territory: 'carthag', amount });
|
||||
assert.equal(ledger[0].fulfilled, undefined);
|
||||
}
|
||||
assert.deepEqual(ledger, original);
|
||||
assert.equal(movementMatches(claim, null), false);
|
||||
assert.equal(movementMatches(claim, { territory: 'arrakeen', amount: 20 }), false);
|
||||
assert.equal(movementMatches(claim, { territory: 'carthag', amount: Infinity }), false);
|
||||
assert.equal(movementMatches(claim, { territory: 'carthag', amount: 6 }), true);
|
||||
const fulfilled = settleMovementEvent(ledger, 'target', 2, { territory: 'carthag', amount: 7 });
|
||||
assert.equal(fulfilled[0].fulfilled, true);
|
||||
assert.deepEqual(ledger, original);
|
||||
});
|
||||
|
||||
void test('No survives a nonmatching first move and still rejects a later Hajr or Ornithopter matching event', () => {
|
||||
const original = [promise({ answer: false })];
|
||||
const afterFirst = settleMovementEvent(original, 'target', 2, { territory: 'arrakeen', amount: 8 });
|
||||
assert.equal(afterFirst[0].fulfilled, undefined);
|
||||
assert.equal(movementEventAllowed(afterFirst, { territory: 'carthag', amount: 6 }), false);
|
||||
assert.throws(() => settleMovementEvent(afterFirst, 'target', 2, { territory: 'carthag', amount: 6 }),
|
||||
/accepted No/);
|
||||
assert.deepEqual(afterFirst, original);
|
||||
assert.equal(movementEventAllowed(afterFirst, { territory: 'carthag', amount: 5 }), true);
|
||||
const finished = finishMovementPromises(afterFirst, 'target', 2);
|
||||
assert.equal(finished[0].fulfilled, true); assert.equal(original[0].fulfilled, undefined);
|
||||
});
|
||||
|
||||
void test('a fulfilled Yes remains satisfied through later moves and actual turn completion', () => {
|
||||
const original = [promise()];
|
||||
assert.throws(() => finishMovementPromises(original, 'target', 2), /before ending movement/);
|
||||
const fulfilled = settleMovementEvent(original, 'target', 2, { territory: 'carthag', amount: 6 });
|
||||
const afterLater = settleMovementEvent(fulfilled, 'target', 2, { territory: 'arrakeen', amount: 1 });
|
||||
assert.equal(afterLater[0].fulfilled, true);
|
||||
assert.deepEqual(finishMovementPromises(afterLater, 'target', 2), afterLater);
|
||||
assert.equal(original[0].fulfilled, undefined);
|
||||
});
|
||||
|
||||
void test('settlement and closing affect only live current-player promises and preserve their metadata', () => {
|
||||
const ledger = [
|
||||
{ ...promise(), note: { source: 'original' } },
|
||||
{ ...promise({ player: 'other', answer: false }), note: { source: 'other' } },
|
||||
{ ...promise({ turn: 1, answer: false }), note: { source: 'old' } },
|
||||
{ ...promise({ released: true }), note: { source: 'released' } },
|
||||
{ ...promise({ fulfilled: true }), note: { source: 'fulfilled' } },
|
||||
];
|
||||
const before = structuredClone(ledger);
|
||||
assert.deepEqual(liveMovementPromises(ledger, 'target', 2), [ledger[0]]);
|
||||
const result = settleMovementEvent(ledger, 'target', 2, { territory: 'carthag', amount: 6 });
|
||||
assert.equal(result[0].fulfilled, true); assert.deepEqual(result[0].note, ledger[0].note);
|
||||
assert.deepEqual(result.slice(1), ledger.slice(1)); assert.deepEqual(ledger, before);
|
||||
assert.deepEqual(finishMovementPromises(result, 'target', 2), result);
|
||||
assert.deepEqual(settleMovementEvent(ledger, 'target', 3, { territory: 'carthag', amount: 6 }), ledger);
|
||||
assert.equal(movementEventAllowed([promise({ answer: false, fulfilled: true }), promise({ answer: false, released: true })],
|
||||
{ territory: 'carthag', amount: 6 }), true);
|
||||
});
|
||||
|
||||
function movementBoundary(): Game {
|
||||
const g = createGame('MOVEMENTPROMISEBOUNDARY', newPlayer('target', 'Target', 'atreides'), false);
|
||||
joinGame(g, newPlayer('asker', 'Asker', 'emperor'));
|
||||
g.status = 'playing'; g.phase = 5; g.turn = 2; g.active = 'target';
|
||||
g.movementRemaining = ['target', 'asker']; g.players[0].shipped = true;
|
||||
return g;
|
||||
}
|
||||
void test('the Basic profile permits a clean active post-shipment respondent with Truthtrance or ordinary flight open', () => {
|
||||
const g = movementBoundary(); assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
|
||||
g.truthtrance = { stage: 'ask', queue: [], passed: [], question: null };
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
|
||||
g.ornithopter = { event: 'flight', player: 'target', turn: 2, mode: 'twoGroups', startingMove: 0, completed: 0,
|
||||
card: { id: 'richese-ornithopter', name: 'Ornithopter', kind: 'special', effect: 'ornithopter' } };
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
|
||||
g.players[0].shipped = false; g.players[0].moved = 1;
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'asker'), false);
|
||||
});
|
||||
void test('movement question profile rejects an unspent shipment, exhausted turn and active opposing response', () => {
|
||||
const unused = movementBoundary(); unused.players[0].shipped = false;
|
||||
assert.equal(movementPromiseTargetAvailable(unused, 'target'), false);
|
||||
const ended = movementBoundary(); ended.movementRemaining = ['asker'];
|
||||
assert.equal(movementPromiseTargetAvailable(ended, 'target'), false);
|
||||
const response = movementBoundary(); response.response = { kind: 'fremenMovement', owner: 'target', location: 'carthag:11', amount: 1, passed: [] };
|
||||
assert.equal(movementPromiseTargetAvailable(response, 'target'), false);
|
||||
const advanced = movementBoundary(); advanced.advanced = true;
|
||||
assert.equal(movementPromiseTargetAvailable(advanced, 'target'), false);
|
||||
});
|
||||
void test('Basic native selected families qualify, while optional modules and undeclared native families do not', () => {
|
||||
const native = movementBoundary(); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
|
||||
assert.equal(movementPromiseTargetAvailable(native, 'target'), true);
|
||||
const missingFamily = structuredClone(native); missingFamily.expansions = [];
|
||||
assert.equal(movementPromiseTargetAvailable(missingFamily, 'target'), false);
|
||||
for (const key of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
|
||||
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery'] as const) {
|
||||
const configured = { ...native, [key]: true } as unknown as Game;
|
||||
assert.equal(movementPromiseTargetAvailable(configured, 'target'), false, key);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
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 { MovementClaimFields } from '../components/movement-claim-fields';
|
||||
import { Truthtrance } from '../components/truthtrance';
|
||||
import { viewGame, type Action } from '../game/engine';
|
||||
import { createTruthMovementFixture, actTruthMovement as act } from './fixture-truth-movement';
|
||||
|
||||
function question() {
|
||||
const f = createTruthMovementFixture(); let game = act(f.game, f.asker, f.truthStep.action);
|
||||
for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' });
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'movement', target: f.target, territory: 'carthag', minimum: 6 } });
|
||||
return { f, game };
|
||||
}
|
||||
function clickables(node: unknown): { children?: unknown; disabled?: boolean; onClick: () => void }[] {
|
||||
if (Array.isArray(node)) return node.flatMap(clickables);
|
||||
if (!node || typeof node !== 'object' || !('props' in node)) return [];
|
||||
const props = (node as ReactElement<{ children?: unknown; disabled?: boolean; onClick?: () => void }>).props;
|
||||
return [...(props.onClick ? [{ ...props, onClick: props.onClick }] : []), ...clickables(props.children)];
|
||||
}
|
||||
void test('the actual movement question shows truthful feasible answers and its continuing No semantics', () => {
|
||||
const { f, game } = question(), view = viewGame(game, f.target);
|
||||
const html = renderToStaticMarkup(createElement(Truthtrance, { game: view, act() {}, busy: false }));
|
||||
assert.match(html, /before your Shipment and Movement turn ends/);
|
||||
assert.match(html, /No forbids every matching move through later Hajr or Ornithopter/);
|
||||
assert.match(html, />Answer Yes<\/button>/); assert.match(html, />Answer No<\/button>/);
|
||||
assert.doesNotMatch(html, />I don’t know yet<\/button>/);
|
||||
assert.equal(viewGame(game, f.asker).truthMovementAnswers, null);
|
||||
});
|
||||
void test('persistent promise controls submit the actual private witness and respect busy state', () => {
|
||||
const { f, game: asked } = question();
|
||||
const game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const view = viewGame(game, f.target), actions: Action[] = [];
|
||||
const props = { game: view, act: (action: Action) => { actions.push(action); }, busy: false };
|
||||
const html = renderToStaticMarkup(createElement(MovementPromises, props));
|
||||
assert.match(html, /Your ground-movement promises/); assert.match(html, /Move 6 forces to Carthag/);
|
||||
clickables(MovementPromises(props))[0].onClick();
|
||||
assert.deepEqual(actions, view.movementPromisePolicy);
|
||||
const next = act(game, f.target, actions[0]); assert.equal(next.movementPromises![0].fulfilled, true);
|
||||
clickables(MovementPromises({ ...props, busy: true }))[0].onClick(); assert.equal(actions.length, 1);
|
||||
assert.equal(MovementPromises({ ...props, game: viewGame(game, f.asker) }), null);
|
||||
assert.equal(viewGame(game, f.asker).movementPromisePolicy, null);
|
||||
});
|
||||
void test('movement authoring fields describe ground forces and reject invalid physical minima', () => {
|
||||
const html = renderToStaticMarkup(createElement(MovementClaimFields, {
|
||||
id: 'movement', value: { territory: 'carthag', minimum: 0 }, onChange() {},
|
||||
}));
|
||||
assert.match(html, /Ground-movement destination/); assert.match(html, /Minimum physical forces in one ground move/);
|
||||
assert.match(html, /aria-invalid="true"/); assert.doesNotMatch(html, /from reserves|shipment conditions/);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
|
||||
import { createTruthMovementChoamFixture } from './fixture-truth-movement-choam';
|
||||
import { createBasicSemutaAmbassadorFixture } from './fixture-basic-semuta-ambassador';
|
||||
import { viewGame, normalizeAutomaticGame, type Game } from '../game/engine';
|
||||
import { createTruthMovementFixture, actTruthMovement as act, advanceTruthMovement as advance,
|
||||
truthMovementPlayer as player, truthMovementInventory as inventory, truthMovementClean as clean } from './fixture-truth-movement';
|
||||
|
||||
function ask(game: Game, asker: string, card: string, target: string, minimum = 6, territory = 'carthag') {
|
||||
game = act(game, asker, { type: 'card', card });
|
||||
for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' });
|
||||
return act(game, asker, { type: 'truthAsk', question: { kind: 'movement', target, territory, minimum } });
|
||||
}
|
||||
void test('an original public Basic movement Yes binds a real six-force move before turn end', () => {
|
||||
const f = createTruthMovementFixture(), source = structuredClone(f.game);
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['yes', 'no']);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(game.movementPromises?.length, 1);
|
||||
assert.throws(() => act(game, f.target, { type: 'endMovement' }));
|
||||
const guide = viewGame(game, f.target).movementPromisePolicy;
|
||||
assert.ok(guide?.length);
|
||||
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 });
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
game = advance(game, clean);
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.ok(!game.movementRemaining?.includes(f.target));
|
||||
assert.equal(player(game).forces['carthag:11'], 6);
|
||||
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(f.game, source);
|
||||
});
|
||||
|
||||
void test('a nonmatching first move keeps Yes live until an owned Hajr completion', () => {
|
||||
const f = createTruthMovementFixture();
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 4, territory: 'imperial_basin', sector: 9 });
|
||||
game = advance(game, clean);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
const next = viewGame(game, f.target).movementPromisePolicy?.[0];
|
||||
assert.deepEqual(next, { type: 'card', card: f.hajr });
|
||||
game = act(game, f.target, next!); game = advance(game, clean);
|
||||
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 });
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
game = advance(game, clean); game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('No survives a nonmatching first move and forbids a later matching Hajr movement', () => {
|
||||
const f = createTruthMovementFixture();
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 4, territory: 'imperial_basin', sector: 9 });
|
||||
game = advance(game, clean);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
game = act(game, f.target, { type: 'card', card: f.hajr! }); game = advance(game, clean);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 }));
|
||||
assert.deepEqual(game, before);
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a voluntary nonmatching last movement cannot evade Yes when no Hajr is held', () => {
|
||||
const f = createTruthMovementFixture({ hajr: false });
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 10, territory: 'imperial_basin', sector: 9 }));
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('controlled second physical Truthtrance binds compatible Yes/No thresholds without aggregating groups', () => {
|
||||
const f = createTruthMovementFixture();
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
// Controlled acquisition of the other original card; the first question,
|
||||
// original army, wallets and all movement allowances remain genuine.
|
||||
const second = game.deck.splice(game.deck.findIndex(card => card.effect === 'truthtrance'), 1)[0];
|
||||
assert.ok(second); player(game, f.asker).hand.push(second);
|
||||
game = ask(game, f.asker, second.id, f.target, 7);
|
||||
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['yes', 'no']);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 });
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.equal(game.movementPromises![1].fulfilled, undefined);
|
||||
game = advance(game, clean); game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.ok(game.movementPromises!.every(promise => promise.fulfilled));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a controlled impossible opposing outcome releases Yes without replaying or altering the original card cost', () => {
|
||||
const f = createTruthMovementFixture();
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
// Explicit outcome staging, not a claim of an unstaged Atomics/Terror source.
|
||||
const harmed = structuredClone(game), owner = player(harmed);
|
||||
owner.tanks += owner.forces['arrakeen:10']; delete owner.forces['arrakeen:10'];
|
||||
game = normalizeAutomaticGame(harmed);
|
||||
assert.equal(game.movementPromises![0].released, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.truthtrance).length, 1);
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('all four existing profiles answer and fulfill the actual Basic movement commitment legally', () => {
|
||||
const f = createTruthMovementFixture();
|
||||
const question = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = question, actions = 0;
|
||||
while (game.active === f.target && game.phase === 5 && actions++ < 20) {
|
||||
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
if (game.response || game.phaseOpening || game.decision) { game = advance(game, clean); continue; }
|
||||
const action = botActions(view)[0]; assert.ok(action); game = act(game, f.target, action);
|
||||
}
|
||||
assert.ok(actions < 20); assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
function passSemuta(game: Game): Game {
|
||||
if (!game.pendingTreacheryDiscard?.reaction) return game;
|
||||
const event = game.pendingTreacheryDiscard.batch.event;
|
||||
for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
|
||||
function ornithopterSource() {
|
||||
const f = createBasicSemutaAmbassadorFixture({ effect: 'choam', cacheCard: 'ornithopter', ecazTruth: true });
|
||||
const game = act(f.game, f.beneficiary, { type: 'decision', event: f.event, cards: [] });
|
||||
const truth = player(game, f.owner).hand.find(card => card.effect === 'truthtrance')!;
|
||||
assert.ok(truth); return { ...f, game, asker: f.owner, target: f.holder, truth: truth.id };
|
||||
}
|
||||
for (const answer of ['yes', 'no'] as const) void test(`actual bought two-group Ornithopter retains ${answer} after its first nonmatching group`, () => {
|
||||
const f = ornithopterSource();
|
||||
let game = ask(f.game, f.asker, f.truth, f.target, 1, 'habbanya_ridge_flat');
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer });
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'truthtranceDiscard');
|
||||
game = passSemuta(game);
|
||||
game = act(game, f.target, { type: 'move', from: 'tueks_sietch:5', amount: 1,
|
||||
territory: 'south_mesa', sector: 5, movementCard: f.cacheCard, ornithopter: 'twoGroups' });
|
||||
game = advance(game, clean);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
const event = game.ornithopter!.event;
|
||||
const second = { type: 'move', from: 'habbanya_ridge_sietch:17', amount: 1,
|
||||
territory: 'habbanya_ridge_flat', sector: 17, ornithopterEvent: event };
|
||||
if (answer === 'yes') {
|
||||
game = act(game, f.target, second);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true, 'Physical commitment precedes final card disposal');
|
||||
// Native Ecaz retains its existing final-move Semuta boundary; the
|
||||
// physical card still retires once before the ordinary entry resumes.
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
} else {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, f.target, second)); assert.deepEqual(game, before);
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined, 'No remains live until the original ending continuation');
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
game = passSemuta(game);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
}
|
||||
assert.equal(game.discard.filter(card => card.id === f.cacheCard).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalTreachery);
|
||||
});
|
||||
|
||||
void test('a real bought range-three Ornithopter supplies the authoritative Yes completion', () => {
|
||||
const f = ornithopterSource();
|
||||
let game = ask(f.game, f.asker, f.truth, f.target, 1, 'the_greater_flat');
|
||||
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['yes', 'no']);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'truthtranceDiscard');
|
||||
game = passSemuta(game);
|
||||
const next = viewGame(game, f.target).movementPromisePolicy?.[0];
|
||||
assert.equal(next?.movementCard, f.cacheCard); assert.equal(next?.ornithopter, 'range3');
|
||||
game = act(game, f.target, next!);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
game = passSemuta(game); game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.deepEqual(inventory(game), f.originalTreachery);
|
||||
});
|
||||
|
||||
void test('a controlled opposing loss releases only the impossible Yes and keeps an independent smaller promise', () => {
|
||||
const f = createTruthMovementFixture();
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const second = game.deck.splice(game.deck.findIndex(card => card.effect === 'truthtrance'), 1)[0];
|
||||
assert.ok(second); player(game, f.asker).hand.push(second);
|
||||
game = ask(game, f.asker, second.id, f.target, 1, 'imperial_basin');
|
||||
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const harmed = structuredClone(game), owner = player(harmed);
|
||||
owner.forces['arrakeen:10'] -= 9; owner.tanks += 9;
|
||||
game = normalizeAutomaticGame(harmed);
|
||||
assert.equal(game.movementPromises![0].released, true);
|
||||
assert.equal(game.movementPromises![1].released, undefined);
|
||||
assert.ok(viewGame(game, f.target).movementPromisePolicy?.length);
|
||||
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
|
||||
assert.equal(game.movementPromises![1].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('actual native Kulon and city access preserve a four-territory CHOAM movement promise', () => {
|
||||
const f = createTruthMovementChoamFixture();
|
||||
const destination = TERRITORIES.flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))).find(to =>
|
||||
gameDistance(f.game, 'arrakeen:10', `${to.territory}:${to.sector}`, key => {
|
||||
const location = splitLocation(key); return location.sector !== 0 && location.sector === f.game.storm;
|
||||
}) === 4);
|
||||
assert.ok(destination);
|
||||
let game = ask(f.game, f.asker, f.truth, f.target, 1, destination.territory);
|
||||
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const next = viewGame(game, f.target).movementPromisePolicy?.[0]; assert.ok(next);
|
||||
assert.equal(next.movementCard, undefined);
|
||||
game = act(game, f.target, next);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
@@ -593,6 +593,11 @@ for (const stage of ['response', 'payment'] as const)
|
||||
});
|
||||
initial = applyAction(initial, 'p', { type: 'traitorCall', call: false });
|
||||
initial = applyAction(initial, 'q', { type: 'traitorCall', call: false });
|
||||
while (initial.battle?.outcomeWindow) {
|
||||
const window = initial.battle.outcomeWindow;
|
||||
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id)); assert.ok(actor);
|
||||
initial = applyAction(initial, actor, { type: 'battleOutcomeReady', event: window.event });
|
||||
}
|
||||
assert.equal(initial.decision?.kind, 'choamAudit');
|
||||
initial = applyAction(initial, 'q', {
|
||||
type: 'decision',
|
||||
|
||||
Reference in new issue
Block a user