Files
Dune/tests/semuta-ecaz-economy-engine.test.ts
T

271 lines
21 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, initializeSemutaGameForAudit, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createSemutaEcazEconomyFixture as fixture, actSemutaEcazEconomy as act,
passSemutaEcazEconomy as pass, claimSemutaEcazEconomy as claim,
allowSemutaEcazEconomy as allow, finishSemutaEcazEconomy as finish,
semutaEcazEconomyPlayer as player, semutaEcazEconomyInventory as inventory,
semutaEcazEconomyPhysical as physical, semutaEcazEconomyProfile as profile,
type SemutaEcazOccupyFixture as SemutaEcazEconomyFixture,
type SemutaEcazEconomyFixture as SemutaEcazEconomySource,
type SemutaEcazCollectionFixture,
} from './fixture-semuta-ecaz-economy';
const leads = ['ecaz', 'ally'] as const;
const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
order: game.order, movementRemaining: game.movementRemaining, storm: game.storm,
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard });
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function nativeSource(f: SemutaEcazEconomyFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, false); assert.equal(f.setup.semutaPreview, true);
assert.equal(f.setup.ecazOccupyPreview, true); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
assert.deepEqual(player(f.afterSetup, f.owner).forces, { 'imperial_basin:9': 6 });
assert.equal(player(f.afterSetup, f.owner).reserves, 14);
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
assert.equal(player(f.acquired, f.ally).spice - player(f.acquisition.before, f.ally).spice, 1);
assert.equal(f.placement.steps[0].action.token, f.selfToken); assert.equal(f.placement.steps[0].action.territory, f.territory);
assert.equal(player(f.ecazShipment.before, f.owner).spice - player(f.ecazShipment.after, f.owner).spice, 2);
assert.equal(player(f.alliance.before, f.ally).spice - player(f.alliance.after, f.ally).spice, 2);
assert.ok(f.alliance.steps.some(step => step.actor === f.owner && step.action.trigger === true && step.action.choice === 'alliance'));
assert.ok(f.alliance.steps.some(step => step.actor === f.ally && step.action.accept === true));
assert.ok(f.alliance.steps.some(step => step.actor === f.owner && step.action.loan === false));
assert.equal(player(f.sharedStronghold, f.owner).ally, f.ally); assert.equal(player(f.sharedStronghold, f.ally).ally, f.owner);
assert.equal(player(f.sharedStronghold, f.owner).forces[f.location], 2);
assert.equal(player(f.sharedStronghold, f.ally).forces[f.location], 2);
assert.equal(f.basicCollectionQuote.collectionBonus, null, 'Basic does not gain the Advanced bank-income source');
assert.equal(player(f.opponentShipment.before, f.opponent).spice - player(f.opponentShipment.after, f.opponent).spice, 1);
assert.equal(player(f.game, f.opponent).forces[f.location], 1);
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead'); assert.equal(f.beforeLead.decision?.player, f.owner);
assert.equal(f.leadAction.lead, f.lead); assert.equal(f.game.battle?.ecazOccupy?.lead, f.lead);
assert.equal(f.game.battle?.ecazOccupy?.canceled, false); assert.equal(f.game.battle?.revealed, false);
assert.deepEqual(f.game.battle?.plans, {}); assert.deepEqual(f.game.battle, f.originalBattle);
assert.deepEqual(f.game.response, f.originalResponse); assert.equal(f.game.response?.kind, 'ecazOccupy');
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 6); assert.equal(f.game.pendingAmbassador ?? null, null);
assert.equal(f.allowedProfile.fixedEcazDial, 1); assert.equal(f.allowedProfile.forceOwner, f.ally);
assert.equal(f.allowedProfile.maxDial, 3); assert.equal(f.canceledProfile.maxDial, 2);
assert.equal(f.canceledProfile.forceOwner, f.lead); assert.equal(f.canceledProfile.fixedEcazDial, 0);
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.equal(f.sourceActionCount, f.actions.length);
}
function open(f: SemutaEcazEconomySource) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaEcazMoritaniDiscard');
if (pending.continuation.kind !== 'karamaEcazMoritaniDiscard') throw Error('Expected the original Ecaz cost');
assert.equal(pending.batch.cause, `karama:${f.kind}`); assert.equal(pending.reaction?.stage, 'offer');
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
[{ card: f.cost, by: f.spender, face: true }]);
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(game.ecazCollection ?? null, f.collection);
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function planEntry(state: Game, f: SemutaEcazEconomyFixture, canceled: boolean) {
assert.equal(state.pendingTreacheryDiscard ?? null, null); assert.equal(state.battle?.ecazOccupy?.lead, f.lead);
assert.equal(state.battle?.ecazOccupy?.canceled, canceled); assert.deepEqual(state.battle?.plans, {});
assert.equal(state.battle?.revealed, false); assert.deepEqual(physical(state), f.originalPhysical);
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
assert.equal(game.phase, 6); assert.equal(game.turn, 1); assert.equal(game.battle?.event, f.originalBattle.event);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(game.battle?.ecazOccupy?.lead, f.lead); assert.equal(game.battle?.ecazOccupy?.canceled, canceled);
assert.equal(game.battle?.revealed, false); assert.deepEqual(game.battle?.plans, {});
assert.deepEqual(profile(game), canceled ? f.canceledProfile : f.allowedProfile);
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
assert.deepEqual(new Set(game.battle?.preLeader?.ready), new Set([f.lead, f.opponent]));
assert.ok(steps.every(step => step.action.type !== 'battlePlan' && step.action.type !== 'card'));
const otherAlly = f.lead === f.owner ? f.ally : f.owner;
reject(game, otherAlly, f.canceledPlan);
if (canceled) reject(game, f.lead, f.allowedPlan);
const plan = canceled ? f.canceledPlan : f.allowedPlan;
const sealed = act(structuredClone(game), f.lead, plan);
assert.equal(sealed.battle?.plans[f.lead]?.dial, plan.dial); assert.equal(sealed.battle?.revealed, false);
assert.deepEqual(physical(sealed), f.originalPhysical); assert.deepEqual(inventory(sealed), f.originalInventory);
return game;
}
function denied(state: Game, f: SemutaEcazEconomyFixture, claimed: boolean) {
const game = planEntry(state, f, true);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
reject(game, f.spender, f.action); assert.deepEqual(inventory(game), f.originalInventory); return game;
}
for (const lead of leads) for (const claimed of [false, true]) void test(
`Basic native Occupy ${lead} lead cost Semuta ${claimed ? 'claim' : 'pass'} retains the lead and only disables the combined dial`, () => {
const f = fixture({ kind: 'ecazOccupy', lead }); nativeSource(f); const offered = open(f);
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
},
);
void test('native Basic Occupy allowance keeps its mandatory free Ecaz contribution and actual Richese variable pool', () => {
for (const lead of leads) {
const f = fixture({ kind: 'ecazOccupy', lead }); nativeSource(f);
const game = planEntry(allow(structuredClone(f.game)), f, false);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
}
});
void test('native Occupy cost reactions reject stale foreign and underlying lead or plan replies before native preparation', () => {
for (const lead of leads) {
const f = fixture({ kind: 'ecazOccupy', lead }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
reject(game, f.owner, f.leadAction); reject(game, f.owner, { type: 'passResponse' });
reject(game, f.lead, f.allowedPlan); reject(game, f.opponent, f.opponentPlan);
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
}
});
void test('all four minimal profiles legally claim or pass Basic native Occupy costs for either earned lead', () => {
for (const lead of leads) {
const f = fixture({ kind: 'ecazOccupy', lead }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
const proposed = botActions(own)[0]; assert.ok(proposed, difficulty);
assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event);
let game = act(structuredClone(offered), f.holder, proposed);
const claimed = proposed.type === 'semutaCommit';
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
const selected = botActions(selecting)[0]; assert.ok(selected);
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
game = act(game, f.holder, selected);
} else if (game.pendingTreacheryDiscard) game = pass(game);
denied(game, f, claimed);
}
}
});
function nativeCollectionSource(f: SemutaEcazCollectionFixture) {
assert.equal(f.lobby.advanced, true); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
assert.equal(f.setup.status, 'setup'); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'ecaz', 'emperor']);
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
assert.deepEqual(player(f.afterSetup, f.owner).forces, { 'imperial_basin:9': 6 });
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
assert.equal(player(f.acquired, f.ally).spice - player(f.acquisition.before, f.ally).spice, 1);
assert.equal(player(f.alliance.before, f.ally).spice - player(f.alliance.after, f.ally).spice, 2);
assert.equal(player(f.ecazShipment.before, f.owner).spice - player(f.ecazShipment.after, f.owner).spice, 2);
assert.equal(player(f.ordinaryShipment.before, f.holder).spice - player(f.ordinaryShipment.after, f.holder).spice, 2);
assert.ok(f.ordinaryShipment.steps.some(step => step.actor === f.holder && step.action.type === 'ship' && step.action.amount === 1));
assert.equal(player(f.game, f.holder).forces['funeral_plain:15'], 1);
assert.ok(f.alliance.steps.some(step => step.actor === f.owner && step.action.trigger === true && step.action.choice === 'alliance'));
assert.ok(f.alliance.steps.some(step => step.actor === f.ally && step.action.accept === true));
assert.ok(f.alliance.steps.every(step => step.action.loan === undefined), 'Advanced alliance needs no fabricated Basic loan reply');
assert.equal(player(f.game, f.owner).ally, f.ally); assert.equal(player(f.game, f.ally).ally, f.owner);
assert.equal(player(f.game, f.owner).forces[f.location], 2); assert.equal(player(f.game, f.ally).forces[f.location], 2);
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 7); assert.equal(f.game.battle ?? null, null);
assert.equal(f.game.pendingAmbassador ?? null, null); assert.equal(f.game.phaseOpening ?? null, null);
assert.equal(f.game.response?.kind, 'ecazCollection'); assert.equal(f.game.response?.owner, f.owner);
assert.deepEqual(f.game.ecazCollection, f.collection); assert.equal(f.collection.stage, 'response');
assert.deepEqual(f.allowedQuote.collectionBonus?.strongholds, [f.territory]);
assert.ok(f.allowedQuote.collectionBonus && f.allowedQuote.collectionBonus.amount > 0);
const allowed = f.allowedQuote.receipts.find(receipt => receipt.player === f.owner)!;
const canceled = f.canceledQuote.receipts.find(receipt => receipt.player === f.owner)!;
assert.equal(allowed.balance - canceled.balance, f.allowedQuote.collectionBonus.amount);
assert.ok(f.canceledQuote.receipts.find(receipt => receipt.player === f.ally)!.strongholds > 0,
'The canceled special benefit still has native ordinary allied city income');
assert.ok(f.canceledQuote.receipts.find(receipt => receipt.player === f.holder)!.desert > 0,
'An actual paid CHOAM desert cohort gives nonzero ordinary Collection independent of the bank bonus');
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
const collectionForces = (game: Game) => game.players.map(p => ({ id: p.id, forces: p.forces, elites: p.elites,
reserves: p.reserves, tanks: p.tanks, shipped: p.shipped, moved: p.moved, ally: p.ally }));
function collectionOutcome(state: Game, f: SemutaEcazCollectionFixture, canceled: boolean) {
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
const quote = canceled ? f.canceledQuote : f.allowedQuote;
assert.equal(game.phase, 7); assert.equal(game.turn, 1); assert.equal(game.response ?? null, null);
assert.equal(game.decision ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.equal(game.battle ?? null, null); assert.equal(game.ecazCollection?.event, f.collection.event);
assert.equal(game.ecazCollection?.stage, 'complete'); assert.equal(game.ecazCollection?.canceled, canceled);
assert.equal(game.ecazCollection?.allocation, null); assert.deepEqual(game.ecazCollection?.settled, []);
assert.deepEqual(game.spice, quote.spice); assert.deepEqual(collectionForces(game), collectionForces(f.game));
assert.deepEqual(game.ecazAmbassadors, f.game.ecazAmbassadors); assert.deepEqual(game.dukeVidal, f.game.dukeVidal);
for (const receipt of quote.receipts) assert.equal(player(game, receipt.player).spice, receipt.balance);
assert.deepEqual(clock(game), clock(f.game));
assert.ok(steps.every(step => step.action.type !== 'ship' && step.action.type !== 'card'));
const balances = game.players.map(p => [p.id, p.spice]), ready = act(structuredClone(game), f.owner, { type: 'ready' });
assert.equal(ready.phase, 7); assert.deepEqual(ready.players.map(p => [p.id, p.spice]), balances,
'An original Collection acknowledgement cannot collect or initialize income again');
assert.deepEqual(ready.ecazCollection, game.ecazCollection);
reject(game, f.owner, { type: 'passResponse' }); assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function collectionDenied(state: Game, f: SemutaEcazCollectionFixture, claimed: boolean) {
const game = collectionOutcome(state, f, true);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
reject(game, f.spender, f.action); return game;
}
for (const claimed of [false, true]) void test(
`Advanced native Ecaz Collection cost Semuta ${claimed ? 'claim' : 'pass'} credits only the native ordinary collection once`, () => {
const f = fixture({ kind: 'ecazCollection' }); nativeCollectionSource(f); const offered = open(f);
collectionDenied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
},
);
void test('native Advanced Ecaz Collection allowance credits the quoted real shared-stronghold bonus once', () => {
const f = fixture({ kind: 'ecazCollection' }); nativeCollectionSource(f);
const game = collectionOutcome(allow(structuredClone(f.game)), f, false);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
});
void test('native Advanced Ecaz Collection cost rejects stale foreign and underlying acknowledgements before income', () => {
const f = fixture({ kind: 'ecazCollection' }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, { type: 'ready' });
const done = collectionDenied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
});
void test('all four minimal profiles legally claim or pass native Advanced Ecaz Collection costs', () => {
const f = fixture({ kind: 'ecazCollection' }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
const proposed = botActions(own)[0]; assert.ok(proposed, difficulty);
assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event);
let game = act(structuredClone(offered), f.holder, proposed);
const claimed = proposed.type === 'semutaCommit';
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
const selected = botActions(selecting)[0]; assert.ok(selected);
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
game = act(game, f.holder, selected);
} else if (game.pendingTreacheryDiscard) game = pass(game);
collectionDenied(game, f, claimed);
}
});
void test('fresh Advanced Ecaz Semuta initialization preserves original undealt inventories and public preview behavior', () => {
const f = fixture({ kind: 'ecazCollection' }); nativeCollectionSource(f);
reject(f.lobby, f.lobby.host, { type: 'start' });
const ordinaryPreview = act(structuredClone(f.lobby), f.lobby.host, { type: 'start', advancedPreview: true });
assert.equal(ordinaryPreview.advanced, true); assert.equal(ordinaryPreview.status, 'setup');
assert.equal(ordinaryPreview.semutaPreview, undefined, 'Normal Advanced preview does not silently gain the private Semuta profile');
assert.deepEqual(inventory(ordinaryPreview), f.originalInventory);
const before = structuredClone(f.setup);
assert.throws(() => initializeSemutaGameForAudit(f.setup)); assert.deepEqual(f.setup, before,
'The original development initializer cannot overwrite an initialized inventory');
});