Files
Dune/tests/fixture-allied-marker-guild-rate.ts

149 lines
7.7 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
export interface AlliedMarkerRateStep { actor: string; action: Action }
export interface AlliedMarkerGuildRateOptions {
token?: 'zero' | 'five';
payer?: 'supplier' | 'recipient';
unaffordable?: boolean;
independentCard?: boolean;
advanced?: boolean;
}
export interface AlliedMarkerGuildRateFixture {
initial: Game;
beforeAction: Game;
actions: AlliedMarkerRateStep[];
action: Action;
owner: string;
recipient: string;
observer: string;
token: 'zero' | 'five';
payer: string;
territory: string;
sector: number;
target: string;
priorSource: string;
karama: string;
independentKarama: string | null;
amount: number;
provenance: string[];
}
export const alliedMarkerRatePlayer = (g: Game, id: string) => {
const p = g.players.find(seat => seat.id === id); assert.ok(p); return p;
};
export const alliedMarkerRateReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
export function stepAlliedMarkerRate(g: Game, step: AlliedMarkerRateStep, actions?: AlliedMarkerRateStep[]) {
const next = applyAction(alliedMarkerRateReload(g), step.actor, step.action);
actions?.push(structuredClone(step));
return next;
}
/** Bounded CONTROLLED source from richese-allied-no-field.test.ts, not an
* original setup/action-only chronology. Its turn, alliance, empty armies,
* ten-spice wallets and prior three-marker deployment are assigned here.
* Karama cards move out of the actual base deck, conserving physical identity;
* they are controlled acquisitions, not auction purchases. The original paired
* E3 producer excludes Richese from its supported roster. Every subsequent
* wallet reduction, offer, consent, response and settlement is an actual action.
*/
export function createAlliedMarkerGuildRate(options: AlliedMarkerGuildRateOptions = {}): AlliedMarkerGuildRateFixture {
const owner = 'r', recipient = 'g', observer = 'a';
let game = createGame('ALLIEDMARKERRATE', newPlayer(owner, 'Richese', 'richese'),
options.advanced ?? true, ['choam']);
game.players.push(newPlayer(recipient, 'Guild', 'guild'), newPlayer(observer, 'Atreides', 'atreides'));
game.status = 'playing'; game.phase = 5; game.turn = 2; game.active = recipient;
game.order = [owner, recipient, observer]; game.movementRemaining = [recipient, owner, observer];
game.storm = 18; game.deck = baseDeck();
for (const p of game.players) {
p.forces = {}; p.reserves = 20; p.tanks = 0; p.spice = 10; p.hand = [];
p.traitorChoices = []; p.traitors = [];
}
const supplier = alliedMarkerRatePlayer(game, owner);
supplier.ally = recipient; alliedMarkerRatePlayer(game, recipient).ally = owner;
supplier.noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']), {
tokenId: 'three', controller: owner, location: { territory: 'red_chasm', sector: 7 },
});
supplier.noFieldEvent = 'event-a';
const cards: string[] = [];
for (let n = 0; n < (options.independentCard ? 2 : 1); n++) {
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [card] = game.deck.splice(index, 1);
alliedMarkerRatePlayer(game, observer).hand.push(card); cards.push(card.id);
}
const initial = alliedMarkerRateReload(game), actions: AlliedMarkerRateStep[] = [];
if (options.unaffordable) game = stepAlliedMarkerRate(game, { actor: recipient,
action: { type: 'bribe', target: observer, amount: 9 } }, actions);
const token = options.token ?? 'five', payer = options.payer === 'supplier' ? owner : recipient;
const territory = 'imperial_basin', sector = 10;
const action: Action = { type: 'offerRicheseNoField', token, event: supplier.noFieldEvent,
payer, territory, sector };
return { initial, beforeAction: alliedMarkerRateReload(game), actions, action,
owner, recipient, observer, token, payer, territory, sector, target: `${territory}:${sector}`,
priorSource: 'red_chasm:7', karama: cards[0], independentKarama: cards[1] ?? null,
amount: token === 'zero' ? 0 : 5,
provenance: [
'CONTROLLED existing allied-loan setup: turn 2 Shipment, Richese/Guild alliance, 20 counters and 10 spice per seat, prior Richese-controlled three-marker in Red Chasm sector 7.',
'One or two real Karama identities are removed from the undealt base deck into Atreides custody; no original auction purchase is claimed.',
'Optional Guild poverty comes only from an actual nine-spice bribe. All loan offers, recipient consents, responses and physical settlement are actual actions from this bounded source.',
] };
}
/** Only pass a current physical card holder's actual cancel opportunity. */
export function passAlliedMarkerRateResponses(state: Game, kind: 'richeseNoField' | 'guildRate',
actions?: AlliedMarkerRateStep[]) {
let game = alliedMarkerRateReload(state);
for (let n = 0; game.response?.kind === kind && n < 20; n++) {
const actor = game.players.find(p => {
const controls = viewGame(game, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
})?.id;
assert.ok(actor, 'The pending response needs a real eligible card holder.');
game = stepAlliedMarkerRate(game, { actor, action: { type: 'passResponse' } }, actions);
}
assert.notEqual(game.response?.kind, kind);
return game;
}
/** Includes original source fields before any price cancellation. */
export function declareAlliedMarkerGuildRate(f: AlliedMarkerGuildRateFixture, state = f.beforeAction,
action = f.action) {
const actions: AlliedMarkerRateStep[] = [];
let game = stepAlliedMarkerRate(state, { actor: f.owner, action }, actions);
const offer = structuredClone(game.richeseAllyOffer); assert.ok(offer);
const beforeAcceptance = alliedMarkerRateReload(game);
game = stepAlliedMarkerRate(game, { actor: f.recipient,
action: { type: 'decision', event: offer.event, accept: true, elite: 0 } }, actions);
const accepted = alliedMarkerRateReload(game);
game = passAlliedMarkerRateResponses(game, 'richeseNoField', actions);
if (game.decision?.kind === 'guildShipment') {
assert.equal(game.decision.amount, 1);
game = stepAlliedMarkerRate(game, { actor: f.recipient,
action: { type: 'decision', allow: true } }, actions);
}
return { game, actions, beforeAcceptance, accepted, offer };
}
/** API/UI-loadable genuine pending rate scene; no response or decision is staged. */
export function createAlliedMarkerGuildRateScene(options: AlliedMarkerGuildRateOptions = {}) {
const fixture = createAlliedMarkerGuildRate(options);
const declared = declareAlliedMarkerGuildRate(fixture);
assert.equal(declared.game.response?.kind, 'guildRate');
assert.equal(declared.game.pendingShipment?.cost, 1);
assert.ok(viewGame(declared.game, fixture.observer).responseControls?.cancelCards.includes(fixture.karama));
return { ...fixture, ...declared, declarationActions: declared.actions,
actions: [...fixture.actions, ...declared.actions],
original: {
offer: declared.offer, cost: 1, bankOnly: true,
source: { owner: fixture.owner, controller: fixture.recipient, tokenId: fixture.token,
tokenEvent: alliedMarkerRatePlayer(fixture.beforeAction, fixture.owner).noFieldEvent,
territory: fixture.territory, sector: fixture.sector },
supplierPayment: fixture.payer === fixture.owner ? 1 : 0,
recipientPayment: fixture.payer === fixture.recipient ? 1 : 0,
},
cancel: { actor: fixture.observer,
action: { type: 'card', mode: 'cancel', card: fixture.karama } as Action },
};
}