Files
Dune/tests/fixture-discovery-native-e1-skills.ts
Quintine 9ca68c9ff8
Verify and publish checkpoint container / container (push) Successful in 10m37s
Enable paired Ecaz and Moritani Discovery preview
2026-10-29 20:05:34 +11:00

281 lines
17 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, joinGame, newPlayer, viewGame, type Game,
} from '../game/engine';
import { gameDistance, gameTerritories, splitLocation, territory } from '../game/board';
import { type Card } from '../game/cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { type LeaderSkillId } from '../game/leader-skill-cards';
import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills';
import { nextSkillsTechBattleStep, openSkillsTechBattle, type SkillsTechBattleStep } from './fixture-skills-tech-battle';
import { nativeTypedClearBoard, nativeTypedCustody, nativeTypedPlace, nativeTypedPlayer } from './fixture-discovery-native-typed';
import { putDiscovery } from './fixture-discovery';
export type DiscoveryNativeE1SkillsOptions = {
/** Original authenticated lobby or original undealt skill setup. Never convert a played save. */
initial?: Game;
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
skill?: LeaderSkillId;
};
export type DiscoveryNativeE1SkillsStep = SkillsTechBattleStep;
export type DiscoveryNativeE1SkillsEntry = {
initial: Game;
offered: Game;
afterSetup: Game;
afterFirstStorm: Game;
beforeFirstMentat: Game;
firstMentatStep: DiscoveryNativeE1SkillsStep;
game: Game;
owner: string;
opponent: string;
guild: string;
fremen: string;
trainer: string;
skill: LeaderSkillId;
token: string;
source: string;
actions: DiscoveryNativeE1SkillsStep[];
staging: string[];
};
export type DiscoveryNativeE1SkillsBattle = DiscoveryNativeE1SkillsEntry & {
beforeBattle: Game;
plans: DiscoveryNativeE1SkillsStep[];
weapon: string;
defense: string;
losingCard: string;
losingLeader: string;
location: 'shrine:0';
replacementSource: string;
};
export const reloadDiscoveryNativeE1Skills = (game: Game): Game => JSON.parse(JSON.stringify(game));
export const discoveryNativeE1SkillsPlayer = nativeTypedPlayer;
export function assertDiscoveryNativeE1SkillsCustody(game: Game): void {
nativeTypedCustody(game);
validateLeaderSkills(game.leaderSkills!, game.players);
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Original controls, declining optional entry/substitution/Face Dance. Human consumers stop before them. */
export function nextDiscoveryNativeE1SkillsStep(game: Game): DiscoveryNativeE1SkillsStep {
const d = game.decision;
if (d?.kind === 'discoveryEntry') return { actor: d.player,
action: { type: 'decision', event: d.event, accept: false } };
if (d?.kind === 'ixSubstitution') return { actor: d.player, action: { type: 'decision', decline: true } };
if (d?.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } };
if (d?.kind === 'sukRescue') {
const maximum = Math.max(...d.options.map(o => o.normal + o.elite));
const choice = d.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal');
return { actor: d.player, action: { type: 'decision', event: d.event,
choice: choice >= 0 ? choice : d.options.findIndex(o => o.normal + o.elite === maximum) } };
}
if (d?.kind === 'battleLosses') return { actor: d.player, action: { type: 'decision',
choice: d.options.findIndex(o => o.elite === Math.max(...d.options.map(c => c.elite))) } };
if (game.status === 'playing' && game.phase === 2 && game.charityOrder?.remaining[0] &&
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard)
return { actor: game.charityOrder.remaining[0], action: { type: 'ready' } };
return nextSkillsTechBattleStep(game);
}
export function stepDiscoveryNativeE1Skills(game: Game, step = nextDiscoveryNativeE1SkillsStep(game)): Game {
return applyAction(game, step.actor, step.action);
}
export function advanceDiscoveryNativeE1Skills(state: Game, until: (game: Game) => boolean,
actions?: DiscoveryNativeE1SkillsStep[]): Game {
let game = state;
for (let i = 0; i < 1800; i++) {
if (until(game)) return game;
assert.equal(game.status, 'playing', 'Original programme must retain a live game');
const step = nextDiscoveryNativeE1SkillsStep(game);
actions?.push(structuredClone(step));
game = stepDiscoveryNativeE1Skills(game, step);
}
throw Error('Original E1 Discovery/Skills programme did not reach its requested control');
}
export function phaseDiscoveryNativeE1Skills(state: Game, phase: number): Game {
const turn = state.turn;
return advanceDiscoveryNativeE1Skills(state, game => {
assert.equal(game.turn, turn, 'Phase continuation cannot invent a later turn');
return game.phase === phase && clean(game);
});
}
export function settleDiscoveryNativeE1Skills(state: Game): Game {
return advanceDiscoveryNativeE1Skills(state, game => clean(game) && !game.pendingShipment && !game.pendingTreacheryDiscard);
}
function held(game: Game, actor: string, predicate: (card: Card) => boolean): string {
const player = nativeTypedPlayer(game, actor);
const owned = player.hand.find(predicate);
if (owned) return owned.id;
const index = game.deck.findIndex(predicate);
assert.ok(index >= 0 && player.hand.length < 4, 'Canonical unplayed card and native hand capacity required');
const card = game.deck.splice(index, 1)[0]; player.hand.push(card);
return card.id;
}
function matchingDancer(game: Game, actor: string, leader: string): void {
const stock = nativeTypedPlayer(game, actor).faceDancers!;
if (stock.some(c => c.leader === leader && !c.revealed)) return;
const dancer = stock.find(c => !c.revealed); assert.ok(dancer);
const index = game.traitorReserve!.indexOf(leader);
if (index >= 0) game.traitorReserve![index] = dancer.leader;
else {
const donor = game.players.find(p => p.traitors.includes(leader)); assert.ok(donor);
donor.traitors[donor.traitors.indexOf(leader)] = dancer.leader;
}
dancer.leader = leader;
}
/** Real setup → first Storm/Tech → Collection inspect/reveal → first END Mentat claim
* → first free typed entry. Conserved unplayed lotteries/positions are labeled below.
* Explicit requested disciplines require their genuine offer; omitted discipline
* on a supplied original setup selects an actually available offered card. */
export function createDiscoveryNativeE1SkillsEntry(options: DiscoveryNativeE1SkillsOptions = {}): DiscoveryNativeE1SkillsEntry {
const requestedSkill = options.skill ?? 'suk-graduate';
let game = options.initial ? structuredClone(options.initial)
: createGame('DISCOVERYE1SKILLS', newPlayer('ix', 'Ixians', 'ixians'), options.advanced ?? false, ['ix']);
if (!options.initial) for (const [id, faction] of [['tl', 'tleilaxu'], ['gu', 'guild'], ['fr', 'fremen']] as const)
joinGame(game, newPlayer(id, faction, faction));
assert.ok(game.status === 'lobby' || game.status === 'setup');
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const seat = (faction: string) => {
const player = game.players.find(p => p.faction === faction); assert.ok(player, `Original ${faction} seat required`);
return player.id;
};
const owner = seat('ixians'), opponent = seat('tleilaxu'), guild = seat('guild'), fremen = seat('fremen');
if (game.status === 'setup') {
assert.ok(game.discoveryEnabled && game.leaderSkills && game.turn === 1 && game.phase === 0);
assert.ok(game.players.every(p => !p.hand.length && !p.traitors.length && !p.traitorChoices.length),
'Only the original undealt setup with unused Traitor selections may continue');
if (options.tech) assert.ok(game.techTokens);
if (options.strongholds) assert.ok(game.strongholdCards);
} else {
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (options.strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
game.discoveryEnabled = true;
}
const initial = structuredClone(game), actions: DiscoveryNativeE1SkillsStep[] = [];
game = initializeAdvancedNativeSkillsSetup({ initial: game, family: 'ixians', skillOwner: 'ixians',
requestedSkill, rules: game.advanced ? 'advanced' : 'basic' });
const offered = structuredClone(game);
for (let i = 0; game.status === 'setup' && i < 250; i++) {
let step: DiscoveryNativeE1SkillsStep;
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const selected = actor === owner && !(initial.status === 'setup' && options.skill === undefined)
? requestedSkill : offer.cards.find(c => !view.unavailableSkills?.[c]);
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
const leader = nativeTypedPlayer(game, actor).leaders.filter(l => eligible.has(l.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(selected && offer.cards.includes(selected) && !view.unavailableSkills?.[selected] && leader,
'The actual original offer and living eligible disc must supply this programme');
step = { actor, action: { type: 'leaderSkill', event: offer.event, skill: selected, leader: leader.id } };
} else step = nextDiscoveryNativeE1SkillsStep(game);
actions.push(structuredClone(step)); game = stepDiscoveryNativeE1Skills(game, step);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game), assignment = game.leaderSkills!.assignments.find(a => a.owner === owner)!;
const trainer = assignment.leader, skill = assignment.skill;
const blows = game.spiceDeck.filter(c => 'territory' in c && !c.discovery).slice(0, 6);
assert.equal(blows.length, 6);
for (const [index, card] of blows.entries()) game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
game = advanceDiscoveryNativeE1Skills(game, g => g.phase === 5 && clean(g), actions);
const afterFirstStorm = structuredClone(game);
nativeTypedClearBoard(game); // Retains the actual native HMS six-counter garrison.
const token = putDiscovery(game, 'shrine'), source = `${token.territory}:${token.sector}`;
assert.notEqual(token.sector, game.storm);
nativeTypedPlace(game, owner, source, 2, 1);
if (game.strongholdCards) nativeTypedPlace(game, owner, `arrakeen:${territory('arrakeen').sectors[0]}`, 1);
game = advanceDiscoveryNativeE1Skills(game, g => g.phase === 7 && clean(g), actions);
if (viewGame(game, owner).discoveries!.canInspect.includes(token.id)) {
const step: DiscoveryNativeE1SkillsStep = { actor: owner, action: { type: 'discovery', token: token.id, reveal: false } };
actions.push(step); game = stepDiscoveryNativeE1Skills(game, step);
}
assert.ok(viewGame(game, owner).discoveries!.canReveal.includes(token.id));
const reveal: DiscoveryNativeE1SkillsStep = { actor: owner, action: { type: 'discovery', token: token.id, reveal: true } };
actions.push(reveal); game = stepDiscoveryNativeE1Skills(game, reveal);
game = advanceDiscoveryNativeE1Skills(game, g => g.phase === 8 && clean(g), actions);
let beforeFirstMentat: Game | undefined, firstMentatStep: DiscoveryNativeE1SkillsStep | undefined;
while (game.turn === 1) {
const before = structuredClone(game), step = nextDiscoveryNativeE1SkillsStep(game);
actions.push(structuredClone(step)); game = stepDiscoveryNativeE1Skills(game, step);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
game = advanceDiscoveryNativeE1Skills(game, g => g.decision?.kind === 'discoveryEntry', actions);
assert.equal(game.decision?.player, owner);
assertDiscoveryNativeE1SkillsCustody(game);
return { initial, offered, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep,
game, owner, opponent, guild, fremen, trainer, skill, token: token.id, source, actions,
staging: ['Only the original first all14 shuffle is controlled; supplied original offers are unchanged.',
'Conserved first six unused ordinary Spice Blows moved within their original deck; no fake phase or wallet history.',
'Conserved original board → reserves except native HMS; two Suboids/one Cyborg placed at an original supply Shrine token.',
'Optional Arrakeen control is conserved before the real first END Mentat; its retained card is never granted directly.'] };
}
/** Human sequence: decline free entry; real turn-two Movement; physical nested
* armies/cards; choose Shrine battle; hide training; seal these original plans.
* The second original conflict keeps cleanup/reward/Face Dance in Battle. */
export function createDiscoveryNativeE1SkillsBattle(options: DiscoveryNativeE1SkillsOptions = {}): DiscoveryNativeE1SkillsBattle {
const entry = createDiscoveryNativeE1SkillsEntry(options);
assert.ok(entry.skill === 'suk-graduate' || entry.skill === 'planetologist',
'This battle consumer requires genuine Suk/Planetologist training; other actual offers retain the typed entry programme');
assert.ok(entry.game.decision?.kind === 'discoveryEntry');
let game = applyAction(entry.game, entry.owner, { type: 'decision', event: entry.game.decision.event, accept: false });
game = phaseDiscoveryNativeE1Skills(game, 5);
nativeTypedClearBoard(game);
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
const location = 'shrine:0';
nativeTypedPlace(game, entry.owner, location, 6, 2);
nativeTypedPlace(game, entry.opponent, location, 8);
const parent = gameTerritories(game).find(t => t.id === splitLocation(entry.source).territory)!;
const replacementSource = `${parent.id}:${parent.sectors.find(s => s !== game.storm)}`;
nativeTypedPlace(game, entry.opponent, replacementSource, 3);
const other = gameTerritories(game).find(t => t.type === 'sand' && t.id !== parent.id && !t.sectors.includes(game.storm));
assert.ok(other);
for (const actor of [entry.owner, entry.opponent]) nativeTypedPlace(game, actor, `${other.id}:${other.sectors[0]}`, 1);
matchingDancer(game, entry.opponent, entry.trainer);
const losingLeader = nativeTypedPlayer(game, entry.opponent).leaders.filter(l => !l.dead &&
!game.leaderSkills!.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(losingLeader);
const weapon = held(game, entry.owner, c => entry.skill === 'planetologist' ? c.id === 'ix-thumper' : c.kind === 'projectile');
const defense = held(game, entry.owner, c => c.kind === 'shield');
const losingCard = held(game, entry.opponent, c => c.kind === 'worthless');
game = phaseDiscoveryNativeE1Skills(game, 6);
const beforeBattle = structuredClone(game);
game = openSkillsTechBattle(game, entry.owner, entry.opponent, 'shrine');
assertDiscoveryNativeE1SkillsCustody(game);
const plans: DiscoveryNativeE1SkillsStep[] = [
{ actor: entry.owner, action: { type: 'battlePlan', leader: entry.trainer,
dial: game.advanced ? 5 : 6, support: game.advanced ? 1 : 0, weapon, defense } },
{ actor: entry.opponent, action: { type: 'battlePlan', leader: losingLeader.id, dial: 0, support: 0, weapon: losingCard } },
];
return { ...entry, game, beforeBattle, plans, weapon, defense, losingCard, losingLeader: losingLeader.id,
location, replacementSource, staging: [...entry.staging,
'Original turn-two Movement: conserve six Suboids/two Cyborgs at Shrine, eight opposing counters, three replacement sources and a real second conflict.',
'Canonical unplayed Treachery and private matching Face Dancer identities exchanged within original custody; not a natural lottery claim.'] };
}
export function revealDiscoveryNativeE1SkillsBattle(fixture: DiscoveryNativeE1SkillsBattle): Game {
let game = reloadDiscoveryNativeE1Skills(fixture.game);
for (const step of fixture.plans) {
game = stepDiscoveryNativeE1Skills(game, step);
if (!game.battle?.revealed) game = settleDiscoveryNativeE1Skills(game);
}
assert.ok(game.battle?.revealed);
return game;
}
export function finishDiscoveryNativeE1SkillsBattle(state: Game,
stop: 'rescue' | 'substitution' | 'cards' | 'faceDance' | 'complete' = 'complete'): Game {
const kinds = { rescue: 'sukRescue', substitution: 'ixSubstitution', cards: 'battleCards', faceDance: 'faceDance', complete: '' };
return advanceDiscoveryNativeE1Skills(state, game => game.decision?.kind === kinds[stop] ||
!game.battle && clean(game) && !game.pendingTreacheryDiscard);
}
export function movementDiscoveryNativeE1Skills(state: Game, owner: string): Game {
return advanceDiscoveryNativeE1Skills(phaseDiscoveryNativeE1Skills(state, 5), game => game.active === owner && clean(game));
}
export function discoveryNativeE1PlanetologistRoute(game: Game): { source: string; distance: 2 | 3 } {
const source = gameTerritories(game).filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => `${t.id}:${s}`))
.find(key => splitLocation(key).sector !== game.storm && gameDistance(game, key, 'shrine:0', k => splitLocation(k).sector === game.storm) === 3);
assert.ok(source, 'Original revealed Shrine must supply a storm-safe three-territory route');
return { source, distance: 3 };
}