Support phase-wide Truthtrance bid commitments
This commit is contained in:
1 parent
474674f7fe
commit
2916b4d48f
19 files changed
+1512
-15
No files matched your search
@@ -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} />}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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))) ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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'] },
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user