Add interactive bidding practice with shared ordinary auction quotes

Expand the internal introduction to eight lessons and six Basic exercises. A Fremen practice auction teaches budget limits, raising, passing and re-entry, full-hand exclusion, all-pass termination, winner-only payment and private purchase inspection. Two explicitly fixed opponent scripts make legal teaching moves without a confirmation step or changes to actual AI strategies.

Extract ordinary bid validation and next-seat/payment/unbid selection into shared pure helpers used by both the practice and live engine. The engine retains actual card custody, ally contribution, Karama, Bureaucrat, payment and response handling. Inconsistent order/eligibility fails closed instead of an unbounded loop. No mode/expansion/publication gate changes.

Version 3 lesson saves retain the existing key and migrate all five v1 and seven v2 positions by identity. Prior committed movement, battle and Traitor choices persist. Only learner bid/pass choices are saved and semantically replayed; over-budget, underbid, full-hand and post-terminal choices are rejected, and unrelated fields are stripped.

Independent review found no material defect. Types, lint, 4925 offline tests and production build pass on stable final source. Nine new tests cover shared bidding order/eligibility/funding and practice outcomes, actual engine payment/card-custody comparisons, all-pass/full-hand paths, all v2 lesson migrations and saved replay. Existing v1, movement, combat and Traitor checks remain green. HTTP/auth/schema code did not change; offline checks include existing in-memory persistence tests, and no live HTTP suite was rerun for this checkpoint.

Browser checks confirm preserved older movement practice, heading focus, keyboard budget control, invalid bid blocking, pass/re-entry, refresh during and after the auction, private Shield inspection, full-hand and all-pass results. The completed bid display stays at the actual committed amount. At 390px controls fit at 309px wide with at least 44px height and no document overflow; the viewport was reset. Screenshot capture still times out, so visual screenshot acceptance remains unverified. The page has no external links and the existing Learn tab is retained.

All 1337 original rooms, 2805 seats and every existing recovery/entry/handover/delegation/migration row are unchanged. SQLite integrity and foreign keys pass. No reset, migration, restart, automation, account-usage check or AI strength calibration occurred. Bounded current-state review also confirmed Extortion, Atomics and Kull still cross previously recorded material rulings; no duplicate questions or house rules were introduced. Alliances, faction/Advanced/expansion teaching and the full game objective remain unfinished.

