Bind Truthtrance shipments before respondent turns
Admit supported active or originally queued unused shipments during the current Shipment & Movement phase. Answering preserves the real active player and queue; private future reachability uses owned resources, joint whole promises and the actual respondent profile. Hide executable guidance until that owner is active. Keep native Guild WAIT/TAKE, frozen mixed facts, interception, costs and actual delivery. Do not release a waiting promise while another declaration is unresolved or count its approved ally contribution as freely recoverable. Before response declaration, reject Emperor gifts or optional BG accompaniment that remove the last legal completion; affordable options remain legal. Minimal AI declines optional accompaniment while bound rather than attempting to spend required reserves. No strategy tuning or new profile/module/earlier-phase opening. Validation and exact frozen-source evidence are recorded in the canonical Truthtrance shipment contract. Three real public Basic/Advanced programmes use original unopened starting deals, paid intervening turns and a later Emperor or native out-of-order Guild fulfillment. Four minimal policies answer while waiting and ship only on their actual turn. Controlled funding, temporary price, gift and advisor probes remain separate. Existing stale scope fixtures and two type declarations were repaired during feedback. Earlier phases, broader modules/actions, full Truthtrance/modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
This commit is contained in:
1 parent
2939ffa900
commit
ae2d0371ce
16 files changed
+745
-56
No files matched your search
@@ -2720,6 +2720,10 @@ function policyActions(g: GameView): Action[] {
|
||||
{ type: 'decision', accept: false },
|
||||
];
|
||||
}
|
||||
if (d.kind === 'advisor' && liveShipmentPromises(g.shipmentPromises ?? [], me.id, g.turn).length)
|
||||
// A waiting promise may need these reserves. Declining is the minimal
|
||||
// legal policy until optional accompaniment strategy is implemented.
|
||||
return [{ type: 'decision', accept: false }];
|
||||
if (d.kind === 'advisor')
|
||||
return [
|
||||
...(g.advanced && d.destination && (g.homeworldMobility?.advisorAccompanyMaximum ?? 1) > 0
|
||||
|
||||
+62
-24
@@ -373,6 +373,7 @@ import {
|
||||
shipmentClaimDestinations,
|
||||
liveShipmentPromises,
|
||||
shipmentPromiseModeSupported,
|
||||
shipmentPromiseTurnAvailable,
|
||||
type ShipmentClaim,
|
||||
type ShipmentPromise,
|
||||
} from './shipment-promises';
|
||||
@@ -13457,6 +13458,16 @@ function settleStrongholdOwnership(g: Game, quoted?: StrongholdState) {
|
||||
);
|
||||
}
|
||||
}
|
||||
function committedOutgoingAid(g: Game, p: Player): number {
|
||||
if (!p.ally) return 0;
|
||||
return Math.max(
|
||||
g.pendingHomeworldShipment?.player === p.ally ? g.pendingHomeworldShipment.allyPayment : 0,
|
||||
g.pendingShipment?.player === p.ally ? g.pendingShipment.allyPayment : 0,
|
||||
g.pendingGuildTransport?.player === p.ally ? g.pendingGuildTransport.allyPayment : 0,
|
||||
g.phase === 6 ? (g.battle?.plans[p.ally!]?.allyPayment ?? 0)
|
||||
: g.auction?.bidder === p.ally ? (g.auction.allyPayment ?? 0) : 0,
|
||||
);
|
||||
}
|
||||
function pledgeAid(g: Game, p: Player, requested: unknown) {
|
||||
requireRule(
|
||||
p.ally && getPlayer(g, p.ally).ally === p.id,
|
||||
@@ -13473,14 +13484,7 @@ function pledgeAid(g: Game, p: Player, requested: unknown) {
|
||||
),
|
||||
'Ally funding cannot be reduced until the submitted Silent bids are revealed.',
|
||||
);
|
||||
const committed =
|
||||
g.pendingHomeworldShipment?.player === p.ally
|
||||
? g.pendingHomeworldShipment.allyPayment
|
||||
: g.phase === 6
|
||||
? (g.battle?.plans[p.ally]?.allyPayment ?? 0)
|
||||
: g.auction?.bidder === p.ally
|
||||
? (g.auction.allyPayment ?? 0)
|
||||
: 0;
|
||||
const committed = committedOutgoingAid(g, p);
|
||||
const amount = integer(
|
||||
requested,
|
||||
g.richeseAuction?.method === 'silent' &&
|
||||
@@ -22671,12 +22675,9 @@ function shipmentPromiseIntegrity(g: Game) {
|
||||
);
|
||||
if (promise.turn === g.turn && !promise.released && !promise.fulfilled)
|
||||
requireRule(
|
||||
shipmentPromiseModeSupported(g) &&
|
||||
g.status === 'playing' &&
|
||||
g.phase === 5 &&
|
||||
g.active === promise.player &&
|
||||
shipmentAvailable(g,getPlayer(g,promise.player)),
|
||||
'The saved shipment promise does not belong to a supported unused shipment opportunity.',
|
||||
shipmentPromiseModeSupported(g, promise.player) &&
|
||||
shipmentPromiseTurnAvailable(g, promise.player, shipmentAvailable(g,getPlayer(g,promise.player))),
|
||||
'The saved shipment promise does not belong to a supported unused shipment opportunity this phase.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22702,10 +22703,7 @@ function findShipmentCompletion(
|
||||
),
|
||||
): { actions: Action[] } | null {
|
||||
if (
|
||||
state.status !== 'playing' ||
|
||||
state.phase !== 5 ||
|
||||
state.active !== owner.id ||
|
||||
!shipmentAvailable(state,owner)
|
||||
!shipmentPromiseTurnAvailable(state, owner.id, shipmentAvailable(state,owner))
|
||||
)
|
||||
return null;
|
||||
if (promises.every(p => matchesShipment(p, null) === p.answer))
|
||||
@@ -22713,6 +22711,18 @@ function findShipmentCompletion(
|
||||
const initial = structuredClone(state);
|
||||
initial.truthtrance = null;
|
||||
projectPendingShipmentKarama(initial, owner.id);
|
||||
const waiting = state.active !== owner.id;
|
||||
const outgoingFloor = committedOutgoingAid(state, owner);
|
||||
if (waiting) {
|
||||
// Quote this owner's later turn without taking it or resolving another
|
||||
// player's declaration. Keep all actual resources and the original queue.
|
||||
initial.active = owner.id;
|
||||
if (initial.karamaShipping?.player !== owner.id) initial.karamaShipping = null;
|
||||
if (initial.pendingShipment?.player !== owner.id) initial.pendingShipment = null;
|
||||
if (initial.pendingGuildTransport?.player !== owner.id) initial.pendingGuildTransport = null;
|
||||
if (initial.pendingHomeworldShipment?.player !== owner.id) initial.pendingHomeworldShipment = null;
|
||||
initial.pendingKarama = null;
|
||||
}
|
||||
// Test choices after the interrupt, while leaving the real continuation intact.
|
||||
// Every actual action still revalidates its timing, quote and physical custody.
|
||||
initial.response = null;
|
||||
@@ -22831,8 +22841,8 @@ function findShipmentCompletion(
|
||||
// conversion effects. More reserves/lower cost cannot remove
|
||||
// a previously available choice of physical shipment count.
|
||||
const preparations: Action[] = [];
|
||||
if (g.aid[p.id]?.amount > 0)
|
||||
preparations.push({ type: 'pledgeAid', amount: 0 });
|
||||
if ((g.aid[p.id]?.amount ?? 0) > outgoingFloor)
|
||||
preparations.push({ type: 'pledgeAid', amount: outgoingFloor });
|
||||
const karama = p.hand.find((c) => canUseAsKaramaRole(g, p, c));
|
||||
if (karama && !g.karamaShipping)
|
||||
preparations.push({
|
||||
@@ -22951,6 +22961,18 @@ function reconcileShipmentPromises(
|
||||
if (promise.turn !== g.turn || promise.released || promise.fulfilled)
|
||||
continue;
|
||||
const p = getPlayer(g, promise.player);
|
||||
const future = g.active !== p.id;
|
||||
const ownVoluntary = voluntary?.actor === p.id &&
|
||||
(['card', 'bribe', 'pledgeAid', 'emperorGift'].includes(voluntary.action.type) ||
|
||||
future && voluntary.action.type === 'decision');
|
||||
// A foreign pending parent may spend or refund its approved funding. Wait
|
||||
// for that outcome; never release a future promise just for waiting.
|
||||
if (future && !ownVoluntary && (g.truthtrance || g.response || g.decision ||
|
||||
g.phaseOpening || g.pendingShipment || g.pendingGuildTransport ||
|
||||
g.pendingHomeworldShipment || g.pendingTreacheryDiscard || g.ornithopter)) {
|
||||
kept.push(promise);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
findShipmentCompletion(g, p, [
|
||||
...kept.filter((old) => old.player === p.id),
|
||||
@@ -22960,9 +22982,7 @@ function reconcileShipmentPromises(
|
||||
kept.push(promise);
|
||||
else {
|
||||
requireRule(
|
||||
!voluntary ||
|
||||
voluntary.actor !== p.id ||
|
||||
!['card', 'bribe', 'pledgeAid'].includes(voluntary.action.type),
|
||||
!ownVoluntary,
|
||||
'You cannot voluntarily make your Truthtrance shipment answer impossible.',
|
||||
);
|
||||
promise.released = true;
|
||||
@@ -32727,6 +32747,13 @@ function applyActionInner(
|
||||
location: location(dest.territory, dest.sector),
|
||||
amount,
|
||||
};
|
||||
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length) {
|
||||
// Accepting accompaniment is voluntary even though a later responder
|
||||
// commits its reserve withdrawal. Check that allowed outcome now.
|
||||
const afterAdvisor = structuredClone(g);
|
||||
finishResponse(afterAdvisor, false);
|
||||
reconcileShipmentPromises(afterAdvisor, { actor: p.id, action });
|
||||
}
|
||||
} else log(g, `${p.name} declined the free shipment.`);
|
||||
} else if (decision.kind === 'wormPlacement') {
|
||||
requireRule(
|
||||
@@ -33645,6 +33672,13 @@ function applyActionInner(
|
||||
maximum,
|
||||
t === 'emperorGift' ? 'Spice' : 'Forces',
|
||||
);
|
||||
if (t === 'emperorGift' && liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length) {
|
||||
// Permission to cancel the gift is not funding for an already promised
|
||||
// shipment. Validate the voluntary outflow before opening its response.
|
||||
const afterGift = structuredClone(g);
|
||||
getPlayer(afterGift, p.id).spice -= amount;
|
||||
reconcileShipmentPromises(afterGift, { actor: p.id, action });
|
||||
}
|
||||
const elite =
|
||||
t === 'emperorRevival'
|
||||
? eliteChoice(
|
||||
@@ -35150,7 +35184,10 @@ export function viewGame(state: Game, id: string) {
|
||||
battleChoices: g.status === 'playing' && g.phase === 6 ? battleOrderQuote(g).choices : [],
|
||||
battleOrder: projectedBattleOrder(g),
|
||||
guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id),
|
||||
truthShipmentSupported: shipmentPromiseModeSupported(g),
|
||||
truthShipmentSupported: g.players.some(p => shipmentPromiseModeSupported(g, p.id)),
|
||||
truthShipmentTargets: !g.response && !g.decision && !g.phaseOpening && !g.pendingShipment
|
||||
? g.players.filter(p => shipmentPromiseModeSupported(g, p.id) &&
|
||||
shipmentPromiseTurnAvailable(g, p.id, shipmentAvailable(g, p))).map(p => p.id) : [],
|
||||
guildShipmentStopPromiseBlock: ownGuildStopPromiseBlock(g, me),
|
||||
truthShipmentAnswers:
|
||||
g.truthtrance?.stage === 'answer' &&
|
||||
@@ -35162,6 +35199,7 @@ export function viewGame(state: Game, id: string) {
|
||||
// original question and whole answer remain public through truthHistory.
|
||||
shipmentPromises: (g.shipmentPromises ?? []).filter(p => !p.mixed || p.player === id),
|
||||
shipmentCompletion:
|
||||
g.active === id &&
|
||||
!g.pendingTreacheryDiscard &&
|
||||
!g.response && !g.pendingKarama && !g.truthtrance &&
|
||||
!g.decision && !g.phaseOpening && !g.pendingShipment &&
|
||||
|
||||
+8
-5
@@ -2912,15 +2912,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'Bind a shipment from reserves to a named territory with a minimum physical force count during the active supported shipment opportunity.',
|
||||
'Bind an unused reserve shipment this phase, including a player whose turn is still ahead, to a named destination and minimum physical count.',
|
||||
steps: [
|
||||
'Declare Truthtrance, select the active player and choose Bind a shipment from reserves. Choose a printed destination and minimum of one to twenty forces, or combine two shipment conditions using AND or OR. Each describes the same shipment. Automatic enforcement supports base Basic and base Advanced games, including Guild, before that active player ships and after pending decisions finish. Other optional configurations keep their existing boundaries.',
|
||||
'Declare Truthtrance, select an eligible player whose unused shipment is active or still ahead this phase, and choose Bind a shipment from reserves. Choose a printed destination and minimum of one to twenty forces, or combine two shipment conditions using AND or OR. Each describes the same shipment. Automatic enforcement supports base Basic and base Advanced games, including Guild, before that player ships and after pending decisions finish. Earlier players keep their original turns; answering never grants an early shipment. Other optional configurations keep their existing boundaries.',
|
||||
'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 their own resources and earlier promises. Available Ghola revival, Karama discounts, incoming pledged aid and reclaiming their own unused pledge are included. Other players’ unplayed cards and future voluntary gifts are not assumed.',
|
||||
'Accepted whole answers are public and constrain the event together with earlier promises. When an answer requires a shipment, honor it before ground movement or finishing and do not voluntarily spend away the last legal completion. A completed opposing effect that makes compliance impossible releases it publicly.',
|
||||
'Guild interception and rate or transport-permission responses finish before delivery. An opposing stop consumes the shipment without payment or arrival: answers matching no shipment fulfill and impossible answers release. You cannot stop your own shipment to evade your answer. An unaffordable canceled rate leaves shipment available; a retained owned discount or ordinary Fremen reinforcement may still provide a legal route.',
|
||||
'Choose Mixed current fact and shipment for nested AND/OR groups of supported current facts and this-turn reserve shipments. Current facts freeze privately when answered: Yes OR a true current fact can allow skipping even after later losing that holding; No AND a false current fact can allow shipping even after that fact changes. Unknown is not false. Only the whole question and answer are public.',
|
||||
'Your own promises show the original grouped question, not private compiled clause results. Optional private next-step guidance includes preparation, funding or shipment, only after pending controls finish. All four legal policies use server-feasible choices and the same complete route. Earlier phases, other modules, expansion shipment modes, other future actions and arbitrary prose enforcement remain unfinished.',
|
||||
'Your own promises show the original grouped question, not private compiled clause results. While waiting, the promise remains visible without an executable action. Optional private next-step guidance includes preparation, funding or shipment only on your actual turn after pending controls finish. All four legal policies use server-feasible choices and the same complete route. Earlier phases, other modules, expansion shipment modes, other future actions and arbitrary prose enforcement remain unfinished.',
|
||||
],
|
||||
example:
|
||||
'Yes to at least six forces to Carthag and No to at least eight there leaves a shipment of six or seven. Moving existing forces into Carthag does not fulfill either shipment event.',
|
||||
@@ -2930,7 +2930,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Authoritative reserve commitments, mixed current-fact AND/OR, frozen clauses, owned preparation, actual shipment/skip and opposing release include base Advanced Guild interception, permission and rates. Self-stop cannot evade an answer; broader timing/modules remain unfinished.',
|
||||
'Authoritative reserve commitments, mixed current-fact AND/OR, frozen clauses, owned preparation, actual shipment/skip and opposing release include base Advanced Guild interception, permission and rates. Same-phase waiting turns retain joint promises through native Guild WAIT/TAKE and paid intervening turns. Own gifts/accompaniment cannot spend the last completion; broader phases/modules remain unfinished.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
@@ -2954,7 +2954,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Verification',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Current Guild batch passes106 rule cases plus one updated existing in-memory scope case, types/lint/build, four original-action/rendered-table programmes and four finite policy continuations. Earlier persistence/privacy evidence is historical; full Truthtrance, modes, final assurance and deployed acceptance remain open.',
|
||||
'Current waiting-turn batch passes146 rule cases plus one updated existing in-memory scope case, types/lint/build, three original-action/rendered-table programmes and four finite legal policy continuations. Earlier persistence/privacy evidence is historical; full Truthtrance, modes, final assurance and deployed acceptance remain open.',
|
||||
evidence: [
|
||||
'tests/shipment-promises.test.ts',
|
||||
'tests/shipment-promises-bots.test.ts',
|
||||
@@ -2965,6 +2965,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/compound-shipment-recovery.test.ts',
|
||||
'tests/mixed-shipment-question.test.ts',
|
||||
'tests/mixed-shipment-engine.test.ts',
|
||||
'tests/truthtrance-future-shipment.test.ts',
|
||||
'tests/truthtrance-future-shipment-engine.test.ts',
|
||||
'tests/fixture-truthtrance-future-shipment.ts',
|
||||
'tests/truthtrance-guild-shipment.test.ts',
|
||||
'tests/truthtrance-guild-shipment-engine.test.ts',
|
||||
'tests/fixture-truthtrance-guild-shipment.ts',
|
||||
|
||||
@@ -159,7 +159,7 @@ export function shipmentPromiseModeSupported(game: {
|
||||
semutaPreview?: unknown;
|
||||
leaderSkills?: unknown;
|
||||
discoveryEnabled?: unknown;
|
||||
}): boolean {
|
||||
}, respondent = game.active): boolean {
|
||||
if (game.semutaPreview === true && game.expansions.length === 1 &&
|
||||
game.expansions[0] === 'choam' && !game.techTokens &&
|
||||
!game.strongholdCards && !game.nexusCards && !game.homeworlds &&
|
||||
@@ -169,7 +169,7 @@ export function shipmentPromiseModeSupported(game: {
|
||||
player.faction === 'atreides' || player.faction === 'harkonnen' ||
|
||||
player.faction === 'emperor' || player.faction === 'beneGesserit' ||
|
||||
player.faction === 'fremen')) {
|
||||
const target = game.players.find(player => player.id === game.active);
|
||||
const target = game.players.find(player => player.id === respondent);
|
||||
return !!target && target.faction !== 'richese';
|
||||
}
|
||||
return (
|
||||
@@ -181,6 +181,16 @@ export function shipmentPromiseModeSupported(game: {
|
||||
!game.homeworlds))
|
||||
);
|
||||
}
|
||||
/** Current-turn eligibility, distinct from permission to execute an action now. */
|
||||
export function shipmentPromiseTurnAvailable(game: {
|
||||
status: string;
|
||||
phase: number;
|
||||
active?: string | null;
|
||||
movementRemaining?: readonly string[] | null;
|
||||
}, player: string, unused: boolean): boolean {
|
||||
return game.status === 'playing' && game.phase === 5 && unused &&
|
||||
(game.active === player || !!game.movementRemaining?.includes(player));
|
||||
}
|
||||
export type ShipmentPromise = ShipmentClaim & {
|
||||
turn: number;
|
||||
player: string;
|
||||
|
||||
+6
-9
@@ -2,6 +2,7 @@ import { shipmentAvailable } from './shipment-opportunity';
|
||||
import type { ShipmentClaim } from './shipment-promises';
|
||||
import {
|
||||
shipmentPromiseModeSupported,
|
||||
shipmentPromiseTurnAvailable,
|
||||
parseShipmentClaim,
|
||||
shipmentClaimText,
|
||||
ShipmentClaimError,
|
||||
@@ -192,19 +193,15 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
);
|
||||
if (v.kind === 'shipment' || v.kind === 'mixedShipment') {
|
||||
check(
|
||||
shipmentPromiseModeSupported(g) &&
|
||||
g.status === 'playing' &&
|
||||
g.phase === 5 &&
|
||||
g.active === v.target &&
|
||||
shipmentAvailable(
|
||||
g,
|
||||
g.players.find((p) => p.id === v.target)!,
|
||||
) &&
|
||||
shipmentPromiseModeSupported(g, v.target) &&
|
||||
shipmentPromiseTurnAvailable(g, v.target, shipmentAvailable(
|
||||
g, g.players.find((p) => p.id === v.target)!,
|
||||
)) &&
|
||||
!g.response &&
|
||||
!g.decision &&
|
||||
!g.phaseOpening &&
|
||||
!g.pendingShipment,
|
||||
'Structured shipment promises require a supported active unused shipment: Basic or Advanced base rules, or a classic payer in the Richese Semuta profile. Finish pending decisions first.',
|
||||
'Structured shipment promises require a supported unused shipment this phase: the respondent must be active or still waiting for their turn. Finish pending decisions first.',
|
||||
);
|
||||
try {
|
||||
if (v.kind === 'mixedShipment') {
|
||||
|
||||
Reference in new issue
Block a user