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

+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);
}