Files
Dune/tests/semuta-revival-benefits-forces-engine.test.ts
T

279 lines
19 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { location } from '../game/board';
import { quoteRevivalCancellation } from '../game/revival-cancellation';
import {
createSemutaRevivalBenefitsForcesFixture as fixture, actSemutaRevivalBenefitsForces as act,
passSemutaRevivalBenefitsForces as pass, claimSemutaRevivalBenefitsForces as claim,
allowSemutaRevivalBenefitsForces as allow, finishSemutaRevivalBenefitsForces as finish,
semutaRevivalBenefitsForcesPlayer as player, semutaRevivalBenefitsForcesInventory as inventory,
type SemutaRevivalBenefitsForcesFixture,
} from './fixture-semuta-revival-benefits-forces';
const variants = [
{ kind: 'choamRevival' as const, advanced: false },
{ kind: 'choamRevival' as const, advanced: true },
{ kind: 'revivalLimit' as const, advanced: false },
{ kind: 'revivalLimit' as const, advanced: true },
{ kind: 'revivalDiscount' as const, advanced: false },
{ kind: 'revivalDiscount' as const, advanced: true },
{ kind: 'choamRevival' as const, advanced: false, amount: 4 },
{ kind: 'revivalDiscount' as const, advanced: false, unfunded: true },
];
const physical = (game: Game) => ({
turn: game.turn, phase: game.phase, storm: game.storm, active: game.active, order: game.order, ready: game.ready,
revivalRules: game.revivalRules, freeRevival: game.freeRevival, revivalFreeIncome: game.revivalFreeIncome,
emperorExtra: game.emperorExtra, revivalRequests: game.revivalRequests,
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
players: game.players.map(player => ({ id: player.id, spice: player.spice,
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
revived: player.revived, freeForcesRevived: player.freeForcesRevived,
leaderRevived: player.leaderRevived, leaders: player.leaders, shipped: player.shipped, moved: player.moved })),
});
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: SemutaRevivalBenefitsForcesFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true);
assert.equal(f.game.advanced, f.advanced); assert.equal(f.setup.advanced, f.advanced);
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
assert.equal(player(f.acquisition.after, f.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
assert.equal(f.firstRevival.storm, 18); assert.equal(player(f.firstRevival, f.atreides).tanks, 0);
assert.equal(player(f.firstRevival, f.atreides).forces['arrakeen:10'], 10);
assert.deepEqual(f.actions.filter(step => step.action.type === 'stormDial' &&
f.actions.indexOf(step) < f.actions.findIndex(action => action.action.type === 'richeseBid'))
.map(step => Number(step.action.amount)).sort((a, b) => a - b), [0, 17]);
const destination = location(f.shipmentDestination.territory, f.shipmentDestination.sector);
const shipped = f.kind === 'revivalLimit' ? 5 : 4;
assert.equal(player(f.shipment.before, f.recipient).spice - player(f.shipment.after, f.recipient).spice, 2 * shipped);
assert.equal(player(f.shipment.after, f.recipient).forces[destination], shipped);
assert.ok(f.shipment.steps.some(step => step.actor === f.recipient && step.action.type === 'ship' && step.action.amount === shipped));
assert.equal(player(f.storm.before, f.recipient).tanks, 0); assert.equal(player(f.storm.after, f.recipient).tanks, shipped);
assert.equal(player(f.storm.after, f.recipient).forces[destination] ?? 0, 0);
assert.equal(f.firstStorm.before.storm, 18);
if (f.kind !== 'choamRevival') {
assert.ok(f.financingFunding);
assert.equal(player(f.financingFunding.before, f.spender).spice - player(f.financingFunding.after, f.spender).spice, 6);
assert.equal(player(f.financingFunding.after, f.atreides).spice, 10);
assert.equal(player(f.financingFunding.after, f.atreides).bribes, 6);
assert.equal(player(f.firstShipment.after, f.atreides).spice, 0);
assert.equal(player(f.firstShipment.after, f.atreides).forces['cielago_north:1'], 5);
assert.equal(player(f.firstStorm.before, f.atreides).spice, 6 + (f.advanced ? 2 : 0));
assert.equal(player(f.firstStorm.before, f.atreides).bribes, 0);
assert.equal(player(f.firstStorm.after, f.atreides).tanks, 5);
}
if (f.kind === 'revivalLimit') {
assert.equal(f.game.turn, 2); assert.equal(f.financingReturn, null);
assert.ok(f.revival.steps.some(step => step.actor === f.tleilaxu && step.action.type === 'tleilaxuRevivalLimit' && step.action.target === f.recipient));
assert.equal(f.pending.free, 2); assert.equal(f.pending.cost, 2 * (f.amount - 2));
assert.equal(f.expected.outcome, 'abandoned');
} else {
assert.equal(f.game.turn, f.kind === 'choamRevival' ? 2 : 3);
if (f.kind === 'choamRevival') {
assert.equal(f.financingFunding, null); assert.equal(f.financingReturn, null);
assert.equal(player(f.shipment.before, f.holder).spice, 11);
assert.equal(f.pending.cost, f.amount); assert.equal(f.pending.free, 0);
assert.equal(f.expected.outcome, f.amount === 4 ? 'abandoned' : 'revive');
if (f.amount === 3) assert.equal(f.expected.pending?.cost, 6);
} else {
assert.ok(f.financingReturn);
assert.equal(player(f.financingReturn.before, f.atreides).spice - player(f.financingReturn.after, f.atreides).spice, 6);
assert.equal(player(f.financingReturn.after, f.atreides).revived, 5);
assert.equal(player(f.financingReturn.after, f.tleilaxu).spice - player(f.financingReturn.before, f.tleilaxu).spice, 7);
assert.equal(player(f.shipment.before, f.tleilaxu).spice, 12);
assert.equal(player(f.shipment.after, f.tleilaxu).spice, 4);
assert.equal(player(f.storm.before, f.tleilaxu).spice, 4);
assert.equal(f.shipmentDestination.sector, f.shipment.before.storm % 18 + 1);
assert.ok(Object.entries(f.shipment.before.spice).every(([key, amount]) =>
amount === 0 || !key.startsWith(`${f.shipmentDestination.territory}:`)));
assert.equal(f.pending.amount, 4); assert.equal(f.pending.free, 2);
assert.equal(f.pending.cost, 2); assert.equal(f.pending.normalCost, 4);
assert.equal(f.expected.pending?.cost, 4); assert.equal(f.expected.outcome, f.unfunded ? 'unfunded' : 'revive');
assert.ok(f.revival.steps.some(step => step.action.type === 'passResponse'), 'The earlier own-T limit response is actually allowed');
if (f.unfunded) {
assert.ok(f.funding); assert.equal(player(f.funding.after, f.payer).spice, 2);
assert.ok(f.funding.steps.some(step => step.actor === f.payer && step.action.type === 'bribe' && step.action.target === f.spender));
assert.equal(player(f.game, f.payer).spice, f.pending.cost);
}
}
}
assert.equal(f.game.phase, 4); assert.equal(f.game.response?.kind, f.kind);
assert.equal(f.game.response?.owner, f.owner); assert.equal(f.game.response?.recipient, f.recipient);
assert.deepEqual(f.game.pendingRevival, f.pending);
assert.deepEqual(player(f.game, f.recipient), player(f.beforeRevival, f.recipient));
assert.deepEqual(quoteRevivalCancellation(f.game, f.game.response!), f.expected);
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null);
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 57);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function open(f: SemutaRevivalBenefitsForcesFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaRevivalDiscard');
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, `karama:${f.kind}`);
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 }]);
if (pending.continuation.kind !== 'karamaRevivalDiscard') throw Error('Use the exact revival-benefit cost');
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
assert.deepEqual(game.pendingRevival, f.pending); assert.equal(game.response ?? null, null);
assert.equal(game.decision ?? null, null); assert.equal(game.phaseOpening ?? null, null);
assert.deepEqual(physical(game), physical(f.game));
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
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;
}
/** Predict the exact price/free-event income independently. The last usable
* counter's disposal may make that income auto-allow, while a reclaimed cost
* may leave the same native income window actionable. */
function settled(game: Game, f: SemutaRevivalBenefitsForcesFixture, claimed: boolean) {
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingRevival ?? null, null);
assert.equal(game.pendingKarama ?? null, null); assert.equal(game.decision ?? null, null);
assert.deepEqual(game.revivalRules, f.expected.rules);
const returned = f.expected.outcome === 'revive', quote = f.expected.pending;
const nativeIncome = returned && f.expectedResume?.nextResponse?.kind === 'revivalIncome'
? f.expectedResume.nextResponse : null;
const income = game.response ? structuredClone(game.response) : null;
const outstanding = income?.kind === 'revivalIncome' ? income.amount! : 0;
const credited = (nativeIncome?.amount ?? 0) - outstanding;
assert.ok(outstanding === 0 || outstanding === nativeIncome?.amount);
assert.ok(credited === 0 || credited === nativeIncome?.amount);
if (income) {
assert.equal(income.kind, 'revivalIncome'); assert.ok(nativeIncome);
assert.equal(income.owner, nativeIncome.owner); assert.equal(income.recipient, nativeIncome.recipient);
assert.equal(income.amount, nativeIncome.amount);
}
const original = player(f.game, f.recipient), recipient = player(game, f.recipient);
assert.equal(recipient.tanks, original.tanks - (returned ? f.amount : 0));
assert.equal(recipient.reserves, original.reserves + (returned ? f.amount : 0));
assert.equal(recipient.revived, original.revived + (returned ? f.amount : 0));
assert.equal(recipient.freeForcesRevived ?? 0, (original.freeForcesRevived ?? 0) + (returned ? quote!.free : 0));
assert.equal(player(game, f.payer).spice, player(f.game, f.payer).spice - (returned ? quote!.cost : 0) +
(f.payer === f.tleilaxu ? credited : 0));
assert.equal(player(game, f.tleilaxu).spice, player(f.game, f.tleilaxu).spice -
(returned && f.payer === f.tleilaxu ? quote!.cost : 0) + credited);
assert.deepEqual(recipient.forces, original.forces); assert.deepEqual(recipient.elites, original.elites);
assert.deepEqual(game.freeRevival, f.game.freeRevival);
assert.equal(game.active, f.game.active); assert.deepEqual(game.ready, f.game.ready);
assert.equal(game.phase, 4); assert.equal(game.turn, f.game.turn);
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));
if (returned) {
const resumed = f.expectedResume; assert.ok(resumed);
assert.equal(resumed.outcome, 'revive'); assert.equal(resumed.pending.cost, quote!.cost);
assert.ok(nativeIncome); assert.equal(nativeIncome.owner, f.tleilaxu);
assert.equal(nativeIncome.recipient, f.payer); assert.equal(nativeIncome.amount, f.payer === f.tleilaxu ? 1 : quote!.cost);
if (resumed.freeIncome) {
assert.equal(resumed.freeIncome.player, f.payer);
assert.equal(game.revivalFreeIncome?.[f.payer], f.game.turn);
} else assert.deepEqual(game.revivalFreeIncome, f.game.revivalFreeIncome);
} else {
assert.equal(income, null); assert.deepEqual(game.revivalFreeIncome, f.game.revivalFreeIncome);
}
const beforeIncome = structuredClone(game), steps: { actor: string; action: Action }[] = [];
game = finish(game, steps);
assert.ok(steps.every(step => step.action.type === 'passResponse'));
assert.equal(game.response ?? null, null); assert.equal(game.pendingRevival ?? null, null);
assert.equal(player(game, f.tleilaxu).spice, player(beforeIncome, f.tleilaxu).spice + outstanding);
for (const id of game.players.map(player => player.id).filter(id => id !== f.tleilaxu))
assert.deepEqual(player(game, id), player(beforeIncome, id));
assert.equal(player(game, f.recipient).tanks, recipient.tanks);
assert.equal(player(game, f.recipient).reserves, recipient.reserves);
assert.equal(player(game, f.recipient).revived, recipient.revived);
assert.deepEqual(inventory(game), f.originalInventory);
return game;
}
for (const variant of variants) for (const claimed of [false, true]) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.kind}${'amount' in variant ? ' above normal cap' : ''}${'unfunded' in variant ? ' unfunded' : ''} cost ${claimed ? 'claim' : 'pass'} precedes its original return outcome`, () => {
const f = fixture(variant); nativeSource(f);
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
const game = settled(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaPass', event });
reject(game, f.recipient, f.nativeAction);
});
void test('native benefit allowances complete the original cheap/granted return once without spending either held card', () => {
for (const variant of variants.filter(variant => !('unfunded' in variant))) {
const f = fixture(variant); nativeSource(f);
let game = allow(structuredClone(f.game));
const beforeIncome = structuredClone(game), income = game.response?.kind === 'revivalIncome' ? game.response.amount! : 0;
assert.equal(f.allowedResume.outcome, 'revive'); assert.equal(f.allowedResume.pending.cost, f.pending.cost);
const expectedIncome = f.allowedResume.nextResponse?.kind === 'revivalIncome' ? f.allowedResume.nextResponse.amount! : 0;
const credited = expectedIncome - income;
assert.ok(credited === 0 || credited === expectedIncome);
assert.equal(player(game, f.payer).spice, player(f.game, f.payer).spice - f.pending.cost +
(f.payer === f.tleilaxu ? credited : 0));
assert.equal(player(game, f.tleilaxu).spice, player(f.game, f.tleilaxu).spice -
(f.payer === f.tleilaxu ? f.pending.cost : 0) + credited);
if (f.allowedResume.freeIncome) assert.equal(game.revivalFreeIncome?.[f.payer], f.game.turn);
assert.equal(player(game, f.recipient).tanks, player(f.game, f.recipient).tanks - f.amount);
assert.equal(player(game, f.recipient).reserves, player(f.game, f.recipient).reserves + f.amount);
assert.equal(player(game, f.recipient).revived, player(f.game, f.recipient).revived + f.amount);
game = finish(game);
assert.equal(player(game, f.tleilaxu).spice, player(beforeIncome, f.tleilaxu).spice + income);
assert.equal(game.pendingRevival ?? null, null); assert.equal(game.response ?? null, null);
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));
assert.deepEqual(inventory(game), f.originalInventory);
}
});
void test('abandoned and unfunded native requests preserve a real smaller return opportunity', () => {
for (const variant of [variants[2], variants[6], variants[7]]) {
const f = fixture(variant), game = settled(claim(open(f), f.holder), f, true);
const amount = f.kind === 'revivalDiscount' ? 2 : 3, before = structuredClone(game);
let next = act(game, f.recipient, { type: 'revive', amount, elite: 0 });
next = finish(next);
assert.equal(player(next, f.recipient).tanks, player(before, f.recipient).tanks - amount);
assert.equal(player(next, f.recipient).reserves, player(before, f.recipient).reserves + amount);
assert.equal(player(next, f.recipient).revived, player(before, f.recipient).revived + amount);
assert.equal(next.pendingRevival ?? null, null); assert.equal(next.response ?? null, null);
assert.deepEqual(inventory(next), f.originalInventory);
}
});
void test('fresh revival-benefit costs reject stale, foreign and underlying actions while the original request is unpaid', () => {
for (const variant of variants) {
const f = fixture(variant), 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.recipient, f.nativeAction);
reject(game, f.owner, { type: 'passResponse' });
assert.deepEqual(game.pendingRevival, f.pending); assert.deepEqual(physical(game), physical(f.game));
settled(pass(game), f, false);
}
});
void test('all four minimal policies answer the actual force-benefit cancellation costs legally', () => {
for (const variant of variants) {
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty;
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
assert.deepEqual(own, before);
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event);
let game = act(structuredClone(offered), f.holder, action);
const claimed = action.type === 'semutaCommit';
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty;
const selected = botActions(choosing)[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);
settled(game, f, claimed);
}
}
});