Add current-lot Truthtrance bid commitments

This commit is contained in:
Quintine committed 2026-10-26 12:44:00 +11:00
1 parent 4ab20a2838
commit bd6e5bbf7f
18 files changed
+1346 -27

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
export type BidClaim = { turn: number; lot: number; minimum: number };
export type BidPromise = BidClaim & {
event: string;
/** Internal physical-lot binding. Never project the unrevealed card identity. */
source: string;
player: string;
asker: string;
answer: boolean;
fulfilled?: boolean;
released?: boolean;
};
export type BidOpportunity = {
turn: number; lot: number; source: string; bid: number;
bidder: string | null; passed: readonly string[]; eligible: readonly string[];
};
export class BidPromiseError extends Error {}
export function parseBidClaim(value: unknown): BidClaim {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new BidPromiseError('Choose a current ordinary-auction bid question.');
const input = value as Record<string, unknown>;
for (const field of ['turn', 'lot', 'minimum'] as const)
if (typeof input[field] !== 'number' || !Number.isSafeInteger(input[field]) || input[field] < 1)
throw new BidPromiseError('Choose the current turn and lot, and a positive whole bid minimum.');
return { turn: input.turn as number, lot: input.lot as number, minimum: input.minimum as number };
}
/** Original ordinary lots first; native special auctions and optional modules
* retain their own required future commitment work. Never changes start gates. */
export function bidPromiseModeSupported(game: NativePromiseScope & {
semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown;
richeseBetrayalPreview?: unknown;
}): boolean {
return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.kullPreview &&
!game.nexusKullPreview && !game.richeseBetrayalPreview;
}
export function bidClaimText(claim: BidClaim): string {
return `place a bid of at least ${claim.minimum} spice from now until ordinary lot ${claim.lot} closes`;
}
export function liveBidPromises(promises: readonly BidPromise[], player: string, opportunity: BidOpportunity): BidPromise[] {
return promises.filter(promise => promise.player === player && promise.turn === opportunity.turn &&
promise.source === opportunity.source && !promise.released && !promise.fulfilled);
}
/** One future accepted declaration, not a total of bids or a purchase invoice. */
export function bidPromiseRange(promises: readonly BidPromise[], player: string,
opportunity: BidOpportunity, maximum: number): { minimum: number; maximum: number } | null {
if (!opportunity.eligible.includes(player) || opportunity.passed.includes(player) ||
opportunity.bidder === player || !Number.isSafeInteger(maximum) || maximum < 1 ||
!Number.isSafeInteger(opportunity.bid) || opportunity.bid < 0 || opportunity.bid === Number.MAX_SAFE_INTEGER)
return null;
let minimum = opportunity.bid + 1;
for (const promise of liveBidPromises(promises, player, opportunity)) {
if (promise.answer) minimum = Math.max(minimum, promise.minimum);
else maximum = Math.min(maximum, promise.minimum - 1);
}
return minimum <= maximum ? { minimum, maximum } : null;
}
export function bidRequired(promises: readonly BidPromise[], player: string, opportunity: BidOpportunity): boolean {
return liveBidPromises(promises, player, opportunity).some(promise => promise.answer);
}
/** Record before the native bid advances/settles its lot. Caller owns the
* detached transaction, so a later native validation failure remains atomic. */
export function recordPromisedBid(promises: readonly BidPromise[], player: string,
opportunity: BidOpportunity, amount: number): BidPromise[] {
const live = liveBidPromises(promises, player, opportunity);
if (live.some(promise => !promise.answer && amount >= promise.minimum))
throw new BidPromiseError('This bid conflicts with your Truthtrance No answer for this lot.');
return promises.map(promise => live.includes(promise) && promise.answer && amount >= promise.minimum
? { ...promise, fulfilled: true } : promise);
}
export function validateBidPromises(promises: readonly BidPromise[], players: readonly string[]): void {
if (!Array.isArray(promises)) throw new BidPromiseError('The bid commitment record is invalid.');
const events = new Set<string>();
for (const promise of promises) {
parseBidClaim(promise);
if (typeof promise.event !== 'string' || !promise.event || events.has(promise.event) ||
typeof promise.source !== 'string' || !promise.source || !players.includes(promise.player) ||
!players.includes(promise.asker) || promise.player === promise.asker || typeof promise.answer !== 'boolean' ||
(promise.fulfilled !== undefined && typeof promise.fulfilled !== 'boolean') ||
(promise.released !== undefined && typeof promise.released !== 'boolean') ||
promise.fulfilled && promise.released)
throw new BidPromiseError('The bid commitment lost its original event, source or participants.');
events.add(promise.event);
}
}
+5
View File
@@ -1307,12 +1307,17 @@ function policyActions(g: GameView): Action[] {
? [{ type: 'decision', event: search.event, card: ordered[0].id }]
: [];
}
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
if (g.truthtrance) {
const w = g.truthtrance;
if (w.stage === 'priority')
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
if (w.stage === 'answer') {
if (w.question?.target !== me.id) return [];
if (w.question.kind === 'bid') {
const answer = g.truthBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'traitorCall') {
const answers = g.truthTraitorAnswers ?? [];
const answer = answers.find(answer => answer === 'yes') ?? answers[0];
+154 -23
View File
@@ -18,6 +18,8 @@ import { findMovementCompletion, type MovementCompletion } from './movement-prom
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedFactionStrongholdProfile, selectedIndependentCardsProfile } from './faction-module-profile';
import { homeworldOccupationLeaderSkillsProfile, homeworldOccupationModulesProfile, homeworldPhaseModulesProfile } from './homeworld-module-profile';
import { BidPromiseError, bidPromiseModeSupported, bidPromiseRange, bidRequired, liveBidPromises,
recordPromisedBid, validateBidPromises, type BidClaim, type BidOpportunity, type BidPromise } from './bid-promises';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
@@ -2133,6 +2135,7 @@ export type Game = {
truthtrance?: TruthWindow | null;
truthHistory?: TruthRecord[];
shipmentPromises?: ShipmentPromise[];
bidPromises?: BidPromise[];
movementPromises?: MovementPromise[];
/** Server-only certainty of movement-relevant cards still in the discard. */
movementDiscardKnowledge?: MovementDiscardKnowledge;
@@ -15075,6 +15078,132 @@ function decideNexusEmperorPayment(g: Game, p: Player, action: Action): void {
if (action.use) settleAuction(g, false);
else recoverAuctionPayment(g, getPlayer(g, source.winner));
}
function normalBidMaximum(g: Game, p: Player): number {
return (g.kullPreview ? p.hand.some(card => canUseAsKaramaRole(g, p, card)) : !!karamaCard(g, p))
? Number.MAX_SAFE_INTEGER : p.spice + (aidFor(g, p)?.amount ?? 0);
}
/** Same native quote for ordinary controls and the detached current-lot guide. */
function normalBidQuote(g: Game, p: Player, amount: unknown, requestedAid?: unknown) {
requireRule(g.auction, 'The ordinary auction is no longer present.');
const capacity = normalAuctionHandBlock(p.hand.length, handLimit(p));
requireRule(!capacity, capacity ?? 'Your Treachery hand is full.');
const quote = quoteNormalAuctionBid({ currentBid: g.auction.bid, amount, maximum: normalBidMaximum(g, p) });
if (!quote.ok) throw new RuleError(quote.reason);
requireRule(!g.kullPreview || (p.id === byFaction(g, 'choam')?.id &&
!kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id, 'auction-free-purchase')) ||
quote.amount <= p.spice + (aidFor(g, p)?.amount ?? 0),
'Kull preview defers an unfunded Karama-dependent overbid before acceptance.');
const allyPayment = quote.amount <= p.spice + (aidFor(g, p)?.amount ?? 0)
? contribution(g, p, quote.amount, requestedAid) : 0;
const parties = auctionBureaucratParties(g, p, quote.amount, allyPayment);
checkBureaucratContributors(g, parties.payee, parties.shares);
return { amount: quote.amount, allyPayment };
}
function bidOpportunity(g: Game): BidOpportunity | null {
const a = g.auction;
if (g.status !== 'playing' || g.phase !== 3 || !a || g.currentAuctionSale ||
!a.cards[a.index] || !bidPromiseModeSupported(g)) return null;
return { turn: g.turn, lot: a.index + 1,
source: JSON.stringify(['ordinaryBid', g.turn, a.index, a.cards[a.index].id]),
bid: a.bid, bidder: a.bidder, passed: a.passed,
eligible: g.players.filter(player => player.hand.length < handLimit(player)).map(player => player.id) };
}
function bidQuestionWindow(g: Game): boolean {
return !!bidOpportunity(g) && !g.response && !g.decision && !g.phaseOpening && !g.richeseAuction &&
!g.ixAuction && !g.pendingKarama && !g.pendingTreacheryDiscard;
}
function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []): Action | null {
const opportunity = bidOpportunity(g);
if (!opportunity) return null;
if (!bidRequired(promises, p.id, opportunity)) return { type: 'passBid' };
const range = bidPromiseRange(promises, p.id, opportunity, normalBidMaximum(g, p));
if (!range) return null;
try {
const action: Action = { type: 'bid', ...normalBidQuote(g, p, range.minimum) };
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length) {
const trial = structuredClone(g);
trial.truthtrance = null;
trial.auction!.bid = action.amount as number;
trial.auction!.bidder = p.id;
trial.auction!.allyPayment = action.allyPayment as number;
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
}
return action;
}
catch (error) { if (error instanceof RuleError) return null; throw error; }
}
function bidTruthAnswers(g: Game, p: Player, claim: BidClaim): ('yes' | 'no' | 'unknown')[] {
const opportunity = bidOpportunity(g);
if (!opportunity || !bidQuestionWindow(g) || claim.turn !== opportunity.turn || claim.lot !== opportunity.lot ||
!opportunity.eligible.includes(p.id) || opportunity.passed.includes(p.id) || opportunity.bidder === p.id) return [];
return (['yes', 'no'] as const).filter(answer => !!bidPromiseCompletion(g, p, [...(g.bidPromises ?? []),
{ ...claim, event: 'candidate', source: opportunity.source, player: p.id,
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes' }]));
}
function bindBidTruth(g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) {
const opportunity = bidOpportunity(g);
requireRule(opportunity && bidTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'This ordinary bid answer no longer has a legal completion.');
(g.bidPromises ??= []).push({ ...claim, event: crypto.randomUUID(), source: opportunity.source,
player: p.id, asker, answer });
}
function bidPromiseIntegrity(g: Game) {
try { validateBidPromises(g.bidPromises ?? [], g.players.map(player => player.id)); }
catch (error) { if (error instanceof BidPromiseError) throw new RuleError(error.message); throw error; }
}
function reconcileBidPromises(g: Game, voluntary?: { actor: string; action: Action }) {
bidPromiseIntegrity(g);
if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return;
const opportunity = bidOpportunity(g);
const kept: BidPromise[] = [];
const no = (g.bidPromises ?? []).filter(promise => !promise.answer && !promise.fulfilled && !promise.released);
for (const promise of g.bidPromises ?? []) {
if (promise.fulfilled || promise.released) continue;
const same = opportunity && promise.turn === opportunity.turn && promise.source === opportunity.source;
if (!promise.answer && same) continue;
const own = voluntary?.actor === promise.player &&
['bid', 'passBid', 'card', 'bribe', 'pledgeAid', 'emperorGift', 'richeseGift', 'richeseBid', 'decision', 'karama', 'specialKarama'].includes(voluntary.action.type);
const possible = same && bidPromiseCompletion(g, getPlayer(g, promise.player), [...no, ...kept, promise]);
if (promise.answer && !possible) {
requireRule(!own, 'You cannot voluntarily evade your Truthtrance bid answer.');
promise.released = true;
log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance bid answer for ordinary lot ${promise.lot}; that answer is no longer binding.`);
} else if (!promise.answer) promise.fulfilled = true;
else kept.push(promise);
}
}
function projectedBidPromises(g: Game, player: string) {
return (g.bidPromises ?? []).filter(promise => promise.player === player).map(promise => ({
event: promise.event, player: promise.player, asker: promise.asker, turn: promise.turn,
lot: promise.lot, minimum: promise.minimum, answer: promise.answer,
...(promise.fulfilled === undefined ? {} : { fulfilled: promise.fulfilled }),
...(promise.released === undefined ? {} : { released: promise.released }),
}));
}
function projectedBidPromiseGuide(g: Game, player: string): { action: Action } | null {
const opportunity = bidOpportunity(g);
if (!opportunity || !bidQuestionWindow(g) || g.truthtrance || g.auction?.active !== player) return null;
const promise = liveBidPromises(g.bidPromises ?? [], player, opportunity)[0];
if (!promise) return null;
const action = bidPromiseCompletion(g, getPlayer(g, player));
return action ? { action: { ...action, bidPromiseEvent: promise.event } } : null;
}
function guardPromisedBid(g: Game, p: Player, action: Action, amount?: number) {
const opportunity = bidOpportunity(g);
const live = opportunity ? liveBidPromises(g.bidPromises ?? [], p.id, opportunity) : [];
if (action.bidPromiseEvent !== undefined) requireRule(typeof action.bidPromiseEvent === 'string' &&
live.some(promise => promise.event === action.bidPromiseEvent), 'This bid commitment guide is stale.');
if (!opportunity) return;
if (amount !== undefined && live.length) {
try { g.bidPromises = recordPromisedBid(g.bidPromises ?? [], p.id, opportunity, amount); }
catch (error) { if (error instanceof BidPromiseError) throw new RuleError(error.message); throw error; }
}
requireRule(!bidRequired(g.bidPromises ?? [], p.id, opportunity),
'Place the qualifying bid required by your Truthtrance Yes before passing or taking a lower bid.');
}
function auctionNext(g: Game) {
const a = g.auction!;
const eligible = g.order.filter((id) => {
@@ -34438,6 +34567,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
bidPromiseIntegrity(state);
revivalOrderIntegrity(state);
state = migrateAtomicsTargets(state);
state = migrateFremenNexusRevival(state);
@@ -34542,6 +34672,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
reconcileBidPromises(g, { actor: id, action });
reconcileGroundMovementPromises(g, id, action, state.phase);
reconcileTraitorTruth(g, id, action);
harkonnenNexusBetrayalIntegrity(g);
@@ -34663,6 +34794,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
leaderSkillsIntegrity(g);
reconcileBattlePromises(g, { actor: id, action });
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
reconcileBidPromises(g, { actor: id, action });
settleAutomaticContinuations(g);
observeOccupation(g);
reconcileHomeworldEcazDuke(g);
@@ -34863,6 +34995,7 @@ function settleAutomaticContinuations(g: Game) {
// they are not the original actor voluntarily spending a promised resource.
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
reconcileBidPromises(g);
reconcileGroundMovementPromises(g);
reconcileTraitorTruth(g);
requireRule(
@@ -34876,6 +35009,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
bidPromiseIntegrity(state);
state = migrateAtomicsTargets(state);
state = migrateFremenNexusRevival(state);
harkonnenNexusBetrayalIntegrity(state);
@@ -34959,6 +35093,7 @@ export function normalizeAutomaticGame(state: Game): Game {
}
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
reconcileBidPromises(g);
reconcileGroundMovementPromises(g);
reconcileTraitorTruth(g);
settleAutomaticContinuations(g);
@@ -35204,6 +35339,8 @@ function applyActionInner(
bindShipment: bindShipmentTruth,
movementAnswers: movementTruthAnswers,
bindMovement: bindMovementTruth,
bidAnswers: bidTruthAnswers,
bindBid: bindBidTruth,
traitorAnswers: traitorTruthAnswers,
bindTraitor: bindTraitorTruth,
battleAnswers: battleTruthAnswers,
@@ -35213,7 +35350,7 @@ function applyActionInner(
)
return g;
} catch (error) {
if (error instanceof TruthError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
if (error instanceof TruthError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
throw new RuleError(error.message);
throw error;
}
@@ -37700,31 +37837,16 @@ function applyActionInner(
);
const a = g.auction;
if (t === 'bid') {
const capacity = normalAuctionHandBlock(p.hand.length, handLimit(p));
requireRule(!capacity, capacity ?? 'Your Treachery hand is full.');
const quotedBid = quoteNormalAuctionBid({
currentBid: a.bid,
amount: action.amount,
maximum: (g.kullPreview ? p.hand.some(card => canUseAsKaramaRole(g, p, card)) : !!karamaCard(g, p))
? Number.MAX_SAFE_INTEGER
: p.spice + (aidFor(g, p)?.amount ?? 0),
});
if (!quotedBid.ok) throw new RuleError(quotedBid.reason);
requireRule(!g.kullPreview ||
(p.id === byFaction(g, 'choam')?.id &&
!kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id, 'auction-free-purchase')) ||
quotedBid.amount <= p.spice + (aidFor(g, p)?.amount ?? 0),
'Kull preview defers an unfunded Karama-dependent overbid before acceptance.');
const quotedBid = normalBidQuote(g, p, action.amount, action.allyPayment);
guardPromisedBid(g, p, action, quotedBid.amount);
a.bid = quotedBid.amount;
a.allyPayment =
a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0)
? contribution(g, p, a.bid, action.allyPayment)
: 0;
const parties = auctionBureaucratParties(g,p,a.bid,a.allyPayment);
checkBureaucratContributors(g,parties.payee,parties.shares);
a.allyPayment = quotedBid.allyPayment;
a.bidder = id;
a.passed = [];
} else if (!a.passed.includes(id)) a.passed.push(id);
} else {
guardPromisedBid(g, p, action);
if (!a.passed.includes(id)) a.passed.push(id);
}
auctionNext(g);
return g;
}
@@ -39173,6 +39295,15 @@ export function viewGame(state: Game, id: string) {
g.truthtrance.question.kind === 'battlePlan'
? battleTruthAnswers(g, me, g.truthtrance.question.claim)
: null,
bidPromiseLot: (() => { const lot = bidOpportunity(g); return lot ? { turn: lot.turn, lot: lot.lot } : null; })(),
truthBidTargets: bidQuestionWindow(g) ? g.players.filter(player => {
const lot = bidOpportunity(g)!;
return lot.eligible.includes(player.id) && !lot.passed.includes(player.id) && lot.bidder !== player.id;
}).map(player => player.id) : [],
truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null,
bidPromises: projectedBidPromises(g, id),
bidPromiseGuide: projectedBidPromiseGuide(g, id),
truthtrance: g.truthtrance ?? null,
truthHistory: g.truthHistory ?? [],
truthAnswer:
+28 -1
View File
@@ -3208,6 +3208,31 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: '13 files101/101, types/lint/build and six native engine/answer-callback/GameTable SSR journeys pass. Genuine26/72/77-action Basic/allied/Cunning sources earn original cards, alliances, payments, reveal and draw/return. Four profiles answer/vote legally. Unmatched-card staging is labelled; submitted-false and response/typing fixture feedback is repaired. Full future promises, combinations and final assurance remain open.', evidence: ['tests/traitor-decision-promises.test.ts', 'tests/fixture-truth-traitor-decision.ts', 'tests/fixture-truth-traitor-harkonnen.ts', 'tests/truth-traitor-decision-engine.test.ts', 'tests/truth-traitor-harkonnen-engine.test.ts', 'tests/truth-traitor-decision-controls.test.tsx', 'docs/TRUTHTRANCE_TRAITOR_DECISIONS.md'] },
],
},
{
id: 'truthtrance-bid',
title: 'Truthtrance: a bid in the current ordinary lot',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Bind one future minimum bid in the current ordinary bank lot, while preserving its original funding, payment and turn order.',
steps: [
'Choose Bind a bid in this ordinary lot, another remaining eligible bidder and a positive whole spice minimum. The turn and lot are fixed. Registered native Basic and Advanced tables without optional modules are supported first; special auctions and broader compositions remain unfinished.',
'Yes requires one new accepted bid at or above the minimum while possible. An earlier bid does not count. A pass or lower bid cannot rely on another player raising later. No forbids every later qualifying bid until this lot closes.',
'The declaration fulfills Yes even if another player later wins. If you win, the original winning invoice and Karama payment rules still apply. A promise creates no spice or free purchase. Current-highest, already-passed and full-hand players have no new opportunity for this structured question.',
'Available answers use current native funding, prepaid allied aid and a spendable Karama. Earlier shipment and movement obligations retain their funding. Unknown future purchases or rival income are not assumed. Several answers must remain jointly possible: Yes sets the required minimum and No can cap a later bid.',
'Your own voluntary spending cannot evade Yes. A completed opposing raise may make it impossible and release that answer; still-possible answers and No constraints remain. Closed lots finish No and cannot bind the next lot or turn.',
'Your persistent bid-promise panel waits for your actual active seat, then offers a legal bid or pass. It shows fulfilled and released status without timing out. Only the questioned player receives its feasible answers and private guide; the question and reply are public.',
'This current-lot and next-opportunity protocol is a provisional online composition of the general current-turn Truthtrance rule. Native special auctions, optional modules, grouped/phase-wide bidding commitments, force-revival commitments and complete-mode acceptance remain unfinished.',
],
related: ['card-truthtrance', 'bidding', 'truthtrance-shipment', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Exact current-lot minimum bids use the native quote, real declaration, joint Yes/No interval, accepted-order forced release and lot expiry. Original registered bare native Basic/Advanced scope; no new game-start gate.', evidence: ['game/bid-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/bid-promises.test.ts', 'tests/truth-bid-runtime.test.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Original question selector, public preview, target-only answers and persistent event-bound guide use the native bid packet. Winning payments remain separate.', evidence: ['components/bid-claim-fields.tsx', 'components/bid-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-bid-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles use the projected feasible answer and ordinary guide. Minimal legal participation only; strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-bid-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'General FAQ authority, provisional current-lot meaning, physical declaration/payment distinction and remaining scope are explicit.', evidence: ['docs/TRUTHTRANCE_BID_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Original public H/A/E sources and actual callbacks cover Yes/No, payments, release, multiple original cards, stale ownership and earlier shipment funding. Broader rule/module, recovery and deployed acceptance remain required.', evidence: ['tests/fixture-truth-bid.ts', 'tests/truth-bid-runtime.test.ts', 'tests/truth-bid-controls.test.tsx'] },
],
},
{
id: 'truthtrance-movement',
title: 'Truthtrance: ground-movement promises',
@@ -3357,7 +3382,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
summary:
'Ask another player a public yes/no question. Verified facts, supported shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
steps: [
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
@@ -3368,6 +3393,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.',
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.',
'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. The complete grouped builder supports eight children per group, sixteen total conditions including groups and depth four. Add/wrap/remove/collapse retains the whole claim and validates every field. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.',
'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.',
@@ -3384,6 +3410,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-plan-expression',
'truthtrance-shipment',
'truthtrance-movement',
'truthtrance-bid',
'richese-no-field',
'card-reinforcements',
],
+24
View File
@@ -46,6 +46,7 @@ import { canUseAsTruthtranceRole } from './shrine';
import { isKnowledgeFact, parseKnowledgeFact, knowledgeFactAnswer, knowledgeFactText, truthKnowledgeOf, type KnowledgeFact, type TruthKnowledge } from './truthtrance-knowledge';
import { parseNoFieldValueFact, noFieldValueFactMatches, noFieldValueFactText, type NoFieldValueFact } from './truthtrance-no-field-value';
import { parseFaceDancerFact, faceDancerFactMatches, faceDancerFactText, type FaceDancerFact } from './truthtrance-face-dancer';
import { parseBidClaim, bidClaimText, bidPromiseModeSupported, type BidClaim } from './bid-promises';
export type TruthFact =
| KnowledgeFact
@@ -59,6 +60,7 @@ export type TruthFact =
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type TruthQuestion =
| ({ kind: 'bid'; target: string } & BidClaim)
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
| ({ kind: 'movement'; target: string } & MovementClaim)
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
@@ -210,6 +212,17 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
g.players.some((p) => p.id === v.target),
'Ask one other player.',
);
if (v.kind === 'bid') {
check(Object.keys(v).sort().join(',') === 'kind,lot,minimum,target,turn',
'Supply the current ordinary lot, turn, bid minimum and respondent.');
const claim = parseBidClaim(v);
check(bidPromiseModeSupported(g) && g.status === 'playing' && g.phase === 3 && g.auction &&
!g.currentAuctionSale && !g.richeseAuction && !g.ixAuction && !g.response && !g.decision &&
!g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
claim.turn === g.turn && claim.lot === g.auction.index + 1,
'Choose the current ordinary bid lot in a supported table and finish pending decisions.');
return { kind: 'bid', target: v.target, ...claim };
}
if (v.kind === 'traitorCall') {
check(Object.keys(v).sort().join(',') === 'battle,kind,target,territory' &&
g.status === 'playing' && g.phase === 6 && g.battle?.revealed &&
@@ -434,6 +447,7 @@ export function truthQuestionText(
locationName: (id: string) => string,
): string {
if (q.kind === 'freeform') return q.text;
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
if (q.kind === 'traitorCall') return `In this revealed battle in ${locationName(q.territory)}, will you declare the opposing leader a Traitor?`;
if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
if (q.kind === 'shipment')
@@ -479,6 +493,8 @@ export function resolveTruthAction(
) => void;
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[];
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
traitorAnswers?: (g: Game, p: Player) => TruthAnswer[];
bindTraitor?: (g: Game, p: Player, answer: boolean, asker: string) => void;
battleAnswers: (g: Game, p: Player, claim: PlanClaim) => TruthAnswer[];
@@ -597,6 +613,9 @@ export function resolveTruthAction(
'Waiting for the Truthtrance holder to ask a question.',
);
w.question = parseQuestion(g, p.id, a.question);
if (w.question.kind === 'bid')
check(effects.bidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a bidder with a remaining ordinary opportunity in this lot.');
if (w.question.kind === 'traitorCall')
check(effects.traitorAnswers?.(g, g.players.find(other => other.id === w.question!.target)!).length,
'Choose an actual unsubmitted native Traitor voter.');
@@ -647,6 +666,11 @@ export function resolveTruthAction(
'Choose an answer consistent with legal ground movement and earlier promises.');
if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'bid') {
check(effects.bidAnswers && effects.bindBid && effects.bidAnswers(g, p, q).includes(a.answer),
'Choose an answer consistent with this lot, your legal bids and earlier answers.');
if (a.answer !== 'unknown') effects.bindBid(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'traitorCall') {
check(effects.traitorAnswers && effects.bindTraitor && effects.traitorAnswers(g, p).includes(a.answer),
'Choose an answer compatible with your current legal Traitor declaration and earlier answers.');