Support Truthtrance in Richese Once Around bidding
This commit is contained in:
1 parent
65a1936b94
commit
090d764f6f
18 files changed
+1003
-7
No files matched your search
@@ -1319,6 +1319,7 @@ function policyActions(g: GameView): Action[] {
|
||||
if (g.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action];
|
||||
if (g.leaderRevivalPromiseGuide) return [g.leaderRevivalPromiseGuide.action];
|
||||
if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action];
|
||||
if (g.richeseBidPromiseGuide) return [g.richeseBidPromiseGuide.action];
|
||||
if (g.phaseBidPromiseGuide) return [g.phaseBidPromiseGuide.action];
|
||||
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
|
||||
if (g.truthtrance) {
|
||||
@@ -1376,6 +1377,10 @@ function policyActions(g: GameView): Action[] {
|
||||
const answer = g.truthBidAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'richeseBid') {
|
||||
const answer = g.truthRicheseBidAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'phaseBid') {
|
||||
const answer = g.truthPhaseBidAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
|
||||
+121
-3
@@ -1,3 +1,6 @@
|
||||
import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid,
|
||||
liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises,
|
||||
type RicheseBidClaim, type RicheseBidPromise, type RicheseBidOpportunity } from './richese-bid-promises';
|
||||
import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
|
||||
type AuctionClock, type AuctionClockView } from './auction-clock';
|
||||
import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
|
||||
@@ -2248,6 +2251,7 @@ export type Game = {
|
||||
shipmentPromises?: ShipmentPromise[];
|
||||
bidPromises?: BidPromise[];
|
||||
phaseBidPromises?: PhaseBidPromise[];
|
||||
richeseBidPromises?: RicheseBidPromise[];
|
||||
/** Raw mixed question linked to one private native current-lot obligation. */
|
||||
mixedBidQuestions?: (MixedBidClaim & {
|
||||
event: string; player: string; asker: string; answer: boolean; binding: boolean;
|
||||
@@ -15661,7 +15665,8 @@ function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no'
|
||||
const promises = [...(g.bribePromises ?? []), { ...claim, event: 'candidate', player: p.id,
|
||||
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: 0 }];
|
||||
return !!bribePromiseCompletion(source, p, promises) &&
|
||||
phaseBidCompatibleBribeAnswer({ ...source, bribePromises: promises }, p);
|
||||
phaseBidCompatibleBribeAnswer({ ...source, bribePromises: promises }, p) &&
|
||||
richeseBidCompatibleLaterPromises({ ...source, bribePromises: promises },p);
|
||||
});
|
||||
}
|
||||
function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionClaim): ('yes' | 'no' | 'unknown')[] {
|
||||
@@ -15672,7 +15677,8 @@ function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionC
|
||||
const expressions = [...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id,
|
||||
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: [] }];
|
||||
return !!bribePromiseCompletion(source, p, g.bribePromises ?? [], expressions) &&
|
||||
phaseBidCompatibleBribeAnswer({ ...source, bribeExpressionPromises: expressions }, p);
|
||||
phaseBidCompatibleBribeAnswer({ ...source, bribeExpressionPromises: expressions }, p) &&
|
||||
richeseBidCompatibleLaterPromises({ ...source, bribeExpressionPromises: expressions },p);
|
||||
});
|
||||
}
|
||||
function reopenBribePayer(g: Game, p: Player, answer: boolean) {
|
||||
@@ -16071,6 +16077,91 @@ function phaseBidWitnesses(g: Game, p: Player): PhaseBidOpportunity[] {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function richeseBidRule<T>(run: () => T): T {
|
||||
try { return run(); } catch (error) { if (error instanceof RicheseBidPromiseError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function richeseBidWindow(g: Game): boolean {
|
||||
return bidPromiseModeSupported({ ...g, semutaPreview: undefined }) && g.status === 'playing' && g.phase === 3 &&
|
||||
!!g.richeseAuction && g.richeseAuction.method === 'onceAround' && !g.richeseAuction.outcome &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard;
|
||||
}
|
||||
function richeseBidOpportunity(g: Game, p: Player): RicheseBidOpportunity | null {
|
||||
const lot = g.richeseAuction;
|
||||
if (!lot || !richeseBidWindow(g) || !lot.eligible.includes(p.id) || lot.acted.includes(p.id) ||
|
||||
!richeseBidFitsHand(lot.source,lot.owner,p.id,p.hand.length,handLimit(p)) ||
|
||||
(p.id === lot.owner && lot.active !== p.id && !lot.bidder) || lot.bid >= Number.MAX_SAFE_INTEGER) return null;
|
||||
const own = uncommittedSpice(g,p) + richeseOwnCommitment(lot,g.richeseFunding ?? {},p.id);
|
||||
const max = Math.min(Number.MAX_SAFE_INTEGER,own + (aidFor(g,p)?.amount ?? 0));
|
||||
if (!Number.isSafeInteger(max) || max < lot.bid + 1) return null;
|
||||
return {turn:g.turn,event:lot.event,player:p.id,minimum:lot.bid + 1,maximum:max};
|
||||
}
|
||||
function richeseBidOtherPromises(g: Game, p: Player): boolean {
|
||||
return liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn).some(row => row.answer) ||
|
||||
hasMovementPromises(g,p.id,true) || liveRevivalPromises(g.revivalPromises ?? [],p.id,g.turn).some(row => row.answer) ||
|
||||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [],p.id,g.turn).some(row => row.answer) ||
|
||||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [],p.id,g.turn).some(row => row.answer) ||
|
||||
hasBribePromises(g,p.id,true) || liveTurnActionPromises(g.turnActionPromises ?? [],p.id,g.turn).length > 0 ||
|
||||
livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn).some(row => row.answer);
|
||||
}
|
||||
/** Other financial/action answers need a joint planner before they can
|
||||
* coexist with a live Once Around Yes. Reject the unsupported combination at
|
||||
* 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;
|
||||
const context = {turn:g.turn,event:lot.event,player:p.id};
|
||||
return !richeseBidRequired(g.richeseBidPromises ?? [],context) || !richeseBidOtherPromises(g,p);
|
||||
}
|
||||
function richeseBidTruthAnswers(g: Game, p: Player, claim: RicheseBidClaim): TruthAnswer[] {
|
||||
const lot = g.richeseAuction;
|
||||
if (!richeseBidWindow(g) || !lot || claim.turn !== g.turn || claim.event !== lot.event ||
|
||||
!lot.eligible.includes(p.id) || lot.acted.includes(p.id) ||
|
||||
!richeseBidFitsHand(lot.source,lot.owner,p.id,p.hand.length,handLimit(p))) return [];
|
||||
const base = g.richeseBidPromises ?? [], opportunity = richeseBidOpportunity(g,p);
|
||||
const context = {turn:g.turn,event:lot.event,player:p.id};
|
||||
const asker = g.truthtrance?.queue[0]?.player ?? g.players.find(row => row.id !== p.id)?.id ?? g.host;
|
||||
return (['yes','no'] as const).filter(answer => {
|
||||
const candidate: RicheseBidPromise = {...claim,id:'candidate',player:p.id,asker,answer:answer === 'yes'};
|
||||
const all = [...base,candidate];
|
||||
if (answer === 'yes' && richeseBidOtherPromises(g,p)) return false;
|
||||
return !richeseBidRequired(all,context) || !!opportunity && !!richeseBidCompletionRange(all,opportunity);
|
||||
});
|
||||
}
|
||||
function bindRicheseBidTruth(g: Game,p: Player,claim: RicheseBidClaim,answer: boolean,asker: string) {
|
||||
requireRule(richeseBidTruthAnswers(g,p,claim).includes(answer ? 'yes' : 'no'),
|
||||
'Choose an answer compatible with this Once Around bid.');
|
||||
(g.richeseBidPromises ??= []).push({...claim,id:crypto.randomUUID(),player:p.id,asker,answer});
|
||||
}
|
||||
function richeseBidIntegrity(g: Game) {
|
||||
richeseBidRule(() => validateRicheseBidPromises(g.richeseBidPromises ?? [],g.players.map(row => row.id)));
|
||||
}
|
||||
function reconcileRicheseBids(g: Game,voluntary?: {actor:string;action:Action}) {
|
||||
richeseBidIntegrity(g);
|
||||
if (!g.richeseBidPromises?.length || g.truthtrance || g.response || g.decision || g.phaseOpening ||
|
||||
g.pendingKarama || g.pendingTreacheryDiscard) return;
|
||||
for (const row of g.richeseBidPromises) {
|
||||
if (row.fulfilled || row.released) continue;
|
||||
const context = {turn:row.turn,event:row.event,player:row.player};
|
||||
const change = voluntary?.actor === row.player ? 'voluntary' : 'external';
|
||||
const lot = g.richeseAuction;
|
||||
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,
|
||||
richeseBidOpportunity(g,getPlayer(g,row.player)),change));
|
||||
}
|
||||
}
|
||||
function projectedRicheseBidGuide(g: Game, player: string): {action:Action} | null {
|
||||
const lot = g.richeseAuction, p = getPlayer(g,player);
|
||||
if (!lot || lot.active !== player || g.truthtrance || !richeseBidWindow(g)) return null;
|
||||
const context = {turn:g.turn,event:lot.event,player}, live = liveRicheseBidPromises(g.richeseBidPromises ?? [],context);
|
||||
if (!live.length) return null;
|
||||
if (!richeseBidRequired(live,context)) return {action:{type:'richeseBid',event:lot.event,amount:null,allyPayment:0,richeseBidPromiseId:live[0].id}};
|
||||
const opportunity = richeseBidOpportunity(g,p), range = opportunity && richeseBidCompletionRange(live,opportunity);
|
||||
if (!range) return null;
|
||||
const own = uncommittedSpice(g,p) + richeseOwnCommitment(lot,g.richeseFunding ?? {},player);
|
||||
return {action:{type:'richeseBid',event:lot.event,amount:range.minimum,
|
||||
allyPayment:Math.max(0,range.minimum-own),richeseBidPromiseId:live[0].id}};
|
||||
}
|
||||
function phaseBidQuestionWindow(g: Game): boolean {
|
||||
return g.status === 'playing' && g.phase >= 0 && g.phase <= 3 && phaseBidPromiseModeSupported(g) &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
|
||||
@@ -16159,6 +16250,7 @@ function viablePhaseBidWitnesses(g: Game, p: Player, promises: readonly PhaseBid
|
||||
* bid impossible. If the future joint planner has no witness, a definite
|
||||
* answer is withheld rather than offered and rejected after binding. */
|
||||
function phaseBidCompatibleLaterPromises(g: Game, p: Player): boolean {
|
||||
if (!richeseBidCompatibleLaterPromises(g,p)) return false;
|
||||
const live = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn);
|
||||
if (!live.some(row => row.answer)) return true;
|
||||
return viablePhaseBidWitnesses(g,p,g.phaseBidPromises ?? [])
|
||||
@@ -20695,6 +20787,7 @@ function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): Tru
|
||||
if (promise.compiled.kind === 'constant' && promise.compiled.value !== answer) continue;
|
||||
const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards, { claim, answer }),
|
||||
turnActionPromises: [...(g.turnActionPromises ?? []), promise], travelPromiseOrder: undefined };
|
||||
if (!richeseBidCompatibleLaterPromises(trial,p) && promise.compiled.kind !== 'constant') continue;
|
||||
const phases = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn);
|
||||
const lot = bidOpportunity(trial);
|
||||
if (phases.length && lot) {
|
||||
@@ -38937,6 +39030,7 @@ function normalizeCardNames(g: Game) {
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
bidPromiseIntegrity(state);
|
||||
phaseBidIntegrity(state);
|
||||
richeseBidIntegrity(state);
|
||||
revivalPromiseIntegrity(state);
|
||||
bribePromiseIntegrity(state);
|
||||
alliancePromiseIntegrity(state);
|
||||
@@ -39109,6 +39203,12 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
|
||||
'This phase bid guide is stale or belongs to another auction lot.');
|
||||
}
|
||||
if (action.richeseBidPromiseId !== undefined) {
|
||||
const guide = projectedRicheseBidGuide(state,id);
|
||||
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
|
||||
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
|
||||
'This Once Around bid guide is stale or belongs to another lot.');
|
||||
}
|
||||
const g = applyActionInner(state, id, action);
|
||||
if (state.phase === 3 && liveTurnActionPromises(state.turnActionPromises ?? [], id, state.turn).some(row => {
|
||||
const stage = row.windowCards?.karamaAuction;
|
||||
@@ -39123,6 +39223,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
reconcileTravelPromises(g, promiseActor, action, state.phase);
|
||||
reconcileBidPromises(g, voluntary);
|
||||
reconcilePhaseBids(g, voluntary);
|
||||
reconcileRicheseBids(g, voluntary);
|
||||
reconcileRevivalPromises(g, voluntary);
|
||||
reconcileBribePromises(g, voluntary);
|
||||
reconcileGroundMovementPromises(g, promiseActor, action, state.phase);
|
||||
@@ -39248,6 +39349,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
|
||||
reconcileBidPromises(g, { actor: id, action });
|
||||
reconcilePhaseBids(g, { actor: id, action });
|
||||
reconcileRicheseBids(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
reconcileBribePromises(g, { actor: id, action });
|
||||
reconcileAlliancePromises(g, { actor: id, action });
|
||||
@@ -39466,6 +39568,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileBidPromises(g);
|
||||
reconcilePhaseBids(g);
|
||||
reconcileRicheseBids(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileBribePromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
@@ -39484,6 +39587,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
bidPromiseIntegrity(state);
|
||||
phaseBidIntegrity(state);
|
||||
richeseBidIntegrity(state);
|
||||
revivalPromiseIntegrity(state);
|
||||
bribePromiseIntegrity(state);
|
||||
alliancePromiseIntegrity(state);
|
||||
@@ -39573,6 +39677,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileBidPromises(g);
|
||||
reconcilePhaseBids(g);
|
||||
reconcileRicheseBids(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileBribePromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
@@ -39849,6 +39954,8 @@ function applyActionInner(
|
||||
bindMixedBid: bindMixedBidTruth,
|
||||
bidAnswers: bidTruthAnswers,
|
||||
bindBid: bindBidTruth,
|
||||
richeseBidAnswers: richeseBidTruthAnswers,
|
||||
bindRicheseBid: bindRicheseBidTruth,
|
||||
phaseBidAnswers: phaseBidTruthAnswers,
|
||||
bindPhaseBid: bindPhaseBidTruth,
|
||||
turnActionAnswers: turnActionTruthAnswers,
|
||||
@@ -39868,7 +39975,7 @@ function applyActionInner(
|
||||
)
|
||||
return g;
|
||||
} catch (error) {
|
||||
if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof AllianceExpressionPromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof MixedActionQuestionError || error instanceof TraitorDecisionError)
|
||||
if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof AllianceExpressionPromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof RicheseBidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof MixedActionQuestionError || error instanceof TraitorDecisionError)
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
@@ -42325,6 +42432,9 @@ function applyActionInner(
|
||||
if (parties.richeseSaleAmount !== undefined)
|
||||
checkBureaucratContributors(g,byFaction(g,'emperor')?.id,[{payer:lot.owner,amount:allyPayment}]);
|
||||
}
|
||||
if (lot.method === 'onceAround' && g.richeseBidPromises?.length)
|
||||
g.richeseBidPromises = richeseBidRule(() => recordRicheseBid(g.richeseBidPromises!,
|
||||
{turn:g.turn,event:lot.event,player:p.id},amount));
|
||||
g.richeseAuction = submitRicheseBid(
|
||||
lot,
|
||||
{ event: stringField(action.event), actor: p.id, amount },
|
||||
@@ -43849,6 +43959,14 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
}).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,
|
||||
truthRicheseBidTargets: richeseBidWindow(g) ? g.players.filter(player => {
|
||||
const lot = g.richeseAuction!;
|
||||
return lot.eligible.includes(player.id) && !lot.acted.includes(player.id);
|
||||
}).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,
|
||||
richeseBidPromises: (g.richeseBidPromises ?? []).filter(row => row.player === id).map(row => ({...row})),
|
||||
richeseBidPromiseGuide: projectedRicheseBidGuide(g,id),
|
||||
truthPhaseBidTargets: phaseBidQuestionWindow(g) ? g.players.map(player => player.id) : [],
|
||||
truthPhaseBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'phaseBid' ? phaseBidTruthAnswers(g,me,g.truthtrance.question) : null,
|
||||
|
||||
+4
-3
@@ -3652,13 +3652,14 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-bid',
|
||||
title: 'Truthtrance: ordinary bid commitments',
|
||||
title: 'Truthtrance: bidding commitments',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Bind one future minimum ordinary bid in the current lot or remaining Bidding phase, preserving native funding, payment and turn order.',
|
||||
summary: 'Bind a minimum bid in an ordinary lot, the remaining ordinary Bidding phase, or one Richese Once Around lot, 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; special auctions and broader compositions remain unfinished.',
|
||||
'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. Silent and 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.',
|
||||
'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.',
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/** One future declaration in the exact public Richese Once Around lot.
|
||||
* The engine owns native eligibility, timing, hand capacity and funding. */
|
||||
export type RicheseBidClaim = { turn: number; event: string; minimum: number };
|
||||
export type RicheseBidPromise = RicheseBidClaim & {
|
||||
id: string;
|
||||
player: string;
|
||||
asker: string;
|
||||
answer: boolean;
|
||||
fulfilled?: boolean;
|
||||
released?: boolean;
|
||||
};
|
||||
export type RicheseBidContext = { turn: number; event: string; player: string };
|
||||
/** Inclusive actual funded interval supplied by the engine; never a forecast of
|
||||
* another player's bid or a replacement for the native quote. */
|
||||
export type RicheseBidOpportunity = RicheseBidContext & { minimum: number; maximum: number };
|
||||
export type RicheseBidRange = { minimum: number; maximum: number };
|
||||
export type RicheseBidChange = 'voluntary' | 'external';
|
||||
export class RicheseBidPromiseError 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();
|
||||
|
||||
export function parseRicheseBidClaim(value: unknown): RicheseBidClaim {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new RicheseBidPromiseError('Choose a current Once Around bid question.');
|
||||
const input = value as Record<string, unknown>;
|
||||
if (!positive(input.turn) || !identifier(input.event) || !positive(input.minimum))
|
||||
throw new RicheseBidPromiseError('Choose the current turn, auction event and a positive whole bid minimum.');
|
||||
return { turn: input.turn, event: input.event, minimum: input.minimum };
|
||||
}
|
||||
|
||||
export function richeseBidClaimText(claim: RicheseBidClaim): string {
|
||||
return `place a bid of at least ${claim.minimum} spice in this Once Around auction`;
|
||||
}
|
||||
|
||||
function checkContext(context: RicheseBidContext): void {
|
||||
if (!context || !positive(context.turn) || !identifier(context.event) || !identifier(context.player))
|
||||
throw new RicheseBidPromiseError('Retain the original turn, auction event and bidder.');
|
||||
}
|
||||
function checkOpportunity(opportunity: RicheseBidOpportunity): void {
|
||||
checkContext(opportunity);
|
||||
if (!positive(opportunity.minimum) || !positive(opportunity.maximum) || opportunity.minimum > opportunity.maximum)
|
||||
throw new RicheseBidPromiseError('Retain an actual funded Once Around bid interval.');
|
||||
}
|
||||
function sameContext(a: RicheseBidContext, b: RicheseBidContext): boolean {
|
||||
return a.turn === b.turn && a.event === b.event && a.player === b.player;
|
||||
}
|
||||
|
||||
export function liveRicheseBidPromises(promises: readonly RicheseBidPromise[], context: RicheseBidContext): RicheseBidPromise[] {
|
||||
checkContext(context);
|
||||
return promises.filter(row => sameContext(row, context) && !row.fulfilled && !row.released);
|
||||
}
|
||||
|
||||
/** Yes requires a bid; No alone permits passing. Each No limits any bid. */
|
||||
export function richeseBidAllowedRange(promises: readonly RicheseBidPromise[], opportunity: RicheseBidOpportunity): RicheseBidRange | null {
|
||||
checkOpportunity(opportunity);
|
||||
let maximum = opportunity.maximum;
|
||||
for (const row of liveRicheseBidPromises(promises, opportunity))
|
||||
if (!row.answer) maximum = Math.min(maximum, row.minimum - 1);
|
||||
return opportunity.minimum <= maximum ? { minimum: opportunity.minimum, maximum } : null;
|
||||
}
|
||||
export function richeseBidCompletionRange(promises: readonly RicheseBidPromise[], opportunity: RicheseBidOpportunity): RicheseBidRange | null {
|
||||
const allowed = richeseBidAllowedRange(promises, opportunity);
|
||||
if (!allowed) return null;
|
||||
let minimum = allowed.minimum;
|
||||
for (const row of liveRicheseBidPromises(promises, opportunity))
|
||||
if (row.answer) minimum = Math.max(minimum, row.minimum);
|
||||
return minimum <= allowed.maximum ? { minimum, maximum: allowed.maximum } : null;
|
||||
}
|
||||
export function richeseBidRequired(promises: readonly RicheseBidPromise[], context: RicheseBidContext): boolean {
|
||||
return liveRicheseBidPromises(promises, context).some(row => row.answer);
|
||||
}
|
||||
|
||||
/** Call inside the detached engine transaction immediately before its native
|
||||
* bid/pass validation. A Once Around actor receives one declaration only; a
|
||||
* small bid cannot rely on a later turn. The native action owns the actual
|
||||
* bid amount, funding, eligibility, hand space and event validity. */
|
||||
export function recordRicheseBid<T extends RicheseBidPromise>(promises: readonly T[], context: RicheseBidContext,
|
||||
amount: number | null): T[] {
|
||||
checkContext(context);
|
||||
if (amount !== null && !positive(amount))
|
||||
throw new RicheseBidPromiseError('Record only a positive bid or a pass.');
|
||||
const live = liveRicheseBidPromises(promises, context);
|
||||
if (live.some(row => row.answer && (amount === null || amount < row.minimum)))
|
||||
throw new RicheseBidPromiseError('Honor your Truthtrance Yes before passing or bidding less.');
|
||||
if (amount !== null && live.some(row => !row.answer && amount >= row.minimum))
|
||||
throw new RicheseBidPromiseError('This bid conflicts with your Truthtrance No answer.');
|
||||
return promises.map(row => live.includes(row) ? { ...row, fulfilled: true } : row);
|
||||
}
|
||||
|
||||
/** Supply null only after the engine has determined there is no native
|
||||
* opportunity. External events release individually impossible Yes answers;
|
||||
* the respondent cannot voluntarily make their own Yes impossible. */
|
||||
export function reconcileRicheseBidPromises<T extends RicheseBidPromise>(promises: readonly T[],
|
||||
context: RicheseBidContext, opportunity: RicheseBidOpportunity | null, change: RicheseBidChange): T[] {
|
||||
checkContext(context);
|
||||
if (change !== 'voluntary' && change !== 'external')
|
||||
throw new RicheseBidPromiseError('Retain the cause of the auction change.');
|
||||
if (opportunity) {
|
||||
checkOpportunity(opportunity);
|
||||
if (!sameContext(context, opportunity))
|
||||
throw new RicheseBidPromiseError('The bid opportunity belongs to a different auction or bidder.');
|
||||
}
|
||||
const live = liveRicheseBidPromises(promises, context);
|
||||
const range = opportunity ? richeseBidAllowedRange(promises, opportunity) : null;
|
||||
const impossible = live.filter(row => row.answer && (!range || row.minimum > range.maximum));
|
||||
if (impossible.length && change === 'voluntary')
|
||||
throw new RicheseBidPromiseError('This action would remove your last funded Once Around bid opportunity.');
|
||||
return promises.map(row => impossible.includes(row) ? { ...row, released: true } : row);
|
||||
}
|
||||
|
||||
/** Close only the original lot; outstanding No is established and an
|
||||
* unfulfilled Yes is released only on an external or already-forced close. */
|
||||
export function finishRicheseBidLot<T extends RicheseBidPromise>(promises: readonly T[], context: RicheseBidContext,
|
||||
change: RicheseBidChange): T[] {
|
||||
checkContext(context);
|
||||
if (change !== 'voluntary' && change !== 'external')
|
||||
throw new RicheseBidPromiseError('Retain the cause of the auction close.');
|
||||
const live = liveRicheseBidPromises(promises, context);
|
||||
if (change === 'voluntary' && live.some(row => row.answer))
|
||||
throw new RicheseBidPromiseError('Do not close the auction by abandoning your Truthtrance Yes.');
|
||||
return promises.map(row => live.includes(row)
|
||||
? row.answer ? { ...row, released: true } : { ...row, fulfilled: true } : row);
|
||||
}
|
||||
|
||||
/** The public lot event is intentionally shared by multiple questions. */
|
||||
export function validateRicheseBidPromises(promises: readonly RicheseBidPromise[], players: readonly string[]): void {
|
||||
if (!Array.isArray(promises) || !Array.isArray(players))
|
||||
throw new RicheseBidPromiseError('The Once Around bid commitment record is invalid.');
|
||||
const receipts = new Set<string>();
|
||||
for (const row of promises) {
|
||||
parseRicheseBidClaim(row);
|
||||
if (!identifier(row.id) || receipts.has(row.id) || !identifier(row.player) || !identifier(row.asker) || !players.includes(row.player) ||
|
||||
!players.includes(row.asker) || row.player === row.asker || typeof row.answer !== 'boolean' ||
|
||||
(row.fulfilled !== undefined && typeof row.fulfilled !== 'boolean') ||
|
||||
(row.released !== undefined && typeof row.released !== 'boolean') || row.fulfilled && row.released)
|
||||
throw new RicheseBidPromiseError('The Once Around commitment lost its receipt, original lot or participants.');
|
||||
receipts.add(row.id);
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ import { parseNoFieldValueFact, noFieldValueFactMatches, noFieldValueFactText, t
|
||||
import { parseFaceDancerFact, faceDancerFactMatches, faceDancerFactText, type FaceDancerFact } from './truthtrance-face-dancer';
|
||||
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';
|
||||
|
||||
export type TruthFact =
|
||||
| KnowledgeFact
|
||||
@@ -104,6 +105,7 @@ export type TruthQuestion =
|
||||
| ({ kind: 'revival'; target: string } & RevivalClaim)
|
||||
| ({ kind: 'bid'; target: string } & BidClaim)
|
||||
| ({ kind: 'phaseBid'; target: string } & PhaseBidClaim)
|
||||
| ({ kind: 'richeseBid'; target: string } & RicheseBidClaim)
|
||||
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
|
||||
| ({ kind: 'movement'; target: string } & MovementClaim)
|
||||
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
|
||||
@@ -356,6 +358,16 @@ 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 === 'richeseBid') {
|
||||
check(Object.keys(v).sort().join(',') === 'event,kind,minimum,target,turn',
|
||||
'Choose the current Once Around lot and bid minimum.');
|
||||
const claim = parseRicheseBidClaim(v), lot = g.richeseAuction;
|
||||
check(bidPromiseModeSupported({ ...g, semutaPreview: undefined }) && g.status === 'playing' && g.phase === 3 && lot &&
|
||||
lot.method === 'onceAround' && !lot.outcome && lot.event === claim.event && claim.turn === g.turn &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard,
|
||||
'Choose a quiet supported Once Around auction and its current public event.');
|
||||
return { kind: 'richeseBid', target: v.target, ...claim };
|
||||
}
|
||||
if (v.kind === 'phaseBid') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,turn',
|
||||
'Supply the current turn, phase bid minimum and respondent.');
|
||||
@@ -733,6 +745,7 @@ export function truthQuestionText(
|
||||
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
|
||||
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 === '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?`;
|
||||
@@ -863,6 +876,8 @@ export function resolveTruthAction(
|
||||
bindMixedBid?: (g: Game, p: Player, claim: MixedBidClaim, 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;
|
||||
richeseBidAnswers?: (g: Game, p: Player, claim: RicheseBidClaim) => TruthAnswer[];
|
||||
bindRicheseBid?: (g: Game, p: Player, claim: RicheseBidClaim, 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[];
|
||||
@@ -1036,6 +1051,9 @@ export function resolveTruthAction(
|
||||
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 === 'richeseBid')
|
||||
check(effects.richeseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a bidder with a remaining Once Around 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.');
|
||||
@@ -1169,6 +1187,11 @@ export function resolveTruthAction(
|
||||
'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 === 'richeseBid') {
|
||||
check(effects.richeseBidAnswers && effects.bindRicheseBid && effects.richeseBidAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer consistent with this Once Around lot and your legal bid.');
|
||||
if (a.answer !== 'unknown') effects.bindRicheseBid(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