Add current-lot Truthtrance bid commitments

This commit is contained in:
Quintine committed 2026-10-26 12:44:00 +11:00
1 parent 4ab20a2838
commit bd6e5bbf7f
18 files changed
+1346 -27

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
'use client';
import { parseBidClaim, type BidClaim } from '@/game/bid-promises';
import { Input } from './ui/input';
export function bidClaimInputError(value: unknown): string | null {
try { parseBidClaim(value); return null; }
catch (error) { return error instanceof Error ? error.message : 'Choose a positive whole bid minimum.'; }
}
/** The parent supplies the public current lot; only the minimum is editable. */
export function BidClaimFields(props: {
value: BidClaim;
onChange: (claim: BidClaim) => void;
disabled?: boolean;
}) {
const error = bidClaimInputError(props.value);
return <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Ordinary lot bid condition">
<legend>Current ordinary-lot bid</legend>
<p>Turn {props.value.turn} · Ordinary lot {props.value.lot}</p>
<label htmlFor="truth-bid-minimum">Minimum bid in spice</label>
<Input id="truth-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-bid-help"
onChange={event => {
if (props.disabled) return;
const next = { ...props.value, minimum: event.currentTarget.valueAsNumber };
if (bidClaimInputError(next)) return;
props.onChange(parseBidClaim(next));
}} />
{error && <p role="alert">{error}</p>}
<p className="fine" id="truth-bid-help">
This asks whether you will place at least one bid of this size from now until this lot closes.
It does not promise a win. If you win, the original payment rules still apply. Ordinary bank auctions only;
optional modules and native special auctions are excluded.
</p>
</fieldset>;
}
+62
View File
@@ -0,0 +1,62 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { bidClaimText, bidPromiseModeSupported } from '@/game/bid-promises';
import { Button } from './ui/button';
type BidPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
/** The server's current guide owns bid feasibility and funding. This leaf only
* checks that its personalized event and public action opportunity stay live. */
export function BidPromiseGuideButton(props: BidPromiseProps) {
const game = props.game, guide = game.bidPromiseGuide, lot = game.bidPromiseLot;
if (!guide || !lot) return null;
const actor = game.me, turn = lot.turn, number = lot.lot;
const action = guide.action, event = action.bidPromiseEvent, signature = JSON.stringify(action);
const canAct = () => {
const current = props.game, me = current.players.find(player => player.id === current.me);
return !props.busy && current === game && bidPromiseModeSupported(current) && current.status === 'playing' && current.phase === 3 &&
current.me === actor && current.auction?.active === actor &&
current.turn === turn && current.bidPromiseLot?.turn === turn && current.bidPromiseLot.lot === number &&
current.bidPromiseGuide === guide && current.bidPromiseGuide.action === action && JSON.stringify(action) === signature &&
typeof event === 'string' && (action.type === 'bid' || action.type === 'passBid') &&
current.bidPromises.some(promise => promise.event === event && promise.player === actor &&
promise.turn === turn && promise.lot === number && !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="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 answers`
: 'Pass this bid while honoring your answers'}
</Button>;
}
export function BidPromises(props: BidPromiseProps) {
const own = props.game.bidPromises.filter(promise => promise.player === props.game.me);
if (!own.length) return null;
const waiting = own.some(promise => !promise.fulfilled && !promise.released &&
promise.turn === props.game.turn && promise.lot === props.game.bidPromiseLot?.lot);
return <section className="notice space-y-3" aria-label="Your Truthtrance bid promises" id="bid-promise-guidance">
<h3>Your bid promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}, ordinary lot {promise.lot}: You answered {promise.answer ? 'Yes' : 'No'} to {bidClaimText(promise)}.
{promise.released ? ' Released: this commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This answer is binding for this lot.'}
</li>)}</ul>
<p>
Yes requires one qualifying bid from your answer until this lot closes, while it remains possible.
No forbids every later bid at or above that minimum through the close of this lot.
A qualifying declaration fulfills this answer even if another player wins.
If you win, the original payment rules still apply.
</p>
<p className="fine">
<a href="/rules#truthtrance-bid">Bid promise rules</a>.{' '}
These commitments concern the current ordinary bank lot. Optional modules and native special auctions are excluded.
Ordinary bids keep their existing funding and payment rules.
</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>}
<BidPromiseGuideButton {...props} />
</section>;
}
+2
View File
@@ -24,6 +24,7 @@ import {
liveShipmentPromises,
} from '@/game/shipment-promises';
import { ShipmentPromises } from './shipment-promises';
import { BidPromises } from './bid-promises';
import { HomeworldShipment } from './homeworld-shipment';
import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
import { CaladanReinforcement } from './caladan-reinforcement';
@@ -2030,6 +2031,7 @@ export function GameTable({
{g.status === 'playing' && g.phase < 5 && (
<ShipmentPromises game={g} act={act} busy={busy} />
)}
{g.status === 'playing' && <BidPromises game={g} send={act} busy={busy} />}
{g.guildBetrayalReaction ? (
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
) : g.richeseBetrayalReaction ? (
+26 -3
View File
@@ -1,5 +1,7 @@
'use client';
import { MovementClaimFields } from './movement-claim-fields';
import { BidClaimFields, bidClaimInputError } from './bid-claim-fields';
import type { BidClaim } from '@/game/bid-promises';
import { TraitorDecisionAnswers } from './traitor-decision-promises';
import { validMovementClaim, type MovementClaim } from '@/game/movement-promises';
import { TruthShipmentQuestion } from './truth-shipment-question';
@@ -53,8 +55,11 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall'
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid'
>('fact');
const [bidMinimum, setBidMinimum] = useState(1);
const bidClaim: BidClaim = { turn: g.bidPromiseLot?.turn ?? g.turn,
lot: g.bidPromiseLot?.lot ?? 1, minimum: bidMinimum };
const [movement, setMovement] = useState<MovementClaim>({ territory: 'carthag', minimum: 6 });
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
const [mixed, setMixed] = useState<MixedShipmentExpression>({
@@ -93,6 +98,7 @@ export function Truthtrance({
}
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
: kind === 'movement' ? { kind, target, ...movement }
: kind === 'bid' ? { kind, target, ...bidClaim }
: kind === 'shipment'
? {
kind,
@@ -200,6 +206,13 @@ export function Truthtrance({
</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>
{(g.truthBidAnswers ?? []).map(answer => <div key={answer}>
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'movement' ? (
<>
<p className="notice">Yes requires one matching ground movement before your turn ends, while possible. No forbids every matching move through later Hajr or Ornithopter opportunities. Separate smaller groups do not add together. The No-Field marker itself is not a physical force counter.</p>
@@ -317,6 +330,7 @@ export function Truthtrance({
Verified current facts
</option>
<option value="movement">Bind ground movement this turn</option>
<option value="bid">Bind a bid in this ordinary lot</option>
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
<option value="shipment">
Bind a shipment from reserves
@@ -332,7 +346,15 @@ export function Truthtrance({
)}
</select>
</label>
{kind === 'traitorCall' ? (
{kind === 'bid' ? (
<>
<BidClaimFields value={bidClaim} disabled={busy || !g.bidPromiseLot}
onChange={claim => setBidMinimum(claim.minimum)} />
<p className="fine">One accepted bid from now until this lot closes. Earlier bids do not count. A larger opposing bid may make Yes impossible; your own pass, lower bid or voluntary spending cannot evade it.</p>
{!g.truthBidTargets.includes(target) && <p className="notice">Choose a bidder with an unused opportunity in the current ordinary lot and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported; other auctions and module compositions remain unfinished.</p>}
<p className="notice" aria-label="Public bid question preview">{truthQuestionText(question, leaderName, locationName)}</p>
</>
) : kind === 'traitorCall' ? (
<>
<p className="notice">Choose a still-unsubmitted voter in the current revealed battle. A definite answer binds that player’s original declaration, without reopening any sealed vote or promising that a later response will allow it.</p>
{!g.truthTraitorTargets.includes(target) && <p>Reveal both plans and choose a voter who has not submitted their Traitor decision.</p>}
@@ -429,6 +451,7 @@ export function Truthtrance({
'Ask publicly',
{ type: 'truthAsk', question },
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
(kind === 'mixedShipment' &&
(!!invalidMixed || !shipmentAvailable)) ||
@@ -449,7 +472,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
<details className="notice truthtrance-history">
<summary>Truthtrance answers · Turn {g.turn}</summary>
<p className="fine">
Structured battle-plan and supported reserve-shipment answers are
Structured battle-plan, ordinary-bid and supported reserve-shipment answers are
enforced. Current-turn freeform promises remain binding when possible,
but their automatic enforcement is unfinished.
</p>
+30
View File
@@ -16,6 +16,36 @@ open mode gates.
## Current checkpoint and work
**Truthtrance / ordinary bid commitments — 26 October. Prototyped / Partial.**
Registered bare native Basic/Advanced ordinary lots now support a structured
future minimum bid. Actual declarations fulfill Yes; No persists to exact lot
close. Original funding, payment, native turn order and earlier commitments
remain. Current-lot/next-owned-opportunity timing is explicitly provisional;
no game-start gate opens and optional/native-special compositions remain due.
Root shared integration with disjoint model-regression/control/native lanes
passes **14 files,175/175**, types/lint/build and **four native runtime paths**,
17 GameTable SSR renders and two actual guide callbacks. Native Basic/Advanced
H/A/E active sources use22/25 actions plus8 each: H really holds both original
Truthtrances, E answers, bids4 before cash changes, then pays its original4.
Four minimal profiles each submit a legal native guide action per source.
Twenty-action waiting sources add12 No/expiry or9 opposing-release actions.
E may bid3 below No4, then bid4 in the next lot. A's real10 bid prices out E10,
releasing Yes8; A later pays10 and native E collects10. Other new cases retain
two-copy No6/Yes4, actual loss after fulfillment, Karama11/free purchase,
voluntary funding guards, exact event/ownership, and an earlier shipment Yes9
that rules out bid Yes4 but permits a paid1 card and actual9-force shipment.
All33 original cards remain; no played hand/purse/force/auction/promise is installed.
Persistent own controls expose no private lot identity and preserve original
winning-payment instructions. Frozen report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-bid-proof-v1/`;
journeys/HTML: adjacent `20261026-truth-bid-runtime/`.
No saved-room/D1/server, reset/restart, deployment or assurance campaign.
SSR/callback proof leaves browser/mobile/deployed, broader promises/modules
and complete-mode acceptance open. [Source and contract](TRUTHTRANCE_BID_PROMISES.md).
**World occupation / live Sandtrout — 26 October. Prototyped / Partial.**
A real original Basic World/Tech draw reproduced an occupation-context rejection.
Clean World and World/all14 profiles now retain canonical pending Sandtrout as
+30
View File
@@ -1,5 +1,35 @@
# Dune implementation status
## 26 October 2026 — Truthtrance ordinary bid commitments
**Prototyped / Partial.** Registered bare native
Basic/Advanced ordinary lots now support a structured future minimum bid with
real Yes/No, exact lot expiry, joint constraints, original funding/payment and
forced-impossibility release. Prior bids and payment do not fulfill it; a real
declaration does, even when another bidder wins. Current-lot/next-opportunity
timing remains provisional under general current-turn authority. No start gate
changes; full module/native-special and broader commitment work remain.
Root model/shared integration and disjoint pure/control/native lanes pass
**14 files,175/175**, types/lint/build and **four native paths**,17 GameTable SSR
renders/two actual guide callbacks. Original H/A/E Basic22/Advanced25 sources
plus8 actions each bind Yes4, submit the exact guide bid before money changes,
then pay4. Four minimal profiles per source submit legal next actions. Original
waiting sources20+12/9 prove No4 permits3 and expires before next-lot4; A's10
prices out E10 and releases Yes8 before original A payment/native E income10.
Fourteen native cases also cover two original copies No6/Yes4, actual loss after
declaration, original Karama11/free purchase, own funding/stale-event guards,
and earlier shipment Yes9: bid Yes4 is unavailable, but an actual paid1 card
leaves9 for the actual nine-force shipment. Native card inventory remains33;
no played card/purse/force/auction/question/promise is installed. Controlled
UI supplements retain their staging labels. Frozen report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-bid-proof-v1/`;
journeys/HTML: adjacent `20261026-truth-bid-runtime/`.
No saved-room/D1/server/reset/restart/deployment or assurance campaign.
Browser/mobile/deployed, broader promises/modules and complete-mode acceptance remain.
[Source and contract](TRUTHTRANCE_BID_PROMISES.md).
## 26 October 2026 — Live World Sandtrout
**Prototyped / Partial.** A real original Basic
+16
View File
@@ -1,5 +1,21 @@
# Rule decision index
## Truthtrance ordinary bid commitments — 26 October 2026
Apply the existing November2020 FAQ p8 current-turn commitment authority to one
future accepted minimum bid in the current ordinary lot. Under standing
choose-recommended priority, use the next owned available opportunity rather
than assuming a future rival raise; current-highest/passed/full-hand targets
have no fresh opportunity. Actual declaration fulfills Yes without promising a
win; No persists to lot close. Original winning payment remains binding.
This exact-lot/interval/owned-funding and accepted-order release model is a
provisional software composition, not a dedicated publisher bidding ruling.
Keep current cash/aid/Karama, earlier commitments and original quote/custody
rules. No new start permission; optional modules and native special auctions
remain outside the first registered native Basic/Advanced prototype.
[Source, controls and boundaries](TRUTHTRANCE_BID_PROMISES.md).
## Sapho in ordered Advanced Revival — 26 October 2026
The printed Sapho first/last alternative applies to the now-connected ordered
+109
View File
@@ -0,0 +1,109 @@
# Truthtrance ordinary bid commitments
## Current-lot prototype — 26 October 2026
**Prototyped / Partial.** The question binds one
future ordinary bid declaration of at least a chosen positive whole amount in
the current lot. Registered native Basic/Advanced tables use their selected
decks and ordinary auctions. Optional modules, independent cards, Semuta/Kull,
native special auctions, phase-wide/grouped bidding claims and force-revival
promises remain required work. This does not change game-start permissions.
## Authority and selected interpretation
The [GF9 November 2020 FAQ, printed p8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8)
permits game-related yes/no questions and current-turn action commitments,
requires compliance while possible, and releases a later impossible answer.
Its worked example concerns shipment; it does not prescribe a bidding-specific
packet or online auction boundary. The official-domain indexed text was checked
for this slice. The previous [general source audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md)
remains applicable.
Under the standing 14 October choose-recommended instruction, use a single
accepted bid in this exact ordinary lot. Only a still-eligible active or waiting
bidder can answer; current-highest, already-passed and full-hand players have no
new opportunity for this question. An earlier bid does not fulfill it. Yes
requires a qualifying declaration at the player's next available opportunity
while possible; a smaller bid or pass cannot rely on a future rival raise.
No forbids every later qualifying bid through the close of the lot. These are
explicit provisional software interpretations, not a dedicated publisher FAQ.
Fulfillment records the declaration, even if someone else wins. The original
winning invoice and Karama payment remain separate and binding. Legal native
Karama overbidding is retained; the question creates no spice, discount or free
purchase. Existing current cash, prepaid allied aid, capacity and normal quote
checks remain authoritative. Unknown purchases and future rival income are not
assumed. Earlier shipment/movement funding obligations constrain the bid quote.
## State, actions and continuation
`game/bid-promises.ts` validates positive safe-integer `turn`, one-based `lot`
and `minimum`. The new question is `{kind:'bid',target,turn,lot,minimum}`.
It uses the original card declaration, priority, Ask, public Yes/No, physical
discard and interrupted-auction continuation. It adds no automatic human reply.
The engine privately binds the original physical lot, turn, respondent and
asker with a distinct event. Public history contains the stated lot/minimum and
answer; the unrevealed card binding is never projected. Only the respondent
receives feasible answers and its own sanitized commitment/guide. The guide
waits for the real active seat and original pending windows, and its event
rejects stale guided callbacks. Ordinary manual bid packets retain their shape.
The native bid quote is shared with ordinary bidding. Yes requirements form a
lower bound; No requirements cap the permitted amount. Several original
Truthtrances must remain jointly satisfiable. A real accepted bid fulfills each
matching Yes; No persists. Native settlement, custody and income then continue
normally. A paid purchase is not another bid.
Own voluntary spending or closure cannot erase a remaining Yes. A completed
opposing raise can price out the respondent and release only impossible Yes
answers in accepted order; feasible answers and No constraints remain. Finished
lots fulfill No and release any forced impossible remainder. Records may stay
in history, but never bind another lot or turn. Recovery/privacy/custody
certification is deferred with the rest of the full-rules review.
## Connected controls and evidence
The original Truthtrance selector offers **Bind a bid in this ordinary lot**,
with fixed public lot/turn, editable validated minimum and an explicit preview.
Only feasible respondent answers are shown. `components/bid-promises.tsx`
keeps the owner's obligation and fulfilled/released status visible; its guarded
button submits the authoritative bid/pass packet without duplicating prices.
No transient notice is required. All four existing policies use the same legal
answer/next-action guidance; strategy and strength tuning remain deferred.
Pure cases: `tests/bid-promises.test.ts`. Native source and engine cases:
`tests/fixture-truth-bid.ts`, `tests/truth-bid-runtime.test.ts`. Controlled and
source-earned callbacks: `tests/truth-bid-controls.test.tsx`. Original H/A/E
starts genuinely deal Harkonnen both physical Truthtrances and the other native
hands. Only unopened deal/lot/Spice positions and scalar lotteries are selected;
no played card, purse, force, auction, question or promise is installed.
Frozen integration passes **14 files,175/175**, types/lint/build and four native
runtime paths with17 GameTable SSR renders and two actual guide callbacks.
Ten pure cases, fourteen native engine cases and six controlled/source-earned
UI cases cover the new contract. Related original auction, Banker/Bureaucrat,
funding and Truthtrance cases remain passing after the native quote extraction.
Public H/A/E Basic/Advanced active sources use22/25 actions plus8 each. Actual
declaration/priority/Ask/answer precede the authoritative bid callback; Yes4
fulfills before cash changes, then the original winner pays4. Four existing
minimal policies each submit a legal guide action per source. Waiting sources
use20+12 actions for No4/lower3/lot expiry/new-lot4, or20+9 for an actual A10
opposing bid, E10's released Yes8 and original A payment/native E income10.
Other native cases bind both original cards as No6 plus Yes4, lose after a
fulfilled declaration, spend an original Karama after the promised11 bid and
reject stale/foreign packets and voluntary loss of required funding. An earlier
shipment Yes9 disallows later bid Yes4; a paid1 card leaves the actual9 for a
native nine-force shipment. All33 original cards remain physical. SSR confirms
the original authoring option, respondent answers, persistent guide and final
statuses; actual hook-free callbacks execute the original native bid packet.
This is not DOM/hydration or browser evidence.
Report/source manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-bid-proof-v1/`;
journeys/HTML: adjacent `20261026-truth-bid-runtime/`.
No saved-room/D1/server, reset/restart, deployment or separate assurance campaign.
Full Truthtrance, module/native-special composition, browser/mobile/deployed
acceptance and final assurance remain open.
@@ -1,5 +1,11 @@
# Truthtrance nonbattle readiness and first implementation slice
The [26 October ordinary-bid follow-up](TRUTHTRANCE_BID_PROMISES.md) adds a
structured current-lot bid commitment. The historical absence table below is
superseded for that slice, as well as the previously linked fact, shipment and
movement work. Native force-revival promises and broader action commitments
remain unfinished; do not reimplement existing recorded-knowledge predicates.
## Grouped battle-plan authoring — 26 October 2026
The existing battle-plan question now has a complete bounded expression form,
+93
View File
@@ -0,0 +1,93 @@
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
export type BidClaim = { turn: number; lot: number; minimum: number };
export type BidPromise = BidClaim & {
event: string;
/** Internal physical-lot binding. Never project the unrevealed card identity. */
source: string;
player: string;
asker: string;
answer: boolean;
fulfilled?: boolean;
released?: boolean;
};
export type BidOpportunity = {
turn: number; lot: number; source: string; bid: number;
bidder: string | null; passed: readonly string[]; eligible: readonly string[];
};
export class BidPromiseError extends Error {}
export function parseBidClaim(value: unknown): BidClaim {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new BidPromiseError('Choose a current ordinary-auction bid question.');
const input = value as Record<string, unknown>;
for (const field of ['turn', 'lot', 'minimum'] as const)
if (typeof input[field] !== 'number' || !Number.isSafeInteger(input[field]) || input[field] < 1)
throw new BidPromiseError('Choose the current turn and lot, and a positive whole bid minimum.');
return { turn: input.turn as number, lot: input.lot as number, minimum: input.minimum as number };
}
/** Original ordinary lots first; native special auctions and optional modules
* retain their own required future commitment work. Never changes start gates. */
export function bidPromiseModeSupported(game: NativePromiseScope & {
semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown;
richeseBetrayalPreview?: unknown;
}): boolean {
return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.kullPreview &&
!game.nexusKullPreview && !game.richeseBetrayalPreview;
}
export function bidClaimText(claim: BidClaim): string {
return `place a bid of at least ${claim.minimum} spice from now until ordinary lot ${claim.lot} closes`;
}
export function liveBidPromises(promises: readonly BidPromise[], player: string, opportunity: BidOpportunity): BidPromise[] {
return promises.filter(promise => promise.player === player && promise.turn === opportunity.turn &&
promise.source === opportunity.source && !promise.released && !promise.fulfilled);
}
/** One future accepted declaration, not a total of bids or a purchase invoice. */
export function bidPromiseRange(promises: readonly BidPromise[], player: string,
opportunity: BidOpportunity, maximum: number): { minimum: number; maximum: number } | null {
if (!opportunity.eligible.includes(player) || opportunity.passed.includes(player) ||
opportunity.bidder === player || !Number.isSafeInteger(maximum) || maximum < 1 ||
!Number.isSafeInteger(opportunity.bid) || opportunity.bid < 0 || opportunity.bid === Number.MAX_SAFE_INTEGER)
return null;
let minimum = opportunity.bid + 1;
for (const promise of liveBidPromises(promises, player, opportunity)) {
if (promise.answer) minimum = Math.max(minimum, promise.minimum);
else maximum = Math.min(maximum, promise.minimum - 1);
}
return minimum <= maximum ? { minimum, maximum } : null;
}
export function bidRequired(promises: readonly BidPromise[], player: string, opportunity: BidOpportunity): boolean {
return liveBidPromises(promises, player, opportunity).some(promise => promise.answer);
}
/** Record before the native bid advances/settles its lot. Caller owns the
* detached transaction, so a later native validation failure remains atomic. */
export function recordPromisedBid(promises: readonly BidPromise[], player: string,
opportunity: BidOpportunity, amount: number): BidPromise[] {
const live = liveBidPromises(promises, player, opportunity);
if (live.some(promise => !promise.answer && amount >= promise.minimum))
throw new BidPromiseError('This bid conflicts with your Truthtrance No answer for this lot.');
return promises.map(promise => live.includes(promise) && promise.answer && amount >= promise.minimum
? { ...promise, fulfilled: true } : promise);
}
export function validateBidPromises(promises: readonly BidPromise[], players: readonly string[]): void {
if (!Array.isArray(promises)) throw new BidPromiseError('The bid commitment record is invalid.');
const events = new Set<string>();
for (const promise of promises) {
parseBidClaim(promise);
if (typeof promise.event !== 'string' || !promise.event || events.has(promise.event) ||
typeof promise.source !== 'string' || !promise.source || !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 BidPromiseError('The bid commitment lost its original event, source or participants.');
events.add(promise.event);
}
}
+5
View File
@@ -1307,12 +1307,17 @@ function policyActions(g: GameView): Action[] {
? [{ type: 'decision', event: search.event, card: ordered[0].id }]
: [];
}
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
if (g.truthtrance) {
const w = g.truthtrance;
if (w.stage === 'priority')
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
if (w.stage === 'answer') {
if (w.question?.target !== me.id) return [];
if (w.question.kind === 'bid') {
const answer = g.truthBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'traitorCall') {
const answers = g.truthTraitorAnswers ?? [];
const answer = answers.find(answer => answer === 'yes') ?? answers[0];
+154 -23
View File
@@ -18,6 +18,8 @@ import { findMovementCompletion, type MovementCompletion } from './movement-prom
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedFactionStrongholdProfile, selectedIndependentCardsProfile } from './faction-module-profile';
import { homeworldOccupationLeaderSkillsProfile, homeworldOccupationModulesProfile, homeworldPhaseModulesProfile } from './homeworld-module-profile';
import { BidPromiseError, bidPromiseModeSupported, bidPromiseRange, bidRequired, liveBidPromises,
recordPromisedBid, validateBidPromises, type BidClaim, type BidOpportunity, type BidPromise } from './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, maximumBribe, type BribeOptions } from './bribe-options';
@@ -2133,6 +2135,7 @@ export type Game = {
truthtrance?: TruthWindow | null;
truthHistory?: TruthRecord[];
shipmentPromises?: ShipmentPromise[];
bidPromises?: BidPromise[];
movementPromises?: MovementPromise[];
/** Server-only certainty of movement-relevant cards still in the discard. */
movementDiscardKnowledge?: MovementDiscardKnowledge;
@@ -15075,6 +15078,132 @@ function decideNexusEmperorPayment(g: Game, p: Player, action: Action): void {
if (action.use) settleAuction(g, false);
else recoverAuctionPayment(g, getPlayer(g, source.winner));
}
function normalBidMaximum(g: Game, p: Player): number {
return (g.kullPreview ? p.hand.some(card => canUseAsKaramaRole(g, p, card)) : !!karamaCard(g, p))
? Number.MAX_SAFE_INTEGER : p.spice + (aidFor(g, p)?.amount ?? 0);
}
/** Same native quote for ordinary controls and the detached current-lot guide. */
function normalBidQuote(g: Game, p: Player, amount: unknown, requestedAid?: unknown) {
requireRule(g.auction, 'The ordinary auction is no longer present.');
const capacity = normalAuctionHandBlock(p.hand.length, handLimit(p));
requireRule(!capacity, capacity ?? 'Your Treachery hand is full.');
const quote = quoteNormalAuctionBid({ currentBid: g.auction.bid, amount, maximum: normalBidMaximum(g, p) });
if (!quote.ok) throw new RuleError(quote.reason);
requireRule(!g.kullPreview || (p.id === byFaction(g, 'choam')?.id &&
!kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id, 'auction-free-purchase')) ||
quote.amount <= p.spice + (aidFor(g, p)?.amount ?? 0),
'Kull preview defers an unfunded Karama-dependent overbid before acceptance.');
const allyPayment = quote.amount <= p.spice + (aidFor(g, p)?.amount ?? 0)
? contribution(g, p, quote.amount, requestedAid) : 0;
const parties = auctionBureaucratParties(g, p, quote.amount, allyPayment);
checkBureaucratContributors(g, parties.payee, parties.shares);
return { amount: quote.amount, allyPayment };
}
function bidOpportunity(g: Game): BidOpportunity | null {
const a = g.auction;
if (g.status !== 'playing' || g.phase !== 3 || !a || g.currentAuctionSale ||
!a.cards[a.index] || !bidPromiseModeSupported(g)) return null;
return { turn: g.turn, lot: a.index + 1,
source: JSON.stringify(['ordinaryBid', g.turn, a.index, a.cards[a.index].id]),
bid: a.bid, bidder: a.bidder, passed: a.passed,
eligible: g.players.filter(player => player.hand.length < handLimit(player)).map(player => player.id) };
}
function bidQuestionWindow(g: Game): boolean {
return !!bidOpportunity(g) && !g.response && !g.decision && !g.phaseOpening && !g.richeseAuction &&
!g.ixAuction && !g.pendingKarama && !g.pendingTreacheryDiscard;
}
function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []): Action | null {
const opportunity = bidOpportunity(g);
if (!opportunity) return null;
if (!bidRequired(promises, p.id, opportunity)) return { type: 'passBid' };
const range = bidPromiseRange(promises, p.id, opportunity, normalBidMaximum(g, p));
if (!range) return null;
try {
const action: Action = { type: 'bid', ...normalBidQuote(g, p, range.minimum) };
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length) {
const trial = structuredClone(g);
trial.truthtrance = null;
trial.auction!.bid = action.amount as number;
trial.auction!.bidder = p.id;
trial.auction!.allyPayment = action.allyPayment as number;
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
}
return action;
}
catch (error) { if (error instanceof RuleError) return null; throw error; }
}
function bidTruthAnswers(g: Game, p: Player, claim: BidClaim): ('yes' | 'no' | 'unknown')[] {
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' }]));
}
function bindBidTruth(g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) {
const opportunity = bidOpportunity(g);
requireRule(opportunity && bidTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'This ordinary bid answer no longer has a legal completion.');
(g.bidPromises ??= []).push({ ...claim, event: crypto.randomUUID(), source: opportunity.source,
player: p.id, asker, answer });
}
function bidPromiseIntegrity(g: Game) {
try { validateBidPromises(g.bidPromises ?? [], g.players.map(player => player.id)); }
catch (error) { if (error instanceof BidPromiseError) throw new RuleError(error.message); throw error; }
}
function reconcileBidPromises(g: Game, voluntary?: { actor: string; action: Action }) {
bidPromiseIntegrity(g);
if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return;
const opportunity = bidOpportunity(g);
const kept: BidPromise[] = [];
const no = (g.bidPromises ?? []).filter(promise => !promise.answer && !promise.fulfilled && !promise.released);
for (const promise of g.bidPromises ?? []) {
if (promise.fulfilled || promise.released) continue;
const same = opportunity && promise.turn === opportunity.turn && promise.source === opportunity.source;
if (!promise.answer && same) continue;
const own = voluntary?.actor === promise.player &&
['bid', 'passBid', 'card', 'bribe', 'pledgeAid', 'emperorGift', 'richeseGift', 'richeseBid', 'decision', 'karama', 'specialKarama'].includes(voluntary.action.type);
const possible = same && bidPromiseCompletion(g, getPlayer(g, promise.player), [...no, ...kept, promise]);
if (promise.answer && !possible) {
requireRule(!own, 'You cannot voluntarily evade your Truthtrance bid answer.');
promise.released = true;
log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance bid answer for ordinary lot ${promise.lot}; that answer is no longer binding.`);
} else if (!promise.answer) promise.fulfilled = true;
else kept.push(promise);
}
}
function projectedBidPromises(g: Game, player: string) {
return (g.bidPromises ?? []).filter(promise => promise.player === player).map(promise => ({
event: promise.event, player: promise.player, asker: promise.asker, turn: promise.turn,
lot: promise.lot, minimum: promise.minimum, answer: promise.answer,
...(promise.fulfilled === undefined ? {} : { fulfilled: promise.fulfilled }),
...(promise.released === undefined ? {} : { released: promise.released }),
}));
}
function projectedBidPromiseGuide(g: Game, player: string): { action: Action } | 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];
if (!promise) return null;
const action = bidPromiseCompletion(g, getPlayer(g, player));
return action ? { action: { ...action, bidPromiseEvent: promise.event } } : null;
}
function guardPromisedBid(g: Game, p: Player, action: Action, amount?: number) {
const opportunity = bidOpportunity(g);
const live = opportunity ? liveBidPromises(g.bidPromises ?? [], p.id, opportunity) : [];
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;
if (amount !== undefined && live.length) {
try { g.bidPromises = recordPromisedBid(g.bidPromises ?? [], p.id, opportunity, amount); }
catch (error) { if (error instanceof BidPromiseError) throw new RuleError(error.message); throw error; }
}
requireRule(!bidRequired(g.bidPromises ?? [], p.id, opportunity),
'Place the qualifying bid required by your Truthtrance Yes before passing or taking a lower bid.');
}
function auctionNext(g: Game) {
const a = g.auction!;
const eligible = g.order.filter((id) => {
@@ -34438,6 +34567,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
bidPromiseIntegrity(state);
revivalOrderIntegrity(state);
state = migrateAtomicsTargets(state);
state = migrateFremenNexusRevival(state);
@@ -34542,6 +34672,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
reconcileBidPromises(g, { actor: id, action });
reconcileGroundMovementPromises(g, id, action, state.phase);
reconcileTraitorTruth(g, id, action);
harkonnenNexusBetrayalIntegrity(g);
@@ -34663,6 +34794,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
leaderSkillsIntegrity(g);
reconcileBattlePromises(g, { actor: id, action });
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
reconcileBidPromises(g, { actor: id, action });
settleAutomaticContinuations(g);
observeOccupation(g);
reconcileHomeworldEcazDuke(g);
@@ -34863,6 +34995,7 @@ function settleAutomaticContinuations(g: Game) {
// they are not the original actor voluntarily spending a promised resource.
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
reconcileBidPromises(g);
reconcileGroundMovementPromises(g);
reconcileTraitorTruth(g);
requireRule(
@@ -34876,6 +35009,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
bidPromiseIntegrity(state);
state = migrateAtomicsTargets(state);
state = migrateFremenNexusRevival(state);
harkonnenNexusBetrayalIntegrity(state);
@@ -34959,6 +35093,7 @@ export function normalizeAutomaticGame(state: Game): Game {
}
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
reconcileBidPromises(g);
reconcileGroundMovementPromises(g);
reconcileTraitorTruth(g);
settleAutomaticContinuations(g);
@@ -35204,6 +35339,8 @@ function applyActionInner(
bindShipment: bindShipmentTruth,
movementAnswers: movementTruthAnswers,
bindMovement: bindMovementTruth,
bidAnswers: bidTruthAnswers,
bindBid: bindBidTruth,
traitorAnswers: traitorTruthAnswers,
bindTraitor: bindTraitorTruth,
battleAnswers: battleTruthAnswers,
@@ -35213,7 +35350,7 @@ function applyActionInner(
)
return g;
} catch (error) {
if (error instanceof TruthError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
if (error instanceof TruthError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
throw new RuleError(error.message);
throw error;
}
@@ -37700,31 +37837,16 @@ function applyActionInner(
);
const a = g.auction;
if (t === 'bid') {
const capacity = normalAuctionHandBlock(p.hand.length, handLimit(p));
requireRule(!capacity, capacity ?? 'Your Treachery hand is full.');
const quotedBid = quoteNormalAuctionBid({
currentBid: a.bid,
amount: action.amount,
maximum: (g.kullPreview ? p.hand.some(card => canUseAsKaramaRole(g, p, card)) : !!karamaCard(g, p))
? Number.MAX_SAFE_INTEGER
: p.spice + (aidFor(g, p)?.amount ?? 0),
});
if (!quotedBid.ok) throw new RuleError(quotedBid.reason);
requireRule(!g.kullPreview ||
(p.id === byFaction(g, 'choam')?.id &&
!kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id, 'auction-free-purchase')) ||
quotedBid.amount <= p.spice + (aidFor(g, p)?.amount ?? 0),
'Kull preview defers an unfunded Karama-dependent overbid before acceptance.');
const quotedBid = normalBidQuote(g, p, action.amount, action.allyPayment);
guardPromisedBid(g, p, action, quotedBid.amount);
a.bid = quotedBid.amount;
a.allyPayment =
a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0)
? contribution(g, p, a.bid, action.allyPayment)
: 0;
const parties = auctionBureaucratParties(g,p,a.bid,a.allyPayment);
checkBureaucratContributors(g,parties.payee,parties.shares);
a.allyPayment = quotedBid.allyPayment;
a.bidder = id;
a.passed = [];
} else if (!a.passed.includes(id)) a.passed.push(id);
} else {
guardPromisedBid(g, p, action);
if (!a.passed.includes(id)) a.passed.push(id);
}
auctionNext(g);
return g;
}
@@ -39173,6 +39295,15 @@ export function viewGame(state: Game, id: string) {
g.truthtrance.question.kind === 'battlePlan'
? battleTruthAnswers(g, me, g.truthtrance.question.claim)
: null,
bidPromiseLot: (() => { const lot = bidOpportunity(g); return lot ? { turn: lot.turn, lot: lot.lot } : null; })(),
truthBidTargets: bidQuestionWindow(g) ? g.players.filter(player => {
const lot = bidOpportunity(g)!;
return lot.eligible.includes(player.id) && !lot.passed.includes(player.id) && lot.bidder !== player.id;
}).map(player => player.id) : [],
truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null,
bidPromises: projectedBidPromises(g, id),
bidPromiseGuide: projectedBidPromiseGuide(g, id),
truthtrance: g.truthtrance ?? null,
truthHistory: g.truthHistory ?? [],
truthAnswer:
+28 -1
View File
@@ -3208,6 +3208,31 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: '13 files101/101, types/lint/build and six native engine/answer-callback/GameTable SSR journeys pass. Genuine26/72/77-action Basic/allied/Cunning sources earn original cards, alliances, payments, reveal and draw/return. Four profiles answer/vote legally. Unmatched-card staging is labelled; submitted-false and response/typing fixture feedback is repaired. Full future promises, combinations and final assurance remain open.', evidence: ['tests/traitor-decision-promises.test.ts', 'tests/fixture-truth-traitor-decision.ts', 'tests/fixture-truth-traitor-harkonnen.ts', 'tests/truth-traitor-decision-engine.test.ts', 'tests/truth-traitor-harkonnen-engine.test.ts', 'tests/truth-traitor-decision-controls.test.tsx', 'docs/TRUTHTRANCE_TRAITOR_DECISIONS.md'] },
],
},
{
id: 'truthtrance-bid',
title: 'Truthtrance: a bid in the current ordinary lot',
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.',
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.',
'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, 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: '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'] },
],
},
{
id: 'truthtrance-movement',
title: 'Truthtrance: ground-movement promises',
@@ -3357,7 +3382,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
summary:
'Ask another player a public yes/no question. Verified facts, supported shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
steps: [
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
@@ -3368,6 +3393,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.',
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.',
'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. The complete grouped builder supports eight children per group, sixteen total conditions including groups and depth four. Add/wrap/remove/collapse retains the whole claim and validates every field. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.',
'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.',
@@ -3384,6 +3410,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-plan-expression',
'truthtrance-shipment',
'truthtrance-movement',
'truthtrance-bid',
'richese-no-field',
'card-reinforcements',
],
+24
View File
@@ -46,6 +46,7 @@ import { canUseAsTruthtranceRole } from './shrine';
import { isKnowledgeFact, parseKnowledgeFact, knowledgeFactAnswer, knowledgeFactText, truthKnowledgeOf, type KnowledgeFact, type TruthKnowledge } from './truthtrance-knowledge';
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';
export type TruthFact =
| KnowledgeFact
@@ -59,6 +60,7 @@ export type TruthFact =
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type TruthQuestion =
| ({ kind: 'bid'; target: string } & BidClaim)
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
| ({ kind: 'movement'; target: string } & MovementClaim)
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
@@ -210,6 +212,17 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
g.players.some((p) => p.id === v.target),
'Ask one other player.',
);
if (v.kind === 'bid') {
check(Object.keys(v).sort().join(',') === 'kind,lot,minimum,target,turn',
'Supply the current ordinary lot, turn, bid minimum and respondent.');
const claim = parseBidClaim(v);
check(bidPromiseModeSupported(g) && g.status === 'playing' && g.phase === 3 && g.auction &&
!g.currentAuctionSale && !g.richeseAuction && !g.ixAuction && !g.response && !g.decision &&
!g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
claim.turn === g.turn && claim.lot === g.auction.index + 1,
'Choose the current ordinary bid lot in a supported table and finish pending decisions.');
return { kind: 'bid', target: v.target, ...claim };
}
if (v.kind === 'traitorCall') {
check(Object.keys(v).sort().join(',') === 'battle,kind,target,territory' &&
g.status === 'playing' && g.phase === 6 && g.battle?.revealed &&
@@ -434,6 +447,7 @@ export function truthQuestionText(
locationName: (id: string) => string,
): string {
if (q.kind === 'freeform') return q.text;
if (q.kind === 'bid') return `Will you ${bidClaimText(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 === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
if (q.kind === 'shipment')
@@ -479,6 +493,8 @@ export function resolveTruthAction(
) => void;
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[];
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
traitorAnswers?: (g: Game, p: Player) => TruthAnswer[];
bindTraitor?: (g: Game, p: Player, answer: boolean, asker: string) => void;
battleAnswers: (g: Game, p: Player, claim: PlanClaim) => TruthAnswer[];
@@ -597,6 +613,9 @@ export function resolveTruthAction(
'Waiting for the Truthtrance holder to ask a question.',
);
w.question = parseQuestion(g, p.id, a.question);
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 === 'traitorCall')
check(effects.traitorAnswers?.(g, g.players.find(other => other.id === w.question!.target)!).length,
'Choose an actual unsubmitted native Traitor voter.');
@@ -647,6 +666,11 @@ export function resolveTruthAction(
'Choose an answer consistent with legal ground movement and earlier promises.');
if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'bid') {
check(effects.bidAnswers && effects.bindBid && effects.bidAnswers(g, p, q).includes(a.answer),
'Choose an answer consistent with this lot, your legal bids and earlier answers.');
if (a.answer !== 'unknown') effects.bindBid(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'traitorCall') {
check(effects.traitorAnswers && effects.bindTraitor && effects.traitorAnswers(g, p).includes(a.answer),
'Choose an answer compatible with your current legal Traitor declaration and earlier answers.');
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
BidPromiseError,
bidClaimText,
bidPromiseModeSupported,
bidPromiseRange,
bidRequired,
liveBidPromises,
parseBidClaim,
recordPromisedBid,
validateBidPromises,
type BidOpportunity,
type BidPromise,
} from '../game/bid-promises';
const seats = ['respondent', 'asker', 'rival'] as const;
function opportunity(extra: Partial<BidOpportunity> = {}): BidOpportunity {
return { turn: 2, lot: 3, source: 'original-physical-lot', bid: 4,
bidder: 'rival', passed: [], eligible: seats, ...extra };
}
function promise(event: string, minimum: number, answer = true,
extra: Partial<BidPromise> = {}): BidPromise {
return { event, turn: 2, lot: 3, source: 'original-physical-lot',
player: 'respondent', asker: 'asker', answer, minimum, ...extra };
}
void test('bid claims normalize one-based safe-integer turn, lot and minimum without adding a purchase or cumulative amount', () => {
const input = { turn: 2, lot: 3, minimum: 6 };
const parsed = parseBidClaim(input);
assert.deepEqual(parsed, input); assert.notEqual(parsed, input);
assert.deepEqual(parseBidClaim({ turn: 1, lot: 1, minimum: 1 }), { turn: 1, lot: 1, minimum: 1 });
assert.deepEqual(parseBidClaim({ turn: Number.MAX_SAFE_INTEGER, lot: Number.MAX_SAFE_INTEGER,
minimum: Number.MAX_SAFE_INTEGER }), { turn: Number.MAX_SAFE_INTEGER, lot: Number.MAX_SAFE_INTEGER,
minimum: Number.MAX_SAFE_INTEGER });
for (const field of ['turn', 'lot', 'minimum'] as const)
for (const value of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '6', null, undefined])
assert.throws(() => parseBidClaim({ ...input, [field]: value }), BidPromiseError, `${field}/${String(value)}`);
for (const value of [null, undefined, [], '6', 6]) assert.throws(() => parseBidClaim(value), BidPromiseError);
const text = bidClaimText(parsed);
assert.match(text, /place a bid of at least 6 spice/); assert.match(text, /from now until ordinary lot 3 closes/);
assert.doesNotMatch(text, /win|purchase|sum|original-physical-lot/);
});
void test('bid commitment scope retains original bare Basic and Advanced native rosters without admitting optional modules', () => {
const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] };
const native = { advanced: true, expansions: ['ix', 'choam', 'ecaz'],
players: ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].map(faction => ({ faction })) };
for (const game of [classic, { ...classic, advanced: true }, native, { ...native, advanced: false },
{ ...classic, expansions: ['ix', 'choam', 'ecaz'] },
{ ...classic, advanced: true, expansions: ['ecaz'], players: [{ faction: 'moritani' }, { faction: 'harkonnen' }] },
]) assert.equal(bidPromiseModeSupported(game), true);
for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery',
'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const)
assert.equal(bidPromiseModeSupported({ ...native, [option]: true }), false, option);
for (const game of [
{ ...native, expansions: ['ix', 'ix'] }, { ...native, expansions: ['ix', 'choam'] },
{ ...classic, expansions: ['unknown'] }, { ...classic, players: [{ faction: 'atreides' }, { faction: 'unknown' }] },
{ ...classic, players: [{ faction: 'atreides' }] },
{ ...classic, players: [...native.players, { faction: 'atreides' }] },
{ ...classic, advanced: 'basic' as unknown as boolean },
]) assert.equal(bidPromiseModeSupported(game), false);
});
void test('one new accepted bid fulfills Yes while smaller bids, an old high bid and a purchase do not substitute', () => {
const original = [promise('at-least-six', 6)];
let records = recordPromisedBid(original, 'respondent', opportunity({ bid: 2 }), 3);
records = recordPromisedBid(records, 'respondent', opportunity({ bid: 4 }), 5);
assert.equal(records[0].fulfilled, undefined, 'Accepted bids 3 and 5 are not a cumulative bid of 8');
assert.equal(bidRequired(records, 'respondent', opportunity()), true);
assert.deepEqual(bidPromiseRange(records, 'respondent', opportunity({ bid: 6 }), 10), { minimum: 7, maximum: 10 });
const alreadyHighest = opportunity({ bid: 9, bidder: 'respondent' });
assert.equal(bidPromiseRange(records, 'respondent', alreadyHighest, 10), null);
assert.equal(bidRequired(records, 'respondent', alreadyHighest), true, 'A pre-existing winning bid does not answer a new promise');
assert.equal(records[0].fulfilled, undefined, 'Holding the high bid or later paying its invoice is not a new accepted declaration');
const accepted = recordPromisedBid(records, 'respondent', opportunity({ bid: 6 }), 7);
assert.equal(accepted[0].fulfilled, true, 'A qualifying accepted bid is sufficient without winning the card');
assert.equal(bidRequired(accepted, 'respondent', opportunity({ bid: 8, bidder: 'rival' })), false);
assert.equal(original[0].fulfilled, undefined);
});
void test('joint Yes lower bounds and No caps preserve every qualifying single-bid amount', () => {
const records = [promise('yes-six', 6), promise('yes-seven', 7),
promise('no-ten', 10, false), promise('no-eight', 8, false)];
const source = opportunity();
assert.deepEqual(bidPromiseRange(records, 'respondent', source, 20), { minimum: 7, maximum: 7 });
assert.deepEqual(bidPromiseRange(records.slice(0, 1).concat(records.slice(2)), 'respondent', source, 20),
{ minimum: 6, maximum: 7 });
assert.deepEqual(bidPromiseRange(records, 'respondent', source, 7), { minimum: 7, maximum: 7 });
assert.equal(bidPromiseRange(records, 'respondent', source, 6), null, 'Funding cannot be invented to meet Yes');
assert.equal(bidPromiseRange(records, 'respondent', opportunity({ bid: 7 }), 20), null,
'A later competing bid can remove the entire permissible interval');
});
void test('conflicting promises have no completion while a No-only cap never requires placing a bid', () => {
const conflict = [promise('yes-eight', 8), promise('no-eight', 8, false)];
assert.equal(bidPromiseRange(conflict, 'respondent', opportunity(), 20), null);
assert.equal(bidRequired(conflict, 'respondent', opportunity()), true);
const no = [promise('stay-below-six', 6, false), promise('stay-below-nine', 9, false)];
assert.deepEqual(bidPromiseRange(no, 'respondent', opportunity(), 20), { minimum: 5, maximum: 5 });
assert.equal(bidRequired(no, 'respondent', opportunity()), false);
assert.equal(bidPromiseRange(no, 'respondent', opportunity({ bid: 5 }), 20), null);
assert.equal(bidRequired(no, 'respondent', opportunity({ bid: 5 })), false,
'An unavailable permitted bid does not turn a No answer into a required action');
});
void test('passed, current-highest and ineligible players have no future own declaration route in this opportunity', () => {
const records = [promise('yes-six', 6)];
for (const source of [opportunity({ passed: ['respondent'] }),
opportunity({ bidder: 'respondent', bid: 6 }), opportunity({ eligible: ['asker', 'rival'] })]) {
assert.equal(bidPromiseRange(records, 'respondent', source, 20), null);
assert.equal(records[0].fulfilled, undefined, 'Eligibility and current high bids cannot fulfill a promise');
}
assert.deepEqual(bidPromiseRange(records, 'respondent', opportunity({ passed: ['asker'] }), 20),
{ minimum: 6, maximum: 20 });
});
void test('live obligations retain their original turn, physical lot and respondent while independent askers combine', () => {
const own = promise('own', 6), secondAsker = promise('second-asker', 8, false, { asker: 'rival' });
const records = [own, secondAsker,
promise('other-turn', 99, true, { turn: 1 }),
promise('other-physical-lot', 99, true, { source: 'another-original-lot', lot: 4 }),
promise('other-respondent', 99, true, { player: 'rival' }),
promise('already-fulfilled', 99, true, { fulfilled: true }),
promise('already-released', 99, true, { released: true })];
assert.deepEqual(liveBidPromises(records, 'respondent', opportunity()), [own, secondAsker]);
assert.deepEqual(bidPromiseRange(records, 'respondent', opportunity(), 20), { minimum: 6, maximum: 7 });
const accepted = recordPromisedBid(records, 'respondent', opportunity(), 6);
assert.equal(accepted[0].fulfilled, true); assert.equal(accepted[1], secondAsker);
for (let index = 2; index < records.length; index++) assert.equal(accepted[index], records[index]);
assert.deepEqual(liveBidPromises(records, 'absent', opportunity()), []);
});
void test('maximum safe bids keep exact inclusive bounds and never overflow into an invented next bid', () => {
const maximum = Number.MAX_SAFE_INTEGER;
const source = opportunity({ bid: maximum - 1 });
const yes = [promise('largest-yes', maximum)];
assert.deepEqual(bidPromiseRange(yes, 'respondent', source, maximum), { minimum: maximum, maximum });
assert.equal(recordPromisedBid(yes, 'respondent', source, maximum)[0].fulfilled, true);
assert.equal(bidPromiseRange(yes, 'respondent', opportunity({ bid: maximum }), maximum), null);
assert.equal(bidPromiseRange([promise('largest-no', maximum, false)], 'respondent', source, maximum), null);
for (const invalid of [0, -1, 1.5, Number.NaN, Infinity, maximum + 1])
assert.equal(bidPromiseRange([], 'respondent', opportunity(), invalid), null);
for (const invalid of [-1, 1.5, Number.NaN, Infinity, maximum + 1])
assert.equal(bidPromiseRange([], 'respondent', opportunity({ bid: invalid }), maximum), null);
});
void test('a rejected No bid is immutable and fulfilled Yes leaves No binding for later accepted declarations', () => {
const yes = Object.freeze(promise('yes-six', 6)), no = Object.freeze(promise('no-eight', 8, false));
const records = Object.freeze([yes, no]); const before = structuredClone(records);
assert.throws(() => recordPromisedBid(records, 'respondent', opportunity(), 8), BidPromiseError);
assert.deepEqual(records, before);
const accepted = recordPromisedBid(records, 'respondent', opportunity(), 7);
assert.notEqual(accepted, records); assert.notEqual(accepted[0], yes); assert.equal(accepted[0].fulfilled, true);
assert.equal(accepted[1], no); assert.equal(accepted[1].fulfilled, undefined);
assert.equal(bidRequired(accepted, 'respondent', opportunity()), false);
assert.deepEqual(liveBidPromises(accepted, 'respondent', opportunity()), [no]);
assert.throws(() => recordPromisedBid(accepted, 'respondent', opportunity({ bid: 7 }), 8), BidPromiseError);
const repeated = recordPromisedBid(accepted, 'respondent', opportunity(), 7);
assert.equal(repeated[0], accepted[0]); assert.equal(repeated[1], no);
assert.deepEqual(records, before);
});
void test('persisted bid records require distinct events, physical sources and two actual participants with valid status', () => {
const valid = [promise('live', 6), promise('fulfilled', 7, true, { fulfilled: true }),
promise('released', 8, false, { released: true })];
const before = structuredClone(valid);
assert.doesNotThrow(() => validateBidPromises(valid, seats)); assert.deepEqual(valid, before);
assert.doesNotThrow(() => validateBidPromises([], seats));
const invalid: unknown[] = [
null, {}, [null], [valid[0], { ...valid[1], event: 'live' }],
[{ ...valid[0], event: '' }], [{ ...valid[0], source: '' }],
[{ ...valid[0], player: 'outsider' }], [{ ...valid[0], asker: 'outsider' }],
[{ ...valid[0], asker: 'respondent' }], [{ ...valid[0], answer: 'yes' }],
[{ ...valid[0], fulfilled: 'true' }], [{ ...valid[0], released: 1 }],
[{ ...valid[0], fulfilled: true, released: true }],
[{ ...valid[0], turn: 0 }], [{ ...valid[0], lot: 0 }], [{ ...valid[0], minimum: 0 }],
];
for (const records of invalid)
assert.throws(() => validateBidPromises(records as BidPromise[], seats), BidPromiseError);
assert.deepEqual(valid, before);
});
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { actTruthMovement, type TruthMovementStep } from './fixture-truth-movement';
export type TruthBidStep = TruthMovementStep;
export const actTruthBid = actTruthMovement;
export const truthBidPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const truthBidClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard;
export function truthBidInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Each original Treachery Card keeps one physical slot');
return ids;
}
export function nextTruthBid(game: Game): TruthBidStep | null {
if (game.truthtrance) return null;
if (truthBidClean(game) && game.phase === 3 && game.auction) {
const actor = game.auction.active, guide = viewGame(game, actor).bidPromiseGuide;
return { actor, action: guide?.action ?? { type: 'passBid' } };
}
if (!game.phaseOpening && !game.response && game.decision?.kind === 'auctionPayment' && game.auction &&
game.auction.bid > truthBidPlayer(game, game.decision.player).spice)
return null; // Caller owns the actual unfunded Karama payment choice.
return nextStrongholdFactionsNativeStep(game);
}
export function advanceTruthBid(game: Game, until: (state: Game) => boolean, actions: TruthBidStep[] = []): Game {
for (let count = 0; count < 900; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original bid source ended before its owned control');
const next = nextTruthBid(game); assert.ok(next, 'The original Truthtrance or unfunded Karama choice belongs to its caller');
game = actTruthBid(game, next.actor, next.action, actions);
}
throw Error('The original bid source did not reach its finite boundary');
}
export function continueTruthBidLot(game: Game, actions: TruthBidStep[] = []): Game {
assert.ok(game.auction); const turn = game.turn, lot = game.auction.index + 1;
return advanceTruthBid(game, state => state.turn !== turn || state.phase !== 3 ||
!state.auction || state.auction.index + 1 !== lot, actions);
}
export function finishTruthBid(game: Game, actions: TruthBidStep[] = []): Game {
return advanceTruthBid(game, state => state.phase === 4 && truthBidClean(state), actions);
}
/** Public original H/A/E base33, Basic or Advanced preview. Only original
* unopened starting-deal/lot/Spice positions and scalar lotteries are selected.
* Harkonnen really receives both original consecutive Truthtrances, Atreides
* Baliset and Emperor Shield (or original Karama). No dealt hand, purse, force,
* auction, question, answer or promise is installed. */
export function createTruthBidFixture(options: {
advanced?: boolean; window?: 'waiting' | 'active'; targetKarama?: boolean;
} = {}) {
const advanced = options.advanced ?? false, window = options.window ?? 'waiting';
const asker = 'harkonnen', opponent = 'atreides', target = 'emperor';
let game = createGame('TRUTHBIDSOURCE', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced);
joinGame(game, newPlayer(opponent, 'Atreides', 'atreides'));
joinGame(game, newPlayer(target, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthBidStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthBid(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthBid(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(asker, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game); assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0));
const originalCards = truthBidInventory(game); assert.equal(originalCards.length, 33);
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
const filler = game.deck.find(card => card.name === 'Baliset');
const targetCard = game.deck.find(card => options.targetKarama ? card.effect === 'karama' : card.kind === 'shield');
const lotCards = [game.deck.find(card => card.kind === 'projectile'), game.deck.find(card => card.kind === 'poison'),
game.deck.find(card => card.kind === 'snooper')];
assert.ok(filler && targetCard && lotCards.every(Boolean));
const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id];
const prefix = [...deal, ...lotCards.map(card => card!.id)];
const dealOrder = prefix.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(truthBidPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
assert.deepEqual(truthBidPlayer(game, opponent).hand.map(card => card.id), [filler.id]);
assert.deepEqual(truthBidPlayer(game, target).hand.map(card => card.id), [targetCard.id]);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
reach(state => state.turn === 1 && state.phase === 3 && !!state.auction && truthBidClean(state));
const beforePasses = structuredClone(game);
if (window === 'active') reach(state => state.auction?.active === target && truthBidClean(state));
assert.equal(game.auction!.index, 0); assert.equal(game.auction!.bid, 0);
assert.equal(game.auction!.active, window === 'active' ? target : asker);
assert.equal(truthBidPlayer(game, target).spice, 10); assert.equal(truthBidPlayer(game, opponent).spice, 10);
assert.equal(viewGame(game, target).bidPromiseLot?.lot, 1);
assert.deepEqual(truthBidInventory(game), originalCards);
const question = { kind: 'bid' as const, target, turn: game.turn, lot: game.auction!.index + 1, minimum: 4 };
return { game, sourceGame: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup, beforePasses,
advanced, window, asker, opponent, target, actor: asker, truth: truths[0].id, card: truths[0].id,
truths: truths.map(card => card.id), targetCard: targetCard.id, karama: options.targetKarama ? targetCard.id : null,
question, declarationAction: { type: 'card', card: truths[0].id } as Action,
questionAction: { type: 'truthAsk', question } as Action,
lotCard: game.auction!.cards[game.auction!.index].id, lotCards: lotCards.map(card => card!.id),
deal, dealOrder, spiceOrder, originalSpice, originalCards, originalInventory: originalCards,
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[], sourceDisclosure: 'Genuine public H/A/E base33 setup. Only original unopened four-card starting deal, three ordinary lot positions, two ordinary Spice positions and scalar lotteries are selected. Harkonnen owns both physical Truthtrances; active Emperor additionally follows real H/A passes. No played hand, wallet, force, lot, Truthtrance or promise is installed.' };
}
export type TruthBidFixture = ReturnType<typeof createTruthBidFixture>;
export function declareTruthBid(f: TruthBidFixture, state = f.game, options: { copies?: 1 | 2 } = {}) {
let game = state; const before = structuredClone(game), actions: TruthBidStep[] = [];
const declaration: Action = { type: 'card', card: f.truths[0], ...(options.copies === 2 ? { cards: [f.truths[1]] } : {}) };
game = actTruthBid(game, f.asker, declaration, actions);
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthBid(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, declaration, actions, actionCount: actions.length };
}
export function askTruthBid(game: Game, asker: string, target: string, minimum = 4, actions: TruthBidStep[] = []): Game {
assert.ok(game.auction);
return actTruthBid(game, asker, { type: 'truthAsk', question: {
kind: 'bid', target, turn: game.turn, lot: game.auction.index + 1, minimum,
} }, actions);
}
export function answerTruthBid(game: Game, target: string, answer: 'yes' | 'no', actions: TruthBidStep[] = []): Game {
return actTruthBid(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthBid(f: TruthBidFixture, answer: 'yes' | 'no' = 'yes', minimum = 4, state = f.game) {
const declared = declareTruthBid(f, state), actions = [...declared.actions];
const asked = askTruthBid(declared.game, f.asker, f.target, minimum, actions);
const game = answerTruthBid(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { BidClaimFields, bidClaimInputError } from '../components/bid-claim-fields';
import { BidPromises, BidPromiseGuideButton } from '../components/bid-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { BidClaim } from '../game/bid-promises';
import { createTechTokens } from '../game/tech-tokens';
import { createTruthBidFixture, bindTruthBid, actTruthBid, 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 { Truthtrance } = await import('../components/truthtrance');
aliases.deregister();
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function edit(props: Parameters<typeof BidClaimFields>[0], minimum: number) {
const input = elements(BidClaimFields(props)).find(element => element.props.id === 'truth-bid-minimum' && typeof element.props.onChange === 'function');
assert.ok(input);
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber: minimum } });
}
function guide(props: Parameters<typeof BidPromiseGuideButton>[0]) {
const button = BidPromiseGuideButton(props); assert.ok(button);
return button;
}
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
const markup = (game: GameView, busy = false) => renderToStaticMarkup(createElement(BidPromises, { game, busy, send() {} }));
/** Controlled personalized projections exercise UI guards only. The native
* producer below supplies earned questions, answers and ordinary lot actions. */
function projected(action: 'bid' | 'passBid' = 'bid'): GameView {
const source = createGame('TRUTHBIDCONTROLS', 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.bidPromises = [{ event: 'controlled-bid-event', player: 'p', asker: 'a', turn: 2, lot: 1,
minimum: 4, answer: action === 'bid' }];
game.bidPromiseGuide = { action: action === 'bid'
? { type: 'bid', amount: 4, allyPayment: 1, bidPromiseEvent: 'controlled-bid-event' }
: { type: 'passBid', bidPromiseEvent: 'controlled-bid-event' } };
return game;
}
void test('bid claim fields keep the displayed turn and lot fixed and accept only parser-valid whole minimum edits', () => {
const value: BidClaim = { turn: 2, lot: 3, minimum: 4 }, changes: BidClaim[] = [];
const props = { value, onChange(next: BidClaim) { changes.push(next); } };
const html = renderToStaticMarkup(createElement(BidClaimFields, props));
assert.match(html, /Turn 2 · Ordinary lot 3/); assert.match(html, /Minimum bid in spice/);
assert.match(html, /min="1"/); assert.match(html, /max="9007199254740991"/);
assert.match(html, /If you win, the original payment rules still apply/);
assert.match(html, /optional modules and native special auctions are excluded/);
assert.equal(elements(BidClaimFields(props)).filter(element => typeof element.props.onChange === 'function').length, 1);
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(bidClaimInputError({ ...value, minimum })); edit(props, minimum);
}
assert.equal(changes.length, 3);
edit({ ...props, disabled: true }, 8); assert.equal(changes.length, 3);
edit({ ...props, value: { ...value, lot: 0 } }, 8); assert.equal(changes.length, 3);
assert.match(renderToStaticMarkup(createElement(BidClaimFields, { ...props, value: { ...value, minimum: 0 } })), /role="alert"/);
});
void test('own bid commitments remain visible with exact Yes/No meaning, completion and release statuses', () => {
const game = projected();
game.bidPromises.push(
{ event: 'no-event', player: 'p', asker: 'a', turn: 2, lot: 1, minimum: 8, answer: false },
{ event: 'fulfilled-event', player: 'p', asker: 'a', turn: 2, lot: 1, minimum: 2, answer: true, fulfilled: true },
{ event: 'released-event', player: 'p', asker: 'a', turn: 2, lot: 1, minimum: 12, answer: true, released: true },
{ event: 'foreign-event', player: 'a', asker: 'p', turn: 2, lot: 1, minimum: 99, answer: true },
);
game.auction!.active = 'a'; game.bidPromiseGuide = null;
const before = structuredClone(game);
for (let render = 0; render < 2; render++) {
const html = markup(game);
assert.match(html, /Your bid promises/); assert.match(html, /ordinary lot 1/);
assert.match(html, /Yes requires one qualifying bid/); assert.match(html, /No forbids every later bid at or above/);
assert.match(html, /If you win, the original payment rules still apply/);
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-event|controlled-bid-event/);
}
assert.deepEqual(game, before);
assert.equal(BidPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('the actual guide callback forwards bid or pass with the authoritative funding and event fields unchanged', () => {
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 = guide(props); press(button);
assert.deepEqual(sent, [game.bidPromiseGuide!.action]); assert.equal(sent[0], game.bidPromiseGuide!.action);
assert.equal(button.props.disabled, false);
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
}
});
void test('busy and stale guide callbacks cannot cross current owner, lot, event, stage or continuation boundaries', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.auction!.active = 'a'; },
game => { game.turn = 3; }, game => { game.phase = 5; }, game => { game.status = 'setup'; },
game => { game.bidPromiseLot = { turn: 2, lot: 2 }; }, game => { game.bidPromiseLot = null; },
game => { game.bidPromiseGuide = { action: { ...game.bidPromiseGuide!.action } }; },
game => { game.bidPromiseGuide = null; }, game => { game.bidPromiseGuide!.action.amount = 5; },
game => { game.bidPromises[0].event = 'another-event'; }, game => { game.bidPromises[0].player = 'a'; },
game => { game.bidPromises[0].fulfilled = true; }, game => { game.bidPromises[0].released = true; },
game => { game.bidPromises = []; }, game => { game.auction = null; },
game => { game.phaseOpening = { passed: [] }; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.techTokens = createTechTokens(); },
game => { game.automaticContinuationPending = true; },
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
];
for (const change of changes) {
const game = projected(), sent: Action[] = [];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = guide(props); change(game); press(button); assert.deepEqual(sent, []);
}
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy guide cannot dispatch'); } };
const button = guide(props); props.busy = true; press(button);
});
void test('bid controls use the public lot and own guide without reading card faces, source fingerprints or rival holdings', () => {
const game = projected(), sent: Action[] = [];
for (const player of game.players) {
for (const key of ['hand', 'traitors', 'faceDancers']) Object.defineProperty(player, key, {
get() { throw Error(`Bid controls cannot inspect ${key}`); }, configurable: true,
});
}
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No ordinary-lot face inspection'); } });
Object.defineProperty(game.bidPromises[0], 'source', { get() { throw Error('No internal bid source inspection'); } });
assert.match(markup(game), /Bid 4 spice while honoring your answers/);
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [game.bidPromiseGuide!.action]);
});
void test('source-earned Basic and Advanced current-lot answers connect persistent guidance to the actual qualifying bid callback', () => {
for (const advanced of [false, true]) {
const f = createTruthBidFixture({ advanced, window: 'active' }), bound = bindTruthBid(f, 'yes', 4);
const respondent = viewGame(bound.asked, f.target);
assert.ok(respondent.truthBidAnswers?.includes('yes'));
assert.equal(viewGame(bound.asked, f.asker).truthBidAnswers, null);
const answerHtml = renderToStaticMarkup(createElement(Truthtrance, { game: respondent, busy: false, act() {} }));
assert.match(answerHtml, /Answer Yes/); assert.match(answerHtml, /Answer No/);
const game = viewGame(bound.game, f.target), sent: Action[] = [];
assert.deepEqual(game.bidPromiseLot, { turn: bound.game.turn, lot: 1 });
assert.match(markup(game), /Your bid promises/); assert.match(markup(game), /Bid 4 spice/);
const next = game.bidPromiseGuide; assert.ok(next);
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [next.action]);
const after = actTruthBid(bound.game, f.target, sent[0]);
const event = next.action.bidPromiseEvent; assert.equal(typeof event, 'string');
assert.equal(after.bidPromises?.find(promise => promise.event === event)?.fulfilled, true);
assert.equal(after.players.find(player => player.id === f.target)!.spice, 10,
'The qualifying bid fulfills Yes before any later winning invoice is paid.');
assert.deepEqual(truthBidInventory(after), f.originalCards);
assert.equal(BidPromises({ game: viewGame(after, f.asker), busy: false, send() {} }), null);
}
});
+215
View File
@@ -0,0 +1,215 @@
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 {
actTruthBid as act, advanceTruthBid as advance, answerTruthBid as answer,
askTruthBid as ask, bindTruthBid as bind, continueTruthBidLot as closeLot,
createTruthBidFixture as fixture, declareTruthBid as declare, finishTruthBid as finish,
truthBidClean as clean, truthBidInventory as inventory, truthBidPlayer as player,
} from './fixture-truth-bid';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function current(game: Game, actor: string): Game {
return advance(game, state => clean(state) && state.auction?.active === actor);
}
void test('two original Truth cards keep a prior shipment commitment funded when answering a later bid question', () => {
const f = fixture();
let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'shipment', target: f.target, claim: { territory: 'arrakeen', minimum: 9 },
} });
assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes'));
game = answer(game, f.target, 'yes');
assert.equal(game.truthtrance?.stage, 'ask');
game = ask(game, f.asker, f.target, 4);
assert.deepEqual(viewGame(game, f.target).truthBidAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(game, f.target, 'no');
game = current(game, f.target);
game = act(game, f.target, { type: 'bid', amount: 1, allyPayment: 0 });
game = closeLot(game);
assert.equal(player(game, f.target).spice, 9);
game = finish(game);
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
game = act(game, f.target, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 9, elite: 0, allyPayment: 0 });
game = advance(game, clean);
assert.equal(player(game, f.target).forces['arrakeen:10'], 9);
assert.ok(game.shipmentPromises?.some(promise => promise.player === f.target && promise.fulfilled));
assert.ok(game.bidPromises?.every(promise => promise.fulfilled));
assert.deepEqual(inventory(game), f.originalCards);
});
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} current-lot Truth Yes requires a real new bid and preserves original payment`, () => {
const f = fixture({ advanced, window: 'active' });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.deepEqual(f.game.expansions, []); assert.deepEqual(f.staging, []);
assert.equal(f.originalCards.length, 33); assert.equal(f.truths.length, 2);
assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
const before = structuredClone(f.game), bound = bind(f);
assert.deepEqual(f.game, before);
assert.equal(bound.game.truthtrance, null);
assert.equal(bound.game.discard.filter(card => card.id === f.truth).length, 1);
assert.equal(player(bound.game, f.asker).hand.filter(card => card.effect === 'truthtrance').length, 1);
rejected(bound.game, f.target, { type: 'passBid' });
rejected(bound.game, f.target, { type: 'bid', amount: 3 });
const guide = viewGame(bound.game, f.target).bidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 4);
const bid = act(bound.game, f.target, guide.action);
assert.equal(bid.bidPromises!.find(promise => promise.player === f.target)!.fulfilled, true);
assert.equal(player(bid, f.target).spice, 10, 'Declaring the bid does not pay its later invoice');
const paid = closeLot(bid);
assert.equal(player(paid, f.target).spice, 6);
assert.equal(player(paid, f.target).hand.filter(card => card.id === f.lotCard).length, 1);
assert.ok((paid.bidPromises ?? []).every(promise => promise.fulfilled || promise.released));
assert.deepEqual(inventory(paid), f.originalCards);
const done = finish(paid); assert.equal(done.phase, 4); assert.deepEqual(inventory(done), f.originalCards);
});
for (const lowerBid of [false, true]) void test(`native current-lot Truth No permits ${lowerBid ? 'a lower bid' : 'a pass'} and expires before the next ordinary lot`, () => {
const f = fixture({ window: lowerBid ? 'active' : 'waiting' });
let source = f.game;
if (!lowerBid) {
source = act(source, f.asker, { type: 'bid', amount: 1 });
source = act(source, f.opponent, { type: 'passBid' });
}
const bound = bind(f, 'no', 4, source);
rejected(bound.game, f.target, { type: 'bid', amount: 4 });
const chosen = act(bound.game, f.target, lowerBid ? { type: 'bid', amount: 2 } : { type: 'passBid' });
const next = chosen.auction?.index === 0 ? closeLot(chosen) : chosen;
assert.equal(next.auction!.index, 1);
assert.ok((next.bidPromises ?? []).every(promise => promise.fulfilled || promise.released));
const ready = current(next, f.target);
const accepted = act(ready, f.target, { type: 'bid', amount: 4 });
assert.equal(accepted.auction!.bid, 4); assert.equal(accepted.auction!.bidder, f.target);
assert.deepEqual(inventory(accepted), f.originalCards);
});
void test('native waiting bidder Yes is released by a real opposing ten-spice bid, with ordinary Emperor income later', () => {
const f = fixture(), bound = bind(f);
assert.equal(bound.game.auction!.active, f.asker);
assert.equal(viewGame(bound.game, f.target).bidPromiseGuide, null);
let game = act(bound.game, f.asker, { type: 'passBid' });
assert.equal(game.auction!.active, f.opponent);
game = act(game, f.opponent, { type: 'bid', amount: 10 });
assert.equal(game.auction!.active, f.target);
assert.equal(game.bidPromises!.find(promise => promise.player === f.target)!.released, true);
game = act(game, f.target, { type: 'passBid' }); game = closeLot(game);
assert.equal(player(game, f.opponent).spice, 0);
assert.equal(player(game, f.target).spice, 20, 'Original Emperor auction income remains separate from its released declaration promise');
assert.ok(player(game, f.opponent).hand.some(card => card.id === f.lotCard));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native bidder cannot bribe away the last Yes funding but may keep its real four-spice witness', () => {
const f = fixture(), bound = bind(f);
rejected(bound.game, f.target, { type: 'bribe', target: f.asker, amount: 7 });
let game = act(bound.game, f.target, { type: 'bribe', target: f.asker, amount: 6 });
assert.equal(player(game, f.target).spice, 4); assert.equal(player(game, f.asker).bribes, 6);
game = current(game, f.target);
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide);
game = act(game, f.target, guide.action); game = closeLot(game);
assert.equal(player(game, f.target).spice, 0); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native earlier qualifying bid does not fulfill a Truth promise answered after a real outbid', () => {
const f = fixture(); let game = f.game;
for (const [actor, amount] of [[f.asker, 1], [f.opponent, 2], [f.target, 4], [f.asker, 5], [f.opponent, 6]] as const)
game = act(game, actor, { type: 'bid', amount });
assert.equal(game.auction!.active, f.target);
game = bind(f, 'yes', 4, game).game;
assert.equal(game.bidPromises!.find(promise => promise.player === f.target)!.fulfilled ?? false, false);
rejected(game, f.target, { type: 'passBid' });
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 7);
game = act(game, f.target, guide.action); assert.equal(game.bidPromises![0].fulfilled, true);
game = closeLot(game); assert.equal(player(game, f.target).spice, 3);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native bid declaration fulfills Yes even when a different real bidder later wins and pays', () => {
const f = fixture(); let game = act(f.game, f.asker, { type: 'bid', amount: 1 });
game = act(game, f.opponent, { type: 'bid', amount: 2 });
game = bind(f, 'yes', 4, game).game;
game = act(game, f.target, { type: 'bid', amount: 4 }); assert.equal(game.bidPromises![0].fulfilled, true);
game = act(game, f.asker, { type: 'bid', amount: 5 });
game = act(game, f.opponent, { type: 'passBid' });
game = act(game, f.target, { type: 'passBid' });
game = advance(game, clean);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.lotCard));
assert.ok(!player(game, f.target).hand.some(card => card.id === f.lotCard));
assert.equal(player(game, f.asker).spice, 5); assert.equal(player(game, f.target).spice, 15);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original queued Truthtrances combine No six with Yes four and each discard only once', () => {
const f = fixture({ window: 'active' }), declared = declare(f, f.game, { copies: 2 });
let game = ask(declared.game, f.asker, f.target, 6); game = answer(game, f.target, 'no');
assert.equal(game.truthtrance!.stage, 'ask');
game = ask(game, f.asker, f.target, 4); game = answer(game, f.target, 'yes');
assert.equal(game.bidPromises!.length, 2);
for (const card of f.truths) assert.equal(game.discard.filter(held => held.id === card).length, 1);
rejected(game, f.target, { type: 'passBid' }); rejected(game, f.target, { type: 'bid', amount: 6 });
game = act(game, f.target, { type: 'bid', amount: 5 }); game = closeLot(game);
assert.equal(player(game, f.target).spice, 5);
assert.ok((game.bidPromises ?? []).every(promise => promise.fulfilled || promise.released));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native question ownership, exact current-lot binding and stale guided callbacks retain original action boundaries', () => {
const f = fixture({ window: 'active' }), declared = declare(f), question = f.question;
rejected(declared.game, f.opponent, { type: 'truthAsk', question });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...question, lot: question.lot + 1 } });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...question, turn: question.turn + 1 } });
let game = ask(declared.game, f.asker, f.target);
assert.deepEqual(viewGame(game, f.target).truthBidAnswers, ['yes', 'no']);
assert.equal(viewGame(game, f.opponent).truthBidAnswers, null);
rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' });
game = answer(game, f.target, 'yes');
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide);
assert.ok(guide.action.bidPromiseEvent);
assert.ok(viewGame(game, f.target).bidPromises.every(promise => !Object.hasOwn(promise, 'source')));
game = act(game, f.target, guide.action); game = closeLot(game); game = current(game, f.target);
rejected(game, f.target, guide.action);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native already-passed and current-highest bidders cannot supply a new Truth bid opportunity', () => {
const passed = fixture({ window: 'active' }), first = declare(passed);
assert.ok(passed.game.auction!.passed.includes(passed.opponent));
rejected(first.game, passed.asker, { type: 'truthAsk', question: { ...passed.question, target: passed.opponent } });
const highest = fixture(); let game = highest.game;
for (const [actor, amount] of [[highest.asker, 1], [highest.opponent, 2], [highest.target, 3]] as const)
game = act(game, actor, { type: 'bid', amount });
assert.equal(game.auction!.bidder, highest.target);
game = declare(highest, game).game;
rejected(game, highest.asker, { type: 'truthAsk', question: highest.question });
assert.deepEqual(inventory(game), highest.originalCards);
});
void test('native actual Karama funds the promised overbid and its original free purchase consumes one card', () => {
const f = fixture({ window: 'active', targetKarama: true }); let game = bind(f, 'yes', 11).game;
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 11);
game = act(game, f.target, guide.action);
game = advance(game, state => !state.response && state.decision?.kind === 'auctionPayment');
assert.equal(game.auction!.bid, 11); assert.equal(player(game, f.target).spice, 10);
game = act(game, f.target, { type: 'decision', karama: true, card: f.karama });
assert.equal(player(game, f.target).spice, 10); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal policies offer a legal native bid for the current answered Yes', () => {
const f = fixture({ window: 'active' }), bound = bind(f).game;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(bound, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const offered = botActions(view).find(action => action.type === 'bid' && Number(action.amount) >= 4); assert.ok(offered, difficulty);
const accepted = act(structuredClone(bound), f.target, offered);
assert.equal(accepted.bidPromises![0].fulfilled, true); assert.deepEqual(inventory(accepted), f.originalCards);
}
});