456 lines
25 KiB
TypeScript
456 lines
25 KiB
TypeScript
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<T>(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 = <V extends ArrayBufferView | null>(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;
|
||
}
|