Offer Semuta on printed Emperor-share cancellation and repair stale unit tests
This commit is contained in:
1 parent
63e5f22607
commit
308797567e
12 files changed
+319
-28
No files matched your search
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { baseDeck, spiceDeck } from '../game/cards';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
|
||||
export type SemutaEmperorShareKind = 'emperorGift' | 'emperorRevival';
|
||||
export type SemutaEmperorShareFixture = {
|
||||
game: Game;
|
||||
owner: string;
|
||||
holder: string;
|
||||
recipient: string;
|
||||
karama: string;
|
||||
karamaCard: { id: string; name: string; kind: string; effect?: string };
|
||||
semuta: string;
|
||||
kind: SemutaEmperorShareKind;
|
||||
declare: Action;
|
||||
cancel: Action;
|
||||
};
|
||||
export function semutaEmperorPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(seat => seat.id === id);
|
||||
assert.ok(player, `Missing original ${id} seat`);
|
||||
return player;
|
||||
}
|
||||
/** Locate one existing physical card identity across the original zones. */
|
||||
export function semutaEmperorCardId(game: Game, match: (card: { id: string; effect?: string; kind?: string }) => boolean): string {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache ?? [],
|
||||
game.richeseRemoved ?? [], ...game.players.map(p => p.hand)]) {
|
||||
const card = zone.find(candidate => match(candidate));
|
||||
if (card) return card.id;
|
||||
}
|
||||
throw new Error('The requested original physical card is unavailable');
|
||||
}
|
||||
/** Move one existing physical card between zones; never duplicate a face. */
|
||||
export function moveSemutaEmperorCard(game: Game, id: string, to: string): string {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache ?? [],
|
||||
game.richeseRemoved ?? [], ...game.players.map(p => p.hand)]) {
|
||||
const index = zone.findIndex(card => card.id === id);
|
||||
if (index < 0) continue;
|
||||
const card = zone.splice(index, 1)[0];
|
||||
semutaEmperorPlayer(game, to).hand.push(card);
|
||||
return card.id;
|
||||
}
|
||||
throw new Error(`Missing original physical ${id}`);
|
||||
}
|
||||
/** Genuine three-seat Richese/Emperor/Atreides Semuta setup; only the clock,
|
||||
* phase, alliance, active seat and later held cards are conserved. */
|
||||
export function createSemutaEmperorShareFixture(kind: SemutaEmperorShareKind): SemutaEmperorShareFixture {
|
||||
let game = createGame('SEMUTAEMPEROR', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
for (const player of game.players) player.ready = true;
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
game.status = 'playing';
|
||||
game.phase = kind === 'emperorRevival' ? 4 : 2;
|
||||
game.turn = 2;
|
||||
game.storm = 18;
|
||||
game.active = 'r';
|
||||
game.order = ['r', 'e', 'a'];
|
||||
game.ready = [];
|
||||
game.decision = null;
|
||||
game.response = null;
|
||||
game.phaseOpening = null;
|
||||
game.deck = baseDeck();
|
||||
game.spiceDeck = spiceDeck();
|
||||
const owner = semutaEmperorPlayer(game, 'e'), holder = semutaEmperorPlayer(game, 'r');
|
||||
const recipient = semutaEmperorPlayer(game, 'a');
|
||||
for (const player of game.players) { player.hand = []; player.spice = 20; player.forces = {}; }
|
||||
owner.ally = recipient.id;
|
||||
recipient.ally = owner.id;
|
||||
recipient.tanks = 3;
|
||||
const karamaId = moveSemutaEmperorCard(game, semutaEmperorCardId(game, card => card.effect === 'karama'), recipient.id);
|
||||
const semuta = moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, holder.id);
|
||||
const karamaCard = recipient.hand.find(card => card.id === karamaId)!;
|
||||
const declare: Action = kind === 'emperorGift'
|
||||
? { type: 'emperorGift', amount: 3 }
|
||||
: { type: 'emperorRevival', amount: 2 };
|
||||
return {
|
||||
game, owner: owner.id, holder: holder.id, recipient: recipient.id,
|
||||
karama: karamaId, karamaCard, semuta, kind, declare,
|
||||
cancel: { type: 'card', mode: 'cancel', card: karamaId },
|
||||
};
|
||||
}
|
||||
/** Resolve the neutral offer: a real commit (singleton completes directly) or all passes. */
|
||||
export function reactSemutaEmperorShare(
|
||||
state: Game, fixture: SemutaEmperorShareFixture, claim: boolean,
|
||||
): Game {
|
||||
const event = state.pendingTreacheryDiscard!.batch.event;
|
||||
if (claim) return applyAction(state, fixture.holder, { type: 'semutaCommit', event });
|
||||
let game = state;
|
||||
for (const player of state.players)
|
||||
game = applyAction(game, player.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
export function semutaEmperorSharePhysical(game: Game) {
|
||||
return game.players.map(player => ({
|
||||
id: player.id, faction: player.faction, spice: player.spice, forces: player.forces,
|
||||
reserves: player.reserves, tanks: player.tanks, elites: player.elites,
|
||||
moved: player.moved, revived: player.revived,
|
||||
}));
|
||||
}
|
||||
export function semutaEmperorShareViews(game: Game): void {
|
||||
for (const player of game.players) viewGame(game, player.id);
|
||||
}
|
||||
Reference in new issue
Block a user