Files
Dune/tests/introduction-bidding.test.ts
T
Quintine 67e429e32f Add Charity and Revival resource practice
Expand the internal introduction to eleven lessons and nine independent Basic exercises. Charity shows the ordinary top-up to two spice and prevents duplicate claims. Revival lets Atreides, Emperor and Fremen return ordinary forces in batches and revive an eligible leader using the same spice balance. Reuse production charity and revival calculations; only narrow the leader-options TypeScript signature, with no live rules or AI strategy changes.

Version 5 practice saves retain all earlier lesson identities and accepted choices. Replay a bounded, validated history to reconstruct spice, Tanks, reserves, leader custody and turn allowances. Cover first-cycle, surviving-leader and repeat-death rosters, cumulative free allowances, paid returns, the three-force cap, separate leader allowance and unaffordable choices. Seven new tests compare real engine actions, immutable inputs, migration and impossible-save rejection.

Independent review found no material issues. Types, lint, 4941 offline tests and production build passed on stable final source. Browser checks confirm previous alliance migration, keyboard Charity, shared revival spending, refreshed force/leader outcomes, roster restrictions, faction free rates, cap rejection and leader inspection. At390px all observed new controls fit, with 44px or taller targets. Screenshot capture remains unavailable; DOM bounds are narrower evidence. The full curriculum, faction/expansion teaching and rules/publication gates remain incomplete.

Reused the running server. A stale development import for the newly-created file cleared through import-graph invalidation and reload without restarting. All 1337 rooms, 2805 seats and existing recovery, entry, handover, delegation and migration rows remain unchanged. SQLite integrity and foreign keys pass. No database migration/reset, automation, usage check or AI calibration. HTTP/auth/persistence source is unchanged; the required offline suite includes recovery checks and no broad HTTP suite was rerun.

Private evidence: 20260921-resource-practice.
Verified source tree: 40e207a5d46f53501a9ec8f77a8b48a5e4f0b3d67278bf670b1e53050770a40b
2026-09-21 08:22:06 +10:00

245 lines
8.5 KiB
TypeScript

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, 5);
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),
);
}
});