Files
Dune/tests/fixture-discovery-native-typed.ts

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 };