Files
Dune/components/introduction-bidding.tsx
T
Quintine b7ec94015a 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
2026-09-20 17:19:18 +10:00

199 lines
6.9 KiB
TypeScript

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>
</>
);
}