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:
1 parent
b9718acda8
commit
b7ec94015a
15 files changed
+882
-70
No files matched your search
@@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 })]) {
|
||||
|
||||
@@ -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.' },
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user