Files
Dune/tests/semuta-advisor-gamont-engine.test.ts

183 lines
13 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 { isAdvisor } from '../game/advisors';
import {
createSemutaAdvisorGamontFixture as fixture, actSemutaAdvisorGamont as act,
passSemutaAdvisorGamont as pass, claimSemutaAdvisorGamont as claim,
allowSemutaAdvisorGamont as allow, finishSemutaAdvisorGamont as finish,
closeSemutaAdvisorGamont as close, semutaAdvisorGamontPlayer as player,
semutaAdvisorGamontInventory as inventory, semutaAdvisorGamontPhysical as physical,
semutaAdvisorGamontStrongholds as strongholds, type SemutaAdvisorGamontFixture,
} from './fixture-semuta-advisor-gamont';
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, choamMentatPending: game.choamMentatPending });
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: SemutaAdvisorGamontFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, 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.ok(player(f.afterSetup, f.holder).hand.some(card => card.id === f.gamont && card.name === 'Trip to Gamont'));
assert.deepEqual(f.advisorSetup.steps[0].action, { type: 'advisorSetup', territory: 'arrakeen', sector: 10 });
assert.equal(player(f.afterSetup, f.owner).forces[f.location], 1);
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.territory), true);
assert.equal(player(f.afterSetup, f.spender).forces[f.location], 10);
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.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
assert.equal(player(f.bgShipment.before, f.owner).spice - player(f.bgShipment.after, f.owner).spice, 2);
assert.ok(f.bgShipment.steps.some(step => step.action.type === 'ship' && step.action.amount === 2));
assert.ok(f.bgMovement.steps.some(step => step.action.type === 'move' && step.action.from === 'carthag:11' && step.action.amount === 1));
assert.ok(f.outsiderDeparture.steps.some(step => step.actor === f.spender && step.action.type === 'move' && step.action.amount === 9));
assert.equal(player(f.beforeGamont, f.spender).forces[f.location], 1);
assert.equal(player(f.beforeGamont, f.spender).forces['imperial_basin:10'], 9);
assert.equal(player(f.beforeGamont, f.owner).forces['carthag:11'], 1);
assert.equal(player(f.beforeGamont, f.owner).forces['sietch_tabr:14'], 1);
assert.equal(isAdvisor(player(f.beforeGamont, f.owner), f.territory), true);
assert.equal(strongholds(f.beforeGamont, f.owner).length, 2);
assert.equal(f.beforeGamont.decision?.kind ?? null, f.final ? 'choamMentat' : null);
assert.equal(f.declaredGamont.response?.kind, 'choamWorthless');
assert.equal(f.gamontHistory.steps[0].actor, f.holder); assert.deepEqual(f.gamontHistory.steps[0].action, f.gamontAction);
assert.ok(f.gamontHistory.steps.some(step => step.actor === f.spender && step.action.type === 'passResponse'));
for (const disposal of f.fuelDisposals) {
assert.equal(disposal.before.pendingTreacheryDiscard?.continuation.kind, 'choamGamontDiscard');
assert.equal(viewGame(disposal.before, f.holder).semutaReaction?.canCommit ?? false, false);
assert.ok(disposal.steps.every(step => step.action.type === 'semutaPass'));
assert.notEqual(disposal.after.pendingTreacheryDiscard?.batch.event, disposal.event);
}
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
assert.equal(player(f.game, f.holder).hand.some(card => card.id === f.gamont), false);
assert.equal(f.game.discard.filter(card => card.id === f.gamont).length, 1);
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta), 'Own fuel disposal never spends Semuta');
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost), 'The original Gamont counter was genuinely declined');
assert.equal(player(f.game, f.spender).forces[f.location] ?? 0, 0);
assert.equal(player(f.game, f.spender).reserves, player(f.beforeGamont, f.spender).reserves + 1);
assert.equal(player(f.game, f.spender).tanks, player(f.beforeGamont, f.spender).tanks);
assert.equal(f.game.response?.kind, 'advisorFlip'); assert.deepEqual(f.game.response, f.originalResponse);
assert.equal(f.originalAutomaticSource.producer, 'gamont'); assert.equal(f.originalAutomaticSource.gamont.returned, true);
assert.equal(f.originalAutomaticSource.gamont.mentat, f.final);
assert.equal(f.originalAutomaticSource.gamont.player, f.holder); assert.equal(f.originalAutomaticSource.gamont.target, f.spender);
assert.equal(f.originalParent.card, f.gamont); assert.deepEqual(f.originalParent.receipt, f.originalAutomaticSource.gamont);
assert.deepEqual(f.game.pendingAutomaticAdvisorGamont, f.originalParent);
assert.deepEqual(f.game.pendingAutomaticAdvisorFlips, f.originalQueue);
assert.equal(f.originalStance.aloneFlipHold, f.originalAutomaticSource.event);
assert.equal(isAdvisor(player(f.game, f.owner), f.territory), true);
assert.equal(strongholds(f.game, f.owner).length, 2);
assert.equal(f.game.status, 'playing'); assert.deepEqual(f.game.winner, []);
assert.equal(f.game.phase, 8); assert.equal(f.game.turn, 1); assert.equal(f.game.decision ?? null, null);
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.equal(f.sourceActionCount, f.actions.length);
}
function open(f: SemutaAdvisorGamontFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Expected the original Gamont conversion cost');
assert.equal(pending.batch.cause, 'karama:advisorFlip'); 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.pendingAutomaticAdvisorGamont, f.originalParent);
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalStance);
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
assert.equal(game.discard.filter(card => card.id === f.gamont).length, 1);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function nativeOutcome(state: Game, f: SemutaAdvisorGamontFixture, canceled: boolean) {
const game = finish(state);
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingAutomaticAdvisorGamont ?? null, null);
assert.deepEqual(game.pendingAutomaticAdvisorFlips ?? [], []); assert.equal(game.response ?? null, null);
assert.deepEqual(game.decision, f.chooser); assert.deepEqual(clock(game), clock(f.game));
assert.deepEqual(physical(game), f.originalPhysical);
assert.equal(isAdvisor(player(game, f.owner), f.territory), canceled);
if (canceled) assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalStance);
assert.equal(player(game, f.spender).forces[f.location] ?? 0, 0);
assert.equal(player(game, f.spender).reserves, player(f.game, f.spender).reserves);
assert.equal(game.discard.filter(card => card.id === f.gamont).length, 1);
assert.equal(strongholds(game, f.owner).length, canceled ? 2 : 3);
assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []);
reject(game, f.holder, f.gamontAction);
const closeSteps: { actor: string; action: Action }[] = [], completed = close(structuredClone(game), closeSteps);
assert.ok(closeSteps.some(step => step.actor === f.holder && step.action.type === 'decision' && step.action.done === true));
if (f.final) assert.deepEqual(closeSteps[0], { actor: f.confirmationActor, action: f.confirmationAction });
else assert.ok(closeSteps.some(step => step.action.type === 'ready'));
if (canceled) {
assert.equal(completed.status, 'playing'); assert.equal(completed.turn, 2); assert.deepEqual(completed.winner, []);
} else {
assert.equal(completed.status, 'finished'); assert.equal(completed.turn, 1); assert.deepEqual(completed.winner, [f.owner]);
}
assert.equal(completed.discard.filter(card => card.id === f.gamont).length, 1);
assert.equal(player(completed, f.spender).reserves, player(f.game, f.spender).reserves);
assert.equal(player(completed, f.spender).tanks, player(f.game, f.spender).tanks);
assert.equal(player(completed, f.spender).forces[f.location] ?? 0, 0);
assert.deepEqual(inventory(completed), f.originalInventory); return game;
}
function denied(state: Game, f: SemutaAdvisorGamontFixture, claimed: boolean) {
const game = nativeOutcome(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 final of [false, true]) for (const claimed of [false, true]) void test(
`native ${final ? 'final' : 'ordinary'} Gamont conversion cost Semuta ${claimed ? 'claim' : 'pass'} keeps the real return and resumes only original Mentat`, () => {
const f = fixture({ final }); nativeSource(f); const offered = open(f);
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
},
);
void test('native Gamont conversion allowance earns the real third stronghold but waits for actual final confirmation', () => {
for (const final of [false, true]) {
const f = fixture({ final }); nativeSource(f); const game = nativeOutcome(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 Gamont conversion cost rejects stale foreign fuel and underlying chooser replies before stance or victory', () => {
for (const final of [false, true]) {
const f = fixture({ final }), 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.holder, f.gamontAction); reject(game, f.holder, f.confirmationAction);
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, { type: 'ready' });
for (const disposal of f.fuelDisposals) reject(game, f.holder, { type: 'semutaCommit', event: disposal.event });
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
}
});
void test('all four minimal profiles legally claim or pass native ordinary and final Gamont conversion costs', () => {
for (const final of [false, true]) {
const f = fixture({ final }), 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);
}
}
});