diff --git a/README.md b/README.md index cdda224f..d828d2dc 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ Use **Inspect forces** under any player to enlarge their counters and read publi reserves, Tanks and deployed groups, including Homeworld reserve breakdowns. See [force inspection](docs/FORCE_INSPECTION.md). +Above the board, **Treachery draw pile** and **Spice draw pile** show live card +counts separately from hands, auction cards and discards. See +[draw-pile counts](docs/DRAW_PILES.md). + The table header offers optional **Sound effects**, with mute, volume and a test sample. Short cues mark phase changes and automatic action notices. Sound starts off; preferences stay in your browser. See [table sounds](docs/TABLE_SOUNDS.md) diff --git a/app/globals.css b/app/globals.css index aee0c263..84a8bd36 100644 --- a/app/globals.css +++ b/app/globals.css @@ -970,6 +970,18 @@ footer { margin-top: 28px; border-top: 1px solid var(--border); } +.draw-piles { + margin-bottom: 12px; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: 8px; +} +.draw-piles dl { display: flex; flex-wrap: wrap; gap: 12px 32px; margin: 0; } +.draw-piles dt { font-size: 0.875rem; } +.draw-piles dd { margin: 2px 0 0; font-size: 1rem; } +.draw-piles strong { color: #e0c384; font-size: 1.25rem; font-variant-numeric: tabular-nums; } +.draw-piles p { margin: 8px 0 0; font-size: 1rem; line-height: 1.5; } +.draw-piles a { text-decoration: underline; text-underline-offset: 3px; } .console-tabs { display: flex; gap: 28px; diff --git a/components/draw-piles.tsx b/components/draw-piles.tsx new file mode 100644 index 00000000..c4772941 --- /dev/null +++ b/components/draw-piles.tsx @@ -0,0 +1,26 @@ +import Link from 'next/link'; + +/** Counts only: this component never receives deck contents or private cards. */ +export function DrawPiles({ piles }: { + piles: { treachery: number; spice: number } | null; +}) { + if (!piles) return null; + return ( +
+
+
+
Treachery draw pile
+
{piles.treachery} {piles.treachery === 1 ? 'card' : 'cards'}
+
+
+
Spice draw pile
+
{piles.spice} {piles.spice === 1 ? 'card' : 'cards'}
+
+
+

Draw piles exclude hands, auction cards and discards. + {(piles.treachery === 0 || piles.spice === 0) && ' An empty pile may be replenished from eligible discards.'} + {' '}About these piles +

+
+ ); +} diff --git a/components/game-table.tsx b/components/game-table.tsx index 80a5dc2a..9679a4dd 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -3,6 +3,7 @@ import { EcazSetup } from './ecaz-setup'; import { StormCardInspector, StormCardLogInspector } from './storm-cards'; import { KwisatzInspector } from './kwisatz-inspector'; import { ForceInspector } from './force-inspector'; +import { DrawPiles } from './draw-piles'; import { LobbyBotControls } from './lobby-bot-controls'; import { IxRicheseTechnology } from './ix-richese-technology'; import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options'; @@ -1236,6 +1237,7 @@ export function GameTable({
+
diff --git a/docs/COMPONENT_INVENTORY.md b/docs/COMPONENT_INVENTORY.md index efcd5c19..e0e71db7 100644 --- a/docs/COMPONENT_INVENTORY.md +++ b/docs/COMPONENT_INVENTORY.md @@ -1,5 +1,12 @@ # Component inventory and verification ledger +**Draw-pile counts, 21 September 2026:** the table now displays authoritative +Treachery and Spice draw counts above the board, separate from auctions, hands, +caches and discards. Existing Nexus counts and authorized card inspectors remain +in place. No private card faces or order are exposed. This closes the ordinary +draw-count gap; broader card handling and visual acceptance remain open. See +[scope and evidence](DRAW_PILES.md). + **Force counter inspection, 21 September 2026:** each public player summary now opens enlarged faction diagrams and a physical location inventory. All twelve sets have reference faces; special counters, advisor stances, native Homeworld diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 46fac758..7d266945 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,20 +6,20 @@ gated.** Prototypes do not open completion or publication gates. ## Current checkpoint and work -Latest delivered checkpoint: `2b071a4`, [live Nexus offer withdrawal](NEXUS_OFFERS.md). -Types, lint, **4,973 offline tests**, production build and **49 HTTP tests** passed. -All 1,459 opening rooms, 3,081 seats and auxiliary rows stayed unchanged. Isolated -QA brought the local baseline to 1,499 rooms and 3,171 seats. Browser proposal, -refresh, keyboard withdrawal, preserved incoming offer, acceptance, break and -390px layout checks passed. Full visual acceptance remains open. The healthy -server was reused. Prior checkpoints include `91dcca4` for bribes, `7814f74` for -table discussion and `b2fcef8` for Storm/Spice practice. +Latest delivered checkpoint: `537da4a`, [public force inspection](FORCE_INSPECTION.md). +Types, lint, **4,984 offline tests** and production build passed. All 1,499 rooms, +3,171 seats and auxiliary rows stayed unchanged. Browser checks covered live +counts, twelve faction faces, keyboard inspection, internal guidance, refresh and +390px layout. Screenshot capture remained unavailable; full visual acceptance is +open. The healthy server was reused. Prior checkpoints include `2b071a4` for +Nexus offers, `91dcca4` for bribes and `7814f74` for table discussion. -Current work: [public force inspection](FORCE_INSPECTION.md) connects enlarged -counters and public location inventories for all factions, with special/advisor -subsets, Homeworld reserve breakdowns and concealed No-Field markers. This is -read-only; source-bound checks, independent review, browser and preservation/Git -evidence belong to the private checkpoint. The broader game remains unfinished. +Current work: [Treachery and Spice draw counts](DRAW_PILES.md) connect public +physical pile sizes above the board, separate from auction and private holdings. +Setup, actual draw/refill, skipped-worm return, Thumper exclusion, privacy and +JSON/rendering checks accompany source-bound independent review, browser and +preservation/Git evidence. No game action or AI strategy changes. The broader +game remains unfinished; continue independent card handling and player features. The separate Bureaucrat/Emperor gift question is pending. The `8f7a62c` policy checkpoint removed usage checks/cutoffs and deferred AI refinement until feature completion, followed by three adjacent 75% targets. diff --git a/docs/DRAW_PILES.md b/docs/DRAW_PILES.md new file mode 100644 index 00000000..50dd606e --- /dev/null +++ b/docs/DRAW_PILES.md @@ -0,0 +1,54 @@ +# Live draw-pile counts + +Connected prototype, 21 September 2026. This completes the missing ordinary +draw-count display; full component, rules-mode and visual acceptance remain open. + +## Behavior and boundaries + +`viewGame` now projects `drawPiles: { treachery, spice }` from the lengths of the +authoritative draw arrays, or `null` while the lobby is uninitialized. The table +displays both counts above the board with an internal guide. Counts refresh with +the existing saved-table response and require no decision, command or schema. +Finished games retain their actual counts. + +The numbers exclude hands, auction pools, the Richese cache, discards and other +separate holdings. Auction `remaining` already counts its unsold pool including +the current lot; Nexus already exposes its own deck/discard counts. No inferred +Storm deck is introduced: that runtime selects from six values without a saved +physical draw array. No private identities, order, peeks or discard faces are +added to the response. + +Zero is a real empty draw pile. Existing Treachery and Spice refill logic remains +authoritative; viewing never triggers a shuffle. First-turn worms set aside are +outside the pile until returned, and Thumper annotations are not physical Spice +Cards to recycle. Optional cards contribute only when physically in a draw pile. +The feature does not change any of these rules or the existing card custody. + +The existing [Nullentropy contract](NULLENTROPY_BOX_ENGINE_AUDIT.md) records the +GF9 CHOAM & Richese rulebook's discard privacy restriction and the paid owner's +search entitlement. Counts grant no new discard inspection. Public Spice tops, +owned hands and entitled peeks keep their existing inspectors. Generic discard +browsing and hand sorting/filtering are not supplied by this checkpoint. + +## Verification + +- Genuine three-player Basic setup checks undealt and dealt counts, all viewers, + private-field exclusion, input immutability and JSON restoration. +- Controlled Charity boundaries use real ready/auction draws, including an + exhausted Treachery pile, and reconcile physical auction/deck identities. +- A later Storm/Spice boundary exercises real Spice refill with an excluded + Thumper annotation. A first-turn draw checks a skipped worm leaving and + rejoining the counted pile. +- Full-table rendering connects the display while controls are busy. Empty, + single-card and lobby states have focused rendering checks. +- Existing CHOAM market, Ecaz alliance, Richese Ambassador and Sapho privacy + fixtures now keep public draw counts fixed while varying hidden allocations + or identities. Complete opposing-view equality assertions remain intact, with + physical custody checks. Moving a card out of a public draw pile is not an + otherwise-identical public state. + +Run `npm test -- draw-piles`. Independent review, browser checks, final +type/lint/offline/build/HTTP checks, saved-game preservation and Git delivery +are recorded in the private source-bound checkpoint. Focused fixtures are not +complete-game or expansion-combination certification. AI strategy and release +gates are unchanged. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 983ebb13..5738b222 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,15 @@ # Dune implementation status +## 21 September 2026 — live Treachery and Spice draw counts + +The table now shows both physical draw-pile counts above the board. The public +projection excludes auction pools, hands, caches and discards, while keeping +private card identities and order concealed. [Draw-pile counts](DRAW_PILES.md) +connect existing saved responses, empty/refill guidance and internal inspection +help. Setup, actual draws/refills, skipped worms, Thumper exclusion, privacy and +JSON/rendering checks accompany independent review and private final evidence. +No schema, game action, AI strategy or mode/publication gate changed. + ## 21 September 2026 — public force counter inspection Every player now has **Inspect forces**, with enlarged diagrams, special subsets, diff --git a/game/engine.ts b/game/engine.ts index cd24eee8..ea25c01f 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -24761,6 +24761,8 @@ export function viewGame(state: Game, id: string) { guildTimingLocked: g.guildTimingLocked ?? false, ready: g.ready, spice: g.spice, + // Public physical draw piles only; auction pools and private holdings stay separate. + drawPiles: g.status === 'lobby' ? null : { treachery: g.deck.length, spice: g.spiceDeck.length }, log: g.log, nexus: g.nexus, summonedWorm: g.summonedWorm diff --git a/game/reference.ts b/game/reference.ts index 1d2203b5..a54bddd0 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -204,6 +204,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'setup', 'interactive-introduction', 'storm-cards', + 'draw-piles', 'kwisatz-haderach', 'truthtrance-spice', 'ecaz-ambassadors', @@ -2107,6 +2108,29 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: 'Full-table rendering, JSON continuation, pending-window rejection and SQLite acceptance/withdrawal races are covered. All-configuration and full visual acceptance remain open.', evidence: ['tests/nexus-offer-controls.test.tsx', 'tests/nexus-offer-recovery.test.ts', 'tests/nexus-alliance.test.ts'] }, ], }, + { + id: 'draw-piles', + title: 'Cards and draw piles', + category: 'Getting started', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Read the Treachery and Spice draw counts above the board and distinguish them from cards already elsewhere on the table.', + steps: [ + 'Once setup begins, the two draw counts show cards currently in the Treachery and Spice draw piles. Everyone sees the same counts. The numbers follow table updates and refresh without a separate game action.', + 'Cards in hands, the auction pool, separate faction caches and discard piles are outside these draw counts. The ordinary auction’s remaining count includes its current lot. Nexus Cards retain their separate deck and discard counts in the Nexus panel.', + 'Zero means the current draw pile is empty. The existing draw rules may replenish it from eligible discards; the count is not a total of all cards remaining in the game. First-turn worms temporarily set aside rejoin the Spice draw pile when that sequence finishes.', + 'A pile count does not reveal card identities or their order. Inspect your own hand, the public Spice discard tops and any private cards an effect permits you to see using their existing card inspectors.', + 'Treachery discard contents remain private unless an effect permits inspection. A paid Nullentropy Box search uses its own private controls; the draw counts do not grant additional inspection.', + ], + related: ['bidding', 'spice-blow', 'nullentropy-search', 'nexus-cards', 'privacy'], + checklist: [ + { area: 'Implementation', status: 'Implemented', detail: 'Authoritative Treachery and Spice physical draw counts are projected after setup begins, separately from auction, hands and discards.', evidence: ['game/engine.ts'] }, + { area: 'Player controls', status: 'Implemented', detail: 'A read-only display above the board follows live updates and links to this guide.', evidence: ['components/draw-piles.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Counts add no decision or strategy change. Minimal legal participation remains available; strength tuning waits for feature completion.' }, + { area: 'Documentation', status: 'Implemented', detail: 'Physical pile boundaries, empty/refill behavior and existing inspection permissions are explained.', evidence: ['docs/DRAW_PILES.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Genuine Basic setup, actual auction draws, refill, skipped worms, Thumper exclusion, view privacy, JSON restoration and live component rendering are checked. Complete combined-game and visual acceptance remain open.', evidence: ['tests/draw-piles.test.ts', 'tests/draw-piles-controls.test.tsx'] }, + ], + }, { id: 'force-counters', title: 'Inspect force counters and their locations', diff --git a/tests/choam-market-automatic.test.ts b/tests/choam-market-automatic.test.ts index 77c59d6c..cc637b21 100644 --- a/tests/choam-market-automatic.test.ts +++ b/tests/choam-market-automatic.test.ts @@ -148,8 +148,19 @@ void test('all non-Bidding markets preserve identical opposing views across hidd a.phase = phase; if (phase === 0) a.choamMarket!.resume = 'storm'; if (changed === 'e') hold(a, 'e', 'Shield'); + // Keep public draws fixed and vary only the tested hidden hand. In the + // donor branch CHOAM must stay empty, exposing any automatic-close oracle. + const hidden = a.deck.findIndex(c => c.name === 'Snooper'); + assert.ok(hidden >= 0); + a.discard.push(...a.deck.splice(hidden, 1)); const b = structuredClone(a); - hold(b, changed, 'Snooper'); + b.players.find(p => p.id === changed)!.hand.push(b.discard.pop()!); + if (changed === 'e') { + assert.equal(a.players[0].hand.length, 0); + assert.equal(b.players[0].hand.length, 0); + } + assert.deepEqual(a.deck, b.deck); + assert.deepEqual(inventory(a), inventory(b)); assert.deepEqual(viewGame(a, 'b'), viewGame(b, 'b')); const first = normalizeAutomaticGame(reload(a)), second = normalizeAutomaticGame(reload(b)); diff --git a/tests/draw-piles-controls.test.tsx b/tests/draw-piles-controls.test.tsx new file mode 100644 index 00000000..834dcacb --- /dev/null +++ b/tests/draw-piles-controls.test.tsx @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { registerHooks } from 'node:module'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { viewGame } from '../game/engine'; +import { nexusOfferGame } from './fixture-nexus-offer'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(m => [m[1], m[1]])); + return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true }; + }, +}); +const { DrawPiles } = await import('../components/draw-piles'); +const { GameTable } = await import('../components/game-table'); +aliases.deregister(); + +void test('live table connects named draw counts without an action while controls are busy', () => { + const g = nexusOfferGame(); + const before = JSON.stringify(g); + const html = renderToStaticMarkup(createElement(GameTable, { + game: viewGame(g, 'e'), busy: true, send: async () => { assert.fail('Read-only counts must not act.'); }, onExit() {}, + })); + assert.match(html, /aria-label="Draw piles"/); + assert.match(html, /Treachery draw pile<\/dt>
29<\/strong> cards/); + assert.match(html, /Spice draw pile<\/dt>
19<\/strong> cards/); + assert.match(html, /href="\/rules\?topic=draw-piles#draw-piles"/); + assert.equal(JSON.stringify(g), before); +}); + +void test('empty and single-card piles stay explicit; an uninitialized lobby has no invented counts', () => { + assert.equal(renderToStaticMarkup(createElement(DrawPiles, { piles: null })), ''); + const html = renderToStaticMarkup(createElement(DrawPiles, { piles: { treachery: 0, spice: 1 } })); + assert.match(html, /0<\/strong> cards/); + assert.match(html, /1<\/strong> card<\/dd>/); + assert.match(html, /empty pile may be replenished/); + assert.doesNotMatch(html, /button|No cards left|out of cards/); +}); diff --git a/tests/draw-piles.test.ts b/tests/draw-piles.test.ts new file mode 100644 index 00000000..948405c6 --- /dev/null +++ b/tests/draw-piles.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { nexusReady, orderNexusSpice } from './fixture-nexus-cards'; + +function lobby() { + const g = createGame('DRAWPILE', newPlayer('e', 'Emperor', 'emperor')); + joinGame(g, newPlayer('a', 'Atreides', 'atreides')); + joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen')); + return g; +} +function setup() { + let g = lobby(); + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + return applyAction(g, 'e', { type: 'start' }); +} +function started() { + let g = setup(); + for (const p of g.players) + if (p.traitorChoices.length) g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] }); + assert.equal(g.status, 'playing'); + return g; +} +function assertPublic(g: Game, expected: { treachery: number; spice: number } | null) { + const before = JSON.stringify(g); + for (const p of g.players) { + const view = viewGame(g, p.id); + assert.deepEqual(view.drawPiles, expected); + assert.equal('deck' in view, false); + assert.equal('discard' in view, false); + assert.equal('spiceDeck' in view, false); + for (const rival of view.players.filter(other => other.id !== p.id)) + assert.equal(rival.hand, undefined); + assert.deepEqual(viewGame(JSON.parse(before), p.id).drawPiles, expected); + } + assert.equal(JSON.stringify(g), before); +} + +void test('draw counts appear after genuine setup and exclude dealt hands for every seat', () => { + assertPublic(lobby(), null); + const prepared = setup(); + assert.equal(prepared.status, 'setup'); + assertPublic(prepared, { treachery: baseDeck().length, spice: 21 }); + const g = started(); + assert.equal(g.players.reduce((n, p) => n + p.hand.length, 0), 4); + assertPublic(g, { treachery: baseDeck().length - 4, spice: 21 }); + const oldView = viewGame(g, 'e'); + g.deck.reverse(); g.spiceDeck.reverse(); + assert.deepEqual(viewGame(g, 'e').drawPiles, oldView.drawPiles); + oldView.drawPiles!.treachery = 999; + assert.notEqual(viewGame(g, 'e').drawPiles!.treachery, 999); +}); + +void test('real auction draws and exhausted-pile recycling keep unsold lots separate', () => { + for (const refill of [false, true]) { + let g = started(); + // Controlled Charity boundary; retain all physical cards and use real ready/draw actions. + for (const p of g.players) g.deck.push(...p.hand.splice(0)); + Object.assign(g, { phase: 2, ready: [], phaseOpening: null }); + if (refill) g.discard.push(...g.deck.splice(0)); + assertPublic(g, { treachery: refill ? 0 : baseDeck().length, spice: 21 }); + g = nexusReady(g); + assert.equal(g.phase, 3); + assert.ok(g.auction); + assert.equal(g.auction.cards.length, 3); + assertPublic(g, { treachery: baseDeck().length - 3, spice: 21 }); + assert.equal(viewGame(g, 'e').auction!.remaining, 3); + assert.equal(viewGame(g, 'e').auction!.card, null); + assert.deepEqual([...g.deck, ...g.auction.cards].map(c => c.id).sort(), baseDeck().map(c => c.id).sort()); + assert.equal(g.discard.length, 0); + } +}); + +void test('empty Spice pile survives viewing and refills through a real later-turn Spice draw', () => { + let g = started(); + for (const p of g.players) g.deck.push(...p.hand.splice(0)); + Object.assign(g, { turn: 2, storm: 18, phase: 0, ready: [], stormDials: {}, stormPending: null }); + g.spiceDiscard[0].push(...g.spiceDeck.splice(0)); + // A Thumper annotation is not a physical Spice Card and must not refill the pile. + g.spiceDiscard[0].push({ worm: true, thumper: true }); + assertPublic(g, { treachery: baseDeck().length, spice: 0 }); + for (const id of g.stormDialers) g = applyAction(g, id, { type: 'stormDial', amount: 1 }); + g = nexusReady(g); + assert.equal(g.phase, 1); + g = nexusReady(g); + assert.ok(g.spiceWindow); + const drawn = g.spiceDiscard.flat(); + assert.ok(drawn.length >= 1 && drawn.length <= 7); // Worms may precede the territory. + assert.equal(drawn.filter(c => 'territory' in c).length, 1); + assert.ok([...drawn, ...g.spiceDeck].every(c => !('worm' in c && c.thumper))); + assertPublic(g, { treachery: baseDeck().length, spice: 21 - drawn.length }); + assert.equal(g.spiceDeck.length + drawn.length, 21); +}); + +void test('first-turn worms set aside leave the draw count and return after the Spice window', () => { + let g = started(); + for (const p of g.players) g.deck.push(...p.hand.splice(0)); + orderNexusSpice(g, ['worm', 'land']); + for (const id of g.stormDialers) g = applyAction(g, id, { type: 'stormDial', amount: 0 }); + g = nexusReady(g); + assert.equal(g.phase, 1); + g = nexusReady(g); + assert.ok(g.spiceWindow); + assert.equal(g.spiceSequence?.skipped.length, 1); + assertPublic(g, { treachery: baseDeck().length, spice: 19 }); + g = nexusReady(g); + assert.equal(g.phase, 2); + assert.equal(g.spiceSequence, null); + assertPublic(g, { treachery: baseDeck().length, spice: 20 }); +}); diff --git a/tests/ecaz-alliance-engine.test.ts b/tests/ecaz-alliance-engine.test.ts index ce3821aa..5ee4aec1 100644 --- a/tests/ecaz-alliance-engine.test.ts +++ b/tests/ecaz-alliance-engine.test.ts @@ -280,10 +280,13 @@ void test('a dead or previously controlled Duke cannot gate the no-loan alliance void test('changing BG private prediction and unrelated private hands does not change the public alliance offer', () => { const g = enter(); + // Exchange custody between hidden hands without changing the public draw count. + seat(g, 'bg').hand.push(g.deck.shift()!); const changed = json(g); seat(changed, 'bg').prediction = { faction: 'atreides', turn: 9 }; - const card = changed.deck.splice(0, 1)[0]; - seat(changed, 'a').hand.push(card); + seat(changed, 'a').hand.push(seat(changed, 'bg').hand.pop()!); + assert.deepEqual(g.deck, changed.deck); + assert.deepEqual(g.players.flatMap(p => p.hand).map(c => c.id).sort(), changed.players.flatMap(p => p.hand).map(c => c.id).sort()); assert.deepEqual(viewGame(g, 'ec'), viewGame(changed, 'ec')); const offered = propose(g); assert.equal(viewGame(offered, 'a').ambassadorEntry!.allianceOffer, null); diff --git a/tests/juice-of-sapho-engine.test.ts b/tests/juice-of-sapho-engine.test.ts index 24b3c675..e1911af0 100644 --- a/tests/juice-of-sapho-engine.test.ts +++ b/tests/juice-of-sapho-engine.test.ts @@ -606,7 +606,12 @@ void test('Sapho options are owner-only and do not reveal hidden cards through a assert.ok(viewGame(withCard, 'e').saphoOptions.length); assert.deepEqual(viewGame(withCard, 'a').saphoOptions, []); const without = reload(withCard); - without.players[1].hand = [without.deck.pop()!]; + // Swap identities at fixed public draw/hand sizes and preserve the physical Sapho. + const replacement = without.deck.pop()!; + without.deck.push(...without.players[1].hand.splice(0)); + without.players[1].hand.push(replacement); + assert.equal(without.deck.length, withCard.deck.length); + assert.deepEqual([...without.deck, ...without.players[1].hand].map(c => c.id).sort(), [...withCard.deck, ...withCard.players[1].hand].map(c => c.id).sort()); assert.deepEqual(viewGame(without, 'a'), viewGame(withCard, 'a')); assert.deepEqual(viewGame(without, 'e').saphoOptions, []); reject(withCard, 'a', action(withCard, 'last')); diff --git a/tests/richese-ambassador-bots.test.ts b/tests/richese-ambassador-bots.test.ts index 65ed8123..3cde0831 100644 --- a/tests/richese-ambassador-bots.test.ts +++ b/tests/richese-ambassador-bots.test.ts @@ -146,11 +146,16 @@ for (const level of DIFFICULTIES) { } const source = fixture('richese', true); source.players[0].spice = 0; + // Both opponents' hands are hidden: vary their allocation at a fixed draw count. + filled(source, 'in'); const g = enter(source); const poor = structuredClone(g); poor.players[2].spice = 0; const full = structuredClone(g); - filled(full, 'al'); + full.players[2].hand.push(...full.players[1].hand.splice(0)); + assert.equal(full.players[2].hand.length, 4); + assert.deepEqual(full.deck, g.deck); + assert.deepEqual(full.players.flatMap(p => p.hand).map(c => c.id).sort(), g.players.flatMap(p => p.hand).map(c => c.id).sort()); for (const changed of [poor, full]) { assert.deepEqual( view(g, level),