Offer Semuta before native paid-income Karama cancellations
This commit is contained in:
1 parent
adb054023d
commit
d718e380b7
10 files changed
+799
-15
No files matched your search
@@ -0,0 +1,192 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { Card } from '../game/cards';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
|
||||
import {
|
||||
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
|
||||
} from './fixture-semuta-emperor-share';
|
||||
|
||||
export type SemutaEmperorIncomeKind = 'emperorIncome' | 'richesePurchaseIncome';
|
||||
export type SemutaEmperorIncomeFixture = {
|
||||
game: Game;
|
||||
beforePayment: Game;
|
||||
sourceActions: { actor: string; action: Action }[];
|
||||
owner: string;
|
||||
holder: string;
|
||||
buyer: string;
|
||||
canceler: string;
|
||||
karama: string;
|
||||
karamaCard: Card;
|
||||
acquired: string;
|
||||
special?: string;
|
||||
amount: number;
|
||||
kind: SemutaEmperorIncomeKind;
|
||||
cancel: Action;
|
||||
originalCards: string[];
|
||||
staging: string[];
|
||||
};
|
||||
|
||||
/** Original ownership zones only; the paid auction prefix is a reference, not another card. */
|
||||
export function semutaEmperorIncomeCards(game: Game): string[] {
|
||||
return [
|
||||
...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? []),
|
||||
].map(card => card.id).sort();
|
||||
}
|
||||
|
||||
export function nextSemutaEmperorIncomeStep(game: Game): { actor: string; action: Action } {
|
||||
const pending = game.pendingTreacheryDiscard;
|
||||
const reaction = pending?.reaction;
|
||||
if (reaction?.stage === 'offer' && pending) {
|
||||
const actor = game.players.find(player => !reaction.passed.includes(player.id))!.id;
|
||||
return { actor, action: { type: 'semutaPass', event: pending.batch.event } };
|
||||
}
|
||||
return nextRicheseSkillsTechNativeStep(game);
|
||||
}
|
||||
|
||||
function reach(state: Game, until: (game: Game) => boolean): Game {
|
||||
let game = state;
|
||||
for (let n = 0; !until(game) && n < 1400; n++) {
|
||||
assert.notEqual(game.status, 'finished', 'Native actions must reach the original income source');
|
||||
const next = nextSemutaEmperorIncomeStep(game);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game), 'Native actions must reach the requested source boundary');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Move an original physical card to the next native draw, without replacing the deck. */
|
||||
function nextOriginalDraw(game: Game, id: string): void {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache ?? [],
|
||||
game.richeseRemoved ?? [], ...game.players.map(player => player.hand)]) {
|
||||
const index = zone.findIndex(card => card.id === id);
|
||||
if (index < 0) continue;
|
||||
const card = zone.splice(index, 1)[0];
|
||||
game.deck.unshift(card);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Missing original draw ${id}`);
|
||||
}
|
||||
|
||||
function fillHolder(game: Game): void {
|
||||
const holder = semutaEmperorPlayer(game, 'r');
|
||||
while (holder.hand.length < 4) {
|
||||
const card = game.deck.find(candidate => candidate.effect !== 'karama');
|
||||
assert.ok(card);
|
||||
moveSemutaEmperorCard(game, card.id, holder.id);
|
||||
}
|
||||
assert.equal(holder.hand.length, 4);
|
||||
}
|
||||
|
||||
/** Fresh original setup and first-turn phase actions. Only unplayed Spice order and
|
||||
* conserved later physical cards are staged; no clocks, wallets, allies or stocks are minted.
|
||||
* Purchase is made inside the genuine Richese declaration, so its original owner must return.
|
||||
* The auction optionally buys the very printed Karama that the winner then spends on income. */
|
||||
export function createSemutaEmperorIncomeFixture(
|
||||
kind: SemutaEmperorIncomeKind,
|
||||
options: { acquiredKarama?: boolean } = {},
|
||||
): SemutaEmperorIncomeFixture {
|
||||
const advanced = kind === 'richesePurchaseIncome';
|
||||
let game = createGame('SEMUTAINCOME', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
const originalCards = semutaEmperorIncomeCards(game);
|
||||
const staging: string[] = [];
|
||||
game = reach(game, state => state.status === 'playing');
|
||||
// Control only the two still-unplayed original first-turn blows.
|
||||
for (let position = 0; position < 2; position++) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card);
|
||||
assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
staging.push('Two unplayed original non-worm Spice Cards precede native first-turn Charity/Bidding.');
|
||||
game = reach(game, state => state.phase === 2 && !state.phaseOpening && !state.response && !state.decision);
|
||||
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
|
||||
let special: string | undefined;
|
||||
if (advanced) {
|
||||
special = moveSemutaEmperorCard(game,
|
||||
semutaEmperorCardId(game, card => card.effect === 'karama'), 'r');
|
||||
}
|
||||
const karama = semutaEmperorCardId(game,
|
||||
card => card.effect === 'karama' && card.id !== special);
|
||||
if (options.acquiredKarama) nextOriginalDraw(game, karama);
|
||||
else moveSemutaEmperorCard(game, karama, 'a');
|
||||
staging.push('Original Semuta moves from cache to Richese; original printed Karama custody is conserved.');
|
||||
if (!advanced) {
|
||||
const inspectionCounter = semutaEmperorCardId(game,
|
||||
card => card.effect === 'karama' && card.id !== karama);
|
||||
moveSemutaEmperorCard(game, inspectionCounter, 'e');
|
||||
staging.push('The other original printed Karama is held by Emperor, qualifying the next Atreides inspection response.');
|
||||
}
|
||||
const sourceActions: { actor: string; action: Action }[] = [];
|
||||
let beforePayment: Game;
|
||||
let acquired: string;
|
||||
if (advanced) {
|
||||
fillHolder(game);
|
||||
staging.push('Original later deck cards fill Richese to its four-card limit, including its separate activation.');
|
||||
game = reach(game, state => state.decision?.kind === 'richeseDeclaration' && !state.response && !state.phaseOpening);
|
||||
assert.equal(game.decision!.player, 'r');
|
||||
beforePayment = structuredClone(game);
|
||||
acquired = game.richeseCache![0].id;
|
||||
const purchase: Action = { type: 'card', mode: 'special', card: special!, acquire: acquired };
|
||||
sourceActions.push({ actor: 'r', action: purchase });
|
||||
game = applyAction(game, 'r', purchase);
|
||||
} else {
|
||||
if (!options.acquiredKarama) {
|
||||
const first = game.deck.find(card => card.effect !== 'karama');
|
||||
assert.ok(first);
|
||||
acquired = first.id;
|
||||
nextOriginalDraw(game, acquired);
|
||||
} else acquired = karama;
|
||||
staging.push(`Original ${acquired} is moved to the next native ordinary-pool draw, not a replacement deck.`);
|
||||
game = reach(game, state => !!state.auction && state.phase === 3 &&
|
||||
!state.phaseOpening && !state.response && !state.decision);
|
||||
assert.equal(game.auction!.cards[game.auction!.index].id, acquired);
|
||||
assert.ok(game.auction!.cards.length >= 2, 'The native ordinary pool must have a successor lot');
|
||||
fillHolder(game);
|
||||
staging.push('Conserved later deck cards bring Semuta holder to hand4 after the original pool is drawn.');
|
||||
for (let n = 0; game.auction!.active !== 'a' && n < 6; n++)
|
||||
game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
||||
assert.equal(game.auction!.active, 'a');
|
||||
beforePayment = structuredClone(game);
|
||||
sourceActions.push({ actor: 'a', action: { type: 'bid', amount: 1 } });
|
||||
game = applyAction(game, 'a', sourceActions[0].action);
|
||||
for (let n = 0; !game.response && n < 8; n++) {
|
||||
const next = nextSemutaEmperorIncomeStep(game);
|
||||
sourceActions.push(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
}
|
||||
assert.equal(game.response?.kind, kind);
|
||||
assert.equal(game.response!.owner, 'e');
|
||||
assert.notEqual(game.response!.source, 'ambassador');
|
||||
assert.equal(game.turn, 1);
|
||||
assert.equal(game.phase, 3);
|
||||
assert.deepEqual(semutaEmperorIncomeCards(game), originalCards);
|
||||
const karamaCard = semutaEmperorPlayer(game, 'a').hand.find(card => card.id === karama)!;
|
||||
assert.ok(karamaCard);
|
||||
if (special) assert.notEqual(special, karama);
|
||||
return {
|
||||
game, beforePayment, sourceActions, owner: 'e', holder: 'r', buyer: advanced ? 'r' : 'a', canceler: 'a',
|
||||
karama, karamaCard, acquired, special, amount: advanced ? 3 : 1, kind,
|
||||
cancel: { type: 'card', mode: 'cancel', card: karama }, originalCards, staging,
|
||||
};
|
||||
}
|
||||
|
||||
export function reactSemutaEmperorIncome(
|
||||
state: Game, fixture: SemutaEmperorIncomeFixture, claim: boolean,
|
||||
): Game {
|
||||
const event = state.pendingTreacheryDiscard!.batch.event;
|
||||
if (claim) return applyAction(state, fixture.holder, { type: 'semutaCommit', event });
|
||||
let game = state;
|
||||
for (const player of state.players)
|
||||
game = applyAction(game, player.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { botEntryAllowed } 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 { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
import {
|
||||
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
|
||||
} from './fixture-semuta-emperor-share';
|
||||
|
||||
export type SemutaGuildIncomeOptions = { advanced?: boolean; fullHand?: boolean };
|
||||
export type SemutaGuildIncomeFixture = {
|
||||
game: Game;
|
||||
income: Game;
|
||||
initial: Game;
|
||||
setup: Game;
|
||||
afterSetup: Game;
|
||||
afterFirstStorm: Game;
|
||||
beforeStaging: Game;
|
||||
actions: RicheseSkillsTechStep[];
|
||||
producerActions: RicheseSkillsTechStep[];
|
||||
staging: string[];
|
||||
owner: string;
|
||||
payer: string;
|
||||
holder: string;
|
||||
canceller: string;
|
||||
karama: Card;
|
||||
semuta: string;
|
||||
territory: string;
|
||||
key: string;
|
||||
amount: number;
|
||||
cost: number;
|
||||
ship: Action;
|
||||
cancel: Action;
|
||||
};
|
||||
export function semutaGuildIncomeInventory(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;
|
||||
}
|
||||
export function semutaGuildIncomeCounters(game: Game) {
|
||||
return game.players.map(player => ({
|
||||
id: player.id,
|
||||
total: player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0),
|
||||
elite: player.elites ? player.elites.reserves + player.elites.tanks +
|
||||
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0) : 0,
|
||||
}));
|
||||
}
|
||||
/** Exclude only hands from the already-paid shipment snapshot. Semuta changes
|
||||
* custody, never wallets, force pools, shipment flags or Movement ownership. */
|
||||
export function semutaGuildIncomePosition(game: Game) {
|
||||
return {
|
||||
status: game.status, turn: game.turn, phase: game.phase, active: game.active,
|
||||
order: game.order, ready: game.ready, movementRemaining: game.movementRemaining,
|
||||
pendingShipment: game.pendingShipment, guildRateBlocked: game.guildRateBlocked,
|
||||
players: game.players.map(player => ({
|
||||
id: player.id, faction: player.faction, ally: player.ally, spice: player.spice,
|
||||
forces: player.forces, reserves: player.reserves, tanks: player.tanks,
|
||||
elites: player.elites, leaders: player.leaders,
|
||||
shipped: player.shipped, moved: player.moved, revived: player.revived,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
/** Existing no-spend native setup/phase policy, with neutral passes for any
|
||||
* fresh discard encountered on the way. No clock, wallet or turn is staged. */
|
||||
export function nextSemutaGuildIncomeNativeStep(game: Game): RicheseSkillsTechStep {
|
||||
const pending = game.pendingTreacheryDiscard;
|
||||
const reaction = pending?.reaction;
|
||||
if (reaction?.stage === 'offer' && pending) {
|
||||
const actor = game.players.find(player => !reaction.passed.includes(player.id));
|
||||
assert.ok(actor);
|
||||
return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } };
|
||||
}
|
||||
return nextRicheseSkillsTechNativeStep(game);
|
||||
}
|
||||
/** Fresh Richese/Guild/classic setup, original first Storm and actual first-turn
|
||||
* Movement. Only still-unplayed Spice order and conserved later hands change.
|
||||
* Original board, leaders, wallets, reserve stocks and native order stay intact. */
|
||||
export function createSemutaGuildIncomeFixture(options: SemutaGuildIncomeOptions = {}): SemutaGuildIncomeFixture {
|
||||
let game = createGame('SEMUTAGUILDINCOME', newPlayer('r', 'Richese', 'richese'), options.advanced ?? false, ['choam']);
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
|
||||
const initial = structuredClone(game), actions: RicheseSkillsTechStep[] = [];
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
const setup = structuredClone(game), inventory = semutaGuildIncomeInventory(game);
|
||||
const reach = (until: () => boolean) => {
|
||||
for (let n = 0; !until() && n < 1200; n++) {
|
||||
assert.notEqual(game.status, 'finished', 'Original native actions must reach Movement');
|
||||
const next = nextSemutaGuildIncomeNativeStep(game);
|
||||
actions.push(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(), 'The genuine native phase path must reach its requested boundary');
|
||||
};
|
||||
reach(() => game.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
const counters = semutaGuildIncomeCounters(game);
|
||||
// Prevent an incidental first-turn worm by reordering original unplayed cards.
|
||||
for (const position of [0, 1]) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card);
|
||||
assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
reach(() => game.phase === 1 && clean(game));
|
||||
const afterFirstStorm = structuredClone(game);
|
||||
reach(() => game.phase === 5 && game.active === 'a' && clean(game));
|
||||
assert.equal(game.turn, 1);
|
||||
const beforeStaging = structuredClone(game), position = semutaGuildIncomePosition(game);
|
||||
for (const player of game.players) game.deck.push(...player.hand.splice(0));
|
||||
const staging = [
|
||||
'Reordered only the first two original unplayed Spice Cards to non-worm blows.',
|
||||
'At actual first-turn Atreides Movement, returned original held Treachery Cards to the same deck; no board, reserve, leader or wallet staging.',
|
||||
];
|
||||
const karamaId = moveSemutaEmperorCard(game, semutaEmperorCardId(game, card => card.effect === 'karama'), 'e');
|
||||
const semuta = moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
|
||||
const karama = semutaEmperorPlayer(game, 'e').hand.find(card => card.id === karamaId)!;
|
||||
staging.push(`Conserved original ${karamaId} to Emperor and canonical ${semuta} to Richese; neither is a replacement card.`);
|
||||
if (options.fullHand) {
|
||||
const holder = semutaEmperorPlayer(game, 'r');
|
||||
while (holder.hand.length < handLimit(holder)) {
|
||||
const id = semutaEmperorCardId(game, card => card.id !== semuta && card.id !== karamaId &&
|
||||
card.effect !== 'karama' && !holder.hand.some(held => held.id === card.id));
|
||||
moveSemutaEmperorCard(game, id, holder.id);
|
||||
staging.push(`Conserved original ${id} into Richese's full-hand exchange position.`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(semutaGuildIncomePosition(game), position);
|
||||
assert.deepEqual(semutaGuildIncomeInventory(game), inventory);
|
||||
assert.deepEqual(semutaGuildIncomeCounters(game), counters);
|
||||
const own = viewGame(game, 'a'), payer = own.players.find(player => player.id === 'a')!;
|
||||
// Empty ordinary sand isolates the paid shipment from battle/arrival parents.
|
||||
// The consumer's original entry and tariff helpers choose the actual 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, payer, t.id, t.sectors[0], 'ship'));
|
||||
assert.ok(destination, 'An original legal empty sand destination must remain');
|
||||
const amount = 3, sector = destination.sectors[0];
|
||||
const cost = reserveShipmentCost({ faction: payer.faction, halfRate: false }, destination.type, amount);
|
||||
assert.ok(payer.reserves >= amount && (payer.spice ?? 0) >= cost);
|
||||
const ship: Action = { type: 'ship', territory: destination.id, sector, amount, elite: 0, allyPayment: 0 };
|
||||
const producerActions: RicheseSkillsTechStep[] = [{ actor: 'a', action: ship }];
|
||||
if (game.advanced) producerActions.push({ actor: 'g', action: { type: 'decision', allow: true } });
|
||||
const fixture: Omit<SemutaGuildIncomeFixture, 'income'> = {
|
||||
game, initial, setup, afterSetup, afterFirstStorm, beforeStaging, actions, producerActions, staging,
|
||||
owner: 'g', payer: 'a', holder: 'r', canceller: 'e', karama, semuta,
|
||||
territory: destination.id, key: `${destination.id}:${sector}`, amount, cost, ship,
|
||||
cancel: { type: 'card', mode: 'cancel', card: karamaId },
|
||||
};
|
||||
return { ...fixture, income: shipSemutaGuildIncome(fixture) };
|
||||
}
|
||||
/** Issue the real reserve shipment; Advanced's original Guild special-stop
|
||||
* decision must explicitly allow it before any payment or force arrival. */
|
||||
export function shipSemutaGuildIncome(
|
||||
fixture: Pick<SemutaGuildIncomeFixture, 'game' | 'payer' | 'owner' | 'ship' | 'cost'>,
|
||||
): Game {
|
||||
let game = applyAction(fixture.game, fixture.payer, fixture.ship);
|
||||
if (fixture.game.advanced) {
|
||||
assert.equal(game.decision?.kind, 'guildShipment');
|
||||
assert.equal(game.decision?.player, fixture.owner);
|
||||
assert.deepEqual(semutaGuildIncomePosition(game), semutaGuildIncomePosition({
|
||||
...fixture.game, pendingShipment: game.pendingShipment,
|
||||
}));
|
||||
game = applyAction(game, fixture.owner, { type: 'decision', allow: true });
|
||||
}
|
||||
assert.equal(game.response?.kind, 'guildIncome', 'This is paid Guild income, not a rate or shipment-stop cancellation');
|
||||
assert.equal(game.response.owner, fixture.owner);
|
||||
assert.equal(game.response.amount, fixture.cost);
|
||||
assert.equal(semutaEmperorPlayer(game, fixture.payer).shipped, true);
|
||||
return game;
|
||||
}
|
||||
export function reactSemutaGuildIncome(state: Game, fixture: SemutaGuildIncomeFixture, claim: boolean): Game {
|
||||
const event = state.pendingTreacheryDiscard!.batch.event;
|
||||
if (claim) return applyAction(state, fixture.holder, { type: 'semutaCommit', event });
|
||||
let game = state;
|
||||
for (const seat of state.players) game = applyAction(game, seat.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { semutaEmperorPlayer } from './fixture-semuta-emperor-share';
|
||||
import {
|
||||
createSemutaEmperorIncomeFixture, nextSemutaEmperorIncomeStep,
|
||||
reactSemutaEmperorIncome, semutaEmperorIncomeCards,
|
||||
type SemutaEmperorIncomeKind,
|
||||
} from './fixture-semuta-emperor-income';
|
||||
|
||||
for (const kind of ['emperorIncome', 'richesePurchaseIncome'] as SemutaEmperorIncomeKind[]) {
|
||||
for (const claim of [false, true]) void test(
|
||||
`Semuta ${claim ? 'claims' : 'passes'} the foreign Karama canceling native ${kind} without repeating its paid source`, () => {
|
||||
const f = createSemutaEmperorIncomeFixture(kind);
|
||||
const originalBuyer = semutaEmperorPlayer(f.beforePayment, f.buyer);
|
||||
const originalOwner = semutaEmperorPlayer(f.beforePayment, f.owner);
|
||||
assert.equal(semutaEmperorPlayer(f.game, f.buyer).spice, originalBuyer.spice - f.amount);
|
||||
assert.equal(semutaEmperorPlayer(f.game, f.owner).spice, originalOwner.spice);
|
||||
assert.equal(semutaEmperorPlayer(f.game, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
|
||||
assert.equal(semutaEmperorPlayer(f.game, f.holder).hand.length, 4);
|
||||
const wallets = f.game.players.map(player => ({ id: player.id, spice: player.spice }));
|
||||
const pending = applyAction(f.game, f.canceler, f.cancel);
|
||||
const cost = pending.pendingTreacheryDiscard!;
|
||||
assert.ok(cost, 'The actual paid income cancellation must first offer its printed Karama cost');
|
||||
assert.equal(cost.batch.cause, `karama:${kind}`);
|
||||
assert.deepEqual(cost.batch.entries, [{ card: f.karamaCard, discardedBy: f.canceler, publicFace: true }]);
|
||||
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true,
|
||||
'A genuine full hand can exchange Semuta for the foreign cancellation cost');
|
||||
assert.deepEqual(viewGame(pending, f.holder).semutaReaction!.candidates, []);
|
||||
assert.equal(semutaEmperorPlayer(pending, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
|
||||
assert.deepEqual(pending.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
|
||||
if (kind === 'emperorIncome') {
|
||||
assert.equal(pending.auction!.index, f.game.auction!.index, 'The original next lot must wait for Semuta');
|
||||
assert.equal(pending.currentAuctionSale!.winner, f.buyer);
|
||||
} else {
|
||||
assert.equal(pending.richeseCache!.some(card => card.id === f.acquired), false);
|
||||
assert.equal(pending.discard.filter(card => card.id === f.special).length, 1);
|
||||
assert.equal(cost.batch.entries.some(entry => entry.card.id === f.special), false,
|
||||
'Richese never claims its own separately spent activation');
|
||||
assert.equal(semutaEmperorPlayer(pending, f.buyer).specialKaramaUsed, true);
|
||||
}
|
||||
const done = reactSemutaEmperorIncome(pending, f, claim);
|
||||
assert.equal(done.resolvedTreacheryDiscardSequence, cost.sequence);
|
||||
assert.deepEqual(done.players.map(player => ({ id: player.id, spice: player.spice })), wallets,
|
||||
'No refund, repeated debit or canceled Emperor credit occurs');
|
||||
assert.equal(semutaEmperorPlayer(done, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
|
||||
assert.equal(semutaEmperorPlayer(done, f.holder).hand.length, 4);
|
||||
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.karama), claim);
|
||||
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
|
||||
assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!claim));
|
||||
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
|
||||
assert.deepEqual(semutaEmperorIncomeCards(done), f.originalCards);
|
||||
assert.throws(() => applyAction(done, f.canceler, f.cancel));
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event }));
|
||||
if (kind === 'emperorIncome') {
|
||||
assert.equal(done.auction!.index, f.game.auction!.index + 1);
|
||||
assert.deepEqual(done.auction!.cards.map(card => card.id), f.game.auction!.cards.map(card => card.id));
|
||||
assert.equal(done.response!.kind, 'atreidesAuction');
|
||||
assert.equal(done.response!.owner, 'a', 'Original Atreides inspection owns the next paid-auction suffix');
|
||||
let resumed = done;
|
||||
while (resumed.response?.kind === 'atreidesAuction') {
|
||||
const player = resumed.players.find(seat => !resumed.response!.passed.includes(seat.id))!;
|
||||
resumed = applyAction(resumed, player.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(resumed.auction!.index, done.auction!.index, 'Inspection must not advance a second lot');
|
||||
const bidder = resumed.auction!.active;
|
||||
resumed = applyAction(resumed, bidder, { type: 'bid', amount: 1 });
|
||||
assert.equal(resumed.auction!.bidder, bidder, 'The original next lot is usable by its actual active bidder');
|
||||
assert.equal(resumed.auction!.index, done.auction!.index);
|
||||
assert.deepEqual(resumed.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
|
||||
} else {
|
||||
assert.deepEqual(done.decision, f.beforePayment.decision,
|
||||
'The native Richese declaration, including its original owner, is restored');
|
||||
assert.equal(done.richeseCache!.length, f.beforePayment.richeseCache!.length - 1);
|
||||
assert.equal(done.discard.filter(card => card.id === f.special).length, 1);
|
||||
const next = nextSemutaEmperorIncomeStep(done);
|
||||
assert.equal(next.actor, f.buyer);
|
||||
assert.throws(() => applyAction(done, f.owner, next.action), 'Emperor cannot answer the original Richese decision');
|
||||
const resumed = applyAction(done, next.actor, next.action);
|
||||
assert.equal(resumed.decision!.kind, 'richeseCache');
|
||||
assert.equal(resumed.decision!.player, f.buyer,
|
||||
'Native declaration opens its actual cache choice, after automatic uncanceled acknowledgements');
|
||||
assert.deepEqual(resumed.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
|
||||
assert.equal(semutaEmperorPlayer(resumed, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
|
||||
assert.throws(() => applyAction(resumed, next.actor, next.action), 'Declaration is not replayable');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (const claim of [false, true]) void test(
|
||||
`a real paid-auction winner spends its newly purchased Karama and Semuta ${claim ? 'claims' : 'passes'} that same physical card`, () => {
|
||||
const f = createSemutaEmperorIncomeFixture('emperorIncome', { acquiredKarama: true });
|
||||
assert.equal(f.acquired, f.karama);
|
||||
assert.equal(semutaEmperorPlayer(f.game, f.buyer).spice,
|
||||
semutaEmperorPlayer(f.beforePayment, f.buyer).spice - 1);
|
||||
const pending = applyAction(f.game, f.canceler, f.cancel);
|
||||
assert.ok(pending.pendingTreacheryDiscard);
|
||||
assert.equal(pending.pendingTreacheryDiscard.batch.entries[0].discardedBy, f.buyer);
|
||||
assert.equal(semutaEmperorPlayer(pending, f.buyer).hand.some(card => card.id === f.acquired), false);
|
||||
const done = reactSemutaEmperorIncome(pending, f, claim);
|
||||
assert.equal(semutaEmperorPlayer(done, f.buyer).spice, semutaEmperorPlayer(f.game, f.buyer).spice,
|
||||
'Buying then spending the card does not refund its paid auction');
|
||||
assert.equal(semutaEmperorPlayer(done, f.owner).spice, semutaEmperorPlayer(f.beforePayment, f.owner).spice);
|
||||
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.acquired), claim);
|
||||
assert.equal(done.discard.filter(card => card.id === f.acquired).length, Number(!claim));
|
||||
assert.equal(done.auction!.index, f.game.auction!.index + 1);
|
||||
assert.equal(done.response!.kind, 'atreidesAuction');
|
||||
assert.deepEqual(semutaEmperorIncomeCards(done), f.originalCards);
|
||||
},
|
||||
);
|
||||
|
||||
for (const kind of ['emperorIncome', 'richesePurchaseIncome'] as SemutaEmperorIncomeKind[]) void test(
|
||||
`a legal Easy bot completes the full-hand native ${kind} Semuta cost and returns its original controls`, () => {
|
||||
const f = createSemutaEmperorIncomeFixture(kind);
|
||||
const pending = applyAction(f.game, f.canceler, f.cancel);
|
||||
const view = viewGame(pending, f.holder);
|
||||
view.players.find(player => player.id === f.holder)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action);
|
||||
assert.equal(action.type, 'semutaCommit');
|
||||
const done = applyAction(pending, f.holder, action);
|
||||
assert.equal(semutaEmperorPlayer(done, f.holder).hand.length, 4);
|
||||
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.karama), true);
|
||||
assert.equal(semutaEmperorPlayer(done, f.buyer).spice, semutaEmperorPlayer(f.game, f.buyer).spice);
|
||||
assert.equal(semutaEmperorPlayer(done, f.owner).spice, semutaEmperorPlayer(f.beforePayment, f.owner).spice);
|
||||
if (kind === 'emperorIncome') {
|
||||
assert.equal(done.auction!.index, f.game.auction!.index + 1);
|
||||
assert.equal(done.response!.owner, 'a');
|
||||
} else {
|
||||
assert.deepEqual(done.decision, f.beforePayment.decision);
|
||||
assert.equal(semutaEmperorPlayer(done, f.buyer).specialKaramaUsed, true);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,130 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
createSemutaGuildIncomeFixture, reactSemutaGuildIncome,
|
||||
semutaGuildIncomeInventory,
|
||||
semutaGuildIncomePosition, type SemutaGuildIncomeFixture,
|
||||
} from './fixture-semuta-guild-income';
|
||||
import { semutaEmperorPlayer as semutaGuildIncomePlayer } from './fixture-semuta-emperor-share';
|
||||
|
||||
function openPaid(f: SemutaGuildIncomeFixture): Game {
|
||||
const paid = f.income;
|
||||
const payerBefore = semutaGuildIncomePlayer(f.game, f.payer), payerAfter = semutaGuildIncomePlayer(paid, f.payer);
|
||||
assert.equal(f.cost, 6, 'Three ordinary sand arrivals pay full price, not the Guild half rate');
|
||||
assert.equal(payerAfter.spice, payerBefore.spice - f.cost);
|
||||
assert.equal(payerAfter.reserves, payerBefore.reserves - f.amount);
|
||||
assert.equal(payerAfter.forces[f.key], (payerBefore.forces[f.key] ?? 0) + f.amount);
|
||||
assert.equal(payerAfter.shipped, true);
|
||||
assert.equal(payerAfter.moved, payerBefore.moved);
|
||||
assert.equal(paid.active, f.payer);
|
||||
assert.deepEqual(paid.movementRemaining, f.game.movementRemaining);
|
||||
assert.equal(semutaGuildIncomePlayer(paid, f.owner).spice, semutaGuildIncomePlayer(f.game, f.owner).spice);
|
||||
return paid;
|
||||
}
|
||||
function openCost(f: SemutaGuildIncomeFixture, paid: Game): Game {
|
||||
assert.ok(viewGame(paid, f.canceller).responseControls!.cancelCards.includes(f.karama.id));
|
||||
assert.deepEqual(viewGame(paid, f.owner).responseControls!.cancelCards, [], 'Guild is income owner, not foreign canceller');
|
||||
assert.throws(() => applyAction(paid, f.payer, f.cancel),
|
||||
'The shipper cannot spend the foreign Emperor-controlled cost');
|
||||
const pending = applyAction(paid, f.canceller, f.cancel);
|
||||
const discard = pending.pendingTreacheryDiscard;
|
||||
assert.ok(discard, 'Printed Karama needs its immediate public Semuta opportunity');
|
||||
assert.equal(discard.batch.cause, 'karama:guildIncome');
|
||||
assert.deepEqual(discard.batch.entries, [{ card: f.karama, discardedBy: f.canceller, publicFace: true }]);
|
||||
assert.equal(discard.reaction?.stage, 'offer');
|
||||
assert.deepEqual(semutaGuildIncomePosition(pending), semutaGuildIncomePosition(paid));
|
||||
assert.equal(semutaGuildIncomePlayer(pending, f.canceller).hand.some(card => card.id === f.karama.id), false);
|
||||
for (const seat of pending.players) {
|
||||
const offer = viewGame(pending, seat.id).semutaReaction!;
|
||||
assert.equal(offer.canCommit, seat.id === f.holder);
|
||||
}
|
||||
assert.throws(() => applyAction(pending, f.payer, { type: 'endMovement' }));
|
||||
assert.throws(() => applyAction(pending, f.payer, { type: 'semutaCommit', event: discard.batch.event }));
|
||||
return pending;
|
||||
}
|
||||
function assertCompletion(f: SemutaGuildIncomeFixture, paid: Game, pending: Game, done: Game, claim: boolean): void {
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
const holder = semutaGuildIncomePlayer(done, f.holder);
|
||||
assert.equal(holder.hand.some(card => card.id === f.karama.id), claim);
|
||||
assert.equal(holder.hand.some(card => card.id === f.semuta), !claim);
|
||||
assert.equal(done.discard.filter(card => card.id === f.karama.id).length, Number(!claim));
|
||||
assert.equal(done.discard.filter(card => card.id === f.semuta).length, Number(claim));
|
||||
assert.equal(done.resolvedTreacheryDiscardSequence, pending.pendingTreacheryDiscard!.sequence);
|
||||
assert.deepEqual(semutaGuildIncomePosition(done), semutaGuildIncomePosition(paid),
|
||||
'Canceling Guild income never refunds, reprices, re-ships, moves or advances the native owner');
|
||||
assert.deepEqual(semutaGuildIncomeInventory(done), semutaGuildIncomeInventory(f.setup));
|
||||
assert.throws(() => applyAction(done, f.payer, f.ship));
|
||||
assert.throws(() => applyAction(done, f.canceller, f.cancel));
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event }));
|
||||
// Compare the next native Movement scheduling with the original allowed
|
||||
// income branch. Only Guild's collected spice differs; no owner/suffix is invented.
|
||||
const allowed = applyAction(paid, f.canceller, { type: 'passResponse' });
|
||||
assert.equal(semutaGuildIncomePlayer(allowed, f.owner).spice,
|
||||
semutaGuildIncomePlayer(f.game, f.owner).spice + f.cost);
|
||||
const ordinarySuffix = applyAction(allowed, f.payer, { type: 'endMovement' });
|
||||
const canceledSuffix = applyAction(done, f.payer, { type: 'endMovement' });
|
||||
assert.equal(canceledSuffix.turn, ordinarySuffix.turn);
|
||||
assert.equal(canceledSuffix.phase, ordinarySuffix.phase);
|
||||
assert.equal(canceledSuffix.active, ordinarySuffix.active);
|
||||
assert.deepEqual(canceledSuffix.movementRemaining, ordinarySuffix.movementRemaining);
|
||||
assert.deepEqual(canceledSuffix.decision, ordinarySuffix.decision);
|
||||
assert.deepEqual(canceledSuffix.response, ordinarySuffix.response);
|
||||
assert.ok(!canceledSuffix.movementRemaining?.includes(f.payer));
|
||||
const payer = semutaGuildIncomePlayer(canceledSuffix, f.payer);
|
||||
assert.equal(payer.spice, semutaGuildIncomePlayer(f.game, f.payer).spice - f.cost);
|
||||
assert.equal(payer.reserves, semutaGuildIncomePlayer(f.game, f.payer).reserves - f.amount);
|
||||
assert.equal(payer.forces[f.key], f.amount);
|
||||
assert.equal(payer.shipped, true);
|
||||
assert.equal(semutaGuildIncomePlayer(canceledSuffix, f.owner).spice, semutaGuildIncomePlayer(f.game, f.owner).spice);
|
||||
assert.deepEqual(semutaGuildIncomeInventory(canceledSuffix), semutaGuildIncomeInventory(f.setup));
|
||||
for (const seat of canceledSuffix.players) viewGame(canceledSuffix, seat.id);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const claim of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} native paid reserve shipment Semuta ${claim ? 'claims' : 'passes'} income-canceling Karama without refund or repeat arrival`, () => {
|
||||
const f = createSemutaGuildIncomeFixture({ advanced }), paid = openPaid(f), pending = openCost(f, paid);
|
||||
const done = reactSemutaGuildIncome(pending, f, claim);
|
||||
assertCompletion(f, paid, pending, done, claim);
|
||||
},
|
||||
);
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} full-hand Richese exchanges Semuta for the exact foreign Guild-income cost`, () => {
|
||||
const f = createSemutaGuildIncomeFixture({ advanced, fullHand: true });
|
||||
const held = semutaGuildIncomePlayer(f.game, f.holder);
|
||||
assert.equal(held.hand.length, handLimit(held));
|
||||
const paid = openPaid(f), pending = openCost(f, paid);
|
||||
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true);
|
||||
const done = reactSemutaGuildIncome(pending, f, true);
|
||||
assert.equal(semutaGuildIncomePlayer(done, f.holder).hand.length, handLimit(held));
|
||||
assert.deepEqual(semutaGuildIncomePlayer(done, f.holder).hand.map(card => card.id).sort(),
|
||||
[...held.hand.filter(card => card.id !== f.semuta).map(card => card.id), f.karama.id].sort());
|
||||
assertCompletion(f, paid, pending, done, true);
|
||||
});
|
||||
}
|
||||
|
||||
void test('native Guild-income denial waits through neutral passes and the original paid Movement stays owned', () => {
|
||||
const f = createSemutaGuildIncomeFixture(), paid = openPaid(f), pending = openCost(f, paid);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
let game = pending;
|
||||
for (const actor of [f.owner, f.payer, f.canceller]) {
|
||||
game = applyAction(game, actor, { type: 'semutaPass', event });
|
||||
assert.equal(game.pendingTreacheryDiscard!.reaction!.stage, 'offer');
|
||||
assert.deepEqual(semutaGuildIncomePosition(game), semutaGuildIncomePosition(paid));
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama.id).length, 1);
|
||||
}
|
||||
game = applyAction(game, f.holder, { type: 'semutaPass', event });
|
||||
assertCompletion(f, paid, pending, game, false);
|
||||
});
|
||||
|
||||
void test('legal Advanced Semuta bot finishes the full-hand Guild-income cancellation and resumes native Movement', () => {
|
||||
const f = createSemutaGuildIncomeFixture({ advanced: true, fullHand: true });
|
||||
const paid = openPaid(f), pending = openCost(f, paid);
|
||||
const own = viewGame(pending, f.holder);
|
||||
own.players.find(player => player.id === f.holder)!.bot = 'Easy';
|
||||
const action = botActions(own)[0];
|
||||
assert.ok(action);
|
||||
const done = applyAction(pending, f.holder, action);
|
||||
assertCompletion(f, paid, pending, done, true);
|
||||
});
|
||||
Reference in new issue
Block a user