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

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