156 lines
9.6 KiB
TypeScript
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);
|
|
});
|
|
}
|