211 lines
14 KiB
TypeScript
211 lines
14 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 { choamPowerPlays } from '../game/choam-power-options';
|
|
import {
|
|
allowSemutaNexusCunning, createSemutaNexusCunningProactive,
|
|
gamePlayer, nextSemutaNexusCunningStep, reactSemutaNexusCunning,
|
|
semutaNexusCunningInventory,
|
|
} from './fixture-semuta-nexus-cunning';
|
|
|
|
for (const advanced of [false, true]) for (const claim of [false, true]) {
|
|
void test(`${advanced ? 'Advanced' : 'Basic'} original Cunning Kulon ${claim ? 'claim' : 'pass'} preserves private projectile fuel and grants one native move bonus`, () => {
|
|
const f = createSemutaNexusCunningProactive('kulon', { advanced, fullHand: claim });
|
|
assert.equal(f.setup.status, 'setup');
|
|
assert.ok(Object.values(f.setup.nexusCards!.cards!.hands).every(card => card === null));
|
|
assert.equal(f.afterFirstStorm.turn, 1); assert.equal(f.afterFirstStorm.phase, 1);
|
|
assert.equal(f.drawBefore.turn, 2); assert.equal(f.drawBefore.phase, 1);
|
|
assert.equal(f.drawBefore.nexusCards!.phase!.occurred, true);
|
|
assert.equal(gamePlayer(f.drawBefore, f.target).ally, f.other);
|
|
assert.equal(gamePlayer(f.drawBefore, f.other).ally, f.target);
|
|
assert.equal(gamePlayer(f.drawBefore, f.owner).ally, null);
|
|
assert.deepEqual([...f.drawBefore.nexusCards!.phase!.eligible].sort(), [f.owner, f.holder].sort());
|
|
assert.equal(f.drawAfter.nexusCards!.phase!.stage, 'complete');
|
|
assert.ok(f.drawAfter.nexusCards!.cards!.hands[f.owner]);
|
|
assert.equal(f.drawAfter.nexusCards!.cards!.hands[f.holder], null);
|
|
assert.equal(f.drawAfter.nexusCards!.cards!.deck.length, 11);
|
|
assert.equal(f.game.active, f.owner); assert.equal(f.game.phase, 5);
|
|
assert.equal(f.cost.kind, 'projectile');
|
|
assert.throws(() => applyAction(f.game, f.owner, f.move!), /more than 1/);
|
|
const wallets = f.game.players.map(p => p.spice), handBefore = gamePlayer(f.game, f.holder).hand.length;
|
|
const declared = applyAction(f.game, f.owner, f.action);
|
|
assert.ok(declared.response, 'The original printed Karama may prevent Cunning');
|
|
assert.deepEqual(gamePlayer(declared, f.owner).hand.find(c => c.id === f.cost.id), f.cost);
|
|
assert.equal(declared.nexusCards!.cards!.hands[f.owner], null);
|
|
assert.equal(declared.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
|
|
assert.equal(declared.nexusChoamHistory!.at(-1)!.stage, 'pending');
|
|
const pending = allowSemutaNexusCunning(declared);
|
|
const fresh = pending.pendingTreacheryDiscard!;
|
|
assert.equal(fresh.continuation.kind, 'choamKulonDiscard');
|
|
assert.deepEqual(fresh.batch.entries, [{ card: f.cost, discardedBy: f.owner, publicFace: false }]);
|
|
assert.equal(fresh.reaction?.stage, 'offer');
|
|
assert.equal(pending.nexusChoamHistory!.length, 1);
|
|
assert.equal(pending.nexusChoamHistory![0].stage, 'complete', 'Allowance retires the spent Nexus before Semuta reacts');
|
|
assert.equal(pending.nexusChoamHistory![0].receipt.card, f.cost.id);
|
|
assert.equal(pending.nexusChoamHistory![0].receipt.effect, 'kulon');
|
|
assert.equal(pending.discard.filter(c => c.id === f.cost.id).length, 1);
|
|
assert.deepEqual(viewGame(pending, f.holder).semutaReaction!.candidates, [], 'No cost inspection before committing Semuta');
|
|
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true);
|
|
assert.throws(() => applyAction(pending, f.owner, f.move!));
|
|
const done = reactSemutaNexusCunning(pending, f, claim);
|
|
assert.equal(done.pendingTreacheryDiscard, null, 'The only foreign candidate completes directly at Commit');
|
|
assert.equal(done.resolvedTreacheryDiscardSequence, fresh.sequence);
|
|
assert.equal(done.active, f.owner); assert.equal(gamePlayer(done, f.owner).moved, 0);
|
|
assert.equal(gamePlayer(done, f.holder).hand.length, handBefore);
|
|
assert.equal(gamePlayer(done, f.holder).hand.some(c => c.id === f.semuta), !claim);
|
|
assert.equal(done.discard.filter(c => c.id === f.semuta).length, Number(claim));
|
|
assert.equal(done.discard.filter(c => c.id === f.cost.id).length, Number(!claim));
|
|
assert.deepEqual(gamePlayer(done, f.holder).hand.find(c => c.id === f.cost.id), claim ? f.cost : undefined);
|
|
assert.deepEqual(done.players.map(p => p.spice), wallets);
|
|
assert.equal(done.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
|
|
assert.equal(done.nexusChoamHistory!.length, 1);
|
|
assert.throws(() => applyAction(done, f.owner, f.action));
|
|
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: fresh.batch.event }));
|
|
const moved = applyAction(done, f.owner, f.move!);
|
|
assert.equal(gamePlayer(moved, f.owner).moved, 1);
|
|
assert.equal(gamePlayer(moved, f.owner).forces[f.from], 1);
|
|
assert.throws(() => applyAction(moved, f.owner, f.move!), /movement is not available/);
|
|
assert.deepEqual(semutaNexusCunningInventory(moved), f.cards);
|
|
if (claim) assert.equal(handBefore, handLimit(gamePlayer(f.game, f.holder)));
|
|
});
|
|
|
|
void test(`${advanced ? 'Advanced' : 'Basic'} original closing Cunning Gamont ${claim ? 'claim' : 'pass'} returns one ${advanced && claim ? 'elite' : 'normal'} force before its original owner closes Mentat`, () => {
|
|
const optional = advanced && claim;
|
|
const f = createSemutaNexusCunningProactive('gamont', {
|
|
advanced, elite: optional, fullHand: claim,
|
|
costKind: optional ? 'special' : 'snooper',
|
|
skills: optional, tech: optional, strongholds: optional,
|
|
});
|
|
const target = f.selection.target as string, beforeTarget = structuredClone(gamePlayer(f.game, target));
|
|
const wallets = f.game.players.map(p => p.spice), hands = gamePlayer(f.game, f.holder).hand.length;
|
|
assert.equal(f.cost.kind, optional ? 'special' : 'snooper');
|
|
if (optional) {
|
|
assert.equal(f.cost.effect, 'hajr');
|
|
assert.equal(f.game.leaderSkills!.assignments.length, 4);
|
|
assert.ok(Object.values(f.game.techTokens!).every(t => f.game.players.some(p => p.id === t.owner)));
|
|
assert.equal(f.game.strongholdCards!.claimedTurn, 1);
|
|
}
|
|
assert.equal(f.game.phase, 8);
|
|
assert.deepEqual(f.game.decision, { kind: 'choamMentat', player: f.owner });
|
|
assert.equal(viewGame(f.game, f.target).mentatVictoryPending, true);
|
|
const declared = applyAction(f.game, f.owner, f.action);
|
|
assert.equal(declared.nexusCards!.cards!.hands[f.owner], null);
|
|
assert.deepEqual(gamePlayer(declared, f.owner).hand.find(c => c.id === f.cost.id), f.cost);
|
|
const pending = allowSemutaNexusCunning(declared), fresh = pending.pendingTreacheryDiscard!;
|
|
assert.equal(fresh.continuation.kind, 'choamGamontDiscard');
|
|
assert.deepEqual(fresh.batch.entries, [{ card: f.cost, discardedBy: f.owner, publicFace: false }]);
|
|
assert.equal(gamePlayer(pending, target).forces[f.from], 1);
|
|
assert.equal(gamePlayer(pending, target).reserves, beforeTarget.reserves);
|
|
assert.equal(pending.status, 'playing'); assert.deepEqual(pending.winner, []);
|
|
assert.equal(pending.nexusChoamHistory![0].stage, 'complete');
|
|
assert.equal(pending.nexusChoamHistory![0].receipt.effect, 'gamont');
|
|
assert.throws(() => applyAction(pending, f.owner, { type: 'decision', done: true }));
|
|
if (optional) assert.equal(pending.strongholdCards!.claimedTurn, 1);
|
|
const done = reactSemutaNexusCunning(pending, f, claim), returned = gamePlayer(done, target);
|
|
assert.equal(done.pendingTreacheryDiscard, null);
|
|
assert.equal(done.resolvedTreacheryDiscardSequence, fresh.sequence);
|
|
assert.equal(returned.forces[f.from], undefined);
|
|
assert.equal(returned.reserves, beforeTarget.reserves + 1);
|
|
assert.equal(returned.tanks, beforeTarget.tanks);
|
|
assert.equal(returned.battleLosses, beforeTarget.battleLosses);
|
|
if (f.elite) {
|
|
assert.equal(returned.elites!.forces[f.from], undefined);
|
|
assert.equal(returned.elites!.reserves, beforeTarget.elites!.reserves + 1);
|
|
assert.equal(returned.elites!.tanks, beforeTarget.elites!.tanks);
|
|
}
|
|
assert.equal(done.status, 'playing'); assert.equal(done.turn, 2); assert.deepEqual(done.winner, []);
|
|
assert.deepEqual(done.decision, { kind: 'choamMentat', player: f.owner });
|
|
assert.equal(done.choamMentatPending, true);
|
|
assert.equal(gamePlayer(done, f.holder).hand.length, hands);
|
|
assert.deepEqual(gamePlayer(done, f.holder).hand.find(c => c.id === f.cost.id), claim ? f.cost : undefined);
|
|
assert.equal(done.discard.filter(c => c.id === f.cost.id).length, Number(!claim));
|
|
assert.equal(done.discard.filter(c => c.id === f.semuta).length, Number(claim));
|
|
assert.deepEqual(done.players.map(p => p.spice), wallets, 'Returning a force grants no spice or replayed Special fuel power');
|
|
assert.equal(done.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
|
|
assert.throws(() => applyAction(done, f.owner, f.action));
|
|
assert.throws(() => applyAction(done, f.target, { type: 'decision', done: true }));
|
|
if (optional) assert.equal(done.strongholdCards!.claimedTurn, 1);
|
|
const closed = applyAction(done, f.owner, { type: 'decision', done: true });
|
|
assert.equal(closed.turn, 3); assert.equal(closed.status, 'playing');
|
|
assert.equal(gamePlayer(closed, target).reserves, returned.reserves, 'Closing cannot repeat the return');
|
|
assert.equal(closed.nexusChoamHistory!.length, 1);
|
|
if (optional) {
|
|
assert.equal(closed.strongholdCards!.claimedTurn, 2);
|
|
assert.equal(closed.strongholdCards!.owners.arrakeen, null, 'The returned sole force cannot claim Arrakeen');
|
|
}
|
|
assert.deepEqual(semutaNexusCunningInventory(closed), f.cards);
|
|
});
|
|
}
|
|
|
|
for (const effect of ['kulon', 'gamont'] as const) void test(`Original printed Karama cancellation retains ${effect} fuel and spends Cunning without offering that fuel`, () => {
|
|
const f = createSemutaNexusCunningProactive(effect);
|
|
let game = applyAction(f.game, f.owner, f.action);
|
|
game = applyAction(game, f.other, { type: 'card', card: f.karama, mode: 'cancel' });
|
|
// A separately discarded printed Karama may have its own ordinary reaction.
|
|
for (let n = 0; (game.pendingTreacheryDiscard || game.response) && n < 30; n++) {
|
|
assert.ok(!game.pendingTreacheryDiscard?.batch.entries.some(e => e.card.id === f.cost.id));
|
|
const step = nextSemutaNexusCunningStep(game);
|
|
game = applyAction(game, step.actor, step.action);
|
|
}
|
|
assert.deepEqual(gamePlayer(game, f.owner).hand.find(c => c.id === f.cost.id), f.cost);
|
|
assert.equal(game.discard.some(c => c.id === f.cost.id), false);
|
|
assert.equal(gamePlayer(game, f.holder).hand.some(c => c.id === f.cost.id), false);
|
|
assert.equal(gamePlayer(game, f.holder).hand.some(c => c.id === f.semuta), true);
|
|
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
|
|
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
|
|
assert.equal(game.nexusChoamHistory!.length, 1); assert.equal(game.nexusChoamHistory![0].stage, 'canceled');
|
|
if (effect === 'kulon') {
|
|
assert.throws(() => applyAction(game, f.owner, f.move!), /more than 1/);
|
|
} else {
|
|
assert.equal(gamePlayer(game, f.target).forces[f.from], 1);
|
|
assert.deepEqual(game.decision, { kind: 'choamMentat', player: f.owner });
|
|
}
|
|
assert.throws(() => applyAction(game, f.owner, f.action));
|
|
assert.deepEqual(semutaNexusCunningInventory(game), f.cards);
|
|
});
|
|
|
|
void test('Rivals cannot activate native Cunning and the Semuta profile cannot spend its Nexus on Kull', () => {
|
|
const f = createSemutaNexusCunningProactive('kulon'), own = viewGame(f.game, f.owner);
|
|
assert.throws(() => applyAction(f.game, f.holder, f.action));
|
|
const kull = choamPowerPlays(own, 'kull').find(p => p.source === 'nexus')!;
|
|
assert.throws(() => applyAction(f.game, f.owner,
|
|
{ type: 'card', mode: 'choam', card: kull.card.id, nexus: kull.event, effect: 'kull' }));
|
|
assert.equal(f.game.nexusCards!.cards!.hands[f.owner], 'choam');
|
|
assert.deepEqual(gamePlayer(f.game, f.owner).hand.find(c => c.id === f.cost.id), f.cost);
|
|
assert.deepEqual(semutaNexusCunningInventory(f.game), f.cards);
|
|
});
|
|
|
|
void test('Minimal native Cunning and Semuta bot actions use the original cost and finish singleton commitment legally', () => {
|
|
const f = createSemutaNexusCunningProactive('kulon');
|
|
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
|
const own = viewGame(f.game, f.owner); own.players.find(p => p.id === f.owner)!.bot = difficulty;
|
|
const action = botActions(own)[0]; assert.ok(action);
|
|
assert.equal(action.card, f.cost.id); assert.equal(action.nexus, f.action.nexus); assert.equal(action.effect, 'kulon');
|
|
const pending = allowSemutaNexusCunning(applyAction(f.game, f.owner, action));
|
|
const richese = viewGame(pending, f.holder); richese.players.find(p => p.id === f.holder)!.bot = difficulty;
|
|
const commit = botActions(richese)[0]; assert.ok(commit); assert.equal(commit.type, 'semutaCommit');
|
|
const done = applyAction(pending, f.holder, commit);
|
|
assert.equal(done.pendingTreacheryDiscard, null);
|
|
assert.deepEqual(gamePlayer(done, f.holder).hand.find(c => c.id === f.cost.id), f.cost);
|
|
const moved = applyAction(done, f.owner, f.move!);
|
|
assert.equal(gamePlayer(moved, f.owner).forces[f.from], 1);
|
|
assert.equal(gamePlayer(moved, f.owner).moved, 1);
|
|
assert.deepEqual(semutaNexusCunningInventory(moved), f.cards);
|
|
}
|
|
let game: Game = allowSemutaNexusCunning(applyAction(f.game, f.owner, f.action));
|
|
for (const player of game.players) {
|
|
const own = viewGame(game, player.id); own.players.find(p => p.id === player.id)!.bot = 'Easy';
|
|
const action = player.id === f.holder
|
|
? { type: 'semutaPass', event: game.pendingTreacheryDiscard!.batch.event }
|
|
: botActions(own)[0];
|
|
assert.equal(action?.type, 'semutaPass');
|
|
game = applyAction(game, player.id, action!);
|
|
}
|
|
assert.equal(game.pendingTreacheryDiscard, null);
|
|
const moved = applyAction(game, f.owner, f.move!);
|
|
assert.equal(gamePlayer(moved, f.owner).forces[f.from], 1);
|
|
assert.equal(gamePlayer(moved, f.owner).moved, 1);
|
|
assert.deepEqual(semutaNexusCunningInventory(moved), f.cards);
|
|
});
|