Support sector-specific Truthtrance movement
This commit is contained in:
1 parent
536d334543
commit
1059cc1682
24 files changed
+1016
-100
No files matched your search
@@ -15,7 +15,7 @@ const config: MixedActionConfig<MovementClaim> = {
|
||||
actionLabel: 'ground-movement condition',
|
||||
actionTiming: 'Future ground move · this turn',
|
||||
actionHelp: <>Each movement condition needs one actual future ground move after the answer, before your Movement turn ends this round.
|
||||
Smaller moves never add together. Prior moves and shipments do not count. The claim grants no extra movement opportunity.</>,
|
||||
You may name an exact sector. Smaller moves never add together. Prior moves and shipments do not count. The claim grants no extra movement opportunity.</>,
|
||||
fallbackError: 'Choose a current fact and a future ground-movement condition.',
|
||||
maxLeaves: MOVEMENT_EXPRESSION_MAX_LEAVES,
|
||||
maxDepth: MOVEMENT_EXPRESSION_MAX_DEPTH,
|
||||
@@ -25,10 +25,21 @@ const config: MixedActionConfig<MovementClaim> = {
|
||||
<label htmlFor={`${id}-territory`}>Ground-movement destination</label>
|
||||
<select id={`${id}-territory`} value={TERRITORIES.some(destination => destination.id === value.territory) ? value.territory : ''}
|
||||
disabled={disabled} aria-invalid={!TERRITORIES.some(destination => destination.id === value.territory)}
|
||||
onChange={event => onChange({ ...value, territory: event.currentTarget.value })}>
|
||||
onChange={event => onChange({ territory: event.currentTarget.value, minimum: value.minimum })}>
|
||||
<option value="">Choose a printed Arrakis territory</option>
|
||||
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`${id}-sector`}>Ground-movement sector</label>
|
||||
<select id={`${id}-sector`} disabled={disabled} value={value.sector === undefined ? '' : String(value.sector)}
|
||||
aria-invalid={value.sector !== undefined && !TERRITORIES.find(row => row.id === value.territory)?.sectors.includes(value.sector)}
|
||||
onChange={event => {
|
||||
const selected = event.currentTarget.value, printed = TERRITORIES.find(row => row.id === value.territory);
|
||||
if (selected === '') onChange({ territory: value.territory, minimum: value.minimum });
|
||||
else { const sector = Number(selected); if (printed?.sectors.includes(sector)) onChange({ ...value, sector }); }
|
||||
}}>
|
||||
<option value="">Any sector in this territory</option>
|
||||
{TERRITORIES.find(row => row.id === value.territory)?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</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 disabled={disabled}
|
||||
value={Number.isNaN(value.minimum) ? '' : value.minimum}
|
||||
|
||||
@@ -10,9 +10,20 @@ export function MovementClaimFields({ id, value, onChange }: {
|
||||
<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 })}>
|
||||
onChange={event => onChange({ territory: event.currentTarget.value, minimum: value.minimum })}>
|
||||
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`${id}-sector`}>Ground-movement sector</label>
|
||||
<select id={`${id}-sector`} value={value.sector === undefined ? '' : String(value.sector)}
|
||||
aria-invalid={value.sector !== undefined && !TERRITORIES.find(row => row.id === value.territory)?.sectors.includes(value.sector)}
|
||||
onChange={event => {
|
||||
const selected = event.currentTarget.value, printed = TERRITORIES.find(row => row.id === value.territory);
|
||||
if (selected === '') onChange({ territory: value.territory, minimum: value.minimum });
|
||||
else { const sector = Number(selected); if (printed?.sectors.includes(sector)) onChange({ ...value, sector }); }
|
||||
}}>
|
||||
<option value="">Any sector in this territory</option>
|
||||
{TERRITORIES.find(row => row.id === value.territory)?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</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)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import { TERRITORIES } from '@/game/board';
|
||||
import {
|
||||
parseMovementExpression, MOVEMENT_EXPRESSION_MAX_DEPTH, MOVEMENT_EXPRESSION_MAX_LEAVES,
|
||||
type MovementExpression,
|
||||
@@ -17,7 +18,7 @@ export function movementExpressionInputError(value: unknown): string | null {
|
||||
// Structural edits preserve repairable destination and numeric drafts. The
|
||||
// detached valid placeholders are used only to check tree shape and limits.
|
||||
const structuralDraft = (node: MovementExpression): MovementExpression => node.kind === 'move'
|
||||
? { ...node, territory: 'carthag', minimum: 1 }
|
||||
? { kind: 'move', territory: 'carthag', minimum: 1 }
|
||||
: { ...node, terms: node.terms.map(structuralDraft) };
|
||||
const inputSize = (node: MovementExpression): { leaves: number; depth: number } => {
|
||||
if (node.kind === 'move') return { leaves: 1, depth: 0 };
|
||||
@@ -49,10 +50,21 @@ export function MovementExpressionFields({ value, territories, disabled, onChang
|
||||
<label htmlFor={`${id}-territory`}>Ground-movement destination</label>
|
||||
<select id={`${id}-territory`} value={territories.some(destination => destination.id === node.territory) ? node.territory : ''}
|
||||
disabled={disabled} aria-invalid={!territories.some(destination => destination.id === node.territory)}
|
||||
onChange={event => change({ ...node, territory: event.currentTarget.value })}>
|
||||
onChange={event => change({ kind: 'move', territory: event.currentTarget.value, minimum: node.minimum })}>
|
||||
<option value="">Choose a printed Arrakis territory</option>
|
||||
{territories.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`${id}-sector`}>Ground-movement sector</label>
|
||||
<select id={`${id}-sector`} disabled={disabled} value={node.sector === undefined ? '' : String(node.sector)}
|
||||
aria-invalid={node.sector !== undefined && !TERRITORIES.find(row => row.id === node.territory)?.sectors.includes(node.sector)}
|
||||
onChange={event => {
|
||||
const selected = event.currentTarget.value, printed = TERRITORIES.find(row => row.id === node.territory);
|
||||
if (selected === '') change({ kind: 'move', territory: node.territory, minimum: node.minimum });
|
||||
else { const sector = Number(selected); if (printed?.sectors.includes(sector)) change({ ...node, sector }); }
|
||||
}}>
|
||||
<option value="">Any sector in this territory</option>
|
||||
{TERRITORIES.find(row => row.id === node.territory)?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</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 disabled={disabled}
|
||||
value={Number.isNaN(node.minimum) ? '' : node.minimum} aria-invalid={!!movementExpressionInputError(node)}
|
||||
@@ -98,7 +110,7 @@ export function MovementExpressionFields({ value, territories, disabled, onChang
|
||||
AND requires every condition; OR requires at least one. No makes the whole expression false,
|
||||
not necessarily every condition. Legal alternatives remain available; the question does not pin a fixed branch.
|
||||
Each condition needs one future actual ground move after the answer, before your Movement turn ends this round.
|
||||
Smaller moves never add together; a shipment can prepare a move but does not fulfill a condition.
|
||||
Name an exact sector when needed. Smaller moves never add together; a shipment can prepare a move but does not fulfill a condition.
|
||||
</p>
|
||||
<p className="fine">App input limits: {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions and {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping.
|
||||
Groups do not count as conditions. These are input limits, not additional printed rules.</p>
|
||||
|
||||
@@ -15,7 +15,7 @@ const config: MixedActionConfig<TravelActionClaim> = {
|
||||
actionTiming: 'Future shipment or ground move · this turn',
|
||||
actionHelp: <>Shipment conditions concern your single future original reserve shipment after the answer.
|
||||
Choose any sector or one exact printed sector in that territory; only that sector satisfies an exact condition.
|
||||
Ground-movement conditions concern one actual future ground move before your Movement turn ends this round.
|
||||
Ground-movement conditions concern one actual future ground move before your Movement turn ends this round; its sector may also be named.
|
||||
Count physical forces in one action; smaller moves never add together. Earlier actions do not count.
|
||||
OR keeps legal shipment and movement alternatives available. No makes the whole expression false.
|
||||
Original costs, movement rights, cards and preparation still apply; this claim grants no extra shipment or movement.</>,
|
||||
@@ -29,8 +29,8 @@ const config: MixedActionConfig<TravelActionClaim> = {
|
||||
<label htmlFor={`${id}-action`}>Future action</label>
|
||||
<select id={`${id}-action`} value={value.kind} disabled={disabled} onChange={event => {
|
||||
const kind = event.currentTarget.value;
|
||||
if (kind === 'ship') onChange({ ...value, kind });
|
||||
else if (kind === 'move') onChange({ kind, territory: value.territory, minimum: value.minimum });
|
||||
if ((kind === 'ship' || kind === 'move') && kind !== value.kind)
|
||||
onChange({ kind, territory: value.territory, minimum: value.minimum });
|
||||
}}>
|
||||
<option value="ship">Ship from reserves</option>
|
||||
<option value="move">Make ground movement</option>
|
||||
@@ -40,20 +40,19 @@ const config: MixedActionConfig<TravelActionClaim> = {
|
||||
disabled={disabled} aria-invalid={!TERRITORIES.some(destination => destination.id === value.territory)}
|
||||
onChange={event => {
|
||||
const destination = event.currentTarget.value;
|
||||
if (value.kind === 'ship') onChange({ kind: 'ship', territory: destination, minimum: value.minimum });
|
||||
else onChange({ ...value, territory: destination });
|
||||
onChange({ kind: value.kind, territory: destination, minimum: value.minimum });
|
||||
}}>
|
||||
<option value="">Choose a printed Arrakis territory</option>
|
||||
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
|
||||
</select>
|
||||
{value.kind === 'ship' && (() => {
|
||||
{(value.kind === 'ship' || value.kind === 'move') && (() => {
|
||||
const printed = TERRITORIES.find(destination => destination.id === value.territory);
|
||||
return <>
|
||||
<label htmlFor={`${id}-sector`}>Shipment sector</label>
|
||||
<label htmlFor={`${id}-sector`}>{value.kind === 'ship' ? 'Shipment sector' : 'Ground-movement sector'}</label>
|
||||
<select id={`${id}-sector`} disabled={disabled} value={value.sector === undefined ? '' : String(value.sector)}
|
||||
aria-invalid={value.sector !== undefined && !printed?.sectors.includes(value.sector)} onChange={event => {
|
||||
const selected = event.currentTarget.value;
|
||||
if (selected === '') onChange({ kind: 'ship', territory: value.territory, minimum: value.minimum });
|
||||
if (selected === '') onChange({ kind: value.kind, territory: value.territory, minimum: value.minimum });
|
||||
else {
|
||||
const sector = Number(selected);
|
||||
if (printed?.sectors.includes(sector)) onChange({ ...value, sector });
|
||||
|
||||
@@ -119,7 +119,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
Amal halves available cash before ordinary payments become legal; preserve enough remaining cash for any payment condition.
|
||||
Shipment conditions concern the single future original reserve shipment after the answer. Choose any sector or one exact printed sector;
|
||||
a different sector in the same territory does not satisfy an exact-sector condition.
|
||||
Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together.
|
||||
Ground-movement conditions use the maximum physical forces in one actual future move to that destination and optional exact sector; smaller moves never add together.
|
||||
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
|
||||
Shipment and movement close at your own Movement turn end, while payment conditions last until the actual turn ends.
|
||||
AND requires every condition; OR keeps legal alternatives. No makes the whole expression false, not every condition separately.
|
||||
@@ -434,20 +434,19 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
const destination = event.currentTarget.value;
|
||||
// An exact sector belongs to its printed territory. Selecting a
|
||||
// different territory returns to the explicit any-sector choice.
|
||||
if (value.kind === 'ship') onChange({ kind: 'ship', territory: destination, minimum: value.minimum });
|
||||
else onChange({ ...value, territory: destination });
|
||||
onChange({ kind: value.kind, territory: destination, minimum: value.minimum });
|
||||
}}>
|
||||
<option value="">Choose a printed Arrakis territory</option>
|
||||
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
|
||||
</select>
|
||||
{value.kind === 'ship' && (() => {
|
||||
{(value.kind === 'ship' || value.kind === 'move') && (() => {
|
||||
const printed = TERRITORIES.find(destination => destination.id === value.territory);
|
||||
return <>
|
||||
<label htmlFor={`${id}-sector`}>Shipment sector</label>
|
||||
<label htmlFor={`${id}-sector`}>{value.kind === 'ship' ? 'Shipment sector' : 'Ground-movement sector'}</label>
|
||||
<select id={`${id}-sector`} disabled={disabled} value={value.sector === undefined ? '' : String(value.sector)}
|
||||
aria-invalid={value.sector !== undefined && !printed?.sectors.includes(value.sector)} onChange={event => {
|
||||
const chosen = event.currentTarget.value;
|
||||
if (chosen === '') onChange({ kind: 'ship', territory: value.territory, minimum: value.minimum });
|
||||
if (chosen === '') onChange({ kind: value.kind, territory: value.territory, minimum: value.minimum });
|
||||
else {
|
||||
const sector = Number(chosen);
|
||||
if (printed?.sectors.includes(sector)) onChange({ ...value, sector });
|
||||
|
||||
@@ -20,6 +20,14 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Truthtrance ground-movement sectors — 27 October. Prototyped / Partial.**
|
||||
A future physical-movement condition can name the whole destination territory
|
||||
or one exact printed sector. Separate eligible moves may satisfy separate
|
||||
clauses; each minimum counts one actual group. Older territory-only progress
|
||||
never invents a sector. Native guidance and minimal legal bots use actual
|
||||
movement choices; broader combinations and deployed acceptance remain open.
|
||||
[Scope](TRUTHTRANCE_MOVEMENT_PROMISES.md#ground-movement-destination-sector--27-october-2026).
|
||||
|
||||
**Truthtrance shipment sectors — 27 October. Prototyped / Partial.**
|
||||
A future reserve-shipment condition may name an exact printed sector or the
|
||||
whole territory. Sector and territory clauses share the original physical
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Truthtrance ground-movement sector
|
||||
|
||||
Future physical-movement conditions may name an exact printed sector.
|
||||
Standalone, mixed, expression, travel and whole-turn controls share the native
|
||||
move, per-group progress and legal guidance. Separate moves can satisfy
|
||||
different clauses; legacy territory maxima never imply a sector. Broader
|
||||
combinations, complete rules and deployed acceptance remain open.
|
||||
[Contract](TRUTHTRANCE_MOVEMENT_PROMISES.md#ground-movement-destination-sector--27-october-2026).
|
||||
|
||||
## 27 October 2026 — Truthtrance reserve shipment sector
|
||||
|
||||
An optional exact printed sector is part of the existing future reserve
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Rule decision index
|
||||
|
||||
## Truthtrance and ground-movement sector — 27 October 2026
|
||||
|
||||
A sector-qualified current-turn question counts a real physical ground group
|
||||
ending in that printed sector. Separate eligible moves can satisfy separate
|
||||
clauses; smaller groups are never added together. This is a provisional online
|
||||
application of the FAQ's current-turn question permission, not a separate
|
||||
publisher timing ruling.
|
||||
[Contract](TRUTHTRANCE_MOVEMENT_PROMISES.md#ground-movement-destination-sector--27-october-2026).
|
||||
|
||||
## Truthtrance and reserve shipment sector — 27 October 2026
|
||||
|
||||
An optional exact sector names the same original reserve-shipment event as
|
||||
|
||||
@@ -688,3 +688,25 @@ 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.
|
||||
|
||||
## Ground-movement destination sector — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** The publisher FAQ (November 2020, p. 8) permits
|
||||
current-turn Yes/No questions; naming the destination sector is a provisional
|
||||
application of the project's online binding procedure. A movement clause may
|
||||
name any sector of a printed territory or a single printed sector and requires
|
||||
one actual physical ground-moving group of the selected minimum. Shipment,
|
||||
marker-only movement and special relocations retain their existing distinctions.
|
||||
|
||||
Standalone, expression, mixed, travel and whole-turn questions use the same
|
||||
native legal movement and shared private guide. No forbids the matching move;
|
||||
Yes retains an available native completion. Different actual moves can satisfy
|
||||
different positive sector clauses when movement allowances and cards permit.
|
||||
Per-sector maxima record each qualifying group without adding smaller moves;
|
||||
old territory-wide progress is preserved and never implies an unrecorded sector.
|
||||
Dynamic board locations remain native legal destinations but cannot be named
|
||||
as printed-sector claims in this prototype.
|
||||
|
||||
Runtime cases use a genuine Basic setup and actual physical shipment, Hajr and
|
||||
movement actions; complete expansion interactions, broader source review,
|
||||
browser and deployed acceptance remain open.
|
||||
+15
-10
@@ -21,7 +21,7 @@ import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurn
|
||||
recordTurnActionStormDial, closeTurnActionStormDial, turnActionStormDialChoices,
|
||||
turnActionPromiseModeSupported, type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation, type TurnActionWindowCards } from './turn-action-promises';
|
||||
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
|
||||
finishTravelTurn, validateTravelPromises, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises';
|
||||
finishTravelTurn, validateTravelPromises, needsMovementSectorProgress, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises';
|
||||
import { mixedTraitorRequirement, type MixedTraitorClaim } from './mixed-traitor-question';
|
||||
import type { MixedBattleActionClaim, MixedBattleClaim } from './mixed-battle-question';
|
||||
import { AllianceExpressionPromiseError, allianceExpressionOutcomes, findAllianceExpressionPlans,
|
||||
@@ -33,7 +33,7 @@ import type { RevivalActionClaim, MixedRevivalClaim } from './mixed-revival-ques
|
||||
import type { BribePaymentClaim, MixedBribeClaim } from './mixed-bribe-question';
|
||||
import { compileMixedActionExpression, MixedActionQuestionError, type CompiledMixedAction } from './mixed-action-question';
|
||||
import { MovementExpressionPromiseError, liveMovementExpressionPromises, recordMovementExpressionEvent,
|
||||
validateMovementExpressionPromises, type MovementExpression, type MovementExpressionClaim, type MovementExpressionPromise } from './movement-expression-promises';
|
||||
validateMovementExpressionPromises, movementExpressionSectors, type MovementExpression, type MovementExpressionClaim, type MovementExpressionPromise } from './movement-expression-promises';
|
||||
import { BribeExpressionPromiseError, bribeExpressionRecipients, bribeCompletionCandidates,
|
||||
liveBribeExpressionPromises, recordBribeExpressionPayment, validateBribeExpressionPromises,
|
||||
type BribeExpression, type BribeExpressionClaim, type BribeExpressionPromise } from './bribe-expression-promises';
|
||||
@@ -19893,7 +19893,7 @@ function groundMovementProbe(source: Game, actor: string): Game {
|
||||
return game;
|
||||
}
|
||||
function groundMovementEvent(move: MovementOrder) {
|
||||
return { territory: move.to, amount: move.total - Number(!!move.noField) };
|
||||
return { territory: move.to, sector: move.sector, amount: move.total - Number(!!move.noField) };
|
||||
}
|
||||
function projectGroundMovement(source: Game, move: MovementOrder): Game {
|
||||
const game = structuredClone(source), player = getPlayer(game, move.player);
|
||||
@@ -20973,7 +20973,7 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
|
||||
else if (leaf.kind === 'ship') trial.shipmentPromises!.push({ ...metadata,
|
||||
territory: leaf.territory, minimum: leaf.minimum, ...(leaf.sector === undefined ? {} : { sector: leaf.sector }) });
|
||||
else if (leaf.kind === 'move') trial.movementPromises!.push({ ...metadata,
|
||||
territory: leaf.territory, minimum: leaf.minimum });
|
||||
territory: leaf.territory, minimum: leaf.minimum, ...(leaf.sector === undefined ? {} : { sector: leaf.sector }) });
|
||||
else if (leaf.kind === 'bid') (trial.bidPromises ??= []).push({ ...metadata,
|
||||
lot: leaf.lot, source: leaf.source, minimum: leaf.minimum });
|
||||
else if (leaf.kind === 'forces') (trial.revivalPromises ??= []).push({ ...metadata,
|
||||
@@ -21171,7 +21171,8 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
...(preparation ? { preparation } : {}),
|
||||
...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}),
|
||||
shipment: { pending: open && (g.phase < 5 || !!g.phaseOpening?.initialize || shipmentAvailable(g, p)), event: null },
|
||||
progress: [], payments: [], transportClosed: !open, paymentClosed: false,
|
||||
progress: [], ...(needsMovementSectorProgress(claim.mixed) ? { sectorProgress: [] } : {}),
|
||||
payments: [], transportClosed: !open, paymentClosed: false,
|
||||
fulfilled: compiled.kind === 'constant' && compiled.value === answer, released: false };
|
||||
if (combat?.plan && battle?.plans[p.id]) promise = turnActionRule(() => recordTurnActionPlan([promise], p.id,
|
||||
g.turn, combat.event, combat.territory, turnActionPlanValues(g, p.id, [claim.mixed])))[0];
|
||||
@@ -21535,7 +21536,7 @@ function travelCompletion(source: Game, actor: string,
|
||||
shipmentPromises: [...shipments], movementPromises: [...movements], movementExpressionPromises: [...expressions] };
|
||||
for (const leaf of leaves) {
|
||||
const promise = { territory: leaf.territory, minimum: leaf.minimum, turn: source.turn,
|
||||
...(leaf.kind === 'ship' && leaf.sector !== undefined ? { sector: leaf.sector } : {}),
|
||||
...(leaf.sector !== undefined ? { sector: leaf.sector } : {}),
|
||||
player: actor, asker: source.players.find(player => player.id !== actor)!.id, answer: leaf.answer };
|
||||
if (leaf.kind === 'ship') trial.shipmentPromises.push(promise);
|
||||
else trial.movementPromises.push(promise);
|
||||
@@ -21553,7 +21554,8 @@ function travelTruthAnswers(g: Game, p: Player, claim: TravelClaim): TruthAnswer
|
||||
if (compiled.kind === 'constant' && compiled.value !== answer) continue;
|
||||
const promise: TravelPromise = { ...structuredClone(claim), event: 'candidate', player: p.id,
|
||||
asker: g.players.find(player => player.id !== p.id)!.id, answer, compiled,
|
||||
shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
|
||||
shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [],
|
||||
...(needsMovementSectorProgress(claim.mixed) ? { sectorProgress: [] } : {}), movementClosed: false,
|
||||
fulfilled: compiled.kind === 'constant', released: false };
|
||||
const answerSource = (g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder)
|
||||
? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
@@ -21576,7 +21578,8 @@ function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean
|
||||
reopenMovementPreparation(g, p);
|
||||
initializeTravelOrder(g);
|
||||
(g.travelPromises ??= []).push({ ...structuredClone(claim), event: crypto.randomUUID(), player: p.id, asker, answer,
|
||||
compiled, shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
|
||||
compiled, shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [],
|
||||
...(needsMovementSectorProgress(claim.mixed) ? { sectorProgress: [] } : {}), movementClosed: false,
|
||||
fulfilled: compiled.kind === 'constant', released: false });
|
||||
appendTravelOrder(g, 'travel', g.travelPromises!.length - 1);
|
||||
}
|
||||
@@ -21951,7 +21954,8 @@ function movementExpressionTruthAnswers(g: Game, p: Player, claim: MovementExpre
|
||||
const answers: TruthAnswer[] = [];
|
||||
for (const answer of [true, false]) {
|
||||
const promise: MovementExpressionPromise = { ...structuredClone(claim), event: 'candidate', player: p.id,
|
||||
asker: g.players.find(other => other.id !== p.id)!.id, answer, progress: [] };
|
||||
asker: g.players.find(other => other.id !== p.id)!.id, answer, progress: [],
|
||||
...(movementExpressionSectors(claim.claim).length ? { sectorProgress: [] } : {}) };
|
||||
const expressions = [...(g.movementExpressionPromises ?? []), promise];
|
||||
const trial = { ...answerSource, movementExpressionPromises: expressions };
|
||||
if (phaseBidCompatibleLaterPromises(trial,p) && groundMovementCompletion(trial, p.id) &&
|
||||
@@ -21965,7 +21969,8 @@ function bindMovementExpressionTruth(g: Game, p: Player, claim: MovementExpressi
|
||||
'Choose a whole movement answer compatible with owned actions and earlier answers.');
|
||||
reopenMovementPreparation(g, p);
|
||||
const promise: MovementExpressionPromise = { ...structuredClone(claim), event: crypto.randomUUID(),
|
||||
player: p.id, asker, answer, progress: [] };
|
||||
player: p.id, asker, answer, progress: [],
|
||||
...(movementExpressionSectors(claim.claim).length ? { sectorProgress: [] } : {}) };
|
||||
(g.movementExpressionPromises ??= []).push(promise);
|
||||
(g.movementPromiseSequence ??= []).push(promise.event);
|
||||
appendTravelOrder(g, 'movementExpression', g.movementExpressionPromises!.length - 1);
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { TERRITORIES } from './board';
|
||||
import { TERRITORIES, validLocation } from './board';
|
||||
import { MovementClaimError, movementClaimText, parseMovementClaim, type MovementEvent } from './movement-promises';
|
||||
|
||||
export { movementPromiseModeSupported as movementExpressionPromiseModeSupported } from './movement-promises';
|
||||
|
||||
export type MovementExpression = { kind: 'move'; territory: string; minimum: number } |
|
||||
export type MovementExpression = { kind: 'move'; territory: string; minimum: number; sector?: number } |
|
||||
{ kind: 'and' | 'or'; terms: MovementExpression[] };
|
||||
export type MovementExpressionClaim = { turn: number; claim: MovementExpression };
|
||||
export type MovementExpressionProgressEntry = { territory: string; maximum: number };
|
||||
export type MovementExpressionProgress = MovementExpressionProgressEntry[];
|
||||
export type MovementExpressionSectorProgressEntry = { territory: string; sector: number; maximum: number };
|
||||
export type MovementExpressionSectorProgress = MovementExpressionSectorProgressEntry[];
|
||||
export type MovementExpressionEvent = MovementEvent;
|
||||
export type MovementExpressionPromise = MovementExpressionClaim & {
|
||||
event: string; player: string; asker: string; answer: boolean;
|
||||
/** Largest actual physical ground move to each named destination after this answer. */
|
||||
progress: MovementExpressionProgress;
|
||||
/** Present only for named exact-sector clauses; old territory-only saves omit it. */
|
||||
sectorProgress?: MovementExpressionSectorProgress;
|
||||
fulfilled?: boolean; released?: boolean;
|
||||
};
|
||||
|
||||
@@ -56,7 +60,8 @@ export function parseMovementExpression(value: unknown): MovementExpression {
|
||||
}
|
||||
check(++leaves <= MOVEMENT_EXPRESSION_MAX_LEAVES,
|
||||
'Use at most sixteen ground-movement statements.');
|
||||
check(input.kind === 'move' && keys(input, 'kind,minimum,territory'),
|
||||
check(input.kind === 'move' && (keys(input, 'kind,minimum,territory') ||
|
||||
keys(input, 'kind,minimum,sector,territory')),
|
||||
'Choose one physical ground-movement minimum and printed destination.');
|
||||
try { return { kind: 'move', ...parseMovementClaim(input) }; }
|
||||
catch (error) {
|
||||
@@ -90,6 +95,18 @@ export function movementExpressionTerritories(expression: MovementExpression): s
|
||||
return [...destinations];
|
||||
}
|
||||
|
||||
/** Exact named locations, useful for bounded native completion and progress. */
|
||||
export function movementExpressionSectors(expression: MovementExpression): { territory: string; sector: number }[] {
|
||||
const locations = new Map<string, { territory: string; sector: number }>();
|
||||
const visit = (node: MovementExpression): void => {
|
||||
if ('terms' in node) node.terms.forEach(visit);
|
||||
else if (node.sector !== undefined) locations.set(JSON.stringify([node.territory, node.sector]),
|
||||
{ territory: node.territory, sector: node.sector });
|
||||
};
|
||||
visit(parseMovementExpression(expression));
|
||||
return [...locations.values()];
|
||||
}
|
||||
|
||||
function validateProgress(value: unknown): asserts value is MovementExpressionProgress {
|
||||
check(Array.isArray(value), 'Retain the largest future physical ground move by named destination.');
|
||||
const destinations = new Set<string>();
|
||||
@@ -101,17 +118,38 @@ function validateProgress(value: unknown): asserts value is MovementExpressionPr
|
||||
}
|
||||
}
|
||||
|
||||
function evaluate(expression: MovementExpression, progress: readonly MovementExpressionProgressEntry[]): boolean {
|
||||
if ('terms' in expression) return expression.kind === 'and' ?
|
||||
expression.terms.every(term => evaluate(term, progress)) : expression.terms.some(term => evaluate(term, progress));
|
||||
return (progress.find(entry => entry.territory === expression.territory)?.maximum ?? 0) >= expression.minimum;
|
||||
function validateSectorProgress(value: unknown): asserts value is MovementExpressionSectorProgress {
|
||||
check(Array.isArray(value), 'Retain exact-sector physical ground-movement maxima.');
|
||||
const locations = new Set<string>();
|
||||
for (const entry of value) {
|
||||
check(object(entry) && keys(entry, 'maximum,sector,territory') && printedTerritory(entry.territory) &&
|
||||
typeof entry.sector === 'number' && Number.isSafeInteger(entry.sector) &&
|
||||
TERRITORIES.find(destination => destination.id === entry.territory)!.sectors.includes(entry.sector) &&
|
||||
count(entry.maximum) && !locations.has(JSON.stringify([entry.territory, entry.sector])),
|
||||
'Retain unique printed sectors and physical ground-movement maxima from zero to twenty.');
|
||||
locations.add(JSON.stringify([entry.territory, entry.sector]));
|
||||
}
|
||||
}
|
||||
|
||||
function evaluate(expression: MovementExpression, progress: readonly MovementExpressionProgressEntry[],
|
||||
sectorProgress: readonly MovementExpressionSectorProgressEntry[]): boolean {
|
||||
if ('terms' in expression) return expression.kind === 'and' ?
|
||||
expression.terms.every(term => evaluate(term, progress, sectorProgress)) :
|
||||
expression.terms.some(term => evaluate(term, progress, sectorProgress));
|
||||
const maximum = expression.sector === undefined
|
||||
? progress.find(entry => entry.territory === expression.territory)?.maximum
|
||||
: sectorProgress.find(entry => entry.territory === expression.territory && entry.sector === expression.sector)?.maximum;
|
||||
return (maximum ?? 0) >= expression.minimum;
|
||||
}
|
||||
|
||||
/** Missing sector progress is never inferred from territory-wide maxima. */
|
||||
export function evaluateMovementExpression(expression: MovementExpression,
|
||||
progress: readonly MovementExpressionProgressEntry[]): boolean {
|
||||
progress: readonly MovementExpressionProgressEntry[],
|
||||
sectorProgress: readonly MovementExpressionSectorProgressEntry[] = []): boolean {
|
||||
const parsed = parseMovementExpression(expression);
|
||||
validateProgress(progress);
|
||||
return evaluate(parsed, progress);
|
||||
validateSectorProgress(sectorProgress);
|
||||
return evaluate(parsed, progress, sectorProgress);
|
||||
}
|
||||
|
||||
export function liveMovementExpressionPromises<T extends MovementExpressionPromise>(promises: readonly T[], player: string,
|
||||
@@ -124,7 +162,16 @@ function readPromise(promise: MovementExpressionPromise): MovementExpression {
|
||||
check(object(promise), 'The ground-movement expression commitment record is invalid.');
|
||||
const { claim } = parseMovementExpressionClaim({ turn: promise.turn, claim: promise.claim });
|
||||
validateProgress(promise.progress);
|
||||
const destinations = movementExpressionTerritories(claim);
|
||||
const destinations = movementExpressionTerritories(claim), sectors = movementExpressionSectors(claim);
|
||||
check(!sectors.length || Array.isArray(promise.sectorProgress),
|
||||
'Retain the separate actual-sector progress for every exact-sector question.');
|
||||
if (promise.sectorProgress !== undefined) {
|
||||
validateSectorProgress(promise.sectorProgress);
|
||||
check(sectors.length > 0 && promise.sectorProgress.every(entry =>
|
||||
sectors.some(location => location.territory === entry.territory && location.sector === entry.sector) &&
|
||||
entry.maximum <= (promise.progress.find(progress => progress.territory === entry.territory)?.maximum ?? 0)),
|
||||
'Retain only named actual-sector maxima bounded by the territory-wide maxima.');
|
||||
}
|
||||
check(typeof promise.answer === 'boolean' && optionalBoolean(promise.fulfilled) && optionalBoolean(promise.released) &&
|
||||
!(promise.fulfilled && promise.released) && promise.progress.every(entry => destinations.includes(entry.territory)),
|
||||
'Retain a definite whole movement answer and its own matching future physical move progress.');
|
||||
@@ -136,22 +183,40 @@ function readPromise(promise: MovementExpressionPromise): MovementExpression {
|
||||
* event may have amount zero, which cannot advance a positive minimum. */
|
||||
export function recordMovementExpressionEvent<T extends MovementExpressionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, event: MovementEvent): T[] {
|
||||
check(object(event) && keys(event, 'amount,territory') && identifier(event.territory) && count(event.amount),
|
||||
'Retain the exact destination and zero to twenty actual physical ground-moving forces.');
|
||||
check(object(event) && (keys(event, 'amount,territory') || keys(event, 'amount,sector,territory')) &&
|
||||
identifier(event.territory) && count(event.amount) && (!('sector' in event) ||
|
||||
typeof event.sector === 'number' && Number.isSafeInteger(event.sector) &&
|
||||
validLocation(event.territory, event.sector)),
|
||||
'Retain the exact destination, any printed sector and zero to twenty actual physical ground-moving forces.');
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveMovementExpressionPromises(promises, player, turn)) {
|
||||
const claim = readPromise(promise);
|
||||
if (!movementExpressionTerritories(claim).includes(event.territory)) continue;
|
||||
const old = promise.progress.find(entry => entry.territory === event.territory)?.maximum ?? 0;
|
||||
if (event.amount <= old) continue;
|
||||
const namedSector = event.sector !== undefined && movementExpressionSectors(claim).some(location =>
|
||||
location.territory === event.territory && location.sector === event.sector);
|
||||
const oldSector = namedSector ? promise.sectorProgress?.find(entry =>
|
||||
entry.territory === event.territory && entry.sector === event.sector)?.maximum ?? 0 : 0;
|
||||
const advanceTerritory = event.amount > old, advanceSector = namedSector && event.amount > oldSector;
|
||||
if (!advanceTerritory && !advanceSector) continue;
|
||||
const progress = promise.progress.map(entry => ({ ...entry }));
|
||||
const entry = progress.find(entry => entry.territory === event.territory);
|
||||
if (entry) entry.maximum = event.amount;
|
||||
else progress.push({ territory: event.territory, maximum: event.amount });
|
||||
const satisfied = evaluate(claim, progress);
|
||||
if (advanceTerritory) {
|
||||
const entry = progress.find(entry => entry.territory === event.territory);
|
||||
if (entry) entry.maximum = event.amount;
|
||||
else progress.push({ territory: event.territory, maximum: event.amount });
|
||||
}
|
||||
const sectorProgress = promise.sectorProgress?.map(entry => ({ ...entry })) ?? [];
|
||||
if (advanceSector && event.sector !== undefined) {
|
||||
const entry = sectorProgress.find(item => item.territory === event.territory && item.sector === event.sector);
|
||||
if (entry) entry.maximum = event.amount;
|
||||
else sectorProgress.push({ territory: event.territory, sector: event.sector, maximum: event.amount });
|
||||
}
|
||||
const satisfied = evaluate(claim, progress, sectorProgress);
|
||||
check(promise.answer || !satisfied,
|
||||
'This ground movement conflicts with your whole Truthtrance movement No answer.');
|
||||
updates.set(promise, { ...promise, progress, ...(promise.answer && satisfied ? { fulfilled: true } : {}) });
|
||||
updates.set(promise, { ...promise, progress,
|
||||
...(promise.sectorProgress !== undefined || advanceSector ? { sectorProgress } : {}),
|
||||
...(promise.answer && satisfied ? { fulfilled: true } : {}) });
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
@@ -161,7 +226,7 @@ export function validateMovementExpressionPromises(promises: readonly MovementEx
|
||||
const events = new Set<string>();
|
||||
for (const promise of promises) {
|
||||
const claim = readPromise(promise);
|
||||
const satisfied = evaluate(claim, promise.progress);
|
||||
const satisfied = evaluate(claim, promise.progress, promise.sectorProgress ?? []);
|
||||
check(identifier(promise.event) && !events.has(promise.event) && identifier(promise.player) && identifier(promise.asker) &&
|
||||
players.includes(promise.player) && players.includes(promise.asker) && promise.player !== promise.asker &&
|
||||
(promise.answer || !satisfied) && (!promise.answer || promise.released || !!promise.fulfilled === satisfied),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Action, Game, Player } from './engine';
|
||||
import { gameTerritories, splitLocation } from './board';
|
||||
import { liveMovementPromises, movementEventAllowed, movementMatches, settleMovementEvent, type MovementEvent, type MovementPromise } from './movement-promises';
|
||||
import { evaluateMovementExpression, liveMovementExpressionPromises, movementExpressionTerritories,
|
||||
import { evaluateMovementExpression, liveMovementExpressionPromises, movementExpressionTerritories, movementExpressionSectors,
|
||||
MovementExpressionPromiseError, recordMovementExpressionEvent, type MovementExpression,
|
||||
type MovementExpressionPromise } from './movement-expression-promises';
|
||||
|
||||
@@ -69,6 +69,9 @@ export function findMovementCompletion(source: Game, actor: string,
|
||||
positiveWhole.some(promise => !evaluateMovementExpression(promise.claim,
|
||||
movementExpressionTerritories(promise.claim).map(territory => ({ territory,
|
||||
maximum: Math.max(promise.progress.find(entry => entry.territory === territory)?.maximum ?? 0,
|
||||
Math.min(20, possiblePhysical)) })),
|
||||
movementExpressionSectors(promise.claim).map(({ territory, sector }) => ({ territory, sector,
|
||||
maximum: Math.max(promise.sectorProgress?.find(entry => entry.territory === territory && entry.sector === sector)?.maximum ?? 0,
|
||||
Math.min(20, possiblePhysical)) }))))) return null;
|
||||
const key = JSON.stringify([player.forces, player.elites, player.advisors, player.moved, player.shipped, player.reserves, player.tanks, player.hand.map(card => card.id),
|
||||
player.hajrPlays, player.hajrWindow, player.ornithopterExtraMoves, player.spice, game.choamMovement,
|
||||
@@ -78,8 +81,8 @@ export function findMovementCompletion(source: Game, actor: string,
|
||||
game.aid, game.karamaShipping, game.guildRateBlocked, game.shipmentPromises,
|
||||
game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy,
|
||||
game.choamWorthlessBlocked, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null,
|
||||
live.map(promise => [promise.territory, promise.minimum, promise.answer]),
|
||||
liveWhole.map(promise => [promise.claim, promise.answer, promise.progress]), adapter.progressKey?.(game, actor)]);
|
||||
live.map(promise => [promise.territory, promise.sector, promise.minimum, promise.answer]),
|
||||
liveWhole.map(promise => [promise.claim, promise.answer, promise.progress, promise.sectorProgress]), adapter.progressKey?.(game, actor)]);
|
||||
if (failed.has(key)) return null;
|
||||
const destinations = gameTerritories(game).filter(destination => !destination.id.startsWith('homeworld:'));
|
||||
const wanted = new Set([...live.filter(promise => promise.answer).map(promise => promise.territory),
|
||||
@@ -110,12 +113,13 @@ export function findMovementCompletion(source: Game, actor: string,
|
||||
const amounts = [...(carriesMarker ? [0] : []), ...Array.from({ length: total }, (_, i) => i + 1)];
|
||||
const helpsWhole = (expression: MovementExpression, amount: number): boolean => 'terms' in expression
|
||||
? expression.terms.some(term => helpsWhole(term, amount))
|
||||
: expression.territory === destination.id && amount >= expression.minimum;
|
||||
const helps = (amount: number) => live.some(p => p.answer && p.territory === destination.id && amount >= p.minimum) ||
|
||||
: expression.territory === destination.id && (expression.sector === undefined || expression.sector === sector) && amount >= expression.minimum;
|
||||
const helps = (amount: number) => live.some(p => p.answer && p.territory === destination.id &&
|
||||
(p.sector === undefined || p.sector === sector) && amount >= p.minimum) ||
|
||||
positiveWhole.some(promise => helpsWhole(promise.claim, amount));
|
||||
amounts.sort((a, b) => Number(helps(b)) - Number(helps(a)) || a - b);
|
||||
for (const amount of amounts) {
|
||||
const event = { territory: destination.id, amount };
|
||||
const event = { territory: destination.id, sector, amount };
|
||||
if (!movementEventAllowed(live, event)) continue;
|
||||
if (!mayMoveAgain && live.some(promise => promise.answer && !movementMatches(promise, event))) continue;
|
||||
let projectedWhole: MovementExpressionPromise[];
|
||||
|
||||
@@ -3,8 +3,8 @@ import { nativePromiseScopeSupported } from './native-promise-scope';
|
||||
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 MovementClaim = { territory: string; minimum: number; sector?: number };
|
||||
export type MovementEvent = { territory: string; amount: number; sector?: number };
|
||||
export type MovementPromise = MovementClaim & {
|
||||
/** New answers share accepted order with whole expressions; older saves omit it. */
|
||||
event?: string;
|
||||
@@ -31,6 +31,12 @@ export function parseMovementClaim(value: unknown): MovementClaim {
|
||||
check(typeof claim.minimum === 'number' && Number.isSafeInteger(claim.minimum) &&
|
||||
claim.minimum >= 1 && claim.minimum <= 20,
|
||||
'Choose a minimum of one to twenty physical forces.');
|
||||
if (Object.hasOwn(claim, 'sector')) {
|
||||
check(typeof claim.sector === 'number' && Number.isSafeInteger(claim.sector) &&
|
||||
TERRITORIES.find(destination => destination.id === claim.territory)!.sectors.includes(claim.sector),
|
||||
'Choose a printed sector of the ground-movement destination.');
|
||||
return { territory: claim.territory, minimum: claim.minimum, sector: claim.sector };
|
||||
}
|
||||
return { territory: claim.territory, minimum: claim.minimum };
|
||||
}
|
||||
export function validMovementClaim(value: unknown): value is MovementClaim {
|
||||
@@ -41,10 +47,11 @@ export function validMovementClaim(value: unknown): value is MovementClaim {
|
||||
}
|
||||
}
|
||||
export function movementClaimText(claim: MovementClaim): string {
|
||||
return `make a ground movement of at least ${claim.minimum} physical forces to ${territory(claim.territory).name}`;
|
||||
return `make a ground movement of at least ${claim.minimum} physical forces to ${territory(claim.territory).name}${claim.sector === undefined ? '' : `, sector ${claim.sector}`}`;
|
||||
}
|
||||
export function movementMatches(claim: MovementClaim, event: MovementEvent | null): boolean {
|
||||
return event !== null && event.territory === claim.territory &&
|
||||
(claim.sector === undefined || event.sector === claim.sector) &&
|
||||
Number.isSafeInteger(event.amount) && event.amount >= claim.minimum && event.amount <= 20;
|
||||
}
|
||||
export function liveMovementPromises<T extends MovementPromise>(
|
||||
|
||||
+2
-2
@@ -3298,7 +3298,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A printed Amal clause names the original phase opening and counts a future printed play after this answer. The actual available-spice reduction occurs before ordinary payments become possible; known Mentat bribe receipts arrive afterward. Definite answers reopen your own opening pass. Own cost or acceptance must retain a completion, while a foreign Amal can release genuine impossibility before another player takes the last pass. Later openings keep separate intervals.',
|
||||
'One remaining owned Charity claim may fund cumulative ordinary installments after actual prepayments. Charity itself is not a bribe; it cannot be claimed twice or spent again on shipment or revival. Current card windows precede future Charity, and native guides wait for its actual phase. Own premature claiming or Ready cannot evade a commitment; cumulative No caps remain.',
|
||||
'CHOAM may also fund later installments with its own automatic opening receipt, once and independently of an eligible ordinary Charity claim. Future income is not current spendable cash. Native counters and opposing claims retain their rights; a last response pass settles the opposing claim rather than making its debit voluntary. Guides wait for actual cash, and other future market, card, collection or gift revenue is not forecast.',
|
||||
'Shipment clauses describe the same one future original reserve shipment. Choose any sector of the named territory or one exact printed sector; a sector clause matches only that actual destination and physical group. Territory-wide and sector-specific conditions share one shipment, payment and allowance. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
|
||||
'Shipment clauses describe the same one future original reserve shipment. Choose any sector of the named territory or one exact printed sector; a sector clause matches only that actual destination and physical group. Territory-wide and sector-specific conditions share one shipment, payment and allowance. Ground-movement clauses may likewise name a printed destination sector, but separate moves can satisfy separate clauses. Each movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
|
||||
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
|
||||
'Transport and Hajr close at your actual Movement end. Ghola or a payment alternative may remain after that close, through the full current turn including Mentat. A known Inflation Double opening closes payments earlier without closing Ghola. Complete a required payment before its actual opportunity closes; No payment caps remain binding until then.',
|
||||
'Review the persistent own history and current source-bound native guide, or choose another legal manual route. Every definite answer uniformly reopens applicable concurrent preparation or final payment readiness. Four minimal AI profiles participate; complete strategy and final rules/deployed acceptance remain unfinished.',
|
||||
@@ -3742,7 +3742,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Bind one future qualifying physical ground movement before a supported Basic or Advanced respondent with declared native families finishes their current-round turn.',
|
||||
steps: [
|
||||
'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Basic and Advanced with declared native families support clean phases0–4 before Movement. During Movement ask an active player before or after shipment, or a still-unused waiting respondent. Finish pending decisions first. Optional modules, later rounds and wider owned preparations remain unfinished.',
|
||||
'Choose Bind ground movement this turn, a printed territory, optionally one of its printed sectors, and a minimum of one to twenty physical forces. Basic and Advanced with declared native families support clean phases0–4 before Movement. During Movement ask an active player before or after shipment, or a still-unused waiting respondent. Finish pending decisions first. Optional modules, later rounds and wider owned preparations remain unfinished.',
|
||||
'Yes requires one qualifying ground movement from now until that player’s Shipment and Movement turn ends, while possible. Smaller groups do not add together and earlier moves do not count. Shipment, HMS relocation and Ambassador relocation do not fulfill this statement; a concealed No-Field marker itself is not a physical on-board force counter.',
|
||||
'A nonmatching move leaves Yes pending when an owned later route remains. One matching physical commitment fulfills it before card disposal and later Intrusion, Ambassador or Terror effects. You are promising the move, not survival afterward. No forbids every later matching move, including Hajr and both Ornithopter groups, until the actual turn closes.',
|
||||
'Ordinary reserve shipment may prepare or reinforce a later ground group using its actual cost, native allocation and pledged payment split. Shipment itself neither fulfills movement Yes nor violates movement No. Joint guidance honors a required shipment before End; an already declared reserve or native Guild transport keeps its exact source, destination and funding while responses finish.',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TERRITORIES } from './board';
|
||||
import { TERRITORIES, validLocation } from './board';
|
||||
import {
|
||||
parseMixedActionExpression,
|
||||
type CompiledMixedAction,
|
||||
@@ -15,6 +15,7 @@ export const TRAVEL_MAX_LEAVES = 16;
|
||||
export type TravelActionClaim = { kind: 'ship' | 'move'; territory: string; minimum: number; sector?: number };
|
||||
export type TravelClaim = { turn: number; mixed: MixedActionExpression<TravelActionClaim> };
|
||||
export type TravelEvent = { territory: string; amount: number; sector?: number };
|
||||
export type MovementSectorProgress = { territory: string; sector: number; maximum: number };
|
||||
export type TravelBranchLeaf = TravelActionClaim & { answer: boolean };
|
||||
export type TravelPromise = TravelClaim & {
|
||||
event: string;
|
||||
@@ -26,6 +27,8 @@ export type TravelPromise = TravelClaim & {
|
||||
shipment: { pending: boolean; event: TravelEvent | null };
|
||||
/** Largest actual physical ground group per destination after this answer. */
|
||||
progress: { territory: string; maximum: number }[];
|
||||
/** Present only for original questions naming a specific movement sector. */
|
||||
sectorProgress?: MovementSectorProgress[];
|
||||
movementClosed: boolean;
|
||||
fulfilled: boolean;
|
||||
released: boolean;
|
||||
@@ -55,16 +58,16 @@ const physical = (value: unknown): value is number =>
|
||||
const printedTerritory = (value: unknown): value is string =>
|
||||
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
|
||||
|
||||
/** An optional printed sector applies only to the original reserve shipment. */
|
||||
/** An optional printed sector names the actual reserve shipment or ground arrival. */
|
||||
export function parseTravelActionClaim(value: unknown): TravelActionClaim {
|
||||
check(object(value) && (keys(value, 'kind,minimum,territory') ||
|
||||
value.kind === 'ship' && keys(value, 'kind,minimum,sector,territory')) &&
|
||||
keys(value, 'kind,minimum,sector,territory')) &&
|
||||
(value.kind === 'ship' || value.kind === 'move'),
|
||||
'Choose one reserve shipment or physical ground movement, with its minimum and printed destination.');
|
||||
if ('sector' in value) check(value.kind === 'ship' && printedTerritory(value.territory) &&
|
||||
if ('sector' in value) check(printedTerritory(value.territory) &&
|
||||
typeof value.sector === 'number' && Number.isSafeInteger(value.sector) &&
|
||||
TERRITORIES.find(destination => destination.id === value.territory)!.sectors.includes(value.sector),
|
||||
'Choose an actual printed sector in the shipment destination.');
|
||||
'Choose an actual printed sector in the destination.');
|
||||
try {
|
||||
const claim = value.kind === 'ship' ? parseShipmentClaim(value) : parseMovementClaim(value);
|
||||
// parseShipmentClaim receives a flat leaf above, never its expression form.
|
||||
@@ -92,6 +95,16 @@ function storedFact(value: unknown, depth = 0): TruthFact {
|
||||
return value as unknown as TruthFact;
|
||||
}
|
||||
|
||||
/** New-only ledger initializer. The original expression is inspected, not a
|
||||
* compiled branch, because frozen facts can fold an originally named sector. */
|
||||
export function needsMovementSectorProgress(node: unknown): boolean {
|
||||
if (!object(node)) return false;
|
||||
if (node.kind === 'action') return object(node.action) && node.action.kind === 'move' &&
|
||||
Object.hasOwn(node.action, 'sector');
|
||||
return (node.kind === 'and' || node.kind === 'or') && Array.isArray(node.terms) &&
|
||||
node.terms.some(needsMovementSectorProgress);
|
||||
}
|
||||
|
||||
function actionKey(action: TravelActionClaim): string {
|
||||
return `${action.kind}:${action.territory}:${action.minimum}${action.sector === undefined ? '' : `:sector:${action.sector}`}`;
|
||||
}
|
||||
@@ -123,11 +136,15 @@ function readCompiled(value: unknown, originals: ReadonlySet<string>): CompiledM
|
||||
}
|
||||
|
||||
function validateEvent(event: unknown): asserts event is TravelEvent {
|
||||
// Events are native physical arrivals, which may be at the mobile
|
||||
// stronghold or a revealed Discovery. Question claims remain printed-only;
|
||||
// the engine independently validates that a dynamic site actually exists.
|
||||
check(object(event) && (keys(event, 'amount,territory') || keys(event, 'amount,sector,territory')) &&
|
||||
printedTerritory(event.territory) && physical(event.amount) &&
|
||||
(!('sector' in event) || typeof event.sector === 'number' && Number.isSafeInteger(event.sector) &&
|
||||
TERRITORIES.find(destination => destination.id === event.territory)!.sectors.includes(event.sector)),
|
||||
'Retain the printed destination, any actual printed sector and zero to twenty physical forces.');
|
||||
typeof event.territory === 'string' && physical(event.amount) &&
|
||||
('sector' in event ? typeof event.sector === 'number' && Number.isSafeInteger(event.sector) &&
|
||||
validLocation(event.territory, event.sector) :
|
||||
printedTerritory(event.territory) || validLocation(event.territory, 0)),
|
||||
'Retain a structurally valid destination, any actual sector and zero to twenty physical forces.');
|
||||
}
|
||||
|
||||
function readPromise(promise: TravelPromise): CompiledMixedAction<TravelActionClaim> {
|
||||
@@ -135,10 +152,14 @@ function readPromise(promise: TravelPromise): CompiledMixedAction<TravelActionCl
|
||||
const mixed = parseMixedActionExpression(promise.mixed, storedFact, parseTravelActionClaim, { requireFact: false });
|
||||
const originals = new Set<string>();
|
||||
const destinations = new Set<string>();
|
||||
const sectorDestinations = new Set<string>();
|
||||
const visit = (node: MixedActionExpression<TravelActionClaim>): void => {
|
||||
if (node.kind === 'action') {
|
||||
originals.add(actionKey(node.action));
|
||||
if (node.action.kind === 'move') destinations.add(node.action.territory);
|
||||
if (node.action.kind === 'move') {
|
||||
destinations.add(node.action.territory);
|
||||
if (node.action.sector !== undefined) sectorDestinations.add(JSON.stringify([node.action.territory, node.action.sector]));
|
||||
}
|
||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
visit(mixed);
|
||||
@@ -161,6 +182,17 @@ function readPromise(promise: TravelPromise): CompiledMixedAction<TravelActionCl
|
||||
'Retain unique named movement destinations and actual physical group maxima.');
|
||||
seen.add(entry.territory);
|
||||
}
|
||||
check(sectorDestinations.size ? Array.isArray(promise.sectorProgress) : !('sectorProgress' in promise),
|
||||
'Retain per-sector movement maxima only for original sector-qualified questions.');
|
||||
const seenSectors = new Set<string>();
|
||||
for (const entry of promise.sectorProgress ?? []) {
|
||||
const key = object(entry) ? JSON.stringify([entry.territory, entry.sector]) : '';
|
||||
check(object(entry) && keys(entry, 'maximum,sector,territory') && sectorDestinations.has(key) &&
|
||||
physical(entry.maximum) && !seenSectors.has(key) &&
|
||||
(promise.progress.find(row => row.territory === entry.territory)?.maximum ?? 0) >= entry.maximum,
|
||||
'Retain unique named physical sector maxima under the actual territory maximum.');
|
||||
seenSectors.add(key);
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
@@ -168,7 +200,10 @@ function actionValue(action: TravelActionClaim, promise: TravelPromise): boolean
|
||||
if (action.kind === 'ship') return promise.shipment.event !== null &&
|
||||
promise.shipment.event.territory === action.territory && promise.shipment.event.amount >= action.minimum &&
|
||||
(action.sector === undefined || promise.shipment.event.sector === action.sector);
|
||||
return (promise.progress.find(entry => entry.territory === action.territory)?.maximum ?? 0) >= action.minimum;
|
||||
return ((action.sector === undefined
|
||||
? promise.progress.find(entry => entry.territory === action.territory)?.maximum
|
||||
: promise.sectorProgress?.find(entry => entry.territory === action.territory && entry.sector === action.sector)?.maximum
|
||||
) ?? 0) >= action.minimum;
|
||||
}
|
||||
|
||||
function evaluate(node: CompiledMixedAction<TravelActionClaim>, promise: TravelPromise): boolean {
|
||||
@@ -287,12 +322,28 @@ export function recordTravelMovement<T extends TravelPromise>(promises: readonly
|
||||
visit(promise.mixed);
|
||||
if (!named) continue;
|
||||
const old = promise.progress.find(entry => entry.territory === event.territory)?.maximum ?? 0;
|
||||
if (event.amount <= old) continue;
|
||||
const namesSector = (node: MixedActionExpression<TravelActionClaim>): boolean => node.kind === 'action'
|
||||
? node.action.kind === 'move' && node.action.territory === event.territory && node.action.sector === event.sector && event.sector !== undefined
|
||||
: node.kind !== 'fact' && node.terms.some(namesSector);
|
||||
const sectorNamed = event.sector !== undefined && namesSector(promise.mixed);
|
||||
const oldSector = sectorNamed ? promise.sectorProgress!.find(entry => entry.territory === event.territory &&
|
||||
entry.sector === event.sector)?.maximum ?? 0 : 0;
|
||||
if (event.amount <= old && (!sectorNamed || event.amount <= oldSector)) continue;
|
||||
const progress = promise.progress.map(entry => ({ ...entry }));
|
||||
const entry = progress.find(candidate => candidate.territory === event.territory);
|
||||
if (entry) entry.maximum = event.amount;
|
||||
else progress.push({ territory: event.territory, maximum: event.amount });
|
||||
updates.set(promise, settle(promise, { ...promise, progress }));
|
||||
if (event.amount > old) {
|
||||
const entry = progress.find(candidate => candidate.territory === event.territory);
|
||||
if (entry) entry.maximum = event.amount;
|
||||
else progress.push({ territory: event.territory, maximum: event.amount });
|
||||
}
|
||||
let sectorProgress = promise.sectorProgress;
|
||||
if (sectorNamed && event.amount > oldSector) {
|
||||
sectorProgress = promise.sectorProgress!.map(entry => ({ ...entry }));
|
||||
const entry = sectorProgress.find(candidate => candidate.territory === event.territory && candidate.sector === event.sector);
|
||||
if (entry) entry.maximum = event.amount;
|
||||
else sectorProgress.push({ territory: event.territory, sector: event.sector!, maximum: event.amount });
|
||||
}
|
||||
updates.set(promise, settle(promise, { ...promise, progress,
|
||||
...(sectorProgress ? { sectorProgress } : {}) }));
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
+4
-3
@@ -515,7 +515,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), action => {
|
||||
check(action && typeof action === 'object' && !Array.isArray(action) &&
|
||||
Object.keys(action).sort().join(',') === 'minimum,territory', 'Supply only a printed movement destination and physical minimum.');
|
||||
['minimum,territory', 'minimum,sector,territory'].includes(Object.keys(action).sort().join(',')),
|
||||
'Supply a printed movement destination, optional sector and physical minimum.');
|
||||
return parseMovementClaim(action);
|
||||
});
|
||||
return { kind: 'mixedMovement', target: v.target as string, turn: g.turn, mixed };
|
||||
@@ -529,8 +530,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
return { kind: 'movementExpression', target: v.target as string, ...claim };
|
||||
}
|
||||
if (v.kind === 'movement') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,territory',
|
||||
'Supply one ground-movement destination, minimum and respondent.');
|
||||
check(['kind,minimum,target,territory', 'kind,minimum,sector,target,territory'].includes(Object.keys(v).sort().join(',')),
|
||||
'Supply one ground-movement destination, optional sector, minimum and respondent.');
|
||||
check(movementPromiseTargetAvailable(g, v.target as string),
|
||||
'Ground-movement promises need a supported unused opportunity this round under Basic or Advanced rules with declared native families and pending decisions finished. Optional modules remain unfinished.');
|
||||
try { return { kind: 'movement', target: v.target as string, ...parseMovementClaim(v) }; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TERRITORIES } from './board';
|
||||
import { TERRITORIES, validLocation } from './board';
|
||||
import { BribePromiseError, parseBribeClaim } from './bribe-promises';
|
||||
import { BidPromiseError, parseBidClaim } from './bid-promises';
|
||||
import { AlliancePromiseError, parseAllianceClaim } from './alliance-promises';
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type TravelActionClaim,
|
||||
type TravelBranchLeaf,
|
||||
type TravelEvent,
|
||||
type MovementSectorProgress,
|
||||
} from './travel-expression-promises';
|
||||
import type { TruthFact } from './truthtrance';
|
||||
|
||||
@@ -95,6 +96,8 @@ export type TurnActionPromise = TurnActionClaim & {
|
||||
shipment: { pending: boolean; event: TravelEvent | null };
|
||||
/** Largest physical ground group after this answer, never a sum of groups. */
|
||||
progress: { territory: string; maximum: number }[];
|
||||
/** Present only for original questions naming a specific movement sector. */
|
||||
sectorProgress?: MovementSectorProgress[];
|
||||
/** Actual completed ordinary payer actions after this answer. */
|
||||
payments: TurnActionPayment[];
|
||||
transportClosed: boolean;
|
||||
@@ -232,7 +235,7 @@ function actionKey(action: TurnActionLeaf): string {
|
||||
if (action.kind === 'leader') return JSON.stringify([action.kind, action.leader]);
|
||||
if (action.kind === 'forces') return JSON.stringify([action.kind, action.counter, action.minimum]);
|
||||
if (action.kind === 'bid') return JSON.stringify([action.kind, action.lot, action.minimum]);
|
||||
if (action.kind === 'ship' && action.sector !== undefined)
|
||||
if ((action.kind === 'ship' || action.kind === 'move') && action.sector !== undefined)
|
||||
return JSON.stringify([action.kind, action.territory, action.minimum, action.sector]);
|
||||
return JSON.stringify(action.kind === 'payment' ? [action.kind, action.recipient, action.minimum] :
|
||||
[action.kind, action.territory, action.minimum]);
|
||||
@@ -265,16 +268,21 @@ function readCompiled(value: unknown, originals: ReadonlySet<string>): CompiledM
|
||||
}
|
||||
|
||||
function validateEvent(event: unknown): asserts event is TravelEvent {
|
||||
// Events are native physical arrivals, which may be at the mobile
|
||||
// stronghold or a revealed Discovery. Question claims remain printed-only;
|
||||
// the engine independently validates that a dynamic site actually exists.
|
||||
check(object(event) && (keys(event, 'amount,territory') || keys(event, 'amount,sector,territory')) &&
|
||||
printedTerritory(event.territory) && physical(event.amount) &&
|
||||
(!('sector' in event) || typeof event.sector === 'number' && Number.isSafeInteger(event.sector) &&
|
||||
TERRITORIES.find(destination => destination.id === event.territory)!.sectors.includes(event.sector)),
|
||||
'Retain the printed destination, any actual printed sector and zero to twenty physical forces.');
|
||||
typeof event.territory === 'string' && physical(event.amount) &&
|
||||
('sector' in event ? typeof event.sector === 'number' && Number.isSafeInteger(event.sector) &&
|
||||
validLocation(event.territory, event.sector) :
|
||||
printedTerritory(event.territory) || validLocation(event.territory, 0)),
|
||||
'Retain a structurally valid destination, any actual sector and zero to twenty physical forces.');
|
||||
}
|
||||
|
||||
function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnActionLeaf> {
|
||||
check(object(promise), 'Retain the complete original turn-action commitment and its metadata.');
|
||||
const fields = 'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,progress,released,shipment,transportClosed,turn'.split(',');
|
||||
if ('sectorProgress' in promise) fields.push('sectorProgress');
|
||||
if ('preparation' in promise) fields.push('preparation');
|
||||
if ('alliance' in promise) fields.push('alliance');
|
||||
if ('combat' in promise) fields.push('combat');
|
||||
@@ -295,6 +303,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
}
|
||||
const originals = new Set<string>();
|
||||
const destinations = new Set<string>();
|
||||
const sectorDestinations = new Set<string>();
|
||||
const recipients = new Set<string>();
|
||||
const lots = new Set<number>();
|
||||
let hasRevival = false;
|
||||
@@ -309,7 +318,10 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
|
||||
if (node.kind === 'action') {
|
||||
originals.add(actionKey(node.action));
|
||||
if (node.action.kind === 'move') destinations.add(node.action.territory);
|
||||
if (node.action.kind === 'move') {
|
||||
destinations.add(node.action.territory);
|
||||
if (node.action.sector !== undefined) sectorDestinations.add(JSON.stringify([node.action.territory, node.action.sector]));
|
||||
}
|
||||
if (node.action.kind === 'payment') recipients.add(node.action.recipient);
|
||||
if (node.action.kind === 'bid') lots.add(node.action.lot);
|
||||
if (node.action.kind === 'forces' || node.action.kind === 'leader') hasRevival = true;
|
||||
@@ -464,6 +476,17 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
'Retain unique named movement destinations and actual physical group maxima.');
|
||||
seenDestinations.add(entry.territory);
|
||||
}
|
||||
check(sectorDestinations.size ? Array.isArray(promise.sectorProgress) : !('sectorProgress' in promise),
|
||||
'Retain per-sector movement maxima only for original sector-qualified questions.');
|
||||
const seenSectors = new Set<string>();
|
||||
for (const entry of promise.sectorProgress ?? []) {
|
||||
const key = object(entry) ? JSON.stringify([entry.territory, entry.sector]) : '';
|
||||
check(object(entry) && keys(entry, 'maximum,sector,territory') && sectorDestinations.has(key) &&
|
||||
physical(entry.maximum) && !seenSectors.has(key) &&
|
||||
(promise.progress.find(row => row.territory === entry.territory)?.maximum ?? 0) >= entry.maximum,
|
||||
'Retain unique named physical sector maxima under the actual territory maximum.');
|
||||
seenSectors.add(key);
|
||||
}
|
||||
check(Array.isArray(promise.payments), 'Retain this answer’s cumulative ordinary payments.');
|
||||
const seenRecipients = new Set<string>();
|
||||
for (const payment of promise.payments) {
|
||||
@@ -496,7 +519,10 @@ function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolea
|
||||
promise.shipment.event.territory === action.territory &&
|
||||
(action.sector === undefined || promise.shipment.event.sector === action.sector) &&
|
||||
promise.shipment.event.amount >= action.minimum;
|
||||
return (promise.progress.find(entry => entry.territory === action.territory)?.maximum ?? 0) >= action.minimum;
|
||||
return ((action.sector === undefined
|
||||
? promise.progress.find(entry => entry.territory === action.territory)?.maximum
|
||||
: promise.sectorProgress?.find(entry => entry.territory === action.territory && entry.sector === action.sector)?.maximum
|
||||
) ?? 0) >= action.minimum;
|
||||
}
|
||||
|
||||
function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnActionPromise): boolean {
|
||||
@@ -567,7 +593,7 @@ function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): bo
|
||||
if (left.kind === 'bid') return right.kind === 'bid' && left.lot === right.lot && left.source === right.source;
|
||||
if (left.kind === 'payment') return right.kind === 'payment' && left.recipient === right.recipient && left.progress === right.progress;
|
||||
return (right.kind === 'ship' || right.kind === 'move') && left.territory === right.territory &&
|
||||
(left.kind !== 'ship' || left.sector === right.sector);
|
||||
left.sector === right.sector;
|
||||
}
|
||||
|
||||
/** Legal values for all signed leaves tied to the same original dial. A caller
|
||||
@@ -619,15 +645,15 @@ function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf
|
||||
const no = merged.find(other => sameAction(other, leaf) && !other.answer);
|
||||
if (yes && no && 'minimum' in yes && 'minimum' in no &&
|
||||
yes.minimum >= no.minimum) return null;
|
||||
if (leaf.kind === 'ship') {
|
||||
if (leaf.answer && merged.some(other => other.kind === 'ship' && other.answer &&
|
||||
if (leaf.kind === 'ship' || leaf.kind === 'move') {
|
||||
if (leaf.kind === 'ship' && leaf.answer && merged.some(other => other.kind === 'ship' && other.answer &&
|
||||
(other.territory !== leaf.territory || other.sector !== undefined && leaf.sector !== undefined &&
|
||||
other.sector !== leaf.sector))) return null;
|
||||
// One exact positive shipment necessarily satisfies any broader negative
|
||||
// condition with an equal or lower minimum. A broad positive and exact
|
||||
// negative remain separate: a different sector may still be available.
|
||||
for (const required of merged) if (required.kind === 'ship' && required.answer)
|
||||
for (const excluded of merged) if (excluded.kind === 'ship' && !excluded.answer &&
|
||||
// One exact positive action necessarily satisfies a broader negative
|
||||
// condition at an equal or lower minimum. A broad positive and an exact
|
||||
// negative can coexist when another legal sector is available.
|
||||
for (const required of merged) if (required.kind === leaf.kind && required.answer)
|
||||
for (const excluded of merged) if (excluded.kind === leaf.kind && !excluded.answer &&
|
||||
required.territory === excluded.territory && required.minimum >= excluded.minimum &&
|
||||
(excluded.sector === undefined || required.sector !== undefined && required.sector === excluded.sector)) return null;
|
||||
}
|
||||
@@ -1006,12 +1032,28 @@ export function recordTurnActionMovement<T extends TurnActionPromise>(promises:
|
||||
: node.kind !== 'fact' && node.terms.some(named);
|
||||
if (!named(promise.mixed)) continue;
|
||||
const old = promise.progress.find(entry => entry.territory === event.territory)?.maximum ?? 0;
|
||||
if (event.amount <= old) continue;
|
||||
const namesSector = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
|
||||
? node.action.kind === 'move' && node.action.territory === event.territory && node.action.sector === event.sector && event.sector !== undefined
|
||||
: node.kind !== 'fact' && node.terms.some(namesSector);
|
||||
const sectorNamed = event.sector !== undefined && namesSector(promise.mixed);
|
||||
const oldSector = sectorNamed ? promise.sectorProgress!.find(entry => entry.territory === event.territory &&
|
||||
entry.sector === event.sector)?.maximum ?? 0 : 0;
|
||||
if (event.amount <= old && (!sectorNamed || event.amount <= oldSector)) continue;
|
||||
const progress = promise.progress.map(entry => ({ ...entry }));
|
||||
const entry = progress.find(candidate => candidate.territory === event.territory);
|
||||
if (entry) entry.maximum = event.amount;
|
||||
else progress.push({ territory: event.territory, maximum: event.amount });
|
||||
updates.set(promise, settle(promise, { ...promise, progress }));
|
||||
if (event.amount > old) {
|
||||
const entry = progress.find(candidate => candidate.territory === event.territory);
|
||||
if (entry) entry.maximum = event.amount;
|
||||
else progress.push({ territory: event.territory, maximum: event.amount });
|
||||
}
|
||||
let sectorProgress = promise.sectorProgress;
|
||||
if (sectorNamed && event.amount > oldSector) {
|
||||
sectorProgress = promise.sectorProgress!.map(entry => ({ ...entry }));
|
||||
const entry = sectorProgress.find(candidate => candidate.territory === event.territory && candidate.sector === event.sector);
|
||||
if (entry) entry.maximum = event.amount;
|
||||
else sectorProgress.push({ territory: event.territory, sector: event.sector!, maximum: event.amount });
|
||||
}
|
||||
updates.set(promise, settle(promise, { ...promise, progress,
|
||||
...(sectorProgress ? { sectorProgress } : {}) }));
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Action, Game } from '../game/engine';
|
||||
import type { MovementExpression } from '../game/movement-expression-promises';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TurnActionLeaf } from '../game/turn-action-promises';
|
||||
import {
|
||||
actTruthMovementExpression, createTruthMovementExpressionFixture,
|
||||
declareTruthMovementExpression, settleTruthMovementExpression,
|
||||
truthMovementExpressionInventory, truthMovementExpressionPlayer,
|
||||
} from './fixture-truth-movement-expression';
|
||||
|
||||
export type MovementSectorQuestionKind = 'movement' | 'movementExpression' | 'mixedMovement' | 'travel' | 'turnActions';
|
||||
export const actMovementSector = actTruthMovementExpression;
|
||||
export const settleMovementSector = settleTruthMovementExpression;
|
||||
export const movementSectorInventory = truthMovementExpressionInventory;
|
||||
export const movementSectorPlayer = truthMovementExpressionPlayer;
|
||||
|
||||
/** Genuine Basic Atreides/Emperor setup and the original one-force Polar Sink
|
||||
* shipment. The starting Hajr and Truthtrance are original unopened selected
|
||||
* deal positions. This adapter chooses two unstormed printed Imperial Basin
|
||||
* sectors adjacent to the original Arrakeen army; it installs no hand, force,
|
||||
* wallet, timer, question, promise, progress or saved state. */
|
||||
export function createTruthMovementSectorFixture() {
|
||||
const base = createTruthMovementExpressionFixture(), game = structuredClone(base.game);
|
||||
assert.equal(game.advanced, false); assert.equal(game.phase, 5); assert.equal(game.active, base.target);
|
||||
assert.ok(base.hajr && movementSectorPlayer(game, base.target).hand.some(card => card.id === base.hajr));
|
||||
const sectors = [9, 10, 11].filter(sector => sector !== game.storm);
|
||||
assert.ok(sectors.length >= 2);
|
||||
const [sector, alternative] = sectors;
|
||||
const move = (destination: number, amount = 1): Action => ({ type: 'move', from: 'arrakeen:10',
|
||||
territory: 'imperial_basin', sector: destination, amount });
|
||||
const leaf = (destination: number, minimum = 1): MovementExpression => ({ kind: 'move',
|
||||
territory: 'imperial_basin', sector: destination, minimum });
|
||||
const action = (destination: number, minimum = 1): MixedActionExpression<TurnActionLeaf> => ({
|
||||
kind: 'action', action: { kind: 'move', territory: 'imperial_basin', sector: destination, minimum },
|
||||
});
|
||||
const forceTotal = (state: Game) => {
|
||||
const player = movementSectorPlayer(state, base.target);
|
||||
return player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
|
||||
};
|
||||
assert.deepEqual(movementSectorInventory(game), base.originalCards);
|
||||
return { base, game, target: base.target, asker: base.asker, truth: base.truth, hajr: base.hajr,
|
||||
sector, alternative, move, leaf, action, originalCards: base.originalCards, forceTotal,
|
||||
originalForces: forceTotal(game), disclosure: base.sourceDisclosure };
|
||||
}
|
||||
export type TruthMovementSectorFixture = ReturnType<typeof createTruthMovementSectorFixture>;
|
||||
|
||||
export function askMovementSector(game: Game, f: TruthMovementSectorFixture, kind: MovementSectorQuestionKind,
|
||||
grouped = false): Game {
|
||||
const exact = f.leaf(f.sector, grouped ? 2 : 1), other = f.leaf(f.alternative, 1);
|
||||
let question: Record<string, unknown>;
|
||||
if (kind === 'movement') {
|
||||
assert.equal(grouped, false);
|
||||
question = { kind, target: f.target, territory: 'imperial_basin', sector: f.sector, minimum: 1 };
|
||||
} else if (kind === 'movementExpression') {
|
||||
question = { kind, target: f.target, turn: game.turn,
|
||||
claim: grouped ? { kind: 'and', terms: [exact, other] } : exact };
|
||||
} else if (kind === 'mixedMovement') {
|
||||
const physicalHajr = movementSectorPlayer(game, f.target).hand.find(card => card.id === f.hajr);
|
||||
assert.ok(physicalHajr);
|
||||
const knownFact = { kind: 'fact', fact: { kind: 'hand', name: physicalHajr.name } };
|
||||
const selected = { kind: 'action', action: { territory: 'imperial_basin', sector: f.sector, minimum: grouped ? 2 : 1 } };
|
||||
const second = { kind: 'action', action: { territory: 'imperial_basin', sector: f.alternative, minimum: 1 } };
|
||||
question = { kind, target: f.target, turn: game.turn,
|
||||
mixed: { kind: 'and', terms: grouped ? [knownFact, selected, second] : [knownFact, selected] } };
|
||||
} else {
|
||||
question = { kind, target: f.target, turn: game.turn, mixed: grouped
|
||||
? { kind: 'and', terms: [f.action(f.sector, 2), f.action(f.alternative)] }
|
||||
: f.action(f.sector) };
|
||||
}
|
||||
return actMovementSector(game, f.asker, { type: 'truthAsk', question } as Action);
|
||||
}
|
||||
export function bindMovementSector(f: TruthMovementSectorFixture, kind: MovementSectorQuestionKind,
|
||||
answer: 'yes' | 'no', grouped = false) {
|
||||
const declared = declareTruthMovementExpression(f.base, f.game).game;
|
||||
const asked = askMovementSector(declared, f, kind, grouped);
|
||||
const game = actMovementSector(asked, f.target, { type: 'truthAnswer', answer });
|
||||
return { declared, asked, game };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { MovementClaimFields } from '../components/movement-claim-fields';
|
||||
import { MovementExpressionFields } from '../components/movement-expression-fields';
|
||||
import { MixedMovementFields } from '../components/mixed-movement-fields';
|
||||
import { TravelClaimFields } from '../components/travel-claim-fields';
|
||||
import { TurnActionFields } from '../components/turn-action-fields';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
|
||||
import type { MovementClaim } from '../game/movement-promises';
|
||||
import type { MovementExpression } from '../game/movement-expression-promises';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TravelActionClaim } from '../game/travel-expression-promises';
|
||||
import type { TurnActionLeaf } from '../game/turn-action-promises';
|
||||
|
||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||
const destination = TERRITORIES.find(item => item.id === 'imperial_basin')!;
|
||||
assert.ok(destination.sectors.includes(9));
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
function select(tree: ReactNode, id: string, chosen: string) {
|
||||
const field = elements(tree).find(item => item.props.id === id); assert.ok(field, id);
|
||||
(field.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: chosen } });
|
||||
}
|
||||
/** Controlled projection exercises editing, not actual movement legality. */
|
||||
function projection(): GameView {
|
||||
const game = createGame('MOVEMENTSECTOR', newPlayer('p', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('a', 'Emperor', 'emperor'));
|
||||
const view = viewGame(game, 'p'); view.status = 'playing'; view.turn = 1; view.phase = 5;
|
||||
view.active = 'p'; view.movementRemaining = ['p']; return view;
|
||||
}
|
||||
|
||||
void test('standalone movement editor selects or clears an exact printed sector and resets on territory change', () => {
|
||||
let value: MovementClaim = { territory: destination.id, minimum: 2 };
|
||||
const props = () => ({ id: 'plain', value, onChange(next: MovementClaim) { value = next; } });
|
||||
const tree = () => MovementClaimFields(props());
|
||||
assert.match(renderToStaticMarkup(createElement(MovementClaimFields, props())), /Any sector in this territory/);
|
||||
select(tree(), 'plain-sector', '9'); assert.equal(value.sector, 9);
|
||||
select(tree(), 'plain-sector', ''); assert.deepEqual(value, { territory: destination.id, minimum: 2 });
|
||||
select(tree(), 'plain-sector', '9'); select(tree(), 'plain-territory', 'arrakeen');
|
||||
assert.deepEqual(value, { territory: 'arrakeen', minimum: 2 });
|
||||
});
|
||||
|
||||
void test('movement-expression editor keeps exact-sector values through grouping and clears changed destination', () => {
|
||||
let value: MovementExpression = { kind: 'move', territory: destination.id, minimum: 2 };
|
||||
const tree = () => MovementExpressionFields({ value, territories: TERRITORIES, disabled: false, onChange(next) { value = next; } });
|
||||
select(tree(), 'truth-movement-expression-1-sector', '9');
|
||||
assert.deepEqual(value, { kind: 'move', territory: destination.id, minimum: 2, sector: 9 });
|
||||
const wrap = elements(tree()).find(item => item.props['data-movement-expression-control'] === 'and-1'); assert.ok(wrap);
|
||||
(wrap.props.onClick as () => void)();
|
||||
const current = (): MovementExpression => value;
|
||||
const grouped = current(); assert.equal(grouped.kind, 'and');
|
||||
if (grouped.kind !== 'and') throw Error('Expected the new expression group');
|
||||
assert.deepEqual(grouped.terms[0], { kind: 'move', territory: destination.id, minimum: 2, sector: 9 });
|
||||
select(tree(), 'truth-movement-expression-1.1-territory', 'arrakeen');
|
||||
const updated = current();
|
||||
if (updated.kind !== 'and') throw Error('Expected the same expression group');
|
||||
assert.deepEqual(updated.terms[0], { kind: 'move', territory: 'arrakeen', minimum: 2 });
|
||||
});
|
||||
|
||||
void test('mixed movement editor selects the target sector and clears it when the territory changes', () => {
|
||||
const game = projection(); let value: MixedActionExpression<MovementClaim> = { kind: 'action', action: { territory: destination.id, minimum: 1 } };
|
||||
const tree = () => MixedMovementFields({ game, value, target: 'p', disabled: false, onChange(next) { value = next; } });
|
||||
select(tree(), 'truth-mixed-movement-1-sector', '9');
|
||||
assert.deepEqual(value, { kind: 'action', action: { territory: destination.id, minimum: 1, sector: 9 } });
|
||||
select(tree(), 'truth-mixed-movement-1-territory', 'arrakeen');
|
||||
assert.deepEqual(value, { kind: 'action', action: { territory: 'arrakeen', minimum: 1 } });
|
||||
});
|
||||
|
||||
void test('travel and whole-turn action editors choose movement sectors and clear sectors on action changes', () => {
|
||||
const game = projection();
|
||||
let travel: MixedActionExpression<TravelActionClaim> = { kind: 'action', action: { kind: 'move', territory: destination.id, minimum: 1 } };
|
||||
const travelTree = () => TravelClaimFields({ game, value: travel, target: 'p', disabled: false, onChange(next) { travel = next; } });
|
||||
select(travelTree(), 'truth-travel-1-sector', '9');
|
||||
assert.deepEqual(travel, { kind: 'action', action: { kind: 'move', territory: destination.id, minimum: 1, sector: 9 } });
|
||||
select(travelTree(), 'truth-travel-1-action', 'ship');
|
||||
assert.deepEqual(travel, { kind: 'action', action: { kind: 'ship', territory: destination.id, minimum: 1 } });
|
||||
select(travelTree(), 'truth-travel-1-sector', '9'); select(travelTree(), 'truth-travel-1-action', 'move');
|
||||
assert.deepEqual(travel, { kind: 'action', action: { kind: 'move', territory: destination.id, minimum: 1 } });
|
||||
let whole: MixedActionExpression<TurnActionLeaf> = { kind: 'action', action: { kind: 'move', territory: destination.id, minimum: 1 } };
|
||||
const wholeTree = () => TurnActionFields({ game, value: whole, target: 'p', disabled: false, onChange(next) { whole = next; } });
|
||||
select(wholeTree(), 'truth-turn-action-1-sector', '9');
|
||||
assert.deepEqual(whole, { kind: 'action', action: { kind: 'move', territory: destination.id, minimum: 1, sector: 9 } });
|
||||
select(wholeTree(), 'truth-turn-action-1-territory', 'arrakeen');
|
||||
assert.deepEqual(whole, { kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 1 } });
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { MOBILE_STRONGHOLD } from '../game/board';
|
||||
import {
|
||||
MovementExpressionPromiseError,
|
||||
evaluateMovementExpression,
|
||||
movementExpressionSectors,
|
||||
movementExpressionText,
|
||||
parseMovementExpression,
|
||||
recordMovementExpressionEvent,
|
||||
validateMovementExpressionPromises,
|
||||
type MovementExpression,
|
||||
type MovementExpressionPromise,
|
||||
} from '../game/movement-expression-promises';
|
||||
|
||||
const move = (sector?: number, minimum = 3): MovementExpression => ({
|
||||
kind: 'move', territory: 'wind_pass', minimum, ...(sector === undefined ? {} : { sector }),
|
||||
});
|
||||
const group = (kind: 'and' | 'or', ...terms: MovementExpression[]): MovementExpression => ({ kind, terms });
|
||||
const promise = (claim: MovementExpression, answer = true): MovementExpressionPromise => ({
|
||||
event: 'move-expression', turn: 2, player: 'p', asker: 'a', answer, claim, progress: [],
|
||||
...(movementExpressionSectors(claim).length ? { sectorProgress: [] } : {}),
|
||||
});
|
||||
|
||||
void test('sector-aware movement expressions parse printed sectors and preserve legacy leaves', () => {
|
||||
assert.deepEqual(parseMovementExpression(move(15)), move(15));
|
||||
assert.deepEqual(parseMovementExpression(move()), move());
|
||||
assert.deepEqual(movementExpressionSectors(group('or', move(15), move(14), move(15), move())), [
|
||||
{ territory: 'wind_pass', sector: 15 }, { territory: 'wind_pass', sector: 14 },
|
||||
]);
|
||||
assert.match(movementExpressionText(move(15)), /Wind Pass.*sector 15/i);
|
||||
for (const invalid of [
|
||||
{ kind: 'move', territory: 'wind_pass', minimum: 1, sector: 19 },
|
||||
{ kind: 'move', territory: 'wind_pass', minimum: 1, sector: 15.5 },
|
||||
{ kind: 'move', territory: 'wind_pass', minimum: 1, sector: null },
|
||||
{ kind: 'move', territory: 'wind_pass', minimum: 1, sector: 15, extra: true },
|
||||
]) assert.throws(() => parseMovementExpression(invalid), MovementExpressionPromiseError);
|
||||
});
|
||||
|
||||
void test('territory-wide progress never invents an exact sector for an older record', () => {
|
||||
const territoryProgress = [{ territory: 'wind_pass', maximum: 8 }];
|
||||
assert.equal(evaluateMovementExpression(move(), territoryProgress), true);
|
||||
assert.equal(evaluateMovementExpression(move(15), territoryProgress), false);
|
||||
assert.equal(evaluateMovementExpression(move(15), territoryProgress,
|
||||
[{ territory: 'wind_pass', sector: 14, maximum: 8 }]), false);
|
||||
assert.equal(evaluateMovementExpression(move(15), territoryProgress,
|
||||
[{ territory: 'wind_pass', sector: 15, maximum: 3 }]), true);
|
||||
const record = promise(move(15), false);
|
||||
const [result] = recordMovementExpressionEvent([record], 'p', 2, { territory: 'wind_pass', amount: 8 });
|
||||
assert.deepEqual(result.progress, territoryProgress);
|
||||
assert.deepEqual(result.sectorProgress, []);
|
||||
validateMovementExpressionPromises([result], ['p', 'a']);
|
||||
});
|
||||
|
||||
void test('separate actual sectors each keep a per-group maximum, never a sum', () => {
|
||||
const record = promise(group('and', move(14, 4), move(15, 3)));
|
||||
const afterFirst = recordMovementExpressionEvent([record], 'p', 2,
|
||||
{ territory: 'wind_pass', sector: 14, amount: 4 });
|
||||
const afterSmall = recordMovementExpressionEvent(afterFirst, 'p', 2,
|
||||
{ territory: 'wind_pass', sector: 15, amount: 2 });
|
||||
const afterRepeat = recordMovementExpressionEvent(afterSmall, 'p', 2,
|
||||
{ territory: 'wind_pass', sector: 15, amount: 2 });
|
||||
assert.equal(afterRepeat[0].fulfilled, undefined);
|
||||
assert.deepEqual(afterRepeat[0].progress, [{ territory: 'wind_pass', maximum: 4 }]);
|
||||
assert.deepEqual(afterRepeat[0].sectorProgress, [
|
||||
{ territory: 'wind_pass', sector: 14, maximum: 4 },
|
||||
{ territory: 'wind_pass', sector: 15, maximum: 2 },
|
||||
]);
|
||||
const [done] = recordMovementExpressionEvent(afterRepeat, 'p', 2,
|
||||
{ territory: 'wind_pass', sector: 15, amount: 3 });
|
||||
assert.equal(done.fulfilled, true);
|
||||
assert.deepEqual(done.progress, [{ territory: 'wind_pass', maximum: 4 }]);
|
||||
assert.deepEqual(done.sectorProgress, [
|
||||
{ territory: 'wind_pass', sector: 14, maximum: 4 },
|
||||
{ territory: 'wind_pass', sector: 15, maximum: 3 },
|
||||
]);
|
||||
validateMovementExpressionPromises([done], ['p', 'a']);
|
||||
});
|
||||
|
||||
void test('No blocks an actual named sector but permits an unrelated sector', () => {
|
||||
const record = promise(group('or', move(14, 3), move(15, 4)), false);
|
||||
const [other] = recordMovementExpressionEvent([record], 'p', 2,
|
||||
{ territory: 'wind_pass', sector: 16, amount: 8 });
|
||||
assert.deepEqual(other.progress, [{ territory: 'wind_pass', maximum: 8 }]);
|
||||
assert.deepEqual(other.sectorProgress, []);
|
||||
assert.throws(() => recordMovementExpressionEvent([other], 'p', 2,
|
||||
{ territory: 'wind_pass', sector: 14, amount: 3 }), MovementExpressionPromiseError);
|
||||
assert.throws(() => recordMovementExpressionEvent([other], 'p', 2,
|
||||
{ territory: 'wind_pass', sector: 15, amount: 4 }), MovementExpressionPromiseError);
|
||||
validateMovementExpressionPromises([other], ['p', 'a']);
|
||||
});
|
||||
|
||||
void test('legacy records keep their existing shape and malformed sector progress is rejected', () => {
|
||||
const [legacy] = recordMovementExpressionEvent([promise(move())], 'p', 2,
|
||||
{ territory: 'wind_pass', sector: 14, amount: 3 });
|
||||
assert.deepEqual(legacy.progress, [{ territory: 'wind_pass', maximum: 3 }]);
|
||||
assert.equal(Object.hasOwn(legacy, 'sectorProgress'), false);
|
||||
assert.equal(legacy.fulfilled, true);
|
||||
validateMovementExpressionPromises(JSON.parse(JSON.stringify([legacy])), ['p', 'a']);
|
||||
const missing = promise(move(14)); delete missing.sectorProgress;
|
||||
const invalid = [
|
||||
missing,
|
||||
{ ...promise(move()), sectorProgress: [] },
|
||||
{ ...promise(move(14)), progress: [{ territory: 'wind_pass', maximum: 3 }],
|
||||
sectorProgress: [{ territory: 'wind_pass', sector: 15, maximum: 3 }] },
|
||||
{ ...promise(move(14)), progress: [{ territory: 'wind_pass', maximum: 2 }],
|
||||
sectorProgress: [{ territory: 'wind_pass', sector: 14, maximum: 3 }] },
|
||||
{ ...promise(move(14)), progress: [{ territory: 'wind_pass', maximum: 3 }],
|
||||
sectorProgress: [{ territory: 'wind_pass', sector: 14, maximum: 1 },
|
||||
{ territory: 'wind_pass', sector: 14, maximum: 2 }] },
|
||||
];
|
||||
for (const row of invalid) assert.throws(() => validateMovementExpressionPromises([row], ['p', 'a']), MovementExpressionPromiseError);
|
||||
assert.throws(() => recordMovementExpressionEvent([promise(move(14))], 'p', 2,
|
||||
{ territory: 'wind_pass', sector: 19, amount: 1 }), MovementExpressionPromiseError);
|
||||
});
|
||||
|
||||
void test('unrelated dynamic stronghold entry events retain native structural validity', () => {
|
||||
const row = promise(move(14));
|
||||
const untouched = recordMovementExpressionEvent([row], 'p', 2,
|
||||
{ territory: MOBILE_STRONGHOLD, sector: 0, amount: 1 });
|
||||
assert.equal(untouched[0], row);
|
||||
assert.deepEqual(untouched[0].progress, []);
|
||||
assert.deepEqual(untouched[0].sectorProgress, []);
|
||||
assert.throws(() => recordMovementExpressionEvent([], 'p', 2,
|
||||
{ territory: MOBILE_STRONGHOLD, sector: 1, amount: 1 }), MovementExpressionPromiseError);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import {
|
||||
finishMovementPromises, movementClaimText, movementEventAllowed, movementMatches,
|
||||
parseMovementClaim, settleMovementEvent, validMovementClaim, type MovementPromise,
|
||||
} from '../game/movement-promises';
|
||||
|
||||
const destination = TERRITORIES.find(row => row.sectors.length >= 2)!;
|
||||
assert.ok(destination);
|
||||
const [first, second] = destination.sectors;
|
||||
const promise = (sector: number | undefined, answer: boolean, minimum = 2): MovementPromise => ({
|
||||
territory: destination.id, minimum, ...(sector === undefined ? {} : { sector }),
|
||||
event: 'promise', turn: 1, player: 'owner', asker: 'asker', answer,
|
||||
});
|
||||
const movement = (sector: number | undefined, amount = 2) => ({
|
||||
territory: destination.id, amount, ...(sector === undefined ? {} : { sector }),
|
||||
});
|
||||
|
||||
void test('movement claim accepts one actual printed sector and preserves original territory-only data', () => {
|
||||
const claim = { territory: destination.id, minimum: 2, sector: first };
|
||||
assert.deepEqual(parseMovementClaim(claim), claim);
|
||||
assert.match(movementClaimText(claim), new RegExp(`sector ${first}`));
|
||||
assert.deepEqual(parseMovementClaim({ territory: destination.id, minimum: 2 }),
|
||||
{ territory: destination.id, minimum: 2 });
|
||||
assert.equal(JSON.stringify(parseMovementClaim({ territory: destination.id, minimum: 2 })),
|
||||
JSON.stringify({ territory: destination.id, minimum: 2 }));
|
||||
assert.deepEqual(parseMovementClaim({ ...claim, turn: 1, player: 'owner' }), claim);
|
||||
for (const invalid of [undefined, null, -1, 1.1, '1', Math.max(...destination.sectors) + 100]) {
|
||||
assert.equal(validMovementClaim({ ...claim, sector: invalid }), false);
|
||||
assert.throws(() => parseMovementClaim({ ...claim, sector: invalid }));
|
||||
}
|
||||
});
|
||||
|
||||
void test('sector match requires the actual movement destination, not a missing or different sector', () => {
|
||||
const exact = promise(first, true), generic = promise(undefined, true);
|
||||
assert.equal(movementMatches(exact, movement(first)), true);
|
||||
assert.equal(movementMatches(exact, movement(second)), false);
|
||||
assert.equal(movementMatches(exact, movement(undefined)), false);
|
||||
assert.equal(movementMatches(exact, movement(first, 1)), false);
|
||||
assert.equal(movementMatches(exact, movement(first, 21)), false);
|
||||
assert.equal(movementMatches(exact, null), false);
|
||||
assert.equal(movementMatches(generic, movement(first)), true);
|
||||
assert.equal(movementMatches(generic, movement(second)), true);
|
||||
assert.equal(movementMatches(generic, movement(undefined)), true);
|
||||
});
|
||||
|
||||
void test('Yes remains live through other sectors and smaller groups; one actual group fulfills it', () => {
|
||||
const original = [promise(first, true, 3)];
|
||||
const other = settleMovementEvent(original, 'owner', 1, movement(second, 4));
|
||||
assert.equal(other[0].fulfilled, undefined);
|
||||
const small = settleMovementEvent(other, 'owner', 1, movement(first, 2));
|
||||
assert.equal(small[0].fulfilled, undefined);
|
||||
assert.throws(() => finishMovementPromises(small, 'owner', 1));
|
||||
const matching = settleMovementEvent(small, 'owner', 1, movement(first, 3));
|
||||
assert.equal(matching[0].fulfilled, true);
|
||||
assert.equal(original[0].fulfilled, undefined);
|
||||
assert.equal(finishMovementPromises(matching, 'owner', 1)[0].fulfilled, true);
|
||||
});
|
||||
|
||||
void test('No blocks only its exact sector and retains the restriction across other movements', () => {
|
||||
const original = [promise(first, false)];
|
||||
assert.equal(movementEventAllowed(original, movement(first)), false);
|
||||
assert.throws(() => settleMovementEvent(original, 'owner', 1, movement(first)));
|
||||
const allowed = settleMovementEvent(original, 'owner', 1, movement(second));
|
||||
assert.equal(allowed[0].fulfilled, undefined);
|
||||
assert.equal(movementEventAllowed(allowed, movement(first)), false);
|
||||
assert.equal(finishMovementPromises(allowed, 'owner', 1)[0].fulfilled, true);
|
||||
});
|
||||
|
||||
void test('distinct exact-sector Yes claims can be fulfilled by two distinct physical moves', () => {
|
||||
const rows = [promise(first, true), { ...promise(second, true), event: 'second-promise' }];
|
||||
const firstMove = settleMovementEvent(rows, 'owner', 1, movement(first));
|
||||
assert.equal(firstMove[0].fulfilled, true); assert.equal(firstMove[1].fulfilled, undefined);
|
||||
assert.throws(() => finishMovementPromises(firstMove, 'owner', 1));
|
||||
const secondMove = settleMovementEvent(firstMove, 'owner', 1, movement(second));
|
||||
assert.ok(secondMove.every(row => row.fulfilled));
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { compileMixedActionExpression, type MixedActionExpression } from '../game/mixed-action-question';
|
||||
import {
|
||||
needsMovementSectorProgress, parseTravelActionClaim, recordTravelMovement, travelBranches,
|
||||
travelPromiseValue, TravelPromiseError, validateTravelPromises,
|
||||
type TravelActionClaim, type TravelPromise,
|
||||
} from '../game/travel-expression-promises';
|
||||
|
||||
type Expression = MixedActionExpression<TravelActionClaim>;
|
||||
const move = (minimum: number, sector?: number): Expression => ({ kind: 'action', action: {
|
||||
kind: 'move', territory: 'imperial_basin', minimum, ...(sector === undefined ? {} : { sector }),
|
||||
} });
|
||||
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
||||
function promise(mixed: Expression, answer = true): TravelPromise {
|
||||
return { turn: 1, mixed, event: 'travel-sector', player: 'p', asker: 'a', answer,
|
||||
compiled: compileMixedActionExpression(mixed, () => 'yes', answer), shipment: { pending: true, event: null },
|
||||
progress: [], ...(needsMovementSectorProgress(mixed) ? { sectorProgress: [] } : {}),
|
||||
movementClosed: false, fulfilled: false, released: false };
|
||||
}
|
||||
void test('movement-sector leaves validate only printed sectors and preserve sectorless bytes', () => {
|
||||
const leaf = { kind: 'move', territory: 'imperial_basin', minimum: 1, sector: 9 };
|
||||
assert.deepEqual(parseTravelActionClaim(leaf), leaf);
|
||||
for (const invalid of [{ ...leaf, sector: 0 }, { ...leaf, sector: 9.5 }, { ...leaf, sector: '9' },
|
||||
{ ...leaf, territory: 'nowhere' }, { ...leaf, extra: true }]) assert.throws(() => parseTravelActionClaim(invalid));
|
||||
const original = promise(move(1)); assert.ok(!Object.hasOwn(original, 'sectorProgress'));
|
||||
const [recorded] = recordTravelMovement([original], 'p', 1, { territory: 'imperial_basin', amount: 1, sector: 9 });
|
||||
assert.deepEqual(recorded.progress, [{ territory: 'imperial_basin', maximum: 1 }]);
|
||||
assert.ok(!Object.hasOwn(recorded, 'sectorProgress'));
|
||||
});
|
||||
void test('exact sector movement uses separate real maxima and never infers an unrecorded sector', () => {
|
||||
const p = promise(move(2, 9));
|
||||
let [next] = recordTravelMovement([p], 'p', 1, { territory: 'imperial_basin', amount: 3 });
|
||||
assert.equal(travelPromiseValue(next), false); assert.deepEqual(next.sectorProgress, []);
|
||||
[next] = recordTravelMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
|
||||
assert.equal(travelPromiseValue(next), false); assert.deepEqual(next.sectorProgress, []);
|
||||
[next] = recordTravelMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 1 });
|
||||
assert.deepEqual(next.progress, [{ territory: 'imperial_basin', maximum: 3 }]);
|
||||
assert.deepEqual(next.sectorProgress, [{ territory: 'imperial_basin', sector: 9, maximum: 1 }]);
|
||||
[next] = recordTravelMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 2 });
|
||||
assert.equal(next.fulfilled, true); assert.equal(travelPromiseValue(next), true);
|
||||
validateTravelPromises([next], ['p', 'a']);
|
||||
});
|
||||
void test('two named sectors may be satisfied by distinct actual movements without summing their groups', () => {
|
||||
const p = promise(group('and', move(3, 9), move(2, 10)));
|
||||
assert.deepEqual([...travelBranches(p)], [[{ kind: 'move', territory: 'imperial_basin', minimum: 3, sector: 9, answer: true },
|
||||
{ kind: 'move', territory: 'imperial_basin', minimum: 2, sector: 10, answer: true }]]);
|
||||
let [next] = recordTravelMovement([p], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 3 });
|
||||
assert.equal(next.fulfilled, false);
|
||||
[next] = recordTravelMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 1 });
|
||||
assert.equal(next.fulfilled, false);
|
||||
[next] = recordTravelMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
|
||||
assert.equal(next.fulfilled, true);
|
||||
assert.deepEqual(next.sectorProgress, [{ territory: 'imperial_basin', sector: 9, maximum: 3 },
|
||||
{ territory: 'imperial_basin', sector: 10, maximum: 2 }]);
|
||||
});
|
||||
void test('whole No rejects an exact matching movement but permits another sector', () => {
|
||||
const p = promise(move(2, 9), false);
|
||||
const [other] = recordTravelMovement([p], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
|
||||
assert.throws(() => recordTravelMovement([other], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 2 }), TravelPromiseError);
|
||||
assert.equal(other.fulfilled, false);
|
||||
});
|
||||
void test('sector metadata is required only for an originally named exact movement and cannot be forged', () => {
|
||||
const p = promise(move(2, 9));
|
||||
assert.throws(() => { const incomplete = structuredClone(p); delete incomplete.sectorProgress; validateTravelPromises([incomplete], ['p', 'a']); });
|
||||
const forged = { ...p, progress: [{ territory: 'imperial_basin', maximum: 1 }],
|
||||
sectorProgress: [{ territory: 'imperial_basin', sector: 10, maximum: 1 }] };
|
||||
assert.throws(() => validateTravelPromises([forged], ['p', 'a']));
|
||||
const folded = promise(group('or', { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 1 } }, move(2, 9)));
|
||||
assert.deepEqual(folded.sectorProgress, []);
|
||||
});
|
||||
void test('unrelated physical moves into recognized dynamic locations do not break a printed sector question', () => {
|
||||
const p = promise(move(2, 9));
|
||||
for (const territory of ['hidden_mobile_stronghold', 'cistern']) {
|
||||
const [next] = recordTravelMovement([p], 'p', 1, { territory, sector: 0, amount: 1 });
|
||||
assert.deepEqual(next, p);
|
||||
const [legacy] = recordTravelMovement([p], 'p', 1, { territory, amount: 1 });
|
||||
assert.deepEqual(legacy, p);
|
||||
assert.throws(() => recordTravelMovement([p], 'p', 1, { territory, sector: 1, amount: 1 }), TravelPromiseError);
|
||||
assert.throws(() => parseTravelActionClaim({ kind: 'move', territory, minimum: 1, sector: 0 }), TravelPromiseError);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { MovementPromises } from '../components/movement-promises';
|
||||
import { MovementExpressionPromiseGuideButton } from '../components/movement-expression-promises';
|
||||
import { TravelPromiseGuideButton } from '../components/travel-promises';
|
||||
import { TurnActionGuideButton } from '../components/turn-action-promises';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { viewGame, type Action, type Game, type GameView } from '../game/engine';
|
||||
import {
|
||||
actMovementSector as act, bindMovementSector as bind,
|
||||
createTruthMovementSectorFixture as fixture, movementSectorInventory as inventory,
|
||||
movementSectorPlayer as player, settleMovementSector as settle,
|
||||
type MovementSectorQuestionKind,
|
||||
} from './fixture-truth-movement-sector';
|
||||
|
||||
const kinds: MovementSectorQuestionKind[] = ['movement', 'movementExpression', 'mixedMovement', 'travel', 'turnActions'];
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function row(game: Game, kind: MovementSectorQuestionKind, target: string): {
|
||||
fulfilled?: boolean; released?: boolean; progress?: { territory: string; maximum: number }[];
|
||||
sectorProgress?: { territory: string; sector: number; maximum: number }[];
|
||||
} {
|
||||
const rows = kind === 'movement' ? game.movementPromises : kind === 'movementExpression' || kind === 'mixedMovement'
|
||||
? game.movementExpressionPromises : kind === 'travel' ? game.travelPromises : game.turnActionPromises;
|
||||
const found = rows?.find(item => item.player === target); assert.ok(found); return found;
|
||||
}
|
||||
function answers(view: GameView, kind: MovementSectorQuestionKind) {
|
||||
return kind === 'movement' ? view.truthMovementAnswers : kind === 'movementExpression'
|
||||
? view.truthMovementExpressionAnswers : kind === 'mixedMovement' ? view.truthMixedMovementAnswers
|
||||
: kind === 'travel' ? view.truthTravelAnswers : view.truthTurnActionAnswers;
|
||||
}
|
||||
function guide(view: GameView, kind: MovementSectorQuestionKind): Action | undefined {
|
||||
return kind === 'movement' ? view.movementPromisePolicy?.[0] : kind === 'movementExpression' || kind === 'mixedMovement'
|
||||
? view.movementExpressionPromiseGuide?.action : kind === 'travel'
|
||||
? view.travelPromiseGuide?.action : view.turnActionGuide?.action;
|
||||
}
|
||||
function guideMarkup(view: GameView, kind: MovementSectorQuestionKind): string {
|
||||
const noop = () => {};
|
||||
return kind === 'movement' ? renderToStaticMarkup(createElement(MovementPromises, { game: view, busy: false, act: noop }))
|
||||
: kind === 'movementExpression' || kind === 'mixedMovement' ? renderToStaticMarkup(createElement(MovementExpressionPromiseGuideButton, { game: view, busy: false, send: noop }))
|
||||
: kind === 'travel' ? renderToStaticMarkup(createElement(TravelPromiseGuideButton, { game: view, busy: false, act: noop }))
|
||||
: renderToStaticMarkup(createElement(TurnActionGuideButton, { game: view, busy: false, act: noop }));
|
||||
}
|
||||
|
||||
for (const kind of kinds) void test(`${kind}: native Yes binds one exact ground sector and exposes a legal human/bot guide`, () => {
|
||||
const f = fixture(), bound = bind(f, kind, 'yes'); let game = bound.game;
|
||||
assert.ok(answers(viewGame(bound.asked, f.target), kind)?.includes('yes'));
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
rejected(game, f.target, { type: 'endMovement' });
|
||||
const view = viewGame(game, f.target), action = guide(view, kind); assert.ok(action);
|
||||
assert.equal(action.type, 'move'); assert.equal(action.territory, 'imperial_basin'); assert.equal(action.sector, f.sector);
|
||||
assert.match(guideMarkup(view, kind), new RegExp(`sector ${f.sector}`, 'i'));
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot = viewGame(game, f.target);
|
||||
bot.players.find(entry => entry.id === f.target)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(bot)[0], action);
|
||||
}
|
||||
game = settle(act(game, f.target, action));
|
||||
assert.equal(row(game, kind, f.target).fulfilled, true);
|
||||
assert.ok((player(game, f.target).forces[`imperial_basin:${f.sector}`] ?? 0) > 0);
|
||||
assert.equal(f.forceTotal(game), f.originalForces);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
for (const kind of kinds) void test(`${kind}: native No blocks only the named sector`, () => {
|
||||
const f = fixture(); let game = bind(f, kind, 'no').game;
|
||||
rejected(game, f.target, f.move(f.sector));
|
||||
game = settle(act(game, f.target, f.move(f.alternative)));
|
||||
assert.equal(player(game, f.target).forces[`imperial_basin:${f.alternative}`], 1);
|
||||
assert.notEqual(row(game, kind, f.target).fulfilled, true);
|
||||
game = settle(act(game, f.target, { type: 'endMovement' }));
|
||||
assert.equal(row(game, kind, f.target).fulfilled, true);
|
||||
assert.equal(f.forceTotal(game), f.originalForces);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
for (const kind of kinds) void test(`${kind}: a different first sector preserves Yes only while actual Hajr remains available`, () => {
|
||||
const f = fixture(); assert.ok(f.hajr); let game = bind(f, kind, 'yes').game;
|
||||
game = settle(act(game, f.target, f.move(f.alternative)));
|
||||
assert.notEqual(row(game, kind, f.target).fulfilled, true);
|
||||
rejected(game, f.target, { type: 'endMovement' });
|
||||
game = settle(act(game, f.target, { type: 'card', card: f.hajr }));
|
||||
game = settle(act(game, f.target, f.move(f.sector)));
|
||||
assert.equal(row(game, kind, f.target).fulfilled, true);
|
||||
assert.equal(player(game, f.target).forces[`imperial_basin:${f.sector}`], 1);
|
||||
assert.equal(player(game, f.target).forces[`imperial_basin:${f.alternative}`], 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
assert.equal(f.forceTotal(game), f.originalForces);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
for (const kind of ['movementExpression', 'mixedMovement', 'travel', 'turnActions'] as const)
|
||||
void test(`${kind}: two exact-sector conditions use separate native group maxima`, () => {
|
||||
const f = fixture(); assert.ok(f.hajr); let game = bind(f, kind, 'yes', true).game;
|
||||
game = settle(act(game, f.target, f.move(f.sector, 2)));
|
||||
assert.notEqual(row(game, kind, f.target).fulfilled, true);
|
||||
assert.deepEqual(row(game, kind, f.target).progress, [{ territory: 'imperial_basin', maximum: 2 }]);
|
||||
game = settle(act(game, f.target, { type: 'card', card: f.hajr }));
|
||||
game = settle(act(game, f.target, f.move(f.alternative)));
|
||||
assert.equal(row(game, kind, f.target).fulfilled, true);
|
||||
assert.deepEqual(row(game, kind, f.target).progress, [{ territory: 'imperial_basin', maximum: 2 }]);
|
||||
assert.deepEqual(row(game, kind, f.target).sectorProgress, [
|
||||
{ territory: 'imperial_basin', sector: f.sector, maximum: 2 },
|
||||
{ territory: 'imperial_basin', sector: f.alternative, maximum: 1 },
|
||||
]);
|
||||
assert.equal(f.forceTotal(game), f.originalForces);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('invalid movement sector cannot consume the declared physical Truthtrance', () => {
|
||||
const f = fixture(), declared = bind(f, 'movement', 'yes');
|
||||
// Reopen only the actual original declaration returned by the fixture; no state is installed.
|
||||
rejected(declared.declared, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'movement', target: f.target, territory: 'imperial_basin', sector: 0, minimum: 1,
|
||||
} });
|
||||
assert.ok(player(declared.declared, f.asker).hand.some(card => card.id === f.truth));
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { compileMixedActionExpression, type MixedActionExpression } from '../game/mixed-action-question';
|
||||
import { needsMovementSectorProgress } from '../game/travel-expression-promises';
|
||||
import {
|
||||
parseTurnActionLeaf, recordTurnActionMovement, turnActionBranches, turnActionValue, TurnActionError,
|
||||
validateTurnActionPromises, type TurnActionLeaf, type TurnActionPromise,
|
||||
} from '../game/turn-action-promises';
|
||||
|
||||
type Expression = MixedActionExpression<TurnActionLeaf>;
|
||||
const move = (minimum: number, sector?: number): Expression => ({ kind: 'action', action: {
|
||||
kind: 'move', territory: 'imperial_basin', minimum, ...(sector === undefined ? {} : { sector }),
|
||||
} });
|
||||
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
||||
function promise(mixed: Expression, answer = true): TurnActionPromise {
|
||||
return { turn: 1, mixed, event: 'turn-movement-sector', player: 'p', asker: 'a', answer,
|
||||
compiled: compileMixedActionExpression(mixed, () => 'yes', answer), shipment: { pending: true, event: null },
|
||||
progress: [], ...(needsMovementSectorProgress(mixed) ? { sectorProgress: [] } : {}), payments: [],
|
||||
transportClosed: false, paymentClosed: false, fulfilled: false, released: false };
|
||||
}
|
||||
void test('turn-action movement parser accepts exact printed sectors and rejects unprinted choices', () => {
|
||||
const leaf = { kind: 'move', territory: 'imperial_basin', minimum: 1, sector: 9 };
|
||||
assert.deepEqual(parseTurnActionLeaf(leaf), leaf);
|
||||
assert.throws(() => parseTurnActionLeaf({ ...leaf, sector: 0 }));
|
||||
assert.throws(() => parseTurnActionLeaf({ ...leaf, sector: 9.5 }));
|
||||
assert.throws(() => parseTurnActionLeaf({ ...leaf, extra: true }));
|
||||
});
|
||||
void test('whole turn-action distinguishes original sector from general maxima without guessing old events', () => {
|
||||
const p = promise(move(2, 9));
|
||||
let [next] = recordTurnActionMovement([p], 'p', 1, { territory: 'imperial_basin', amount: 3 });
|
||||
assert.equal(turnActionValue(next), false); assert.deepEqual(next.sectorProgress, []);
|
||||
[next] = recordTurnActionMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
|
||||
assert.equal(turnActionValue(next), false); assert.deepEqual(next.sectorProgress, []);
|
||||
[next] = recordTurnActionMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 2 });
|
||||
assert.deepEqual(next.progress, [{ territory: 'imperial_basin', maximum: 3 }]);
|
||||
assert.deepEqual(next.sectorProgress, [{ territory: 'imperial_basin', sector: 9, maximum: 2 }]);
|
||||
assert.equal(next.fulfilled, true); validateTurnActionPromises([next], ['p', 'a']);
|
||||
});
|
||||
void test('distinct positive sector movements stay separate branches and can occur as separate groups', () => {
|
||||
const p = promise(group('and', move(3, 9), move(2, 10)));
|
||||
assert.deepEqual([...turnActionBranches(p)], [[{ kind: 'move', territory: 'imperial_basin', minimum: 3, sector: 9, answer: true },
|
||||
{ kind: 'move', territory: 'imperial_basin', minimum: 2, sector: 10, answer: true }]]);
|
||||
let [next] = recordTurnActionMovement([p], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 3 });
|
||||
assert.equal(next.fulfilled, false);
|
||||
[next] = recordTurnActionMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
|
||||
assert.equal(next.fulfilled, true);
|
||||
assert.deepEqual(next.sectorProgress, [{ territory: 'imperial_basin', sector: 9, maximum: 3 },
|
||||
{ territory: 'imperial_basin', sector: 10, maximum: 2 }]);
|
||||
});
|
||||
void test('broad and exact movement conditions retain separate thresholds and actual destinations', () => {
|
||||
const p = promise(group('and', move(3), move(2, 9)));
|
||||
assert.equal([...turnActionBranches(p)][0].length, 2);
|
||||
let [next] = recordTurnActionMovement([p], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 2 });
|
||||
assert.equal(next.fulfilled, false);
|
||||
[next] = recordTurnActionMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 3 });
|
||||
assert.equal(next.fulfilled, true);
|
||||
});
|
||||
void test('whole No forbids only its actual named sector and sectorless records keep their original shape', () => {
|
||||
const p = promise(move(2, 9), false);
|
||||
const [other] = recordTurnActionMovement([p], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
|
||||
assert.throws(() => recordTurnActionMovement([other], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 2 }), TurnActionError);
|
||||
const generic = promise(move(2));
|
||||
const [accepted] = recordTurnActionMovement([generic], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
|
||||
assert.ok(!Object.hasOwn(accepted, 'sectorProgress'));
|
||||
assert.deepEqual(accepted.progress, [{ territory: 'imperial_basin', maximum: 2 }]);
|
||||
});
|
||||
void test('exact-sector records require their own ledger and cannot invent another sector maximum', () => {
|
||||
const p = promise(move(2, 9));
|
||||
assert.throws(() => { const missing = structuredClone(p); delete missing.sectorProgress; validateTurnActionPromises([missing], ['p', 'a']); });
|
||||
const forged = { ...p, progress: [{ territory: 'imperial_basin', maximum: 1 }],
|
||||
sectorProgress: [{ territory: 'imperial_basin', sector: 10, maximum: 1 }] };
|
||||
assert.throws(() => validateTurnActionPromises([forged], ['p', 'a']));
|
||||
});
|
||||
void test('unrelated native dynamic-location movement leaves printed sector conditions intact', () => {
|
||||
const p = promise(move(2, 9));
|
||||
for (const territory of ['hidden_mobile_stronghold', 'cistern']) {
|
||||
const [next] = recordTurnActionMovement([p], 'p', 1, { territory, sector: 0, amount: 1 });
|
||||
assert.deepEqual(next, p);
|
||||
const [legacy] = recordTurnActionMovement([p], 'p', 1, { territory, amount: 1 });
|
||||
assert.deepEqual(legacy, p);
|
||||
assert.throws(() => recordTurnActionMovement([p], 'p', 1, { territory, sector: 1, amount: 1 }), TurnActionError);
|
||||
assert.throws(() => parseTurnActionLeaf({ kind: 'move', territory, minimum: 1, sector: 0 }), TurnActionError);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user