Add current-lot Truthtrance bid commitments
This commit is contained in:
1 parent
4ab20a2838
commit
bd6e5bbf7f
18 files changed
+1346
-27
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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.');
|
||||
|
||||
Reference in new issue
Block a user