Files
Dune/tests/semuta-nexus-trade.test.ts

156 lines
9.6 KiB
TypeScript

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 { DIFFICULTIES } from '../game/bot-profiles';
import {
createSemutaNexusTradeFixture, claimSemutaNexusTrade, finishSemutaNexusTradeCollection,
nextSemutaNexusTradeNativeStep, passSemutaNexusTrade, semutaNexusTradeInventory,
semutaNexusTradePlayer, type SemutaNexusTradeFixture,
} from './fixture-semuta-nexus-trade';
function openTrade(f: SemutaNexusTradeFixture): Game {
assert.equal(f.afterFirstStorm.phase, 1);
assert.ok(Object.values(f.afterSetup.nexusCards!.cards!.hands).every(card => card === null));
assert.equal(f.beforeAlliance.turn, 2);
assert.equal(f.beforeAlliance.nexus, true);
assert.equal(f.beforeDraw.nexusCards!.phase!.occurred, true);
assert.equal(f.beforeDraw.nexusCards!.phase!.stage, 'drawing');
assert.deepEqual([...f.beforeDraw.nexusCards!.phase!.eligible].sort(), [f.holder, f.trader].sort());
assert.equal(semutaNexusTradePlayer(f.beforeDraw, f.allies[0]).ally, f.allies[1]);
assert.equal(semutaNexusTradePlayer(f.beforeDraw, f.allies[1]).ally, f.allies[0]);
assert.equal(f.beforeDraw.nexusCards!.cards!.hands[f.trader], null);
assert.ok(f.afterDraw.nexusCards!.cards!.hands[f.trader], 'Actual closing draw precedes identity staging');
assert.ok(f.afterDraw.nexusCards!.phase!.done.includes(f.trader));
assert.equal(semutaNexusTradePlayer(f.game, f.trader).ally, null);
assert.ok(!f.game.players.some(p => p.faction === 'choam'));
assert.equal(f.game.nexusCards!.cards!.hands[f.trader], 'choam');
assert.equal(f.game.phase, 7);
const own = viewGame(f.game, f.trader);
assert.equal(own.nexusChoamTrade!.blocked, null);
assert.equal(own.nexusChoamTrade!.spice, 2);
assert.deepEqual(own.nexusChoamTrade!.cards.map(card => card.id), [f.cost.id, f.spare.id]);
const pending = applyAction(f.game, f.trader, f.action);
assert.equal(semutaNexusTradePlayer(pending, f.trader).spice, semutaNexusTradePlayer(f.game, f.trader).spice + 2);
assert.equal(pending.nexusCards!.cards!.hands[f.trader], null);
assert.deepEqual(pending.nexusCards!.cards!.discard.filter(card => card === 'choam'), ['choam']);
assert.equal(pending.nexusChoamTrades!.length, 1);
assert.equal(pending.nexusChoamTrades![0].stage, 'discard');
assert.equal(pending.pendingTreacheryDiscard!.continuation.kind, 'nexusChoamTrade');
assert.equal(pending.pendingTreacheryDiscard!.batch.cause, 'nexus:choamTrade');
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries,
[{ card: f.cost, discardedBy: f.trader, publicFace: true }]);
assert.equal(pending.pendingTreacheryDiscard!.reaction!.stage, 'offer');
for (const player of pending.players) {
const reaction = viewGame(pending, player.id).semutaReaction!;
assert.equal(reaction.event, pending.pendingTreacheryDiscard!.batch.event);
assert.equal(reaction.canCommit, player.id === f.holder);
assert.equal(reaction.stage, 'offer');
}
return pending;
}
function assertCompletedTrade(f: SemutaNexusTradeFixture, completed: Game, claim: boolean): void {
const before = semutaNexusTradePlayer(f.game, f.trader), after = semutaNexusTradePlayer(completed, f.trader);
assert.equal(after.spice, before.spice + 2);
assert.deepEqual(after.hand.map(card => card.id), [f.spare.id]);
for (const player of completed.players.filter(p => p.id !== f.trader))
assert.equal(player.spice, semutaNexusTradePlayer(f.game, player.id).spice, 'Semuta and neutral acknowledgements pay no spice');
assert.equal(completed.pendingTreacheryDiscard, null);
assert.equal(completed.nexusChoamTrades!.length, 1);
assert.equal(completed.nexusChoamTrades![0].stage, 'complete');
assert.deepEqual(completed.nexusChoamTradeLast, { event: f.action.event, stage: 'complete' });
assert.equal(completed.resolvedTreacheryDiscardSequence, completed.treacheryDiscardSequence);
const held = semutaNexusTradePlayer(completed, f.holder).hand;
assert.equal(held.some(card => card.id === f.cost.id), claim);
assert.equal(held.some(card => card.id === f.semuta), !claim);
assert.equal(completed.discard.filter(card => card.id === f.cost.id).length, claim ? 0 : 1);
assert.equal(completed.discard.filter(card => card.id === f.semuta).length, claim ? 1 : 0);
assert.equal(completed.nexusCards!.cards!.hands[f.trader], null);
assert.deepEqual(completed.nexusCards!.cards!.discard.filter(card => card === 'choam'), ['choam']);
assert.deepEqual(semutaNexusTradeInventory(completed), semutaNexusTradeInventory(f.game));
assert.ok(![...completed.deck, ...completed.discard, ...completed.players.flatMap(p => p.hand)]
.some(card => card.id === 'choam'), 'A spent Nexus identity is never a Treachery Card');
assert.throws(() => applyAction(completed, f.trader, f.action), /current CHOAM Nexus trade|Worthless|trade/i);
const continued = finishSemutaNexusTradeCollection(completed);
assert.equal(continued.phase, 8, 'The original Collection readiness completes into Mentat');
assert.equal(continued.nexusChoamTrades!.length, 1);
assert.equal(continued.nexusChoamTrades![0].stage, 'complete');
assert.equal(semutaNexusTradePlayer(continued, f.trader).spice, before.spice + 2);
const next = nextSemutaNexusTradeNativeStep(continued);
const moved = applyAction(continued, next.actor, next.action);
assert.equal(moved.nexusChoamTrades!.length, 1);
assert.equal(moved.nexusChoamTrades![0].stage, 'complete');
assert.deepEqual(semutaNexusTradeInventory(moved), semutaNexusTradeInventory(f.game));
}
for (const advanced of [false, true]) {
for (const claim of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native Secret Ally public Worthless trade ${claim ? 'claim' : 'pass'} resumes its paid receipt once`, () => {
const f = createSemutaNexusTradeFixture({ advanced }), pending = openTrade(f);
const completed = claim ? claimSemutaNexusTrade(pending, f) : passSemutaNexusTrade(pending);
assertCompletedTrade(f, completed, claim);
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} full Richese hand exchanges Semuta for the exact spent Worthless without overflow`, () => {
const f = createSemutaNexusTradeFixture({ advanced, fullHand: true }), pending = openTrade(f);
const holder = semutaNexusTradePlayer(f.game, f.holder);
assert.equal(holder.hand.length, handLimit(holder));
const completed = claimSemutaNexusTrade(pending, f);
assert.equal(semutaNexusTradePlayer(completed, f.holder).hand.length, handLimit(holder));
assert.deepEqual(semutaNexusTradePlayer(completed, f.holder).hand.map(card => card.id).sort(),
[...holder.hand.filter(card => card.id !== f.semuta).map(card => card.id), f.cost.id].sort());
assertCompletedTrade(f, completed, true);
});
void test(`${advanced ? 'Advanced Tech/Strongholds' : 'Basic Tech'} and explicit original Skills retain native Semuta/Nexus trade behavior`, () => {
const f = createSemutaNexusTradeFixture({ advanced, tech: true, strongholds: advanced, skills: true });
const pending = openTrade(f);
assert.ok(Object.values(f.afterFirstStorm.techTokens!).every(token => token.owner &&
f.afterFirstStorm.players.some(player => player.id === token.owner)), 'Original first Storm earns token assignment');
assert.equal(f.game.leaderSkills!.assignments.length, 4);
const completed = claimSemutaNexusTrade(pending, f);
assert.deepEqual(completed.leaderSkills, f.game.leaderSkills);
assert.deepEqual(completed.techTokens, f.game.techTokens);
assert.deepEqual(completed.strongholdCards, f.game.strongholdCards);
assertCompletedTrade(f, completed, true);
});
}
void test('neutral passes retain the public cost and paid original receipt until the final acknowledgement', () => {
const f = createSemutaNexusTradeFixture(), pending = openTrade(f);
let game = pending;
const event = pending.pendingTreacheryDiscard!.batch.event;
for (const actor of [f.trader, ...f.allies]) {
game = applyAction(game, actor, { type: 'semutaPass', event });
assert.equal(game.nexusChoamTrades![0].stage, 'discard');
assert.equal(game.pendingTreacheryDiscard!.reaction!.stage, 'offer');
assert.equal(game.discard.filter(card => card.id === f.cost.id).length, 1);
assert.equal(semutaNexusTradePlayer(game, f.trader).spice, semutaNexusTradePlayer(f.game, f.trader).spice + 2);
}
assert.throws(() => applyAction(game, f.trader, { type: 'ready' }), /discard|Semuta|reaction/i);
game = applyAction(game, f.holder, { type: 'semutaPass', event });
assertCompletedTrade(f, game, false);
});
for (const difficulty of DIFFICULTIES) {
void test(`${difficulty} uses the original trade and exact Semuta decisions through the existing bot API`, () => {
const f = createSemutaNexusTradeFixture({ fullHand: true });
const tradeView = viewGame(f.game, f.trader);
tradeView.players.find(p => p.id === f.trader)!.bot = difficulty;
let game = applyAction(f.game, f.trader, botActions(tradeView)[0]);
const event = game.pendingTreacheryDiscard!.batch.event;
for (const actor of [f.trader, ...f.allies]) {
const view = viewGame(game, actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
const action = botActions(view)[0];
assert.deepEqual(action, { type: 'semutaPass', event });
game = applyAction(game, actor, action);
}
const own = viewGame(game, f.holder);
own.players.find(p => p.id === f.holder)!.bot = difficulty;
const commit = botActions(own)[0];
assert.deepEqual(commit, { type: 'semutaCommit', event });
game = applyAction(game, f.holder, commit);
assertCompletedTrade(f, game, true);
});
}