diff --git a/README.md b/README.md index ce8ea883..5b1c340a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/components/introduction-bidding.tsx b/components/introduction-bidding.tsx new file mode 100644 index 00000000..ddff4a4d --- /dev/null +++ b/components/introduction-bidding.tsx @@ -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) => void; +}) { + const auction = introductionBidding(state); + const reset = (patch: Partial = {}) => + 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 ( + <> +

+ In this separate example you are Fremen with 6 spice. + 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. +

+
+
+ + + +

+ 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. +

+ + + update({ auctionBid: Number(event.target.value) }) + } + /> + + + +
+
+

+ {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'} +

+ {state.auctionHandFull && ( +

+ Your four-card hand is full, so you cannot bid. The auction + continues with the eligible players. +

+ )} + {auction.status === 'bidding' ? ( + <> +

+ {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. +

+

+ You still hold all 6 spice. The winning bid is paid when the + auction closes. +

+ {auction.bidError &&

{auction.bidError}

} + {state.auctionActions.includes(null) && ( +

+ You passed earlier, but the card is still up for sale. You may + re-enter on this turn. +

+ )} + + ) : auction.status === 'unbid' ? ( + <> +

+ 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. +

+ + ) : ( + <> +

+ {INTRODUCTION_BIDDERS[auction.bidder!]} pays {auction.bid} spice{' '} + {auction.bankIncome ? 'to the Spice Bank' : 'to the Emperor'}. + Losing bidders pay nothing. +

+

+ You have {auction.remaining} spice and{' '} + {auction.handCount} cards in your hand. +

+ {auction.received ? ( + <> +

+ Your new card is private. Buying it did not reveal its face + to the other players. +

+ + + + ) : ( +

The purchased card remains concealed from you.

+ )} + + )} +

Auction history

+ {auction.transcript.length ? ( +
    + {auction.transcript.map((entry, index) => ( +
  1. {entry}
  2. + ))} +
+ ) : ( +

You open the bidding. Bid at least 1 spice or pass.

+ )} +
+
+

+ 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. +

+

+ 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. +

+ + ); +} diff --git a/components/introduction.tsx b/components/introduction.tsx index d2bef812..c1d6cfdc 100644 --- a/components/introduction.tsx +++ b/components/introduction.tsx @@ -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() { ← Return to Arrakis

LEARN TO PLAY · BASIC GAME

A first turn on Arrakis

-

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.

+

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.

{/* Remount on schema changes so a development hot update also migrates old state. */} - {hydrated ? : Restoring your lesson…} + {hydrated ? : Restoring your lesson…} ; } @@ -66,7 +67,7 @@ function IntroductionLessons() {

{state.step + 1} / {INTRODUCTION_STEPS.length}

{lesson.title}

{state.step === 0 && <> -

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.

+

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.

On the table

Everyone can see the board, forces, storm and revealed cards. Your spice, Treachery hand and Traitors stay private unless a rule reveals them.

Your objective

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.

@@ -75,7 +76,8 @@ function IntroductionLessons() {
    {PHASES.map(phase =>
  1. {phase}
  2. )}

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.

} - {state.step === 1 && <> + {state.step === 1 && } + {state.step === 2 && <>

You have 8 spice 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.

@@ -99,8 +101,8 @@ function IntroductionLessons() {

Ordinary movement reaches an adjacent territory. Ornithopters and faction powers can extend its range. Inspect the live destination guide before committing.

} - {state.step === 2 && } - {state.step === 3 && <> + {state.step === 3 && } + {state.step === 4 && <>

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.

@@ -128,8 +130,8 @@ function IntroductionLessons() {
} - {state.step === 4 && } - {state.step === 5 && <> + {state.step === 5 && } + {state.step === 6 && <>

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.

@@ -145,8 +147,8 @@ function IntroductionLessons() {
} - {state.step === 6 && <> -

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.

+ {state.step === 7 && <> +

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.

  • Create a private room, choose a faction, and add friends or AI players. Share the room invitation.
  • Mark yourself ready after reviewing the selected rules. The host begins once all seats are ready.
  • diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 0027057c..61542d9a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 852309e8..c63b8bb7 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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, diff --git a/docs/INTERACTIVE_INTRODUCTION.md b/docs/INTERACTIVE_INTRODUCTION.md index c6500149..b446f3a3 100644 --- a/docs/INTERACTIVE_INTRODUCTION.md +++ b/docs/INTERACTIVE_INTRODUCTION.md @@ -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. diff --git a/game/engine.ts b/game/engine.ts index de6adc25..1f916192 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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) diff --git a/game/introduction-bidding.ts b/game/introduction-bidding.ts new file mode 100644 index 00000000..187f66ac --- /dev/null +++ b/game/introduction-bidding.ts @@ -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; +}; + +const order = ['you', 'emperor', 'guild']; +export const INTRODUCTION_BIDDERS: Readonly> = { + 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, + }; +} diff --git a/game/introduction.ts b/game/introduction.ts index 5b367571..1e404b78 100644 --- a/game/introduction.ts +++ b/game/introduction.ts @@ -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) { diff --git a/game/normal-auction.ts b/game/normal-auction.ts new file mode 100644 index 00000000..4e3a8b04 --- /dev/null +++ b/game/normal-auction.ts @@ -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 }; +} diff --git a/game/reference.ts b/game/reference.ts index 8f6c2b1e..adbec871 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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'] }, ], }, { diff --git a/tests/introduction-bidding.test.ts b/tests/introduction-bidding.test.ts new file mode 100644 index 00000000..1c3259ec --- /dev/null +++ b/tests/introduction-bidding.test.ts @@ -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 + >) { + const current = { ...newIntroduction(), step: 1, auctionActions }; + const restored = restoreIntroduction(JSON.stringify(current)); + assert.deepEqual(restored, current); + assert.deepEqual( + introductionBidding(restored!), + introductionBidding(current), + ); + } +}); diff --git a/tests/introduction-practice.test.ts b/tests/introduction-practice.test.ts index 6e530aaa..3e3a11d2 100644 --- a/tests/introduction-practice.test.ts +++ b/tests/introduction-practice.test.ts @@ -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); } }); diff --git a/tests/introduction.test.ts b/tests/introduction.test.ts index 0b2cd054..489aea78 100644 --- a/tests/introduction.test.ts +++ b/tests/introduction.test.ts @@ -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 })]) { diff --git a/tests/normal-auction.test.ts b/tests/normal-auction.test.ts new file mode 100644 index 00000000..04d9a591 --- /dev/null +++ b/tests/normal-auction.test.ts @@ -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.' }, + ); +});