Compose Semuta recovery with physical Shrine card roles
This commit is contained in:
1 parent
f587374923
commit
5902286db5
13 files changed
+360
-29
No files matched your search
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
|
||||
import { moveSemutaEmperorCard, semutaEmperorCardId } from './fixture-semuta-emperor-share';
|
||||
|
||||
export const shrinePlayer = (game: Game, id: string) => game.players.find(p => p.id === id)!;
|
||||
export function semutaShrineLobby(advanced = true) {
|
||||
let game = createGame('SEMUTASHRINE', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
||||
game.discoveryEnabled = true;
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Original ready lobby/setup and first Movement; then CONTROLLED physical
|
||||
* Shrine placement/occupation and conserved hand acquisition. Not an original
|
||||
* Discovery draw/entry journey or full-game acceptance. */
|
||||
export function createControlledSemutaShrine(advanced = true) {
|
||||
let game = initializeSemutaGameForAudit(semutaShrineLobby(advanced));
|
||||
for (const position of [0, 1]) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
for (let n = 0; n < 600; n++) {
|
||||
if (game.phase === 5 && game.active === 'g' && !game.phaseOpening && !game.response && !game.decision) break;
|
||||
const step = nextSemutaGuildIncomeNativeStep(game);
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.phase, 5);
|
||||
assert.ok(!game.response && !game.decision && !game.phaseOpening);
|
||||
for (const player of game.players) game.deck.push(...player.hand.splice(0));
|
||||
const shrine = game.discoveries!.tokens.find(token => token.face === 'shrine')!;
|
||||
Object.assign(shrine, { status: 'placed', territory: 'gara_kulon', sector: 8, revealedTurn: game.turn });
|
||||
shrinePlayer(game, 'e').reserves--;
|
||||
shrinePlayer(game, 'e').forces['shrine:0'] = 1;
|
||||
const truth = semutaEmperorCardId(game, card => card.effect === 'truthtrance');
|
||||
const karama = semutaEmperorCardId(game, card => card.effect === 'karama');
|
||||
moveSemutaEmperorCard(game, truth, 'e');
|
||||
moveSemutaEmperorCard(game, karama, 'e');
|
||||
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
|
||||
return { game, truth, karama, holder: 'r', owner: 'e',
|
||||
provenance: 'CONTROLLED Shrine placement/occupation and conserved hand acquisition after genuine ready/setup/first Movement.' };
|
||||
}
|
||||
|
||||
export function startShrineTruth(game: Game, karama: string): Game {
|
||||
game = applyAction(game, 'e', { type: 'card', card: karama, shrineTruthtrance: [karama] });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id))!;
|
||||
game = applyAction(game, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
const ask: Action = { type: 'truthAsk', question: { kind: 'fact', target: 'r',
|
||||
fact: { kind: 'hand', name: 'Semuta Drug' } } };
|
||||
game = applyAction(game, 'e', ask);
|
||||
return applyAction(game, 'r', { type: 'truthAnswer', answer: 'yes' });
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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/);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user