233 lines
14 KiB
TypeScript
233 lines
14 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
|
import { choamPowerPlays } from '../game/choam-power-options';
|
|
import {
|
|
allowSemutaNexusCunning, gamePlayer, reactSemutaNexusCunning,
|
|
semutaNexusCunningInventory,
|
|
} from './fixture-semuta-nexus-cunning';
|
|
import {
|
|
cancelSemutaNexusCunningReactive, createSemutaNexusCunningReactive,
|
|
quoteSemutaNexusCunningReactive, semutaNexusCunningReactiveBoard,
|
|
type SemutaNexusCunningReactiveEffect, type SemutaNexusCunningReactiveFixture,
|
|
} from './fixture-semuta-nexus-cunning-reactive';
|
|
|
|
const holds = (game: Game, id: string, card: string) =>
|
|
gamePlayer(game, id).hand.some(candidate => candidate.id === card);
|
|
const nexusSpent = (game: Game, owner: string) =>
|
|
game.nexusCards?.cards?.discard.filter(id => id === 'choam').length === 1 &&
|
|
game.nexusCards?.cards?.hands[owner] === null;
|
|
/** Declare the original private Cunning quote and resolve its ordinary response. */
|
|
function declareAndAllow(f: SemutaNexusCunningReactiveFixture) {
|
|
const request = quoteSemutaNexusCunningReactive(f.game, f);
|
|
const declared = applyAction(f.game, f.owner, request);
|
|
return { request, declared, offered: allowSemutaNexusCunning(declared) };
|
|
}
|
|
function assertPrivateFuel(f: SemutaNexusCunningReactiveFixture, declared: Game, request: Action) {
|
|
assert.equal(f.cost.kind, 'projectile');
|
|
const pending = declared.pendingChoamWorthless;
|
|
assert.equal(declared.response?.kind, 'choamWorthless');
|
|
assert.equal(pending?.card, f.cost.id);
|
|
assert.equal(pending?.nexusEvent, request.nexus);
|
|
assert.equal(pending?.owner, f.owner);
|
|
assert.equal(nexusSpent(declared, f.owner), true);
|
|
// The other seats see neither the private cost list nor the chosen fuel.
|
|
assert.equal(viewGame(declared, f.target).choamWorthless, null);
|
|
assert.equal(viewGame(declared, f.other).choamWorthless, null);
|
|
assert.equal(JSON.stringify(viewGame(declared, f.target)).includes(f.cost.id), false);
|
|
assert.equal(viewGame(declared, f.holder).semutaReaction, null);
|
|
}
|
|
|
|
void test('reactive Baliset Cunning keeps canonical fuel private until Commit and stops the declared move', () => {
|
|
const f = createSemutaNexusCunningReactive('baliset', { karama: true });
|
|
const mover = gamePlayer(f.game, f.target);
|
|
const source = Object.keys(mover.forces)[0];
|
|
assert.equal(mover.forces[source], 2);
|
|
assert.equal(mover.moved, 0);
|
|
const plays = choamPowerPlays(viewGame(f.game, f.owner), 'baliset');
|
|
const nexus = plays.find(play => play.source === 'nexus' && play.card.id === f.cost.id);
|
|
assert.ok(nexus && !nexus.blocked);
|
|
assert.equal(nexus.card.kind, 'projectile');
|
|
assert.equal(plays.some(play => play.source === 'printed'), false);
|
|
const { request, declared, offered } = declareAndAllow(f);
|
|
assertPrivateFuel(f, declared, request);
|
|
assert.equal(declared.pendingChoamWorthless?.movement, true);
|
|
assert.equal(offered.response, null);
|
|
assert.equal(offered.decision, null);
|
|
assert.equal(offered.pendingChoamMove, null);
|
|
assert.equal(offered.nexusChoamLast?.stage, 'complete');
|
|
assert.equal(offered.pendingTreacheryDiscard?.batch.entries.length, 1);
|
|
assert.equal(offered.pendingTreacheryDiscard?.batch.entries[0].card.id, f.cost.id);
|
|
assert.equal(offered.pendingTreacheryDiscard?.batch.entries[0].publicFace, false);
|
|
assert.equal(offered.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
// The private cost face remains unavailable before commitment.
|
|
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
|
assert.deepEqual(viewGame(offered, f.holder).semutaReaction?.candidates, []);
|
|
const cards = semutaNexusCunningInventory(offered);
|
|
const claimed = reactSemutaNexusCunning(offered, f.start, true);
|
|
const passed = reactSemutaNexusCunning(offered, f.start, false);
|
|
for (const settled of [claimed, passed]) {
|
|
assert.equal(settled.pendingTreacheryDiscard, null);
|
|
assert.equal(settled.pendingChoamMove, null);
|
|
assert.equal(settled.nexusChoamLast?.stage, 'complete');
|
|
assert.deepEqual(settled.choamBaliset, [{ turn: settled.turn, player: f.target, territory: f.territory }]);
|
|
assert.equal(viewGame(settled, f.target).balisetRestrictions.length, 1);
|
|
assert.deepEqual(semutaNexusCunningInventory(settled), cards);
|
|
// The stopped movement spends no force, movement allowance or spice.
|
|
assert.deepEqual(semutaNexusCunningReactiveBoard(settled), semutaNexusCunningReactiveBoard(f.game));
|
|
assert.equal(gamePlayer(settled, f.target).forces[source], 2);
|
|
assert.equal(gamePlayer(settled, f.target).moved, 0);
|
|
assert.equal(nexusSpent(settled, f.owner), true);
|
|
assert.equal(holds(settled, f.owner, f.cost.id), false);
|
|
assert.throws(() => applyAction(settled, f.target, f.parent), /Baliset prevents/);
|
|
assert.throws(() => reactSemutaNexusCunning(settled, f.start, true));
|
|
}
|
|
// The single foreign claim takes the actual discarded fuel; the decline does not.
|
|
assert.equal(holds(claimed, f.holder, f.cost.id), true);
|
|
assert.equal(claimed.discard.some(card => card.id === f.semuta), true);
|
|
assert.equal(holds(claimed, f.holder, f.semuta), false);
|
|
assert.equal(passed.discard.some(card => card.id === f.cost.id), true);
|
|
assert.equal(holds(passed, f.holder, f.semuta), true);
|
|
});
|
|
|
|
void test('reactive La La La Cunning stops the free revival and leaves tanks, allowances and payment untouched', () => {
|
|
const f = createSemutaNexusCunningReactive('laLaLa', { karama: true });
|
|
const target = gamePlayer(f.game, f.target);
|
|
assert.equal(target.tanks, 3);
|
|
assert.equal(target.reserves, 17);
|
|
assert.ok(f.game.pendingRevival);
|
|
const { request, declared, offered } = declareAndAllow(f);
|
|
assertPrivateFuel(f, declared, request);
|
|
assert.equal(declared.pendingChoamWorthless?.revival, true);
|
|
assert.equal(declared.pendingChoamWorthless?.target, f.target);
|
|
assert.equal(offered.nexusChoamLast?.stage, 'complete');
|
|
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
|
assert.deepEqual(viewGame(offered, f.holder).semutaReaction?.candidates, []);
|
|
const cards = semutaNexusCunningInventory(offered);
|
|
const claimed = reactSemutaNexusCunning(offered, f.start, true);
|
|
const passed = reactSemutaNexusCunning(offered, f.start, false);
|
|
for (const settled of [claimed, passed]) {
|
|
assert.equal(settled.pendingTreacheryDiscard, null);
|
|
assert.equal(settled.pendingRevival, null);
|
|
assert.equal(settled.decision, null);
|
|
assert.equal(settled.nexusChoamLast?.stage, 'complete');
|
|
assert.deepEqual(semutaNexusCunningReactiveBoard(settled), semutaNexusCunningReactiveBoard(f.game));
|
|
assert.equal(viewGame(settled, f.target).revivalRules?.freeBlocked?.includes(f.target), true);
|
|
assert.deepEqual(semutaNexusCunningInventory(settled), cards);
|
|
assert.equal(nexusSpent(settled, f.owner), true);
|
|
assert.throws(() => reactSemutaNexusCunning(settled, f.start, true));
|
|
// The original parent continues once: the ordinary paid revival still works.
|
|
const paid = applyAction(settled, f.target, { type: 'revive', amount: 2 });
|
|
assert.equal(paid.response, null);
|
|
assert.equal(gamePlayer(paid, f.target).spice, target.spice - 4);
|
|
assert.equal(gamePlayer(paid, f.target).tanks, 1);
|
|
assert.equal(gamePlayer(paid, f.target).reserves, 19);
|
|
assert.equal(gamePlayer(paid, f.target).freeForcesRevived, 0);
|
|
}
|
|
assert.equal(holds(claimed, f.holder, f.cost.id), true);
|
|
assert.equal(claimed.discard.some(card => card.id === f.semuta), true);
|
|
assert.equal(passed.discard.some(card => card.id === f.cost.id), true);
|
|
});
|
|
|
|
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced card' : 'Basic dialed'} reactive Jubba Cunning protects forces in one crossed territory without protecting spice`, () => {
|
|
const f = createSemutaNexusCunningReactive('jubba', { advanced, karama: true });
|
|
const protectedKey = f.crossed.find(entry => entry.territory === f.territory)!.key;
|
|
const otherKey = f.crossed.find(entry => entry.territory === f.otherTerritory)!.key;
|
|
const owner = gamePlayer(f.game, f.owner);
|
|
const target = gamePlayer(f.game, f.target);
|
|
assert.equal(owner.forces[protectedKey], 3);
|
|
assert.equal(owner.forces[otherKey], 3);
|
|
assert.equal(target.forces[protectedKey], 2);
|
|
assert.equal(target.forces[otherKey], 2);
|
|
assert.equal(f.game.stormPending, f.distance);
|
|
const { request, declared, offered } = declareAndAllow(f);
|
|
assertPrivateFuel(f, declared, request);
|
|
assert.equal(declared.pendingChoamWorthless?.storm, true);
|
|
assert.equal(declared.pendingChoamWorthless?.location, f.territory);
|
|
assert.equal(offered.nexusChoamLast?.stage, 'complete');
|
|
assert.ok(offered.stormResolution, 'The real storm stays untraversed behind its cost offer');
|
|
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
|
assert.deepEqual(viewGame(offered, f.holder).semutaReaction?.candidates, []);
|
|
const cards = semutaNexusCunningInventory(offered);
|
|
const claimed = reactSemutaNexusCunning(offered, f.start, true);
|
|
const passed = reactSemutaNexusCunning(offered, f.start, false);
|
|
const arrived = ((f.before.storm + f.distance - 1) % 18) + 1;
|
|
for (const settled of [claimed, passed]) {
|
|
assert.equal(settled.pendingTreacheryDiscard, null);
|
|
assert.equal(settled.nexusChoamLast?.stage, 'complete');
|
|
// The one protected territory is recorded and the real storm pauses for the
|
|
// remaining original CHOAM choice rather than resolving early.
|
|
assert.equal(settled.stormResolution?.choamProtected?.includes(f.territory!), true);
|
|
const remaining = settled.decision?.kind === 'choamStorm'
|
|
? settled.decision.territories.map(entry => entry.territory) : [];
|
|
assert.deepEqual(remaining, [f.otherTerritory]);
|
|
const continued = settled.decision?.kind === 'choamStorm'
|
|
? applyAction(settled, f.owner, { type: 'decision', decline: true })
|
|
: settled;
|
|
assert.equal(continued.stormResolution, null);
|
|
assert.equal(continued.storm, arrived);
|
|
assert.equal(gamePlayer(continued, f.owner).forces[protectedKey], 3);
|
|
assert.equal(gamePlayer(continued, f.owner).forces[otherKey], undefined);
|
|
assert.equal(gamePlayer(continued, f.owner).tanks, 3);
|
|
assert.equal(gamePlayer(continued, f.target).forces[protectedKey], undefined);
|
|
assert.equal(gamePlayer(continued, f.target).forces[otherKey], undefined);
|
|
assert.equal(gamePlayer(continued, f.target).tanks, 4);
|
|
assert.equal(continued.spice[protectedKey], undefined, 'Jubba protects CHOAM forces, not spice under the storm');
|
|
assert.equal(continued.spice[otherKey], undefined);
|
|
assert.deepEqual(semutaNexusCunningInventory(continued), cards);
|
|
assert.equal(nexusSpent(continued, f.owner), true);
|
|
assert.throws(() => reactSemutaNexusCunning(settled, f.start, true));
|
|
}
|
|
assert.equal(holds(claimed, f.holder, f.cost.id), true);
|
|
assert.equal(claimed.discard.some(card => card.id === f.semuta), true);
|
|
assert.equal(passed.discard.some(card => card.id === f.cost.id), true);
|
|
});
|
|
|
|
void test('canceling reactive Cunning retains the canonical fuel and resumes its original parent without offering that fuel', () => {
|
|
for (const effect of ['baliset', 'laLaLa', 'jubba'] as SemutaNexusCunningReactiveEffect[]) {
|
|
const f = createSemutaNexusCunningReactive(effect, { karama: true });
|
|
const request = quoteSemutaNexusCunningReactive(f.game, f);
|
|
const declared = applyAction(f.game, f.owner, request);
|
|
assert.equal(declared.pendingChoamWorthless?.nexusEvent, request.nexus);
|
|
let canceled = cancelSemutaNexusCunningReactive(declared, f);
|
|
for (let n = 0; canceled.pendingTreacheryDiscard && n < 30; n++) {
|
|
assert.ok(!canceled.pendingTreacheryDiscard.batch.entries.some(entry => entry.card.id === f.cost.id));
|
|
canceled = reactSemutaNexusCunning(canceled, f.start, false);
|
|
}
|
|
assert.equal(canceled.response, null);
|
|
assert.equal(canceled.pendingChoamWorthless, null);
|
|
assert.equal(canceled.nexusChoamLast?.stage, 'canceled');
|
|
assert.equal(canceled.choamWorthlessBlocked?.cards.includes(f.cost.id), true);
|
|
assert.equal(holds(canceled, f.owner, f.cost.id), true);
|
|
assert.equal(canceled.discard.some(card => card.id === f.cost.id), false);
|
|
assert.equal(nexusSpent(canceled, f.owner), true);
|
|
assert.equal(choamPowerPlays(viewGame(canceled, f.owner), effect).some(play => play.source === 'nexus'), false);
|
|
if (effect === 'baliset') {
|
|
const moved = gamePlayer(canceled, f.target);
|
|
assert.equal(moved.moved, 1);
|
|
assert.equal(moved.forces[f.destination!], 1);
|
|
assert.equal(moved.forces[f.origin!], 1);
|
|
assert.equal(canceled.pendingChoamMove, null);
|
|
} else if (effect === 'laLaLa') {
|
|
const revived = gamePlayer(canceled, f.target);
|
|
assert.equal(canceled.pendingRevival, null);
|
|
assert.equal(revived.tanks, 1);
|
|
assert.equal(revived.reserves, 19);
|
|
assert.equal(revived.revived, 2);
|
|
assert.equal(revived.spice, gamePlayer(f.game, f.target).spice);
|
|
} else {
|
|
const storm = canceled.decision?.kind === 'choamStorm' ? canceled.decision : null;
|
|
assert.ok(storm, 'The prevented Jubba still leaves the original storm choice');
|
|
const traversed = applyAction(canceled, f.owner, { type: 'decision', decline: true });
|
|
assert.equal(traversed.stormResolution, null);
|
|
const protectedKey = f.crossed.find(entry => entry.territory === f.territory)!.key;
|
|
const otherKey = f.crossed.find(entry => entry.territory === f.otherTerritory)!.key;
|
|
assert.equal(gamePlayer(traversed, f.owner).forces[protectedKey], undefined);
|
|
assert.equal(gamePlayer(traversed, f.owner).forces[otherKey], undefined);
|
|
assert.equal(gamePlayer(traversed, f.owner).tanks, 6);
|
|
assert.equal(traversed.spice[protectedKey], undefined);
|
|
assert.equal(traversed.spice[otherKey], undefined);
|
|
}
|
|
}
|
|
});
|