import assert from 'node:assert/strict'; import { applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player, } from '../game/engine'; import { TERRITORIES } from '../game/board'; import { botEntryAllowed, botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility'; import { reserveShipmentCost } from '../game/shipment-price'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import type { Card } from '../game/cards'; import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech'; export type BankerOverlayKind = 'bank' | 'below' | 'player'; export type BankerOverlaySemuta = 'claim' | 'pass'; export type BankerOverlayOptions = { /** Basic by default; Advanced adds the Guild's own native shipment stop. */ advanced?: boolean; /** Optional original Tech Tokens in the same ordinary Skills setup. */ tech?: boolean; /** Optional original Advanced Stronghold Cards. */ strongholds?: boolean; /** Which real native shipment actually funds the Bank. */ kind?: BankerOverlayKind; /** Conserved normal reserve forces; the printed desert price follows the payer's rate. */ amount?: number; /** Conserved printed Semuta reaction at the real Hajr discard. */ semuta?: BankerOverlaySemuta; }; export type BankerOverlayPlan = { /** Basic plays at own movement; Advanced first completes an actual movement. */ hajr?: boolean; /** Seat committing its own printed Semuta Drug at that fresh discard; null passes. */ claim?: string | null; }; export type BankerOverlayFixture = { /** Real phase 5 position: the payer is active and has not shipped yet. */ game: Game; /** Fresh ready lobby before the ordinary Semuta Skills initializer. */ initial: Game; /** Immediately after that initializer: ledger stamped, overlays undealt. */ setup: Game; /** Same position as `game`, before any conserved printed-card staging. */ beforePayment: Game; actions: RicheseSkillsTechStep[]; staging: string[]; owner: string; trainer: string; payer: string; payerFaction: Player['faction']; holder: string; semuta: string; hajr: string; territory: string; sector: number; key: string; amount: number; cost: number; /** Actual printed recipient of the real shipment price. */ payee: 'bank' | 'guild'; /** Printed entitlement of this exact payment. */ grant: boolean; kind: BankerOverlayKind; advanced: boolean; tech: boolean; strongholds: boolean; shipment: Action; /** Original Advanced Guild stop, acknowledged before any payment. */ allow: RicheseSkillsTechStep | null; plan: BankerOverlayPlan; }; export function bankerOverlayPlayer(game: Game, id: string): Player { const player = game.players.find(p => p.id === id); assert.ok(player, `Missing original ${id} seat`); return player; } /** No pending opening, response, decision or committed fresh discard. */ export const bankerOverlayClean = (game: Game): boolean => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; /** One physical slot per original Treachery identity, including the Richese cache. */ export function bankerOverlayInventory(game: Game): string[] { const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); assert.equal(new Set(ids).size, ids.length, 'One physical slot per original Treachery identity'); return ids; } /** Wallets, counters, clocks, flags and skill custody; original hands excluded. */ export function bankerOverlayPosition(game: Game) { return structuredClone({ status: game.status, turn: game.turn, phase: game.phase, active: game.active, order: game.order, ready: game.ready, movementRemaining: game.movementRemaining, storm: game.storm, hajr: game.hajr, pendingShipment: game.pendingShipment, karamaShipping: game.karamaShipping, guildRateBlocked: game.guildRateBlocked, response: game.response, decision: game.decision, leaderSkills: game.leaderSkills, players: game.players.map(player => ({ id: player.id, faction: player.faction, ally: player.ally, spice: player.spice, bribes: player.bribes, forces: player.forces, reserves: player.reserves, tanks: player.tanks, elites: player.elites, leaders: player.leaders, shipped: player.shipped, moved: player.moved, revived: player.revived, traitors: player.traitors, })), }); } export function bankerOverlayWallets(game: Game) { return game.players.map(player => ({ id: player.id, faction: player.faction, spice: player.spice, bribes: player.bribes, reserves: player.reserves, tanks: player.tanks, shipped: player.shipped, moved: player.moved, hand: player.hand.map(card => card.id), })); } /** Own-view ledger projection plus the committed history, for exact comparisons. */ export function bankerOverlayLedger(game: Game, owner: string) { const projection = viewGame(game, owner).spiceBankerIncome; assert.ok(projection, `The ordinary Skills overlay must keep ${owner}'s Spice Banker income projection.`); return { preview: game.spiceBankerIncomePreview === true, state: structuredClone(game.spiceBankerIncome ?? null), deferred: projection.deferred, usedThisPhase: projection.usedThisPhase, }; } export function bankerOverlayDeferred(game: Game, owner: string): number { return bankerOverlayLedger(game, owner).deferred.find(row => row.owner === owner)?.amount ?? 0; } /** Existing ordinary native policy plus the two genuine overlay continuations: * the original Advanced Guild timing choice and the printed Semuta reaction. */ export function nextBankerOverlayNativeStep( game: Game, plan: BankerOverlayPlan = {}, ): RicheseSkillsTechStep { const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction; if (pending && reaction?.stage === 'offer') { const event = pending.batch.event; const claim = plan.claim ?? null; if (claim && !reaction.passed.includes(claim)) { const own = viewGame(game, claim).semutaReaction; if (own?.stage === 'offer' && own.canCommit) return { actor: claim, action: { type: 'semutaCommit', event } }; } const actor = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor, 'The neutral Semuta offer must keep one original waiting seat.'); return { actor: actor.id, action: { type: 'semutaPass', event } }; } if (pending && reaction?.stage === 'select') { const card = reaction.candidates[0]; assert.ok(card, 'A committed Semuta claim must keep one private fresh candidate.'); return { actor: reaction.player, action: { type: 'semutaSelect', event: pending.batch.event, card } }; } // Conserved own movement: the Guild may take the current combined turn; the // ordinary order then resumes with the same physical queue. if (!game.response && !game.phaseOpening && game.decision?.kind === 'guildTiming') return { actor: game.decision.player, action: { type: 'decision', take: true } }; if (plan.hajr && game.status === 'playing' && game.phase === 5 && bankerOverlayClean(game)) { const holder = game.players.find(player => player.id === 'a' && player.id === game.active && !game.hajr.includes(player.id) && player.hand.some(card => card.effect === 'hajr')); if (holder) { if (game.advanced && (holder.hajrWindow?.turn !== game.turn || holder.hajrWindow.move !== holder.moved)) { assert.equal(holder.moved, 0, 'Earn the original immediate Hajr opportunity with the still-unused first Force Movement.'); const own = viewGame(game, holder.id), seat = own.players.find(player => player.id === holder.id)!; const movement = Object.entries(holder.forces).filter(([key, count]) => count > (holder.elites?.forces[key] ?? 0)) .flatMap(([from]) => TERRITORIES.filter(destination => destination.type === 'sand' && !game.players.some(player => Object.keys(player.forces).some(key => key.startsWith(`${destination.id}:`))) && !Object.keys(game.spice).some(key => key.startsWith(`${destination.id}:`) && game.spice[key] > 0)) .flatMap(destination => destination.sectors.map(sector => ({ from, territory: destination.id, sector })))) .find(move => groundMoveRouteDistance(own, seat, move.from, `${move.territory}:${move.sector}`) === 1 && botGroundMoveAllowed(own, seat, move.from, `${move.territory}:${move.sector}`, 0)); assert.ok(movement, 'The original Atreides army must have a clear adjacent empty sand destination.'); return { actor: holder.id, action: { type: 'move', ...movement, amount: 1, elite: 0 } }; } const card = holder.hand.find(candidate => candidate.effect === 'hajr')!; return { actor: holder.id, action: { type: 'card', card: card.id } }; } } return nextRicheseSkillsTechNativeStep(game); } function step(game: Game, plan: BankerOverlayPlan, actions?: RicheseSkillsTechStep[]): Game { const next = nextBankerOverlayNativeStep(game, plan); actions?.push(next); return applyAction(game, next.actor, next.action); } /** Apply only original native actions until the requested boundary holds. */ export function advanceBankerOverlay( state: Game, until: (game: Game) => boolean, plan: BankerOverlayPlan = {}, actions?: RicheseSkillsTechStep[], ): Game { let game = state; for (let n = 0; !until(game) && n < 1500; n++) { assert.notEqual(game.status, 'finished', 'The genuine native path must reach its requested boundary'); game = step(game, plan, actions); } assert.ok(until(game), 'The genuine native path must reach its requested boundary'); return game; } export function advanceBankerOverlayToPhase( state: Game, phase: number, plan: BankerOverlayPlan = {}, actions?: RicheseSkillsTechStep[], ): Game { let game = state; const turn = state.turn; for (let n = 0; (game.phase !== phase || !bankerOverlayClean(game)) && game.turn === turn && game.status === 'playing' && n < 1500; n++) game = step(game, plan, actions); assert.equal(game.turn, turn); assert.equal(game.phase, phase); assert.ok(bankerOverlayClean(game)); return game; } /** Stop at the real native Mentat Pause entry; its own opening has already collected. */ export function advanceBankerOverlayToMentat( state: Game, plan: BankerOverlayPlan = {}, actions?: RicheseSkillsTechStep[], ): Game { let game = state; const turn = state.turn; for (let n = 0; (game.phase !== 8 || game.phaseOpening) && game.turn === turn && game.status === 'playing' && n < 1500; n++) game = step(game, plan, actions); assert.equal(game.turn, turn); assert.equal(game.phase, 8); return game; } /** Fix the initial all14 offer and native setup scalar entropy; leave UUIDs native. * The printed Spice Banker lands in the first seat's own two-card offer. */ export function withBankerOverlaySkillOffer(initialize: () => T): T { const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); const original = crypto.getRandomValues.bind(crypto); const target = LEADER_SKILL_CARDS.findIndex(card => card.id === 'spice-banker'); assert.ok(target >= 0, 'The original all14 deck must keep the Spice Banker card.'); let index = LEADER_SKILL_CARDS.length - 1; crypto.getRandomValues = (array: V): V => { if (!(array instanceof Uint32Array)) return Reflect.apply(original, crypto, [array]) as V; array.fill(0xffffffff); if (index > 0) { assert.equal(array.length, 1, 'The initial all14 shuffle uses single scalar draws.'); array[0] = index === target ? 0 : 0xffffffff; index--; } return array; }; try { return initialize(); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } /** Conserve one canonical printed card into its owner's hand from the first * original slot already holding it; never mint a second identity. */ function takePrintedCard( game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], ): string { const holder = bankerOverlayPlayer(game, actor); const held = holder.hand.find(matches); if (held) return held.id; assert.ok(holder.hand.length < handLimit(holder), 'The conserved hand must keep room for its own printed overlay card.'); const zones: [Card[], string][] = [ [game.deck, 'undealt Treachery deck'], [game.discard, 'discard pile'], [game.richeseCache ?? [], 'Richese cache'], ]; for (const [zone, name] of zones) { const index = zone.findIndex(matches); if (index < 0) continue; const card = zone.splice(index, 1)[0]; holder.hand.push(card); staging.push(`Conserved canonical ${card.id} from the ${name} into ${actor}'s hand.`); return card.id; } const donor = game.players.find(player => player.id !== actor && player.hand.some(matches)); assert.ok(donor, 'The requested printed overlay card must keep one original custody slot.'); const index = donor.hand.findIndex(matches); const card = donor.hand.splice(index, 1)[0]; holder.hand.push(card); staging.push(`Conserved canonical ${card.id} from ${donor.id}'s original dealt hand into ${actor}'s hand.`); return card.id; } /** Genuine Basic/Advanced Semuta + Skills setup, first turn and the payer's own * Movement turn. Initial setup scalar entropy, the first two unplayed Spice Cards * and two conserved printed overlay cards are controlled and explicitly disclosed. * Wallets, counters, clocks, turns, phases, skills, receipts and assignments stay native. */ export function createBankerOverlayFixture(options: BankerOverlayOptions = {}): BankerOverlayFixture { const kind = options.kind ?? 'bank'; const advanced = options.advanced ?? false; const tech = options.tech ?? false; const strongholds = options.strongholds ?? false; const semuta = options.semuta ?? 'claim'; const payee = kind === 'player' ? 'guild' as const : 'bank' as const; assert.ok(advanced || !strongholds, 'Original Stronghold Cards require Advanced rules.'); let game = createGame('BANKEROVERLAY', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']); joinGame(game, newPlayer('g', 'Guild', 'guild')); joinGame(game, newPlayer('a', 'Atreides', 'atreides')); if (tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); if (strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); const initial = structuredClone(game); // The ordinary Semuta Skills delegate stamps the mandatory income ledger first, // then sets its own original overlay flag. game = withBankerOverlaySkillOffer(() => initializeSemutaGameForAudit(game, true)); assert.equal(game.semutaPreview, true, 'The original Semuta overlay flag must remain set.'); assert.equal(game.spiceBankerIncomePreview, true, 'Ordinary Leader Skills setup must stamp the income ledger before the overlay flag.'); assert.ok(game.spiceBankerIncome, 'The fresh ordinary setup must commit one income ledger.'); assert.ok(viewGame(game, 'r').spiceBankerIncome, 'Each own seat must project that ledger.'); assert.ok(game.leaderSkills?.offers['r']?.cards.includes('spice-banker'), 'The controlled original all14 shuffle must offer the Spice Banker to the first seat.'); const setup = structuredClone(game); const actions: RicheseSkillsTechStep[] = [], staging: string[] = []; staging.push('Initial all14 offers and native setup scalar entropy are controlled; UUIDs remain native. Components are not replaced.'); const plan: BankerOverlayPlan = { hajr: false, claim: null }; for (let n = 0; game.status === 'setup' && n < 250; n++) { if (game.setupStage === 'leaderSkills') { const actor = Object.keys(game.leaderSkills!.offers)[0]; const offer = game.leaderSkills!.offers[actor]; const own = viewGame(game, actor).leaderSkills!; const skill = offer.cards.includes('spice-banker') ? 'spice-banker' : offer.cards.find(card => !own.unavailableSkills?.[card]); assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill], 'The actual original offer must supply one legal skill.'); const eligible = new Set(own.eligibleLeaders.map(leader => leader.id)); const leader = bankerOverlayPlayer(game, actor).leaders.filter(disc => eligible.has(disc.id)) .sort((a, b) => b.strength - a.strength)[0]; assert.ok(leader, 'The actual original living eligible disc must supply training.'); const action: Action = { type: 'leaderSkill', event: offer.event, skill, leader: leader.id }; actions.push({ actor, action }); game = applyAction(game, actor, action); } else game = step(game, plan, actions); } assert.equal(game.status, 'playing'); const assignment = game.leaderSkills!.assignments.find(entry => entry.skill === 'spice-banker'); assert.ok(assignment, 'The ordinary native setup must assign the real Spice Banker skill.'); const owner = assignment.owner; assert.equal(owner, 'r', 'The real Spice Banker must be trained by the first offered Richese seat.'); assert.equal(bankerOverlayPlayer(game, owner).leaders.find(leader => leader.id === assignment.leader)!.dead, false, 'The actual Banker trainer must still be alive.'); // Prevent an incidental first-turn worm by conserving the first two unplayed blows. for (const position of [0, 1]) { const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card); assert.ok(index >= position, 'One original unplayed Spice Card must remain'); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); } staging.push('Conserved only the first two original unplayed Spice Cards as ordinary land blows.'); const payer = kind === 'player' ? 'a' : 'g'; const amount = options.amount ?? (kind === 'below' ? 3 : kind === 'player' ? 2 : 4); game = advanceBankerOverlay(game, candidate => candidate.phase === 5 && candidate.turn === 1 && bankerOverlayClean(candidate) && candidate.active === payer && !bankerOverlayPlayer(candidate, payer).shipped, plan, actions); const seat = bankerOverlayPlayer(game, payer); const halfRate = seat.faction === 'guild'; const own = viewGame(game, payer); // Empty ordinary sand isolates this paid shipment from battle and arrival parents; // the real consumer entry helpers still choose the exact legal action. const destination = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm) && !game.players.some(player => Object.keys(player.forces).some(key => key.startsWith(`${t.id}:`))) && botEntryAllowed(own, own.players.find(player => player.id === payer)!, t.id, t.sectors[0], 'ship')); assert.ok(destination, 'One original legal empty sand destination must remain'); const sector = destination.sectors[0]; const cost = reserveShipmentCost({ faction: seat.faction, halfRate }, destination.type, amount); assert.equal(cost, halfRate ? amount : amount * 2, 'The printed shipment price must follow the payer’s own ordinary rate.'); assert.ok(seat.reserves >= amount, 'The payer must hold that many ordinary reserves.'); assert.ok((seat.spice ?? 0) >= cost, 'The payer must hold the printed shipment price.'); const shipment: Action = { type: 'ship', territory: destination.id, sector, amount, elite: 0, allyPayment: 0 }; const beforePayment = structuredClone(game); // The two conserved printed overlay cards: Richese's own Semuta and the printed Hajr. const semutaId = takePrintedCard(game, owner, card => card.id === SEMUTA_DRUG_ID, staging); const hajrId = takePrintedCard(game, 'a', card => card.effect === 'hajr', staging); assert.deepEqual(bankerOverlayPosition(game), bankerOverlayPosition(beforePayment), 'Conserved printed-card staging must not move wallets, counters, clocks or skills.'); assert.deepEqual(bankerOverlayInventory(game), bankerOverlayInventory(beforePayment), 'Conserved printed-card staging must not duplicate or replace a physical identity.'); return { game, initial, setup, beforePayment, actions, staging, owner, trainer: assignment.leader, payer, payerFaction: seat.faction, holder: 'a', semuta: semutaId, hajr: hajrId, territory: destination.id, sector, key: `${destination.id}:${sector}`, amount, cost, payee, grant: payee === 'bank' && cost >= 4, kind, advanced, tech, strongholds, shipment, allow: advanced ? { actor: 'g', action: { type: 'decision', allow: true } } : null, plan: { hajr: true, claim: semuta === 'claim' ? owner : null }, }; } /** Apply the payer's own real declaration, its original Advanced Guild stop and * any ordinary suffix. No receipt, leg or authority is ever supplied here. */ export function shipBankerOverlayIncome(fixture: BankerOverlayFixture): Game { let game = applyAction(fixture.game, fixture.payer, fixture.shipment); if (fixture.allow) { assert.equal(game.decision?.kind, 'guildShipment', 'Advanced Shipment and Movement must open the original Guild stop first.'); assert.equal(game.decision?.player, fixture.allow.actor); assert.equal(game.pendingShipment?.player, fixture.payer); game = applyAction(game, fixture.allow.actor, fixture.allow.action); } return advanceBankerOverlay(game, bankerOverlayClean, { hajr: false, claim: null }); } /** Reach the holder's own movement turn, complete the first real movement in * Advanced, then play Hajr immediately after its original arrival finishes. * Basic retains its original timing. No immediate-use window is installed. */ export function openBankerOverlayHajr(state: Game, fixture: BankerOverlayFixture, actions?: RicheseSkillsTechStep[]): Game { const game = advanceBankerOverlay(state, candidate => !!candidate.pendingTreacheryDiscard, { hajr: true, claim: null }, actions); const pending = game.pendingTreacheryDiscard; assert.ok(pending, 'The conserved printed Hajr must produce one real fresh discard.'); assert.equal(pending.continuation.kind, 'ordinaryCardDiscard'); assert.equal(pending.batch.cause, 'ordinary:hajr'); assert.equal(pending.batch.turn, game.turn); assert.equal(pending.batch.phase, 5); assert.deepEqual(pending.batch.entries.map(entry => ({ id: entry.card.id, actor: entry.discardedBy, publicFace: entry.publicFace })), [{ id: fixture.hajr, actor: fixture.holder, publicFace: true }]); assert.equal(pending.reaction?.stage, 'offer', 'The original Semuta overlay must offer its neutral reaction before the printed continuation resumes.'); assert.ok(!bankerOverlayPlayer(game, fixture.holder).hand.some(card => card.id === fixture.hajr)); assert.equal(game.discard.filter(card => card.id === fixture.hajr).length, 1); assert.deepEqual(game.hajr, [fixture.holder]); return game; } /** Commit the owner's own printed Semuta at the open offer. The single freshly * discarded card resolves the private selection atomically and resumes the * original ordinary-card continuation without touching Banker income. */ export function claimBankerOverlaySemuta(state: Game, fixture: BankerOverlayFixture): Game { const pending = state.pendingTreacheryDiscard; assert.equal(pending?.reaction?.stage, 'offer', 'The printed Semuta offer must still be open.'); const own = viewGame(state, fixture.owner).semutaReaction; assert.ok(own?.stage === 'offer' && own.canCommit, own?.blocked ?? 'The printed Semuta claim must be legally available.'); const inventory = bankerOverlayInventory(state), position = bankerOverlayPosition(state); const ledger = bankerOverlayLedger(state, fixture.owner); const game = applyAction(state, fixture.owner, { type: 'semutaCommit', event: pending!.batch.event }); assert.equal(game.pendingTreacheryDiscard, null, 'One fresh card resolves the private Semuta claim atomically.'); assert.equal(game.discard.filter(card => card.id === fixture.semuta).length, 1, 'The spent Semuta Drug must be discarded exactly once.'); assert.equal(bankerOverlayPlayer(game, fixture.owner).hand.filter(card => card.id === fixture.hajr).length, 1, 'The claimed fresh card must stay privately with its claimant.'); assert.deepEqual(bankerOverlayInventory(game), inventory); assert.deepEqual(bankerOverlayPosition(game), position, 'The overlay continuation must not move wallets, counters, clocks or skills.'); assert.deepEqual(bankerOverlayLedger(game, fixture.owner), ledger, 'The overlay continuation must not add, move or duplicate Banker income.'); return game; } /** Decline the printed offer at every remaining seat and resume the same * original continuation; no claim, income or refund is invented. */ export function passBankerOverlaySemuta(state: Game, fixture: BankerOverlayFixture): Game { const inventory = bankerOverlayInventory(state); const game = advanceBankerOverlay(state, candidate => !candidate.pendingTreacheryDiscard, fixture.plan); assert.equal(game.discard.filter(card => card.id === fixture.semuta).length, 0); assert.equal(bankerOverlayPlayer(game, fixture.owner).hand.filter(card => card.id === fixture.semuta).length, 1); assert.equal(game.discard.filter(card => card.id === fixture.hajr).length, 1); assert.deepEqual(bankerOverlayInventory(game), inventory); assert.deepEqual(game.spiceBankerIncome, state.spiceBankerIncome, 'Declining the neutral overlay must not create Banker income.'); return game; }