Support early Truthtrance cache bid commitments
This commit is contained in:
1 parent
ee3e1a0ab2
commit
b64b732e50
16 files changed
+795
-5
No files matched your search
@@ -1393,6 +1393,10 @@ function policyActions(g: GameView): Action[] {
|
||||
const answer = g.truthRicheseBidAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'earlyRicheseBid') {
|
||||
const answer = g.truthEarlyRicheseBidAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'phaseBid') {
|
||||
const answer = g.truthPhaseBidAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { RicheseBidPromise } from './richese-bid-promises';
|
||||
|
||||
/** A single bid in the first actual cache auction of this turn, before its
|
||||
* card, method, direction and public event have been chosen. */
|
||||
export type EarlyRicheseBidClaim = { turn: number; minimum: number };
|
||||
export type EarlyRicheseBidPromise = EarlyRicheseBidClaim & {
|
||||
id: string;
|
||||
player: string;
|
||||
asker: string;
|
||||
answer: boolean;
|
||||
boundEvent?: string;
|
||||
fulfilled?: boolean;
|
||||
released?: boolean;
|
||||
};
|
||||
export type EarlyRicheseBidChange = 'voluntary' | 'external';
|
||||
export type EarlyRicheseBidWitness = {
|
||||
/** Whether the original cache auction may still occur this turn. */
|
||||
cachePending: boolean;
|
||||
/** Whether the respondent has an engine-verified funded opportunity. */
|
||||
canBid: boolean;
|
||||
/** Maximum funded bid, not a forecast of another player's hidden action. */
|
||||
maximum: number;
|
||||
change: EarlyRicheseBidChange;
|
||||
};
|
||||
export class EarlyRicheseBidError extends Error {}
|
||||
|
||||
const positive = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const validMaximum = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
|
||||
export function parseEarlyRicheseBidClaim(value: unknown): EarlyRicheseBidClaim {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new EarlyRicheseBidError('Choose a future cache auction bid question.');
|
||||
const input = value as Record<string, unknown>;
|
||||
if (!positive(input.turn) || !positive(input.minimum))
|
||||
throw new EarlyRicheseBidError('Choose the current turn and a positive whole bid minimum.');
|
||||
return { turn: input.turn, minimum: input.minimum };
|
||||
}
|
||||
|
||||
export function earlyRicheseBidClaimText(claim: EarlyRicheseBidClaim): string {
|
||||
return `place a bid of at least ${claim.minimum} spice in this turn's first Richese cache auction`;
|
||||
}
|
||||
|
||||
/** Bound receipts have been transferred to the real lot and are no longer
|
||||
* waiting; the normal Richese promise ledger owns their subsequent outcome. */
|
||||
export function liveEarlyRicheseBidPromises(
|
||||
promises: readonly EarlyRicheseBidPromise[], player: string, turn: number,
|
||||
): EarlyRicheseBidPromise[] {
|
||||
return promises.filter(row => row.player === player && row.turn === turn &&
|
||||
!row.boundEvent && !row.fulfilled && !row.released);
|
||||
}
|
||||
|
||||
function checkPeople(player: string, asker: string): void {
|
||||
if (!identifier(player) || !identifier(asker) || player === asker)
|
||||
throw new EarlyRicheseBidError('Choose different seated asker and respondent identities.');
|
||||
}
|
||||
function checkMaximum(maximum: number): void {
|
||||
if (!validMaximum(maximum)) throw new EarlyRicheseBidError('Retain the funded cache bid maximum.');
|
||||
}
|
||||
|
||||
/** Return truthfully compatible definite answers, using only a native maximum
|
||||
* independently verified by the engine. It may pass zero when a Yes witness
|
||||
* cannot be established. All earlier waiting answers remain jointly feasible. */
|
||||
export function earlyRicheseBidAnswerOptions(
|
||||
promises: readonly EarlyRicheseBidPromise[], claim: EarlyRicheseBidClaim,
|
||||
player: string, asker: string, maximum: number,
|
||||
): boolean[] {
|
||||
parseEarlyRicheseBidClaim(claim);
|
||||
checkPeople(player, asker);
|
||||
checkMaximum(maximum);
|
||||
const live = liveEarlyRicheseBidPromises(promises, player, claim.turn);
|
||||
return [true, false].filter(answer => {
|
||||
const rows = [...live, { ...claim, answer }];
|
||||
const required = rows.filter(row => row.answer);
|
||||
if (!required.length) return true;
|
||||
const lower = Math.max(...required.map(row => row.minimum));
|
||||
const upper = Math.min(maximum, ...rows.filter(row => !row.answer).map(row => row.minimum - 1));
|
||||
return lower <= upper;
|
||||
});
|
||||
}
|
||||
|
||||
export function bindEarlyRicheseBid(
|
||||
promises: readonly EarlyRicheseBidPromise[], claim: EarlyRicheseBidClaim,
|
||||
player: string, asker: string, answer: boolean, id: string, maximum: number,
|
||||
): EarlyRicheseBidPromise[] {
|
||||
if (!identifier(id) || promises.some(row => row.id === id) || typeof answer !== 'boolean')
|
||||
throw new EarlyRicheseBidError('Retain a distinct receipt and definite answer.');
|
||||
if (!earlyRicheseBidAnswerOptions(promises, claim, player, asker, maximum).includes(answer))
|
||||
throw new EarlyRicheseBidError('Choose an answer compatible with the funded cache bid and existing answers.');
|
||||
return [...promises, { ...parseEarlyRicheseBidClaim(claim), id, player, asker, answer }];
|
||||
}
|
||||
|
||||
/** Called only for creation of the first physical cache lot, before that lot
|
||||
* can auto-settle. No card identity or method is stored in the early receipt. */
|
||||
export function attachEarlyRicheseBids(
|
||||
promises: readonly EarlyRicheseBidPromise[], turn: number, event: string,
|
||||
): { promises: EarlyRicheseBidPromise[]; bound: RicheseBidPromise[] } {
|
||||
if (!positive(turn) || !identifier(event))
|
||||
throw new EarlyRicheseBidError('Retain the current turn and real cache auction event.');
|
||||
const pending = promises.filter(row => row.turn === turn && !row.boundEvent && !row.fulfilled && !row.released);
|
||||
const bound = pending.map(({ id, player, asker, answer, minimum }) =>
|
||||
({ id, player, asker, answer, turn, event, minimum }));
|
||||
return { promises: promises.map(row => pending.includes(row) ? { ...row, boundEvent: event } : row), bound };
|
||||
}
|
||||
|
||||
/** The caller supplies a complete current witness. An actor cannot voluntarily
|
||||
* eliminate their own last funded Yes; an external event releases only the
|
||||
* answers it makes impossible. No closes only when the cache is unavailable. */
|
||||
export function reconcileEarlyRicheseBids(
|
||||
promises: readonly EarlyRicheseBidPromise[], player: string, turn: number,
|
||||
witness: EarlyRicheseBidWitness,
|
||||
): EarlyRicheseBidPromise[] {
|
||||
if (!identifier(player) || !positive(turn) || !witness ||
|
||||
typeof witness.cachePending !== 'boolean' || typeof witness.canBid !== 'boolean' ||
|
||||
!validMaximum(witness.maximum) ||
|
||||
(witness.change !== 'voluntary' && witness.change !== 'external'))
|
||||
throw new EarlyRicheseBidError('Retain the cache auction witness and the cause of the change.');
|
||||
const live = liveEarlyRicheseBidPromises(promises, player, turn);
|
||||
const noCap = Math.min(witness.maximum, ...live.filter(row => !row.answer).map(row => row.minimum - 1));
|
||||
const impossible = live.filter(row => row.answer &&
|
||||
(!witness.cachePending || !witness.canBid || row.minimum > noCap));
|
||||
if (impossible.length && witness.change === 'voluntary')
|
||||
throw new EarlyRicheseBidError('This action would remove your last funded cache bid opportunity.');
|
||||
return promises.map(row => {
|
||||
if (!live.includes(row)) return row;
|
||||
if (impossible.includes(row)) return { ...row, released: true };
|
||||
if (!witness.cachePending && !row.answer) return { ...row, fulfilled: true };
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateEarlyRicheseBidPromises(
|
||||
promises: readonly EarlyRicheseBidPromise[], players: readonly string[],
|
||||
): void {
|
||||
if (!Array.isArray(promises) || !Array.isArray(players))
|
||||
throw new EarlyRicheseBidError('The early cache bid records are invalid.');
|
||||
const ids = new Set<string>();
|
||||
for (const row of promises) {
|
||||
parseEarlyRicheseBidClaim(row);
|
||||
if (!identifier(row.id) || ids.has(row.id) || !players.includes(row.player) ||
|
||||
!players.includes(row.asker) || row.player === row.asker || typeof row.answer !== 'boolean' ||
|
||||
(row.boundEvent !== undefined && !identifier(row.boundEvent)) ||
|
||||
(row.fulfilled !== undefined && typeof row.fulfilled !== 'boolean') ||
|
||||
(row.released !== undefined && typeof row.released !== 'boolean') ||
|
||||
(row.fulfilled && row.released) || (row.boundEvent && (row.fulfilled || row.released)))
|
||||
throw new EarlyRicheseBidError('The early cache bid receipt lost its identity, participants or state.');
|
||||
ids.add(row.id);
|
||||
}
|
||||
}
|
||||
+91
-2
@@ -19,6 +19,9 @@ import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, val
|
||||
import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid,
|
||||
liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises,
|
||||
type RicheseBidClaim, type RicheseBidPromise, type RicheseBidOpportunity } from './richese-bid-promises';
|
||||
import { EarlyRicheseBidError, earlyRicheseBidAnswerOptions, bindEarlyRicheseBid,
|
||||
attachEarlyRicheseBids, liveEarlyRicheseBidPromises, reconcileEarlyRicheseBids,
|
||||
validateEarlyRicheseBidPromises, type EarlyRicheseBidClaim, type EarlyRicheseBidPromise } from './early-richese-bid';
|
||||
import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
|
||||
type AuctionClock, type AuctionClockView } from './auction-clock';
|
||||
import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
|
||||
@@ -2323,6 +2326,7 @@ export type Game = {
|
||||
bidPromises?: BidPromise[];
|
||||
phaseBidPromises?: PhaseBidPromise[];
|
||||
richeseBidPromises?: RicheseBidPromise[];
|
||||
earlyRicheseBidPromises?: EarlyRicheseBidPromise[];
|
||||
/** Raw mixed question linked to one private native current-lot obligation. */
|
||||
mixedBidQuestions?: (MixedBidClaim & {
|
||||
event: string; player: string; asker: string; answer: boolean; binding: boolean;
|
||||
@@ -14985,6 +14989,11 @@ function beginRicheseLot(
|
||||
tieOrder: g.order,
|
||||
});
|
||||
g.richeseAuction = lot;
|
||||
if (source === 'cache' && g.earlyRicheseBidPromises?.length) {
|
||||
const attached = earlyRicheseBidRule(() => attachEarlyRicheseBids(g.earlyRicheseBidPromises!, g.turn, lot.event));
|
||||
g.earlyRicheseBidPromises = attached.promises;
|
||||
(g.richeseBidPromises ??= []).push(...attached.bound);
|
||||
}
|
||||
g.richeseOfferedCard = structuredClone(card);
|
||||
g.richeseFunding = {};
|
||||
g.richesePeekKnown = false;
|
||||
@@ -16596,6 +16605,67 @@ function projectedCharityTruthGuide(g: Game,player: string): {action:Action} | n
|
||||
function richeseBidRule<T>(run: () => T): T {
|
||||
try { return run(); } catch (error) { if (error instanceof RicheseBidPromiseError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function earlyRicheseBidRule<T>(run: () => T): T {
|
||||
try { return run(); } catch (error) { if (error instanceof EarlyRicheseBidError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function earlyRicheseBidWindow(g: Game): boolean {
|
||||
return bidPromiseModeSupported({ ...g, semutaPreview: undefined }) && !g.kullPreview && !g.advanced &&
|
||||
g.status === 'playing' && g.phase >= 0 && g.phase <= 2 && !!byFaction(g, 'richese') &&
|
||||
(g.richeseCache?.length ?? 0) > 0 && !g.response && !g.decision && !g.phaseOpening &&
|
||||
!g.pendingKarama && !g.pendingTreacheryDiscard;
|
||||
}
|
||||
/** A conservative witness uses the respondent's current uncommitted spice;
|
||||
* it does not assume an ally's future aid or an opponent's future bid. */
|
||||
function earlyRicheseBidMaximum(g: Game, p: Player): number {
|
||||
// A leading ordinary bid may become a real purchase when the other players
|
||||
// pass. Reserve its physical hand slot, including Harkonnen's bonus draw.
|
||||
const pendingPurchase = g.auction?.bidder === p.id && !g.currentAuctionSale
|
||||
? 1 + Number(p.faction === 'harkonnen') : 0;
|
||||
if (p.hand.length + pendingPurchase >= handLimit(p)) return 0;
|
||||
const amount = uncommittedSpice(g,p);
|
||||
return Number.isSafeInteger(amount) ? Math.max(0,amount) : 0;
|
||||
}
|
||||
function earlyRicheseBidTruthAnswers(g: Game,p: Player,claim: EarlyRicheseBidClaim): TruthAnswer[] {
|
||||
if (!earlyRicheseBidWindow(g) || claim.turn !== g.turn) return [];
|
||||
if (richeseBidOtherPromises(g,p)) return ['unknown'];
|
||||
const asker = g.truthtrance?.queue[0]?.player ?? g.players.find(row => row.id !== p.id)?.id ?? g.host;
|
||||
const answers = earlyRicheseBidRule(() => earlyRicheseBidAnswerOptions(g.earlyRicheseBidPromises ?? [],claim,p.id,asker,
|
||||
earlyRicheseBidMaximum(g,p))).map(answer => answer ? 'yes' as const : 'no' as const);
|
||||
return answers.includes('yes') ? answers : [...answers,'unknown'];
|
||||
}
|
||||
function bindEarlyRicheseBidTruth(g: Game,p: Player,claim: EarlyRicheseBidClaim,answer: boolean,asker: string): void {
|
||||
requireRule(earlyRicheseBidTruthAnswers(g,p,claim).includes(answer ? 'yes' : 'no'),
|
||||
'This early cache bid answer no longer has a funded completion.');
|
||||
g.earlyRicheseBidPromises = earlyRicheseBidRule(() => bindEarlyRicheseBid(g.earlyRicheseBidPromises ?? [],claim,
|
||||
p.id,asker,answer,crypto.randomUUID(),earlyRicheseBidMaximum(g,p)));
|
||||
}
|
||||
function reconcileEarlyRicheseTruth(g: Game,voluntary?: {actor:string;action:Action}): void {
|
||||
earlyRicheseBidRule(() => validateEarlyRicheseBidPromises(g.earlyRicheseBidPromises ?? [],g.players.map(p => p.id)));
|
||||
if (!g.earlyRicheseBidPromises?.length) return;
|
||||
const pending = !!(g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard);
|
||||
if (pending && !voluntary) return;
|
||||
for (const p of g.players) {
|
||||
// While a response is unsettled, preflight the voluntary actor only. Other
|
||||
// seats wait for the settled continuation, so temporary reservations do
|
||||
// not prematurely release their commitments.
|
||||
if (pending && voluntary?.actor !== p.id) continue;
|
||||
const turns: Set<number> = new Set((g.earlyRicheseBidPromises ?? []).filter(row => row.player === p.id && !row.boundEvent &&
|
||||
!row.fulfilled && !row.released).map(row => row.turn));
|
||||
for (const turn of turns) {
|
||||
const round = g.richeseBidding;
|
||||
const cachePending = g.status === 'playing' && g.turn === turn && g.phase <= 3 &&
|
||||
(g.richeseCache?.length ?? 0) > 0 &&
|
||||
(g.phase < 3 || !round || round.turn !== turn || !round.cacheCanceled && round.stage !== 'complete');
|
||||
const before: EarlyRicheseBidPromise[] = g.earlyRicheseBidPromises ?? [];
|
||||
g.earlyRicheseBidPromises = earlyRicheseBidRule((): EarlyRicheseBidPromise[] => reconcileEarlyRicheseBids(before,p.id,turn,{
|
||||
cachePending,canBid:p.hand.length < handLimit(p),maximum:earlyRicheseBidMaximum(g,p),
|
||||
change:voluntary?.actor === p.id ? 'voluntary' : 'external',
|
||||
}));
|
||||
if (g.earlyRicheseBidPromises.some((row,index) => row.player === p.id && row.released && !before[index]?.released))
|
||||
log(g, `${p.name} can no longer fulfill the early Richese cache bid answer; it is no longer binding.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function richeseBidWindow(g: Game): boolean {
|
||||
return bidPromiseModeSupported({ ...g, semutaPreview: undefined }) && g.status === 'playing' && g.phase === 3 &&
|
||||
!!g.richeseAuction && ['onceAround','silent'].includes(g.richeseAuction.method) && !g.richeseAuction.outcome &&
|
||||
@@ -16628,7 +16698,8 @@ function richeseBidOtherPromises(g: Game, p: Player): boolean {
|
||||
* admission, not later when neither promised action can be completed. */
|
||||
function richeseBidCompatibleLaterPromises(g: Game,p: Player): boolean {
|
||||
const lot = g.richeseAuction;
|
||||
if (!lot) return true;
|
||||
if (!lot) return !liveEarlyRicheseBidPromises(g.earlyRicheseBidPromises ?? [],p.id,g.turn).some(row => row.answer) ||
|
||||
!richeseBidOtherPromises(g,p);
|
||||
const context = {turn:g.turn,event:lot.event,player:p.id};
|
||||
return !richeseBidRequired(g.richeseBidPromises ?? [],context) || !richeseBidOtherPromises(g,p);
|
||||
}
|
||||
@@ -16664,6 +16735,12 @@ function reconcileRicheseBids(g: Game,voluntary?: {actor:string;action:Action})
|
||||
const context = {turn:row.turn,event:row.event,player:row.player};
|
||||
const change = voluntary?.actor === row.player ? 'voluntary' : 'external';
|
||||
const lot = g.richeseAuction;
|
||||
// A waiting cache promise may attach before the owner can act in Once
|
||||
// Around. Preserve it while other seats still have their bid opportunity;
|
||||
// if everyone passes, the native close releases the impossible Yes.
|
||||
if (lot?.event === row.event && lot.method === 'onceAround' && lot.owner === row.player &&
|
||||
lot.active !== row.player && !lot.bidder &&
|
||||
g.earlyRicheseBidPromises?.some(early => early.id === row.id && early.boundEvent === lot.event)) continue;
|
||||
if (g.turn !== row.turn || !lot || lot.event !== row.event || lot.outcome)
|
||||
g.richeseBidPromises = richeseBidRule(() => finishRicheseBidLot(g.richeseBidPromises!,context,change));
|
||||
else g.richeseBidPromises = richeseBidRule(() => reconcileRicheseBidPromises(g.richeseBidPromises!,context,
|
||||
@@ -16881,7 +16958,8 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
|
||||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length ||
|
||||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length ||
|
||||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length ||
|
||||
hasBribePromises(g, p.id)) {
|
||||
hasBribePromises(g, p.id) ||
|
||||
liveEarlyRicheseBidPromises(g.earlyRicheseBidPromises ?? [], p.id, g.turn).some(row => row.answer)) {
|
||||
const trial = structuredClone(g);
|
||||
trial.truthtrance = null;
|
||||
trial.auction!.bid = action.amount as number;
|
||||
@@ -16895,6 +16973,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
|
||||
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
|
||||
reconcileRevivalPromises(trial, { actor: p.id, action });
|
||||
reconcileBribePromises(trial, { actor: p.id, action });
|
||||
reconcileEarlyRicheseTruth(trial, { actor: p.id, action });
|
||||
}
|
||||
return action;
|
||||
}
|
||||
@@ -40327,6 +40406,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
reconcileTravelPromises(g, promiseActor, action, state.phase);
|
||||
reconcileBidPromises(g, voluntary);
|
||||
reconcilePhaseBids(g, voluntary);
|
||||
reconcileEarlyRicheseTruth(g, voluntary);
|
||||
reconcileRicheseBids(g, voluntary);
|
||||
reconcileCharityTruth(g, voluntary);
|
||||
reconcileRevivalPromises(g, voluntary);
|
||||
@@ -40454,6 +40534,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
|
||||
reconcileBidPromises(g, { actor: id, action });
|
||||
reconcilePhaseBids(g, { actor: id, action });
|
||||
reconcileEarlyRicheseTruth(g, { actor: id, action });
|
||||
reconcileRicheseBids(g, { actor: id, action });
|
||||
reconcileCharityTruth(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
@@ -40688,6 +40769,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileBidPromises(g);
|
||||
reconcilePhaseBids(g);
|
||||
reconcileEarlyRicheseTruth(g);
|
||||
reconcileRicheseBids(g);
|
||||
reconcileCharityTruth(g);
|
||||
reconcileRevivalPromises(g);
|
||||
@@ -40804,6 +40886,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileBidPromises(g);
|
||||
reconcilePhaseBids(g);
|
||||
reconcileEarlyRicheseTruth(g);
|
||||
reconcileRicheseBids(g);
|
||||
reconcileCharityTruth(g);
|
||||
reconcileRevivalPromises(g);
|
||||
@@ -41093,6 +41176,8 @@ function applyActionInner(
|
||||
bindCharity: bindCharityTruth,
|
||||
richeseBidAnswers: richeseBidTruthAnswers,
|
||||
bindRicheseBid: bindRicheseBidTruth,
|
||||
earlyRicheseBidAnswers: earlyRicheseBidTruthAnswers,
|
||||
bindEarlyRicheseBid: bindEarlyRicheseBidTruth,
|
||||
phaseBidAnswers: phaseBidTruthAnswers,
|
||||
bindPhaseBid: bindPhaseBidTruth,
|
||||
turnActionAnswers: turnActionTruthAnswers,
|
||||
@@ -45263,6 +45348,10 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
}).map(player => player.id) : [],
|
||||
truthRicheseBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'richeseBid' ? richeseBidTruthAnswers(g,me,g.truthtrance.question) : null,
|
||||
truthEarlyRicheseBidTargets: earlyRicheseBidWindow(g) ? g.players.map(player => player.id) : [],
|
||||
truthEarlyRicheseBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'earlyRicheseBid' ? earlyRicheseBidTruthAnswers(g,me,g.truthtrance.question) : null,
|
||||
ownEarlyRicheseBidPromises: (g.earlyRicheseBidPromises ?? []).filter(row => row.player === id).map(row => ({...row})),
|
||||
richeseBidPromises: (g.richeseBidPromises ?? []).filter(row => row.player === id).map(row => ({...row})),
|
||||
richeseBidPromiseGuide: projectedRicheseBidGuide(g,id),
|
||||
truthPhaseBidTargets: phaseBidQuestionWindow(g) ? g.players.map(player => player.id) : [],
|
||||
|
||||
+4
-2
@@ -3692,12 +3692,14 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Bind a minimum bid in an ordinary lot, the remaining ordinary Bidding phase, or a Richese Once Around or Silent lot, preserving native funding and order.',
|
||||
summary: 'Bind a minimum bid in an ordinary lot, the remaining ordinary Bidding phase, a Richese lot or an upcoming Basic cache auction, preserving native funding and 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; broader compositions remain unfinished.',
|
||||
'In a quiet Richese Once Around auction, choose Bind a bid in this Once Around lot to bind the respondent’s one remaining bid or pass. A waiting Richese owner cannot promise Yes until an actual bid ensures an owner turn. Current hand space, funds and pledged aid remain required; an opposing raise can release an impossible Yes. Normal special auctions remain outside this prototype.',
|
||||
'Alternatively choose Bind a bid this Bidding phase. Yes requires one qualifying accepted ordinary bid after the answer and before Bidding closes; No forbids qualifying later ordinary bids across lots. Earlier bids do not count, and a bid does not promise a win.',
|
||||
'In a Silent Richese lot the question binds the respondent’s single sealed submission. Zero declines; a positive bid remains hidden until the native simultaneous reveal. Available answers use that bidder’s own funding, without using opponents’ concealed amounts.',
|
||||
'Before Basic Bidding, ask about a minimum bid in the first actual Richese cache auction of this turn. The answer waits for the real event and does not predict its card or method. Current uncommitted personal funds and hand space supply a conservative Yes witness; cancellation or an opposing action can release an impossible commitment. The Richese owner’s Once Around turn may vanish if everyone else passes.',
|
||||
'Your waiting cache commitment remains visible until its auction opens or it ends. Once the real lot opens, the ordinary Richese bid guide takes over.',
|
||||
'The phase-wide guide may wait for a later lot only when the existing funded sale, prepared next card and post-sale space and funds support it. An unbid lot, hypothetical rival raise, cancelable unfunded overbid or Harkonnen bonus slot is not invented as a future opportunity. Before Bidding, current funding and available card count provide a provisional forecast; special auction preparation and combined future commitments remain limited.',
|
||||
'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.',
|
||||
@@ -3708,7 +3710,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['card-truthtrance', 'bidding', 'truthtrance-shipment', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Current-lot and separate phase-wide minimum bids use native declarations, funded witnesses, joint Yes/No constraints and their own expiry. No new game-start gate.', evidence: ['game/bid-promises.ts', 'game/turn-bid-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/turn-bid-promises.test.ts', 'tests/truth-turn-bid-runtime.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Current-lot, phase-wide and early Basic Richese cache minimum bids use native declarations, funded witnesses, joint Yes/No constraints and their own expiry. No new game-start gate.', evidence: ['game/bid-promises.ts', 'game/turn-bid-promises.ts', 'game/early-richese-bid.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/turn-bid-promises.test.ts', 'tests/truth-early-richese-bid-runtime.test.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Separate editors, public previews, target-only answers and persistent event-bound native guides keep winning payments separate.', evidence: ['components/bid-claim-fields.tsx', 'components/bid-promises.tsx', 'components/phase-bid-claim-fields.tsx', 'components/phase-bid-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-phase-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'] },
|
||||
|
||||
@@ -75,6 +75,7 @@ import { parseFaceDancerFact, faceDancerFactMatches, faceDancerFactText, type Fa
|
||||
import { parseBidClaim, bidClaimText, bidPromiseModeSupported, type BidClaim } from './bid-promises';
|
||||
import { parsePhaseBidClaim, phaseBidClaimText, phaseBidPromiseModeSupported, type PhaseBidClaim } from './turn-bid-promises';
|
||||
import { parseRicheseBidClaim, richeseBidClaimText, type RicheseBidClaim } from './richese-bid-promises';
|
||||
import { parseEarlyRicheseBidClaim, earlyRicheseBidClaimText, type EarlyRicheseBidClaim } from './early-richese-bid';
|
||||
|
||||
export type TruthFact =
|
||||
| KnowledgeFact
|
||||
@@ -110,6 +111,7 @@ export type TruthQuestion =
|
||||
| ({ kind: 'bid'; target: string } & BidClaim)
|
||||
| ({ kind: 'phaseBid'; target: string } & PhaseBidClaim)
|
||||
| ({ kind: 'richeseBid'; target: string } & RicheseBidClaim)
|
||||
| ({ kind: 'earlyRicheseBid'; target: string } & EarlyRicheseBidClaim)
|
||||
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
|
||||
| ({ kind: 'movement'; target: string } & MovementClaim)
|
||||
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
|
||||
@@ -375,6 +377,17 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
'Choose a remaining native Revival opportunity this turn in a supported table and finish pending decisions.');
|
||||
return { kind: 'revival', target: v.target, ...claim };
|
||||
}
|
||||
if (v.kind === 'earlyRicheseBid') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,turn',
|
||||
'Supply the current turn, cache bid minimum and respondent.');
|
||||
const claim = parseEarlyRicheseBidClaim(v);
|
||||
check(bidPromiseModeSupported({ ...g, semutaPreview: undefined }) && !g.kullPreview && !g.advanced &&
|
||||
g.status === 'playing' && g.phase >= 0 && g.phase <= 2 && claim.turn === g.turn &&
|
||||
g.players.some(player => player.faction === 'richese') && (g.richeseCache?.length ?? 0) > 0 &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard,
|
||||
'Choose a quiet Basic turn before the first Richese cache auction.');
|
||||
return { kind: 'earlyRicheseBid', target: v.target, ...claim };
|
||||
}
|
||||
if (v.kind === 'richeseBid') {
|
||||
check(Object.keys(v).sort().join(',') === 'event,kind,minimum,target,turn',
|
||||
'Choose the current Richese lot and bid minimum.');
|
||||
@@ -773,6 +786,7 @@ export function truthQuestionText(
|
||||
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
|
||||
if (q.kind === 'phaseBid') return `Will you ${phaseBidClaimText(q)}?`;
|
||||
if (q.kind === 'richeseBid') return `Will you ${richeseBidClaimText(q)}?`;
|
||||
if (q.kind === 'earlyRicheseBid') return `Will you ${earlyRicheseBidClaimText(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 === 'movementExpression') return `Will you ${movementExpressionText(q.claim)} before your Shipment and Movement turn ends?`;
|
||||
if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
|
||||
@@ -912,6 +926,8 @@ export function resolveTruthAction(
|
||||
bindCharity?: (g: Game, p: Player, turn: number, answer: boolean, asker: string) => void;
|
||||
richeseBidAnswers?: (g: Game, p: Player, claim: RicheseBidClaim) => TruthAnswer[];
|
||||
bindRicheseBid?: (g: Game, p: Player, claim: RicheseBidClaim, answer: boolean, asker: string) => void;
|
||||
earlyRicheseBidAnswers?: (g: Game, p: Player, claim: EarlyRicheseBidClaim) => TruthAnswer[];
|
||||
bindEarlyRicheseBid?: (g: Game, p: Player, claim: EarlyRicheseBidClaim, answer: boolean, asker: string) => void;
|
||||
phaseBidAnswers?: (g: Game, p: Player, claim: PhaseBidClaim) => TruthAnswer[];
|
||||
bindPhaseBid?: (g: Game, p: Player, claim: PhaseBidClaim, answer: boolean, asker: string) => void;
|
||||
travelAnswers?: (g: Game, p: Player, claim: TravelClaim) => TruthAnswer[];
|
||||
@@ -1104,6 +1120,9 @@ export function resolveTruthAction(
|
||||
if (w.question.kind === 'richeseBid')
|
||||
check(effects.richeseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a bidder with a remaining Richese auction opportunity.');
|
||||
if (w.question.kind === 'earlyRicheseBid')
|
||||
check(effects.earlyRicheseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a bidder with a compatible future cache auction opportunity.');
|
||||
if (w.question.kind === 'phaseBid')
|
||||
check(effects.phaseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a phase bid question compatible with supported native opportunities and earlier answers.');
|
||||
@@ -1247,6 +1266,11 @@ export function resolveTruthAction(
|
||||
'Choose an answer consistent with this Richese lot and your legal bid.');
|
||||
if (a.answer !== 'unknown') effects.bindRicheseBid(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'earlyRicheseBid') {
|
||||
check(effects.earlyRicheseBidAnswers && effects.bindEarlyRicheseBid && effects.earlyRicheseBidAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer compatible with your future cache auction opportunity.');
|
||||
if (a.answer !== 'unknown') effects.bindEarlyRicheseBid(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'phaseBid') {
|
||||
check(effects.phaseBidAnswers && effects.bindPhaseBid && effects.phaseBidAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer consistent with native bids and earlier phase commitments.');
|
||||
|
||||
Reference in new issue
Block a user