Support phase-wide Truthtrance bid commitments

This commit is contained in:
Quintine committed 2026-10-27 17:44:53 +11:00
1 parent 474674f7fe
commit 2916b4d48f
19 files changed
+1512 -15

No files matched your search

+2
View File
@@ -43,6 +43,7 @@ import { RevivalPromises } from './revival-promises';
import { RevivalExpressionPromises } from './revival-expression-promises';
import { LeaderRevivalPromises } from './leader-revival-promises';
import { BidPromises } from './bid-promises';
import { PhaseBidPromises } from './phase-bid-promises';
import { HomeworldShipment } from './homeworld-shipment';
import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
import { CaladanReinforcement } from './caladan-reinforcement';
@@ -2086,6 +2087,7 @@ export function GameTable({
{g.status === 'playing' && <LeaderRevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <RevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <BidPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <PhaseBidPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedBidPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedBattlePromises game={g} />}
{g.status === 'playing' && <MixedTraitorPromises game={g} />}
+38
View File
@@ -0,0 +1,38 @@
'use client';
import { parsePhaseBidClaim, type PhaseBidClaim } from '@/game/turn-bid-promises';
import { Input } from './ui/input';
export function phaseBidClaimInputError(value: unknown): string | null {
try { parsePhaseBidClaim(value); return null; }
catch (error) { return error instanceof Error ? error.message : 'Choose a positive whole bid minimum.'; }
}
/** Turn is fixed by the authoritative question context; the asker chooses the threshold. */
export function PhaseBidClaimFields(props: {
value: PhaseBidClaim;
onChange: (claim: PhaseBidClaim) => void;
disabled?: boolean;
}) {
const error = phaseBidClaimInputError(props.value);
return <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Bidding phase condition">
<legend>Bid during this turn</legend>
<p>Turn {props.value.turn} · Remaining ordinary Bidding phase</p>
<label htmlFor="truth-phase-bid-minimum">Minimum bid in spice</label>
<Input id="truth-phase-bid-minimum" type="number" min={1} max={Number.MAX_SAFE_INTEGER} step={1} required
disabled={props.disabled} value={Number.isFinite(props.value.minimum) ? props.value.minimum : ''}
aria-invalid={!!error} aria-describedby="truth-phase-bid-help"
onChange={event => {
if (props.disabled) return;
const next = { ...props.value, minimum: event.currentTarget.valueAsNumber };
if (phaseBidClaimInputError(next)) return;
props.onChange(parsePhaseBidClaim(next));
}} />
{error && <p role="alert">{error}</p>}
<p className="fine" id="truth-phase-bid-help">
This asks about one accepted bid of at least this size after the answer,
before Bidding ends this turn. Earlier bids do not count; it does not
promise a win or change the winning payment.
</p>
</fieldset>;
}
+67
View File
@@ -0,0 +1,67 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { phaseBidClaimText, phaseBidPromiseModeSupported } from '@/game/turn-bid-promises';
import { Button } from './ui/button';
type PhaseBidPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
/** Only operate the exact server-selected bid/pass for a live owned promise.
* The native auction remains responsible for its own card, funding and turn. */
export function PhaseBidPromiseGuideButton(props: PhaseBidPromiseProps) {
const game = props.game, guide = game.phaseBidPromiseGuide;
if (!guide) return null;
const actor = game.me, turn = game.turn, lot = game.auction;
const lotSignature = lot && JSON.stringify([lot.bid, lot.bidder, lot.active, lot.remaining]);
const action = guide.action, event = action.phaseBidPromiseEvent, lotNumber = action.phaseBidPromiseLot;
const signature = JSON.stringify(action);
const canAct = () => {
const current = props.game, me = current.players.find(player => player.id === current.me);
return !props.busy && current === game && phaseBidPromiseModeSupported(current) &&
current.status === 'playing' && current.phase === 3 &&
current.me === actor && current.turn === turn && !!lot && current.auction === lot &&
current.auction.active === actor &&
JSON.stringify([lot.bid, lot.bidder, lot.active, lot.remaining]) === lotSignature &&
typeof lotNumber === 'number' && Number.isSafeInteger(lotNumber) && lotNumber > 0 &&
current.bidPromiseLot?.turn === turn && current.bidPromiseLot.lot === lotNumber &&
current.phaseBidPromiseGuide === guide && current.phaseBidPromiseGuide.action === action &&
JSON.stringify(action) === signature && typeof event === 'string' &&
(action.type === 'bid' || action.type === 'passBid') &&
current.phaseBidPromises.some(promise => promise.event === event && promise.player === actor &&
promise.turn === turn && !promise.fulfilled && !promise.released) && !!me && !me.autopilot &&
!current.truthtrance && !current.response && !current.decision && !current.phaseOpening &&
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
return <Button className="game-action min-h-11 whitespace-normal" data-testid="phase-bid-promise-guide"
disabled={!canAct()} onClick={() => { if (canAct()) props.send(action); }}>
{action.type === 'bid'
? `Bid ${typeof action.amount === 'number' ? action.amount : 'the suggested amount'} spice while honoring your phase answers`
: 'Pass this lot while honoring your phase answers'}
</Button>;
}
export function PhaseBidPromises(props: PhaseBidPromiseProps) {
const own = props.game.phaseBidPromises.filter(promise => promise.player === props.game.me);
if (!own.length) return null;
const waiting = own.some(promise => promise.turn === props.game.turn && !promise.fulfilled && !promise.released);
return <section className="notice space-y-3" aria-label="Your Truthtrance Bidding phase promises" id="phase-bid-promise-guidance">
<h3>Your Bidding phase promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {phaseBidClaimText(promise)}.
{promise.released ? ' Released: this commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This answer is binding through this Bidding phase.'}
</li>)}</ul>
<p>
Yes requires a qualifying future bid while it remains possible. No forbids
qualifying bids through Bidding close. A bid can fulfill Yes even if another
player wins; the original payment rules still apply.
</p>
<p className="fine">
Earlier bids do not count. Ordinary auctions retain their original order and
funding; the answer gives no extra opportunity, spice or free purchase.
</p>
{waiting && props.game.auction?.active !== props.game.me &&
<p>Your next bid opportunity is still ahead; this promise remains visible while others bid.</p>}
{waiting && <PhaseBidPromiseGuideButton {...props} />}
</section>;
}
+20 -1
View File
@@ -42,6 +42,7 @@ import type { BribeClaim } from '@/game/bribe-promises';
import { RevivalClaimFields, revivalClaimInputError } from './revival-claim-fields';
import type { RevivalClaim } from '@/game/revival-promises';
import { BidClaimFields, bidClaimInputError } from './bid-claim-fields';
import { PhaseBidClaimFields, phaseBidClaimInputError } from './phase-bid-claim-fields';
import type { BidClaim } from '@/game/bid-promises';
import { TraitorDecisionAnswers } from './traitor-decision-promises';
import { validMovementClaim, type MovementClaim } from '@/game/movement-promises';
@@ -96,7 +97,7 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
>('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -138,6 +139,7 @@ export function Truthtrance({
const [bidMinimum, setBidMinimum] = useState(1);
const bidClaim: BidClaim = { turn: g.bidPromiseLot?.turn ?? g.turn,
lot: g.bidPromiseLot?.lot ?? 1, minimum: bidMinimum };
const phaseBidClaim = {turn:g.turn,minimum:bidMinimum};
const [movement, setMovement] = useState<MovementClaim>({ territory: 'carthag', minimum: 6 });
const [movementExpression, setMovementExpression] = useState<MovementExpression>({ kind: 'and', terms: [
{ kind: 'move', territory: 'imperial_basin', minimum: 1 }, { kind: 'move', territory: 'carthag', minimum: 1 },
@@ -239,6 +241,7 @@ export function Truthtrance({
: kind === 'revival' ? { kind, target, ...revivalClaim }
: kind === 'mixedBid' ? { kind, target, turn: bidClaim.turn, lot: bidClaim.lot, mixed: mixedBid }
: kind === 'bid' ? { kind, target, ...bidClaim }
: kind === 'phaseBid' ? { kind, target, ...phaseBidClaim }
: kind === 'shipment'
? {
kind,
@@ -423,6 +426,13 @@ export function Truthtrance({
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'phaseBid' ? (
<>
<p className="notice">Yes requires one qualifying ordinary bid before this turn’s Bidding closes while possible. No forbids all qualifying later bids. A bid does not promise a win; winning still requires payment.</p>
{(g.truthPhaseBidAnswers ?? []).map(answer => <div key={answer}>
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'bid' ? (
<>
<p className="notice">Yes requires one qualifying bid in this ordinary lot while possible. No forbids qualifying bids until it closes. A bid does not promise a win; winning still requires the original payment. Keep the funding needed by your answer.</p>
@@ -578,6 +588,7 @@ export function Truthtrance({
<option value="leaderRevival">Bind a named leader revival this turn</option>
<option value="revival">Bind ordinary force revivals this turn</option>
<option value="bid">Bind a bid in this ordinary lot</option>
<option value="phaseBid">Bind a bid this Bidding phase</option>
<option value="mixedBid">Combine current facts and ordinary bids</option>
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
<option value="mixedTraitorCall">Combine current facts and this Traitor declaration</option>
@@ -721,6 +732,13 @@ export function Truthtrance({
{!g.truthRevivalTargets.includes(target) && <p className="notice">Choose a respondent whose native Revival opportunity remains this turn and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported first.</p>}
<p className="notice" aria-label="Public revival question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'phaseBid' ? (
<>
<PhaseBidClaimFields value={phaseBidClaim} disabled={busy}
onChange={claim => setBidMinimum(claim.minimum)} />
{!g.truthPhaseBidTargets.includes(target) && <p className="notice">Choose a supported current-turn Bidding question and finish pending decisions.</p>}
<p className="notice" aria-label="Public phase bid question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'bid' ? (
<>
<BidClaimFields value={bidClaim} disabled={busy || !g.bidPromiseLot}
@@ -859,6 +877,7 @@ export function Truthtrance({
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
(kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) ||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
(kind === 'phaseBid' && (!!phaseBidClaimInputError(phaseBidClaim) || !g.truthPhaseBidTargets.includes(target))) ||
(kind === 'turnActions' && (!!turnActionsError || !g.truthTurnActionTargets.includes(target))) ||
(kind === 'travel' && (!!travelError || !g.truthTravelTargets.includes(target))) ||
(kind === 'mixedTraitorCall' && (!!mixedTraitorError || !g.truthMixedTraitorTargets.includes(target))) ||
+12
View File
@@ -20,6 +20,18 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Phase-wide Truthtrance bid — 27 October. Prototyped / Partial.**
An owned Yes/No minimum ordinary-bid answer can span the remaining Bidding
phase instead of expiring with the current lot. Actual accepted bids fulfill
Yes; No constrains later bids. A later lot is witnessed by a real funded current
sale, original next card and post-sale capacity/funds, not an imagined rival bid.
The separate current-lot question remains. Public Basic and Advanced game
fixtures exercise actual answers, lots, competing promises and settlement;
server-rendered controls and their engine callback have focused smoke coverage.
Twelve focused files pass 224/224, and type/lint and build pass. Stateful browser
interaction, deployed play and complete modes remain unverified.
[Scope and provisional forecast](TRUTHTRANCE_BID_PROMISES.md#phase-wide-ordinary-bid-prototype--27-october-2026).
**Basic explosion and prior surviving leaders — 27 October. Prototyped / Partial.**
The Advanced Arrakis prior-leader handling now also applies provisionally in
Basic: a later actual explosion kills surviving discs from earlier battles in
+17
View File
@@ -1,5 +1,22 @@
# Dune implementation status
## 27 October 2026 — Phase-wide ordinary bid Truthtrance
Prototype a separate current-turn minimum ordinary-bid Yes/No that persists
through native lot changes. Keep original physical cards, payments and previous
lot-bound questions intact. Actual funded sale, remaining original card, hand
capacity, Harkonnen bonus and earlier current-lot commitments govern the
provisional future witness; no imaginary opponent bid is counted. The editor,
private status/guide and minimal legal AI use the same projected action. Special
auctions, combined future commitments and final acceptance remain open.
Public Basic and Advanced fixtures exercise original Truth cards and actual
bidding, including independently funded and conflicting bribes. Separate
controlled supplements stage conserved resources. Server-rendered controls and
their engine callback are exercised; stateful browser and deployed play are
not certified. Twelve focused files pass 224/224; type/lint and build pass.
The private verification record is `20261027-truth-phase-bid-proof-v2/`.
[Contract](TRUTHTRANCE_BID_PROMISES.md#phase-wide-ordinary-bid-prototype--27-october-2026).
## 27 October 2026 — Basic explosion and prior leaders
Extend the Advanced Arrakis prior-leader explosion to Basic under the standing
+10
View File
@@ -1,5 +1,15 @@
# Rule decision index
## Phase-wide ordinary bid Truthtrance — 27 October 2026
Prototypically support a current-turn Yes/No minimum ordinary bid across
multiple Bidding lots. Only actual accepted later declarations count. An unbid
lot cannot prove another lot will exist; a later witness requires a real funded
current sale, prepared next card, remaining hand space and funds. The generic
GF9 Truthtrance FAQ supports action questions and compliance while possible, not
this specific online forecast. Earlier actions and native payments remain;
special-auction and combined-future planning stay explicit. [Contract](TRUTHTRANCE_BID_PROMISES.md#phase-wide-ordinary-bid-prototype--27-october-2026).
## Basic explosion and prior leaders — 27 October 2026
Provisionally extend the existing Advanced Arrakis casualty handling to Basic.
+39
View File
@@ -1,5 +1,44 @@
# Truthtrance ordinary bid commitments
## Phase-wide ordinary bid prototype — 27 October 2026
**Prototyped / Partial.** A distinct `{kind:'phaseBid', target, turn, minimum}`
asks whether the respondent will make at least one accepted ordinary bid of
that size after the answer and before this turn's Bidding ends. It may be asked
before Bidding or during a quiet ordinary lot on supported native tables. A Yes
survives lot changes until fulfilled or made impossible; No caps every later
ordinary bid until actual phase close. The original current-lot question and its
saved records remain separate. The general FAQ authority below does not contain
a phase-wide bid worked example, so these online feasibility rules are explicitly
provisional.
An accepted declaration fulfills Yes even if it later loses; purchases retain
their real payment. A later lot is not assumed when all players might pass:
there must already be a funded positive winning bid, a physically prepared next
card and post-purchase hand space and funding. Possible Basic and Advanced Harkonnen bonus
draws reserve their hand slot; a cancelable Karama-only overbid is not a funded
later-lot witness. Existing current-lot answers are considered before forecasting
the later invoice. Before Bidding, a witness uses current eligible hand space,
funding and available card count, without inventing a future acquisition. Ixian
or Richese special-auction preparation is not forecast. Existing transport, revival and aggregate turn commitments retain current-lot
checks; the future combined planner is not implemented. Before Bidding, an
independent bribe can be quoted first using currently funded payments, leaving
only the actual remaining funds for the bid; current-lot independent bribe
combinations remain limited. Uncertain combinations do not create a fictitious
later witness or release an answer merely because that planner is absent. Full special-auction, mixed-expression and module coverage
remains open.
The private panel displays owned answers and a server-quoted current bid/pass;
its event and public lot ordinal reject stale guided actions. The ordinary
question control, current answer choices and four minimal bot profiles use the
same server projections. No extra card, funds, hand slot, auction or payment is
created. Prior bids do not count. Public Basic and Advanced fixtures exercise original Truth cards, actual bids
and competing funded and unfunded commitments; additional cases explicitly
stage conserved resources. Server-rendered controls and their engine callback
have focused smoke coverage. Stateful browser interaction, full-game,
recovery and deployed acceptance remain separate. The focused 12-file batch
passes 224/224; type/lint and the production build pass.
## Current-lot prototype — 26 October 2026
**Prototyped / Partial.** The question binds one
+5
View File
@@ -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.phaseBidPromiseGuide) return [g.phaseBidPromiseGuide.action];
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
if (g.truthtrance) {
const w = g.truthtrance;
@@ -1375,6 +1376,10 @@ function policyActions(g: GameView): Action[] {
const answer = g.truthBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'phaseBid') {
const answer = g.truthPhaseBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'turnActions') {
const answer = g.truthTurnActionAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
+301 -9
View File
@@ -67,6 +67,10 @@ import { RevivalPromiseError, revivalPromiseModeSupported, liveRevivalPromises,
revivalPromisesSatisfied, validateRevivalPromises, type RevivalClaim, type RevivalPromise } from './revival-promises';
import { BidPromiseError, bidPromiseModeSupported, bidPromiseRange, bidRequired, liveBidPromises,
recordPromisedBid, validateBidPromises, type BidClaim, type BidOpportunity, type BidPromise } from './bid-promises';
import { PhaseBidPromiseError, phaseBidPromiseModeSupported, livePhaseBidPromises,
phaseBidCompletionRange, recordPhaseBid, reconcilePhaseBidPromises,
finishPhaseBidPhase, validatePhaseBidPromises, type PhaseBidClaim, type PhaseBidOpportunity,
type PhaseBidPromise } from './turn-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, bribesCloseBeforeNextMentat, maximumBribe, type BribeOptions } from './bribe-options';
@@ -2243,6 +2247,7 @@ export type Game = {
})[];
shipmentPromises?: ShipmentPromise[];
bidPromises?: BidPromise[];
phaseBidPromises?: PhaseBidPromise[];
/** Raw mixed question linked to one private native current-lot obligation. */
mixedBidQuestions?: (MixedBidClaim & {
event: string; player: string; asker: string; answer: boolean; binding: boolean;
@@ -15652,17 +15657,23 @@ function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no'
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || claim.recipient === p.id ||
claim.recipient === p.ally || !g.players.some(player => player.id === claim.recipient)) return [];
const source = g.phase === 2 && !g.charityOrder ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(source, p, [...(g.bribePromises ?? []),
{ ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host,
answer: answer === 'yes', progress: 0 }]));
return (['yes', 'no'] as const).filter(answer => {
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);
});
}
function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionClaim): ('yes' | 'no' | 'unknown')[] {
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || bribeExpressionRecipients(claim.claim)
.some(recipient => recipient === p.id || recipient === p.ally || !g.players.some(player => player.id === recipient))) return [];
const source = g.phase === 2 && !g.charityOrder ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(source, p, g.bribePromises ?? [],
[...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id,
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: [] }]));
return (['yes', 'no'] as const).filter(answer => {
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);
});
}
function reopenBribePayer(g: Game, p: Player, answer: boolean) {
if (answer && (g.phase === 2 && !g.charityOrder || g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g)) && g.ready.includes(p.id)) {
@@ -16021,6 +16032,216 @@ function bidQuestionWindow(g: Game): boolean {
return !!bidOpportunity(g) && !g.response && !g.decision && !g.phaseOpening && !g.richeseAuction &&
!g.ixAuction && !g.pendingKarama && !g.pendingTreacheryDiscard;
}
/** A future lot is a witness only after a real funded bid has committed the
* current lot to sale. An unbid lot ends ordinary Bidding instead of opening
* its remaining cards. The source is kept private in the native auction. */
function phaseBidWitnesses(g: Game, p: Player): PhaseBidOpportunity[] {
if (g.status !== 'playing' || !phaseBidPromiseModeSupported(g) || g.phase > 3) return [];
const maximum = (spent = 0, spentAid = 0, canUseKarama = true) => {
if (canUseKarama && karamaCard(g, p, undefined, 'auction-free-purchase')) return Number.MAX_SAFE_INTEGER;
return Math.max(0, uncommittedSpice(g, p) - spent + Math.max(0, (aidFor(g, p)?.amount ?? 0) - spentAid));
};
if (g.phase < 3) {
// No special-auction preparation or future purchase is manufactured.
if (byFaction(g, 'ixians') || byFaction(g, 'richese') ||
p.hand.length >= handLimit(p) || !(g.deck.length || g.discard.length)) return [];
const max = maximum();
return max > 0 ? [{turn:g.turn,player:p.id,kind:'future',minimum:1,maximum:max}] : [];
}
const lot = bidOpportunity(g), auction = g.auction;
if (!lot || !auction) return [];
const result: PhaseBidOpportunity[] = [];
const current = bidPromiseRange(g.bidPromises ?? [], p.id, lot, normalBidMaximum(g, p));
if (current) result.push({turn:g.turn,player:p.id,kind:'current',...current});
const buyer = auction.bidder ? g.players.find(player => player.id === auction.bidder) : undefined;
const paidAid = auction.allyPayment ?? 0;
const funded = !!buyer && paidAid >= 0 && paidAid <= auction.bid &&
buyer.spice >= auction.bid - paidAid && (aidFor(g,buyer)?.amount ?? 0) >= paidAid;
// A native Harkonnen winner may receive a bonus card after the
// purchase. Reserve that slot rather than assuming an opponent cancels it.
const futureCards = buyer?.id === p.id
? 1 + Number(p.faction === 'harkonnen') : 0;
if (buyer && funded && auction.bid > 0 && auction.cards[auction.index + 1] &&
p.hand.length + futureCards < handLimit(p)) {
// uncommittedSpice already reserves the current winner's own invoice.
const spentAid = buyer.id === p.id ? paidAid : 0;
const mustSpendKarama = buyer.id === p.id && auction.bid > p.spice + (aidFor(g,p)?.amount ?? 0);
const max = maximum(0, spentAid, !mustSpendKarama);
if (max > 0) result.push({turn:g.turn,player:p.id,kind:'future',minimum:1,maximum:max});
}
return result;
}
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 &&
!g.richeseAuction && !g.ixAuction && !g.currentAuctionSale && (g.phase < 3 || !!bidOpportunity(g));
}
function phaseBidHonorsTurnActions(g: Game, p: Player, lot: BidOpportunity, action: Action): boolean {
if (!liveTurnActionPromises(g.turnActionPromises ?? [],p.id,g.turn).length) return true;
const trial = structuredClone(g);
trial.truthtrance = null;
try {
if (action.type === 'bid') {
trial.turnActionPromises = turnActionRule(() => recordTurnActionBid(trial.turnActionPromises!,p.id,g.turn,
{lot:lot.lot,source:lot.source,amount:Number(action.amount)}));
trial.bidPromises = recordPromisedBid(trial.bidPromises ?? [],p.id,lot,Number(action.amount));
trial.auction!.bid = Number(action.amount);
trial.auction!.bidder = p.id;
trial.auction!.allyPayment = Number(action.allyPayment ?? 0);
} else trial.turnActionPromises = turnActionRule(() => closeTurnActionBidding(trial.turnActionPromises!,g.turn,lot.source,p.id));
return !!turnActionCompletion(trial,p.id);
} catch (error) { if (error instanceof RuleError || error instanceof BidPromiseError) return false; throw error; }
}
/** Current witnesses must also satisfy the existing native funding and
* current-lot commitments. Future bidding with other financial/transport
* promises awaits their combined planner; do not invent those resources. */
function viablePhaseBidWitnesses(g: Game, p: Player, promises: readonly PhaseBidPromise[]) {
const lot = bidOpportunity(g);
const live = livePhaseBidPromises(promises,p.id,g.turn);
const otherCommitments = liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn).some(row => row.answer) ||
hasMovementPromises(g,p.id) || 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) ||
liveTurnActionPromises(g.turnActionPromises ?? [],p.id,g.turn).length > 0;
return phaseBidWitnesses(g,p).flatMap(witness => {
if (witness.kind === 'future') {
if (otherCommitments) return [];
if (hasBribePromises(g,p.id,true)) {
// Quote only currently funded payments; no future income is invented.
// Current-lot combined forecasts remain unsupported.
if (lot) return [];
const source = structuredClone(g); source.truthtrance = null;
const fundedWitnesses: PhaseBidOpportunity[] = [];
for (const payments of bribeCompletionCandidates(source.bribePromises ?? [], source.bribeExpressionPromises ?? [], p.id, g.turn)) {
const total = payments.reduce((sum, payment) => sum + payment.amount, 0);
if (!Number.isSafeInteger(total) || total > uncommittedSpice(source,getPlayer(source,p.id))) continue;
for (const seed of ordinaryPaymentSeeds(source, p.id, payments)) {
if (seed.actions.some(action => action.type !== 'bribe')) continue;
const after = phaseBidWitnesses(seed.game,getPlayer(seed.game,p.id)).find(row => row.kind === 'future');
if (after) fundedWitnesses.push(after);
}
}
return fundedWitnesses;
}
if (lot && bidRequired(g.bidPromises ?? [],p.id,lot)) {
// A later lot is viable only after satisfying this lot's existing
// obligations. Its actual invoice and extra hand slot remain reserved.
const caps: BidPromise[] = live.filter(row => !row.answer).map((row,index) => ({
turn:g.turn,lot:lot.lot,minimum:row.minimum,event:`phase-future-cap-${index}`,
source:lot.source,player:p.id,asker:row.asker,answer:false,
}));
const required = bidPromiseCompletion(g,p,[...(g.bidPromises ?? []),...caps]);
if (!required || required.type !== 'bid') return [];
const trial = structuredClone(g);
trial.auction!.bid = Number(required.amount);
trial.auction!.bidder = p.id;
trial.auction!.allyPayment = Number(required.allyPayment ?? 0);
const after = phaseBidWitnesses(trial,getPlayer(trial,p.id)).find(row => row.kind === 'future');
return after ? [after] : [];
}
return [witness];
}
if (!lot) return [];
const bounds: BidPromise[] = live.map((row,index) => ({turn:g.turn,lot:lot.lot,minimum:row.minimum,
event:`phase-witness-${index}`,source:lot.source,player:p.id,asker:row.asker,answer:row.answer}));
const action = bidPromiseCompletion(g,p,[...(g.bidPromises ?? []),...bounds]);
return action && phaseBidHonorsTurnActions(g,p,lot,action) ? [witness] : [];
});
}
/** A new independent bribe must preserve an already-binding phase Yes.
* Before Bidding, reserve currently fundable payment first; current-lot
* independent combinations wait for a joint planner. */
function phaseBidCompatibleBribeAnswer(g: Game, p: Player): boolean {
const live = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn);
if (!live.some(row => row.answer)) return true;
if (g.phase === 3 && hasBribePromises(g,p.id,true)) return false;
return viablePhaseBidWitnesses(g,p,g.phaseBidPromises ?? [])
.some(witness => !!phaseBidCompletionRange(g.phaseBidPromises ?? [],witness));
}
function phaseBidRule<T>(run: () => T): T {
try { return run(); }
catch (error) { if (error instanceof PhaseBidPromiseError) throw new RuleError(error.message); throw error; }
}
function phaseBidTruthAnswers(g: Game, p: Player, claim: PhaseBidClaim): TruthAnswer[] {
if (!phaseBidQuestionWindow(g) || claim.turn !== g.turn) return [];
const base = g.phaseBidPromises ?? [];
const answers: TruthAnswer[] = [];
for (const answer of [true, false]) {
const asker = g.truthtrance?.queue[0]?.player ?? g.players.find(player => player.id !== p.id)?.id ?? g.host;
const candidate: PhaseBidPromise = {turn:claim.turn,minimum:claim.minimum,event:'candidate',player:p.id,asker,answer};
const all = [...base,candidate];
const witnesses = viablePhaseBidWitnesses(g,p,all);
const yesPossible = !all.some(row => row.player === p.id && row.turn === g.turn && row.answer && !row.fulfilled && !row.released) ||
witnesses.some(witness => !!phaseBidCompletionRange(all,witness));
// Existing current-lot Yes commitments must also survive this new No.
const lot = bidOpportunity(g);
let lotPossible = true;
if (lot && bidRequired(g.bidPromises ?? [],p.id,lot)) {
const caps: BidPromise[] = all.filter(row => row.player === p.id && row.turn === g.turn && !row.answer &&
!row.fulfilled && !row.released).map((row,index) => ({turn:g.turn,lot:lot.lot,minimum:row.minimum,
event:`phase-cap-${index}`,source:lot.source,player:p.id,asker:row.asker,answer:false}));
const action = bidPromiseCompletion(g,p,[...(g.bidPromises ?? []),...caps]);
lotPossible = !!action && phaseBidHonorsTurnActions(g,p,lot,action);
}
if (yesPossible && lotPossible) answers.push(answer ? 'yes' : 'no');
}
return answers;
}
function bindPhaseBidTruth(g: Game, p: Player, claim: PhaseBidClaim, answer: boolean, asker: string) {
requireRule(phaseBidTruthAnswers(g,p,claim).includes(answer ? 'yes' : 'no'),
'This phase-wide bid answer no longer has a legal completion.');
(g.phaseBidPromises ??= []).push({turn:claim.turn,minimum:claim.minimum,event:crypto.randomUUID(),player:p.id,asker,answer});
}
function phaseBidIntegrity(g: Game) {
phaseBidRule(() => validatePhaseBidPromises(g.phaseBidPromises ?? [],g.players.map(player => player.id)));
}
function reconcilePhaseBids(g: Game, voluntary?: {actor:string;action:Action}) {
phaseBidIntegrity(g);
if (!g.phaseBidPromises?.length || g.truthtrance || g.response || g.decision || g.phaseOpening ||
g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return;
for (const player of g.players) {
const live = livePhaseBidPromises(g.phaseBidPromises,player.id,g.turn);
if (!live.length) continue;
const change = voluntary?.actor === player.id ? 'voluntary' : 'external';
if (g.phase > 3) g.phaseBidPromises = phaseBidRule(() => finishPhaseBidPhase(g.phaseBidPromises!,player.id,g.turn,change));
else {
const witnesses = viablePhaseBidWitnesses(g,player,g.phaseBidPromises);
// An external event must not release an answer merely because the
// combined future-obligation planner is not implemented yet.
const unknownFuture = change === 'external' && !witnesses.some(row => row.kind === 'future') &&
phaseBidWitnesses(g,player).some(row => row.kind === 'future');
if (unknownFuture && live.some(row => row.answer) &&
!witnesses.some(row => !!phaseBidCompletionRange(g.phaseBidPromises!,row))) continue;
g.phaseBidPromises = phaseBidRule(() => reconcilePhaseBidPromises(g.phaseBidPromises!,player.id,g.turn,witnesses,change));
}
}
}
function projectedPhaseBidPromises(g: Game, player: string) {
return (g.phaseBidPromises ?? []).filter(promise => promise.player === player).map(promise => ({...promise}));
}
function projectedPhaseBidGuide(g: Game, player: string): {action:Action} | null {
const opportunity = bidOpportunity(g), seat = getPlayer(g,player);
if (!opportunity || !bidQuestionWindow(g) || g.truthtrance || g.auction?.active !== player) return null;
const live = livePhaseBidPromises(g.phaseBidPromises ?? [],player,g.turn);
if (!live.length) return null;
const required = live.some(promise => promise.answer);
let action: Action = {type:'passBid'};
if (required || bidRequired(g.bidPromises ?? [],player,opportunity)) {
const bounds: BidPromise[] = live.map((promise,index) => ({
turn:g.turn,lot:opportunity.lot,minimum:promise.minimum,event:`phase-guide-${index}`,
source:opportunity.source,player,asker:promise.asker,answer:promise.answer,
}));
const bid = bidPromiseCompletion(g,seat,[...(g.bidPromises ?? []),...bounds]);
if (bid && phaseBidHonorsTurnActions(g,seat,opportunity,bid)) action = bid;
else {
const future = viablePhaseBidWitnesses(g,seat,g.phaseBidPromises ?? []).find(witness => witness.kind === 'future' &&
!!phaseBidCompletionRange(g.phaseBidPromises ?? [],witness));
if (!future || bidRequired(g.bidPromises ?? [],player,opportunity) ||
!phaseBidHonorsTurnActions(g,seat,opportunity,action)) return null;
}
}
return {action:{...action,phaseBidPromiseEvent:live[0].event,phaseBidPromiseLot:opportunity.lot}};
}
function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []): Action | null {
const opportunity = bidOpportunity(g);
if (!opportunity) return null;
@@ -16057,9 +16278,29 @@ function bidTruthAnswers(g: Game, p: Player, claim: BidClaim): ('yes' | 'no' | '
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' }]));
return (['yes', 'no'] as const).filter(answer => {
const phases = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn);
const caps: BidPromise[] = phases.filter(row => !row.answer).map((row,index) => ({
turn:g.turn,lot:opportunity.lot,minimum:row.minimum,event:`phase-cap-${index}`,
source:opportunity.source,player:p.id,asker:row.asker,answer:false,
}));
const claims: BidPromise[] = [...(g.bidPromises ?? []),...caps,
{ ...claim, event: 'candidate', source: opportunity.source, player: p.id,
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes' }];
const candidateAction = bidPromiseCompletion(g,p,claims);
if (!candidateAction || !phaseBidHonorsTurnActions(g,p,opportunity,candidateAction)) return false;
const phaseYes = phases.filter(row => row.answer);
const trial = structuredClone(g);
trial.bidPromises = claims;
if (!phaseYes.length || viablePhaseBidWitnesses(trial,getPlayer(trial,p.id),trial.phaseBidPromises ?? []).some(w => w.kind === 'future' &&
!!phaseBidCompletionRange(g.phaseBidPromises ?? [],w))) return true;
const required: BidPromise[] = phaseYes.map((row,index) => ({
turn:g.turn,lot:opportunity.lot,minimum:row.minimum,event:`phase-yes-${index}`,
source:opportunity.source,player:p.id,asker:row.asker,answer:true,
}));
const requiredAction = bidPromiseCompletion(g,p,[...claims,...required]);
return !!requiredAction && phaseBidHonorsTurnActions(g,p,opportunity,requiredAction);
});
}
function bindBidTruth(g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) {
const opportunity = bidOpportunity(g);
@@ -16147,6 +16388,8 @@ function projectedBidPromises(g: Game, player: string) {
}));
}
function projectedBidPromiseGuide(g: Game, player: string): { action: Action } | null {
// The phase-wide guide combines both sets of live bounds into one packet.
if (livePhaseBidPromises(g.phaseBidPromises ?? [],player,g.turn).length) return 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];
@@ -16160,6 +16403,22 @@ function guardPromisedBid(g: Game, p: Player, action: Action, amount?: number) {
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;
const phaseLive = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn);
if (amount !== undefined && phaseLive.length)
g.phaseBidPromises = phaseBidRule(() => recordPhaseBid(g.phaseBidPromises!,p.id,g.turn,amount));
if (livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn).some(promise => promise.answer)) {
const trial = structuredClone(g);
if (amount !== undefined) {
trial.auction!.bid = amount;
trial.auction!.bidder = p.id;
trial.auction!.allyPayment = normalBidQuote(g,p,amount,action.allyPayment).allyPayment;
try { trial.bidPromises = recordPromisedBid(trial.bidPromises ?? [],p.id,opportunity,amount); }
catch (error) { if (error instanceof BidPromiseError) throw new RuleError(error.message); throw error; }
}
const future = viablePhaseBidWitnesses(trial,getPlayer(trial,p.id),trial.phaseBidPromises ?? []).find(witness => witness.kind === 'future' &&
!!phaseBidCompletionRange(trial.phaseBidPromises ?? [],witness));
requireRule(future, 'Keep a funded later lot or place the bid required by your Truthtrance Yes.');
}
if (g.turnActionPromises) {
if (amount !== undefined) g.turnActionPromises = turnActionRule(() =>
recordTurnActionBid(g.turnActionPromises!, p.id, g.turn, {
@@ -20423,6 +20682,20 @@ 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 };
const phases = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn);
const lot = bidOpportunity(trial);
if (phases.length && lot) {
// The combined planner may use this actual lot, never a guessed later
// invoice. Encode both phase Yes and No in its existing native quote.
const bounds: BidPromise[] = phases.map((row,index) => ({
turn:g.turn,lot:lot.lot,minimum:row.minimum,event:`phase-turn-${index}`,
source:lot.source,player:p.id,asker:row.asker,answer:row.answer,
}));
trial.bidPromises = [...(g.bidPromises ?? []),...bounds];
} else if (phases.some(row => row.answer) && promise.compiled.kind !== 'constant') {
// Future mixed payment/transport composition has no verified planner.
continue;
}
if (turnActionCompletion(trial, p.id, answer && promise.alliance ? consent : undefined)) answers.push(answer ? 'yes' : 'no');
}
if (!answers.includes('yes') && consent.value) answers.push('unknown');
@@ -38645,6 +38918,7 @@ function normalizeCardNames(g: Game) {
}
export function applyAction(state: Game, id: string, action: Action): Game {
bidPromiseIntegrity(state);
phaseBidIntegrity(state);
revivalPromiseIntegrity(state);
bribePromiseIntegrity(state);
alliancePromiseIntegrity(state);
@@ -38811,6 +39085,12 @@ export function applyAction(state: Game, id: string, action: Action): Game {
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
'This revival commitment guide is stale or belongs to another native opportunity.');
}
if (action.phaseBidPromiseEvent !== undefined || action.phaseBidPromiseLot !== undefined) {
const guide = projectedPhaseBidGuide(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 phase bid guide is stale or belongs to another auction lot.');
}
const g = applyActionInner(state, id, action);
if (state.phase === 3 && liveTurnActionPromises(state.turnActionPromises ?? [], id, state.turn).some(row => {
const stage = row.windowCards?.karamaAuction;
@@ -38824,6 +39104,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
const voluntary = forcedThumperDraw || action.type === 'passResponse' ? undefined : { actor: id, action };
reconcileTravelPromises(g, promiseActor, action, state.phase);
reconcileBidPromises(g, voluntary);
reconcilePhaseBids(g, voluntary);
reconcileRevivalPromises(g, voluntary);
reconcileBribePromises(g, voluntary);
reconcileGroundMovementPromises(g, promiseActor, action, state.phase);
@@ -38948,6 +39229,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
reconcileBattlePromises(g, { actor: id, action });
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
reconcileBidPromises(g, { actor: id, action });
reconcilePhaseBids(g, { actor: id, action });
reconcileRevivalPromises(g, { actor: id, action });
reconcileBribePromises(g, { actor: id, action });
reconcileAlliancePromises(g, { actor: id, action });
@@ -39165,6 +39447,7 @@ function settleAutomaticContinuations(g: Game) {
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
reconcileBidPromises(g);
reconcilePhaseBids(g);
reconcileRevivalPromises(g);
reconcileBribePromises(g);
reconcileGroundMovementPromises(g);
@@ -39182,6 +39465,7 @@ function settleAutomaticContinuations(g: Game) {
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
bidPromiseIntegrity(state);
phaseBidIntegrity(state);
revivalPromiseIntegrity(state);
bribePromiseIntegrity(state);
alliancePromiseIntegrity(state);
@@ -39270,6 +39554,7 @@ export function normalizeAutomaticGame(state: Game): Game {
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
reconcileBidPromises(g);
reconcilePhaseBids(g);
reconcileRevivalPromises(g);
reconcileBribePromises(g);
reconcileGroundMovementPromises(g);
@@ -39546,6 +39831,8 @@ function applyActionInner(
bindMixedBid: bindMixedBidTruth,
bidAnswers: bidTruthAnswers,
bindBid: bindBidTruth,
phaseBidAnswers: phaseBidTruthAnswers,
bindPhaseBid: bindPhaseBidTruth,
turnActionAnswers: turnActionTruthAnswers,
bindTurnAction: bindTurnActionTruth,
travelAnswers: travelTruthAnswers,
@@ -43544,6 +43831,11 @@ 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,
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,
phaseBidPromises: projectedPhaseBidPromises(g,id),
phaseBidPromiseGuide: projectedPhaseBidGuide(g,id),
alliancePromiseNexus: (() => { const nexus = allianceOpportunity(g); return nexus ? { turn: nexus.turn } : null; })(),
truthAllianceTargets: allianceQuestionWindow(g) ? g.players.map(player => player.id) : [],
truthAllianceAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
+7 -5
View File
@@ -3652,24 +3652,26 @@ export const RULE_TOPICS: RuleTopic[] = [
},
{
id: 'truthtrance-bid',
title: 'Truthtrance: a bid in the current ordinary lot',
title: 'Truthtrance: ordinary bid commitments',
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.',
summary: 'Bind one future minimum ordinary bid in the current lot or remaining Bidding phase, preserving native 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.',
'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.',
'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, broader force-revival commitments and complete-mode acceptance remain unfinished.',
'These current-lot and phase-wide protocols are provisional online compositions of the general current-turn Truthtrance rule. Native special auctions, optional modules, grouped phase-wide bidding commitments, broader 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: '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: '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'] },
{ 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'] },
+25
View File
@@ -70,6 +70,7 @@ import { isKnowledgeFact, parseKnowledgeFact, knowledgeFactAnswer, knowledgeFact
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';
import { parsePhaseBidClaim, phaseBidClaimText, phaseBidPromiseModeSupported, type PhaseBidClaim } from './turn-bid-promises';
export type TruthFact =
| KnowledgeFact
@@ -102,6 +103,7 @@ export type TruthQuestion =
| ({ kind: 'bribe'; target: string } & BribeClaim)
| ({ kind: 'revival'; target: string } & RevivalClaim)
| ({ kind: 'bid'; target: string } & BidClaim)
| ({ kind: 'phaseBid'; target: string } & PhaseBidClaim)
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
| ({ kind: 'movement'; target: string } & MovementClaim)
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
@@ -354,6 +356,18 @@ 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 === 'phaseBid') {
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,turn',
'Supply the current turn, phase bid minimum and respondent.');
const claim = parsePhaseBidClaim(v);
check(phaseBidPromiseModeSupported(g) && g.status === 'playing' && Number.isInteger(g.phase) &&
g.phase >= 0 && g.phase <= 3 && claim.turn === g.turn &&
(g.phase < 3 || !!g.auction && !g.currentAuctionSale && !g.richeseAuction && !g.ixAuction) &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival &&
!g.pendingKarama && !g.pendingTreacheryDiscard,
'Choose the current turn and a quiet supported ordinary Bidding opportunity.');
return { kind: 'phaseBid', 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.');
@@ -718,6 +732,7 @@ export function truthQuestionText(
if (q.kind === 'bribe') return `Will you ${bribeClaimText(q, playerName)}?`;
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 === '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?`;
@@ -848,6 +863,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;
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[];
bindTravel?: (g: Game, p: Player, claim: TravelClaim, answer: boolean, asker: string) => void;
turnActionAnswers?: (g: Game, p: Player, claim: TurnActionClaim) => TruthAnswer[];
@@ -1019,6 +1036,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 === '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.');
if (w.question.kind === 'travel')
check(effects.travelAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose an unused or active native Shipment and Movement opportunity.');
@@ -1149,6 +1169,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 === '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.');
if (a.answer !== 'unknown') effects.bindPhaseBid(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'travel') {
check(effects.travelAnswers && effects.bindTravel && effects.travelAnswers(g, p, q).includes(a.answer),
'Choose a whole travel answer compatible with native actions and earlier commitments.');
+149
View File
@@ -0,0 +1,149 @@
import { bidPromiseModeSupported } from './bid-promises';
/** One accepted ordinary bid after the answer, in this turn's Bidding phase. */
export type PhaseBidClaim = { turn: number; minimum: number };
export type PhaseBidPromise = PhaseBidClaim & {
event: string;
player: string;
asker: string;
answer: boolean;
fulfilled?: boolean;
released?: boolean;
};
/** An engine-verified inclusive interval for a native, funded declaration.
* A future interval is a conservative witness: the engine must prove the next
* lot can be reached and paid for from the actual state, including committed
* invoices and capacity. An unbid lot and speculative rival actions do not
* establish a later lot. This helper cannot verify those native facts itself. */
export type PhaseBidOpportunity = {
turn: number;
player: string;
kind: 'current' | 'future';
minimum: number;
maximum: number;
};
export type PhaseBidRange = { minimum: number; maximum: number };
export type PhaseBidChange = 'voluntary' | 'external';
export class PhaseBidPromiseError 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 parsePhaseBidClaim(value: unknown): PhaseBidClaim {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new PhaseBidPromiseError('Choose a turn-wide ordinary bid question.');
const input = value as Record<string, unknown>;
if (!positive(input.turn) || !positive(input.minimum))
throw new PhaseBidPromiseError('Choose the current turn and a positive whole bid minimum.');
return { turn: input.turn, minimum: input.minimum };
}
export const phaseBidPromiseModeSupported = bidPromiseModeSupported;
export function phaseBidClaimText(claim: PhaseBidClaim): string {
return `place a bid of at least ${claim.minimum} spice from now until this turn's Bidding phase closes`;
}
export function livePhaseBidPromises(promises: readonly PhaseBidPromise[], player: string, turn: number): PhaseBidPromise[] {
return promises.filter(promise => promise.player === player && promise.turn === turn &&
!promise.fulfilled && !promise.released);
}
function checkedOpportunity(opportunity: PhaseBidOpportunity): void {
if (!opportunity || !positive(opportunity.turn) || !identifier(opportunity.player) ||
opportunity.kind !== 'current' && opportunity.kind !== 'future' ||
!positive(opportunity.minimum) || !positive(opportunity.maximum) || opportunity.minimum > opportunity.maximum)
throw new PhaseBidPromiseError('Retain an actual funded native bid opportunity.');
}
/** Allowed native bids respect every live No; a smaller bid may remain legal
* while a Yes remains achievable in a later verified native opportunity. */
export function phaseBidAllowedRange(promises: readonly PhaseBidPromise[], opportunity: PhaseBidOpportunity): PhaseBidRange | null {
checkedOpportunity(opportunity);
let maximum = opportunity.maximum;
for (const promise of livePhaseBidPromises(promises, opportunity.player, opportunity.turn))
if (!promise.answer) maximum = Math.min(maximum, promise.minimum - 1);
return opportunity.minimum <= maximum ? { minimum: opportunity.minimum, maximum } : null;
}
/** A single qualifying declaration can fulfill all remaining Yes answers. */
export function phaseBidCompletionRange(promises: readonly PhaseBidPromise[], opportunity: PhaseBidOpportunity): PhaseBidRange | null {
const range = phaseBidAllowedRange(promises, opportunity);
if (!range) return null;
let minimum = range.minimum;
for (const promise of livePhaseBidPromises(promises, opportunity.player, opportunity.turn))
if (promise.answer) minimum = Math.max(minimum, promise.minimum);
return minimum <= range.maximum ? { minimum, maximum: range.maximum } : null;
}
/** Call only for a real accepted native bid, inside the detached engine
* transaction. A previous bid, final invoice or sum of bids is not a receipt. */
export function recordPhaseBid<T extends PhaseBidPromise>(promises: readonly T[], player: string,
turn: number, amount: number): T[] {
if (!identifier(player) || !positive(turn) || !positive(amount))
throw new PhaseBidPromiseError('Record only an accepted positive whole native bid.');
const live = livePhaseBidPromises(promises, player, turn);
if (live.some(promise => !promise.answer && amount >= promise.minimum))
throw new PhaseBidPromiseError('This bid conflicts with your Truthtrance No answer for this Bidding phase.');
return promises.map(promise => live.includes(promise) && promise.answer && amount >= promise.minimum
? { ...promise, fulfilled: true } : promise);
}
/** Reconcile only after the engine has supplied a complete set of truthful
* post-action witnesses. Missing or uncomputed quotes are not impossibility.
* A respondent may not voluntarily eliminate their last feasible Yes route;
* another player's action can release only the Yes answers it makes impossible.
* This is a conservative implementation policy, not a printed timing rule. */
export function reconcilePhaseBidPromises<T extends PhaseBidPromise>(promises: readonly T[], player: string,
turn: number, opportunities: readonly PhaseBidOpportunity[], change: PhaseBidChange): T[] {
if (!identifier(player) || !positive(turn) || !Array.isArray(opportunities) ||
change !== 'voluntary' && change !== 'external')
throw new PhaseBidPromiseError('Retain the complete native bid opportunities and cause.');
for (const opportunity of opportunities) {
checkedOpportunity(opportunity);
if (opportunity.player !== player || opportunity.turn !== turn)
throw new PhaseBidPromiseError('Bid opportunities must belong to the original respondent and turn.');
}
const live = livePhaseBidPromises(promises, player, turn);
const impossible = live.filter(promise => promise.answer && !opportunities.some(opportunity => {
const range = phaseBidAllowedRange(promises, opportunity);
return range !== null && Math.max(range.minimum, promise.minimum) <= range.maximum;
}));
if (change === 'voluntary' && impossible.length)
throw new PhaseBidPromiseError('This action would remove the last funded bid opportunity for your Truthtrance Yes answer.');
return promises.map(promise => impossible.includes(promise) ? { ...promise, released: true } : promise);
}
/** Call at actual phase close, after own-action preflight. No is established
* by the absence of a qualifying accepted bid; unfulfilled Yes is released. */
export function finishPhaseBidPhase<T extends PhaseBidPromise>(promises: readonly T[], player: string,
turn: number, change: PhaseBidChange): T[] {
if (!identifier(player) || !positive(turn) || change !== 'voluntary' && change !== 'external')
throw new PhaseBidPromiseError('Retain the actual phase close and its cause.');
const live = livePhaseBidPromises(promises, player, turn);
if (change === 'voluntary' && live.some(promise => promise.answer))
throw new PhaseBidPromiseError('The Bidding phase cannot be closed by abandoning your Truthtrance Yes answer.');
return promises.map(promise => live.includes(promise)
? promise.answer ? { ...promise, released: true } : { ...promise, fulfilled: true }
: promise);
}
export function validatePhaseBidPromises(promises: readonly PhaseBidPromise[], players: readonly string[]): void {
if (!Array.isArray(promises) || !Array.isArray(players))
throw new PhaseBidPromiseError('The phase bid commitment record is invalid.');
const events = new Set<string>();
for (const promise of promises) {
parsePhaseBidClaim(promise);
if (!identifier(promise.event) || events.has(promise.event) || !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 PhaseBidPromiseError('The phase bid commitment lost its original event or participants.');
events.add(promise.event);
}
}
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import type { Game } from '../game/engine';
import { actTruthBid, advanceTruthBid, createTruthBidFixture, declareTruthBid,
nextTruthBid, truthBidClean, truthBidPlayer, type TruthBidStep } from './fixture-truth-bid';
export type TruthTurnBidStep = TruthBidStep;
export const actTruthTurnBid = actTruthBid;
export const truthTurnBidPlayer = truthBidPlayer;
export const truthTurnBidClean = truthBidClean;
export const advanceTruthTurnBid = advanceTruthBid;
/** Original H/A/E (optional G) setup, starting deal, ordinary lot creation,
* and native controls from the shared fixture; only unopened original deal,
* lot/Spice positions and scalar lotteries are selected. */
export function createTruthTurnBidFixture(options: { advanced?: boolean; extraGuild?: boolean } = {}) {
const original = createTruthBidFixture({ advanced: options.advanced, extraGuild: options.extraGuild, window:'waiting' });
return { ...original, phaseQuestion: { kind:'phaseBid' as const, target:original.target,
turn:original.game.turn, minimum:4 },
sourceDisclosure: `${original.sourceDisclosure} Phase-wide question, answer and all subsequent bids remain caller actions.` };
}
export type TruthTurnBidFixture = ReturnType<typeof createTruthTurnBidFixture>;
export function bindTruthTurnBid(f: TruthTurnBidFixture, answer: 'yes'|'no', minimum = 4, state = f.game) {
const declared = declareTruthBid(f,state), actions = [...declared.actions];
let game = actTruthBid(declared.game,f.asker,{ type:'truthAsk', question: {
kind:'phaseBid', target:f.target, turn:declared.game.turn, minimum,
} },actions);
const asked = structuredClone(game);
game = actTruthBid(game,f.target,{type:'truthAnswer',answer},actions);
return { game, declared, asked, actions };
}
/** Finish exactly the existing lot after a genuine winning bid; no future bid
* or pool is installed. Neutral native continuations pay and advance it. */
export function finishTruthTurnBidLot(game: Game, actions: TruthBidStep[] = []) {
assert.ok(game.auction && game.auction.bidder && game.auction.bid > 0);
const turn = game.turn, index = game.auction.index;
return advanceTruthBid(game,state => truthBidClean(state) &&
(state.turn !== turn || state.phase !== 3 || !state.auction || state.auction.index !== index),actions);
}
/** Purchase this actual lot for one spice by the named player. Other bidders
* pass using native controls; native payment and Emperor income settle. */
export function purchaseTruthTurnBidLot(game: Game, winner: string, actions: TruthBidStep[] = []) {
assert.ok(game.auction); const turn = game.turn, index = game.auction.index;
const card = game.auction.cards[index].id, before = structuredClone(game);
for (let n = 0; n < 150; n++) {
if (truthBidClean(game) && (game.turn !== turn || game.phase !== 3 || !game.auction || game.auction.index !== index)) {
assert.ok(truthBidPlayer(game,winner).hand.some(c => c.id === card));
return {game,before,card};
}
if (truthBidClean(game) && game.auction && game.auction.index === index && !game.currentAuctionSale) {
const actor = game.auction.active;
game = actTruthBid(game,actor,actor === winner && !game.auction.bidder
? {type:'bid',amount:1,allyPayment:0} : {type:'passBid'},actions);
} else {
const step = nextTruthBid(game); assert.ok(step);
game = actTruthBid(game,step.actor,step.action,actions);
}
}
throw Error('The actual funded ordinary purchase did not finish');
}
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { PhaseBidClaimFields, phaseBidClaimInputError } from '../components/phase-bid-claim-fields';
import { PhaseBidPromises, PhaseBidPromiseGuideButton } from '../components/phase-bid-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { PhaseBidClaim } from '../game/turn-bid-promises';
type ElementProps = { children?: ReactNode; [key: string]: unknown };
function elements(node: ReactNode): ReactElement<ElementProps>[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function edit(props: Parameters<typeof PhaseBidClaimFields>[0], minimum: number) {
const input = elements(PhaseBidClaimFields(props)).find(element =>
element.props.id === 'truth-phase-bid-minimum' && typeof element.props.onChange === 'function');
assert.ok(input);
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber: minimum } });
}
/** Controlled projections exercise the rendered owned control, not a real question. */
function projected(type: 'bid' | 'passBid' = 'bid'): GameView {
const source = createGame('TRUTHPHASEBIDCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.phase = 3; game.turn = 2;
game.auction = { bid: 1, bidder: 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null };
game.bidPromiseLot = { turn: 2, lot: 1 };
game.phaseBidPromises = [{ event: 'owned-event', player: 'p', asker: 'a', turn: 2, minimum: 4, answer: type === 'bid' }];
game.phaseBidPromiseGuide = { action: type === 'bid'
? { type: 'bid', amount: 4, allyPayment: 1, phaseBidPromiseEvent: 'owned-event', phaseBidPromiseLot: 1 }
: { type: 'passBid', phaseBidPromiseEvent: 'owned-event', phaseBidPromiseLot: 1 } };
return game;
}
void test('phase bid editor preserves the current turn and accepts only whole positive minima', () => {
const value: PhaseBidClaim = { turn: 2, minimum: 4 }, changes: PhaseBidClaim[] = [];
const props = { value, onChange(next: PhaseBidClaim) { changes.push(next); } };
const html = renderToStaticMarkup(createElement(PhaseBidClaimFields, props));
assert.match(html, /Turn 2/);
assert.match(html, /before Bidding ends this turn/);
assert.match(html, /Earlier bids do not count/);
for (const minimum of [1, 7, Number.MAX_SAFE_INTEGER]) edit(props, minimum);
assert.deepEqual(changes, [1, 7, Number.MAX_SAFE_INTEGER].map(minimum => ({ ...value, minimum })));
for (const minimum of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
assert.ok(phaseBidClaimInputError({ ...value, minimum })); edit(props, minimum);
}
edit({ ...props, disabled: true }, 8);
assert.equal(changes.length, 3);
});
void test('phase promises remain visible and do not expose another player’s promises', () => {
const game = projected();
game.phaseBidPromises.push(
{ event: 'no', player: 'p', asker: 'a', turn: 2, minimum: 8, answer: false },
{ event: 'done', player: 'p', asker: 'a', turn: 2, minimum: 2, answer: true, fulfilled: true },
{ event: 'released', player: 'p', asker: 'a', turn: 2, minimum: 12, answer: true, released: true },
{ event: 'foreign', player: 'a', asker: 'p', turn: 2, minimum: 99, answer: true },
);
game.auction!.active = 'a'; game.phaseBidPromiseGuide = null;
const before = structuredClone(game);
const html = renderToStaticMarkup(createElement(PhaseBidPromises, { game, busy: false, send() {} }));
assert.match(html, /Your Bidding phase promises/);
assert.match(html, /No forbids qualifying bids through Bidding close/);
assert.match(html, /Fulfilled: this commitment is complete/);
assert.match(html, /Released: this commitment is no longer possible/);
assert.match(html, /Your next bid opportunity is still ahead/);
assert.doesNotMatch(html, /99 spice|foreign/);
assert.deepEqual(game, before);
});
void test('the real guide callback preserves its event and rejects stale or busy projections', () => {
for (const type of ['bid', 'passBid'] as const) {
const game = projected(type), sent: Action[] = [];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = PhaseBidPromiseGuideButton(props); assert.ok(button);
button.props.onClick();
assert.deepEqual(sent, [game.phaseBidPromiseGuide!.action]);
assert.equal(sent[0], game.phaseBidPromiseGuide!.action);
const stale = PhaseBidPromiseGuideButton(props); assert.ok(stale);
game.phaseBidPromises[0].event = 'different-event'; stale.props.onClick();
assert.equal(sent.length, 1);
}
const game = projected(), sent: Action[] = [];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = PhaseBidPromiseGuideButton(props); assert.ok(button);
props.busy = true; button.props.onClick();
assert.deepEqual(sent, []);
props.busy = false;
const priorLotButton = PhaseBidPromiseGuideButton(props); assert.ok(priorLotButton);
game.bidPromiseLot = { turn: 2, lot: 2 };
priorLotButton.props.onClick();
assert.deepEqual(sent, []);
});
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type Action, type GameView } from '../game/engine';
import { PhaseBidPromiseGuideButton } from '../components/phase-bid-promises';
import { actTruthTurnBid, advanceTruthTurnBid, bindTruthTurnBid,
createTruthTurnBidFixture, truthTurnBidClean } from './fixture-truth-turn-bid';
import { truthBidInventory } from './fixture-truth-bid';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { GameTable } = await import('../components/game-table');
const { Truthtrance } = await import('../components/truthtrance');
aliases.deregister();
function table(game: GameView) {
return renderToStaticMarkup(createElement(GameTable, { game, busy: false, send: async () => {}, onExit() {} }));
}
function truth(game: GameView) {
return renderToStaticMarkup(createElement(Truthtrance, { game, busy: false, act() {} }));
}
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} actual phase-bid question, private answer and current owned guide connect to the original table`, () => {
// Genuine original public setup/card deal and actual question/answer; the
// selected unopened deck/Spice positions are documented by the source fixture.
const f = createTruthTurnBidFixture({ advanced }), b = bindTruthTurnBid(f, 'yes');
const asker = viewGame(b.declared.game, f.asker);
assert.ok(asker.truthPhaseBidTargets.includes(f.target));
const authored = truth(asker);
assert.match(authored, /<option value="phaseBid">Bind a bid this Bidding phase<\/option>/);
assert.match(table(asker), /Bind a bid this Bidding phase/);
// Server rendering does not operate the stateful question selector; the
// fixture's actual native question is submitted by its engine action.
const respondent = viewGame(b.asked, f.target);
assert.deepEqual(respondent.truthtrance?.question, f.phaseQuestion);
assert.ok(respondent.truthPhaseBidAnswers?.includes('yes'));
assert.ok(respondent.truthPhaseBidAnswers?.includes('no'));
assert.equal(viewGame(b.asked, f.asker).truthPhaseBidAnswers, null);
const answers = truth(respondent);
assert.match(answers, /Answer Yes/);
assert.match(answers, /Answer No/);
assert.match(table(respondent), /Answer Yes/);
let game = advanceTruthTurnBid(b.game,
state => truthTurnBidClean(state) && state.phase === 3 && state.auction?.active === f.target);
const own = viewGame(game, f.target), sent: Action[] = [];
assert.ok(own.phaseBidPromises.some(promise => promise.player === f.target && promise.answer));
assert.ok(own.phaseBidPromiseGuide);
const html = table(own);
assert.match(html, /Your Bidding phase promises/);
assert.match(html, /data-testid="phase-bid-promise-guide"/);
const button = PhaseBidPromiseGuideButton({ game: own, busy: false, send(action) { sent.push(action); } });
assert.ok(button);
assert.equal(button.props.disabled, false);
button.props.onClick();
assert.deepEqual(sent, [own.phaseBidPromiseGuide.action]);
assert.equal(typeof sent[0].phaseBidPromiseEvent, 'string');
assert.equal(sent[0].phaseBidPromiseLot, own.bidPromiseLot!.lot);
game = actTruthTurnBid(game, f.target, sent[0]);
assert.equal(game.phaseBidPromises!.find(promise => promise.event === sent[0].phaseBidPromiseEvent)?.fulfilled, true);
assert.deepEqual(truthBidInventory(game), f.originalInventory);
},
);
+383
View File
@@ -0,0 +1,383 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { actTruthBid as act, advanceTruthBid as advance, askTruthBid, answerTruthBid,
declareTruthBid, truthBidClean as clean, truthBidInventory as inventory } from './fixture-truth-bid';
import { bindTruthTurnBid, createTruthTurnBidFixture, finishTruthTurnBidLot,
purchaseTruthTurnBidLot } from './fixture-truth-turn-bid';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game,actor,action)); assert.deepEqual(game,before);
}
/** Fork the genuine public setup before its selected unopened Spice positions,
* reapply only that original selection, and follow native controls to Charity.
* No played purse, hand, phase or promise is staged. */
function early(advanced: boolean) {
const f = createTruthTurnBidFixture({advanced});
let game = structuredClone(f.afterSetup);
for (const {position,from,card} of f.spiceOrder) {
const [selected] = game.spiceDeck.splice(from,1);
assert.deepEqual(selected,card); game.spiceDeck.splice(position,0,selected);
}
game = advance(game,g => g.turn === 1 && g.phase === 2 && clean(g));
return {f,game};
}
function phaseAsk(game: Game, asker: string, target: string, minimum = 4) {
return act(game,asker,{type:'truthAsk',question:{kind:'phaseBid',target,turn:game.turn,minimum}});
}
for (const advanced of [false,true]) {
const mode = advanced ? 'Advanced' : 'Basic';
for (const answer of ['yes','no'] as const) void test(`${mode} pre-Bidding phase ${answer} binds a real later auction declaration`, () => {
const {f,game:source} = early(advanced);
assert.equal(source.phase,2); assert.equal(source.auction,null);
const bound = bindTruthTurnBid(f,answer,4,source);
assert.ok(viewGame(bound.game,f.target).phaseBidPromises.some(p => p.minimum === 4 && p.answer === (answer === 'yes')));
let game = advance(bound.game,g => clean(g) && g.phase === 3 && g.auction?.active === f.target);
if (answer === 'yes') {
reject(game,f.target,{type:'passBid'});
game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.fulfilled));
} else {
reject(game,f.target,{type:'bid',amount:4,allyPayment:0});
game = act(game,f.target,{type:'bid',amount:1,allyPayment:0});
}
assert.deepEqual(inventory(game),f.originalCards);
});
void test(`${mode} existing current-lot Yes prevents a contradictory phase-wide No`, () => {
const f = createTruthTurnBidFixture({advanced});
let game = declareTruthBid(f,f.game,{copies:2}).game;
game = askTruthBid(game,f.asker,f.target,4);
game = answerTruthBid(game,f.target,'yes');
game = phaseAsk(game,f.asker,f.target,4);
assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('no'));
reject(game,f.target,{type:'truthAnswer',answer:'no'});
game = answerTruthBid(game,f.target,'yes');
game = advance(game,g => clean(g) && g.auction?.active === f.target);
game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
assert.ok(game.bidPromises?.some(p => p.player === f.target && p.fulfilled));
assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
assert.deepEqual(inventory(game),f.originalCards);
});
void test(`${mode} phase Yes and current-lot No coexist when a real committed sale opens a later lot`, () => {
const f = createTruthTurnBidFixture({advanced});
let game = act(f.game,f.asker,{type:'bid',amount:1,allyPayment:0});
game = act(game,f.opponent,{type:'passBid'});
assert.equal(game.auction?.active,f.target); assert.equal(game.auction?.bidder,f.asker);
game = declareTruthBid(f,game,{copies:2}).game;
game = phaseAsk(game,f.asker,f.target,4);
game = answerTruthBid(game,f.target,'yes');
game = askTruthBid(game,f.asker,f.target,4);
assert.ok(viewGame(game,f.target).truthBidAnswers?.includes('no'));
game = answerTruthBid(game,f.target,'no');
reject(game,f.target,{type:'bid',amount:4,allyPayment:0});
game = act(game,f.target,{type:'passBid'});
if (game.auction?.index === 0) game = finishTruthTurnBidLot(game);
assert.equal(game.auction?.index,1);
game = advance(game,g => clean(g) && g.auction?.active === f.target);
game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
assert.deepEqual(inventory(game),f.originalCards);
});
void test(`${mode} phase Yes keeps its funded bid and cannot rely on a future buyer after everyone passes`, () => {
const f = createTruthTurnBidFixture({advanced});
let game = bindTruthTurnBid(f,'yes',4).game;
reject(game,f.target,{type:'bribe',target:f.asker,amount:7});
game = act(game,f.target,{type:'bribe',target:f.asker,amount:6});
assert.equal(game.players.find(p => p.id === f.target)!.spice,4);
game = act(game,f.asker,{type:'passBid'});
game = act(game,f.opponent,{type:'passBid'});
assert.equal(game.auction?.bidder,null); assert.equal(game.auction?.active,f.target);
reject(game,f.target,{type:'passBid'});
game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
assert.deepEqual(inventory(game),f.originalCards);
});
void test(`${mode} an original phase bid guide is stale after its lot actually retires`, () => {
const f = createTruthTurnBidFixture({advanced});
let game = act(f.game,f.asker,{type:'bid',amount:1,allyPayment:0});
game = act(game,f.opponent,{type:'passBid'});
game = bindTruthTurnBid(f,'no',4,game).game;
const guide = viewGame(game,f.target).phaseBidPromiseGuide; assert.ok(guide);
const packet = structuredClone(guide.action);
assert.equal(packet.type,'passBid');
game = act(game,f.target,packet);
if (game.auction?.index === 0) game = finishTruthTurnBidLot(game);
assert.equal(game.auction?.index,1);
game = advance(game,g => clean(g) && g.auction?.active === f.target);
reject(game,f.target,packet);
assert.ok(viewGame(game,f.target).phaseBidPromiseGuide);
});
}
/** Controlled supplement only: install the two already-answered, canonical
* current-lot receipts on the genuine live auction, with its exact original
* physical lot binding. No card/force/lot is manufactured. */
function stagedLotBounds(game: Game, target: string, asker: string, yesMinimum = 5, noMinimum = 6) {
assert.ok(game.auction);
const source = JSON.stringify(['ordinaryBid',game.turn,game.auction.index,game.auction.cards[game.auction.index].id]);
game.bidPromises = [
{turn:game.turn,lot:game.auction.index+1,minimum:yesMinimum,event:'controlled-lot-yes',source,player:target,asker,answer:true},
{turn:game.turn,lot:game.auction.index+1,minimum:noMinimum,event:'controlled-lot-no',source,player:target,asker,answer:false},
];
}
function askAs(game: Game, asker: string, card: string, target: string, minimum: number) {
game = act(game,asker,{type:'card',card});
for (let count=0; game.truthtrance?.stage === 'priority' && count<game.players.length; count++) {
const p = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(p);
game = act(game,p.id,{type:'truthPass'});
}
assert.equal(game.truthtrance?.stage,'ask');
return phaseAsk(game,asker,target,minimum);
}
for (const advanced of [false,true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} a new lot Yes cannot erase the funded future needed by an existing phase Yes`, () => {
const f = createTruthTurnBidFixture({advanced});
let game = act(f.game,f.asker,{type:'bid',amount:4,allyPayment:0});
game = act(game,f.opponent,{type:'passBid'});
assert.equal(game.auction?.active,f.target); assert.ok(game.auction?.cards[1]);
game = structuredClone(game);
// Controlled existing answers and wallet: H's real committed4 sale leaves
// E's six spice for its existing phase Yes6. Current-lot No6 prevents E6;
// a proposed lot Yes5 would compel a purchase and leave only one spice.
game.players.find(p => p.id === f.target)!.spice = 6;
stagedLotBounds(game,f.target,f.asker);
game.bidPromises = game.bidPromises!.filter(promise => !promise.answer);
game.phaseBidPromises = [{turn:game.turn,minimum:6,event:'controlled-phase-yes',
player:f.target,asker:f.asker,answer:true}];
game = declareTruthBid(f,game).game;
game = askTruthBid(game,f.asker,f.target,5);
assert.ok(!viewGame(game,f.target).truthBidAnswers?.includes('yes'));
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
game = answerTruthBid(game,f.target,'no');
assert.deepEqual(inventory(game),f.originalCards);
});
void test(`${mode} a live whole-turn No for the last lot excludes the same phase-wide Yes`, () => {
const f = createTruthTurnBidFixture({advanced}); let game = f.game;
// Both purchases and the two independent Truth cards are native; no
// question, wallet or auction state is installed for this source.
for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
assert.equal(game.auction?.index,2);
game = advance(game,g => clean(g) && g.auction?.active === f.target);
game = declareTruthBid(f,game).game;
game = act(game,f.asker,{type:'truthAsk',question:{kind:'turnActions',target:f.target,
turn:game.turn,mixed:{kind:'action',action:{kind:'bid',lot:3,minimum:6}}}});
assert.ok(viewGame(game,f.target).truthTurnActionAnswers?.includes('no'));
game = answerTruthBid(game,f.target,'no');
assert.ok(game.turnActionPromises?.some(p => p.player === f.target && !p.answer && !p.fulfilled && !p.released));
game = askAs(game,f.asker,f.truths[1],f.target,6);
assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('yes'));
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
game = answerTruthBid(game,f.target,'no');
assert.deepEqual(inventory(game),f.originalCards);
});
void test(`${mode} phase Yes cannot ignore a forced current-lot payment that consumes future funding`, () => {
const f = createTruthTurnBidFixture({advanced});
let game = act(f.game,f.asker,{type:'bid',amount:1,allyPayment:0});
game = act(game,f.opponent,{type:'passBid'});
assert.equal(game.auction?.active,f.target); assert.ok(game.auction?.cards[1]);
// Explicit controlled wallet/answers; the physical auction and H1 bid are
// actual native actions. E6 must bid exactly5 under its prior Yes5/No6.
game = structuredClone(game);
game.players.find(p => p.id === f.target)!.spice = 6;
stagedLotBounds(game,f.target,f.asker);
game = askAs(game,f.asker,f.truth,f.target,6);
assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('yes'));
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
game = answerTruthBid(game,f.target,'no');
assert.deepEqual(inventory(game),f.originalCards);
});
void test(`${mode} phase Yes cannot assume Harkonnen will avoid its purchase bonus to make a later slot`, () => {
const f = createTruthTurnBidFixture({advanced});
let game = structuredClone(f.game);
const hark = game.players.find(p => p.id === f.asker)!;
const atreides = game.players.find(p => p.id === f.opponent)!;
// Explicit conserved prior-card transfers: Atreides holds the original
// Truthtrance to ask; Harkonnen has six held cards (two open slots). The
// original undealt deck retains a possible Harkonnen bonus card.
const index = hark.hand.findIndex(c => c.id === f.truth); assert.ok(index >= 0);
atreides.hand.push(hark.hand.splice(index,1)[0]);
while (hark.hand.length < 6) {
const from = game.deck.findIndex(c => ['projectile','poison','shield','snooper','worthless'].includes(c.kind));
assert.ok(from >= 0); hark.hand.push(game.deck.splice(from,1)[0]);
}
assert.ok(game.deck.length > 0); assert.equal(hark.hand.length,6);
game = act(game,f.asker,{type:'passBid'});
game = act(game,f.opponent,{type:'passBid'});
game = act(game,f.target,{type:'bid',amount:1,allyPayment:0});
assert.equal(game.auction?.active,f.asker); assert.ok(game.auction?.cards[1]);
// Harkonnen has ten spice. Its existing Yes2/No4 permits a bid2,
// leaving eight spice after payment; capacity alone removes the future.
assert.equal(game.players.find(p => p.id === f.asker)!.spice,10);
stagedLotBounds(game,f.asker,f.opponent,2,4);
game = askAs(game,f.opponent,f.truth,f.asker,4);
assert.ok(!viewGame(game,f.asker).truthPhaseBidAnswers?.includes('yes'));
reject(game,f.asker,{type:'truthAnswer',answer:'yes'});
game = answerTruthBid(game,f.asker,'no');
assert.deepEqual(inventory(game),f.originalCards);
});
void test(`${mode} an opponent's unfunded Karama-only high bid cannot certify a future lot`, () => {
const f = createTruthTurnBidFixture({advanced});
let game = structuredClone(f.game);
// Explicit conserved prior acquisition: Harkonnen holds an undealt real
// Karama, and can legally declare11 on its actual ten-spice purse. The
// unrelated bidder's future choice to spend that card is not assumed.
const from = game.deck.findIndex(c => c.effect === 'karama'); assert.ok(from >= 0);
game.players.find(p => p.id === f.asker)!.hand.push(game.deck.splice(from,1)[0]);
game = act(game,f.asker,{type:'bid',amount:11,allyPayment:0});
game = act(game,f.opponent,{type:'passBid'});
assert.equal(game.auction?.active,f.target); assert.equal(game.auction?.bidder,f.asker);
assert.ok(game.auction?.cards[1]);
game = askAs(game,f.asker,f.truth,f.target,6);
assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('yes'));
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
game = answerTruthBid(game,f.target,'no');
assert.deepEqual(inventory(game),f.originalCards);
});
}
for (const advanced of [false,true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} a phase bid Yes cannot spend the funds required by a live whole-turn bribe`, () => {
const f = createTruthTurnBidFixture({advanced}); let game = f.game;
// Actual A1 purchases pay E one spice each; E then really commits a
// separate bribe2, leaving ten. Both Truth cards and the last lot are
// original components. No wallet, promise or auction state is staged.
for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
assert.equal(game.auction?.index,2);
game = act(game,f.target,{type:'bribe',target:f.asker,amount:2});
assert.equal(game.players.find(p => p.id === f.target)!.spice,10);
game = advance(game,g => clean(g) && g.auction?.active === f.target);
game = declareTruthBid(f,game).game;
game = act(game,f.asker,{type:'truthAsk',question:{kind:'turnActions',target:f.target,
turn:game.turn,mixed:{kind:'action',action:{kind:'payment',recipient:f.asker,minimum:5}}}});
assert.ok(viewGame(game,f.target).truthTurnActionAnswers?.includes('yes'));
game = answerTruthBid(game,f.target,'yes');
const prior = structuredClone(game.turnActionPromises![0]);
game = askAs(game,f.asker,f.truths[1],f.target,6);
assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('yes'));
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
assert.deepEqual(game.turnActionPromises![0],prior);
game = answerTruthBid(game,f.target,'no');
assert.deepEqual(game.turnActionPromises![0],prior);
assert.deepEqual(inventory(game),f.originalCards);
});
}
/** Use the second real physical Truth card to ask one whole-turn action after
* a phase-bid answer. Keep the normal declaration and priority actions. */
function askTurnActionAs(game: Game, asker: string, card: string, target: string,
action: {kind:'payment';recipient:string;minimum:number} | {kind:'bid';lot:number;minimum:number}) {
game = act(game,asker,{type:'card',card});
for (let count=0; game.truthtrance?.stage === 'priority' && count<game.players.length; count++) {
const p = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(p);
game = act(game,p.id,{type:'truthPass'});
}
assert.equal(game.truthtrance?.stage,'ask');
return act(game,asker,{type:'truthAsk',question:{kind:'turnActions',target,turn:game.turn,
mixed:{kind:'action',action}}});
}
for (const advanced of [false,true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} an existing phase bid Yes excludes an unfunded later whole-turn bribe Yes`, () => {
const f = createTruthTurnBidFixture({advanced}); let game = f.game;
// Real A1 purchases twice give E two income; E's real bribe2 leaves ten.
// The still-unresolved original last lot and both Truth cards are native.
for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
assert.equal(game.auction?.index,2);
game = act(game,f.target,{type:'bribe',target:f.asker,amount:2});
assert.equal(game.players.find(p => p.id === f.target)!.spice,10);
game = advance(game,g => clean(g) && g.auction?.active === f.target);
game = bindTruthTurnBid(f,'yes',6,game).game;
const prior = structuredClone(game.phaseBidPromises![0]);
game = askTurnActionAs(game,f.asker,f.truths[1],f.target,
{kind:'payment',recipient:f.asker,minimum:5});
assert.ok(!viewGame(game,f.target).truthTurnActionAnswers?.includes('yes'));
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
assert.deepEqual(game.phaseBidPromises![0],prior);
game = answerTruthBid(game,f.target,'no');
assert.deepEqual(game.phaseBidPromises![0],prior);
assert.deepEqual(inventory(game),f.originalCards);
});
void test(`${mode} an existing phase bid No excludes a later whole-turn bid Yes on the final lot`, () => {
const f = createTruthTurnBidFixture({advanced}); let game = f.game;
// Two actual purchases leave the actual third and last prepared lot.
for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
assert.equal(game.auction?.index,2);
game = advance(game,g => clean(g) && g.auction?.active === f.target);
game = bindTruthTurnBid(f,'no',6,game).game;
const prior = structuredClone(game.phaseBidPromises![0]);
game = askTurnActionAs(game,f.asker,f.truths[1],f.target,
{kind:'bid',lot:game.auction!.index+1,minimum:6});
assert.ok(!viewGame(game,f.target).truthTurnActionAnswers?.includes('yes'));
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
assert.deepEqual(game.phaseBidPromises![0],prior);
game = answerTruthBid(game,f.target,'no');
assert.deepEqual(game.phaseBidPromises![0],prior);
assert.deepEqual(inventory(game),f.originalCards);
});
}
for (const advanced of [false,true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} an offered independent bribe Yes can be accepted before Bidding alongside a funded phase bid Yes`, () => {
const {f,game:source} = early(advanced);
// Genuine Charity source: E has ten spice; original H/A/E setup and the
// two actual Truth cards are retained. The two requirements cost nine.
assert.equal(source.phase,2);
assert.equal(source.players.find(p => p.id === f.target)!.spice,10);
let game = bindTruthTurnBid(f,'yes',6,source).game;
const phasePromise = structuredClone(game.phaseBidPromises![0]);
game = act(game,f.asker,{type:'card',card:f.truths[1]});
for (let count=0; game.truthtrance?.stage === 'priority' && count<game.players.length; count++) {
const p = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(p);
game = act(game,p.id,{type:'truthPass'});
}
assert.equal(game.truthtrance?.stage,'ask');
game = act(game,f.asker,{type:'truthAsk',question:{kind:'bribe',target:f.target,
turn:game.turn,recipient:f.asker,minimum:3}});
assert.ok(viewGame(game,f.target).truthBribeAnswers?.includes('yes'));
game = answerTruthBid(game,f.target,'yes');
assert.equal(game.truthtrance,null);
assert.deepEqual(game.phaseBidPromises![0],phasePromise);
assert.ok(game.bribePromises?.some(p => p.player === f.target && p.recipient === f.asker && p.answer));
game = act(game,f.target,{type:'bribe',target:f.asker,amount:3});
assert.equal(game.players.find(p => p.id === f.target)!.spice,7);
assert.ok(game.bribePromises?.some(p => p.player === f.target && p.fulfilled));
game = advance(game,g => clean(g) && g.phase === 3 && g.auction?.active === f.target);
game = act(game,f.target,{type:'bid',amount:6,allyPayment:0});
assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
assert.deepEqual(inventory(game),f.originalCards);
});
}
for (const advanced of [false,true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} an underfunded independent bribe Yes is unavailable before Bidding while phase bid Yes survives`, () => {
const {f,game:source} = early(advanced);
assert.equal(source.phase,2);
assert.equal(source.players.find(p => p.id === f.target)!.spice,10);
let game = bindTruthTurnBid(f,'yes',6,source).game;
const phasePromise = structuredClone(game.phaseBidPromises![0]);
game = act(game,f.asker,{type:'card',card:f.truths[1]});
for (let count=0; game.truthtrance?.stage === 'priority' && count<game.players.length; count++) {
const p = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(p);
game = act(game,p.id,{type:'truthPass'});
}
assert.equal(game.truthtrance?.stage,'ask');
game = act(game,f.asker,{type:'truthAsk',question:{kind:'bribe',target:f.target,
turn:game.turn,recipient:f.asker,minimum:5}});
assert.ok(!viewGame(game,f.target).truthBribeAnswers?.includes('yes'));
assert.ok(viewGame(game,f.target).truthBribeAnswers?.includes('no'));
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
assert.deepEqual(game.phaseBidPromises![0],phasePromise);
game = answerTruthBid(game,f.target,'no');
assert.equal(game.truthtrance,null);
assert.deepEqual(game.phaseBidPromises![0],phasePromise);
game = advance(game,g => clean(g) && g.phase === 3 && g.auction?.active === f.target);
game = act(game,f.target,{type:'bid',amount:6,allyPayment:0});
assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
assert.deepEqual(inventory(game),f.originalCards);
});
}
+88
View File
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { truthBidInventory } from './fixture-truth-bid';
import { actTruthTurnBid as act, advanceTruthTurnBid as advance, bindTruthTurnBid,
createTruthTurnBidFixture, finishTruthTurnBidLot, purchaseTruthTurnBidLot,
truthTurnBidClean as clean, truthTurnBidPlayer as player, type TruthTurnBidStep } from './fixture-truth-turn-bid';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game,actor,action));
assert.deepEqual(game,before);
}
function firstContested(game: Game, asker: string, rival: string, target: string, amount: number | null) {
assert.equal(game.auction?.index,0); assert.equal(game.auction?.active,asker);
const actions: TruthTurnBidStep[] = [];
game = act(game,asker,{type:'passBid'},actions);
assert.equal(game.auction?.active,rival);
game = act(game,rival,{type:'bid',amount:1,allyPayment:0},actions);
assert.equal(game.auction?.active,target);
game = act(game,target,amount === null ? {type:'passBid'} : {type:'bid',amount,allyPayment:0},actions);
return {game,actions};
}
for (const advanced of [false,true]) {
const prefix = advanced ? 'Advanced' : 'Basic';
void test(`${prefix} phase bid Yes survives a real first-lot pass then fulfills on a later accepted bid`, () => {
const f = createTruthTurnBidFixture({advanced});
const bound = bindTruthTurnBid(f,'yes',4);
const first = firstContested(bound.game,f.asker,f.opponent,f.target,null);
let game = finishTruthTurnBidLot(first.game,first.actions);
assert.equal(game.auction?.index,1);
assert.ok(player(game,f.opponent).hand.some(c => c.id === f.lotCards[0]));
const promise = viewGame(game,f.target).phaseBidPromises.find(p => p.minimum === 4)!;
assert.ok(promise); assert.equal(promise.fulfilled ?? false,false); assert.equal(promise.released ?? false,false);
game = advance(game,state => clean(state) && state.auction?.active === f.target);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game,f.target);
view.players.find(p => p.id === f.target)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action);
const accepted = act(game,f.target,action);
assert.ok(accepted,`${difficulty} supplies a legal current action`);
}
game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.minimum === 4 && p.fulfilled));
assert.deepEqual(truthBidInventory(game),f.originalCards);
});
void test(`${prefix} phase bid No forbids qualifying bids across both original lots`, () => {
const f = createTruthTurnBidFixture({advanced});
const bound = bindTruthTurnBid(f,'no',4);
let game = firstContested(bound.game,f.asker,f.opponent,f.target,3).game;
game = finishTruthTurnBidLot(game);
assert.equal(game.auction?.index,1);
assert.ok(player(game,f.target).hand.some(c => c.id === f.lotCards[0]));
game = advance(game,state => clean(state) && state.auction?.active === f.target);
assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.minimum === 4 && !p.fulfilled && !p.released));
reject(game,f.target,{type:'bid',amount:4,allyPayment:0});
game = act(game,f.target,{type:'bid',amount:3,allyPayment:0});
assert.deepEqual(truthBidInventory(game),f.originalCards);
});
void test(`${prefix} a live phase bid Yes cannot be abandoned on the final original lot`, () => {
const f = createTruthTurnBidFixture({advanced}); let game = f.game;
for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
assert.equal(game.auction?.index,2);
game = advance(game,state => clean(state) && state.auction?.active === f.target);
game = bindTruthTurnBid(f,'yes',4,game).game;
reject(game,f.target,{type:'passBid'});
reject(game,f.target,{type:'bid',amount:3,allyPayment:0});
game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.minimum === 4 && p.fulfilled));
assert.deepEqual(truthBidInventory(game),f.originalCards);
});
void test(`${prefix} an actual opposing last-lot raise releases an unaffordable phase bid Yes`, () => {
const f = createTruthTurnBidFixture({advanced,extraGuild:true}); let game = f.game;
// Two real E purchases cost him two; a real A purchase pays E one. The
// original fourth lot remains, E has one hand slot and nine spice, and H
// has its real ten spice. This is no staged budget or future rival bid.
for (const buyer of [f.target,f.target,f.opponent]) game = purchaseTruthTurnBidLot(game,buyer).game;
assert.equal(game.auction?.index,3); assert.equal(player(game,f.target).spice,9);
game = advance(game,state => clean(state) && state.auction?.active === f.asker);
assert.ok(!game.auction!.passed.includes(f.target));
game = bindTruthTurnBid(f,'yes',8,game).game;
game = act(game,f.asker,{type:'bid',amount:10,allyPayment:0});
assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.minimum === 8 && p.released));
assert.deepEqual(truthBidInventory(game),f.originalCards);
});
}
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
PhaseBidPromiseError,
finishPhaseBidPhase,
livePhaseBidPromises,
parsePhaseBidClaim,
phaseBidAllowedRange,
phaseBidClaimText,
phaseBidCompletionRange,
reconcilePhaseBidPromises,
recordPhaseBid,
validatePhaseBidPromises,
type PhaseBidOpportunity,
type PhaseBidPromise,
} from '../game/turn-bid-promises';
const seats = ['respondent', 'asker', 'rival'];
const promise = (event: string, minimum: number, answer = true,
extra: Partial<PhaseBidPromise> = {}): PhaseBidPromise =>
({ event, turn: 2, minimum, player: 'respondent', asker: 'asker', answer, ...extra });
const opportunity = (extra: Partial<PhaseBidOpportunity> = {}): PhaseBidOpportunity =>
({ turn: 2, player: 'respondent', kind: 'current', minimum: 1, maximum: 9, ...extra });
void test('phase bid claim is one positive safe turn and threshold, with no lot or cumulative payment', () => {
const claim = parsePhaseBidClaim({ turn: 2, minimum: 6 });
assert.deepEqual(claim, { turn: 2, minimum: 6 });
assert.match(phaseBidClaimText(claim), /bid of at least 6 spice/);
assert.match(phaseBidClaimText(claim), /Bidding phase closes/);
for (const field of ['turn', 'minimum'] as const)
for (const value of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '6', null, undefined])
assert.throws(() => parsePhaseBidClaim({ turn: 2, minimum: 6, [field]: value }), PhaseBidPromiseError);
for (const value of [null, undefined, [], '6', 6])
assert.throws(() => parsePhaseBidClaim(value), PhaseBidPromiseError);
});
void test('Yes survives a smaller accepted bid and lot close when a funded later lot is genuinely witnessed', () => {
const start = Object.freeze([Object.freeze(promise('yes-six', 6))]);
let rows = recordPhaseBid(start, 'respondent', 2, 3);
assert.equal(rows[0].fulfilled, undefined);
const future = opportunity({ kind: 'future', minimum: 1, maximum: 7 });
rows = reconcilePhaseBidPromises(rows, 'respondent', 2, [future], 'voluntary');
assert.equal(rows[0].released, undefined);
rows = recordPhaseBid(rows, 'respondent', 2, 5);
assert.equal(rows[0].fulfilled, undefined, 'Separate bids are not summed');
rows = recordPhaseBid(rows, 'respondent', 2, 6);
assert.equal(rows[0].fulfilled, true, 'An accepted declaration suffices even without winning the card');
assert.equal(start[0].fulfilled, undefined);
});
void test('No caps all later native lots and remains binding after Yes is fulfilled', () => {
const yes = promise('yes-six', 6), no = promise('no-eight', 8, false);
const source = Object.freeze([Object.freeze(yes), Object.freeze(no)]);
const before = structuredClone(source);
assert.deepEqual(phaseBidAllowedRange(source, opportunity()), { minimum: 1, maximum: 7 });
assert.deepEqual(phaseBidCompletionRange(source, opportunity()), { minimum: 6, maximum: 7 });
const rows = recordPhaseBid(source, 'respondent', 2, 7);
assert.equal(rows[0].fulfilled, true); assert.equal(rows[1].fulfilled, undefined);
assert.throws(() => recordPhaseBid(rows, 'respondent', 2, 8), PhaseBidPromiseError);
assert.deepEqual(source, before);
assert.equal(recordPhaseBid(rows, 'rival', 2, 8)[1], no);
assert.equal(recordPhaseBid(rows, 'respondent', 3, 8)[1], no);
const finished = finishPhaseBidPhase(rows, 'respondent', 2, 'external');
assert.equal(finished[1].fulfilled, true);
});
void test('funded witnesses and other No caps constrain joint completion without forcing each bid to qualify', () => {
const rows = [promise('yes-six', 6), promise('yes-seven', 7), promise('no-eight', 8, false)];
assert.deepEqual(phaseBidAllowedRange(rows, opportunity()), { minimum: 1, maximum: 7 });
assert.deepEqual(phaseBidCompletionRange(rows, opportunity()), { minimum: 7, maximum: 7 });
assert.equal(phaseBidCompletionRange(rows, opportunity({ maximum: 6 })), null);
assert.equal(phaseBidCompletionRange(rows, opportunity({ minimum: 8 })), null);
assert.equal(phaseBidCompletionRange([promise('yes-eight', 8), promise('no-eight', 8, false)], opportunity()), null);
assert.deepEqual(phaseBidAllowedRange([promise('no-eight', 8, false)], opportunity()), { minimum: 1, maximum: 7 });
});
void test('own action cannot abandon the last real witness, while outside impossibility releases only affected Yes rows', () => {
const rows = Object.freeze([Object.freeze(promise('yes-five', 5)), Object.freeze(promise('yes-eight', 8)),
Object.freeze(promise('no-ten', 10, false))]);
const before = structuredClone(rows);
const limited = [opportunity({ maximum: 5 })];
assert.throws(() => reconcilePhaseBidPromises(rows, 'respondent', 2, limited, 'voluntary'), PhaseBidPromiseError);
assert.throws(() => reconcilePhaseBidPromises(rows, 'respondent', 2, [], 'voluntary'), PhaseBidPromiseError);
const after = reconcilePhaseBidPromises(rows, 'respondent', 2, limited, 'external');
assert.equal(after[0].released, undefined); assert.equal(after[1].released, true);
assert.equal(after[2].released, undefined, 'An impossible Yes does not erase a No restriction');
assert.deepEqual(rows, before);
assert.throws(() => finishPhaseBidPhase(rows, 'respondent', 2, 'voluntary'), PhaseBidPromiseError);
const closed = finishPhaseBidPhase(rows, 'respondent', 2, 'external');
assert.equal(closed[0].released, true); assert.equal(closed[1].released, true); assert.equal(closed[2].fulfilled, true);
});
void test('only actual verified intervals for the respondent and turn can witness a promise', () => {
const rows = [promise('yes-six', 6)];
for (const wrong of [opportunity({ turn: 3 }), opportunity({ player: 'rival' })])
assert.throws(() => reconcilePhaseBidPromises(rows, 'respondent', 2, [wrong], 'voluntary'), PhaseBidPromiseError);
for (const invalid of [opportunity({ maximum: 0 }), opportunity({ minimum: 4, maximum: 3 }),
opportunity({ maximum: Infinity }), opportunity({ kind: 'invented' as 'future' })])
assert.throws(() => phaseBidAllowedRange(rows, invalid), PhaseBidPromiseError);
assert.throws(() => recordPhaseBid(rows, 'respondent', 2, 0), PhaseBidPromiseError);
assert.deepEqual(livePhaseBidPromises([rows[0], promise('old', 8, true, { turn: 1 }),
promise('someone-else', 9, true, { player: 'rival' })], 'respondent', 2), [rows[0]]);
});
void test('phase records preserve event identities, actual participants and mutually exclusive terminal status', () => {
const valid = [promise('live', 6), promise('fulfilled', 7, true, { fulfilled: true }),
promise('released', 8, true, { released: true })];
assert.doesNotThrow(() => validatePhaseBidPromises(valid, seats));
const invalid: unknown[] = [null, [null], [valid[0], { ...valid[1], event: 'live' }],
[{ ...valid[0], event: '' }], [{ ...valid[0], asker: 'outsider' }],
[{ ...valid[0], player: 'outsider' }], [{ ...valid[0], asker: 'respondent' }],
[{ ...valid[0], minimum: 0 }], [{ ...valid[0], turn: 0 }], [{ ...valid[0], answer: 'yes' }],
[{ ...valid[0], fulfilled: 'true' }], [{ ...valid[0], released: 1 }],
[{ ...valid[0], fulfilled: true, released: true }]];
for (const input of invalid)
assert.throws(() => validatePhaseBidPromises(input as PhaseBidPromise[], seats), PhaseBidPromiseError);
});