Verified source tree: b5d318722db155645e98ec3319ffea47f4caa049a06ff5d97abc850db59cb717
This commit is contained in:
Quintine committed 2026-09-20 17:19:18 +10:00
1 parent b9718acda8
commit b7ec94015a
15 files changed
+882 -70

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@ Until that stage, AI work focuses on minimal legal participation and critical
playability fixes.
Choose **Learn to play** for the [interactive introduction](docs/INTERACTIVE_INTRODUCTION.md)
at `/learn`. Practice shipping, movement, battle plans, Traitor calls and collection before entering a
at `/learn`. Practice bidding, shipping, movement, battle plans, Traitor calls and collection before entering a
room; lesson progress stays in your browser and does not change saved games.
Local games persist in `.wrangler/state`. Keep this directory across restarts. Apply new additive migrations before running code that depends on them. Never delete the local database to resolve a connection or rules problem. `.openai/hosting.json` belongs to the existing Sites project; do not recreate the hosting project.
+198
View File
@@ -0,0 +1,198 @@
import {
introductionBidding,
INTRODUCTION_BIDDERS,
} from '@/game/introduction-bidding';
import type { IntroductionState } from '@/game/introduction';
import { CardInspector, CardRules } from './card-inspector';
import styles from './introduction.module.css';
export function BiddingLesson({
state,
update,
}: {
state: IntroductionState;
update: (patch: Partial<IntroductionState>) => void;
}) {
const auction = introductionBidding(state);
const reset = (patch: Partial<IntroductionState> = {}) =>
update({ auctionActions: [], auctionBid: 1, ...patch });
const act = (amount: number | null) => {
const next = {
...state,
auctionActions: [...state.auctionActions, amount],
};
const result = introductionBidding(next);
update({
auctionActions: next.auctionActions,
auctionBid:
result.status === 'bidding'
? Math.min(7, result.minimum)
: next.auctionBid,
});
};
return (
<>
<p>
In this separate example you are <strong>Fremen with 6 spice</strong>.
One face-down Treachery Card is being auctioned in the order Fremen,
Emperor, Spacing Guild. There are no allies, Karama cards or other
reacting powers here.
</p>
<div className={styles.columns}>
<div className={styles.controls}>
<label htmlFor="learn-auction-scenario">Practice opponents</label>
<select
id="learn-auction-scenario"
value={state.auctionScenario}
onChange={(event) =>
reset({
auctionScenario: event.target
.value as IntroductionState['auctionScenario'],
})
}
>
<option value="contest">
Emperor bids up to 2; Guild bids up to 3
</option>
<option value="all-pass">Both opponents pass</option>
</select>
<label className={styles.checkbox}>
<input
type="checkbox"
checked={state.auctionHandFull}
onChange={(event) =>
reset({ auctionHandFull: event.target.checked })
}
/>
Start with a full hand of four cards
</label>
<p className="fine">
These are fixed teaching responses. At a real table, opponents
choose their own bids and their spice remains private. Changing
either option starts a new example.
</p>
<label htmlFor="learn-auction-bid">
Your bid: {state.auctionBid} spice
</label>
<input
id="learn-auction-bid"
type="range"
min="0"
max="7"
step="1"
value={state.auctionBid}
disabled={auction.status !== 'bidding'}
onChange={(event) =>
update({ auctionBid: Number(event.target.value) })
}
/>
<button
disabled={!auction.canBid}
onClick={() => act(state.auctionBid)}
>
Place practice bid
</button>
<button
disabled={auction.status !== 'bidding'}
onClick={() => act(null)}
>
Pass this bid
</button>
<button onClick={() => reset()}>Restart practice auction</button>
</div>
<div className={styles.result} aria-live="polite">
<h3>
{auction.status === 'bidding'
? 'Your bidding turn'
: auction.status === 'unbid'
? 'No one bought the card'
: auction.won
? 'You bought the card'
: 'Your opponent bought the card'}
</h3>
{state.auctionHandFull && (
<p>
Your four-card hand is full, so you cannot bid. The auction
continues with the eligible players.
</p>
)}
{auction.status === 'bidding' ? (
<>
<p>
{auction.bidder
? `${INTRODUCTION_BIDDERS[auction.bidder]} holds the high bid of ${auction.bid} spice.`
: 'No bid has been made yet.'}{' '}
Your next bid must be at least {auction.minimum} spice.
</p>
<p>
You still hold all 6 spice. The winning bid is paid when the
auction closes.
</p>
{auction.bidError && <p>{auction.bidError}</p>}
{state.auctionActions.includes(null) && (
<p>
You passed earlier, but the card is still up for sale. You may
re-enter on this turn.
</p>
)}
</>
) : auction.status === 'unbid' ? (
<>
<p>
Everyone passed without a bid. No spice changes hands. This card
and any remaining unauctioned cards return to the top of the
deck, and normal bidding ends.
</p>
</>
) : (
<>
<p>
{INTRODUCTION_BIDDERS[auction.bidder!]} pays {auction.bid} spice{' '}
{auction.bankIncome ? 'to the Spice Bank' : 'to the Emperor'}.
Losing bidders pay nothing.
</p>
<p>
You have <strong>{auction.remaining} spice</strong> and{' '}
{auction.handCount} cards in your hand.
</p>
{auction.received ? (
<>
<p>
Your new card is private. Buying it did not reveal its face
to the other players.
</p>
<CardRules card={auction.received} />
<CardInspector card={auction.received} />
</>
) : (
<p>The purchased card remains concealed from you.</p>
)}
</>
)}
<h3>Auction history</h3>
{auction.transcript.length ? (
<ol>
{auction.transcript.map((entry, index) => (
<li key={index}>{entry}</li>
))}
</ol>
) : (
<p>You open the bidding. Bid at least 1 spice or pass.</p>
)}
</div>
</div>
<p>
A higher bid gives the other eligible players another opportunity.
Passing does not permanently remove you: you can bid again if the card
is still available when your turn returns. Once everyone else passes on
the standing bid, its owner buys the card automatically.
</p>
<p>
Ordinary hands hold at most four Treachery Cards; Harkonnen has a larger
limit. Atreides can inspect auction cards through its faction power.
Allies, Karama and expansion auctions add further choices in the live
game.
</p>
</>
);
}
+12 -10
View File
@@ -6,6 +6,7 @@ import { BattleWheel } from './battle-wheel';
import { CardInspector, CardRules } from './card-inspector';
import { PHASES } from '@/game/catalog';
import { MovementLesson, TraitorLesson } from './introduction-practice';
import { BiddingLesson } from './introduction-bidding';
import {
INTRODUCTION_CARDS, INTRODUCTION_LEADERS, INTRODUCTION_STEPS, INTRODUCTION_STORAGE_KEY,
introductionBattle, introductionCollection, introductionShipment, newIntroduction, restoreIntroduction,
@@ -24,10 +25,10 @@ export function Introduction() {
<Link href="/">← Return to Arrakis</Link>
<p className="eyebrow">LEARN TO PLAY · BASIC GAME</p>
<h1>A first turn on Arrakis</h1>
<p>Practice shipping, movement, battle plans, Traitor calls and collection, then take a seat at a real table. These are separate examples; they do not change any saved game.</p>
<p>Practice bidding, shipping, movement, battle plans, Traitor calls and collection, then take a seat at a real table. These are separate examples; they do not change any saved game.</p>
</header>
{/* Remount on schema changes so a development hot update also migrates old state. */}
{hydrated ? <IntroductionLessons key="v2" /> : <output>Restoring your lesson…</output>}
{hydrated ? <IntroductionLessons key="v3" /> : <output>Restoring your lesson…</output>}
</main>;
}
@@ -66,7 +67,7 @@ function IntroductionLessons() {
<p className="eyebrow">{state.step + 1} / {INTRODUCTION_STEPS.length}</p>
<h2 id="lesson-title" ref={heading} tabIndex={-1}>{lesson.title}</h2>
{state.step === 0 && <>
<p>Each faction has its own powers. You will practice as Atreides with ordinary forces in the Basic game. Advanced combat and expansion powers come later.</p>
<p>Each faction has its own powers. Practice Basic bidding as Fremen, then shipping and battles as Atreides with ordinary forces. Advanced combat and expansion powers come later.</p>
<div className={styles.columns}>
<div><h3>On the table</h3><p>Everyone can see the board, forces, storm and revealed cards. Your spice, Treachery hand and Traitors stay private unless a rule reveals them.</p></div>
<div><h3>Your objective</h3><p>Normally, hold three strongholds alone or four together with your ally at the Mentat Pause. Player-count and faction victory rules can change that target; the live table shows your current victory progress.</p></div>
@@ -75,7 +76,8 @@ function IntroductionLessons() {
<ol className={styles.phases}>{PHASES.map(phase => <li key={phase}>{phase}</li>)}</ol>
<p>The table tells you who must act. Your decisions can wait while another player seals a plan or answers a power. Automatic effects show in the chronicle.</p>
</>}
{state.step === 1 && <>
{state.step === 1 && <BiddingLesson state={state} update={update} />}
{state.step === 2 && <>
<p>You have <strong>8 spice</strong> and six available reserve forces. Ship from reserves before moving on the board. This example has no discount, storm, occupancy restriction or reacting faction power.</p>
<div className={styles.columns}>
<div className={styles.controls}>
@@ -99,8 +101,8 @@ function IntroductionLessons() {
</div>
<p className="fine">Ordinary movement reaches an adjacent territory. Ornithopters and faction powers can extend its range. Inspect the live destination guide before committing.</p>
</>}
{state.step === 2 && <MovementLesson state={state} update={update} />}
{state.step === 3 && <>
{state.step === 3 && <MovementLesson state={state} update={update} />}
{state.step === 4 && <>
<p>A separate battle: you are the aggressor in Wind Pass with six ordinary forces and {INTRODUCTION_LEADERS.you.name}, strength {INTRODUCTION_LEADERS.you.strength}. Your opponent also has six forces. Neither side calls a Traitor or uses a faction power in this example.</p>
<div className={styles.columns}>
<div className={styles.controls}>
@@ -128,8 +130,8 @@ function IntroductionLessons() {
</div>
</div>
</>}
{state.step === 4 && <TraitorLesson state={state} update={update} />}
{state.step === 5 && <>
{state.step === 5 && <TraitorLesson state={state} update={update} />}
{state.step === 6 && <>
<p>A fresh position has 8 spice in Wind Pass, sector 14. You have surviving forces in that same sector, outside the storm. Collection uses the forces still on the board after battles.</p>
<div className={styles.columns}>
<div className={styles.controls}>
@@ -145,8 +147,8 @@ function IntroductionLessons() {
</div>
</div>
</>}
{state.step === 6 && <>
<p>You can revisit any example. This introduction covers core resources, movement and battle choices; it does not teach bidding or every faction, card, alliance, storm exception or expansion.</p>
{state.step === 7 && <>
<p>You can revisit any example. This introduction covers bidding, core resources, movement and battle choices; it does not teach every faction, card, alliance, storm exception or expansion.</p>
<ul>
<li>Create a private room, choose a faction, and add friends or AI players. Share the room invitation.</li>
<li>Mark yourself ready after reviewing the selected rules. The host begins once all seats are ready.</li>
+11 -11
View File
@@ -6,22 +6,22 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
Latest delivered checkpoint: `b21b2bc`, [one-use AI permission](SEAT_AI_PERMISSION.md).
Types, lint, **4,906 offline tests**, production build and **45 HTTP tests** passed.
All 1,174 baseline rooms and 2,456 seats stayed unchanged; the new preservation
baseline contains 1,262 rooms and 2,644 seats. No restart was needed.
The preceding `e13ad45` connects [movement and Traitor practice](INTERACTIVE_INTRODUCTION.md).
Latest delivered checkpoint: `b9718ac`, [lobby AI configuration](LOBBY_AI_CONFIGURATION.md).
Types, lint, **4,916 offline tests**, production build and **46 HTTP tests** passed.
All 1,262 baseline rooms and 2,644 seats stayed unchanged; the new preservation
baseline contains 1,337 rooms and 2,805 seats. No restart was needed.
The preceding `b21b2bc` connects [one-use AI permission](SEAT_AI_PERMISSION.md).
The `8f7a62c` policy checkpoint removed the
usage cutoff and deferred AI refinement until feature completion, with three
adjacent 75% win-rate targets. Earlier evidence remains in the
[implementation history](IMPLEMENTATION_STATUS.md).
Current work: [lobby AI configuration](LOBBY_AI_CONFIGURATION.md) connects
host controls for an existing AI seat's difficulty, faction and player circle,
preserving identity and resetting human readiness. It uses the existing profiles
without strategic refinement. Final independent review, verification and delivery
belong to the source-bound private report and Git message. Broader disconnected-seat
acceptance remains unfinished.
Current work: [interactive bidding](INTERACTIVE_INTRODUCTION.md) connects the
eighth lesson to shared ordinary-auction calculations, private purchase inspection
and versioned saved practice. No AI strategy is changed. Independent review and
final browser/check/build/preservation evidence belong to the source-bound private
report and Git message. Alliances, faction/Advanced/expansion teaching and broader
disconnected-seat acceptance remain unfinished.
A bounded remaining-effect audit found that the reviewed faction/Nexus candidates
still cross recorded pending rulings. Continue independent features rather than
+12
View File
@@ -1,5 +1,17 @@
# Dune implementation status
## 20 September 2026 — Interactive ordinary bidding practice
The [introduction](INTERACTIVE_INTRODUCTION.md) now connects eight lessons and six
Basic examples. The new Fremen bidding position teaches raising, passing and
re-entry, full hands, all-pass termination, winner-only payment and private card
inspection. Shared ordinary-auction quotes also serve the live engine without
changing its funding, payment or reaction layers. Version 3 practice saves migrate
every prior lesson by identity and validate exact bid history through replay.
Focused engine comparisons and independent review are paired with final browser,
source-bound checks, preservation and Git evidence in the private checkpoint.
Broader teaching, rules and mode/publication gates remain unfinished.
## 20 September 2026 — Configure existing lobby AI seats
[Lobby AI configuration](LOBBY_AI_CONFIGURATION.md) connects host-only difficulty,
+36 -8
View File
@@ -4,9 +4,9 @@
from the lobby and rules reference. It adds the previously missing guided
introduction alongside the reference's existing standalone practice wheel.
Seven navigable lessons cover public/private table information and the phase
sequence, shipment budgets, movement, sealed battle plans, Traitor calls, spice
collection and joining or recovering a saved table. Five independent Basic practice positions let a player
Eight navigable lessons cover public/private table information and the phase
sequence, bidding, shipment budgets, movement, sealed battle plans, Traitor calls, spice
collection and joining or recovering a saved table. Six independent Basic practice positions let a player
change inputs, commit, inspect results and retry. These positions are deliberately
separate examples rather than a complete game or an authoritative live room.
@@ -23,6 +23,23 @@ production combat quote. The private Traitor has the existing full inspector.
These fixed teaching scenarios are not AI policy or hidden-information tests
of the four live difficulty levels.
The bidding example gives Fremen six spice and a three-card hand, with a full-hand
variant and two deterministic opponent scripts. Players raise, pass, re-enter on
a later turn or end an unbid auction. The winner pays once; losing bids spend
nothing, and only the player's own purchase exposes a card face/inspector.
Ordinary bid validation and next-seat/all-pass decisions use
`quoteNormalAuctionBid` and `quoteNormalAuctionNext`, extracted from the existing
live engine. The engine retains ally funding, Karama, payment, card custody and
reaction handling. No strategic bot policy changes. Scripted lessons omit those
special interactions and are checked against actual `applyAction` outcomes.
Source authority is the [GF9 base rulebook, Bidding](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
and [November 2020 FAQ, p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2).
The publisher-indexed FAQ text was freshly retrieved on 20 September; it confirms
that a prior passer can bid again. Direct PDF retrieval returned 403, so no new
binary/visual source inspection is claimed. Links remain developer provenance;
the player lesson links only to internal rules.
The movement position starts with five ordinary forces in Red Chasm. It compares
range one with range three from a separate Arrakeen occupant, exact sectors,
storm exclusion and a stronghold already occupied by two other factions. Accepted
@@ -34,10 +51,13 @@ actual authoritative `applyAction` transitions; no live movement rule changed.
The existing wheel, keyboard controls and card inspectors remain available.
Lesson changes focus the heading; responsive columns stack on small screens.
Browser storage contains only a versioned, validated set of lesson choices under
`dune-introduction-v1`. Version 2 payloads explicitly migrate each of the five old
version 1 lesson indices to the same lesson after insertion, preserving every
prior choice and committed outcome. New movement and Traitor fields start at
their defaults; hot updates remount the lesson state to apply this migration.
`dune-introduction-v1`. Version 3 payloads explicitly migrate all five version 1
and seven version 2 lesson positions to the same lesson after insertion,
preserving prior choices and committed outcomes. New bidding fields start at
their defaults; version 1 also receives movement/Traitor defaults. Hot updates
remount the lesson state to apply this migration. Only the player's bid/pass
history is saved; deterministic replay validates the complete auction and rejects
underbids, overspending, full-hand actions and actions after the auction ends.
Future lesson insertions must update the schema and mapping again. The save
includes movement commitments and the before-reveal, revealed and resolved
Traitor stages. Unknown versions,
@@ -59,7 +79,15 @@ and new lobby link stayed within the page bounds. Screenshot capture timed out,
so visual screenshot acceptance remains open; DOM bounds are narrower evidence.
Final source-bound reports and Git delivery record required check/build results.
This does not replace a full interactive curriculum. Bidding, alliances, broader movement/arrival powers, faction lessons, Advanced
`tests/introduction-bidding.test.ts` compares offered openings and re-entry with
actual engine payment and physical card custody, checks all-pass/full-hand paths,
all seven version 2 lesson migrations and exact saved bidding continuation.
`tests/normal-auction.test.ts` exercises shared turn order, eligibility, pass
re-entry, finite termination and integer/funding limits. Final source-bound
browser/check/build and preservation evidence for bidding belongs to its private
checkpoint and Git message.
This does not replace a full interactive curriculum. Alliances, broader movement/arrival powers, faction lessons, Advanced
support and expansion lessons remain future work. The expanded lessons retain
Partial/Prototyped coverage. Final browser/check/build, preservation and Git
evidence for this follow-up belong to its source-bound private checkpoint. No rules mode, expansion or publication gate is opened.
+25 -17
View File
@@ -213,6 +213,10 @@ import {
type AuctionContinuationOperation,
type AuctionContinuationQuote,
} from './auction-continuation-quote';
import {
quoteNormalAuctionBid,
quoteNormalAuctionNext,
} from './normal-auction';
import {
quoteNormalAuctionPeek,
quoteIxTechnologyCancellation,
@@ -7766,24 +7770,27 @@ function auctionNext(g: Game) {
const p = getPlayer(g, id);
return p.hand.length < handLimit(p);
});
const others = eligible.filter((id) => id !== a.bidder);
if (others.every((id) => a.passed.includes(id))) {
if (!a.bidder) {
const next = quoteNormalAuctionNext({
order: g.order,
eligible,
active: a.active,
bid: a.bid,
bidder: a.bidder,
passed: a.passed,
});
if (next.kind !== 'bid') {
if (next.kind === 'unbid') {
g.deck = [...a.cards.slice(a.index), ...g.deck];
g.auction = null;
finishNormalBidding(g);
return;
}
g.decision = { kind: 'auctionPayment', player: a.bidder };
g.active = a.bidder;
recoverAuctionPayment(g, getPlayer(g, a.bidder));
g.decision = { kind: 'auctionPayment', player: next.player };
g.active = next.player;
recoverAuctionPayment(g, getPlayer(g, next.player));
return;
}
let i = g.order.indexOf(a.active);
do {
i = (i + 1) % g.order.length;
} while (!eligible.includes(g.order[i]) || g.order[i] === a.bidder);
a.active = g.order[i];
a.active = next.player;
g.active = a.active;
}
function settleAuction(g: Game, free = false, automatic = false) {
@@ -22920,14 +22927,15 @@ function applyActionInner(
);
const a = g.auction;
if (t === 'bid') {
a.bid = integer(
action.amount,
a.bid + 1,
karamaCard(g, p)
const quotedBid = quoteNormalAuctionBid({
currentBid: a.bid,
amount: action.amount,
maximum: karamaCard(g, p)
? Number.MAX_SAFE_INTEGER
: p.spice + (aidFor(g, p)?.amount ?? 0),
'Bid',
);
});
if (!quotedBid.ok) throw new RuleError(quotedBid.reason);
a.bid = quotedBid.amount;
a.allyPayment =
a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0)
? contribution(g, p, a.bid, action.allyPayment)
+113
View File
@@ -0,0 +1,113 @@
import { baseDeck } from './cards';
import {
quoteNormalAuctionBid,
quoteNormalAuctionNext,
} from './normal-auction';
export type IntroductionBiddingChoice = {
auctionScenario: 'contest' | 'all-pass';
auctionHandFull: boolean;
auctionBid: number;
auctionActions: Array<number | null>;
};
const order = ['you', 'emperor', 'guild'];
export const INTRODUCTION_BIDDERS: Readonly<Record<string, string>> = {
you: 'You · Fremen',
emperor: 'Emperor',
guild: 'Spacing Guild',
};
const budget = 6;
/** Replay only validated teaching choices, never imported room or private seat data. */
export function introductionBidding(choice: IntroductionBiddingChoice) {
if (
!['contest', 'all-pass'].includes(choice.auctionScenario) ||
typeof choice.auctionHandFull !== 'boolean' ||
!Number.isSafeInteger(choice.auctionBid) ||
choice.auctionBid < 0 ||
choice.auctionBid > 7 ||
!Array.isArray(choice.auctionActions) ||
choice.auctionActions.length > 7
)
throw new Error('Invalid saved bidding practice.');
const eligible = choice.auctionHandFull ? order.slice(1) : [...order];
const auction = {
order,
eligible,
active: eligible[0],
bid: 0,
bidder: null as string | null,
passed: [] as string[],
};
const transcript: string[] = [];
const actions: Array<{ actor: string; amount: number | null }> = [];
let status: 'bidding' | 'sold' | 'unbid' = 'bidding';
let consumed = 0;
// Strictly increasing bids and three finite seats make this bound unreachable normally.
for (let guard = 0; guard < 30 && status === 'bidding'; guard++) {
const actor = auction.active;
let amount: number | null;
if (actor === 'you') {
if (consumed === choice.auctionActions.length) break;
amount = choice.auctionActions[consumed++];
} else {
const ceiling = actor === 'emperor' ? 2 : 3;
amount =
choice.auctionScenario === 'contest' && auction.bid < ceiling
? ceiling
: null;
}
if (amount !== null) {
const quote = quoteNormalAuctionBid({
currentBid: auction.bid,
amount,
maximum: actor === 'you' ? budget : 4,
});
if (!quote.ok) throw new Error('Invalid saved practice bid.');
auction.bid = quote.amount;
auction.bidder = actor;
auction.passed = [];
} else if (!auction.passed.includes(actor)) auction.passed.push(actor);
actions.push({ actor, amount });
transcript.push(
`${INTRODUCTION_BIDDERS[actor]} ${amount === null ? 'passed.' : `bid ${amount} spice.`}`,
);
const next = quoteNormalAuctionNext(auction);
if (next.kind === 'bid') auction.active = next.player;
else status = next.kind === 'payment' ? 'sold' : 'unbid';
}
if (
consumed !== choice.auctionActions.length ||
(status === 'bidding' && auction.active !== 'you')
)
throw new Error(
'Saved bidding actions extend past the end of this example.',
);
const won = status === 'sold' && auction.bidder === 'you';
const draft = quoteNormalAuctionBid({
currentBid: auction.bid,
amount: choice.auctionBid,
maximum: budget,
});
return {
status,
bid: auction.bid,
bidder: auction.bidder,
active: status === 'bidding' ? auction.active : null,
actions,
transcript,
won,
minimum: auction.bid + 1,
canBid: status === 'bidding' && draft.ok,
bidError: status !== 'bidding' ? null : draft.ok ? null : draft.reason,
remaining: budget - (won ? auction.bid : 0),
handCount: (choice.auctionHandFull ? 4 : 3) + (won ? 1 : 0),
emperorIncome:
status === 'sold' && auction.bidder !== 'emperor' ? auction.bid : 0,
bankIncome:
status === 'sold' && auction.bidder === 'emperor' ? auction.bid : 0,
// The face is part of the example, not a live deck; only the buyer sees it.
received: won ? baseDeck().find((card) => card.kind === 'shield')! : null,
};
}
+20 -10
View File
@@ -3,11 +3,13 @@ import { reserveShipmentCost } from './shipment-price';
import { quoteBattleResolution, type ResolutionCombatant } from './battle-resolution-quote';
import { quoteSpiceCollection } from './board-resolution-quote';
import { INTRODUCTION_MOVE_DESTINATIONS, introductionMovement, type IntroductionMovementChoice } from './introduction-movement';
import { introductionBidding, type IntroductionBiddingChoice } from './introduction-bidding';
/** Fixed teaching examples, never a room, seat or multiplayer save. */
export const INTRODUCTION_STORAGE_KEY = 'dune-introduction-v1';
export const INTRODUCTION_STEPS = [
{ title: 'Your place at the table', topic: 'setup' },
{ title: 'Bid for a hidden card', topic: 'bidding' },
{ title: 'Ship within your budget', topic: 'movement' },
{ title: 'Move across the board', topic: 'movement' },
{ title: 'Seal a battle plan', topic: 'battle' },
@@ -15,8 +17,8 @@ export const INTRODUCTION_STEPS = [
{ title: 'Collect the spice', topic: 'collection' },
{ title: 'Join a table', topic: 'privacy' },
] as const;
export type IntroductionState = IntroductionMovementChoice & {
version: 2;
export type IntroductionState = IntroductionMovementChoice & IntroductionBiddingChoice & {
version: 3;
step: number;
shipment: number;
destination: 'stronghold' | 'sand';
@@ -33,10 +35,11 @@ export type IntroductionState = IntroductionMovementChoice & {
opponentTraitor: boolean;
};
export function newIntroduction(): IntroductionState {
return { version: 2, step: 0, shipment: 4, destination: 'sand', shipped: false,
return { version: 3, step: 0, shipment: 4, destination: 'sand', shipped: false,
dial: 2, defense: 'shield', revealed: false, collectors: 3, city: false, collected: false,
moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false,
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false };
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false,
auctionScenario: 'contest', auctionHandFull: false, auctionBid: 1, auctionActions: [] };
}
const integer = (value: unknown, min: number, max: number): value is number =>
Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max;
@@ -44,13 +47,17 @@ export function restoreIntroduction(raw: string | null): IntroductionState | nul
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (!parsed || (parsed.version !== 1 && parsed.version !== 2)) return null;
if (!parsed || ![1, 2, 3].includes(parsed.version)) return null;
// Keep the existing browser key and translate old positions, not just indices.
if (parsed.version === 1 && !integer(parsed.step, 0, 4)) return null;
const s = (parsed.version === 1 ? { ...newIntroduction(), ...parsed, version: 2,
step: [0, 1, 3, 5, 6][parsed.step],
if (parsed.version === 2 && !integer(parsed.step, 0, 6)) return null;
const auctionDefaults = { auctionScenario: 'contest', auctionHandFull: false, auctionBid: 1, auctionActions: [] };
const s = (parsed.version === 1 ? { ...newIntroduction(), ...parsed, version: 3,
step: [0, 2, 4, 6, 7][parsed.step],
moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false,
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false } : parsed) as IntroductionState;
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false, ...auctionDefaults }
: parsed.version === 2 ? { ...parsed, version: 3, step: [0, 2, 3, 4, 5, 6, 7][parsed.step], ...auctionDefaults }
: parsed) as IntroductionState;
if (!integer(s.step, 0, INTRODUCTION_STEPS.length - 1) ||
!integer(s.shipment, 1, 6) || !['stronghold', 'sand'].includes(s.destination) ||
!integer(s.dial, 0, 6) || !['shield', 'snooper'].includes(s.defense) ||
@@ -62,13 +69,16 @@ export function restoreIntroduction(raw: string | null): IntroductionState | nul
!['plans', 'revealed', 'resolved'].includes(s.traitorStage) ||
(s.traitorStage !== 'resolved' && s.traitorCall) ||
(s.moved && !introductionMovement(s).allowed)) return null;
introductionBidding(s);
// Whitelist fields; never retain credentials or arbitrary imported properties.
return { version: 2, step: s.step, shipment: s.shipment, destination: s.destination,
return { version: 3, step: s.step, shipment: s.shipment, destination: s.destination,
shipped: s.shipped, dial: s.dial, defense: s.defense, revealed: s.revealed,
collectors: s.collectors, city: s.city, collected: s.collected,
moveDestination: s.moveDestination, moveForces: s.moveForces, moveCity: s.moveCity,
moveStorm: s.moveStorm, moved: s.moved, traitorStage: s.traitorStage,
traitorCall: s.traitorCall, opponentTraitor: s.opponentTraitor };
traitorCall: s.traitorCall, opponentTraitor: s.opponentTraitor,
auctionScenario: s.auctionScenario, auctionHandFull: s.auctionHandFull,
auctionBid: s.auctionBid, auctionActions: [...s.auctionActions] };
} catch { return null; }
}
export function introductionShipment(s: Pick<IntroductionState, 'shipment' | 'destination'>) {
+62
View File
@@ -0,0 +1,62 @@
export type NormalAuctionNext =
| { kind: 'unbid' }
| { kind: 'payment'; player: string }
| { kind: 'bid'; player: string };
export type NormalAuctionBid =
| { ok: true; amount: number }
| { ok: false; reason: string };
/**
* Quote the next ordinary auction step. The engine owns card custody, payment,
* phase progression and the hand-limit eligibility calculation.
*/
export function quoteNormalAuctionNext(context: {
order: readonly string[];
eligible: readonly string[];
active: string;
bid: number;
bidder: string | null;
passed: readonly string[];
}): NormalAuctionNext {
const eligible = new Set(context.eligible);
const others = context.eligible.filter((id) => id !== context.bidder);
if (others.every((id) => context.passed.includes(id)))
return context.bidder
? { kind: 'payment', player: context.bidder }
: { kind: 'unbid' };
const activeIndex = context.order.indexOf(context.active);
for (let offset = 1; offset <= context.order.length; offset++) {
const index = (Math.max(activeIndex, -1) + offset) % context.order.length;
const player = context.order[index];
if (eligible.has(player) && player !== context.bidder)
return { kind: 'bid', player };
}
// A malformed restored state must fail closed instead of spinning forever or
// fabricating an auction outcome.
throw new Error(
'Ordinary auction has no eligible next bidder in table order.',
);
}
/** The engine supplies the freshly calculated funding maximum. */
export function quoteNormalAuctionBid(context: {
currentBid: number;
amount: unknown;
maximum: number;
}): NormalAuctionBid {
const minimum = context.currentBid + 1;
if (
typeof context.amount !== 'number' ||
!Number.isSafeInteger(context.amount) ||
context.amount < minimum ||
context.amount > context.maximum
)
return {
ok: false,
reason: `Bid must be an integer from ${minimum} to ${context.maximum}.`,
};
return { ok: true, amount: context.amount };
}
+10 -9
View File
@@ -230,21 +230,22 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Getting started',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Practice shipping, movement, sealed battle plans, Traitor calls and spice collection before joining a table.',
summary: 'Practice bidding, shipping, movement, sealed battle plans, Traitor calls and spice collection before joining a table.',
steps: [
'Choose Learn to play in the lobby or at the top of this reference. Seven lessons introduce table information, shipping budgets, movement, battle choices, Traitor calls, collection and saved-seat recovery.',
'The five practice positions are separate Basic examples. Compare movement range with city access, storm and stronghold limits; reveal a matching Traitor or decline after both plans appear. Shipping, combat and collection use the same rule calculations as live games.',
'Choose Learn to play in the lobby or at the top of this reference. Eight lessons introduce table information, bidding, shipping budgets, movement, battle choices, Traitor calls, collection and saved-seat recovery.',
'The six practice positions are separate Basic examples. Compare movement range with city access, storm and stronghold limits; reveal a matching Traitor or decline after both plans appear. Bidding, shipping, combat and collection use the same rule calculations as live games.',
'Bidding lets you raise, pass and re-enter while the card remains for sale. Only the winner pays; a purchased card stays private. Try a full hand or opponents who all pass, then restart the example.',
'The opponent uses a fixed teaching plan, revealed only after you seal yours. Try another plan to compare a Shield with a Snooper, or a low dial with the aggressor’s winning tie. This is an exercise, not an AI match.',
'Lesson progress stays in this browser, including a revealed plan. No room, multiplayer action or saved-seat credential is created or read. If browser storage is unavailable, practice still works for the current visit.',
'Bidding, faction lessons, Advanced combat, alliances, broader storm exceptions and expansions remain outside this introduction. The full reference and phase guidance explain more; existing mode gates still apply.',
'Faction lessons, Advanced combat, alliances, broader storm exceptions and expansions remain outside this introduction. The full reference and phase guidance explain more; existing mode gates still apply.',
],
related: ['setup', 'movement', 'battle', 'collection', 'privacy', 'implementation-checklist'],
related: ['setup', 'bidding', 'movement', 'battle', 'collection', 'privacy', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Seven lessons and five interactive Basic examples reuse production shipment, map/occupancy, combat and collection calculations. The complete teaching curriculum remains unfinished.', evidence: ['game/introduction.ts', 'game/introduction-movement.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Lesson navigation, keyboard force sliders and battle wheel, card inspectors, seal/reveal, retry and browser-local continuation are connected.', evidence: ['components/introduction.tsx', 'components/introduction-practice.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'A fixed opponent plan provides a reproducible teaching example. The introduction is not an AI game and does not demonstrate opponent strategy or calibration.' },
{ area: 'Implementation', status: 'Partial', detail: 'Eight lessons and six interactive Basic examples reuse production bidding, shipment, map/occupancy, combat and collection calculations. The complete teaching curriculum remains unfinished.', evidence: ['game/introduction.ts', 'game/introduction-movement.ts', 'game/introduction-bidding.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Bid/pass, full-hand and all-pass choices, lesson navigation, keyboard force sliders and battle wheel, card inspectors, seal/reveal, retry and browser-local continuation are connected.', evidence: ['components/introduction.tsx', 'components/introduction-practice.tsx', 'components/introduction-bidding.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Fixed bidding scripts and an opponent battle plan provide reproducible teaching examples. The introduction is not an AI game and does not demonstrate opponent strategy or calibration.' },
{ area: 'Documentation', status: 'Partial', detail: 'Internal links connect each lesson to existing rules; the setup and saved-seat guidance distinguish practice from a live multiplayer game.', evidence: ['docs/INTERACTIVE_INTRODUCTION.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused checks cover pricing, all offered plans, weapon survival, ties, losses, collection limits, 96 movement comparisons against the engine, single/mutual/declined Traitor calls and saved-choice migration/validation. This does not certify all rules or the complete learning journey.', evidence: ['tests/introduction.test.ts', 'tests/introduction-practice.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused checks cover bidding/re-entry against the live engine, full hands, payment/card custody, pricing, all offered plans, weapon survival, ties, losses, collection limits, 96 movement comparisons against the engine, single/mutual/declined Traitor calls and saved-choice migration/validation. This does not certify all rules or the complete learning journey.', evidence: ['tests/introduction.test.ts', 'tests/introduction-practice.test.ts', 'tests/introduction-bidding.test.ts'] },
],
},
{
+244
View File
@@ -0,0 +1,244 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { introductionBidding } from '../game/introduction-bidding';
import {
INTRODUCTION_STEPS,
newIntroduction,
restoreIntroduction,
} from '../game/introduction';
import { applyAction, createGame, newPlayer } from '../game/engine';
import { baseDeck } from '../game/cards';
void test('bidding practice allows pass/re-entry, pays only the winner and reveals only the buyer card', () => {
const state = newIntroduction();
const opening = introductionBidding(state);
assert.equal(opening.status, 'bidding');
assert.equal(opening.minimum, 1);
assert.equal(opening.received, null);
const reentry = introductionBidding({ ...state, auctionActions: [null] });
assert.equal(reentry.status, 'bidding');
assert.equal(reentry.bid, 3);
assert.equal(reentry.minimum, 4);
assert.equal(reentry.remaining, 6);
assert.equal(reentry.received, null);
const won = introductionBidding({ ...state, auctionActions: [null, 4] });
assert.equal(won.status, 'sold');
assert.equal(won.won, true);
assert.equal(won.remaining, 2);
assert.equal(won.emperorIncome, 4);
assert.equal(won.handCount, 4);
assert.equal(won.received?.kind, 'shield');
const lost = introductionBidding({ ...state, auctionActions: [null, null] });
assert.equal(lost.bidder, 'guild');
assert.equal(lost.remaining, 6);
assert.equal(lost.handCount, 3);
assert.equal(lost.received, null);
});
void test('practice enforces hand capacity, all-pass ending, budget and exact replay boundaries', () => {
const state = newIntroduction();
const full = introductionBidding({ ...state, auctionHandFull: true });
assert.equal(full.status, 'sold');
assert.equal(full.bidder, 'guild');
assert.ok(full.actions.every((action) => action.actor !== 'you'));
assert.equal(full.received, null);
const unbid = introductionBidding({
...state,
auctionScenario: 'all-pass',
auctionActions: [null],
});
assert.equal(unbid.status, 'unbid');
assert.equal(unbid.remaining, 6);
assert.equal(unbid.emperorIncome, 0);
for (const auctionBid of [0, 7])
assert.equal(introductionBidding({ ...state, auctionBid }).canBid, false);
for (const patch of [
{ auctionActions: [7] },
{ auctionActions: [1.5] },
{ auctionActions: [null, 3] },
{ auctionActions: [4, 5] },
{ auctionHandFull: true, auctionActions: [null] },
{ auctionScenario: 'invalid' },
{ auctionActions: [] as unknown, auctionBid: 8 },
])
assert.equal(
restoreIntroduction(JSON.stringify({ ...state, ...patch })),
null,
);
});
void test('every offered opening choice and re-entry agrees with actual engine bids, payment and custody', () => {
let samples = 0;
for (const auctionScenario of ['contest', 'all-pass'] as const)
for (const auctionHandFull of [false, true])
for (const first of [null, 1, 2, 3, 4, 5, 6])
for (const second of [undefined, null, 4, 5, 6]) {
if (auctionHandFull && (first !== null || second !== undefined))
continue;
const state = {
...newIntroduction(),
auctionScenario,
auctionHandFull,
auctionActions: auctionHandFull
? []
: second === undefined
? [first]
: [first, second],
};
let expected;
try {
expected = introductionBidding(state);
} catch {
continue;
}
const game = createGame(
'LEARNBID',
newPlayer('you', 'You', 'fremen'),
);
game.players.push(
newPlayer('emperor', 'Emperor', 'emperor'),
newPlayer('guild', 'Guild', 'guild'),
);
const soldCard = baseDeck().find((card) => card.kind === 'shield')!;
const handPool = baseDeck().filter(
(card) => card.id !== soldCard.id && card.effect !== 'karama',
);
let offset = 0;
for (const player of game.players) {
const count = player.id === 'you' && auctionHandFull ? 4 : 3;
player.hand = handPool.slice(offset, offset + count);
offset += count;
player.spice = player.id === 'you' ? 6 : 4;
}
const held = new Set(
game.players.flatMap((player) =>
player.hand.map((card) => card.id),
),
);
game.deck = baseDeck().filter(
(card) => card.id !== soldCard.id && !held.has(card.id),
);
Object.assign(game, {
status: 'playing',
phase: 3,
turn: 2,
phaseOpening: null,
order: ['you', 'emperor', 'guild'],
active: auctionHandFull ? 'emperor' : 'you',
auction: {
cards: [soldCard],
index: 0,
opener: 0,
active: auctionHandFull ? 'emperor' : 'you',
bid: 0,
bidder: null,
passed: [],
},
});
let result = game;
for (const action of expected.actions) {
const input = result;
const before = structuredClone(result);
result = applyAction(
result,
action.actor,
action.amount === null
? { type: 'passBid' }
: { type: 'bid', amount: action.amount },
);
assert.deepEqual(input, before);
}
const own = result.players[0];
assert.equal(own.spice, expected.remaining);
assert.equal(own.hand.length, expected.handCount);
assert.equal(
own.hand.some((card) => card.id === soldCard.id),
expected.won,
);
assert.equal(
result.players[1].spice,
4 + expected.emperorIncome - expected.bankIncome,
);
if (expected.status === 'bidding') {
assert.equal(result.auction!.active, 'you');
assert.equal(result.auction!.bid, expected.bid);
assert.equal(result.auction!.bidder, expected.bidder);
} else {
assert.equal(result.auction, null);
if (expected.status === 'sold')
assert.ok(
result.players
.find((player) => player.id === expected.bidder)!
.hand.some((card) => card.id === soldCard.id),
);
else assert.equal(result.deck[0].id, soldCard.id);
}
const zones = [
...result.deck,
...result.discard,
...result.players.flatMap((player) => player.hand),
...(result.auction?.cards.slice(result.auction.index) ?? []),
];
assert.equal(
zones.filter((card) => card.id === soldCard.id).length,
1,
);
samples++;
}
assert.ok(samples > 20);
});
void test('all seven v2 lessons keep their identity and prior decisions when bidding is inserted', () => {
const titles = [
'Your place at the table',
'Ship within your budget',
'Move across the board',
'Seal a battle plan',
'Reveal a Traitor',
'Collect the spice',
'Join a table',
];
for (const [step, title] of titles.entries()) {
const old = {
...newIntroduction(),
version: 2,
step,
shipped: true,
revealed: true,
moved: true,
moveCity: true,
traitorStage: 'resolved',
traitorCall: true,
auctionActions: [999],
auctionHandFull: 'ignored',
unrelated: 'removed',
};
const result = restoreIntroduction(JSON.stringify(old))!;
assert.equal(result.version, 3);
assert.equal(INTRODUCTION_STEPS[result.step].title, title);
assert.equal(result.shipped, true);
assert.equal(result.revealed, true);
assert.equal(result.moved, true);
assert.equal(result.traitorCall, true);
assert.deepEqual(result.auctionActions, []);
assert.equal(result.auctionHandFull, false);
assert.equal(Object.hasOwn(result, 'unrelated'), false);
}
assert.equal(
restoreIntroduction(
JSON.stringify({ ...newIntroduction(), version: 2, step: 7 }),
),
null,
);
for (const auctionActions of [[], [null], [null, 4], [null, null]] as Array<
Array<number | null>
>) {
const current = { ...newIntroduction(), step: 1, auctionActions };
const restored = restoreIntroduction(JSON.stringify(current));
assert.deepEqual(restored, current);
assert.deepEqual(
introductionBidding(restored!),
introductionBidding(current),
);
}
});
+2 -2
View File
@@ -69,7 +69,7 @@ void test('all five v1 lessons migrate by identity and retain prior committed ch
for (const [step, title] of titles.entries()) {
const result = restoreIntroduction(JSON.stringify({ ...old, step, token: 'excluded', moved: true, traitorCall: true }))!;
assert.equal(INTRODUCTION_STEPS[result.step].title, title);
assert.equal(result.version, 2); assert.equal(result.shipped, true); assert.equal(result.revealed, true);
assert.equal(result.version, 3); assert.equal(result.shipped, true); assert.equal(result.revealed, true);
assert.equal(result.dial, 3); assert.equal(result.collected, true); assert.equal(result.collectors, 2);
assert.equal(result.moved, false); assert.equal(result.traitorCall, false); assert.equal('token' in result, false);
assert.deepEqual(restoreIntroduction(JSON.stringify(result)), result);
@@ -86,7 +86,7 @@ void test('new practice choices survive all stages and reject impossible saved c
}
for (const patch of [{ moveDestination: 'invented:7' }, { moveForces: 0 }, { moveCity: 'yes' },
{ moved: true, moveStorm: true }, { moved: true, moveDestination: 'imperial_basin:9' },
{ traitorStage: 'later' }, { traitorCall: true }, { opponentTraitor: 1 }, { version: 3 }]) {
{ traitorStage: 'later' }, { traitorCall: true }, { opponentTraitor: 1 }, { version: 4 }]) {
assert.equal(restoreIntroduction(JSON.stringify({ ...newIntroduction(), ...patch })), null);
}
});
+2 -2
View File
@@ -44,8 +44,8 @@ void test('saved practice restores sealed choices, rejects corrupt versions and
const saved = { ...newIntroduction(), step: 3, dial: 3, revealed: true, shipped: true };
assert.deepEqual(restoreIntroduction(JSON.stringify(saved)), saved);
assert.deepEqual(restoreIntroduction(JSON.stringify({ ...saved, token: 'not-a-real-token', hand: ['private'] })), saved);
for (const raw of [null, '', '{', 'null', '[]', JSON.stringify({ ...saved, version: 3 }),
JSON.stringify({ ...saved, step: -1 }), JSON.stringify({ ...saved, step: 7 }),
for (const raw of [null, '', '{', 'null', '[]', JSON.stringify({ ...saved, version: 4 }),
JSON.stringify({ ...saved, step: -1 }), JSON.stringify({ ...saved, step: 8 }),
JSON.stringify({ ...saved, defense: 'lasgun' }), JSON.stringify({ ...saved, dial: 2.5 }),
JSON.stringify({ ...saved, collectors: 99 }), JSON.stringify({ ...saved, revealed: 'true' }),
JSON.stringify({ ...saved, shipment: 6, destination: 'sand', shipped: true })]) {
+134
View File
@@ -0,0 +1,134 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
quoteNormalAuctionBid,
quoteNormalAuctionNext,
} from '../game/normal-auction';
void test('ordinary auction follows table order and skips full-hand seats and the high bidder', () => {
assert.deepEqual(
quoteNormalAuctionNext({
order: ['a', 'full', 'b', 'c'],
eligible: ['a', 'b', 'c'],
active: 'a',
bid: 3,
bidder: 'a',
passed: [],
}),
{ kind: 'bid', player: 'b' },
);
assert.deepEqual(
quoteNormalAuctionNext({
order: ['a', 'full', 'b', 'c'],
eligible: ['a', 'b', 'c'],
active: 'c',
bid: 4,
bidder: 'b',
passed: [],
}),
{ kind: 'bid', player: 'a' },
);
});
void test('a cleared pass list lets a prior passer reenter after a new high bid', () => {
assert.deepEqual(
quoteNormalAuctionNext({
order: ['a', 'b', 'c'],
eligible: ['a', 'b', 'c'],
active: 'b',
bid: 3,
bidder: 'a',
passed: ['b'],
}),
{ kind: 'bid', player: 'c' },
);
assert.deepEqual(
quoteNormalAuctionNext({
order: ['a', 'b', 'c'],
eligible: ['a', 'b', 'c'],
active: 'c',
bid: 5,
bidder: 'c',
passed: [],
}),
{ kind: 'bid', player: 'a' },
);
assert.deepEqual(
quoteNormalAuctionNext({
order: ['a', 'b', 'c'],
eligible: ['a', 'b', 'c'],
active: 'a',
bid: 5,
bidder: 'c',
passed: ['a'],
}),
{ kind: 'bid', player: 'b' },
);
});
void test('all-pass and completed winning rounds terminate without another bid', () => {
assert.deepEqual(
quoteNormalAuctionNext({
order: ['a', 'b', 'c'],
eligible: ['a', 'b', 'c'],
active: 'c',
bid: 0,
bidder: null,
passed: ['a', 'b', 'c'],
}),
{ kind: 'unbid' },
);
assert.deepEqual(
quoteNormalAuctionNext({
order: ['a', 'b', 'c'],
eligible: ['a', 'b', 'c'],
active: 'c',
bid: 4,
bidder: 'a',
passed: ['b', 'c'],
}),
{ kind: 'payment', player: 'a' },
);
});
void test('empty eligibility is unbid and malformed order fails closed', () => {
assert.deepEqual(
quoteNormalAuctionNext({
order: ['a', 'b'],
eligible: [],
active: 'a',
bid: 0,
bidder: null,
passed: [],
}),
{ kind: 'unbid' },
);
assert.throws(
() =>
quoteNormalAuctionNext({
order: ['a', 'b'],
eligible: ['missing'],
active: 'a',
bid: 0,
bidder: null,
passed: [],
}),
/no eligible next bidder/,
);
});
void test('ordinary bid quote accepts only safe integers within the funded range', () => {
assert.deepEqual(
quoteNormalAuctionBid({ currentBid: 3, amount: 4, maximum: 8 }),
{ ok: true, amount: 4 },
);
assert.deepEqual(
quoteNormalAuctionBid({ currentBid: 3, amount: 8, maximum: 8 }),
{ ok: true, amount: 8 },
);
for (const amount of [3, 9, 4.5, Number.NaN, Number.POSITIVE_INFINITY, '4'])
assert.deepEqual(
quoteNormalAuctionBid({ currentBid: 3, amount, maximum: 8 }),
{ ok: false, reason: 'Bid must be an integer from 4 to 8.' },
);
});