Combine current facts with native bid declarations
This commit is contained in:
1 parent
957d5a172f
commit
0d979e4f8b
20 files changed
+1214
-7
No files matched your search
@@ -1320,6 +1320,10 @@ function policyActions(g: GameView): Action[] {
|
||||
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
|
||||
if (w.stage === 'answer') {
|
||||
if (w.question?.target !== me.id) return [];
|
||||
if (w.question.kind === 'mixedBid') {
|
||||
const answer = g.truthMixedBidAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'mixedRevival') {
|
||||
const answer = g.truthMixedRevivalAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
|
||||
+62
-1
@@ -1,3 +1,4 @@
|
||||
import { mixedBidMinimum, type MixedBidClaim } from './mixed-bid-question';
|
||||
import type { RevivalActionClaim, MixedRevivalClaim } from './mixed-revival-question';
|
||||
import type { BribePaymentClaim, MixedBribeClaim } from './mixed-bribe-question';
|
||||
import { compileMixedActionExpression, MixedActionQuestionError, type CompiledMixedAction } from './mixed-action-question';
|
||||
@@ -2157,6 +2158,10 @@ export type Game = {
|
||||
truthHistory?: TruthRecord[];
|
||||
shipmentPromises?: ShipmentPromise[];
|
||||
bidPromises?: BidPromise[];
|
||||
/** Raw mixed question linked to one private native current-lot obligation. */
|
||||
mixedBidQuestions?: (MixedBidClaim & {
|
||||
event: string; player: string; asker: string; answer: boolean; binding: boolean;
|
||||
})[];
|
||||
revivalPromises?: RevivalPromise[];
|
||||
leaderRevivalPromises?: LeaderRevivalPromise[];
|
||||
revivalExpressionPromises?: RevivalExpressionPromise[];
|
||||
@@ -6918,6 +6923,9 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
|
||||
battle: g.battle,
|
||||
shipmentPromises: g.shipmentPromises,
|
||||
movementPromises: g.movementPromises,
|
||||
...(next.record.question.kind === 'mixedBid' ? {
|
||||
mixedBidQuestions: g.mixedBidQuestions, bidPromises: g.bidPromises,
|
||||
} : {}),
|
||||
...(next.record.question.kind === 'mixedRevival' ? {
|
||||
mixedRevivalQuestions: g.mixedRevivalQuestions, revivalExpressionPromises: g.revivalExpressionPromises,
|
||||
} : {}),
|
||||
@@ -15572,6 +15580,50 @@ function bindBidTruth(g: Game, p: Player, claim: BidClaim, answer: boolean, aske
|
||||
(g.bidPromises ??= []).push({ ...claim, event: crypto.randomUUID(), source: opportunity.source,
|
||||
player: p.id, asker, answer });
|
||||
}
|
||||
function compileMixedBid(g: Game, p: Player, claim: MixedBidClaim, answer: boolean) {
|
||||
const compiled = compileMixedActionExpression(claim.mixed,
|
||||
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
|
||||
return compiled.kind === 'constant' ? { constant: compiled.value } :
|
||||
{ residual: { turn: claim.turn, lot: claim.lot, minimum: mixedBidMinimum(compiled) } };
|
||||
}
|
||||
function mixedBidTruthAnswers(g: Game, p: Player, claim: MixedBidClaim): TruthAnswer[] {
|
||||
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 [];
|
||||
const answers: TruthAnswer[] = [];
|
||||
for (const answer of [true, false]) {
|
||||
const compiled = compileMixedBid(g, p, claim, answer);
|
||||
if (compiled.constant !== undefined ? compiled.constant === answer :
|
||||
bidTruthAnswers(g, p, compiled.residual!).includes(answer ? 'yes' : 'no')) answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
}
|
||||
function bindMixedBidTruth(g: Game, p: Player, claim: MixedBidClaim, answer: boolean, asker: string) {
|
||||
requireRule(mixedBidTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
|
||||
'Choose a mixed bid answer compatible with the whole current-fact and ordinary-lot question.');
|
||||
const compiled = compileMixedBid(g, p, claim, answer);
|
||||
let event: string;
|
||||
if (compiled.residual) {
|
||||
bindBidTruth(g, p, compiled.residual, answer, asker);
|
||||
event = g.bidPromises!.at(-1)!.event;
|
||||
} else {
|
||||
requireRule(compiled.constant === answer, 'Retain the truthful whole mixed-bid answer.');
|
||||
event = crypto.randomUUID();
|
||||
}
|
||||
(g.mixedBidQuestions ??= []).push({ turn: claim.turn, lot: claim.lot, mixed: structuredClone(claim.mixed),
|
||||
event, player: p.id, asker, answer, binding: !!compiled.residual });
|
||||
}
|
||||
function isMixedBidEvent(g: Game, event: string): boolean {
|
||||
return !!g.mixedBidQuestions?.some(question => question.event === event);
|
||||
}
|
||||
function projectedMixedBidPromises(g: Game, player: string) {
|
||||
return (g.mixedBidQuestions ?? []).filter(question => question.player === player).map(question => {
|
||||
const bound = g.bidPromises?.find(promise => promise.event === question.event);
|
||||
return { event: question.event, player: question.player, asker: question.asker, turn: question.turn, lot: question.lot,
|
||||
mixed: structuredClone(question.mixed), answer: question.answer,
|
||||
fulfilled: !question.binding || bound?.fulfilled === true, released: bound?.released === true };
|
||||
});
|
||||
}
|
||||
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; }
|
||||
@@ -15599,7 +15651,7 @@ function reconcileBidPromises(g: Game, voluntary?: { actor: string; action: Acti
|
||||
}
|
||||
}
|
||||
function projectedBidPromises(g: Game, player: string) {
|
||||
return (g.bidPromises ?? []).filter(promise => promise.player === player).map(promise => ({
|
||||
return (g.bidPromises ?? []).filter(promise => promise.player === player && !isMixedBidEvent(g, promise.event)).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 }),
|
||||
@@ -36583,6 +36635,8 @@ function applyActionInner(
|
||||
bindLeaderRevival: bindLeaderRevivalTruth,
|
||||
revivalAnswers: revivalTruthAnswers,
|
||||
bindRevival: bindRevivalTruth,
|
||||
mixedBidAnswers: mixedBidTruthAnswers,
|
||||
bindMixedBid: bindMixedBidTruth,
|
||||
bidAnswers: bidTruthAnswers,
|
||||
bindBid: bindBidTruth,
|
||||
traitorAnswers: traitorTruthAnswers,
|
||||
@@ -40465,6 +40519,13 @@ export function viewGame(state: Game, id: string) {
|
||||
g.truthtrance.question.kind === 'mixedRevival' ? mixedRevivalTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
mixedRevivalPromises: projectedMixedRevivalPromises(g, id),
|
||||
revivalExpressionPromiseGuide: projectedRevivalExpressionPromiseGuide(g, id),
|
||||
truthMixedBidTargets: 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) : [],
|
||||
truthMixedBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'mixedBid' ? mixedBidTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
mixedBidPromises: projectedMixedBidPromises(g, id),
|
||||
bidPromises: projectedBidPromises(g, id),
|
||||
bidPromiseGuide: projectedBidPromiseGuide(g, id),
|
||||
truthtrance: g.truthtrance ?? null,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BidPromiseError, parseBidClaim } from './bid-promises';
|
||||
import type { CompiledMixedAction, MixedActionExpression } from './mixed-action-question';
|
||||
|
||||
export const MIXED_BID_MAX_LEAVES = 16;
|
||||
export const MIXED_BID_MAX_DEPTH = 4;
|
||||
export type BidActionClaim = { minimum: number };
|
||||
export type MixedBidClaim = { turn: number; lot: number; mixed: MixedActionExpression<BidActionClaim> };
|
||||
|
||||
export function parseBidActionClaim(value: unknown): BidActionClaim {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
||||
Object.keys(value).join(',') !== 'minimum')
|
||||
throw new BidPromiseError('Supply one positive whole bid minimum without turn or lot metadata.');
|
||||
const { minimum } = parseBidClaim({ turn: 1, lot: 1, minimum: (value as Record<string, unknown>).minimum });
|
||||
return { minimum };
|
||||
}
|
||||
|
||||
/** All positive leaves concern the same future accepted declaration interval.
|
||||
* max(a,b) satisfies AND, min(a,b) satisfies OR; whole No retains that exact
|
||||
* threshold. This creates neither a second bid search nor a selected route. */
|
||||
export function mixedBidMinimum(node: CompiledMixedAction<BidActionClaim>): number {
|
||||
if (node.kind === 'action') return parseBidActionClaim(node.action).minimum;
|
||||
if (node.kind === 'constant' || !node.terms.length)
|
||||
throw new BidPromiseError('Reduce only a nonconstant future ordinary-bid expression.');
|
||||
const minima = node.terms.map(mixedBidMinimum);
|
||||
return node.kind === 'and' ? Math.max(...minima) : Math.min(...minima);
|
||||
}
|
||||
@@ -3406,6 +3406,31 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused native original-deal/death, pure and control evidence; broader combinations, browser and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-revival.ts', 'tests/truth-revival-runtime.test.ts', 'tests/truth-revival-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-mixed-bid',
|
||||
title: 'Truthtrance: current facts and ordinary bids',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Combine answer-time facts with future accepted bid declarations in the current ordinary lot, preserving native funding and payment.',
|
||||
steps: [
|
||||
'Choose Combine current facts and ordinary bids. Add full AND/OR groups with at least one current fact and one bid minimum. The fixed public turn and current ordinary lot apply to every bid condition; nested facts share the sixteen-condition, four-level input limit.',
|
||||
'Current facts freeze at the actual answer. A statement that you hold at least10 spice remains that answer-time fact after the winning purchase leaves6. The answer does not require preserving the same resources afterward.',
|
||||
'Yes makes the complete mixed expression true; No keeps the complete expression false. Unknown facts cannot be relied on being true for Yes or false for No. If neither guaranteed answer is available, the unanswered card may be saved or asked differently.',
|
||||
'A fact may already settle the aggregate, needing no bid. Otherwise only future accepted declarations in this exact lot count. Two smaller bids never add into a larger bid. A qualifying declaration fulfills Yes even when another bidder eventually wins; the original winning payment remains separate.',
|
||||
'All remaining minima concern one future bid interval. Satisfying two minima requires the larger; satisfying either requires the smaller. This exact reduction retains every legal amount. No forbids qualifying later declarations through actual lot close.',
|
||||
'Native current funds, prepaid allied aid, Karama overbidding, capacity, active/waiting eligibility, earlier commitments and release order remain. The answer grants no new opportunity, spice or free purchase and assumes no future rival raise.',
|
||||
'Raw editing/preview/Ask, private answers, own aggregate status and one exact native bid/pass guide connect to four minimal policies. Fulfilled history does not duplicate or suppress a live guide. Phase-wide questions, native special auctions, other action families and broader compositions remain unfinished.',
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-bid', 'truthtrance-mixed-revival', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Shared answer-time compilation reduces remaining same-lot minima to an exact native scalar bid obligation; raw aggregate projection hides frozen values, reduced minima and hidden lot source.', evidence: ['game/mixed-action-question.ts', 'game/mixed-bid-question.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/mixed-bid-question.test.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Full shared editor, fixed current lot, repairable bid drafts, raw preview/Ask, private answers and one exact live event guide are connected.', evidence: ['components/mixed-bid-fields.tsx', 'components/mixed-bid-promises.tsx', 'components/bid-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-mixed-bid-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and declare native bids. Strategic refinement/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-mixed-bid-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Snapshot/unknown meaning, exact threshold reduction, original declaration/invoice boundaries and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_MIXED_BIDS.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Pure reduction, original native bid/payment/funding and controlled shared editing/guide cases; broader combinations and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-mixed-bid.ts', 'tests/truth-mixed-bid-runtime.test.ts', 'tests/truth-mixed-bid-funding.test.ts', 'tests/truth-mixed-bid-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-bid',
|
||||
title: 'Truthtrance: a bid in the current ordinary lot',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseBidActionClaim, MIXED_BID_MAX_DEPTH, MIXED_BID_MAX_LEAVES, type MixedBidClaim } from './mixed-bid-question';
|
||||
import { parseRevivalActionClaim, type MixedRevivalClaim } from './mixed-revival-question';
|
||||
import { parseBribePaymentClaim, type MixedBribeClaim } from './mixed-bribe-question';
|
||||
import { parseMixedActionExpression, mixedActionExpressionText, type MixedActionExpression } from './mixed-action-question';
|
||||
@@ -72,6 +73,7 @@ export type TruthFact =
|
||||
| { kind: 'and' | 'or'; terms: TruthFact[] };
|
||||
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression<MovementClaim> };
|
||||
export type TruthQuestion =
|
||||
| ({ kind: 'mixedBid'; target: string } & MixedBidClaim)
|
||||
| ({ kind: 'mixedRevival'; target: string } & MixedRevivalClaim)
|
||||
| ({ kind: 'mixedBribe'; target: string } & MixedBribeClaim)
|
||||
| ({ kind: 'mixedMovement'; target: string } & MixedMovementClaim)
|
||||
@@ -322,6 +324,19 @@ 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 === 'mixedBid') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,lot,mixed,target,turn',
|
||||
'Supply the current ordinary lot, turn, complete mixed bid question and respondent.');
|
||||
const claim = parseBidClaim({ turn: v.turn, lot: v.lot, minimum: 1 });
|
||||
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 lot in a quiet supported window.');
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
return { kind: 'mixedBid', target: v.target as string, turn: claim.turn, lot: claim.lot,
|
||||
mixed: parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), parseBidActionClaim,
|
||||
{ maxLeaves: MIXED_BID_MAX_LEAVES, maxDepth: MIXED_BID_MAX_DEPTH }) };
|
||||
}
|
||||
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.');
|
||||
@@ -605,6 +620,8 @@ export function truthQuestionText(
|
||||
return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`;
|
||||
return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`;
|
||||
};
|
||||
if (q.kind === 'mixedBid') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
action => `you ${bidClaimText({ turn: q.turn, lot: q.lot, minimum: action.minimum })}`)}?`;
|
||||
if (q.kind === 'mixedRevival') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
action => `you ${revivalExpressionText(action, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} before your Revival opportunity ends`)}?`;
|
||||
if (q.kind === 'mixedBribe') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
@@ -657,6 +674,8 @@ export function resolveTruthAction(
|
||||
bindLeaderRevival?: (g: Game, p: Player, claim: LeaderRevivalClaim, answer: boolean, asker: string) => void;
|
||||
revivalAnswers?: (g: Game, p: Player, claim: RevivalClaim) => TruthAnswer[];
|
||||
bindRevival?: (g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) => void;
|
||||
mixedBidAnswers?: (g: Game, p: Player, claim: MixedBidClaim) => TruthAnswer[];
|
||||
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;
|
||||
traitorAnswers?: (g: Game, p: Player) => TruthAnswer[];
|
||||
@@ -807,6 +826,9 @@ export function resolveTruthAction(
|
||||
if (w.question.kind === 'revival')
|
||||
check(effects.revivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a respondent with a remaining native Revival opportunity.');
|
||||
if (w.question.kind === 'mixedBid')
|
||||
check(effects.mixedBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a mixed current-fact and bid question with an available ordinary-lot opportunity.');
|
||||
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.');
|
||||
@@ -911,6 +933,11 @@ export function resolveTruthAction(
|
||||
'Choose an answer consistent with native force revivals and earlier answers.');
|
||||
if (a.answer !== 'unknown') effects.bindRevival(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'mixedBid') {
|
||||
check(effects.mixedBidAnswers && effects.bindMixedBid && effects.mixedBidAnswers(g, p, q).includes(a.answer),
|
||||
'Choose a whole mixed-bid answer compatible with current facts and native ordinary declarations.');
|
||||
if (a.answer !== 'unknown') effects.bindMixedBid(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.');
|
||||
|
||||
Reference in new issue
Block a user