135 lines
7.9 KiB
TypeScript
135 lines
7.9 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, initializeSemutaGameForAudit, viewGame, type Game } from '../game/engine';
|
|
import { TERRITORIES } from '../game/board';
|
|
import { presenceAt } from '../game/force-presence';
|
|
import { createControlledSemutaShrine, semutaShrineLobby, shrinePlayer, startShrineTruth } from './fixture-semuta-shrine';
|
|
import { reactSemutaSpecial } from './fixture-semuta-special-karama';
|
|
import { settleSemutaBgNative } from './fixture-semuta-bg-karama';
|
|
|
|
for (const advanced of [false, true]) {
|
|
void test(`Shrine ${advanced ? 'Advanced' : 'Basic'} recovered Truthtrance retains one original bank-paid shipment`, () => {
|
|
for (const claim of [false, true]) {
|
|
const f = createControlledSemutaShrine(advanced);
|
|
const original = shrinePlayer(f.game, f.owner).hand.find(c => c.id === f.truth)!;
|
|
let game = applyAction(f.game, f.owner, { type: 'card', card: f.truth, mode: 'shipment', target: 'g' });
|
|
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:shipment');
|
|
assert.equal(game.karamaShipping, null);
|
|
game = reactSemutaSpecial(game, f.holder, claim);
|
|
assert.equal(game.karamaShipping?.owner, f.owner);
|
|
assert.equal(!!game.karamaShipping?.costClaimed, claim);
|
|
assert.deepEqual(claim ? shrinePlayer(game, f.holder).hand.find(c => c.id === f.truth) : game.discard.find(c => c.id === f.truth), original);
|
|
assert.equal(game.pendingKarama ?? null, null, 'Shrine use is not the BG counterable conversion');
|
|
const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm) &&
|
|
game.players.every(p => presenceAt(p, t.id) === 0))!;
|
|
const sector = destination.sectors.find(s => s !== game.storm)!;
|
|
const before = structuredClone(shrinePlayer(game, 'g'));
|
|
game = applyAction(game, 'g', { type: 'ship', territory: destination.id, sector, amount: 3, elite: 0, allyPayment: 0 });
|
|
game = settleSemutaBgNative(game);
|
|
assert.equal(shrinePlayer(game, 'g').spice, before.spice - 3);
|
|
assert.equal(shrinePlayer(game, 'g').reserves, before.reserves - 3);
|
|
assert.equal(shrinePlayer(game, 'g').forces[`${destination.id}:${sector}`], 3);
|
|
assert.equal(game.karamaShipping, null);
|
|
}
|
|
});
|
|
|
|
void test(`Shrine ${advanced ? 'Advanced' : 'Basic'} physical Karama is fresh after its committed answer`, () => {
|
|
for (const claim of [false, true]) {
|
|
const f = createControlledSemutaShrine(advanced);
|
|
let game = startShrineTruth(f.game, f.karama);
|
|
assert.equal(game.truthHistory!.at(-1)?.answer, 'yes');
|
|
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'truthtrance');
|
|
const history = structuredClone(game.truthHistory);
|
|
game = reactSemutaSpecial(game, f.holder, claim);
|
|
assert.deepEqual(game.truthHistory, history);
|
|
assert.equal(game.truthtrance, null);
|
|
const physical = claim ? shrinePlayer(game, f.holder).hand.find(c => c.id === f.karama) : game.discard.find(c => c.id === f.karama);
|
|
assert.equal(physical?.effect, 'karama');
|
|
assert.equal(physical?.name, 'Karama');
|
|
assert.equal(game.pendingTreacheryDiscard, null);
|
|
}
|
|
});
|
|
}
|
|
|
|
void test('Shrine special Emperor recovery never repeats already-completed revival', () => {
|
|
for (const claim of [false, true]) {
|
|
const f = createControlledSemutaShrine();
|
|
f.game.phase = 4; // CONTROLLED Revival clock and two conserved Tank counters.
|
|
const owner = shrinePlayer(f.game, f.owner);
|
|
owner.reserves -= 2; owner.tanks += 2;
|
|
const before = owner.reserves;
|
|
let game = applyAction(f.game, f.owner, { type: 'card', card: f.truth, mode: 'special', amount: 2, elite: 0 });
|
|
assert.equal(shrinePlayer(game, f.owner).reserves, before + 2);
|
|
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'specialKarama:emperorForces');
|
|
game = reactSemutaSpecial(game, f.holder, claim);
|
|
assert.equal(shrinePlayer(game, f.owner).reserves, before + 2);
|
|
assert.equal(shrinePlayer(game, f.owner).tanks, 0);
|
|
assert.equal(shrinePlayer(game, f.owner).specialKaramaUsed, true);
|
|
}
|
|
});
|
|
|
|
void test('Shrine purchase recovery preserves the physical auction lot and spends no spice', () => {
|
|
for (const mode of ['purchase', 'auctionPayment'] as const) for (const claim of [false, true]) {
|
|
const f = createControlledSemutaShrine();
|
|
// CONTROLLED auction phase/lot, conserving the original physical deck.
|
|
const game = f.game; game.phase = 3;
|
|
game.richeseBidding = null;
|
|
const lot = game.deck.shift()!;
|
|
game.auction = { cards: [lot], index: 0, bid: mode === 'purchase' ? 0 : 2,
|
|
bidder: mode === 'purchase' ? null : f.owner, active: f.owner, passed: [], opener: 0 };
|
|
if (mode === 'auctionPayment') game.decision = { kind: 'auctionPayment', player: f.owner };
|
|
const spice = shrinePlayer(game, f.owner).spice;
|
|
let done = applyAction(game, f.owner, mode === 'purchase'
|
|
? { type: 'card', mode, card: f.truth }
|
|
: { type: 'decision', karama: true, card: f.truth });
|
|
assert.equal(done.pendingTreacheryDiscard?.batch.cause, `karama:${mode}`);
|
|
assert.equal(shrinePlayer(done, f.owner).hand.some(c => c.id === lot.id), false);
|
|
done = reactSemutaSpecial(done, f.holder, claim);
|
|
assert.equal(shrinePlayer(done, f.owner).hand.filter(c => c.id === lot.id).length, 1);
|
|
assert.equal(shrinePlayer(done, f.owner).spice, spice);
|
|
assert.equal(shrinePlayer(done, f.holder).hand.some(c => c.id === f.truth), claim);
|
|
}
|
|
});
|
|
|
|
void test('Shrine cancellation recovery still denies the original unpaid Guild discount', () => {
|
|
for (const claim of [false, true]) {
|
|
const f = createControlledSemutaShrine();
|
|
const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== f.game.storm) &&
|
|
f.game.players.every(p => presenceAt(p, t.id) === 0))!;
|
|
const sector = destination.sectors.find(s => s !== f.game.storm)!;
|
|
const before = structuredClone(shrinePlayer(f.game, 'g'));
|
|
let game = applyAction(f.game, 'g', { type: 'ship', territory: destination.id, sector, amount: 2, elite: 0, allyPayment: 0 });
|
|
if (game.decision?.kind === 'guildShipment')
|
|
game = applyAction(game, 'g', { type: 'decision', allow: true });
|
|
assert.equal(game.response?.kind, 'guildRate');
|
|
game = applyAction(game, f.owner, { type: 'card', card: f.truth, mode: 'cancel' });
|
|
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:guildRate');
|
|
assert.equal(shrinePlayer(game, 'g').spice, before.spice);
|
|
game = settleSemutaBgNative(reactSemutaSpecial(game, f.holder, claim));
|
|
assert.equal(shrinePlayer(game, 'g').spice, before.spice - 4);
|
|
assert.equal(shrinePlayer(game, 'g').reserves, before.reserves - 2);
|
|
assert.equal(shrinePlayer(game, 'g').forces[`${destination.id}:${sector}`], 2);
|
|
assert.equal(shrinePlayer(game, f.holder).hand.some(c => c.id === f.truth), claim);
|
|
}
|
|
});
|
|
|
|
void test('fresh Discovery setup retains original modules and rejects unsupported Semuta overlays', () => {
|
|
for (const advanced of [false, true]) {
|
|
let lobby = semutaShrineLobby(advanced);
|
|
lobby = applyAction(lobby, lobby.host, { type: 'techTokens', enabled: true });
|
|
if (advanced) lobby = applyAction(lobby, lobby.host, { type: 'strongholdCards', enabled: true });
|
|
for (const player of lobby.players) if (!player.ready) lobby = applyAction(lobby, player.id, { type: 'ready' });
|
|
const setup = initializeSemutaGameForAudit(lobby);
|
|
assert.equal(setup.discoveries!.tokens.length, 8);
|
|
assert.ok(setup.techTokens);
|
|
assert.equal(!!setup.strongholdCards, advanced);
|
|
assert.equal(viewGame(setup, 'r').status, 'setup');
|
|
const alterations: ((g: Game) => void)[] = [g => { g.homeworlds = { custody: null }; },
|
|
g => { g.nexusCards = { cards: null, phase: null }; }];
|
|
for (const alter of alterations) {
|
|
const invalid = structuredClone(lobby); alter(invalid);
|
|
assert.throws(() => initializeSemutaGameForAudit(invalid), /Semuta Discovery/);
|
|
}
|
|
}
|
|
});
|