Support sector-specific Truthtrance movement

This commit is contained in:
Quintine committed 2026-10-27 21:27:07 +11:00
1 parent 536d334543
commit 1059cc1682
24 files changed
+1016 -100

No files matched your search

+15 -10
View File
@@ -21,7 +21,7 @@ import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurn
recordTurnActionStormDial, closeTurnActionStormDial, turnActionStormDialChoices,
turnActionPromiseModeSupported, type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation, type TurnActionWindowCards } from './turn-action-promises';
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
finishTravelTurn, validateTravelPromises, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises';
finishTravelTurn, validateTravelPromises, needsMovementSectorProgress, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises';
import { mixedTraitorRequirement, type MixedTraitorClaim } from './mixed-traitor-question';
import type { MixedBattleActionClaim, MixedBattleClaim } from './mixed-battle-question';
import { AllianceExpressionPromiseError, allianceExpressionOutcomes, findAllianceExpressionPlans,
@@ -33,7 +33,7 @@ import type { RevivalActionClaim, MixedRevivalClaim } from './mixed-revival-ques
import type { BribePaymentClaim, MixedBribeClaim } from './mixed-bribe-question';
import { compileMixedActionExpression, MixedActionQuestionError, type CompiledMixedAction } from './mixed-action-question';
import { MovementExpressionPromiseError, liveMovementExpressionPromises, recordMovementExpressionEvent,
validateMovementExpressionPromises, type MovementExpression, type MovementExpressionClaim, type MovementExpressionPromise } from './movement-expression-promises';
validateMovementExpressionPromises, movementExpressionSectors, type MovementExpression, type MovementExpressionClaim, type MovementExpressionPromise } from './movement-expression-promises';
import { BribeExpressionPromiseError, bribeExpressionRecipients, bribeCompletionCandidates,
liveBribeExpressionPromises, recordBribeExpressionPayment, validateBribeExpressionPromises,
type BribeExpression, type BribeExpressionClaim, type BribeExpressionPromise } from './bribe-expression-promises';
@@ -19893,7 +19893,7 @@ function groundMovementProbe(source: Game, actor: string): Game {
return game;
}
function groundMovementEvent(move: MovementOrder) {
return { territory: move.to, amount: move.total - Number(!!move.noField) };
return { territory: move.to, sector: move.sector, amount: move.total - Number(!!move.noField) };
}
function projectGroundMovement(source: Game, move: MovementOrder): Game {
const game = structuredClone(source), player = getPlayer(game, move.player);
@@ -20973,7 +20973,7 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
else if (leaf.kind === 'ship') trial.shipmentPromises!.push({ ...metadata,
territory: leaf.territory, minimum: leaf.minimum, ...(leaf.sector === undefined ? {} : { sector: leaf.sector }) });
else if (leaf.kind === 'move') trial.movementPromises!.push({ ...metadata,
territory: leaf.territory, minimum: leaf.minimum });
territory: leaf.territory, minimum: leaf.minimum, ...(leaf.sector === undefined ? {} : { sector: leaf.sector }) });
else if (leaf.kind === 'bid') (trial.bidPromises ??= []).push({ ...metadata,
lot: leaf.lot, source: leaf.source, minimum: leaf.minimum });
else if (leaf.kind === 'forces') (trial.revivalPromises ??= []).push({ ...metadata,
@@ -21171,7 +21171,8 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
...(preparation ? { preparation } : {}),
...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}),
shipment: { pending: open && (g.phase < 5 || !!g.phaseOpening?.initialize || shipmentAvailable(g, p)), event: null },
progress: [], payments: [], transportClosed: !open, paymentClosed: false,
progress: [], ...(needsMovementSectorProgress(claim.mixed) ? { sectorProgress: [] } : {}),
payments: [], transportClosed: !open, paymentClosed: false,
fulfilled: compiled.kind === 'constant' && compiled.value === answer, released: false };
if (combat?.plan && battle?.plans[p.id]) promise = turnActionRule(() => recordTurnActionPlan([promise], p.id,
g.turn, combat.event, combat.territory, turnActionPlanValues(g, p.id, [claim.mixed])))[0];
@@ -21535,7 +21536,7 @@ function travelCompletion(source: Game, actor: string,
shipmentPromises: [...shipments], movementPromises: [...movements], movementExpressionPromises: [...expressions] };
for (const leaf of leaves) {
const promise = { territory: leaf.territory, minimum: leaf.minimum, turn: source.turn,
...(leaf.kind === 'ship' && leaf.sector !== undefined ? { sector: leaf.sector } : {}),
...(leaf.sector !== undefined ? { sector: leaf.sector } : {}),
player: actor, asker: source.players.find(player => player.id !== actor)!.id, answer: leaf.answer };
if (leaf.kind === 'ship') trial.shipmentPromises.push(promise);
else trial.movementPromises.push(promise);
@@ -21553,7 +21554,8 @@ function travelTruthAnswers(g: Game, p: Player, claim: TravelClaim): TruthAnswer
if (compiled.kind === 'constant' && compiled.value !== answer) continue;
const promise: TravelPromise = { ...structuredClone(claim), event: 'candidate', player: p.id,
asker: g.players.find(player => player.id !== p.id)!.id, answer, compiled,
shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [],
...(needsMovementSectorProgress(claim.mixed) ? { sectorProgress: [] } : {}), movementClosed: false,
fulfilled: compiled.kind === 'constant', released: false };
const answerSource = (g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder)
? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
@@ -21576,7 +21578,8 @@ function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean
reopenMovementPreparation(g, p);
initializeTravelOrder(g);
(g.travelPromises ??= []).push({ ...structuredClone(claim), event: crypto.randomUUID(), player: p.id, asker, answer,
compiled, shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
compiled, shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [],
...(needsMovementSectorProgress(claim.mixed) ? { sectorProgress: [] } : {}), movementClosed: false,
fulfilled: compiled.kind === 'constant', released: false });
appendTravelOrder(g, 'travel', g.travelPromises!.length - 1);
}
@@ -21951,7 +21954,8 @@ function movementExpressionTruthAnswers(g: Game, p: Player, claim: MovementExpre
const answers: TruthAnswer[] = [];
for (const answer of [true, false]) {
const promise: MovementExpressionPromise = { ...structuredClone(claim), event: 'candidate', player: p.id,
asker: g.players.find(other => other.id !== p.id)!.id, answer, progress: [] };
asker: g.players.find(other => other.id !== p.id)!.id, answer, progress: [],
...(movementExpressionSectors(claim.claim).length ? { sectorProgress: [] } : {}) };
const expressions = [...(g.movementExpressionPromises ?? []), promise];
const trial = { ...answerSource, movementExpressionPromises: expressions };
if (phaseBidCompatibleLaterPromises(trial,p) && groundMovementCompletion(trial, p.id) &&
@@ -21965,7 +21969,8 @@ function bindMovementExpressionTruth(g: Game, p: Player, claim: MovementExpressi
'Choose a whole movement answer compatible with owned actions and earlier answers.');
reopenMovementPreparation(g, p);
const promise: MovementExpressionPromise = { ...structuredClone(claim), event: crypto.randomUUID(),
player: p.id, asker, answer, progress: [] };
player: p.id, asker, answer, progress: [],
...(movementExpressionSectors(claim.claim).length ? { sectorProgress: [] } : {}) };
(g.movementExpressionPromises ??= []).push(promise);
(g.movementPromiseSequence ??= []).push(promise.event);
appendTravelOrder(g, 'movementExpression', g.movementExpressionPromises!.length - 1);
+84 -19
View File
@@ -1,18 +1,22 @@
import { TERRITORIES } from './board';
import { TERRITORIES, validLocation } from './board';
import { MovementClaimError, movementClaimText, parseMovementClaim, type MovementEvent } from './movement-promises';
export { movementPromiseModeSupported as movementExpressionPromiseModeSupported } from './movement-promises';
export type MovementExpression = { kind: 'move'; territory: string; minimum: number } |
export type MovementExpression = { kind: 'move'; territory: string; minimum: number; sector?: number } |
{ kind: 'and' | 'or'; terms: MovementExpression[] };
export type MovementExpressionClaim = { turn: number; claim: MovementExpression };
export type MovementExpressionProgressEntry = { territory: string; maximum: number };
export type MovementExpressionProgress = MovementExpressionProgressEntry[];
export type MovementExpressionSectorProgressEntry = { territory: string; sector: number; maximum: number };
export type MovementExpressionSectorProgress = MovementExpressionSectorProgressEntry[];
export type MovementExpressionEvent = MovementEvent;
export type MovementExpressionPromise = MovementExpressionClaim & {
event: string; player: string; asker: string; answer: boolean;
/** Largest actual physical ground move to each named destination after this answer. */
progress: MovementExpressionProgress;
/** Present only for named exact-sector clauses; old territory-only saves omit it. */
sectorProgress?: MovementExpressionSectorProgress;
fulfilled?: boolean; released?: boolean;
};
@@ -56,7 +60,8 @@ export function parseMovementExpression(value: unknown): MovementExpression {
}
check(++leaves <= MOVEMENT_EXPRESSION_MAX_LEAVES,
'Use at most sixteen ground-movement statements.');
check(input.kind === 'move' && keys(input, 'kind,minimum,territory'),
check(input.kind === 'move' && (keys(input, 'kind,minimum,territory') ||
keys(input, 'kind,minimum,sector,territory')),
'Choose one physical ground-movement minimum and printed destination.');
try { return { kind: 'move', ...parseMovementClaim(input) }; }
catch (error) {
@@ -90,6 +95,18 @@ export function movementExpressionTerritories(expression: MovementExpression): s
return [...destinations];
}
/** Exact named locations, useful for bounded native completion and progress. */
export function movementExpressionSectors(expression: MovementExpression): { territory: string; sector: number }[] {
const locations = new Map<string, { territory: string; sector: number }>();
const visit = (node: MovementExpression): void => {
if ('terms' in node) node.terms.forEach(visit);
else if (node.sector !== undefined) locations.set(JSON.stringify([node.territory, node.sector]),
{ territory: node.territory, sector: node.sector });
};
visit(parseMovementExpression(expression));
return [...locations.values()];
}
function validateProgress(value: unknown): asserts value is MovementExpressionProgress {
check(Array.isArray(value), 'Retain the largest future physical ground move by named destination.');
const destinations = new Set<string>();
@@ -101,17 +118,38 @@ function validateProgress(value: unknown): asserts value is MovementExpressionPr
}
}
function evaluate(expression: MovementExpression, progress: readonly MovementExpressionProgressEntry[]): boolean {
if ('terms' in expression) return expression.kind === 'and' ?
expression.terms.every(term => evaluate(term, progress)) : expression.terms.some(term => evaluate(term, progress));
return (progress.find(entry => entry.territory === expression.territory)?.maximum ?? 0) >= expression.minimum;
function validateSectorProgress(value: unknown): asserts value is MovementExpressionSectorProgress {
check(Array.isArray(value), 'Retain exact-sector physical ground-movement maxima.');
const locations = new Set<string>();
for (const entry of value) {
check(object(entry) && keys(entry, 'maximum,sector,territory') && printedTerritory(entry.territory) &&
typeof entry.sector === 'number' && Number.isSafeInteger(entry.sector) &&
TERRITORIES.find(destination => destination.id === entry.territory)!.sectors.includes(entry.sector) &&
count(entry.maximum) && !locations.has(JSON.stringify([entry.territory, entry.sector])),
'Retain unique printed sectors and physical ground-movement maxima from zero to twenty.');
locations.add(JSON.stringify([entry.territory, entry.sector]));
}
}
function evaluate(expression: MovementExpression, progress: readonly MovementExpressionProgressEntry[],
sectorProgress: readonly MovementExpressionSectorProgressEntry[]): boolean {
if ('terms' in expression) return expression.kind === 'and' ?
expression.terms.every(term => evaluate(term, progress, sectorProgress)) :
expression.terms.some(term => evaluate(term, progress, sectorProgress));
const maximum = expression.sector === undefined
? progress.find(entry => entry.territory === expression.territory)?.maximum
: sectorProgress.find(entry => entry.territory === expression.territory && entry.sector === expression.sector)?.maximum;
return (maximum ?? 0) >= expression.minimum;
}
/** Missing sector progress is never inferred from territory-wide maxima. */
export function evaluateMovementExpression(expression: MovementExpression,
progress: readonly MovementExpressionProgressEntry[]): boolean {
progress: readonly MovementExpressionProgressEntry[],
sectorProgress: readonly MovementExpressionSectorProgressEntry[] = []): boolean {
const parsed = parseMovementExpression(expression);
validateProgress(progress);
return evaluate(parsed, progress);
validateSectorProgress(sectorProgress);
return evaluate(parsed, progress, sectorProgress);
}
export function liveMovementExpressionPromises<T extends MovementExpressionPromise>(promises: readonly T[], player: string,
@@ -124,7 +162,16 @@ function readPromise(promise: MovementExpressionPromise): MovementExpression {
check(object(promise), 'The ground-movement expression commitment record is invalid.');
const { claim } = parseMovementExpressionClaim({ turn: promise.turn, claim: promise.claim });
validateProgress(promise.progress);
const destinations = movementExpressionTerritories(claim);
const destinations = movementExpressionTerritories(claim), sectors = movementExpressionSectors(claim);
check(!sectors.length || Array.isArray(promise.sectorProgress),
'Retain the separate actual-sector progress for every exact-sector question.');
if (promise.sectorProgress !== undefined) {
validateSectorProgress(promise.sectorProgress);
check(sectors.length > 0 && promise.sectorProgress.every(entry =>
sectors.some(location => location.territory === entry.territory && location.sector === entry.sector) &&
entry.maximum <= (promise.progress.find(progress => progress.territory === entry.territory)?.maximum ?? 0)),
'Retain only named actual-sector maxima bounded by the territory-wide maxima.');
}
check(typeof promise.answer === 'boolean' && optionalBoolean(promise.fulfilled) && optionalBoolean(promise.released) &&
!(promise.fulfilled && promise.released) && promise.progress.every(entry => destinations.includes(entry.territory)),
'Retain a definite whole movement answer and its own matching future physical move progress.');
@@ -136,22 +183,40 @@ function readPromise(promise: MovementExpressionPromise): MovementExpression {
* event may have amount zero, which cannot advance a positive minimum. */
export function recordMovementExpressionEvent<T extends MovementExpressionPromise>(promises: readonly T[], player: string,
turn: number, event: MovementEvent): T[] {
check(object(event) && keys(event, 'amount,territory') && identifier(event.territory) && count(event.amount),
'Retain the exact destination and zero to twenty actual physical ground-moving forces.');
check(object(event) && (keys(event, 'amount,territory') || keys(event, 'amount,sector,territory')) &&
identifier(event.territory) && count(event.amount) && (!('sector' in event) ||
typeof event.sector === 'number' && Number.isSafeInteger(event.sector) &&
validLocation(event.territory, event.sector)),
'Retain the exact destination, any printed sector and zero to twenty actual physical ground-moving forces.');
const updates = new Map<T, T>();
for (const promise of liveMovementExpressionPromises(promises, player, turn)) {
const claim = readPromise(promise);
if (!movementExpressionTerritories(claim).includes(event.territory)) continue;
const old = promise.progress.find(entry => entry.territory === event.territory)?.maximum ?? 0;
if (event.amount <= old) continue;
const namedSector = event.sector !== undefined && movementExpressionSectors(claim).some(location =>
location.territory === event.territory && location.sector === event.sector);
const oldSector = namedSector ? promise.sectorProgress?.find(entry =>
entry.territory === event.territory && entry.sector === event.sector)?.maximum ?? 0 : 0;
const advanceTerritory = event.amount > old, advanceSector = namedSector && event.amount > oldSector;
if (!advanceTerritory && !advanceSector) continue;
const progress = promise.progress.map(entry => ({ ...entry }));
const entry = progress.find(entry => entry.territory === event.territory);
if (entry) entry.maximum = event.amount;
else progress.push({ territory: event.territory, maximum: event.amount });
const satisfied = evaluate(claim, progress);
if (advanceTerritory) {
const entry = progress.find(entry => entry.territory === event.territory);
if (entry) entry.maximum = event.amount;
else progress.push({ territory: event.territory, maximum: event.amount });
}
const sectorProgress = promise.sectorProgress?.map(entry => ({ ...entry })) ?? [];
if (advanceSector && event.sector !== undefined) {
const entry = sectorProgress.find(item => item.territory === event.territory && item.sector === event.sector);
if (entry) entry.maximum = event.amount;
else sectorProgress.push({ territory: event.territory, sector: event.sector, maximum: event.amount });
}
const satisfied = evaluate(claim, progress, sectorProgress);
check(promise.answer || !satisfied,
'This ground movement conflicts with your whole Truthtrance movement No answer.');
updates.set(promise, { ...promise, progress, ...(promise.answer && satisfied ? { fulfilled: true } : {}) });
updates.set(promise, { ...promise, progress,
...(promise.sectorProgress !== undefined || advanceSector ? { sectorProgress } : {}),
...(promise.answer && satisfied ? { fulfilled: true } : {}) });
}
return promises.map(promise => updates.get(promise) ?? promise);
}
@@ -161,7 +226,7 @@ export function validateMovementExpressionPromises(promises: readonly MovementEx
const events = new Set<string>();
for (const promise of promises) {
const claim = readPromise(promise);
const satisfied = evaluate(claim, promise.progress);
const satisfied = evaluate(claim, promise.progress, promise.sectorProgress ?? []);
check(identifier(promise.event) && !events.has(promise.event) && identifier(promise.player) && identifier(promise.asker) &&
players.includes(promise.player) && players.includes(promise.asker) && promise.player !== promise.asker &&
(promise.answer || !satisfied) && (!promise.answer || promise.released || !!promise.fulfilled === satisfied),
+10 -6
View File
@@ -1,7 +1,7 @@
import type { Action, Game, Player } from './engine';
import { gameTerritories, splitLocation } from './board';
import { liveMovementPromises, movementEventAllowed, movementMatches, settleMovementEvent, type MovementEvent, type MovementPromise } from './movement-promises';
import { evaluateMovementExpression, liveMovementExpressionPromises, movementExpressionTerritories,
import { evaluateMovementExpression, liveMovementExpressionPromises, movementExpressionTerritories, movementExpressionSectors,
MovementExpressionPromiseError, recordMovementExpressionEvent, type MovementExpression,
type MovementExpressionPromise } from './movement-expression-promises';
@@ -69,6 +69,9 @@ export function findMovementCompletion(source: Game, actor: string,
positiveWhole.some(promise => !evaluateMovementExpression(promise.claim,
movementExpressionTerritories(promise.claim).map(territory => ({ territory,
maximum: Math.max(promise.progress.find(entry => entry.territory === territory)?.maximum ?? 0,
Math.min(20, possiblePhysical)) })),
movementExpressionSectors(promise.claim).map(({ territory, sector }) => ({ territory, sector,
maximum: Math.max(promise.sectorProgress?.find(entry => entry.territory === territory && entry.sector === sector)?.maximum ?? 0,
Math.min(20, possiblePhysical)) }))))) return null;
const key = JSON.stringify([player.forces, player.elites, player.advisors, player.moved, player.shipped, player.reserves, player.tanks, player.hand.map(card => card.id),
player.hajrPlays, player.hajrWindow, player.ornithopterExtraMoves, player.spice, game.choamMovement,
@@ -78,8 +81,8 @@ export function findMovementCompletion(source: Game, actor: string,
game.aid, game.karamaShipping, game.guildRateBlocked, game.shipmentPromises,
game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy,
game.choamWorthlessBlocked, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null,
live.map(promise => [promise.territory, promise.minimum, promise.answer]),
liveWhole.map(promise => [promise.claim, promise.answer, promise.progress]), adapter.progressKey?.(game, actor)]);
live.map(promise => [promise.territory, promise.sector, promise.minimum, promise.answer]),
liveWhole.map(promise => [promise.claim, promise.answer, promise.progress, promise.sectorProgress]), adapter.progressKey?.(game, actor)]);
if (failed.has(key)) return null;
const destinations = gameTerritories(game).filter(destination => !destination.id.startsWith('homeworld:'));
const wanted = new Set([...live.filter(promise => promise.answer).map(promise => promise.territory),
@@ -110,12 +113,13 @@ export function findMovementCompletion(source: Game, actor: string,
const amounts = [...(carriesMarker ? [0] : []), ...Array.from({ length: total }, (_, i) => i + 1)];
const helpsWhole = (expression: MovementExpression, amount: number): boolean => 'terms' in expression
? expression.terms.some(term => helpsWhole(term, amount))
: expression.territory === destination.id && amount >= expression.minimum;
const helps = (amount: number) => live.some(p => p.answer && p.territory === destination.id && amount >= p.minimum) ||
: expression.territory === destination.id && (expression.sector === undefined || expression.sector === sector) && amount >= expression.minimum;
const helps = (amount: number) => live.some(p => p.answer && p.territory === destination.id &&
(p.sector === undefined || p.sector === sector) && amount >= p.minimum) ||
positiveWhole.some(promise => helpsWhole(promise.claim, amount));
amounts.sort((a, b) => Number(helps(b)) - Number(helps(a)) || a - b);
for (const amount of amounts) {
const event = { territory: destination.id, amount };
const event = { territory: destination.id, sector, amount };
if (!movementEventAllowed(live, event)) continue;
if (!mayMoveAgain && live.some(promise => promise.answer && !movementMatches(promise, event))) continue;
let projectedWhole: MovementExpressionPromise[];
+10 -3
View File
@@ -3,8 +3,8 @@ import { nativePromiseScopeSupported } from './native-promise-scope';
import type { Game } from './engine';
/** One qualifying ground movement, never the sum of several smaller groups. */
export type MovementClaim = { territory: string; minimum: number };
export type MovementEvent = { territory: string; amount: number };
export type MovementClaim = { territory: string; minimum: number; sector?: number };
export type MovementEvent = { territory: string; amount: number; sector?: number };
export type MovementPromise = MovementClaim & {
/** New answers share accepted order with whole expressions; older saves omit it. */
event?: string;
@@ -31,6 +31,12 @@ export function parseMovementClaim(value: unknown): MovementClaim {
check(typeof claim.minimum === 'number' && Number.isSafeInteger(claim.minimum) &&
claim.minimum >= 1 && claim.minimum <= 20,
'Choose a minimum of one to twenty physical forces.');
if (Object.hasOwn(claim, 'sector')) {
check(typeof claim.sector === 'number' && Number.isSafeInteger(claim.sector) &&
TERRITORIES.find(destination => destination.id === claim.territory)!.sectors.includes(claim.sector),
'Choose a printed sector of the ground-movement destination.');
return { territory: claim.territory, minimum: claim.minimum, sector: claim.sector };
}
return { territory: claim.territory, minimum: claim.minimum };
}
export function validMovementClaim(value: unknown): value is MovementClaim {
@@ -41,10 +47,11 @@ export function validMovementClaim(value: unknown): value is MovementClaim {
}
}
export function movementClaimText(claim: MovementClaim): string {
return `make a ground movement of at least ${claim.minimum} physical forces to ${territory(claim.territory).name}`;
return `make a ground movement of at least ${claim.minimum} physical forces to ${territory(claim.territory).name}${claim.sector === undefined ? '' : `, sector ${claim.sector}`}`;
}
export function movementMatches(claim: MovementClaim, event: MovementEvent | null): boolean {
return event !== null && event.territory === claim.territory &&
(claim.sector === undefined || event.sector === claim.sector) &&
Number.isSafeInteger(event.amount) && event.amount >= claim.minimum && event.amount <= 20;
}
export function liveMovementPromises<T extends MovementPromise>(
+2 -2
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. Choose any sector of the named territory or one exact printed sector; a sector clause matches only that actual destination and physical group. Territory-wide and sector-specific conditions share one shipment, payment and allowance. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
'Shipment clauses describe the same one future original reserve shipment. Choose any sector of the named territory or one exact printed sector; a sector clause matches only that actual destination and physical group. Territory-wide and sector-specific conditions share one shipment, payment and allowance. Ground-movement clauses may likewise name a printed destination sector, but separate moves can satisfy separate clauses. Each movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
'Transport and Hajr close at your actual Movement end. Ghola or a payment alternative may remain after that close, through the full current turn including Mentat. A known Inflation Double opening closes payments earlier without closing Ghola. Complete a required payment before its actual opportunity closes; No payment caps remain binding until then.',
'Review the persistent own history and current source-bound native guide, or choose another legal manual route. Every definite answer uniformly reopens applicable concurrent preparation or final payment readiness. Four minimal AI profiles participate; complete strategy and final rules/deployed acceptance remain unfinished.',
@@ -3742,7 +3742,7 @@ export const RULE_TOPICS: RuleTopic[] = [
developmentStage: 'Prototyped',
summary: 'Bind one future qualifying physical ground movement before a supported Basic or Advanced respondent with declared native families finishes their current-round turn.',
steps: [
'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Basic and Advanced with declared native families support clean phases0–4 before Movement. During Movement ask an active player before or after shipment, or a still-unused waiting respondent. Finish pending decisions first. Optional modules, later rounds and wider owned preparations remain unfinished.',
'Choose Bind ground movement this turn, a printed territory, optionally one of its printed sectors, and a minimum of one to twenty physical forces. Basic and Advanced with declared native families support clean phases0–4 before Movement. During Movement ask an active player before or after shipment, or a still-unused waiting respondent. Finish pending decisions first. Optional modules, later rounds and wider owned preparations remain unfinished.',
'Yes requires one qualifying ground movement from now until that player’s Shipment and Movement turn ends, while possible. Smaller groups do not add together and earlier moves do not count. Shipment, HMS relocation and Ambassador relocation do not fulfill this statement; a concealed No-Field marker itself is not a physical on-board force counter.',
'A nonmatching move leaves Yes pending when an owned later route remains. One matching physical commitment fulfills it before card disposal and later Intrusion, Ambassador or Terror effects. You are promising the move, not survival afterward. No forbids every later matching move, including Hajr and both Ornithopter groups, until the actual turn closes.',
'Ordinary reserve shipment may prepare or reinforce a later ground group using its actual cost, native allocation and pledged payment split. Shipment itself neither fulfills movement Yes nor violates movement No. Joint guidance honors a required shipment before End; an already declared reserve or native Guild transport keeps its exact source, destination and funding while responses finish.',
+67 -16
View File
@@ -1,4 +1,4 @@
import { TERRITORIES } from './board';
import { TERRITORIES, validLocation } from './board';
import {
parseMixedActionExpression,
type CompiledMixedAction,
@@ -15,6 +15,7 @@ export const TRAVEL_MAX_LEAVES = 16;
export type TravelActionClaim = { kind: 'ship' | 'move'; territory: string; minimum: number; sector?: number };
export type TravelClaim = { turn: number; mixed: MixedActionExpression<TravelActionClaim> };
export type TravelEvent = { territory: string; amount: number; sector?: number };
export type MovementSectorProgress = { territory: string; sector: number; maximum: number };
export type TravelBranchLeaf = TravelActionClaim & { answer: boolean };
export type TravelPromise = TravelClaim & {
event: string;
@@ -26,6 +27,8 @@ export type TravelPromise = TravelClaim & {
shipment: { pending: boolean; event: TravelEvent | null };
/** Largest actual physical ground group per destination after this answer. */
progress: { territory: string; maximum: number }[];
/** Present only for original questions naming a specific movement sector. */
sectorProgress?: MovementSectorProgress[];
movementClosed: boolean;
fulfilled: boolean;
released: boolean;
@@ -55,16 +58,16 @@ const physical = (value: unknown): value is number =>
const printedTerritory = (value: unknown): value is string =>
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
/** An optional printed sector applies only to the original reserve shipment. */
/** An optional printed sector names the actual reserve shipment or ground arrival. */
export function parseTravelActionClaim(value: unknown): TravelActionClaim {
check(object(value) && (keys(value, 'kind,minimum,territory') ||
value.kind === 'ship' && keys(value, 'kind,minimum,sector,territory')) &&
keys(value, 'kind,minimum,sector,territory')) &&
(value.kind === 'ship' || value.kind === 'move'),
'Choose one reserve shipment or physical ground movement, with its minimum and printed destination.');
if ('sector' in value) check(value.kind === 'ship' && printedTerritory(value.territory) &&
if ('sector' in value) check(printedTerritory(value.territory) &&
typeof value.sector === 'number' && Number.isSafeInteger(value.sector) &&
TERRITORIES.find(destination => destination.id === value.territory)!.sectors.includes(value.sector),
'Choose an actual printed sector in the shipment destination.');
'Choose an actual printed sector in the destination.');
try {
const claim = value.kind === 'ship' ? parseShipmentClaim(value) : parseMovementClaim(value);
// parseShipmentClaim receives a flat leaf above, never its expression form.
@@ -92,6 +95,16 @@ function storedFact(value: unknown, depth = 0): TruthFact {
return value as unknown as TruthFact;
}
/** New-only ledger initializer. The original expression is inspected, not a
* compiled branch, because frozen facts can fold an originally named sector. */
export function needsMovementSectorProgress(node: unknown): boolean {
if (!object(node)) return false;
if (node.kind === 'action') return object(node.action) && node.action.kind === 'move' &&
Object.hasOwn(node.action, 'sector');
return (node.kind === 'and' || node.kind === 'or') && Array.isArray(node.terms) &&
node.terms.some(needsMovementSectorProgress);
}
function actionKey(action: TravelActionClaim): string {
return `${action.kind}:${action.territory}:${action.minimum}${action.sector === undefined ? '' : `:sector:${action.sector}`}`;
}
@@ -123,11 +136,15 @@ function readCompiled(value: unknown, originals: ReadonlySet<string>): CompiledM
}
function validateEvent(event: unknown): asserts event is TravelEvent {
// Events are native physical arrivals, which may be at the mobile
// stronghold or a revealed Discovery. Question claims remain printed-only;
// the engine independently validates that a dynamic site actually exists.
check(object(event) && (keys(event, 'amount,territory') || keys(event, 'amount,sector,territory')) &&
printedTerritory(event.territory) && physical(event.amount) &&
(!('sector' in event) || typeof event.sector === 'number' && Number.isSafeInteger(event.sector) &&
TERRITORIES.find(destination => destination.id === event.territory)!.sectors.includes(event.sector)),
'Retain the printed destination, any actual printed sector and zero to twenty physical forces.');
typeof event.territory === 'string' && physical(event.amount) &&
('sector' in event ? typeof event.sector === 'number' && Number.isSafeInteger(event.sector) &&
validLocation(event.territory, event.sector) :
printedTerritory(event.territory) || validLocation(event.territory, 0)),
'Retain a structurally valid destination, any actual sector and zero to twenty physical forces.');
}
function readPromise(promise: TravelPromise): CompiledMixedAction<TravelActionClaim> {
@@ -135,10 +152,14 @@ function readPromise(promise: TravelPromise): CompiledMixedAction<TravelActionCl
const mixed = parseMixedActionExpression(promise.mixed, storedFact, parseTravelActionClaim, { requireFact: false });
const originals = new Set<string>();
const destinations = new Set<string>();
const sectorDestinations = new Set<string>();
const visit = (node: MixedActionExpression<TravelActionClaim>): void => {
if (node.kind === 'action') {
originals.add(actionKey(node.action));
if (node.action.kind === 'move') destinations.add(node.action.territory);
if (node.action.kind === 'move') {
destinations.add(node.action.territory);
if (node.action.sector !== undefined) sectorDestinations.add(JSON.stringify([node.action.territory, node.action.sector]));
}
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
visit(mixed);
@@ -161,6 +182,17 @@ function readPromise(promise: TravelPromise): CompiledMixedAction<TravelActionCl
'Retain unique named movement destinations and actual physical group maxima.');
seen.add(entry.territory);
}
check(sectorDestinations.size ? Array.isArray(promise.sectorProgress) : !('sectorProgress' in promise),
'Retain per-sector movement maxima only for original sector-qualified questions.');
const seenSectors = new Set<string>();
for (const entry of promise.sectorProgress ?? []) {
const key = object(entry) ? JSON.stringify([entry.territory, entry.sector]) : '';
check(object(entry) && keys(entry, 'maximum,sector,territory') && sectorDestinations.has(key) &&
physical(entry.maximum) && !seenSectors.has(key) &&
(promise.progress.find(row => row.territory === entry.territory)?.maximum ?? 0) >= entry.maximum,
'Retain unique named physical sector maxima under the actual territory maximum.');
seenSectors.add(key);
}
return compiled;
}
@@ -168,7 +200,10 @@ function actionValue(action: TravelActionClaim, promise: TravelPromise): boolean
if (action.kind === 'ship') return promise.shipment.event !== null &&
promise.shipment.event.territory === action.territory && promise.shipment.event.amount >= action.minimum &&
(action.sector === undefined || promise.shipment.event.sector === action.sector);
return (promise.progress.find(entry => entry.territory === action.territory)?.maximum ?? 0) >= action.minimum;
return ((action.sector === undefined
? promise.progress.find(entry => entry.territory === action.territory)?.maximum
: promise.sectorProgress?.find(entry => entry.territory === action.territory && entry.sector === action.sector)?.maximum
) ?? 0) >= action.minimum;
}
function evaluate(node: CompiledMixedAction<TravelActionClaim>, promise: TravelPromise): boolean {
@@ -287,12 +322,28 @@ export function recordTravelMovement<T extends TravelPromise>(promises: readonly
visit(promise.mixed);
if (!named) continue;
const old = promise.progress.find(entry => entry.territory === event.territory)?.maximum ?? 0;
if (event.amount <= old) continue;
const namesSector = (node: MixedActionExpression<TravelActionClaim>): boolean => node.kind === 'action'
? node.action.kind === 'move' && node.action.territory === event.territory && node.action.sector === event.sector && event.sector !== undefined
: node.kind !== 'fact' && node.terms.some(namesSector);
const sectorNamed = event.sector !== undefined && namesSector(promise.mixed);
const oldSector = sectorNamed ? promise.sectorProgress!.find(entry => entry.territory === event.territory &&
entry.sector === event.sector)?.maximum ?? 0 : 0;
if (event.amount <= old && (!sectorNamed || event.amount <= oldSector)) continue;
const progress = promise.progress.map(entry => ({ ...entry }));
const entry = progress.find(candidate => candidate.territory === event.territory);
if (entry) entry.maximum = event.amount;
else progress.push({ territory: event.territory, maximum: event.amount });
updates.set(promise, settle(promise, { ...promise, progress }));
if (event.amount > old) {
const entry = progress.find(candidate => candidate.territory === event.territory);
if (entry) entry.maximum = event.amount;
else progress.push({ territory: event.territory, maximum: event.amount });
}
let sectorProgress = promise.sectorProgress;
if (sectorNamed && event.amount > oldSector) {
sectorProgress = promise.sectorProgress!.map(entry => ({ ...entry }));
const entry = sectorProgress.find(candidate => candidate.territory === event.territory && candidate.sector === event.sector);
if (entry) entry.maximum = event.amount;
else sectorProgress.push({ territory: event.territory, sector: event.sector!, maximum: event.amount });
}
updates.set(promise, settle(promise, { ...promise, progress,
...(sectorProgress ? { sectorProgress } : {}) }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
+4 -3
View File
@@ -515,7 +515,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
const respondent = g.players.find(player => player.id === v.target)!;
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), action => {
check(action && typeof action === 'object' && !Array.isArray(action) &&
Object.keys(action).sort().join(',') === 'minimum,territory', 'Supply only a printed movement destination and physical minimum.');
['minimum,territory', 'minimum,sector,territory'].includes(Object.keys(action).sort().join(',')),
'Supply a printed movement destination, optional sector and physical minimum.');
return parseMovementClaim(action);
});
return { kind: 'mixedMovement', target: v.target as string, turn: g.turn, mixed };
@@ -529,8 +530,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
return { kind: 'movementExpression', target: v.target as string, ...claim };
}
if (v.kind === 'movement') {
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,territory',
'Supply one ground-movement destination, minimum and respondent.');
check(['kind,minimum,target,territory', 'kind,minimum,sector,target,territory'].includes(Object.keys(v).sort().join(',')),
'Supply one ground-movement destination, optional sector, minimum and respondent.');
check(movementPromiseTargetAvailable(g, v.target as string),
'Ground-movement promises need a supported unused opportunity this round under Basic or Advanced rules with declared native families and pending decisions finished. Optional modules remain unfinished.');
try { return { kind: 'movement', target: v.target as string, ...parseMovementClaim(v) }; }
+63 -21
View File
@@ -1,4 +1,4 @@
import { TERRITORIES } from './board';
import { TERRITORIES, validLocation } from './board';
import { BribePromiseError, parseBribeClaim } from './bribe-promises';
import { BidPromiseError, parseBidClaim } from './bid-promises';
import { AlliancePromiseError, parseAllianceClaim } from './alliance-promises';
@@ -24,6 +24,7 @@ import {
type TravelActionClaim,
type TravelBranchLeaf,
type TravelEvent,
type MovementSectorProgress,
} from './travel-expression-promises';
import type { TruthFact } from './truthtrance';
@@ -95,6 +96,8 @@ export type TurnActionPromise = TurnActionClaim & {
shipment: { pending: boolean; event: TravelEvent | null };
/** Largest physical ground group after this answer, never a sum of groups. */
progress: { territory: string; maximum: number }[];
/** Present only for original questions naming a specific movement sector. */
sectorProgress?: MovementSectorProgress[];
/** Actual completed ordinary payer actions after this answer. */
payments: TurnActionPayment[];
transportClosed: boolean;
@@ -232,7 +235,7 @@ function actionKey(action: TurnActionLeaf): string {
if (action.kind === 'leader') return JSON.stringify([action.kind, action.leader]);
if (action.kind === 'forces') return JSON.stringify([action.kind, action.counter, action.minimum]);
if (action.kind === 'bid') return JSON.stringify([action.kind, action.lot, action.minimum]);
if (action.kind === 'ship' && action.sector !== undefined)
if ((action.kind === 'ship' || action.kind === 'move') && action.sector !== undefined)
return JSON.stringify([action.kind, action.territory, action.minimum, action.sector]);
return JSON.stringify(action.kind === 'payment' ? [action.kind, action.recipient, action.minimum] :
[action.kind, action.territory, action.minimum]);
@@ -265,16 +268,21 @@ function readCompiled(value: unknown, originals: ReadonlySet<string>): CompiledM
}
function validateEvent(event: unknown): asserts event is TravelEvent {
// Events are native physical arrivals, which may be at the mobile
// stronghold or a revealed Discovery. Question claims remain printed-only;
// the engine independently validates that a dynamic site actually exists.
check(object(event) && (keys(event, 'amount,territory') || keys(event, 'amount,sector,territory')) &&
printedTerritory(event.territory) && physical(event.amount) &&
(!('sector' in event) || typeof event.sector === 'number' && Number.isSafeInteger(event.sector) &&
TERRITORIES.find(destination => destination.id === event.territory)!.sectors.includes(event.sector)),
'Retain the printed destination, any actual printed sector and zero to twenty physical forces.');
typeof event.territory === 'string' && physical(event.amount) &&
('sector' in event ? typeof event.sector === 'number' && Number.isSafeInteger(event.sector) &&
validLocation(event.territory, event.sector) :
printedTerritory(event.territory) || validLocation(event.territory, 0)),
'Retain a structurally valid destination, any actual sector and zero to twenty physical forces.');
}
function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnActionLeaf> {
check(object(promise), 'Retain the complete original turn-action commitment and its metadata.');
const fields = 'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,progress,released,shipment,transportClosed,turn'.split(',');
if ('sectorProgress' in promise) fields.push('sectorProgress');
if ('preparation' in promise) fields.push('preparation');
if ('alliance' in promise) fields.push('alliance');
if ('combat' in promise) fields.push('combat');
@@ -295,6 +303,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
}
const originals = new Set<string>();
const destinations = new Set<string>();
const sectorDestinations = new Set<string>();
const recipients = new Set<string>();
const lots = new Set<number>();
let hasRevival = false;
@@ -309,7 +318,10 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
if (node.kind === 'action') {
originals.add(actionKey(node.action));
if (node.action.kind === 'move') destinations.add(node.action.territory);
if (node.action.kind === 'move') {
destinations.add(node.action.territory);
if (node.action.sector !== undefined) sectorDestinations.add(JSON.stringify([node.action.territory, node.action.sector]));
}
if (node.action.kind === 'payment') recipients.add(node.action.recipient);
if (node.action.kind === 'bid') lots.add(node.action.lot);
if (node.action.kind === 'forces' || node.action.kind === 'leader') hasRevival = true;
@@ -464,6 +476,17 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
'Retain unique named movement destinations and actual physical group maxima.');
seenDestinations.add(entry.territory);
}
check(sectorDestinations.size ? Array.isArray(promise.sectorProgress) : !('sectorProgress' in promise),
'Retain per-sector movement maxima only for original sector-qualified questions.');
const seenSectors = new Set<string>();
for (const entry of promise.sectorProgress ?? []) {
const key = object(entry) ? JSON.stringify([entry.territory, entry.sector]) : '';
check(object(entry) && keys(entry, 'maximum,sector,territory') && sectorDestinations.has(key) &&
physical(entry.maximum) && !seenSectors.has(key) &&
(promise.progress.find(row => row.territory === entry.territory)?.maximum ?? 0) >= entry.maximum,
'Retain unique named physical sector maxima under the actual territory maximum.');
seenSectors.add(key);
}
check(Array.isArray(promise.payments), 'Retain this answer’s cumulative ordinary payments.');
const seenRecipients = new Set<string>();
for (const payment of promise.payments) {
@@ -496,7 +519,10 @@ function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolea
promise.shipment.event.territory === action.territory &&
(action.sector === undefined || promise.shipment.event.sector === action.sector) &&
promise.shipment.event.amount >= action.minimum;
return (promise.progress.find(entry => entry.territory === action.territory)?.maximum ?? 0) >= action.minimum;
return ((action.sector === undefined
? promise.progress.find(entry => entry.territory === action.territory)?.maximum
: promise.sectorProgress?.find(entry => entry.territory === action.territory && entry.sector === action.sector)?.maximum
) ?? 0) >= action.minimum;
}
function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnActionPromise): boolean {
@@ -567,7 +593,7 @@ function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): bo
if (left.kind === 'bid') return right.kind === 'bid' && left.lot === right.lot && left.source === right.source;
if (left.kind === 'payment') return right.kind === 'payment' && left.recipient === right.recipient && left.progress === right.progress;
return (right.kind === 'ship' || right.kind === 'move') && left.territory === right.territory &&
(left.kind !== 'ship' || left.sector === right.sector);
left.sector === right.sector;
}
/** Legal values for all signed leaves tied to the same original dial. A caller
@@ -619,15 +645,15 @@ function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf
const no = merged.find(other => sameAction(other, leaf) && !other.answer);
if (yes && no && 'minimum' in yes && 'minimum' in no &&
yes.minimum >= no.minimum) return null;
if (leaf.kind === 'ship') {
if (leaf.answer && merged.some(other => other.kind === 'ship' && other.answer &&
if (leaf.kind === 'ship' || leaf.kind === 'move') {
if (leaf.kind === 'ship' && leaf.answer && merged.some(other => other.kind === 'ship' && other.answer &&
(other.territory !== leaf.territory || other.sector !== undefined && leaf.sector !== undefined &&
other.sector !== leaf.sector))) return null;
// One exact positive shipment necessarily satisfies any broader negative
// condition with an equal or lower minimum. A broad positive and exact
// negative remain separate: a different sector may still be available.
for (const required of merged) if (required.kind === 'ship' && required.answer)
for (const excluded of merged) if (excluded.kind === 'ship' && !excluded.answer &&
// One exact positive action necessarily satisfies a broader negative
// condition at an equal or lower minimum. A broad positive and an exact
// negative can coexist when another legal sector is available.
for (const required of merged) if (required.kind === leaf.kind && required.answer)
for (const excluded of merged) if (excluded.kind === leaf.kind && !excluded.answer &&
required.territory === excluded.territory && required.minimum >= excluded.minimum &&
(excluded.sector === undefined || required.sector !== undefined && required.sector === excluded.sector)) return null;
}
@@ -1006,12 +1032,28 @@ export function recordTurnActionMovement<T extends TurnActionPromise>(promises:
: node.kind !== 'fact' && node.terms.some(named);
if (!named(promise.mixed)) continue;
const old = promise.progress.find(entry => entry.territory === event.territory)?.maximum ?? 0;
if (event.amount <= old) continue;
const namesSector = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind === 'move' && node.action.territory === event.territory && node.action.sector === event.sector && event.sector !== undefined
: node.kind !== 'fact' && node.terms.some(namesSector);
const sectorNamed = event.sector !== undefined && namesSector(promise.mixed);
const oldSector = sectorNamed ? promise.sectorProgress!.find(entry => entry.territory === event.territory &&
entry.sector === event.sector)?.maximum ?? 0 : 0;
if (event.amount <= old && (!sectorNamed || event.amount <= oldSector)) continue;
const progress = promise.progress.map(entry => ({ ...entry }));
const entry = progress.find(candidate => candidate.territory === event.territory);
if (entry) entry.maximum = event.amount;
else progress.push({ territory: event.territory, maximum: event.amount });
updates.set(promise, settle(promise, { ...promise, progress }));
if (event.amount > old) {
const entry = progress.find(candidate => candidate.territory === event.territory);
if (entry) entry.maximum = event.amount;
else progress.push({ territory: event.territory, maximum: event.amount });
}
let sectorProgress = promise.sectorProgress;
if (sectorNamed && event.amount > oldSector) {
sectorProgress = promise.sectorProgress!.map(entry => ({ ...entry }));
const entry = sectorProgress.find(candidate => candidate.territory === event.territory && candidate.sector === event.sector);
if (entry) entry.maximum = event.amount;
else sectorProgress.push({ territory: event.territory, sector: event.sector!, maximum: event.amount });
}
updates.set(promise, settle(promise, { ...promise, progress,
...(sectorProgress ? { sectorProgress } : {}) }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}