Support Truthtrance No-Field shipment promises
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
9ab9b9a613
commit
7fccd62814
16 files changed
+658
-32
No files matched your search
+3
-1
@@ -4642,7 +4642,9 @@ export function botActions(g: GameView): Action[] {
|
||||
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), sector: Number(action.sector), amount: Number(action.amount) }
|
||||
? { territory: String(action.territory), sector: Number(action.sector),
|
||||
amount: action.noField ? Number(action.noFieldReserves ?? 0) + Number(action.smuggler === true) : Number(action.amount),
|
||||
...(action.noField ? {noField: true as const} : {}) }
|
||||
: null;
|
||||
return promises.every(
|
||||
(promise) => matchesShipment(promise, shipment) === promise.answer,
|
||||
|
||||
+49
-18
@@ -506,11 +506,13 @@ import {
|
||||
parseShipmentClaim,
|
||||
validShipmentClaim,
|
||||
shipmentClaimDestinations,
|
||||
shipmentClaimHasNoField,
|
||||
liveShipmentPromises,
|
||||
shipmentPromiseModeSupported,
|
||||
shipmentPromiseTurnAvailable,
|
||||
type ShipmentClaim,
|
||||
type ShipmentPromise,
|
||||
type ShipmentReceipt,
|
||||
} from './shipment-promises';
|
||||
import { portableSnooperPlanBlock } from './portable-snooper';
|
||||
import {
|
||||
@@ -13095,7 +13097,7 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
|
||||
approvedSupplier = {donor:shipment.alliedNoField.owner,amount:quote.ownerPayment};
|
||||
}
|
||||
if (!approvedSupplier) checkShipmentIncomeFunding(g, p, shipment.cost, shipment.allyPayment);
|
||||
checkShipmentPromises(g, getPlayer(g, shipment.player), shipment);
|
||||
checkShipmentPromises(g, getPlayer(g, shipment.player), shipmentPromiseReceipt(g, shipment));
|
||||
shipper = shipment.player; price = shipment.cost;
|
||||
allyPayment = approvedSupplier?.amount ?? shipment.allyPayment;
|
||||
originalReceiver = guildShipmentIncome({guild: guild.id, shipper, ally: p.ally,
|
||||
@@ -33213,6 +33215,7 @@ function shipmentPromiseIntegrity(g: Game) {
|
||||
(p) => p.id === promise.asker && p.id !== promise.player,
|
||||
) &&
|
||||
validShipmentClaim(promise, {allowConstants: promise.mixed !== undefined}) &&
|
||||
(!shipmentClaimHasNoField(promise) || g.players.some(p => p.id === promise.player && p.faction === 'richese')) &&
|
||||
typeof promise.answer === 'boolean' &&
|
||||
(promise.released === undefined ||
|
||||
typeof promise.released === 'boolean') &&
|
||||
@@ -33436,6 +33439,8 @@ function findShipmentCompletion(
|
||||
p.reserves,
|
||||
p.tanks,
|
||||
p.elites,
|
||||
p.noField,
|
||||
p.noFieldEvent,
|
||||
g.homeworlds,
|
||||
p.hand.map((c) => c.id),
|
||||
g.aid,
|
||||
@@ -33443,6 +33448,22 @@ function findShipmentCompletion(
|
||||
]);
|
||||
if (visited.has(signature)) continue;
|
||||
visited.add(signature);
|
||||
if (p.faction === 'richese' && p.noField && !p.noField.deployed && p.noFieldEvent && !noFieldShipBlock(g, p)) {
|
||||
for (const destination of destinations) for (const sector of territory(destination).sectors) {
|
||||
const receipt: ShipmentReceipt = { territory: destination, sector, amount: 0, noField: true };
|
||||
if (!promises.every(promise => matchesShipment(promise, receipt) === promise.answer)) continue;
|
||||
for (const token of p.noField.tokens.filter(token => token.id !== p.noField!.lastShipped)) {
|
||||
const action: Action = { type: 'ship', noField: token.id, event: p.noFieldEvent,
|
||||
territory: destination, sector };
|
||||
try {
|
||||
applyActionInner(g, p.id, action, 'movementPreparation');
|
||||
return { early, actions: [...actions, action] };
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const destination of destinations) for (let amount = 1; amount <= p.reserves; amount++) {
|
||||
for (const sector of territory(destination).sectors) {
|
||||
if (!promises.every(p => matchesShipment(p, { territory: destination, amount, sector }) === p.answer)) continue;
|
||||
@@ -33530,6 +33551,8 @@ function findShipmentCompletion(
|
||||
// conversion effects. More reserves/lower cost cannot remove
|
||||
// a previously available choice of physical shipment count.
|
||||
const preparations: Action[] = [];
|
||||
if (p.faction === 'richese' && p.noField?.deployed && p.noFieldEvent)
|
||||
preparations.push({ type: 'revealNoField', token: p.noField.deployed.tokenId, event: p.noFieldEvent });
|
||||
if ((g.aid[p.id]?.amount ?? 0) > outgoingFloor)
|
||||
preparations.push({ type: 'pledgeAid', amount: outgoingFloor });
|
||||
const karama = p.hand.find((c) => canUseAsKaramaRole(g, p, c));
|
||||
@@ -33637,7 +33660,7 @@ function bindShipmentTruth(
|
||||
function checkShipmentPromises(
|
||||
g: Game,
|
||||
p: Player,
|
||||
shipment: { territory: string; amount: number; sector?: number } | null,
|
||||
shipment: ShipmentReceipt | null,
|
||||
) {
|
||||
requireRule(
|
||||
liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).every(
|
||||
@@ -33646,24 +33669,36 @@ function checkShipmentPromises(
|
||||
'Honor your Truthtrance shipment answer before using this shipment opportunity.',
|
||||
);
|
||||
if (g.phase === 5 && g.active === p.id) guardTurnActionShipmentPriceBoundary(g, p.id,
|
||||
liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn), undefined, shipment);
|
||||
liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn), undefined,
|
||||
shipment?.noField && shipment.amount === 0 ? null : shipment);
|
||||
}
|
||||
/** Preserve physical counts while explicitly recording an owned marker. */
|
||||
function shipmentPromiseReceipt(g: Game, shipment: PendingShipment): ShipmentReceipt | null {
|
||||
if (shipment.alliedNoField) return null;
|
||||
if (!shipment.noField) return { territory: shipment.territory, sector: shipment.sector, amount: shipment.amount };
|
||||
return { territory: shipment.territory, sector: shipment.sector, noField: true,
|
||||
amount: shipment.richesePair
|
||||
? currentRichesePairForces(g, getPlayer(g, shipment.player), shipment.noFieldReserves ?? 0, shipment.richesePair.revealedTokenId).physical
|
||||
: (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0) };
|
||||
}
|
||||
function finishShipmentPromises(
|
||||
g: Game,
|
||||
p: Player,
|
||||
shipment: { territory: string; amount: number; sector?: number } | null,
|
||||
shipment: ShipmentReceipt | null,
|
||||
) {
|
||||
checkShipmentPromises(g, p, shipment);
|
||||
// Other promise families retain their original physical-force meaning.
|
||||
const physical = shipment?.noField && shipment.amount === 0 ? null : shipment;
|
||||
const probe = (g as TurnActionCardProbe)._turnActionShipmentReceipt;
|
||||
if (probe?.player === p.id && probe.event === undefined)
|
||||
probe.event = shipment ? { territory: shipment.territory, amount: shipment.amount,
|
||||
...(shipment.sector === undefined ? {} : { sector: shipment.sector }) } : null;
|
||||
probe.event = physical ? { territory: physical.territory, amount: physical.amount,
|
||||
...(physical.sector === undefined ? {} : { sector: physical.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,
|
||||
...(shipment.sector === undefined ? {} : { sector: shipment.sector }) } : null));
|
||||
recordTurnActionShipment(g.turnActionPromises!, p.id, g.turn, physical ? { territory: physical.territory, amount: physical.amount,
|
||||
...(physical.sector === undefined ? {} : { sector: physical.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,
|
||||
...(shipment.sector === undefined ? {} : { sector: shipment.sector }) } : null));
|
||||
physical ? { territory: physical.territory, amount: physical.amount,
|
||||
...(physical.sector === undefined ? {} : { sector: physical.sector }) } : null));
|
||||
for (const promise of liveShipmentPromises(
|
||||
g.shipmentPromises ?? [],
|
||||
p.id,
|
||||
@@ -33711,7 +33746,7 @@ function reconcileShipmentPromises(
|
||||
voluntary.action.type === 'richeseBid' && typeof voluntary.action.amount === 'number' && voluntary.action.amount > 0 ||
|
||||
voluntary.action.type === 'ready' && [2, 4].includes(voluntary.phase ?? g.phase));
|
||||
const ownVoluntary = voluntary?.actor === p.id &&
|
||||
(['card', 'bribe', 'charity', 'alliance', 'pledgeAid', 'emperorGift', 'richeseGift'].includes(voluntary.action.type) ||
|
||||
(['card', 'bribe', 'charity', 'alliance', 'pledgeAid', 'emperorGift', 'richeseGift', 'revealNoField'].includes(voluntary.action.type) ||
|
||||
earlyVoluntary ||
|
||||
future && voluntary.action.type === 'decision');
|
||||
// A foreign pending parent may spend or refund its approved funding. Wait
|
||||
@@ -40508,11 +40543,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
|
||||
finishShipmentPromises(
|
||||
g,
|
||||
getPlayer(g, shipment.player),
|
||||
shipment.noField ? (shipment.richesePair
|
||||
? { 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, sector: shipment.sector, amount: (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0) }
|
||||
: null) : shipment.alliedNoField ? null : shipment,
|
||||
shipmentPromiseReceipt(g, shipment),
|
||||
);
|
||||
const {
|
||||
territory: to,
|
||||
@@ -47352,8 +47383,8 @@ function applyActionInner(
|
||||
}),
|
||||
...(noField ? { noField } : {}),
|
||||
};
|
||||
if (noField) checkShipmentPromises(g, p, noFieldGroup!.physical
|
||||
? { territory: to, amount: noFieldGroup!.physical, sector: s } : null);
|
||||
if (noField) checkShipmentPromises(g, p,
|
||||
{ territory: to, amount: noFieldGroup!.physical, sector: s, noField: true });
|
||||
if (noField && (g.leaderSkills || noFieldReserves) && !richesePair) {
|
||||
shipment.noFieldSkillProof = noFieldSkillProof(shipment);
|
||||
validatePhysicalShipment(g, shipment);
|
||||
|
||||
@@ -67,7 +67,7 @@ export function parseMixedShipmentExpression(
|
||||
territory: v.territory,
|
||||
minimum: v.minimum,
|
||||
});
|
||||
check('territory' in leaf, 'Choose a reserve-shipment statement.');
|
||||
check('minimum' in leaf, 'Choose a reserve-shipment statement.');
|
||||
hasShipment = true;
|
||||
return { kind: 'shipment', ...leaf };
|
||||
}
|
||||
|
||||
+2
-1
@@ -3847,9 +3847,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary:
|
||||
'Bind this turn’s reserve shipment to a destination and physical minimum, including earlier Basic phases and players whose shipment turn is still ahead.',
|
||||
'Bind this turn’s reserve shipment to physical-force or owned No-Field conditions, including earlier Basic phases and players whose shipment turn is still ahead.',
|
||||
steps: [
|
||||
'Declare Truthtrance, select an eligible player and choose Bind a shipment from reserves. Bare Basic and Advanced rules with selected native faction decks allow the question during Storm through Revival as well as before that player’s shipment. During Shipment and Movement, base Basic and Advanced, including Guild, support an unused active or waiting turn. Finish pending decisions first. Choose a printed destination and minimum of one to twenty forces, or combine conditions using AND or OR; each describes the same shipment. Answering never grants an early shipment. Optional configurations keep their separate boundaries.',
|
||||
'When the respondent is Richese, a shipment condition may instead ask whether it will ship its own No-Field from reserves, optionally to a named territory or sector. The question does not reveal the token’s identity or value. A physical-force condition still counts actual counters, not the concealed marker. If an existing marker is deployed, Richese may need to reveal it before shipping a different eligible token.',
|
||||
'The pure shipment builder supports up to sixteen shipment conditions, nested AND/OR groups and four grouping levels. Add, wrap, change group meaning or remove a condition; a singleton group collapses without losing its remaining statement. These app input limits do not add printed rules. Every condition describes the same eventual reserve shipment; a current fact is unnecessary.',
|
||||
'Yes requires the complete statement to match while possible: all AND conditions or at least one OR condition. No requires the whole statement to be false: at least one AND condition false, or every OR condition false. A smaller count, different destination, transport of forces already on the board or no shipment may satisfy No. Forces already in the destination and ground movement do not satisfy the question. Fremen reinforcements and Guild transport from southern reserves do count.',
|
||||
'The target privately sees feasible answers based on the current board, owned resources and all earlier promises. Before Movement, include remaining owned Charity and normal Revival, the current winning invoice and unused outgoing Bidding refunds; incoming Bidding aid expires. Auction payment and shipping discounts need distinct physical Karamas. At shipment time, available Ghola, Karama and current pledged aid retain their normal rules. Unknown purchases, unresolved Storm outcomes, rival gifts and future income are not assumed.',
|
||||
|
||||
@@ -24,6 +24,7 @@ export function shipmentExpressionInputError(value: ShipmentExpression): string
|
||||
...('terms' in node && Array.isArray(node.terms) ? { terms: node.terms.map(structure) } : {}),
|
||||
};
|
||||
if ('constant' in node) return node;
|
||||
if ('kind' in node && node.kind === 'noField') return node;
|
||||
// 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
|
||||
|
||||
@@ -5,8 +5,11 @@ 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; sector?: number };
|
||||
/** A public promise about use of a concealed marker, never its identity or value. */
|
||||
export type NoFieldShipmentLeaf = { kind: 'noField'; territory?: string; sector?: number };
|
||||
export type ShipmentExpression =
|
||||
| ShipmentLeaf
|
||||
| NoFieldShipmentLeaf
|
||||
| { constant: TruthAnswer }
|
||||
| { op: 'and' | 'or'; terms: ShipmentExpression[] };
|
||||
/** Old flat questions and saved promises remain valid without migration. */
|
||||
@@ -84,6 +87,25 @@ export function parseShipmentExpression(
|
||||
);
|
||||
return { constant: value.constant };
|
||||
}
|
||||
if (value.kind === 'noField') {
|
||||
const keys = Object.keys(value).sort().join(',');
|
||||
check(
|
||||
(keys === 'kind' || keys === 'kind,territory' || keys === 'kind,sector,territory') &&
|
||||
++leaves <= SHIPMENT_EXPRESSION_MAX_LEAVES,
|
||||
'Choose a No-Field shipment and optionally a printed destination and sector.',
|
||||
);
|
||||
if (keys === 'kind') return { kind: 'noField' };
|
||||
check(typeof value.territory === 'string' &&
|
||||
TERRITORIES.some(t => t.id === value.territory),
|
||||
'Choose a printed destination territory.');
|
||||
if (keys === 'kind,sector,territory') {
|
||||
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 { kind: 'noField', territory: value.territory, sector: value.sector };
|
||||
}
|
||||
return { kind: 'noField', territory: value.territory };
|
||||
}
|
||||
check(
|
||||
(Object.keys(value).sort().join(',') === 'minimum,territory' ||
|
||||
Object.keys(value).sort().join(',') === 'minimum,sector,territory') &&
|
||||
@@ -134,13 +156,22 @@ export function shipmentExpressionOf(claim: ShipmentClaim): ShipmentExpression {
|
||||
...(claim.sector === undefined ? {} : { sector: claim.sector }) }
|
||||
);
|
||||
}
|
||||
/** Public syntax check; it never reads a player's marker inventory. */
|
||||
export function shipmentClaimHasNoField(claim: ShipmentClaim): boolean {
|
||||
const contains = (expression: ShipmentExpression): boolean =>
|
||||
'op' in expression ? expression.terms.some(contains) :
|
||||
'kind' in expression && expression.kind === 'noField';
|
||||
return contains(shipmentExpressionOf(claim));
|
||||
}
|
||||
export function shipmentClaimDestinations(claim: ShipmentClaim): string[] {
|
||||
const collect = (expression: ShipmentExpression): string[] =>
|
||||
'op' in expression
|
||||
? expression.terms.flatMap(collect)
|
||||
: 'constant' in expression
|
||||
? []
|
||||
: [expression.territory];
|
||||
: 'kind' in expression && expression.territory === undefined
|
||||
? TERRITORIES.map(t => t.id)
|
||||
: [expression.territory!];
|
||||
return [...new Set(collect(shipmentExpressionOf(claim)))];
|
||||
}
|
||||
export function shipmentClaimText(
|
||||
@@ -152,9 +183,11 @@ export function shipmentClaimText(
|
||||
!('constant' in expression),
|
||||
'Format a compiled mixed claim using its original question, not its private answers.',
|
||||
);
|
||||
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}${expression.sector === undefined ? '' : `, sector ${expression.sector}`}`;
|
||||
if ('op' in expression)
|
||||
return `(${expression.terms.map(text).join(expression.op === 'and' ? ' AND ' : ' OR ')})`;
|
||||
if ('kind' in expression)
|
||||
return `ship your No-Field from your reserves${expression.territory === undefined ? '' : ` to ${territory(expression.territory).name}${expression.sector === undefined ? '' : `, sector ${expression.sector}`}`}`;
|
||||
return `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));
|
||||
}
|
||||
@@ -221,9 +254,16 @@ export type ShipmentPromise = ShipmentClaim & {
|
||||
released?: boolean;
|
||||
fulfilled?: boolean;
|
||||
};
|
||||
/** A completed reserve shipment. Amount counts real physical counters only. */
|
||||
export type ShipmentReceipt = {
|
||||
territory: string;
|
||||
amount: number;
|
||||
sector?: number;
|
||||
noField?: true;
|
||||
};
|
||||
export function matchesShipment(
|
||||
claim: ShipmentClaim,
|
||||
shipment: { territory: string; amount: number; sector?: number } | null,
|
||||
shipment: ShipmentReceipt | null,
|
||||
): boolean | null {
|
||||
const matches = (expression: ShipmentExpression): boolean | null => {
|
||||
if ('constant' in expression)
|
||||
@@ -240,6 +280,10 @@ export function matchesShipment(
|
||||
}
|
||||
return unknown ? null : expression.op === 'and';
|
||||
}
|
||||
if ('kind' in expression)
|
||||
return !!shipment && shipment.noField === true &&
|
||||
(expression.territory === undefined || shipment.territory === expression.territory) &&
|
||||
(expression.sector === undefined || shipment.sector === expression.sector);
|
||||
return !!shipment &&
|
||||
shipment.territory === expression.territory &&
|
||||
(expression.sector === undefined || shipment.sector === expression.sector) &&
|
||||
|
||||
+5
-1
@@ -32,6 +32,7 @@ import {
|
||||
shipmentPromiseModeSupported,
|
||||
shipmentPromiseTurnAvailable,
|
||||
parseShipmentClaim,
|
||||
shipmentClaimHasNoField,
|
||||
shipmentClaimText,
|
||||
ShipmentClaimError,
|
||||
} from './shipment-promises';
|
||||
@@ -606,7 +607,10 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
return {kind:'mixedShipment',target:v.target,mixed:parseMixedShipmentExpression(v.mixed,
|
||||
fact => parseFact(g,fact,respondent))};
|
||||
}
|
||||
return { kind: 'shipment', target: v.target, ...parseShipmentClaim(v) };
|
||||
const claim = parseShipmentClaim(v);
|
||||
check(!shipmentClaimHasNoField(claim) || g.players.find(p => p.id === v.target)?.faction === 'richese',
|
||||
'A No-Field shipment question needs the Richese player.');
|
||||
return { kind: 'shipment', target: v.target, ...claim };
|
||||
} catch (error) {
|
||||
if (error instanceof ShipmentClaimError)
|
||||
throw new TruthError(error.message);
|
||||
|
||||
Reference in new issue
Block a user