Support sector-specific Truthtrance shipments

This commit is contained in:
Quintine committed 2026-10-27 21:19:07 +11:00
1 parent 8006e53580
commit 536d334543
27 files changed
+1121 -71

No files matched your search

+29 -3
View File
@@ -2,9 +2,11 @@
import { TERRITORIES } from '@/game/board';
import { Input } from '@/components/ui/input';
export type ShipmentClauseInput = { territory: string; minimum: number };
export type ShipmentClauseInput = { territory: string; minimum: number; sector?: number };
export function invalidShipmentClause(claim: ShipmentClauseInput) {
return !Number.isSafeInteger(claim.minimum) || claim.minimum < 1 || claim.minimum > 20;
return !Number.isSafeInteger(claim.minimum) || claim.minimum < 1 || claim.minimum > 20 ||
claim.sector !== undefined && (!Number.isSafeInteger(claim.sector) ||
!TERRITORIES.find(destination => destination.id === claim.territory)?.sectors.includes(claim.sector));
}
/** Destination and physical minimum shared by flat and nested question builders. */
@@ -13,12 +15,36 @@ export function ShipmentClauseFields({ id, value, onChange }: {
value: ShipmentClauseInput;
onChange: (claim: ShipmentClauseInput) => void;
}) {
const printed = TERRITORIES.find(destination => destination.id === value.territory);
// Mixed-shipment leaves have their own `kind` and remain on their existing
// territory-only parser until that separate question family is integrated.
const allowSector = !('kind' in value);
return <>
<label htmlFor={`${id}-territory`}>This-turn shipment destination</label>
<select id={`${id}-territory`} value={value.territory}
onChange={event => onChange({ ...value, territory: event.target.value })}>
onChange={event => {
const next = { ...value, territory: event.target.value };
delete next.sector;
onChange(next);
}}>
{TERRITORIES.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
{allowSector && <>
<label htmlFor={`${id}-sector`}>Shipment sector</label>
<select id={`${id}-sector`} 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 === '') {
const next = { ...value }; delete next.sector; onChange(next);
} else {
const sector = Number(selected);
if (printed?.sectors.includes(sector)) onChange({ ...value, sector });
}
}}>
<option value="">Any sector in this territory</option>
{printed?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</option>)}
</select>
</>}
<label htmlFor={`${id}-minimum`}>Minimum physical forces from reserves</label>
<Input id={`${id}-minimum`} type="number" min={1} max={20} step={1} required
value={Number.isNaN(value.minimum) ? '' : value.minimum}
+26 -2
View File
@@ -14,6 +14,7 @@ const config: MixedActionConfig<TravelActionClaim> = {
actionLabel: 'shipment or ground-movement condition',
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.
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.
@@ -28,7 +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' || kind === 'move') onChange({ ...value, kind });
if (kind === 'ship') onChange({ ...value, kind });
else if (kind === 'move') onChange({ kind, territory: value.territory, minimum: value.minimum });
}}>
<option value="ship">Ship from reserves</option>
<option value="move">Make ground movement</option>
@@ -36,10 +38,32 @@ const config: MixedActionConfig<TravelActionClaim> = {
<label htmlFor={`${id}-territory`}>{value.kind === 'ship' ? 'Reserve-shipment destination' : '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 => {
const destination = event.currentTarget.value;
if (value.kind === 'ship') onChange({ kind: 'ship', territory: destination, minimum: value.minimum });
else onChange({ ...value, territory: destination });
}}>
<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' && (() => {
const printed = TERRITORIES.find(destination => destination.id === value.territory);
return <>
<label htmlFor={`${id}-sector`}>Shipment 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 });
else {
const sector = Number(selected);
if (printed?.sectors.includes(sector)) onChange({ ...value, sector });
}
}}>
<option value="">Any sector in this territory</option>
{printed?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</option>)}
</select>
</>;
})()}
<label htmlFor={`${id}-minimum`}>Minimum physical forces in one {value.kind === 'ship' ? 'reserve shipment' : '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}
+27 -2
View File
@@ -117,7 +117,8 @@ const config: MixedActionConfig<TurnActionLeaf> = {
Amal conditions count an actual accepted future printed play in this original phase opening.
Earlier plays, disposal, other roles and recovery alone do not count. The condition closes with this opening.
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.
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.
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.
@@ -429,10 +430,34 @@ const config: MixedActionConfig<TurnActionLeaf> = {
<label htmlFor={`${id}-territory`}>{value.kind === 'ship' ? 'Reserve-shipment destination' : '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 => {
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 });
}}>
<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' && (() => {
const printed = TERRITORIES.find(destination => destination.id === value.territory);
return <>
<label htmlFor={`${id}-sector`}>Shipment 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 });
else {
const sector = Number(chosen);
if (printed?.sectors.includes(sector)) onChange({ ...value, sector });
}
}}>
<option value="">Any sector in this territory</option>
{printed?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</option>)}
</select>
</>;
})()}
</>}
{'minimum' in value && <>
<label htmlFor={`${id}-minimum`}>{value.kind === 'payment' ? 'Minimum future ordinary payments in spice'
+22 -2
View File
@@ -1,6 +1,7 @@
'use client';
import type { Action, GameView, Plan } from '@/game/engine';
import { TERRITORIES } from '@/game/board';
import { bribeAction } from '@/game/bribe-options';
import { alliancePromiseModeSupported } from '@/game/alliance-promises';
import { nativePromiseScopeSupported } from '@/game/native-promise-scope';
@@ -39,6 +40,9 @@ const namesWindow = (mixed: MixedActionExpression<TurnActionLeaf>, effect: Windo
const namesStormDial = (mixed: MixedActionExpression<TurnActionLeaf>, source: string): boolean =>
mixed.kind === 'action' ? mixed.action.kind === 'stormDial' && mixed.action.source === source
: mixed.kind !== 'fact' && mixed.terms.some(term => namesStormDial(term, source));
const namesSectorShipment = (mixed: MixedActionExpression<TurnActionLeaf>): boolean =>
mixed.kind === 'action' ? mixed.action.kind === 'ship' && mixed.action.sector !== undefined
: mixed.kind !== 'fact' && mixed.terms.some(namesSectorShipment);
const namesBoxPlay = (mixed: MixedActionExpression<TurnActionLeaf>): boolean => mixed.kind === 'action'
? mixed.action.kind === 'cardPlay' && mixed.action.effect === 'nullentropyBox'
: mixed.kind !== 'fact' && mixed.terms.some(namesBoxPlay);
@@ -84,6 +88,14 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const box = game.nullentropy, search = box?.search;
const windowPromise = game.turnActionPromises.find(promise => promise.event === event && promise.player === actor && promise.turn === turn);
const windowQuestion = windowPromise && JSON.stringify(windowPromise.mixed);
const sectorShipmentGuide = action.type === 'ship' && !!windowPromise && namesSectorShipment(windowPromise.mixed);
const shipmentSource = (current: GameView) => {
const owner = current.players.find(player => player.id === actor);
return JSON.stringify({ active: current.active, remaining: current.movementRemaining, storm: current.storm,
owner: owner && { reserves: owner.reserves, spice: owner.spice, shipped: owner.shipped },
occupants: current.players.map(player => ({ id: player.id, ally: player.ally, forces: player.forces })) });
};
const shipmentSignature = sectorShipmentGuide ? shipmentSource(game) : null;
const stormDialSource = action.type === 'stormDial' ? nativeStormDialOpportunity(game, actor) : null;
const semutaAction = action.type === 'semutaCommit' || action.type === 'semutaSelect' || action.type === 'semutaPass';
const semuta = semutaAction ? game.semutaReaction : null;
@@ -415,6 +427,13 @@ export function TurnActionGuideButton(props: TurnActionProps) {
!current.ready.includes(actor) && me.charityClaimed === false && current.charity === charity &&
charitySource(current) === charitySignature && current.charity.incomePending === false &&
Number.isSafeInteger(current.charity.amount) && current.charity.amount > 0;
const currentSectorShipment = sectorShipmentGuide && !!me && phase === 5 && current.active === actor &&
!!windowPromise && current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
shipmentSource(current) === shipmentSignature && me.shipped !== true &&
typeof action.territory === 'string' && typeof action.sector === 'number' && Number.isSafeInteger(action.sector) &&
TERRITORIES.some(territory => territory.id === action.territory && territory.sectors.includes(action.sector as number)) &&
typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount > 0 &&
typeof me.reserves === 'number' && action.amount <= me.reserves;
const currentStormDial = action.type === 'stormDial' && !!stormDialSource && !!windowPromise &&
current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
namesStormDial(windowPromise.mixed, stormDialSource.source) &&
@@ -437,7 +456,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
Number.isSafeInteger(phase) && phase >= 0 && phase <= 8 && current.phase === phase && current.turn === turn && current.me === actor &&
current.turnActionGuide === guide && guide.event === event && guide.action === action &&
action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity && movementPacket &&
(!printedBattleCancelAction || printedWindow) &&
(!printedBattleCancelAction || printedWindow) && (!sectorShipmentGuide || currentSectorShipment) &&
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
!promise.fulfilled && !promise.released) &&
(!amalReadyAction || openingReady) &&
@@ -600,7 +619,8 @@ export function TurnActionPromises(props: TurnActionProps) {
Amal conditions count an actual accepted future printed play in this original phase opening.
Earlier plays, disposal, other roles and recovery alone do not count. The condition closes with this opening.
Amal halves available cash before ordinary payments become legal; preserve enough remaining cash for any payment condition.
Shipment conditions concern one future original reserve shipment. Ground-movement conditions count the maximum physical forces in one actual future move,
Shipment conditions concern one future original reserve shipment. An optional exact sector counts only that printed sector of the named territory.
Ground-movement conditions count the maximum physical forces in one actual future move,
never the sum of smaller moves. Earlier shipment and movement do not count.
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; ordinary payments remain possible until the actual turn ends.
+8
View File
@@ -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 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
shipment and payment, including combined answers and private native guidance.
Older sectorless receipts keep their existing meaning. Broader combinations,
complete rules and deployed acceptance remain open.
[Scope](TRUTHTRANCE_TURN_ACTIONS.md#reserve-shipment-sector--27-october-2026).
**Truthtrance upcoming Basic storm dial — 27 October. Prototyped / Partial.**
An exact-value condition binds an eligible player's original unsubmitted native
dial, with the normal first-turn or later range. The same sealed submission
+9
View File
@@ -1,5 +1,14 @@
# Dune implementation status
## 27 October 2026 — Truthtrance reserve shipment sector
An optional exact printed sector is part of the existing future reserve
shipment condition. Combined answers use one native shipment, its physical
forces and actual payment; private guidance and minimal bot actions use the
real sector. Sectorless legacy receipts retain their old shape. Broader
interactions, complete rules and deployed acceptance remain open.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#reserve-shipment-sector--27-october-2026).
## 27 October 2026 — Truthtrance upcoming Basic storm dial
A source-bound current-turn question can bind an eligible Basic player's
+8
View File
@@ -1,5 +1,13 @@
# Rule decision index
## Truthtrance and reserve shipment sector — 27 October 2026
An optional exact sector names the same original reserve-shipment event as
territory-wide conditions. The actual physical group, destination and payment
determine the answer. This is a provisional online application of the FAQ's
current-turn question permission, not a separate publisher timing ruling.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#reserve-shipment-sector--27-october-2026).
## Truthtrance and the upcoming Basic storm dial — 27 October 2026
A question about an eligible player's future native dial binds their exact
+23
View File
@@ -1057,3 +1057,26 @@ information about the named value but do not publish a sealed dial receipt.
Current facts and other supported whole-action alternatives keep their existing
limitations. Advanced later Storm uses its native deck and remains outside this
Basic question prototype; full rule, browser and deployed acceptance are open.
## Reserve shipment sector — 27 October 2026
**Prototyped / Partial.** The publisher FAQ (November 2020, p. 8) permits
current-turn Yes/No questions; this structured exact-sector condition applies
the project's provisional online binding convention. A question may specify a
printed sector of its destination territory and a physical force minimum, or
omit the sector to match the whole territory. Multiple sector and territory
clauses refer to one original future reserve shipment. The native dispatcher
still decides actual availability, storm, strength, cost and funding.
The planner considers the actual sectors when quoting a legal route and shares
funding and the shipment interval with other accepted answers. Actual delivery
records its real destination and sector for sector-aware questions. An older
sectorless receipt is never assigned an inferred sector, and a legacy
territory-only question keeps its old receipt shape. A No forbids the whole
matching condition; Yes and OR retain actual alternative completions. Human
controls and minimal legal AI use the native projected action. A genuinely
opposing stop may release an impossible answer under existing rules.
Controlled regressions use genuine Basic setup, physical cards and actual
storm-safe printed sectors; broader combinations, Advanced, full rules and
deployed acceptance remain open.
+2 -1
View File
@@ -4528,9 +4528,10 @@ export function botActions(g: GameView): Action[] {
(action.type === 'guildShip' && action.from === 'reserves');
const shipment = junctionNative
? {territory: splitLocation(String(action.destination)).territory,
sector: splitLocation(String(action.destination)).sector,
amount: Object.values(action.sources as Record<string, {normal: number; elite: number}>).reduce((n, source) => n + source.normal + source.elite, 0)}
: fromReserves
? { territory: String(action.territory), amount: Number(action.amount) }
? { territory: String(action.territory), sector: Number(action.sector), amount: Number(action.amount) }
: null;
return promises.every(
(promise) => matchesShipment(promise, shipment) === promise.answer,
+57 -27
View File
@@ -19959,7 +19959,7 @@ type TurnActionCardProbe = Game & {
_turnActionUnknownCards?: string[];
_turnActionReservedTruthCards?: string[];
_turnActionPriceCursor?: { order: string[]; index: number };
_turnActionShipmentReceipt?: { player: string; event?: { territory: string; amount: number } | null };
_turnActionShipmentReceipt?: { player: string; event?: { territory: string; amount: number; sector?: number } | null };
_turnActionThumperForecast?: true;
_turnActionFreshDiscard?: { batch: FreshDiscardBatch; continuation: TruthDiscardContinuation };
};
@@ -20492,7 +20492,7 @@ function guardTurnActionAuctionClosure(g: Game, actor: string, original: readonl
* the owner cannot rely on another faction stopping it. Grant costs instead
* quote the resulting native rate before its optional conversion/discard suffix. */
function guardTurnActionShipmentPriceBoundary(g: Game, actor: string, original: readonly TurnActionPromise[],
source?: string, shipment?: { territory: string; amount: number } | null) {
source?: string, shipment?: { territory: string; amount: number; sector?: number } | null) {
const relevant = original.filter(row => {
const stage = row.windowCards?.karamaShipment;
const identity = stage && parseKaramaShipmentSource(stage.source);
@@ -20508,7 +20508,7 @@ function guardTurnActionShipmentPriceBoundary(g: Game, actor: string, original:
for (const row of relevant) trial.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(
trial.turnActionPromises ?? [], actor, row.turn, 'karamaShipment', row.windowCards!.karamaShipment!.source));
if (shipment !== undefined) trial.turnActionPromises = turnActionRule(() => recordTurnActionShipment(
trial.turnActionPromises ?? [], actor, g.turn, shipment ? { territory: shipment.territory, amount: shipment.amount } : null));
trial.turnActionPromises ?? [], actor, g.turn, shipment ? { territory: shipment.territory, amount: shipment.amount, ...(shipment.sector === undefined ? {} : { sector: shipment.sector }) } : null));
requireRule(!relevant.some(row => trial.turnActionPromises?.find(next => next.event === row.event)?.released),
'Play your promised printed Karama or complete its alternative before closing this shipment.');
requireRule(!!turnActionCompletion(trial, actor), 'Retain a whole Truthtrance completion after this shipment-price opportunity closes.');
@@ -20516,12 +20516,20 @@ function guardTurnActionShipmentPriceBoundary(g: Game, actor: string, original:
/** Quote successful native delivery before another faction owns a response.
* Use its actual fee, forces and canonical shipment receipt, not a second price
* calculation or an assumption that the Guild will stop the declaration. */
function shipmentTreeNamesSector(node: TurnActionClaim['mixed'] | TravelClaim['mixed']): boolean {
return node.kind === 'action' ? node.action.kind === 'ship' && node.action.sector !== undefined
: node.kind !== 'fact' && node.terms.some(shipmentTreeNamesSector);
}
function guardTurnActionShipmentPriceDelivery(g: Game, shipment: PendingShipment) {
const original = liveTurnActionPromises(g.turnActionPromises ?? [], shipment.player, g.turn);
if (!original.some(row => {
const travel = liveTravelPromises(g.travelPromises ?? [], shipment.player, g.turn);
const price = original.some(row => {
const source = parseKaramaShipmentSource(row.windowCards?.karamaShipment?.source);
return source?.turn === g.turn && source.recipient === shipment.player;
})) return;
});
const sector = original.some(row => shipmentTreeNamesSector(row.mixed)) ||
travel.some(row => shipmentTreeNamesSector(row.mixed));
if (!price && !sector) return;
const trial = structuredClone(g);
delete trial.turnActionPromises;
(trial as TurnActionCardProbe)._turnActionShipmentReceipt = { player: shipment.player };
@@ -20529,7 +20537,16 @@ function guardTurnActionShipmentPriceDelivery(g: Game, shipment: PendingShipment
const receipt = (trial as TurnActionCardProbe)._turnActionShipmentReceipt!.event;
requireRule(receipt !== undefined, 'Retain the actual native shipment delivery before its counter response.');
trial.turnActionPromises = structuredClone(g.turnActionPromises);
guardTurnActionShipmentPriceBoundary(trial, shipment.player, original, undefined, receipt);
if (price) guardTurnActionShipmentPriceBoundary(trial, shipment.player, original, undefined, receipt);
if (sector) {
trial.turnActionPromises = turnActionRule(() => recordTurnActionShipment(trial.turnActionPromises ?? [],
shipment.player, trial.turn, receipt));
const completion = liveTurnActionPromises(trial.turnActionPromises, shipment.player, trial.turn).length
? turnActionCompletion(trial, shipment.player)
: liveTravelPromises(trial.travelPromises ?? [], shipment.player, trial.turn).length
? travelCompletion(trial, shipment.player) : true;
requireRule(!!completion, 'Honor your whole Truthtrance shipment answer before committing this destination and sector.');
}
}
function turnActionTargetAvailable(g: Game, actor: string): boolean {
const context = g.phaseOpening ? { ...g, phaseOpening: null } : g;
@@ -20954,7 +20971,7 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
if (leaf.kind === 'payment') trial.bribePromises!.push({ ...metadata,
recipient: leaf.recipient, minimum: leaf.minimum, progress: leaf.progress });
else if (leaf.kind === 'ship') trial.shipmentPromises!.push({ ...metadata,
territory: leaf.territory, minimum: leaf.minimum });
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 });
else if (leaf.kind === 'bid') (trial.bidPromises ??= []).push({ ...metadata,
@@ -21518,6 +21535,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 } : {}),
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);
@@ -30639,14 +30657,8 @@ function findShipmentCompletion(
if (visited.has(signature)) continue;
visited.add(signature);
for (const destination of destinations) for (let amount = 1; amount <= p.reserves; amount++) {
if (
!promises.every(
(p) =>
matchesShipment(p, { territory: destination, amount }) === p.answer,
)
)
continue;
for (const sector of territory(destination).sectors) {
if (!promises.every(p => matchesShipment(p, { territory: destination, amount, sector }) === p.answer)) continue;
const offer = nexusRicheseOffer(g,p.id), guildOffer = nexusGuildSecretAllyOffer(g,p.id);
for (const nexus of [undefined, ...(offer && !offer.blocked && amount <= offer.maxForces ? [offer.event] : []), ...(guildOffer && !guildOffer.blocked ? [guildOffer.event] : [])]) {
const guildSecretEvent = nexus && nexus === guildOffer?.event ? nexus : undefined;
@@ -30838,7 +30850,7 @@ function bindShipmentTruth(
function checkShipmentPromises(
g: Game,
p: Player,
shipment: { territory: string; amount: number } | null,
shipment: { territory: string; amount: number; sector?: number } | null,
) {
requireRule(
liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).every(
@@ -30852,16 +30864,19 @@ function checkShipmentPromises(
function finishShipmentPromises(
g: Game,
p: Player,
shipment: { territory: string; amount: number } | null,
shipment: { territory: string; amount: number; sector?: number } | null,
) {
checkShipmentPromises(g, p, shipment);
const probe = (g as TurnActionCardProbe)._turnActionShipmentReceipt;
if (probe?.player === p.id && probe.event === undefined)
probe.event = shipment ? { territory: shipment.territory, amount: shipment.amount } : null;
probe.event = shipment ? { territory: shipment.territory, amount: shipment.amount,
...(shipment.sector === undefined ? {} : { sector: shipment.sector }) } : null;
if (g.phase === 5 && g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
recordTurnActionShipment(g.turnActionPromises!, p.id, g.turn, shipment ? { territory: shipment.territory, amount: shipment.amount } : null));
recordTurnActionShipment(g.turnActionPromises!, p.id, g.turn, shipment ? { territory: shipment.territory, amount: shipment.amount,
...(shipment.sector === undefined ? {} : { sector: shipment.sector }) } : null));
if (g.phase === 5 && g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn,
shipment ? { territory: shipment.territory, amount: shipment.amount } : null));
shipment ? { territory: shipment.territory, amount: shipment.amount,
...(shipment.sector === undefined ? {} : { sector: shipment.sector }) } : null));
for (const promise of liveShipmentPromises(
g.shipmentPromises ?? [],
p.id,
@@ -36339,7 +36354,7 @@ function quoteJunctionDeclaration(g: Game, p: Player, declaration: JunctionTrans
const arrival = quote.destinationKind === 'arrakis' ? splitLocation(destination) : null;
const nativeDeparture = quote.originKind === 'homeworld' && quote.sources.every((source) =>
source.key === `homeworld:${p.faction}` || (p.faction === 'emperor' && source.key === 'homeworld:emperor:salusa'));
const promise = nativeDeparture && arrival ? {territory: arrival.territory, amount: quote.amount} : null;
const promise = nativeDeparture && arrival ? {territory: arrival.territory, amount: quote.amount, sector: arrival.sector} : null;
checkShipmentPromises(g, p, promise);
const sourceLock = quote.originKind === 'arrakis' ? p.advisors?.[quote.origin]?.lockedTurn : undefined;
const advisors = arrival ? arrivalAsAdvisor(g, p, arrival.territory,
@@ -37253,6 +37268,21 @@ function guildTransportCanceled(g: Game, player: string): boolean {
}
function openGuildTransportPermission(g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean) {
validateGuildTransportForCommit(g, p, frame, fromReserves);
const whole = liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn);
const travel = liveTravelPromises(g.travelPromises ?? [], p.id, g.turn);
if (fromReserves && (whole.some(row => shipmentTreeNamesSector(row.mixed)) ||
travel.some(row => shipmentTreeNamesSector(row.mixed)))) {
// Quote the exact reserve group, sector, funding and native rate before an
// opponent owns the permission response. No foreign decision is assumed.
const trial = structuredClone(g), owner = getPlayer(trial, p.id);
commitGuildTransport(trial, owner, structuredClone(frame), true, undefined, undefined, true);
requireRule(owner.shipped, 'Retain the original Guild reserve shipment before its response.');
const completion = liveTurnActionPromises(trial.turnActionPromises ?? [], p.id, trial.turn).length
? turnActionCompletion(trial, p.id)
: liveTravelPromises(trial.travelPromises ?? [], p.id, trial.turn).length
? travelCompletion(trial, p.id) : true;
requireRule(!!completion, 'Honor your whole Truthtrance shipment answer before declaring this Guild destination and sector.');
}
const intent: PendingGuildTransport = {
...frame, fromReserves, event: crypto.randomUUID(), signature: '', permission: true,
};
@@ -37337,7 +37367,7 @@ function validateGuildTransportForCommit(
'The declared Guild transport price no longer matches its rate.');
contribution(g, p, cost, allyPayment);
checkShipmentIncomeFunding(g, p, cost, allyPayment);
checkShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null);
checkShipmentPromises(g, p, fromReserves ? { territory: to, amount, sector } : null);
}
function commitGuildTransport(
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean,
@@ -37400,7 +37430,7 @@ function commitGuildTransport(
prepareAutomaticAdvisorFlips(g, p, { player: p.id, origin, to, sector, amount, elite,
group: Object.fromEntries(group) }, true);
observeOccupation(g);
finishShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null);
finishShipmentPromises(g, p, fromReserves ? { territory: to, amount, sector } : null);
finishGuildCunningShipment(g, p.id, 'guild', frame);
p.shipped = true;
closeAdvisorPreparationOnShipment(g, p.id, 'guildTransport');
@@ -37497,9 +37527,9 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
g,
getPlayer(g, shipment.player),
shipment.noField ? (shipment.richesePair
? { territory: shipment.territory, amount: currentRichesePairForces(g, getPlayer(g, shipment.player), shipment.noFieldReserves ?? 0, shipment.richesePair.revealedTokenId).physical }
? { territory: shipment.territory, sector: shipment.sector, amount: currentRichesePairForces(g, getPlayer(g, shipment.player), shipment.noFieldReserves ?? 0, shipment.richesePair.revealedTokenId).physical }
: shipment.noFieldReserves || shipment.smugglerCompanion
? { territory: shipment.territory, amount: (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0) }
? { territory: shipment.territory, sector: shipment.sector, amount: (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0) }
: null) : shipment.alliedNoField ? null : shipment,
);
const {
@@ -43493,7 +43523,7 @@ function applyActionInner(
requireRule(action.noFieldReserves === undefined || action.noField !== undefined,
'Paid No-Field reserves accompany a native concealed marker.');
if (action.noField === undefined) checkShipmentPromises(g, p, {
territory: String(action.territory), amount: Number(action.amount),
territory: String(action.territory), amount: Number(action.amount), sector: Number(action.sector),
});
const to = String(action.territory),
s = integer(action.sector, 0, 18, 'Sector');
@@ -43597,7 +43627,7 @@ function applyActionInner(
...(noField ? { noField } : {}),
};
if (noField) checkShipmentPromises(g, p, noFieldGroup!.physical
? { territory: to, amount: noFieldGroup!.physical } : null);
? { territory: to, amount: noFieldGroup!.physical, sector: s } : null);
if (noField && (g.leaderSkills || noFieldReserves) && !richesePair) {
shipment.noFieldSkillProof = noFieldSkillProof(shipment);
validatePhysicalShipment(g, shipment);
@@ -43653,7 +43683,7 @@ function applyActionInner(
g,
p,
action.from === 'reserves'
? { territory: String(action.territory), amount: Number(action.amount) }
? { territory: String(action.territory), amount: Number(action.amount), sector: Number(action.sector) }
: null,
);
requireRule(
+1 -1
View File
@@ -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. 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. Each ground-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.',
+4 -1
View File
@@ -24,7 +24,10 @@ export function shipmentExpressionInputError(value: ShipmentExpression): string
...('terms' in node && Array.isArray(node.terms) ? { terms: node.terms.map(structure) } : {}),
};
if ('constant' in node) return node;
return { ...node, territory: 'arrakeen', minimum: 1 };
// Keep the real territory for an exact sector so the shared parser checks
// their relationship; territory-only draft editing retains its old rules.
return { ...node, territory: Object.hasOwn(node, 'sector') && 'territory' in node
? node.territory : 'arrakeen', minimum: 1 };
};
try {
parseShipmentExpression(structure(value), { allowConstants: false });
+19 -7
View File
@@ -4,7 +4,7 @@ import type { TruthAnswer } from './truthtrance';
import { nativePromiseScopeSupported } from './native-promise-scope';
/** Every shipment leaf describes the same eventual reserve shipment, or its absence. */
export type ShipmentLeaf = { territory: string; minimum: number };
export type ShipmentLeaf = { territory: string; minimum: number; sector?: number };
export type ShipmentExpression =
| ShipmentLeaf
| { constant: TruthAnswer }
@@ -12,7 +12,7 @@ export type ShipmentExpression =
/** Old flat questions and saved promises remain valid without migration. */
export type ShipmentClaim =
| (ShipmentLeaf & { claim?: never })
| { claim: ShipmentExpression; territory?: never; minimum?: never };
| { claim: ShipmentExpression; territory?: never; minimum?: never; sector?: never };
export const SHIPMENT_EXPRESSION_MAX_DEPTH = 4;
export const SHIPMENT_EXPRESSION_MAX_LEAVES = 16;
export class ShipmentClaimError extends Error {}
@@ -37,6 +37,14 @@ function parseLeaf(value: Record<string, unknown>): ShipmentLeaf {
value.minimum <= 20,
'Choose a minimum of one to twenty physical forces.',
);
if (Object.hasOwn(value, 'sector')) {
check(
typeof value.sector === 'number' && Number.isSafeInteger(value.sector) &&
TERRITORIES.find((t) => t.id === value.territory)!.sectors.includes(value.sector),
'Choose a printed sector of the selected destination territory.',
);
return { territory: value.territory, minimum: value.minimum, sector: value.sector };
}
return { territory: value.territory, minimum: value.minimum };
}
/** Bounded software input, not an additional printed limit on asking questions. */
@@ -77,7 +85,8 @@ export function parseShipmentExpression(
return { constant: value.constant };
}
check(
Object.keys(value).sort().join(',') === 'minimum,territory' &&
(Object.keys(value).sort().join(',') === 'minimum,territory' ||
Object.keys(value).sort().join(',') === 'minimum,sector,territory') &&
++leaves <= SHIPMENT_EXPRESSION_MAX_LEAVES,
'Use at most sixteen reserve-shipment statements; other fact or action types cannot be mixed in.',
);
@@ -93,7 +102,8 @@ export function parseShipmentClaim(
check(object(value), 'Choose a valid shipment statement.');
if (Object.hasOwn(value, 'claim')) {
check(
!Object.hasOwn(value, 'territory') && !Object.hasOwn(value, 'minimum'),
!Object.hasOwn(value, 'territory') && !Object.hasOwn(value, 'minimum') &&
!Object.hasOwn(value, 'sector'),
'Choose either a legacy shipment statement or an expression, not both.',
);
return { claim: parseShipmentExpression(value.claim, options) };
@@ -120,7 +130,8 @@ export function validShipmentClaim(
}
export function shipmentExpressionOf(claim: ShipmentClaim): ShipmentExpression {
return (
claim.claim ?? { territory: claim.territory!, minimum: claim.minimum! }
claim.claim ?? { territory: claim.territory!, minimum: claim.minimum!,
...(claim.sector === undefined ? {} : { sector: claim.sector }) }
);
}
export function shipmentClaimDestinations(claim: ShipmentClaim): string[] {
@@ -143,7 +154,7 @@ export function shipmentClaimText(
);
return 'op' in expression
? `(${expression.terms.map(text).join(expression.op === 'and' ? ' AND ' : ' OR ')})`
: `ship at least ${expression.minimum} physical forces from your reserves to ${territory(expression.territory).name}`;
: `ship at least ${expression.minimum} physical forces from your reserves to ${territory(expression.territory).name}${expression.sector === undefined ? '' : `, sector ${expression.sector}`}`;
};
return text(shipmentExpressionOf(claim));
}
@@ -212,7 +223,7 @@ export type ShipmentPromise = ShipmentClaim & {
};
export function matchesShipment(
claim: ShipmentClaim,
shipment: { territory: string; amount: number } | null,
shipment: { territory: string; amount: number; sector?: number } | null,
): boolean | null {
const matches = (expression: ShipmentExpression): boolean | null => {
if ('constant' in expression)
@@ -231,6 +242,7 @@ export function matchesShipment(
}
return !!shipment &&
shipment.territory === expression.territory &&
(expression.sector === undefined || shipment.sector === expression.sector) &&
shipment.amount >= expression.minimum;
};
return matches(shipmentExpressionOf(claim));
+37 -18
View File
@@ -12,9 +12,9 @@ export const TRAVEL_MAX_DEPTH = 4;
export const TRAVEL_MAX_LEAVES = 16;
/** Shipment leaves all describe one original future reserve-shipment event. */
export type TravelActionClaim = { kind: 'ship' | 'move'; territory: string; minimum: number };
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 };
export type TravelEvent = { territory: string; amount: number; sector?: number };
export type TravelBranchLeaf = TravelActionClaim & { answer: boolean };
export type TravelPromise = TravelClaim & {
event: string;
@@ -55,15 +55,21 @@ const physical = (value: unknown): value is number =>
const printedTerritory = (value: unknown): value is string =>
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
/** Only original kind/minimum/destination fields cross the question boundary. */
/** An optional printed sector applies only to the original reserve shipment. */
export function parseTravelActionClaim(value: unknown): TravelActionClaim {
check(object(value) && keys(value, 'kind,minimum,territory') &&
check(object(value) && (keys(value, 'kind,minimum,territory') ||
value.kind === 'ship' && keys(value, 'kind,minimum,sector,territory')) &&
(value.kind === 'ship' || value.kind === 'move'),
'Choose one reserve shipment or physical ground movement, with only its minimum and printed destination.');
'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) &&
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.');
try {
const claim = value.kind === 'ship' ? parseShipmentClaim(value) : parseMovementClaim(value);
// parseShipmentClaim receives a flat leaf above, never its expression form.
return { kind: value.kind, territory: claim.territory!, minimum: claim.minimum! };
return { kind: value.kind, territory: claim.territory!, minimum: claim.minimum!,
...('sector' in value ? { sector: value.sector as number } : {}) };
} catch (error) {
if (error instanceof ShipmentClaimError || error instanceof MovementClaimError)
throw new TravelPromiseError(error.message);
@@ -87,7 +93,7 @@ function storedFact(value: unknown, depth = 0): TruthFact {
}
function actionKey(action: TravelActionClaim): string {
return `${action.kind}:${action.territory}:${action.minimum}`;
return `${action.kind}:${action.territory}:${action.minimum}${action.sector === undefined ? '' : `:sector:${action.sector}`}`;
}
function readCompiled(value: unknown, originals: ReadonlySet<string>): CompiledMixedAction<TravelActionClaim> {
@@ -117,8 +123,11 @@ function readCompiled(value: unknown, originals: ReadonlySet<string>): CompiledM
}
function validateEvent(event: unknown): asserts event is TravelEvent {
check(object(event) && keys(event, 'amount,territory') && printedTerritory(event.territory) && physical(event.amount),
'Retain the printed destination and zero to twenty actual physical forces.');
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.');
}
function readPromise(promise: TravelPromise): CompiledMixedAction<TravelActionClaim> {
@@ -157,7 +166,8 @@ function readPromise(promise: TravelPromise): CompiledMixedAction<TravelActionCl
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;
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;
}
@@ -184,15 +194,18 @@ function mergeBranches(left: TravelBranchLeaf[], right: TravelBranchLeaf[]): Tra
const merged = left.map(leaf => ({ ...leaf }));
for (const leaf of right) {
const same = merged.find(other => other.kind === leaf.kind && other.territory === leaf.territory &&
other.answer === leaf.answer);
other.sector === leaf.sector && other.answer === leaf.answer);
if (same) same.minimum = leaf.answer ? Math.max(same.minimum, leaf.minimum) : Math.min(same.minimum, leaf.minimum);
else merged.push({ ...leaf });
const yes = merged.find(other => other.kind === leaf.kind && other.territory === leaf.territory && other.answer);
const no = merged.find(other => other.kind === leaf.kind && other.territory === leaf.territory && !other.answer);
if (yes && no && yes.minimum >= no.minimum) return null;
// One reserve shipment cannot have two different printed destinations.
// A No rules out a Yes only when every location satisfying the Yes also
// satisfies that No. A territory-wide Yes may use another legal sector.
for (const yes of merged.filter(other => other.kind === leaf.kind && other.territory === leaf.territory && other.answer))
for (const no of merged.filter(other => other.kind === leaf.kind && other.territory === leaf.territory && !other.answer))
if (yes.minimum >= no.minimum && (no.sector === undefined || yes.sector === no.sector)) return null;
// Every positive shipment leaf describes the same one physical shipment.
if (leaf.kind === 'ship' && leaf.answer && merged.some(other =>
other.kind === 'ship' && other.answer && other.territory !== leaf.territory)) return null;
other.kind === 'ship' && other.answer && (other.territory !== leaf.territory ||
other.sector !== undefined && leaf.sector !== undefined && other.sector !== leaf.sector))) return null;
}
return merged;
}
@@ -245,8 +258,14 @@ export function recordTravelShipment<T extends TravelPromise>(promises: readonly
for (const promise of live) {
readPromise(promise);
if (!promise.shipment.pending) continue;
updates.set(promise, settle(promise, { ...promise,
shipment: { pending: false, event: event === null ? null : { ...event } } }));
const namesSector = (node: MixedActionExpression<TravelActionClaim>): boolean => node.kind === 'action'
? node.action.kind === 'ship' && node.action.sector !== undefined
: node.kind !== 'fact' && node.terms.some(namesSector);
// Only a sector-aware question receives the new receipt field. Original
// sectorless records keep their original serialized event shape.
const receipt = event === null ? null : { territory: event.territory, amount: event.amount,
...(event.sector !== undefined && namesSector(promise.mixed) ? { sector: event.sector } : {}) };
updates.set(promise, settle(promise, { ...promise, shipment: { pending: false, event: receipt } }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
+30 -7
View File
@@ -232,6 +232,8 @@ 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)
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]);
}
@@ -263,8 +265,11 @@ function readCompiled(value: unknown, originals: ReadonlySet<string>): CompiledM
}
function validateEvent(event: unknown): asserts event is TravelEvent {
check(object(event) && keys(event, 'amount,territory') && printedTerritory(event.territory) && physical(event.amount),
'Retain the printed destination and zero to twenty actual physical forces.');
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.');
}
function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnActionLeaf> {
@@ -488,7 +493,9 @@ function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolea
if (action.kind === 'payment') return (promise.payments.find(payment =>
payment.recipient === action.recipient)?.amount ?? 0) >= action.minimum;
if (action.kind === 'ship') return promise.shipment.event !== null &&
promise.shipment.event.territory === action.territory && promise.shipment.event.amount >= action.minimum;
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;
}
@@ -559,7 +566,8 @@ function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): bo
if (left.kind === 'forces') return right.kind === 'forces' && left.counter === right.counter && left.progress === right.progress;
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;
return (right.kind === 'ship' || right.kind === 'move') && left.territory === right.territory &&
(left.kind !== 'ship' || left.sector === right.sector);
}
/** Legal values for all signed leaves tied to the same original dial. A caller
@@ -611,8 +619,18 @@ 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' && leaf.answer && merged.some(other =>
other.kind === 'ship' && other.answer && other.territory !== leaf.territory)) return null;
if (leaf.kind === 'ship') {
if (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 &&
required.territory === excluded.territory && required.minimum >= excluded.minimum &&
(excluded.sector === undefined || required.sector !== undefined && required.sector === excluded.sector)) return null;
}
}
return merged;
}
@@ -964,8 +982,13 @@ export function recordTurnActionShipment<T extends TurnActionPromise>(promises:
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
if (!promise.shipment.pending) continue;
const needsSector = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind === 'ship' && node.action.sector !== undefined
: node.kind !== 'fact' && node.terms.some(needsSector);
const recorded = event === null ? null : { territory: event.territory, amount: event.amount,
...(needsSector(promise.mixed) && event.sector !== undefined ? { sector: event.sector } : {}) };
updates.set(promise, settle(promise, { ...promise,
shipment: { pending: false, event: event === null ? null : { ...event } } }));
shipment: { pending: false, event: recorded } }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, newPlayer, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
/** Controlled native Basic state adapted from truth-travel-guild-fremen:
* Fremen and Guild have an already-formed alliance, the Fremen has one
* physical fighter in Arrakeen and 19 in reserves, and Harkonnen owns the two
* original Truthtrances. This deliberately does not claim genuine setup or
* alliance provenance. The deck and force totals are physical and all later
* card, question, answer, transport and ground-movement actions are native. */
export function createSectorGuildGuardFixture() {
const game = createGame('SECTORGUILDGUARDS', newPlayer('a', 'Asker', 'harkonnen'), false);
game.players.push(newPlayer('p', 'Fremen', 'fremen'), newPlayer('g', 'Guild', 'guild'));
Object.assign(game, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'p',
order: ['p', 'a', 'g'], movementRemaining: ['p', 'a', 'g'], deck: baseDeck(), discard: [] });
for (const player of game.players) {
player.hand = []; player.forces = {}; player.reserves = 20; player.spice = 10; player.traitorChoices = [];
}
const fremen = game.players.find(player => player.id === 'p')!, guild = game.players.find(player => player.id === 'g')!;
fremen.forces = { 'arrakeen:10': 1 }; fremen.reserves = 19; fremen.ally = 'g'; guild.ally = 'p';
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
game.deck = game.deck.filter(card => card.effect !== 'truthtrance');
game.players.find(player => player.id === 'a')!.hand = truths;
const inventory = (state: Game) => [...state.deck, ...state.discard, ...state.players.flatMap(player => player.hand)]
.map(card => card.id).sort();
const original = inventory(game);
assert.equal(new Set(original).size, original.length);
const claim = { kind: 'action' as const, action: { kind: 'ship' as const, territory: 'imperial_basin', minimum: 1, sector: 9 } };
const ship = (territory: string, sector: number): Action => ({ type: 'guildShip', from: 'reserves',
territory, sector, amount: 1, elite: 0, allyPayment: 0 });
const movement: Action = { type: 'move', from: 'arrakeen:10', territory: 'oh_gap', sector: 9, amount: 1 };
return { game, target: 'p', asker: 'a', guild: 'g', truths: truths.map(card => card.id),
claim, ship, movement, inventory, original };
}
export type SectorGuildGuardFixture = ReturnType<typeof createSectorGuildGuardFixture>;
export function bindSectorGuildGuard(f: SectorGuildGuardFixture, mixed: unknown = f.claim,
answer: 'yes' | 'no' = 'yes'): Game {
let game = applyAction(f.game, f.asker, { type: 'card', card: f.truths[0] });
while (game.truthtrance?.stage === 'priority') {
const player = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id));
assert.ok(player); game = applyAction(game, player.id, { type: 'truthPass' });
}
assert.equal(game.truthtrance?.stage, 'ask');
game = applyAction(game, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.target, turn: game.turn, mixed: structuredClone(mixed),
} });
return applyAction(game, f.target, { type: 'truthAnswer', answer });
}
+43
View File
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import { type Action, type Game } from '../game/engine';
import {
actTruthTurnAction, createTruthTurnActionFixture, declareTruthTurnAction,
truthTurnActionInventory, truthTurnActionPlayer,
} from './fixture-truth-turn-action';
export const actTruthShipmentSector = actTruthTurnAction;
export const sectorShipment = (territory: string, sector: number, minimum = 1) => ({
kind: 'action' as const, action: { kind: 'ship' as const, territory, minimum, sector },
});
/** Genuine original Harkonnen/Atreides/Emperor Basic setup, including both
* truly dealt original Truthtrances, real auction/Revival and prior movement.
* This adapter selects two printed Imperial Basin sectors outside the actual
* current storm; it changes no card, resource, piece, clock or saved state. */
export function createTruthShipmentSectorFixture() {
const base = createTruthTurnActionFixture();
const game = structuredClone(base.game), territory = 'imperial_basin';
const sectors = [9, 10, 11].filter(sector => sector !== game.storm);
assert.ok(sectors.length >= 2); const [sector, alternative] = sectors;
assert.equal(game.advanced, false); assert.equal(game.turn, 1); assert.equal(game.phase, 5);
assert.equal(game.active, base.target); assert.equal(truthTurnActionPlayer(game, base.target).shipped, false);
assert.ok(truthTurnActionPlayer(game, base.target).spice >= 2);
assert.deepEqual(truthTurnActionInventory(game), base.originalCards);
const mixed = sectorShipment(territory, sector);
const shipment = (where: number): Action => ({ type: 'ship', territory, sector: where,
amount: 1, elite: 0, allyPayment: 0 });
return { base, game, asker: base.asker, target: base.target, observer: base.observer,
truths: base.truths, originalCards: base.originalCards, territory, sector, alternative, mixed, shipment };
}
export type TruthShipmentSectorFixture = ReturnType<typeof createTruthShipmentSectorFixture>;
export function askTruthShipmentSector(game: Game, f: TruthShipmentSectorFixture, mixed: unknown = f.mixed): Game {
return actTruthShipmentSector(game, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.target, turn: game.turn, mixed: structuredClone(mixed),
} } as Action);
}
export function bindTruthShipmentSector(f: TruthShipmentSectorFixture, answer: 'yes' | 'no',
mixed: unknown = f.mixed, source: Game = f.game, copy: 0 | 1 = 0) {
const declared = declareTruthTurnAction(f.base, source, copy).game;
const asked = askTruthShipmentSector(declared, f, mixed);
const game = actTruthShipmentSector(asked, f.target, { type: 'truthAnswer', answer });
return { declared, asked, game };
}
+68
View File
@@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
matchesShipment,
parseShipmentClaim,
parseShipmentExpression,
shipmentClaimDestinations,
shipmentClaimText,
shipmentExpressionOf,
ShipmentClaimError,
validShipmentClaim,
} from '../game/shipment-promises';
void test('sector-specific flat shipment keeps the selected physical destination', () => {
const claim = parseShipmentClaim({ territory: 'wind_pass', minimum: 3, sector: 15 });
assert.deepEqual(claim, { territory: 'wind_pass', minimum: 3, sector: 15 });
assert.deepEqual(shipmentExpressionOf(claim), claim);
assert.equal(shipmentClaimText(claim), 'ship at least 3 physical forces from your reserves to Wind Pass, sector 15');
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 15, amount: 3 }), true);
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 14, amount: 3 }), false);
assert.equal(matchesShipment(claim, { territory: 'wind_pass', amount: 20 }), false);
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 15, amount: 2 }), false);
assert.equal(matchesShipment(claim, { territory: 'false_wall_west', sector: 15, amount: 3 }), false);
assert.equal(matchesShipment(claim, null), false);
});
void test('sector constraints validate against the territory and reject invalid inputs', () => {
for (const sector of [14.5, -1, 0, 19, NaN, Infinity, '15', null, undefined]) {
assert.throws(() => parseShipmentClaim({ territory: 'wind_pass', minimum: 1, sector }), ShipmentClaimError);
assert.equal(validShipmentClaim({ territory: 'wind_pass', minimum: 1, sector }), false);
}
assert.throws(() => parseShipmentExpression({ territory: 'wind_pass', minimum: 1, sector: 14, extra: true }), ShipmentClaimError);
assert.throws(() => parseShipmentClaim({ claim: { territory: 'wind_pass', minimum: 1 }, sector: 14 }), ShipmentClaimError);
assert.equal(validShipmentClaim({ territory: 'wind_pass', minimum: 1, sector: 14 }), true);
});
void test('nested sector alternatives share the actual one shipment and retain three-valued facts', () => {
const claim = parseShipmentClaim({ claim: { op: 'or', terms: [
{ territory: 'wind_pass', minimum: 3, sector: 14 },
{ op: 'and', terms: [
{ territory: 'wind_pass', minimum: 5, sector: 15 },
{ constant: 'unknown' },
] },
] } }, { allowConstants: true });
assert.deepEqual(shipmentClaimDestinations(claim), ['wind_pass']);
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 14, amount: 3 }), true);
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 15, amount: 5 }), null);
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 16, amount: 5 }), false);
assert.equal(matchesShipment(claim, { territory: 'wind_pass', amount: 5 }), false);
assert.throws(() => shipmentClaimText(claim), ShipmentClaimError);
const visible = parseShipmentClaim({ claim: { op: 'and', terms: [
{ territory: 'wind_pass', minimum: 1, sector: 14 },
{ territory: 'wind_pass', minimum: 2 },
] } });
assert.match(shipmentClaimText(visible), /Wind Pass, sector 14 AND .*Wind Pass\)/);
});
void test('legacy territory-only claims preserve their exact JSON and match every sector', () => {
const input = { territory: 'wind_pass', minimum: 2 };
const claim = parseShipmentClaim(input);
assert.deepEqual(claim, input);
assert.equal(JSON.stringify(shipmentExpressionOf(claim)), JSON.stringify(input));
assert.equal(shipmentClaimText(claim), 'ship at least 2 physical forces from your reserves to Wind Pass');
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 15, amount: 2 }), true);
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 14, amount: 2 }), true);
assert.equal(matchesShipment(claim, { territory: 'wind_pass', amount: 2 }), true);
assert.equal(matchesShipment(claim, null), false);
});
@@ -0,0 +1,56 @@
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 { ShipmentClauseFields, invalidShipmentClause, type ShipmentClauseInput } from '../components/shipment-claim-fields';
import { shipmentExpressionInputError } from '../game/shipment-expression-input';
import type { ShipmentExpression } from '../game/shipment-promises';
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
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)];
}
void test('standalone shipment editor defaults to any sector and accepts only printed sectors', () => {
let value: ShipmentClauseInput = { territory: 'wind_pass', minimum: 2 };
const tree = () => ShipmentClauseFields({ id: 'shipment', value, onChange(next) { value = next; } });
const select = (suffix: string, chosen: string) => {
const element = elements(tree()).find(item => item.props.id === `shipment-${suffix}`); assert.ok(element);
(element.props.onChange as (event: { currentTarget: { value: string }; target: { value: string } }) => void)(
{ currentTarget: { value: chosen }, target: { value: chosen } });
};
const html = renderToStaticMarkup(createElement(ShipmentClauseFields, { id: 'shipment', value, onChange() {} }));
assert.match(html, /Any sector in this territory/);
assert.match(html, /Sector 14/);
assert.doesNotMatch(html, /Sector 9/);
select('sector', '15'); assert.deepEqual(value, { territory: 'wind_pass', minimum: 2, sector: 15 });
assert.equal(invalidShipmentClause(value), false);
select('sector', '19'); assert.deepEqual(value, { territory: 'wind_pass', minimum: 2, sector: 15 });
select('sector', ''); assert.deepEqual(value, { territory: 'wind_pass', minimum: 2 });
select('sector', '14'); select('territory', 'arrakeen');
assert.deepEqual(value, { territory: 'arrakeen', minimum: 2 });
});
void test('sector input validation checks the actual territory while preserving incomplete legacy drafts', () => {
const exact: ShipmentExpression = { territory: 'wind_pass', minimum: 2, sector: 15 };
assert.equal(shipmentExpressionInputError(exact), null);
assert.notEqual(shipmentExpressionInputError({ ...exact, sector: 9 }), null);
assert.equal(invalidShipmentClause({ territory: 'wind_pass', minimum: 2, sector: 9 }), true);
assert.equal(invalidShipmentClause({ territory: 'wind_pass', minimum: 0 }), true);
// Structural input checks intentionally allow ordinary fields to be repaired.
assert.equal(shipmentExpressionInputError({ territory: '', minimum: 0 }), null);
assert.equal(shipmentExpressionInputError({ op: 'or', terms: [exact, { territory: 'carthag', minimum: 1 }] }), null);
assert.notEqual(shipmentExpressionInputError({ op: 'and', terms: [exact,
{ territory: 'wind_pass', minimum: 2, sector: 3 }] }), null);
});
void test('mixed-shipment fields remain on their existing territory-only question interface', () => {
const mixed = { kind: 'shipment' as const, territory: 'wind_pass', minimum: 2 };
let result: ShipmentClauseInput = mixed;
const tree = () => ShipmentClauseFields({ id: 'mixed', value: mixed, onChange(next) { result = next; } });
assert.equal(elements(tree()).some(item => item.props.id === 'mixed-sector'), false);
const territory = elements(tree()).find(item => item.props.id === 'mixed-territory'); assert.ok(territory);
(territory.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'arrakeen' } });
assert.deepEqual(result, { kind: 'shipment', territory: 'arrakeen', minimum: 2 });
});
@@ -0,0 +1,64 @@
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 { TravelClaimFields, travelClaimInputError } from '../components/travel-claim-fields';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TravelActionClaim } from '../game/travel-expression-promises';
type Expression = MixedActionExpression<TravelActionClaim>;
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
const ship = (sector?: number): Expression => ({ kind: 'action', action: {
kind: 'ship', territory: 'imperial_basin', minimum: 1, ...(sector === undefined ? {} : { sector }),
} });
/** Controlled public projection exercises the editor, not the server's phase eligibility. */
function projection(): GameView {
const game = createGame('TRAVELSECTOR', 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';
return view;
}
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)];
}
void test('travel shipment editor chooses a printed sector or any sector and resets it on territory changes', () => {
const game = projection(); let value: Expression = ship();
const tree = () => TravelClaimFields({ game, value, target: 'p', disabled: false, onChange(next) { value = next; } });
const select = (suffix: string, chosen: string) => {
const element = elements(tree()).find(item => item.props.id === `truth-travel-1-${suffix}`); assert.ok(element);
(element.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: chosen } });
};
assert.equal(travelClaimInputError(value, game, 'p'), null);
const html = renderToStaticMarkup(createElement(TravelClaimFields, { game, value, target: 'p', disabled: false, onChange() {} }));
assert.match(html, /Any sector in this territory/);
assert.match(html, /Sector 9/);
select('sector', '9'); assert.deepEqual(value, ship(9));
assert.equal(travelClaimInputError(value, game, 'p'), null);
select('sector', '18'); assert.deepEqual(value, ship(9));
assert.notEqual(travelClaimInputError(ship(18), game, 'p'), null);
select('sector', ''); assert.deepEqual(value, ship());
select('sector', '10'); select('territory', 'arrakeen');
assert.deepEqual(value, { kind: 'action', action: { kind: 'ship', territory: 'arrakeen', minimum: 1 } });
});
void test('switching to movement removes the shipment-only sector and disabled fields cannot change the question', () => {
const game = projection(); let value: Expression = ship(9);
const tree = (disabled = false) => TravelClaimFields({ game, value, target: 'p', disabled, onChange(next) { value = next; } });
const action = () => {
const element = elements(tree()).find(item => item.props.id === 'truth-travel-1-action'); assert.ok(element);
return element;
};
(action().props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: 'move' } });
assert.deepEqual(value, { kind: 'action', action: { kind: 'move', territory: 'imperial_basin', minimum: 1 } });
assert.equal(travelClaimInputError(value, game, 'p'), null);
assert.equal(elements(tree()).some(item => item.props.id === 'truth-travel-1-sector'), false);
(action().props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: 'ship' } });
assert.deepEqual(value, ship());
const disabledSector = elements(tree(true)).find(item => item.props.id === 'truth-travel-1-sector'); assert.ok(disabledSector);
(disabledSector.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: '9' } });
assert.deepEqual(value, ship());
});
+91
View File
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { TERRITORIES } from '../game/board';
import { compileMixedActionExpression, type MixedActionExpression } from '../game/mixed-action-question';
import {
parseTravelActionClaim, recordTravelShipment, travelBranches, travelPromiseValue, validateTravelPromises,
type TravelActionClaim, type TravelPromise,
} from '../game/travel-expression-promises';
const destination = TERRITORIES.find(territory => territory.sectors.length >= 2)!;
assert.ok(destination);
const [first, second] = destination.sectors;
const ship = (minimum: number, sector?: number): MixedActionExpression<TravelActionClaim> => ({
kind: 'action', action: { kind: 'ship', territory: destination.id, minimum,
...(sector === undefined ? {} : { sector }) },
});
const group = (kind: 'and' | 'or', ...terms: MixedActionExpression<TravelActionClaim>[]): MixedActionExpression<TravelActionClaim> =>
({ kind, terms });
function promise(mixed: MixedActionExpression<TravelActionClaim>, answer = true): TravelPromise {
return { turn: 1, mixed, event: 'travel', player: 'owner', asker: 'asker', answer,
compiled: compileMixedActionExpression(mixed, () => 'yes', answer),
shipment: { pending: true, event: null }, progress: [], movementClosed: false,
fulfilled: false, released: false };
}
void test('only a physical reserve shipment may name a real printed destination sector', () => {
const input = { kind: 'ship', territory: destination.id, minimum: 2, sector: first };
assert.deepEqual(parseTravelActionClaim(input), input);
assert.deepEqual(parseTravelActionClaim({ kind: 'ship', territory: destination.id, minimum: 2 }),
{ kind: 'ship', territory: destination.id, minimum: 2 });
for (const value of [
{ ...input, kind: 'move' }, { ...input, sector: -1 }, { ...input, sector: 1.2 },
{ ...input, sector: undefined }, { ...input, minimum: 0 }, { ...input, extra: true },
{ ...input, sector: Math.max(...destination.sectors) + 100 },
]) assert.throws(() => parseTravelActionClaim(value));
});
void test('sector claims compare actual destination and counts without inventing a legacy sector', () => {
const row = promise(ship(2, first));
const [correct] = recordTravelShipment([row], 'owner', 1, { territory: destination.id, amount: 2, sector: first });
assert.equal(correct.fulfilled, true);
assert.equal(travelPromiseValue(correct), true);
assert.deepEqual(correct.shipment.event, { territory: destination.id, amount: 2, sector: first });
const [otherSector] = recordTravelShipment([row], 'owner', 1, { territory: destination.id, amount: 2, sector: second });
assert.equal(otherSector.fulfilled, false);
assert.equal(travelPromiseValue(otherSector), false);
const [legacy] = recordTravelShipment([row], 'owner', 1, { territory: destination.id, amount: 2 });
assert.equal(travelPromiseValue(legacy), false);
assert.deepEqual(legacy.shipment.event, { territory: destination.id, amount: 2 });
validateTravelPromises([JSON.parse(JSON.stringify(correct))], ['owner', 'asker']);
});
void test('sectorless original questions retain their original serialized receipt shape', () => {
const row = promise(ship(2));
const [settled] = recordTravelShipment([row], 'owner', 1, { territory: destination.id, amount: 2, sector: first });
assert.equal(settled.fulfilled, true);
assert.deepEqual(settled.shipment.event, { territory: destination.id, amount: 2 });
validateTravelPromises([JSON.parse(JSON.stringify(settled))], ['owner', 'asker']);
});
void test('a whole No blocks the matching sector and permits another legal sector or no shipment', () => {
const row = promise(ship(2, first), false);
assert.throws(() => recordTravelShipment([row], 'owner', 1, { territory: destination.id, amount: 2, sector: first }));
const [different] = recordTravelShipment([row], 'owner', 1, { territory: destination.id, amount: 2, sector: second });
assert.equal(travelPromiseValue(different), false);
const [absent] = recordTravelShipment([row], 'owner', 1, null);
assert.equal(travelPromiseValue(absent), false);
});
void test('AND and No alternatives retain single-shipment sector correlations', () => {
const incompatible = promise(group('and', ship(1, first), ship(1, second)));
assert.deepEqual([...travelBranches(incompatible)], []);
const compatible = promise(group('and', ship(2), ship(3, first)));
const branches = [...travelBranches(compatible)];
assert.equal(branches.length, 1);
assert.equal(branches[0].length, 2);
const alternative = promise(group('and', ship(2), ship(3, first)), false);
assert.equal([...travelBranches(alternative)].length, 2);
const mixed = promise(group('and', ship(3, first), ship(2)), false);
const [matching] = recordTravelShipment([mixed], 'owner', 1, { territory: destination.id, amount: 3, sector: second });
assert.equal(travelPromiseValue(matching), false);
});
void test('stored sector receipts reject mismatched or invented printed sectors', () => {
const row = promise(ship(2, first));
for (const event of [
{ territory: destination.id, amount: 1, sector: -1 },
{ territory: destination.id, amount: 1, sector: Math.max(...destination.sectors) + 100 },
{ territory: destination.id, amount: 1, sector: first, extra: true },
]) assert.throws(() => recordTravelShipment([row], 'owner', 1, event));
});
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { bindSectorGuildGuard as bind, createSectorGuildGuardFixture as fixture } from './fixture-truth-shipment-sector-guards';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function promise(game: Game, player: string) {
const row = game.turnActionPromises?.find(item => item.player === player); assert.ok(row); return row;
}
void test('an exact-sector Yes guards actual Fremen allied Guild reserve transport before commitment', () => {
const f = fixture(); let game = bind(f);
rejected(game, f.target, f.ship('imperial_basin', 10));
rejected(game, f.target, f.ship('carthag', 11));
assert.equal(game.players.find(player => player.id === f.target)?.reserves, 19);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'guildShip'); assert.equal(guide.action.from, 'reserves');
assert.equal(guide.action.territory, 'imperial_basin'); assert.equal(guide.action.sector, 9);
const beforeSpice = game.players.find(player => player.id === f.target)!.spice;
game = applyAction(game, f.target, guide.action);
assert.equal(game.players.find(player => player.id === f.target)?.forces['imperial_basin:9'], 1);
assert.equal(game.players.find(player => player.id === f.target)?.reserves, 18);
assert.equal(game.players.find(player => player.id === f.target)?.spice, beforeSpice - 1);
assert.equal(promise(game, f.target).shipment.event?.sector, 9); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(f.inventory(game), f.original);
});
void test('starting movement cannot forfeit an exact-sector Yes shipment', () => {
const f = fixture(); let game = bind(f);
rejected(game, f.target, f.movement);
rejected(game, f.target, f.ship('imperial_basin', 10));
game = applyAction(game, f.target, f.ship('imperial_basin', 9));
assert.equal(promise(game, f.target).shipment.event?.sector, 9); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(f.inventory(game), f.original);
});
void test('a valid movement OR fulfills the answer while forfeiting the unused shipment', () => {
const f = fixture();
const mixed = { kind: 'or', terms: [f.claim, { kind: 'action', action: { kind: 'move', territory: 'oh_gap', minimum: 1 } }] };
let game = bind(f, mixed);
game = applyAction(game, f.target, f.movement);
assert.equal(promise(game, f.target).fulfilled, true);
rejected(game, f.target, f.ship('imperial_basin', 10));
assert.equal(game.players.find(player => player.id === f.target)?.forces['oh_gap:9'], 1);
assert.deepEqual(f.inventory(game), f.original);
});
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TurnActionGuideButton } from '../components/turn-action-promises';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { declareTruthTurnAction, settleTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer } from './fixture-truth-turn-action';
import {
actTruthShipmentSector as act, askTruthShipmentSector as ask, bindTruthShipmentSector as bind,
createTruthShipmentSectorFixture as fixture, sectorShipment,
} from './fixture-truth-shipment-sector';
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, player: string) {
const promise = game.turnActionPromises?.find(item => item.player === player);
assert.ok(promise); return promise;
}
void test('Yes binds an actual original reserve shipment to the exact sector and shares its private native guide', () => {
const f = fixture(), asked = bind(f, 'yes'); let game = asked.game;
assert.ok(viewGame(asked.asked, f.target).truthTurnActionAnswers?.includes('yes'));
assert.deepEqual(row(game, f.target).mixed, f.mixed);
assert.ok(!viewGame(game, f.observer).turnActionPromises.some(item => item.player === f.target));
rejected(game, f.target, f.shipment(f.alternative));
rejected(game, f.target, { type: 'endMovement' });
const view = viewGame(game, f.target), guide = view.turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'ship'); assert.equal(guide.action.territory, f.territory);
assert.equal(guide.action.sector, f.sector); assert.equal(guide.action.amount, 1);
const sent: Action[] = [], props = { game: view, busy: false, act: (next: Action) => sent.push(next) };
const button = TurnActionGuideButton(props); assert.ok(button); assert.equal(button.props.disabled, false);
assert.match(renderToStaticMarkup(createElement(TurnActionGuideButton, props)), /ship|shipment/i);
button.props.onClick(); assert.deepEqual(sent, [guide.action]);
for (const difficulty of DIFFICULTIES) {
const bot = viewGame(game, f.target);
bot.players.find(player => player.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(bot)[0], guide.action);
}
const spice = truthTurnActionPlayer(game, f.target).spice, reserves = truthTurnActionPlayer(game, f.target).reserves;
game = settleTruthTurnAction(act(game, f.target, guide.action));
assert.equal(truthTurnActionPlayer(game, f.target).forces[`${f.territory}:${f.sector}`], 1);
assert.equal(truthTurnActionPlayer(game, f.target).reserves, reserves - 1);
assert.equal(truthTurnActionPlayer(game, f.target).spice, spice - 2);
assert.equal(row(game, f.target).shipment.event?.sector, f.sector);
assert.equal(row(game, f.target).fulfilled, true);
assert.equal(game.discard.filter(card => card.id === f.truths[0]).length, 1);
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
});
void test('No forbids only the named sector and permits another legal sector of the same territory', () => {
const f = fixture(); let game = bind(f, 'no').game;
rejected(game, f.target, f.shipment(f.sector));
game = settleTruthTurnAction(act(game, f.target, f.shipment(f.alternative)));
assert.equal(truthTurnActionPlayer(game, f.target).forces[`${f.territory}:${f.alternative}`], 1);
assert.equal(row(game, f.target).shipment.event?.sector, f.alternative);
assert.equal(row(game, f.target).fulfilled, true);
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
});
void test('invalid printed sector is rejected without consuming the Truthtrance', () => {
const f = fixture(), declared = declareTruthTurnAction(f.base).game;
rejected(declared, f.asker, { type: 'truthAsk', question: { kind: 'turnActions', target: f.target,
turn: declared.turn, mixed: sectorShipment(f.territory, 0) } });
assert.ok(truthTurnActionPlayer(declared, f.asker).hand.some(card => card.id === f.truths[0]));
});
void test('two original Truthtrances cannot require two different sectors in the single shipment', () => {
const f = fixture(); let game = bind(f, 'yes').game;
game = declareTruthTurnAction(f.base, game, 1).game;
game = ask(game, f, sectorShipment(f.territory, f.alternative));
const answers = viewGame(game, f.target).truthTurnActionAnswers;
assert.ok(answers?.includes('no')); assert.ok(!answers?.includes('yes'));
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
rejected(game, f.target, f.shipment(f.alternative));
game = settleTruthTurnAction(act(game, f.target, f.shipment(f.sector)));
assert.equal(game.turnActionPromises?.filter(item => item.player === f.target).length, 2);
assert.ok(game.turnActionPromises?.filter(item => item.player === f.target).every(item => item.fulfilled));
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
});
void test('an OR answer can be satisfied by either exact sector without inventing another shipment', () => {
const f = fixture();
const mixed = { kind: 'or', terms: [f.mixed, sectorShipment(f.territory, f.alternative)] };
let game = bind(f, 'yes', mixed).game;
game = settleTruthTurnAction(act(game, f.target, f.shipment(f.alternative)));
assert.equal(row(game, f.target).fulfilled, true);
assert.equal(row(game, f.target).shipment.event?.sector, f.alternative);
assert.equal(truthTurnActionPlayer(game, f.target).reserves, 9);
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
});
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { ShipmentPromises } from '../components/shipment-promises';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { liveShipmentPromises, matchesShipment } from '../game/shipment-promises';
import { declareTruthTurnAction, settleTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer } from './fixture-truth-turn-action';
import { actTruthShipmentSector as act, createTruthShipmentSectorFixture as fixture,
type TruthShipmentSectorFixture } from './fixture-truth-shipment-sector';
function asked(f: TruthShipmentSectorFixture): Game {
const declared = declareTruthTurnAction(f.base).game;
return act(declared, f.asker, { type: 'truthAsk', question: {
kind: 'shipment', target: f.target, territory: f.territory, minimum: 1, sector: f.sector,
} });
}
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
void test('standalone exact-sector shipment Yes exposes the native witness and all bot profiles honor it', () => {
const f = fixture(), question = asked(f);
assert.ok(viewGame(question, f.target).truthShipmentAnswers?.includes('yes'));
let game = act(question, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.equal(game.shipmentPromises?.[0].sector, f.sector);
const view = viewGame(game, f.target), witness = view.shipmentCompletion?.actions[0]; assert.ok(witness);
assert.equal(witness.type, 'ship'); assert.equal(witness.territory, f.territory); assert.equal(witness.sector, f.sector);
assert.ok(Number(witness.amount) >= 1);
const html = renderToStaticMarkup(createElement(ShipmentPromises, { game: view, busy: false, act() {} }));
assert.match(html, /Suggested next step/); assert.match(html, new RegExp(`sector ${f.sector}`));
assert.match(html, /Cost:/);
for (const difficulty of DIFFICULTIES) {
const bot = viewGame(game, f.target); bot.players.find(player => player.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(bot)[0], witness);
}
rejected(game, f.target, f.shipment(f.alternative));
const beforeSpice = truthTurnActionPlayer(game, f.target).spice, beforeReserves = truthTurnActionPlayer(game, f.target).reserves;
game = settleTruthTurnAction(act(game, f.target, witness));
const count = Number(witness.amount);
assert.equal(truthTurnActionPlayer(game, f.target).forces[`${f.territory}:${f.sector}`], count);
assert.equal(truthTurnActionPlayer(game, f.target).reserves, beforeReserves - count);
assert.equal(truthTurnActionPlayer(game, f.target).spice, beforeSpice - count * 2);
assert.equal(game.shipmentPromises?.[0].fulfilled, true);
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
});
void test('standalone No excludes the named sector, permits another sector and keeps all bot candidates legal', () => {
const f = fixture(), question = asked(f);
assert.ok(viewGame(question, f.target).truthShipmentAnswers?.includes('no'));
let game = act(question, f.target, { type: 'truthAnswer', answer: 'no' });
const promises = liveShipmentPromises(game.shipmentPromises ?? [], f.target, game.turn);
assert.equal(promises.length, 1); assert.equal(promises[0].sector, f.sector);
rejected(game, f.target, f.shipment(f.sector));
for (const difficulty of DIFFICULTIES) {
const bot = viewGame(game, f.target); bot.players.find(player => player.id === f.target)!.bot = difficulty;
for (const action of botActions(bot)) {
if (action.type !== 'ship' && !(action.type === 'guildShip' && action.from === 'reserves')) continue;
const shipment = { territory: String(action.territory), sector: Number(action.sector), amount: Number(action.amount) };
assert.ok(promises.every(promise => matchesShipment(promise, shipment) === promise.answer));
}
}
const beforeSpice = truthTurnActionPlayer(game, f.target).spice;
game = settleTruthTurnAction(act(game, f.target, f.shipment(f.alternative)));
assert.equal(truthTurnActionPlayer(game, f.target).forces[`${f.territory}:${f.alternative}`], 1);
assert.equal(truthTurnActionPlayer(game, f.target).spice, beforeSpice - 2);
assert.equal(game.shipmentPromises?.[0].fulfilled, true);
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
});
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TravelPromiseGuideButton } from '../components/travel-promises';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { declareTruthTurnAction, settleTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer } from './fixture-truth-turn-action';
import {
actTruthShipmentSector as act, createTruthShipmentSectorFixture as fixture,
sectorShipment, type TruthShipmentSectorFixture,
} from './fixture-truth-shipment-sector';
/** Uses the genuine Basic fixture's original setup, dealt Truth and actual
* Shipment and Movement turn; only its real native sectors are selected. */
function bind(f: TruthShipmentSectorFixture, answer: 'yes' | 'no', mixed: unknown = f.mixed) {
const declared = declareTruthTurnAction(f.base).game;
const asked = act(declared, f.asker, { type: 'truthAsk', question: {
kind: 'travel', target: f.target, turn: declared.turn, mixed: structuredClone(mixed),
} } as Action);
assert.ok(viewGame(asked, f.target).truthTravelAnswers?.includes(answer));
const game = act(asked, f.target, { type: 'truthAnswer', answer });
return { asked, game };
}
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, owner: string) {
const result = game.travelPromises?.find(promise => promise.player === owner);
assert.ok(result); return result;
}
void test('standalone travel Yes binds the one native reserve shipment to the exact sector', () => {
const f = fixture(), bound = bind(f, 'yes'); let game = bound.game;
assert.deepEqual(row(game, f.target).mixed, f.mixed);
assert.ok(!viewGame(game, f.observer).travelPromises.some(promise => promise.player === f.target));
rejected(game, f.target, f.shipment(f.alternative));
rejected(game, f.target, { type: 'endMovement' });
const view = viewGame(game, f.target), guide = view.travelPromiseGuide;
assert.ok(guide); assert.equal(guide.action.type, 'ship');
assert.equal(guide.action.territory, f.territory); assert.equal(guide.action.sector, f.sector);
const sent: Action[] = [], props = { game: view, busy: false, act: (next: Action) => sent.push(next) };
const button = TravelPromiseGuideButton(props); assert.ok(button); assert.equal(button.props.disabled, false);
assert.match(renderToStaticMarkup(createElement(TravelPromiseGuideButton, props)), new RegExp(`sector ${f.sector}`, 'i'));
button.props.onClick(); assert.deepEqual(sent, [guide.action]);
for (const difficulty of DIFFICULTIES) {
const bot = viewGame(game, f.target);
bot.players.find(player => player.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(bot)[0], guide.action);
}
const before = truthTurnActionPlayer(game, f.target);
const reserves = before.reserves, spice = before.spice;
game = settleTruthTurnAction(act(game, f.target, guide.action));
assert.equal(row(game, f.target).shipment.event?.sector, f.sector);
assert.equal(row(game, f.target).fulfilled, true);
assert.equal(truthTurnActionPlayer(game, f.target).forces[`${f.territory}:${f.sector}`], 1);
assert.equal(truthTurnActionPlayer(game, f.target).reserves, reserves - 1);
assert.equal(truthTurnActionPlayer(game, f.target).spice, spice - 2);
assert.equal(game.discard.filter(card => card.id === f.truths[0]).length, 1);
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
});
void test('standalone travel No forbids the named sector and permits another real sector', () => {
const f = fixture(); let game = bind(f, 'no').game;
rejected(game, f.target, f.shipment(f.sector));
game = settleTruthTurnAction(act(game, f.target, f.shipment(f.alternative)));
assert.equal(truthTurnActionPlayer(game, f.target).forces[`${f.territory}:${f.alternative}`], 1);
assert.equal(row(game, f.target).shipment.event?.sector, f.alternative);
game = settleTruthTurnAction(act(game, f.target, { type: 'endMovement' }));
assert.equal(row(game, f.target).fulfilled, true);
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
});
void test('standalone travel OR accepts either actual sector through the single shipment', () => {
const f = fixture();
const mixed = { kind: 'or', terms: [f.mixed, sectorShipment(f.territory, f.alternative)] };
const bound = bind(f, 'yes', mixed).game;
for (const sector of [f.sector, f.alternative]) {
const game = settleTruthTurnAction(act(bound, f.target, f.shipment(sector)));
assert.equal(row(game, f.target).fulfilled, true);
assert.equal(row(game, f.target).shipment.event?.sector, sector);
assert.equal(truthTurnActionPlayer(game, f.target).reserves, 9);
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
}
});
@@ -0,0 +1,66 @@
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 { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
import { TurnActionGuideButton } from '../components/turn-action-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TurnActionLeaf } from '../game/turn-action-promises';
type Expression = MixedActionExpression<TurnActionLeaf>;
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
const ship = (sector?: number): Expression => ({ kind: 'action', action: {
kind: 'ship', territory: 'imperial_basin', minimum: 1, ...(sector === undefined ? {} : { sector }),
} });
/** Controlled public projection: control validation, not native setup. */
function projection(): GameView {
const game = createGame('SECTORCONTROLS', 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']; view.storm = 1;
const me = view.players.find(player => player.id === 'p'); assert.ok(me);
me.shipped = false; me.reserves = 10; me.spice = 10;
return view;
}
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)];
}
void test('shipment editor supports any printed sector or a specific sector and clears it on territory change', () => {
const game = projection(); let value: Expression = ship();
const tree = () => TurnActionFields({ game, value, target: 'p', disabled: false, onChange(next) { value = next; } });
const select = (suffix: string, chosen: string) => {
const element = elements(tree()).find(item => item.props.id === `truth-turn-action-1-${suffix}`); assert.ok(element);
(element.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: chosen } });
};
assert.equal(turnActionInputError(value, game, 'p'), null);
assert.match(renderToStaticMarkup(createElement(TurnActionFields, { game, value, target: 'p', disabled: false, onChange() {} })), /Any sector in this territory/);
select('sector', '9'); assert.deepEqual(value, ship(9)); assert.equal(turnActionInputError(value, game, 'p'), null);
assert.notEqual(turnActionInputError(ship(0), game, 'p'), null);
select('sector', ''); assert.deepEqual(value, ship());
select('sector', '10'); select('territory', 'arrakeen');
assert.deepEqual(value, { kind: 'action', action: { kind: 'ship', territory: 'arrakeen', minimum: 1 } });
});
void test('the native exact-sector guide dispatches its quoted shipment and rejects stale sources', () => {
for (const mutate of [
(view: GameView) => { view.storm = 9; },
(view: GameView) => { view.players.find(player => player.id === 'p')!.shipped = true; },
(view: GameView) => { view.players.find(player => player.id === 'p')!.reserves = 0; },
(view: GameView) => { view.players.find(player => player.id === 'p')!.spice = 0; },
(view: GameView) => { view.players.find(player => player.id === 'a')!.forces = { 'imperial_basin:9': 1 }; },
]) {
const game = projection(), event = 'controlled-sector-promise', mixed = ship(9);
game.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 1, mixed, answer: true, fulfilled: false, released: false }];
const action: Action = { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0,
turnActionPromiseEvent: event, turnActionPromisePhase: 5 };
game.turnActionGuide = { event, action };
const sent: Action[] = [], props = { game, busy: false, act: (next: Action) => sent.push(next) };
const button = TurnActionGuideButton(props); assert.ok(button); assert.equal(button.props.disabled, false);
assert.match(renderToStaticMarkup(createElement(TurnActionGuideButton, props)), /Imperial Basin, sector 9/);
button.props.onClick(); assert.deepEqual(sent, [action]);
mutate(game); button.props.onClick(); assert.deepEqual(sent, [action]);
}
});
+84
View File
@@ -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 {
TurnActionError,
parseTurnActionLeaf,
recordTurnActionShipment,
turnActionBranches,
turnActionValue,
validateTurnActionPromises,
type TurnActionLeaf,
type TurnActionPromise,
} from '../game/turn-action-promises';
type Expression = MixedActionExpression<TurnActionLeaf>;
const ship = (sector?: number, minimum = 3, territory = 'wind_pass'): Expression => ({
kind: 'action', action: { kind: 'ship', territory, 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: 'whole:1', player: 'p', asker: 'a', answer,
compiled: compileMixedActionExpression(mixed, () => 'yes', answer),
shipment: { pending: true, event: null }, progress: [], payments: [],
transportClosed: false, paymentClosed: false, fulfilled: false, released: false,
};
}
void test('whole shipment atoms preserve exact printed sectors and reject invalid ones', () => {
assert.deepEqual(parseTurnActionLeaf({ kind: 'ship', territory: 'wind_pass', minimum: 3, sector: 15 }),
{ kind: 'ship', territory: 'wind_pass', minimum: 3, sector: 15 });
for (const input of [
{ kind: 'ship', territory: 'wind_pass', minimum: 3, sector: 4 },
{ kind: 'ship', territory: 'wind_pass', minimum: 3, sector: 15.5 },
{ kind: 'move', territory: 'wind_pass', minimum: 3, sector: 15 },
{ kind: 'ship', territory: 'wind_pass', minimum: 3, sector: null },
]) assert.throws(() => parseTurnActionLeaf(input), TurnActionError);
});
void test('one actual matching sector fulfills the signed Yes or rejects the signed No', () => {
const exact = promise(ship(15));
const [done] = recordTurnActionShipment([exact], 'p', 1, { territory: 'wind_pass', sector: 15, amount: 3 });
assert.deepEqual(done.shipment.event, { territory: 'wind_pass', sector: 15, amount: 3 });
assert.equal(done.fulfilled, true);
assert.equal(turnActionValue(done), true);
validateTurnActionPromises([done], ['p', 'a']);
assert.throws(() => recordTurnActionShipment([promise(ship(15), false)], 'p', 1,
{ territory: 'wind_pass', sector: 15, amount: 3 }), TurnActionError);
const [other] = recordTurnActionShipment([promise(ship(15), false)], 'p', 1,
{ territory: 'wind_pass', sector: 14, amount: 3 });
assert.equal(other.fulfilled, true);
assert.equal(turnActionValue(other), false);
const [unknown] = recordTurnActionShipment([promise(ship(15), false)], 'p', 1,
{ territory: 'wind_pass', amount: 3 });
assert.equal(turnActionValue(unknown), false);
});
void test('sector and territory-wide atom identity remain distinct across AND and OR', () => {
const broad = ship(), exact14 = ship(14), exact15 = ship(15);
const alternatives = promise(group('or', broad, exact14));
validateTurnActionPromises([alternatives], ['p', 'a']);
const branches = [...turnActionBranches(alternatives)];
assert.equal(branches.length, 2);
assert.ok(branches.some(row => row.some(leaf => leaf.kind === 'ship' && leaf.sector === undefined)));
assert.ok(branches.some(row => row.some(leaf => leaf.kind === 'ship' && leaf.sector === 14)));
assert.deepEqual([...turnActionBranches(promise(group('and', exact14, exact15)))], []);
assert.equal([...turnActionBranches(promise(group('and', broad, exact14)))].length, 1);
const [done] = recordTurnActionShipment([alternatives], 'p', 1, { territory: 'wind_pass', sector: 15, amount: 3 });
assert.equal(done.fulfilled, true);
});
void test('mixed exact and broad receipts retain one real event without changing legacy bytes', () => {
const legacy = promise(ship());
const [legacyDone] = recordTurnActionShipment([legacy], 'p', 1, { territory: 'wind_pass', sector: 15, amount: 3 });
assert.deepEqual(legacyDone.shipment.event, { territory: 'wind_pass', amount: 3 });
assert.equal(JSON.stringify(legacyDone.shipment.event), '{"territory":"wind_pass","amount":3}');
validateTurnActionPromises([legacyDone], ['p', 'a']);
const row = promise(group('or', ship(), ship(14)));
const [done] = recordTurnActionShipment([row], 'p', 1, { territory: 'wind_pass', sector: 15, amount: 3 });
assert.deepEqual(done.shipment.event, { territory: 'wind_pass', amount: 3, sector: 15 });
validateTurnActionPromises([done], ['p', 'a']);
assert.throws(() => recordTurnActionShipment([promise(ship(14))], 'p', 1,
{ territory: 'wind_pass', sector: 19, amount: 3 }), TurnActionError);
});