238 lines
12 KiB
TypeScript
238 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
|
|
viewGame, type Action, type Game, type Player,
|
|
} from '../game/engine';
|
|
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, gameTerritories, splitLocation } from '../game/board';
|
|
import { spiceDeck, treacheryDeck, type Card } from '../game/cards';
|
|
import { traitorDeck } from '../game/traitors';
|
|
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState, type DiscoveryTokenFace } from '../game/discoveries';
|
|
import { nextSkillsTechBattleStep, openSkillsTechBattle } from './fixture-skills-tech-battle';
|
|
import { putDiscovery } from './fixture-discovery';
|
|
|
|
export type NativeTypedOptions = { advanced?: boolean; tech?: boolean; advisors?: boolean };
|
|
export type NativeTypedRevealPosition = { game: Game; owner: string; token: string };
|
|
export const nativeTypedReload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
|
export function nativeTypedPlayer(game: Game, id: string): Player {
|
|
const player = game.players.find(p => p.id === id);
|
|
assert.ok(player);
|
|
return player;
|
|
}
|
|
export function nativeTypedStep(game: Game): Game {
|
|
const next = game.decision?.kind === 'discoveryEntry'
|
|
? { actor: game.decision.player, action: { type: 'decision', accept: false, event: game.decision.event } }
|
|
: game.decision?.kind === 'ixSubstitution'
|
|
? { actor: game.decision.player, action: { type: 'decision', decline: true } }
|
|
: nextSkillsTechBattleStep(game);
|
|
return applyAction(game, next.actor, next.action);
|
|
}
|
|
/** Original controls only: do not assign phases, turns, wallets or pending frames. */
|
|
export function nativeTypedPhase(state: Game, phase: number, turn = state.turn): Game {
|
|
let game = state;
|
|
for (let step = 0; step < 1200 && (game.turn !== turn || game.phase !== phase ||
|
|
game.phaseOpening || game.response || game.decision); step++) {
|
|
assert.equal(game.status, 'playing');
|
|
assert.ok(game.turn <= turn);
|
|
game = nativeTypedStep(game);
|
|
}
|
|
assert.equal(game.turn, turn);
|
|
assert.equal(game.phase, phase);
|
|
assert.ok(!game.phaseOpening && !game.response && !game.decision);
|
|
return game;
|
|
}
|
|
export function nativeTypedCustody(game: Game): void {
|
|
assert.deepEqual([...game.deck, ...game.discard, ...(game.ixSetupCards ?? []),
|
|
...game.players.flatMap(p => p.hand)].map(c => c.id).sort(),
|
|
treacheryDeck(['ix']).map(c => c.id).sort());
|
|
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(p => [
|
|
...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(c => c.leader),
|
|
])].sort(), traitorDeck(game.players, true).sort());
|
|
for (const p of game.players) {
|
|
const board = Object.values(p.forces).reduce((sum, n) => sum + n, 0);
|
|
assert.equal(p.reserves + p.tanks + board, 20);
|
|
assert.ok(p.reserves >= (p.elites?.reserves ?? 0));
|
|
assert.ok(p.tanks >= (p.elites?.tanks ?? 0));
|
|
for (const [key, total] of Object.entries(p.forces)) {
|
|
assert.ok(Number.isSafeInteger(total) && total >= 0);
|
|
assert.ok(total >= (p.elites?.forces[key] ?? 0));
|
|
}
|
|
if (p.elites) {
|
|
assert.equal(p.elites.reserves + p.elites.tanks +
|
|
Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
|
p.faction === 'ixians' ? 7 : p.faction === 'fremen' ? 3 : 5);
|
|
for (const [key, elite] of Object.entries(p.elites.forces))
|
|
assert.ok(Number.isSafeInteger(elite) && elite >= 0 && elite <= (p.forces[key] ?? 0));
|
|
}
|
|
}
|
|
validateDiscoveryState(game.discoveries!);
|
|
}
|
|
/** Labelled conserved position: physical reserves -> board, including typed subsets. */
|
|
export function nativeTypedPlace(game: Game, id: string, key: string, normal: number, elite = 0): void {
|
|
const player = nativeTypedPlayer(game, id);
|
|
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= normal);
|
|
assert.ok((player.elites?.reserves ?? 0) >= elite);
|
|
player.reserves -= normal + elite;
|
|
player.forces[key] = (player.forces[key] ?? 0) + normal + elite;
|
|
if (elite) {
|
|
player.elites!.reserves -= elite;
|
|
player.elites!.forces[key] = (player.elites!.forces[key] ?? 0) + elite;
|
|
}
|
|
}
|
|
/** Keep the actual native HMS garrison; return other original board pieces to reserves. */
|
|
export function nativeTypedClearBoard(game: Game): void {
|
|
for (const p of game.players) {
|
|
for (const [key, total] of Object.entries(p.forces)) {
|
|
if (key === MOBILE_LOCATION) continue;
|
|
p.reserves += total;
|
|
delete p.forces[key];
|
|
if (p.elites) {
|
|
p.elites.reserves += p.elites.forces[key] ?? 0;
|
|
delete p.elites.forces[key];
|
|
}
|
|
}
|
|
if (p.advisors) p.advisors = {};
|
|
}
|
|
}
|
|
export function nativeTypedSetup(options: NativeTypedOptions = {}) {
|
|
let lobby = createGame('DISCOVERYNATIVETYPED', newPlayer('i', 'Ixians', 'ixians'),
|
|
options.advanced ?? false, ['ix']);
|
|
for (const [id, faction] of [['t', 'tleilaxu'], ['f', 'fremen'], ['g', 'guild']] as const)
|
|
joinGame(lobby, newPlayer(id, faction, faction));
|
|
if (options.advisors) joinGame(lobby, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
|
if (options.tech) lobby = applyAction(lobby, lobby.host, { type: 'techTokens', enabled: true });
|
|
for (const p of lobby.players) lobby = applyAction(lobby, p.id, { type: 'ready' });
|
|
lobby.discoveryEnabled = true;
|
|
let game = initializeDiscoveryGameForAudit(lobby);
|
|
const initialized = nativeTypedReload(game);
|
|
assert.equal(game.deck.length, 47);
|
|
assert.equal(game.spiceDeck.length, spiceDeck(true).length + DISCOVERY_SPICE_CARDS.length + 1);
|
|
assert.equal(game.spiceDeck.filter(c => 'sandtrout' in c && c.sandtrout).length, 1);
|
|
assert.equal(game.discoveries!.tokens.length, 8);
|
|
for (let step = 0; step < 250 && game.status === 'setup'; step++) game = nativeTypedStep(game);
|
|
assert.equal(game.status, 'playing');
|
|
assert.equal(nativeTypedPlayer(game, 'i').forces[MOBILE_LOCATION], 6);
|
|
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces[MOBILE_LOCATION], 3);
|
|
assert.equal(nativeTypedPlayer(game, 't').faceDancers!.length, 3);
|
|
// Conserved unplayed deck order keeps the first two turns free of unrelated worms/Discoveries.
|
|
const blows = game.spiceDeck.filter(c => 'territory' in c && !c.discovery);
|
|
assert.ok(blows.length >= 4);
|
|
for (const [position, card] of blows.slice(0, 4).entries()) {
|
|
const index = game.spiceDeck.indexOf(card);
|
|
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
|
}
|
|
nativeTypedCustody(game);
|
|
return { lobby, initialized, game };
|
|
}
|
|
/** Original setup/phase controls, then explicitly labelled supply-token placement
|
|
* and reserve-counter position. Collection inspect/reveal is always the real engine producer. */
|
|
export function nativeTypedCollection(face: DiscoveryTokenFace = 'shrine', owner = 'i',
|
|
options: NativeTypedOptions = {}) {
|
|
const setup = nativeTypedSetup(options);
|
|
let game = nativeTypedPhase(setup.game, 5);
|
|
nativeTypedClearBoard(game);
|
|
const token = putDiscovery(game, face);
|
|
const source = `${token.territory}:${token.sector}`;
|
|
assert.notEqual(token.sector, game.storm);
|
|
nativeTypedPlace(game, owner, source, 2, owner === 'i' || (owner === 'f' && game.advanced) ? 1 : 0);
|
|
game = nativeTypedPhase(game, 7);
|
|
if (viewGame(game, owner).discoveries!.canInspect.includes(token.id))
|
|
game = applyAction(game, owner, { type: 'discovery', token: token.id, reveal: false });
|
|
assert.ok(viewGame(game, owner).discoveries!.canReveal.includes(token.id));
|
|
nativeTypedCustody(game);
|
|
return { ...setup, game, token: token.id, source, parent: token.territory!, owner };
|
|
}
|
|
export function nativeTypedReveal(state: NativeTypedRevealPosition): Game {
|
|
return applyAction(state.game, state.owner, { type: 'discovery', token: state.token, reveal: true });
|
|
}
|
|
export function nativeTypedEntry(options: NativeTypedOptions = {}, owner = 'i') {
|
|
const position = nativeTypedCollection('shrine', owner, options);
|
|
let game = nativeTypedReveal(position);
|
|
game = nativeTypedPhase(game, 8);
|
|
for (let step = 0; step < 100 && game.decision?.kind !== 'discoveryEntry'; step++) game = nativeTypedStep(game);
|
|
assert.equal(game.turn, 2);
|
|
assert.equal(game.phase, 0);
|
|
assert.equal(game.decision?.kind, 'discoveryEntry');
|
|
assert.equal(game.decision?.player, owner);
|
|
nativeTypedCustody(game);
|
|
return { ...position, game };
|
|
}
|
|
export function nativeTypedCard(game: Game, id: string, predicate: (card: Card) => boolean): Card {
|
|
const index = game.deck.findIndex(predicate);
|
|
assert.ok(index >= 0);
|
|
const card = game.deck.splice(index, 1)[0];
|
|
nativeTypedPlayer(game, id).hand.push(card);
|
|
return card;
|
|
}
|
|
/** Exchange a genuine private Traitor identity with one genuine native Face Dancer. */
|
|
export function nativeTypedMatchDancer(game: Game, leader: string): void {
|
|
const dancers = nativeTypedPlayer(game, 't').faceDancers!;
|
|
if (dancers.some(c => c.leader === leader && !c.revealed)) return;
|
|
const dancer = dancers.find(c => !c.revealed)!;
|
|
assert.ok(dancer);
|
|
const reserve = game.traitorReserve!.indexOf(leader);
|
|
if (reserve >= 0) game.traitorReserve![reserve] = dancer.leader;
|
|
else {
|
|
const holder = game.players.find(p => p.traitors.includes(leader));
|
|
assert.ok(holder);
|
|
holder.traitors[holder.traitors.indexOf(leader)] = dancer.leader;
|
|
}
|
|
dancer.leader = leader;
|
|
}
|
|
/** Native nested-location battle. Winner cleanup and original Tech rewards are
|
|
* executed before the original Face Dance choice; no reward/receipt is staged. */
|
|
export function nativeTypedFaceDance(advanced = false, tech = false) {
|
|
const entry = nativeTypedEntry({ advanced, tech });
|
|
assert.ok(entry.game.decision?.kind === 'discoveryEntry');
|
|
let game = applyAction(entry.game, 'i', { type: 'decision', accept: false, event: entry.game.decision!.event });
|
|
game = nativeTypedPhase(game, 5);
|
|
nativeTypedClearBoard(game);
|
|
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
|
|
nativeTypedPlace(game, 'g', 'shrine:0', 8);
|
|
nativeTypedPlace(game, 't', 'shrine:0', 8);
|
|
nativeTypedPlace(game, 't', entry.source, 3);
|
|
// A second conserved conflict keeps Battle open after this reward/Face Dance.
|
|
const other = gameTerritories(game).find(t => t.type === 'sand' &&
|
|
t.id !== splitLocation(entry.source).territory && !t.sectors.includes(game.storm));
|
|
assert.ok(other);
|
|
for (const id of ['g', 't']) nativeTypedPlace(game, id, `${other.id}:${other.sectors[0]}`, 1);
|
|
const winnerLeader = nativeTypedPlayer(game, 'g').leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
|
|
const loserLeader = nativeTypedPlayer(game, 't').leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
|
|
nativeTypedMatchDancer(game, winnerLeader.id);
|
|
const weapon = nativeTypedCard(game, 'g', c => c.kind === 'projectile');
|
|
const defense = nativeTypedCard(game, 'g', c => c.kind === 'shield');
|
|
const loserCard = nativeTypedCard(game, 't', c => c.kind === 'worthless');
|
|
game = nativeTypedPhase(game, 6);
|
|
game = openSkillsTechBattle(game, 'g', 't', 'shrine');
|
|
const beforePlans = nativeTypedReload(game);
|
|
game = applyAction(game, 'g', { type: 'battlePlan', leader: winnerLeader.id, dial: 0, support: 0,
|
|
weapon: weapon.id, defense: defense.id });
|
|
game = applyAction(game, 't', { type: 'battlePlan', leader: loserLeader.id, dial: 0, support: 0, weapon: loserCard.id });
|
|
let cleanup: Game | undefined;
|
|
for (let step = 0; step < 200 && game.decision?.kind !== 'faceDance'; step++) {
|
|
if (game.decision?.kind === 'battleCards') {
|
|
cleanup = nativeTypedReload(game);
|
|
game = applyAction(game, 'g', { type: 'decision', discard: [defense.id] });
|
|
} else game = nativeTypedStep(game);
|
|
}
|
|
assert.ok(cleanup);
|
|
assert.equal(game.decision?.kind, 'faceDance');
|
|
assert.equal(game.decision?.territory, 'shrine');
|
|
assert.equal(game.decision?.player, 't');
|
|
nativeTypedCustody(game);
|
|
return { game, beforePlans, cleanup, winnerLeader: winnerLeader.id, loserLeader: loserLeader.id,
|
|
weapon, defense, loserCard, source: entry.source, action: {
|
|
type: 'decision', reveal: true, sources: { reserves: 1, [entry.source]: 2 }, sector: 0,
|
|
} as Action };
|
|
}
|
|
export function nativeTypedMovementTurn(state: Game, owner: string): Game {
|
|
let game = state;
|
|
for (let step = 0; step < 100 && (game.active !== owner || game.decision || game.response || game.phaseOpening); step++) {
|
|
assert.equal(game.phase, 5);
|
|
game = nativeTypedStep(game);
|
|
}
|
|
assert.equal(game.active, owner);
|
|
assert.equal(game.phase, 5);
|
|
return game;
|
|
}
|
|
export { MOBILE_LOCATION, MOBILE_STRONGHOLD };
|