Compose native Discovery skills and repair hidden trainer participation

This commit is contained in:
Quintine committed 2026-10-06 00:51:21 +11:00
1 parent d51142bf9a
commit 555afe4070
20 files changed
+2220 -27

No files matched your search

+3 -3
View File
@@ -52,9 +52,9 @@ void test('Discovery cannot redeal started components or silently remove another
for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
});
void test('classic Discovery Skills does not silently drop native families or unrelated modules and previews', () => {
const native = lobby(['ixians', 'guild', 'fremen'], ['ix']);
assert.throws(() => initializeLeaderSkillsGameForAudit(native), RuleError);
void test('Discovery Skills cannot silently drop unsupported native families or unrelated modules and previews', () => {
const unsupported = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']);
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
overlays[0].homeworlds = { custody: null };
overlays[1].nexusCards = { cards: null, phase: null };
@@ -0,0 +1,324 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { gameDistance, MOBILE_LOCATION, MOBILE_STRONGHOLD, splitLocation, territory as boardTerritory } from '../game/board';
import { FACTIONS } from '../game/catalog';
import { DISCOVERY_SPICE_CARDS } from '../game/discoveries';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { nativeTypedClearBoard, nativeTypedPlace } from './fixture-discovery-native-typed';
import { openSkillsTechBattle } from './fixture-skills-tech-battle';
import {
advanceDiscoveryNativeE1Skills as advance, assertDiscoveryNativeE1SkillsCustody as custody,
createDiscoveryNativeE1SkillsBattle as battle, createDiscoveryNativeE1SkillsEntry as entry,
discoveryNativeE1PlanetologistRoute, discoveryNativeE1SkillsPlayer as player,
finishDiscoveryNativeE1SkillsBattle as finish, movementDiscoveryNativeE1Skills as movement,
phaseDiscoveryNativeE1Skills as phase, reloadDiscoveryNativeE1Skills as reload,
revealDiscoveryNativeE1SkillsBattle as reveal, settleDiscoveryNativeE1Skills as settle,
stepDiscoveryNativeE1Skills as step,
} from './fixture-discovery-native-e1-skills';
function reject(game: Game, actor: string, action: Action): void {
const before = reload(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Invalid native selections cannot partially debit or move physical inventory');
}
function acceptEntry(game: Game, source: string): Action {
assert.ok(game.decision?.kind === 'discoveryEntry');
return { type: 'decision', event: game.decision.event, accept: true,
groups: [{ source, normal: 1, elite: 1 }] };
}
void test('original E1 all14 offers precede native Traitors and preserve first-Storm Tech and real first-Mentat retained cards', () => {
for (const advanced of [false, true]) for (const tech of [false, true]) {
const f = entry({ advanced, tech, strongholds: advanced });
const skills = f.offered.leaderSkills!;
assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
assert.ok(f.offered.players.every(p => !p.traitorChoices.length && !p.traitors.length));
assert.equal(player(f.offered, f.opponent).faceDancers, undefined);
assert.equal(f.offered.discoveries!.tokens.length, 8);
assert.deepEqual(f.offered.spiceDeck.flatMap(c => 'territory' in c && c.discovery ? [c.discovery] : []).sort(),
DISCOVERY_SPICE_CARDS.map(c => c.discovery).sort());
assert.equal(f.offered.spiceDeck.filter(c => 'sandtrout' in c && c.sandtrout).length, 1);
assert.equal(player(f.afterSetup, f.opponent).faceDancers!.length, 3);
assert.equal(player(f.afterSetup, f.owner).forces[MOBILE_LOCATION], 6);
assert.equal(player(f.afterSetup, f.owner).elites!.forces[MOBILE_LOCATION], 3);
for (const p of f.afterSetup.players) assert.equal(p.spice, FACTIONS.find(c => c.id === p.faction)!.spice);
if (tech) {
assert.ok(Object.values(f.initial.techTokens!).every(t => t.owner === null));
assert.equal(f.afterFirstStorm.techTokens!.heighliners.owner, f.owner);
assert.equal(f.afterFirstStorm.techTokens!.axlotl.owner, f.opponent);
assert.equal(f.afterFirstStorm.techTokens!.production.owner, f.fremen);
}
if (advanced) {
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
const claimed = step(f.beforeFirstMentat, f.firstMentatStep);
assert.equal(claimed.turn, 2);
assert.equal(claimed.strongholdCards!.owners.arrakeen, f.owner);
assert.equal(claimed.strongholdCards!.owners[MOBILE_STRONGHOLD], f.owner);
assert.equal(f.game.strongholdCards!.owners.sietch_tabr, null);
}
custody(f.afterSetup); custody(f.game);
}
});
void test('authenticated original lobby and original undealt setup retain actual IDs and authoritative offers', () => {
const initial = createGame('HUMANE1DISCOVERY', newPlayer('human-ix', 'Human Ix', 'ixians'), true, ['ix']);
for (const [id, faction] of [['human-tl', 'tleilaxu'], ['human-gu', 'guild'], ['human-fr', 'fremen']] as const)
joinGame(initial, newPlayer(id, faction, faction));
const before = reload(initial), f = entry({ initial, tech: true, strongholds: true });
assert.deepEqual(initial, before);
assert.deepEqual(f.game.players.map(p => p.id), before.players.map(p => p.id));
assert.equal(f.owner, 'human-ix');
const saved = reload(f.offered), resumed = entry({ initial: saved, skill: 'suk-graduate' });
assert.deepEqual(saved, f.offered);
assert.deepEqual(resumed.offered.leaderSkills, saved.leaderSkills, 'Never redeal an authenticated offer');
assert.equal(resumed.trainer, f.trainer);
custody(resumed.game);
let randomLobby = reload(initial);
randomLobby.discoveryEnabled = true;
randomLobby = applyAction(randomLobby, randomLobby.host, { type: 'techTokens', enabled: true });
randomLobby = applyAction(randomLobby, randomLobby.host, { type: 'strongholdCards', enabled: true });
for (const p of randomLobby.players) randomLobby = applyAction(randomLobby, p.id, { type: 'ready' });
const randomSetup = initializeLeaderSkillsGameForAudit(randomLobby), originalOffers = reload(randomSetup);
const actual = entry({ initial: randomSetup });
assert.deepEqual(randomSetup, originalOffers);
assert.deepEqual(actual.offered.leaderSkills, originalOffers.leaderSkills);
const entered = applyAction(actual.game, actual.owner, acceptEntry(actual.game, actual.source));
assert.equal(player(entered, actual.owner).forces['shrine:0'], 2);
assert.equal(player(entered, actual.owner).elites!.forces['shrine:0'], 1);
custody(entered);
});
void test('trained original Ix typed free entry moves selected counters once without consuming training, shipment, movement, wallets or Tech', () => {
for (const advanced of [false, true]) {
const f = entry({ advanced, tech: true, strongholds: advanced });
const before = player(f.game, f.owner), action = acceptEntry(f.game, f.source);
assert.deepEqual(viewGame(f.game, f.owner).discoveryEntry!.sources, [{ source: f.source, normal: 2, elite: 1 }]);
reject(f.game, f.opponent, action);
reject(f.game, f.owner, { ...action, groups: [{ source: f.source, normal: 1, elite: 2 }] });
const arrived = applyAction(reload(f.game), f.owner, action), own = player(arrived, f.owner);
assert.equal(own.forces['shrine:0'], 2);
assert.equal(own.elites!.forces['shrine:0'], 1);
assert.equal(own.forces[f.source], 1);
assert.equal(own.elites!.forces[f.source] ?? 0, 0);
assert.equal(own.spice, before.spice);
assert.equal(own.reserves, before.reserves);
assert.equal(own.moved, before.moved);
assert.equal(own.shipped, before.shipped);
assert.deepEqual(arrived.leaderSkills, f.game.leaderSkills);
assert.deepEqual(arrived.techTokens, f.game.techTokens);
assert.equal(arrived.discoveryEntry, undefined);
reject(arrived, f.owner, action);
custody(arrived);
}
});
void test('original paid typed shipment and Planetologist three-territory nested route retain HMS identity and accrue only native Heighliners', () => {
for (const advanced of [false, true]) {
const f = entry({ advanced, tech: true, strongholds: advanced, skill: 'planetologist' });
assert.ok(f.game.decision?.kind === 'discoveryEntry');
let game = applyAction(f.game, f.owner, { type: 'decision', event: f.game.decision.event, accept: false });
game = movement(game, f.owner);
nativeTypedClearBoard(game);
const route = discoveryNativeE1PlanetologistRoute(game);
nativeTypedPlace(game, f.owner, route.source, 3, 1);
assert.equal(gameDistance(game, route.source, 'shrine:0', key => splitLocation(key).sector === game.storm), 3);
const hms = player(game, f.owner).forces[MOBILE_LOCATION], beforeShip = reload(game);
game = settle(applyAction(game, f.owner, { type: 'ship', territory: 'shrine', sector: 0, amount: 2, elite: 1 }));
assert.equal(player(game, f.owner).spice, player(beforeShip, f.owner).spice - 2);
assert.equal(player(game, f.guild).spice, player(beforeShip, f.guild).spice + 2);
assert.equal(player(game, f.owner).reserves, player(beforeShip, f.owner).reserves - 2);
assert.equal(player(game, f.owner).elites!.reserves, player(beforeShip, f.owner).elites!.reserves - 1);
const moving = reload(game), move: Action = { type: 'move', territory: 'shrine', sector: 0,
forces: { [route.source]: 2 }, eliteForces: { [route.source]: 1 }, planetologist: 'range' };
reject(game, f.owner, { ...move, planetologist: undefined });
game = settle(applyAction(game, f.owner, move));
assert.equal(player(game, f.owner).forces['shrine:0'], 4);
assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 2);
assert.equal(player(game, f.owner).forces[route.source], 2);
assert.equal(player(game, f.owner).forces[MOBILE_LOCATION], hms);
assert.equal(player(game, f.owner).spice, player(moving, f.owner).spice);
assert.equal(player(game, f.owner).moved, 1);
assert.equal(game.techTokens!.heighliners.spice, 1);
const wallet = player(game, f.owner).spice;
game = advance(game, g => g.phase !== 5);
assert.equal(game.techTokens!.heighliners.spice, 0);
assert.equal(player(game, f.owner).spice, wallet + 1);
assert.deepEqual(game.leaderSkills, moving.leaderSkills);
custody(game);
}
});
void test('original trained Suk settles fixed physical rescue before only genuine Cyborg Tank losses can be substituted inside Shrine', () => {
for (const advanced of [false, true]) for (const tech of [false, true]) {
const f = battle({ advanced, tech, strongholds: advanced });
const revealed = reveal(f), pending = finish(revealed, 'rescue');
assert.ok(pending.decision?.kind === 'sukRescue');
const own = player(pending, f.owner);
assert.deepEqual(pending.pendingSukRescue!.losses, { normal: 4, elite: 2, paidNormal: 0, paidElite: advanced ? 1 : 0 });
assert.equal(own.tanks, 0);
assert.equal(own.forces[f.location], 8, 'Counters remain physically committed until the real rescue');
assert.equal(pending.pendingIxSubstitution, undefined);
assert.equal(own.spice, player(revealed, f.owner).spice - (advanced ? 1 : 0) +
player(revealed, f.opponent).leaders.find(l => l.id === f.losingLeader)!.strength,
'Retained Arrakeen cannot subsidize a nested site');
const choice = pending.decision.options.findIndex(o => o.normal === 2 && o.elite === 1 && o.kept?.kind === 'normal');
assert.ok(choice >= 0);
reject(pending, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.location]: 1 } });
const rescueAction: Action = { type: 'decision', event: pending.decision.event, choice };
const rescued = applyAction(reload(pending), f.owner, rescueAction), after = player(rescued, f.owner);
assert.equal(rescued.decision?.kind, 'ixSubstitution');
assert.equal(rescued.lastBattleContext!.sukRescue!.completed, true);
assert.equal(after.forces[f.location], 3);
assert.equal(after.elites!.forces[f.location] ?? 0, 0);
assert.equal(after.reserves, own.reserves + 2);
assert.equal(after.elites!.reserves, own.elites!.reserves + 1);
assert.equal(after.tanks, 3);
assert.equal(after.elites!.tanks, 1);
assert.deepEqual(rescued.pendingIxSubstitution!.losses, { [f.location]: 1 });
reject(rescued, f.owner, rescueAction);
reject(rescued, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.replacementSource]: 1 } });
reject(rescued, f.owner, { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 2 } });
const substituted = settle(applyAction(reload(rescued), f.owner, { type: 'decision',
sources: { [f.location]: 1 }, recover: { [f.location]: 1 } }));
assert.equal(player(substituted, f.owner).forces[f.location], 3);
assert.equal(player(substituted, f.owner).elites!.forces[f.location], 1);
assert.equal(player(substituted, f.owner).tanks, 3);
assert.equal(player(substituted, f.owner).elites!.tanks, 0);
assert.equal(player(substituted, f.owner).spice, after.spice);
assert.equal(substituted.lastBattleContext!.ixSubstitution!.completed, true);
custody(substituted);
}
});
void test('green original Ix Planetologist waits through native substitution then disposes exactly once before optional winner cleanup and Tech', () => {
for (const advanced of [false, true]) {
const f = battle({ advanced, tech: true, strongholds: advanced, skill: 'planetologist' });
const pending = finish(reveal(f), 'substitution');
assert.equal(pending.decision?.kind, 'ixSubstitution');
assert.ok(player(pending, f.owner).hand.some(c => c.id === f.weapon));
assert.ok(pending.pendingWinnerDiscards!.cards.includes(f.weapon));
assert.equal(pending.techTokens!.axlotl.owner, f.opponent);
const cards = finish(applyAction(reload(pending), f.owner, { type: 'decision', decline: true }), 'cards');
assert.equal(cards.decision?.kind, 'battleCards');
assert.equal(cards.discard.filter(c => c.id === f.weapon).length, 1);
assert.equal(player(cards, f.owner).hand.some(c => c.id === f.weapon), false);
assert.equal(cards.lastBattleContext!.winnerDiscards!.completed, true);
assert.equal(player(cards, f.owner).forces[f.location], 2);
assert.equal(player(cards, f.owner).tanks, 6);
reject(cards, f.owner, { type: 'decision', discard: [f.weapon] });
const face = finish(applyAction(cards, f.owner, { type: 'decision', discard: [f.defense] }), 'faceDance');
assert.equal(face.techTokens!.axlotl.owner, f.owner);
assert.equal(face.discard.filter(c => c.id === f.weapon).length, 1);
assert.equal(face.discard.filter(c => c.id === f.defense).length, 1);
assert.equal(face.discard.filter(c => c.id === f.losingCard).length, 1);
assert.equal(face.decision?.kind, 'faceDance');
custody(face);
}
});
void test('matching native Face Dance kills the original trainer after rescue/cards/Tech, returns training once and retains native replacement eligibility', () => {
for (const advanced of [false, true]) {
const f = battle({ advanced, tech: true, strongholds: advanced });
const cards = finish(reveal(f), 'cards');
assert.equal(cards.decision?.kind, 'battleCards');
const face = finish(applyAction(cards, f.owner, { type: 'decision', discard: [f.defense] }), 'faceDance');
assert.equal(face.lastBattleContext!.winner, f.owner);
assert.equal(face.techTokens!.axlotl.owner, f.owner);
assert.ok(player(face, f.owner).hand.some(c => c.id === f.weapon));
const own = player(face, f.owner), dancer = player(face, f.opponent);
const action: Action = { type: 'decision', reveal: true, sector: 0,
sources: { reserves: 1, [f.replacementSource]: 2 } };
reject(face, f.opponent, { ...action, sector: 1 });
const danced = applyAction(reload(face), f.opponent, action);
assert.equal(player(danced, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
assert.equal(danced.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
assert.equal(danced.leaderSkills!.deck.filter(s => s === f.skill).length, 1);
assert.equal(player(danced, f.owner).reserves, own.reserves + 3);
assert.equal(player(danced, f.owner).forces[f.location] ?? 0, 0);
assert.equal(player(danced, f.opponent).forces[f.location], 3);
assert.equal(player(danced, f.opponent).forces[f.replacementSource], 1);
assert.equal(player(danced, f.opponent).reserves, dancer.reserves - 1);
for (const p of face.players) assert.equal(player(danced, p.id).spice, p.spice, 'No second death bounty, subsidy or support debit');
assert.deepEqual(danced.techTokens, face.techTokens);
assert.deepEqual(player(danced, f.owner).hand, own.hand);
reject(danced, f.opponent, action);
const mentat = phase(danced, 8), stock = player(mentat, f.opponent).faceDancers!;
const revealedDancer = stock.find(c => c.leader === f.trainer); assert.ok(revealedDancer?.revealed);
reject(mentat, f.opponent, { type: 'replaceFaceDancer', leader: f.trainer });
const untouched = stock.find(c => !c.revealed); assert.ok(untouched);
const stockBefore = stock.map(c => ({ ...c }));
const replaced = settle(applyAction(mentat, f.opponent, { type: 'replaceFaceDancer', leader: untouched.leader }));
assert.equal(player(replaced, f.opponent).faceDancerReplacedTurn, replaced.turn);
assert.deepEqual(player(replaced, f.opponent).faceDancers!.filter(c => c.leader === f.trainer), [revealedDancer]);
for (const c of stockBefore.filter(c => c.leader !== untouched.leader))
assert.ok(player(replaced, f.opponent).faceDancers!.some(n => n.leader === c.leader && n.revealed === c.revealed));
reject(replaced, f.opponent, { type: 'replaceFaceDancer', leader: player(replaced, f.opponent).faceDancers!.find(c => !c.revealed)!.leader });
custody(replaced);
if (!advanced) {
const revival = advance(replaced, g => g.turn === 3 && g.phase === 4 && !g.phaseOpening && !g.response && !g.decision);
assert.equal(player(revival, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
reject(revival, f.opponent, { type: 'reviveForeignGhola', leader: f.trainer });
assert.equal(revival.leaderSkills!.assignments.some(a => a.leader === f.trainer), false,
'Basic native Tleilaxu cannot turn a matching dead trainer into a foreign skill carrier');
custody(revival);
}
}
});
void test('all four original minimal policies produce real typed entry and capped trained rescue outcomes rather than action-list echoes', () => {
const f = entry({ advanced: true, tech: true, strongholds: true });
const b = battle({ advanced: true, tech: true, strongholds: true }), pending = finish(reveal(b), 'rescue');
assert.ok(pending.decision?.kind === 'sukRescue');
assert.equal(DIFFICULTIES.length, 4);
for (const difficulty of DIFFICULTIES) {
const entryView = viewGame(f.game, f.owner); entryView.players.find(p => p.id === f.owner)!.bot = difficulty;
const entryAction = botActions(entryView)[0]; assert.ok(entryAction);
const entered = applyAction(reload(f.game), f.owner, entryAction), before = player(f.game, f.owner), own = player(entered, f.owner);
assert.equal(entered.discoveryEntry, undefined);
const arrived = own.forces['shrine:0'] ?? 0;
assert.equal((own.forces[f.source] ?? 0) + arrived, before.forces[f.source]);
assert.equal((own.elites!.forces[f.source] ?? 0) + (own.elites!.forces['shrine:0'] ?? 0), 1);
assert.equal(own.spice, before.spice);
assert.equal(own.moved, before.moved);
assert.equal(own.shipped, before.shipped);
const rescueView = viewGame(pending, b.owner); rescueView.players.find(p => p.id === b.owner)!.bot = difficulty;
const rescueAction = botActions(rescueView)[0]; assert.ok(rescueAction);
const rescued = applyAction(reload(pending), b.owner, rescueAction);
assert.equal(rescued.pendingSukRescue, null);
assert.equal(rescued.lastBattleContext!.sukRescue!.completed, true);
assert.equal(player(rescued, b.owner).tanks, 3, `${difficulty}: exactly three of six committed casualties are saved`);
assert.equal(player(rescued, b.owner).forces[b.location], 3, `${difficulty}: exactly one rescue is kept in the original nested sector`);
assert.equal(player(rescued, b.owner).reserves, player(pending, b.owner).reserves + 2);
assert.equal(player(rescued, b.owner).spice, player(pending, b.owner).spice);
const completed = finish(rescued);
assert.equal(completed.lastBattleContext!.winner, b.owner);
assert.equal(player(completed, b.opponent).tanks, 8);
assert.equal(completed.techTokens!.axlotl.owner, b.owner);
custody(entered); custody(completed);
}
});
void test('hidden native Smuggler policy seals a real legal trainer plan in Discovery without applying full-state mode rules to a private player view', () => {
const f = entry({ advanced: true, tech: true, strongholds: true, skill: 'smuggler' });
let game = movement(f.game, f.owner);
nativeTypedClearBoard(game);
for (const seat of game.players) { game.deck.push(...seat.hand); seat.hand = []; }
const territory = 'habbanya_ridge_sietch', key = `${territory}:${boardTerritory(territory).sectors[0]}`;
nativeTypedPlace(game, f.owner, key, 3);
nativeTypedPlace(game, f.opponent, key, 3);
game = openSkillsTechBattle(phase(game, 6), f.owner, f.opponent, territory, true);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.owner);
view.players.find(p => p.id === f.owner)!.bot = difficulty;
const plan = botActions(view).find(action => action.type === 'battlePlan' && action.leader === f.trainer);
assert.ok(plan, `${difficulty} must retain the actually available hidden trainer as a legal plan candidate.`);
const committed = applyAction(reload(game), f.owner, plan);
assert.equal(committed.battle!.plans[f.owner].leader, f.trainer);
assert.equal(player(committed, f.owner).forces[key], 3);
assert.equal(player(committed, f.owner).spice, player(game, f.owner).spice,
'A sealed first plan does not debit support or collect unearned Smuggler spice.');
}
});
@@ -0,0 +1,330 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { treacheryDeck, spiceDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { traitorDeck } from '../game/traitors';
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
import { validateLeaderSkills } from '../game/leader-skills';
import { validateRicheseNoField } from '../game/richese-no-field';
import { createTechTokens, ownedTech, TECH_TOKENS } from '../game/tech-tokens';
import { strongholdBenefit, strongholdControllers } from '../game/stronghold-cards';
import { canUseAsKaramaRole, canUseAsTruthtranceRole } from '../game/shrine';
import {
advanceDiscoveryNativeE2Skills, completeDiscoveryNativeE2SkillsSetup, createDiscoveryNativeE2SkillsFixture,
discoveryNativeE2SkillsInvoiceAction, discoveryNativeE2SkillsMovementWindow,
discoveryNativeE2SkillsPlayer as player, finishDiscoveryNativeE2SkillsBattle,
holdDiscoveryNativeE2SkillsCard, prepareDiscoveryNativeE2SkillsMarkerBattle,
revealDiscoveryNativeE2Skills, settleDiscoveryNativeE2Skills,
} from './fixture-discovery-native-e2-skills';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function custody(game: Game): void {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
assert.deepEqual(cards.map(card => card.id).sort(),
[...treacheryDeck(game.expansions), ...richeseCards()].map(card => card.id).sort());
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((total, amount) => total + amount, 0), 20);
if (p.noField) validateRicheseNoField(p.noField);
}
validateLeaderSkills(game.leaderSkills!, game.players);
validateDiscoveryState(game.discoveries!);
if (game.status === 'playing') {
const held = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices])];
assert.deepEqual(held.sort(), traitorDeck(game.players, game.expansions.includes('ix')).sort());
assert.equal(new Set(held).size, held.length);
}
}
// Source composition: LEADER_SKILLS_RUNTIME.md native Richese/Advanced CHOAM,
// CHOAM_LEADER_SKILLS.md, DISCOVERY_COMPONENTS.md and original native marker
// fixture. No Banker income, Mentat questions or mixed No-Field interpretation.
for (const advanced of [false, true]) for (const tech of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} original all14 E2/Discovery preserves independent ordinary/cache custody with ${tech ? 'native Tech' : 'no Tech'}`, () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced, tech, strongholds: advanced });
const ordinary = [...f.setup.deck, ...f.setup.players.flatMap(p => p.hand)];
assert.equal(ordinary.length, 35);
assert.deepEqual(ordinary.map(c => c.id).sort(), treacheryDeck(['choam']).map(c => c.id).sort());
assert.deepEqual(f.setup.richeseCache!.map(c => c.id).sort(), richeseCards().map(c => c.id).sort());
assert.equal(f.setup.richeseCache!.length, 10);
assert.equal(f.setup.leaderSkills!.deck.length + Object.values(f.setup.leaderSkills!.offers).reduce((n, o) => n + o.cards.length, 0), 14);
assert.ok(f.setup.players.every(p => p.traitors.length === 0 && p.traitorChoices.length === 0));
assert.equal(f.setup.spiceDeck.length, spiceDeck().length + 7);
assert.deepEqual(f.setup.spiceDeck.flatMap(c => 'territory' in c && c.discovery ? [c.discovery] : []).sort(),
DISCOVERY_SPICE_CARDS.map(c => c.discovery).sort());
assert.equal(f.setup.discoveries!.tokens.length, 8);
assert.equal(viewGame(f.setup, f.richese).leaderSkills!.offer!.cards.includes('planetologist'), true);
assert.equal(viewGame(f.setup, f.choam).leaderSkills!.offer!.cards.includes('smuggler'), true);
assert.ok(player(f.game, f.richese).hand.some(c => c.id === f.green));
assert.equal(f.game.richeseCache!.some(c => c.id === f.green), false);
assert.ok(f.game.richeseCache!.length > 0, 'Two-turn bounded recipes do not bypass the exhausted-cache ruling.');
assert.equal(f.game.spiceBankerIncomePreview, undefined);
assert.equal(f.game.mentatQuestionPreview, undefined);
if (tech) {
assert.deepEqual(f.setup.techTokens, createTechTokens());
assert.ok(TECH_TOKENS.every(token => f.afterFirstStorm.techTokens![token.id].owner !== null));
assert.ok(TECH_TOKENS.every(token => f.afterFirstStorm.players.some(p => p.id === f.afterFirstStorm.techTokens![token.id].owner)));
} else assert.equal(f.afterFirstStorm.techTokens, undefined);
assert.deepEqual(f.afterSetup.leaderSkills!.assignments, f.game.leaderSkills!.assignments);
custody(f.setup);
custody(f.game);
});
}
void test('actual original offers can train and finish native setup without a controlled shuffle or demanded skill', () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced: true, kind: 'stash', useActualOffers: true });
const offered = structuredClone(f.setup.leaderSkills!.offers);
const after = completeDiscoveryNativeE2SkillsSetup(reload(f.setup), true);
for (const assignment of after.leaderSkills!.assignments) {
assert.ok(offered[assignment.owner].cards.includes(assignment.skill));
assert.ok(player(f.setup, assignment.owner).leaders.some(l => l.id === assignment.leader));
}
assert.equal(after.leaderSkills!.assignments.length, after.players.length);
assert.deepEqual(after.players.flatMap(p => p.hand).map(c => c.id).sort(), f.setup.players.flatMap(p => p.hand).map(c => c.id).sort());
assert.equal(Object.keys(after.leaderSkills!.offers).length, 0);
custody(after);
custody(f.game);
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} CHOAM skill owner opens the native five-card Stash boundary, then every minimal policy disposes its actual sixth card once`, () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced, kind: 'stash', tech: true, strongholds: advanced });
const before = f.game, choam = player(before, f.choam);
assert.equal(handLimit(choam), 5);
while (choam.hand.length < handLimit(choam)) {
const card = before.deck.shift();
assert.ok(card); choam.hand.push(card);
}
f.staging.push('Conserved original undealt ordinary cards into CHOAM hand at its native five-card capacity.');
const wallet = choam.spice, hand = choam.hand.map(c => c.id), drawn = before.deck[0];
assert.ok(drawn);
const revealed = revealDiscoveryNativeE2Skills(f);
assert.equal(revealed.decision?.kind, 'discoveryDiscard');
assert.equal(revealed.decision?.player, f.choam);
assert.deepEqual(player(revealed, f.choam).hand.map(c => c.id), [...hand, drawn.id]);
assert.equal(player(revealed, f.choam).spice, wallet);
assert.equal(revealed.deck.length, before.deck.length - 1);
assert.equal(viewGame(revealed, f.choam).players.find(p => p.id === f.choam)!.hand!.length, 6);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(revealed, f.choam);
view.players.find(p => p.id === f.choam)!.bot = difficulty;
const action = botActions(view).find(a => a.type === 'decision' && typeof a.card === 'string');
assert.ok(action, `${difficulty} must return a real owned disposal action.`);
const done = settleDiscoveryNativeE2Skills(applyAction(reload(revealed), f.choam, action));
assert.equal(player(done, f.choam).hand.length, 5);
assert.equal(player(done, f.choam).spice, wallet);
assert.equal(player(done, f.choam).hand.some(c => c.id === action.card), false);
assert.equal(done.discard.filter(c => c.id === action.card).length, 1);
assert.equal(done.discoveryStash!.stage, 'complete');
assert.equal(done.discoveries!.tokens.find(t => t.id === f.token)!.status, 'removed');
assert.deepEqual(done.leaderSkills!.assignments, revealed.leaderSkills!.assignments);
reject(done, f.choam, action);
reject(done, f.choam, { type: 'discovery', token: f.token, reveal: true });
custody(done);
}
for (const physical of player(revealed, f.choam).hand) {
const action: Action = { type: 'decision', event: revealed.discoveryStash!.event, card: physical.id };
reject(revealed, f.richese, action);
reject(revealed, f.choam, { ...action, event: 'stale-stash' });
const done = settleDiscoveryNativeE2Skills(applyAction(reload(revealed), f.choam, action));
assert.equal(done.discard.filter(c => c.id === physical.id).length, 1);
assert.equal(player(done, f.choam).hand.length, 5);
assert.equal(done.deck.length, before.deck.length - 1);
custody(done);
}
custody(revealed);
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} original first end-Mentat claims and source-local Shrine/Stronghold entitlement precede trained CHOAM invoice and Bureaucrat diversion`, () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced, kind: 'invoice', tech: true, strongholds: advanced });
const checkpoint = discoveryNativeE2SkillsMovementWindow(f, f.choam), before = checkpoint.game;
assert.deepEqual(f.setup.techTokens, createTechTokens());
assert.ok(TECH_TOKENS.every(t => f.afterFirstStorm.techTokens![t.id].owner !== null));
if (advanced) {
assert.equal(checkpoint.beforeFirstMentat.strongholdCards!.claimedTurn, 0);
assert.equal(checkpoint.afterFirstMentat.strongholdCards!.claimedTurn, 1);
const expected = strongholdControllers(checkpoint.beforeFirstMentat.players, false);
assert.deepEqual(checkpoint.afterFirstMentat.strongholdCards!.owners, expected);
const claims = Object.entries(expected).filter(([, owner]) => owner);
assert.ok(claims.length > 0);
for (const [territory, owner] of claims) {
assert.equal(strongholdBenefit(checkpoint.afterFirstMentat.strongholdCards, owner!, territory), territory);
assert.equal(strongholdBenefit(checkpoint.afterFirstMentat.strongholdCards, owner!, 'shrine'), null);
assert.equal(strongholdBenefit(checkpoint.afterFirstMentat.strongholdCards, f.observer === owner ? f.choam : f.observer, territory), null);
}
}
assert.equal(player(before, f.guild).forces['shrine:0'], 1);
const truth = holdDiscoveryNativeE2SkillsCard(before, f.guild, c => c.effect === 'truthtrance', f.staging);
const karama = holdDiscoveryNativeE2SkillsCard(before, f.guild, c => c.effect === 'karama', f.staging);
assert.equal(canUseAsKaramaRole(before, player(before, f.guild), truth), true);
assert.equal(canUseAsTruthtranceRole(before, player(before, f.guild), karama), true);
assert.equal(canUseAsKaramaRole(before, player(before, f.choam), truth), false);
assert.equal(canUseAsTruthtranceRole(before, player(before, f.choam), karama), false);
const invoice = discoveryNativeE2SkillsInvoiceAction(before);
const own = structuredClone(player(before, f.choam)), recipient = player(before, f.guild).spice;
let paid = applyAction(reload(before), f.choam, invoice.action);
paid = advanceDiscoveryNativeE2Skills(paid, g => g.decision?.kind === 'bureaucratPayment');
assert.ok(paid.decision?.kind === 'bureaucratPayment');
const pending = paid.bureaucratPayments!.pending!;
assert.deepEqual({ kind: pending.source.kind, payer: pending.source.payer, payee: pending.source.payee, amount: pending.source.amount },
{ kind: 'shipment', payer: f.choam, payee: f.guild, amount: 5 });
assert.equal(pending.owner, f.observer);
assert.equal(player(paid, f.choam).spice, own.spice - 5);
assert.equal(player(paid, f.choam).reserves, own.reserves - 6);
assert.equal(player(paid, f.choam).forces[invoice.key], 6);
assert.equal(player(paid, f.guild).spice, recipient);
for (const redirect of [false, true]) {
const decision: Action = { type: 'decision', event: paid.decision!.event, redirect };
reject(paid, f.choam, decision);
reject(paid, f.observer, { ...decision, event: 'stale-payment' });
const done = settleDiscoveryNativeE2Skills(applyAction(reload(paid), f.observer, decision));
assert.equal(player(done, f.choam).spice, own.spice - 5);
assert.equal(player(done, f.guild).spice, recipient + (redirect ? 3 : 5));
assert.equal(player(done, f.observer).spice, player(paid, f.observer).spice);
assert.equal(done.bureaucratPayments!.used.length, redirect ? 1 : 0);
assert.equal(done.bureaucratPayments!.pending, undefined);
assert.equal(done.techTokens!.heighliners.spice, paid.techTokens!.heighliners.spice);
reject(done, f.observer, decision);
custody(done);
}
for (const difficulty of DIFFICULTIES) {
const view = viewGame(paid, f.observer);
view.players.find(p => p.id === f.observer)!.bot = difficulty;
const action = botActions(view).find(a => a.type === 'decision' && typeof a.redirect === 'boolean');
assert.ok(action);
const done = settleDiscoveryNativeE2Skills(applyAction(reload(paid), f.observer, action));
assert.equal(player(done, f.guild).spice, recipient + (action.redirect ? 3 : 5));
assert.equal(player(done, f.choam).forces[invoice.key], 6);
assert.equal(player(done, f.choam).spice, own.spice - 5);
custody(done);
}
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} actual CHOAM Kulon fuel preserves the trained invoice, discards once and grants its native movement bonus`, () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced, kind: 'invoice' });
const checkpoint = discoveryNativeE2SkillsMovementWindow(f, f.choam);
const before = checkpoint.game;
const fuel = holdDiscoveryNativeE2SkillsCard(before, f.choam, c => c.name === 'Kulon' && c.kind === 'worthless', f.staging);
// Keep a real cancellation opportunity open to observe held fuel, not a fake receipt.
holdDiscoveryNativeE2SkillsCard(before, f.observer, c => c.effect === 'karama', f.staging);
const action: Action = { type: 'card', mode: 'choam', card: fuel.id };
const wallet = player(before, f.choam).spice, reserves = player(before, f.choam).reserves;
const requested = applyAction(reload(before), f.choam, action);
assert.equal(requested.response?.kind, 'choamWorthless');
assert.deepEqual(player(requested, f.choam).hand.find(c => c.id === fuel.id), fuel);
assert.equal(requested.discard.some(c => c.id === fuel.id), false);
const done = settleDiscoveryNativeE2Skills(requested);
assert.equal(done.discard.filter(c => c.id === fuel.id).length, 1);
assert.equal(player(done, f.choam).hand.some(c => c.id === fuel.id), false);
assert.equal(done.choamMovement!.bonus, 1);
assert.equal(player(done, f.choam).spice, wallet);
assert.equal(player(done, f.choam).reserves, reserves);
assert.deepEqual(done.leaderSkills!.assignments, before.leaderSkills!.assignments);
reject(done, f.choam, action);
const invoice = discoveryNativeE2SkillsInvoiceAction(done);
const paid = settleDiscoveryNativeE2Skills(applyAction(reload(done), f.choam, invoice.action));
assert.equal(player(paid, f.choam).spice, wallet - 5);
assert.equal(player(paid, f.choam).reserves, reserves - 6);
assert.equal(player(paid, f.choam).forces[invoice.key], 6);
assert.equal(paid.choamMovement!.bonus, 1);
custody(paid);
});
for (const advanced of [false, true]) for (const marker of [0, 3, 5] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} nested marker-only ${marker} keeps paid custody until both plans then applies capped physical reveal and canonical green Planetologist strength`, () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced, tech: true, strongholds: advanced });
const battle = prepareDiscoveryNativeE2SkillsMarkerBattle(f, { marker, ...(marker === 5 ? { reserveCap: 3 } : {}) });
const before = player(battle.beforeShipment, f.richese), shipped = player(battle.afterShipment, f.richese);
assert.equal(shipped.spice, before.spice - 1, 'Original Discovery locations use the stronghold shipment price, independently of victory eligibility.');
assert.equal(shipped.reserves, before.reserves);
assert.deepEqual(shipped.forces, before.forces);
assert.deepEqual(shipped.noField!.tokens, before.noField!.tokens);
assert.deepEqual(shipped.noField!.deployed!.location, { territory: 'shrine', sector: 0 });
assert.equal(shipped.noField!.deployed!.tokenId, battle.token);
const privateOpponent = viewGame(battle.game, f.guild);
assert.equal(privateOpponent.richeseNoField!.private, null);
assert.equal(privateOpponent.richeseNoField!.public.deployed!.effectiveForces, 1);
reject(battle.game, f.richese, { ...battle.ownerPlan, dial: battle.materialized + 1, support: advanced ? battle.materialized + 1 : 0 });
let game = applyAction(reload(battle.game), f.richese, battle.ownerPlan);
assert.ok(player(game, f.richese).noField!.deployed);
assert.equal(player(game, f.richese).forces[battle.key] ?? 0, 0);
assert.equal(player(game, f.richese).reserves, shipped.reserves);
assert.ok(player(game, f.richese).hand.some(c => c.id === f.green));
game = applyAction(reload(game), f.guild, battle.opponentPlan);
assert.equal(player(game, f.richese).noField!.deployed, null);
assert.equal(player(game, f.richese).forces[battle.key] ?? 0, battle.materialized);
assert.equal(player(game, f.richese).reserves, shipped.reserves - battle.materialized);
assert.deepEqual(player(game, f.richese).noField!.tokens, before.noField!.tokens);
game = finishDiscoveryNativeE2SkillsBattle(reload(game), 'cleanup');
assert.equal(game.lastBattleContext!.winner, f.richese);
const trainer = player(battle.game, f.richese).leaders.find(l => l.id === f.trainers.richese)!;
assert.equal(player(game, f.richese).leaders.find(l => l.id === trainer.id)!.dead, false);
assert.equal(player(game, f.guild).leaders.find(l => l.id === battle.opponentPlan.leader)!.dead, false);
assert.equal(player(game, f.richese).tanks, before.tanks);
assert.equal(player(game, f.guild).tanks, player(battle.game, f.guild).tanks + 8);
assert.equal(player(game, f.richese).spice, player(battle.game, f.richese).spice, 'Zero support and inert green substitution charge no native fee or death bounty.');
assert.equal(game.discard.filter(c => c.id === f.green).length, 1);
assert.equal(player(game, f.richese).hand.some(c => c.id === f.green), false);
const noGreenPlan = { ...battle.ownerPlan };
delete noGreenPlan.weapon;
let ordinary = applyAction(reload(battle.game), f.richese, noGreenPlan);
ordinary = applyAction(ordinary, f.guild, battle.opponentPlan);
ordinary = finishDiscoveryNativeE2SkillsBattle(ordinary, 'cleanup');
assert.equal(ordinary.lastBattleContext!.winner, f.guild, 'Removing only the physical green substitute loses the same real battle.');
assert.equal(player(ordinary, f.richese).hand.some(c => c.id === f.green), true);
assert.equal(ordinary.discard.some(c => c.id === f.green), false);
custody(ordinary);
const loserTokens = ownedTech(battle.game.techTokens, f.guild);
const done = finishDiscoveryNativeE2SkillsBattle(reload(game));
if (loserTokens.length) {
const changed = TECH_TOKENS.filter(t => battle.game.techTokens![t.id].owner !== done.techTokens![t.id].owner);
assert.equal(changed.length, 1);
assert.ok(loserTokens.includes(changed[0].id));
assert.equal(done.techTokens![changed[0].id].owner, f.richese);
}
assert.equal(done.discard.filter(c => c.id === f.green).length, 1);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
custody(done);
});
}
void test('four minimal policies resolve actual nested marker plans into physical revelation and a completed casualty/skill consumer', () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced: true, tech: true, strongholds: true });
const battle = prepareDiscoveryNativeE2SkillsMarkerBattle(f, { marker: 5, reserveCap: 3 });
for (const difficulty of DIFFICULTIES) {
let game = reload(battle.game);
for (let step = 0; !game.battle?.revealed && step < 100; step++) {
let advanced = false;
for (const seat of game.players) {
const view = viewGame(game, seat.id);
view.players.find(p => p.id === seat.id)!.bot = difficulty;
const action = botActions(view)[0];
if (!action) continue;
game = applyAction(game, seat.id, action);
advanced = true;
break;
}
assert.ok(advanced, `${difficulty} needs an actual owned card, response or plan continuation.`);
}
assert.ok(game.battle?.revealed);
assert.equal(player(game, f.richese).noField!.deployed, null);
assert.equal(player(game, f.richese).forces[battle.key], 3);
assert.equal(player(game, f.richese).reserves, 0);
const done = finishDiscoveryNativeE2SkillsBattle(game);
assert.ok(done.lastBattleContext?.winner === f.richese || done.lastBattleContext?.winner === f.guild);
assert.equal(done.battle, null);
assert.equal(player(done, f.richese).noField!.deployed, null);
assert.equal(player(done, f.richese).noField!.lastShipped, battle.token);
assert.equal(player(done, f.richese).tanks + (player(done, f.richese).forces[battle.key] ?? 0), 3);
assert.deepEqual(player(done, f.richese).noField!.tokens, player(battle.game, f.richese).noField!.tokens);
custody(done);
}
});
@@ -0,0 +1,279 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { leaders, treacheryDeck } from '../game/cards';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { FactionId } from '../game/catalog';
import { validateDiscoveryState } from '../game/discoveries';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { strongholdControllers } from '../game/stronghold-cards';
import { ownedTech } from '../game/tech-tokens';
import { traitorDeck } from '../game/traitors';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
import {
advanceDiscoveryNativeE3Skills as advance, completeDiscoveryNativeE3SkillsSetup,
createDiscoveryNativeE3SkillsFixture as fixture, createDiscoveryNativeE3SkillsRevivalFixture,
discoveryNativeE3SkillsClean as clean, discoveryNativeE3SkillsPlayer as player,
discoveryNativeE3SkillsPolicy as policy, initializeDiscoveryNativeE3Skills,
nextDiscoveryNativeE3SkillsStep, resolveDiscoveryNativeE3SkillsBattle,
finishDiscoveryNativeE3SkillsBattles,
revealDiscoveryNativeE3SkillsPlans,
type DiscoveryNativeE3SkillsOptions,
} from './fixture-discovery-native-e3-skills';
function custody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players); validateDiscoveryState(game.discoveries!);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id).sort();
assert.deepEqual(cards, treacheryDeck(['ecaz']).map(c => c.id).sort());
const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards),
...game.leaderSkills!.assignments.map(a => a.skill)].sort();
assert.deepEqual(skills, LEADER_SKILL_CARDS.map(c => c.id).sort());
const retired = game.moritaniAssassinate?.opportunities.filter(receipt => receipt.stage === 'replaced').map(receipt => receipt.card!) ?? [];
assert.deepEqual([...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), ...retired].sort(),
traitorDeck(game.players).sort());
for (const p of game.players) {
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
if (p.elites) {
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'fremen' ? 3 : 5);
for (const [key, amount] of Object.entries(p.elites.forces)) assert.ok(amount <= (p.forces[key] ?? 0));
}
}
}
function assassinate(game: Game, card: string | null): Game {
const d = game.decision; assert.equal(d?.kind, 'moritaniAssassinate');
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected actual original nested loss opportunity.');
return applyAction(structuredClone(game), d.player, { type: 'decision', event: d.event,
...(card === null ? { decline: true } : { card }) });
}
void test('authenticated original Moritani/classic Discovery preserves native all14 training before Traitors at Basic/Advanced module seat boundaries', () => {
const cases: { options: DiscoveryNativeE3SkillsOptions; counts: number[] }[] = [
{ options: { rules: 'basic', tech: false, strongholds: false }, counts: [2, 6] },
{ options: { rules: 'basic', tech: true, strongholds: false }, counts: [3, 6] },
{ options: { rules: 'advanced', tech: false, strongholds: false }, counts: [2, 6] },
{ options: { rules: 'advanced', tech: true, strongholds: false }, counts: [3, 6] },
{ options: { rules: 'advanced', tech: false, strongholds: true }, counts: [2, 6] },
{ options: { rules: 'advanced', tech: true, strongholds: true }, counts: [3, 6] },
];
const factions: FactionId[] = ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit'];
for (const { options, counts } of cases) for (const count of counts) {
const lobby = createGame('AUTHENTICATEDE3SKILLS', newPlayer('original-m', 'Original Moritani', 'moritani'), options.rules === 'advanced', ['ecaz']);
for (let i = 1; i < count; i++) joinGame(lobby, newPlayer(`original-${i}`, `Original ${factions[i]}`, factions[i]));
const before = structuredClone(lobby), setup = initializeDiscoveryNativeE3Skills({ ...options, initial: lobby });
assert.deepEqual(lobby, before);
assert.deepEqual(setup.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })),
before.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })));
assert.ok(setup.players.every(p => p.traitors.length === 0));
const after = completeDiscoveryNativeE3SkillsSetup(setup), guild = after.players.find(p => p.faction === 'guild')!.id;
assert.equal(after.leaderSkills!.assignments.find(a => a.owner === guild)!.skill, 'suk-graduate');
assert.equal(after.players.find(p => p.faction === 'moritani')!.reserves, 14);
assert.equal(Object.values(after.players.find(p => p.faction === 'moritani')!.forces).reduce((a, b) => a + b, 0), 6);
assert.equal(!!after.moritaniAssassinate, options.rules === 'advanced');
if (after.strongholdCards) assert.ok(Object.values(after.strongholdCards.owners).every(owner => owner === null));
custody(after);
}
});
void test('printed Discovery reveal, actual first-Storm Tech/END-Mentat claims, free entry and paid nested shipment preserve source positions and native wallets', () => {
const f = fixture({ tech: true, strongholds: true });
assert.ok(Object.values(f.setup.techTokens!).every(t => t.owner === null));
for (const p of f.afterFirstStorm.players) assert.equal(ownedTech(f.afterFirstStorm.techTokens, p.id).length, 1);
assert.ok(Object.values(f.beforeFirstMentat.strongholdCards!.owners).every(owner => owner === null));
const claimed = applyAction(structuredClone(f.beforeFirstMentat), f.firstMentatStep.actor, f.firstMentatStep.action);
assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(f.beforeFirstMentat.players, false));
assert.equal(claimed.strongholdCards!.owners.tueks_sietch, f.moritani);
const revealedToken = f.afterReveal.discoveries!.tokens.find(t => t.id === f.token)!;
assert.equal(revealedToken.territory, f.source.split(':')[0]);
assert.equal(player(f.beforeReveal, f.opponent).forces[f.source], 3);
assert.equal(player(f.entry.after, f.opponent).forces[f.source] ?? 0, 0);
assert.equal(player(f.entry.after, f.opponent).forces['cistern:0'], 3);
assert.equal(player(f.entry.after, f.opponent).spice, player(f.entry.before, f.opponent).spice);
assert.equal(player(f.entry.after, f.opponent).reserves, player(f.entry.before, f.opponent).reserves);
assert.equal(player(f.shipment.after, f.moritani).forces['cistern:0'], 1);
assert.equal(player(f.shipment.after, f.moritani).reserves, player(f.shipment.before, f.moritani).reserves - 1);
assert.equal(player(f.shipment.after, f.moritani).spice, player(f.shipment.before, f.moritani).spice - 1);
for (const game of [f.afterSetup, f.afterFirstStorm, f.afterReveal, f.entry.after, f.shipment.after, f.pending]) custody(game);
});
void test('normal Guild Suk resolves physical casualty rescue BEFORE nested after-loss assassination; printed3 bounty and exact skill return never replay support or casualties', () => {
const f = fixture({ tech: true, strongholds: true }), pending = f.pending;
assert.equal(pending.lastBattleContext?.winner, f.opponent);
assert.equal(pending.lastBattleContext?.result, 'normal');
assert.equal(pending.lastBattleContext?.sukRescue?.completed, true);
assert.equal(player(pending, f.opponent).tanks, player(f.revealed, f.opponent).tanks + 2);
assert.equal(player(pending, f.opponent).reserves, player(f.revealed, f.opponent).reserves + 1);
assert.equal(player(pending, f.opponent).forces[f.location], 3);
assert.equal(player(pending, f.moritani).tanks, player(f.revealed, f.moritani).tanks + 6);
assert.equal(player(pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3);
assert.equal(player(pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1);
assert.equal(viewGame(pending, f.moritani).moritaniAssassinate!.pending!.territory, 'cistern');
const target = player(pending, f.opponent).leaders.find(l => l.id === f.target)!;
assert.equal(target.name, 'Master Bewt'); assert.equal(target.strength, 3); assert.notEqual(target.id, f.opponentLeader);
const originalCounters = pending.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks }));
const reserve = [...pending.traitorReserve!], ownTraitors = [...player(pending, f.moritani).traitors];
let game = assassinate(pending, f.target);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1);
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === f.target), false);
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
assert.equal(player(game, f.moritani).spice, player(pending, f.moritani).spice + target.strength);
assert.equal(player(game, f.opponent).spice, player(pending, f.opponent).spice);
assert.deepEqual(game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks })), originalCounters);
assert.deepEqual(game.techTokens, pending.techTokens); assert.deepEqual(game.strongholdCards, pending.strongholdCards);
assert.equal(game.decision?.kind, 'battleCards'); assert.equal(game.decision?.player, f.opponent);
game = applyAction(structuredClone(game), f.opponent, { type: 'decision', discard: f.opponentCards });
game = advance(game, g => !g.battle && clean(g));
const lost = ownedTech(pending.techTokens, f.moritani)[0]; assert.ok(lost);
assert.equal(game.techTokens![lost].owner, f.opponent);
for (const card of [...f.moritaniCards, ...f.opponentCards]) assert.equal(game.discard.filter(c => c.id === card).length, 1);
game = finishDiscoveryNativeE3SkillsBattles(game);
game = advance(game, g => g.phase === 8);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.territory, 'cistern');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.replacement, reserve[0]);
assert.deepEqual(game.traitorReserve, reserve.slice(1));
assert.deepEqual(player(game, f.moritani).traitors, [...ownTraitors.filter(c => c !== f.target), reserve[0]]);
assert.equal('replacement' in viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
game = advance(game, g => g.turn === 3 && g.phase === 4 && clean(g));
const before = structuredClone(game);
assert.throws(() => applyAction(game, f.opponent, { type: 'reviveLeader', leader: f.target }), /All leaders must die/);
assert.deepEqual(game, before); custody(game);
});
void test('earned Tuek Worthless benefits remain source-local: retained card pays in Tuek, never in the separately revealed Cistern', () => {
for (const battle of ['nested', 'tueks_sietch'] as const) {
const f = fixture({ strongholds: true, battle });
assert.equal(f.revealed.strongholdCards!.owners.tueks_sietch, f.moritani);
assert.deepEqual(quoteStrongholdFactionsBattle(f.revealed).strongholdIncome,
battle === 'nested' ? [] : [{ player: f.moritani, amount: 4 }]);
assert.equal(player(f.pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1 + (battle === 'nested' ? 0 : 4));
assert.equal(player(f.pending, f.opponent).tanks, 2);
assert.equal(f.pending.lastBattleContext?.winner, f.opponent);
custody(f.pending);
}
});
void test('Basic Discovery keeps normal Suk and optional original Tech capture without Advanced support, Strongholds or assassination', () => {
for (const tech of [false, true]) {
const f = fixture({ rules: 'basic', tech, strongholds: false });
assert.equal(f.pending.lastBattleContext?.winner, f.opponent);
assert.equal(f.pending.lastBattleContext?.sukRescue?.completed, true);
assert.equal(player(f.pending, f.opponent).tanks, 2); assert.equal(player(f.pending, f.moritani).tanks, 6);
assert.equal(player(f.pending, f.opponent).spice, player(f.revealed, f.opponent).spice);
assert.equal(player(f.pending, f.moritani).spice, player(f.revealed, f.moritani).spice);
assert.equal(f.pending.moritaniAssassinate, undefined); assert.ok(!f.pending.strongholdCards);
assert.equal(f.pending.leaderSkills!.assignments.find(a => a.leader === f.target)!.skill, 'suk-graduate');
if (tech) {
const lost = ownedTech(f.revealed.techTokens, f.moritani)[0]; assert.ok(lost);
assert.equal(f.pending.techTokens![lost].owner, f.opponent);
}
custody(f.pending);
}
});
void test('a normal native Traitor reveal forfeits assassination for the entire game, including a later real nested loss', () => {
const f = fixture({ normalCall: true });
assert.equal(f.pending.lastBattleContext?.result, 'traitor');
assert.equal(f.pending.lastBattleContext?.winner, f.moritani);
assert.equal(f.pending.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, true);
let game = advance(finishDiscoveryNativeE3SkillsBattles(structuredClone(f.pending)), g => g.turn === 3 && g.phase === 5 && clean(g));
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((a, b) => a + b, 0); p.forces = {};
if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((a, b) => a + b, 0); p.elites.forces = {}; }
if (p.advisors) p.advisors = {};
}
for (const actor of [f.moritani, f.opponent]) { const p = player(game, actor); p.reserves -= 4; p.forces['cistern:0'] = 4; }
holdMoritaniStrongholdsTraitor(game, f.moritani, f.target);
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const own = player(game, f.moritani).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
const enemy = player(game, f.opponent).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(own && enemy);
game = advance(game, g => g.phase === 6 && clean(g)); const actor = game.active!;
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'cistern', target: actor === f.moritani ? f.opponent : f.moritani });
game = advance(game, g => !!g.battle && !g.battle.revealed && !nextDiscoveryNativeE3SkillsStep(g));
game = revealDiscoveryNativeE3SkillsPlans(game, [
{ actor: f.moritani, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } },
]);
game = resolveDiscoveryNativeE3SkillsBattle(game, f.moritani);
assert.equal(game.lastBattleContext?.result, 'normal'); assert.equal(game.lastBattleContext?.winner, f.opponent);
assert.equal(game.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(game.moritaniAssassinateCallEvents!.length, 1);
assert.deepEqual(game.moritaniAssassinate!.opportunities, []);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, false); custody(game);
});
void test('four minimal policies actually kill the eligible trained disc or decline the real used-disc-only menu; neither branch replays native resources', () => {
const f = fixture({ tech: true, strongholds: true });
let empty = structuredClone(f.game);
holdMoritaniStrongholdsTraitor(empty, f.moritani, f.opponentLeader);
empty = resolveDiscoveryNativeE3SkillsBattle(revealDiscoveryNativeE3SkillsPlans(empty, f.plans), f.moritani);
assert.deepEqual(viewGame(empty, f.moritani).moritaniAssassinate!.pending!.cards, []);
for (const difficulty of DIFFICULTIES) {
const action = policy(structuredClone(f.pending), f.moritani, difficulty)[0]; assert.ok(action, difficulty);
let game = applyAction(structuredClone(f.pending), f.moritani, action);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.card, f.target);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
assert.equal(player(game, f.moritani).spice, player(f.pending, f.moritani).spice + 3);
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
game = advance(game, g => !g.battle && clean(g));
assert.equal(player(game, f.opponent).tanks, 2); assert.equal(player(game, f.moritani).tanks, 6); custody(game);
const decline = policy(structuredClone(empty), f.moritani, difficulty)[0]; assert.ok(decline, difficulty);
const declined = applyAction(structuredClone(empty), f.moritani, decline);
assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
assert.deepEqual(declined.leaderSkills, empty.leaderSkills);
assert.equal(player(declined, f.moritani).spice, player(empty, f.moritani).spice);
assert.equal(player(declined, f.opponent).leaders.find(l => l.id === f.target)!.dead, false);
custody(advance(declined, g => !g.battle && clean(g)));
}
});
void test('five actual original battles open native own revival; one real printed fee supplies private optional replacement without staged deaths or clocks', () => {
const f = createDiscoveryNativeE3SkillsRevivalFixture();
const assignment = f.beforeDeaths.leaderSkills!.assignments.find(a => a.owner === f.moritani)!;
assert.equal(new Set(f.deaths.map(d => d.leader)).size, 5);
assert.equal(new Set(f.deaths.map(d => d.winnerLeader)).size, 5);
assert.equal(new Set(f.deaths.map(d => d.territory)).size, 5);
for (const [index, death] of f.deaths.entries()) {
assert.equal(player(death.after, f.moritani).leaders.filter(l => l.dead).length, index + 1);
assert.equal(player(death.after, f.moritani).leaders.find(l => l.id === death.leader)!.deaths,
player(death.before, f.moritani).leaders.find(l => l.id === death.leader)!.deaths + 1);
assert.equal(death.after.lastBattleContext?.winner, f.opponent);
assert.equal(player(death.after, f.opponent).hand.filter(c => c.id === f.weapon).length, 1);
assert.equal(death.after.discard.some(c => c.id === f.weapon), false);
assert.equal(player(death.after, f.moritani).tanks, player(death.before, f.moritani).tanks + 1);
assert.equal(player(death.after, f.opponent).tanks, player(death.before, f.opponent).tanks);
custody(death.after);
}
assert.equal(f.revivalWindow.leaderSkills!.assignments.some(a => a.owner === f.moritani), false);
assert.equal(f.revivalWindow.leaderSkills!.deck.filter(c => c === assignment.skill).length, 1);
const printed = player(f.revivalWindow, f.moritani).leaders.find(l => l.id === f.leader)!.strength;
assert.equal(player(f.offered, f.moritani).spice, player(f.revivalWindow, f.moritani).spice - printed);
assert.equal(player(f.offered, f.moritani).leaders.find(l => l.id === f.leader)!.dead, false);
assert.equal(player(f.offered, f.moritani).leaders.filter(l => l.dead).length, 4);
const event = f.offered.leaderSkills!.offers[f.moritani].event;
assert.deepEqual(viewGame(f.offered, f.moritani).leaderSkills!.offer!.cards, []);
assert.equal(viewGame(f.offered, f.opponent).leaderSkills!.offer, null);
const declined = applyAction(structuredClone(f.offered), f.moritani, { type: 'leaderSkill', event, mode: 'decline' });
assert.equal(declined.leaderSkills!.offers[f.moritani], undefined);
assert.equal(declined.leaderSkills!.assignments.some(a => a.owner === f.moritani), false);
assert.equal(player(declined, f.moritani).spice, player(f.offered, f.moritani).spice); custody(declined);
const drawn = applyAction(structuredClone(f.offered), f.moritani, { type: 'leaderSkill', event, mode: 'draw' });
const offer = viewGame(drawn, f.moritani).leaderSkills!.offer!;
assert.equal(offer.cards.length, 2); assert.equal(viewGame(drawn, f.opponent).leaderSkills!.offer, null);
const own = viewGame(drawn, f.moritani).leaderSkills!;
const skill = offer.cards.find(c => !own.unavailableSkills?.[c]); assert.ok(skill);
const selected = applyAction(drawn, f.moritani, { type: 'leaderSkill', event, skill, leader: f.leader });
assert.deepEqual(selected.leaderSkills!.assignments.find(a => a.owner === f.moritani), { owner: f.moritani, skill, leader: f.leader });
assert.equal(selected.leaderSkills!.offers[f.moritani], undefined);
assert.equal(player(selected, f.moritani).spice, player(f.offered, f.moritani).spice);
assert.equal(selected.players.some(p => p.leaders.some(l => l.capturedBy || l.gholaBy)), false);
custody(selected);
});
+277
View File
@@ -0,0 +1,277 @@
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))) } };
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 };
}
+365
View File
@@ -0,0 +1,365 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import type { Card } from '../game/cards';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { nextSkillsTechBattleStep, openSkillsTechBattle, finishSkillsTechBattle } from './fixture-skills-tech-battle';
export type DiscoveryNativeE2SkillsStep = { actor: string; action: Action };
export type DiscoveryNativeE2SkillsOptions = {
/** Authenticated fresh lobby or its original undealt, unassigned setup. */
initial?: Game;
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
kind?: 'stash' | 'invoice' | 'marker';
marker?: 0 | 3 | 5;
reserveCap?: number;
/** Human native-mechanics path: retain native random shuffle and select only
* real available offers. Specific trained consumers require their actual skill. */
useActualOffers?: boolean;
};
export type DiscoveryNativeE2SkillsFixture = {
initial: Game;
setup: Game;
afterSetup: Game;
afterFirstStorm: Game;
game: Game;
actions: DiscoveryNativeE2SkillsStep[];
staging: string[];
kind: 'stash' | 'invoice' | 'marker';
richese: string;
choam: string;
guild: string;
observer: string;
collector: string;
token: string;
face: DiscoveryTokenFace;
parent: string;
parentSector: number;
trainers: { richese: string; choam: string };
green: string;
};
export type DiscoveryNativeE2SkillsCheckpoint = {
beforeFirstMentat: Game;
firstMentatStep: DiscoveryNativeE2SkillsStep;
afterFirstMentat: Game;
game: Game;
};
export type DiscoveryNativeE2MarkerBattle = {
beforeShipment: Game;
shipmentStep: DiscoveryNativeE2SkillsStep;
afterShipment: Game;
game: Game;
token: string;
value: 0 | 3 | 5;
materialized: number;
key: string;
ownerPlan: Action;
opponentPlan: Action;
};
export const discoveryNativeE2SkillsClean = (game: Game) => !game.response && !game.decision && !game.phaseOpening;
export function discoveryNativeE2SkillsPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor);
assert.ok(player);
return player;
}
const greenId = (game: Game) => game.advanced ? 'richese-residual-poison' : 'richese-nullentropy-box';
/** Control only the original all14 shuffle, never an existing offer or later shuffle. */
function withOffers<T>(selected: LeaderSkillId[], operation: () => T): T {
const working = LEADER_SKILL_CARDS.map(card => card.id);
const remaining = working.filter(card => !selected.includes(card));
const desired = selected.flatMap(card => [card, remaining.shift()!]);
desired.push(...remaining);
const rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (cursor < rolls.length) {
assert.ok(array instanceof Uint32Array && array.length === 1);
array[0] = rolls[cursor++];
} else Reflect.apply(original, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function requestedSkill(faction: string): LeaderSkillId | undefined {
return faction === 'richese' ? 'planetologist' : faction === 'choam' ? 'smuggler' : faction === 'emperor' ? 'bureaucrat' : undefined;
}
/** Native no-call/no-spend continuation; acquire the first actual green cache lot.
* Later unbid lots are removed normally, not counted as an exhausted-cache success. */
export function nextDiscoveryNativeE2SkillsStep(game: Game, useActualOffers = false): DiscoveryNativeE2SkillsStep {
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor], view = viewGame(game, actor).leaderSkills!;
const player = discoveryNativeE2SkillsPlayer(game, actor);
const requested = requestedSkill(player.faction);
const skill = useActualOffers
? offer.cards.find(card => card === requested && !view.unavailableSkills?.[card]) ?? offer.cards.find(card => !view.unavailableSkills?.[card])
: requested ?? offer.cards.find(card => !view.unavailableSkills?.[card]);
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
const leader = player.leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && offer.cards.includes(skill) && !view.unavailableSkills?.[skill] && leader,
'The original offer must contain the requested native consumer skill; never replace an existing offer.');
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
}
if (!game.response && !game.phaseOpening && game.decision) {
const d = game.decision, actor = d.player, event = game.richeseBidding?.event;
if (d.kind === 'richeseBlackMarket') return { actor, action: { type: 'decision', event, decline: true } };
if (d.kind === 'richeseDeclaration') return { actor, action: { type: 'decision', event, position: 'first' } };
if (d.kind === 'richeseCache') {
const card = game.richeseCache!.find(c => c.id === greenId(game)) ?? game.richeseCache![0];
assert.ok(card, 'The original cache must still have a physical lot.');
return { actor, action: { type: 'decision', event, card: card.id, method: 'onceAround', direction: 'counterclockwise' } };
}
if (d.kind === 'richeseUnbid') return { actor, action: { type: 'decision', event, keep: game.richeseAuction?.cardId === greenId(game) } };
if (d.kind === 'discoveryEntry') return { actor, action: { type: 'decision', event: d.event, accept: false } };
}
const lot = game.richeseAuction;
if (discoveryNativeE2SkillsClean(game) && lot && !lot.outcome) {
const actor = lot.method === 'silent' ? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active;
assert.ok(actor);
return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
}
return nextSkillsTechBattleStep(game);
}
export function stepDiscoveryNativeE2Skills(game: Game, step: DiscoveryNativeE2SkillsStep, actions?: DiscoveryNativeE2SkillsStep[]): Game {
actions?.push(structuredClone(step));
return applyAction(game, step.actor, step.action);
}
export function advanceDiscoveryNativeE2Skills(game: Game, until: (state: Game) => boolean, actions?: DiscoveryNativeE2SkillsStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
game = stepDiscoveryNativeE2Skills(game, nextDiscoveryNativeE2SkillsStep(game), actions);
}
throw Error('Original E2 Discovery/Skills policy did not reach its requested window.');
}
export function completeDiscoveryNativeE2SkillsSetup(game: Game, useActualOffers = false, actions?: DiscoveryNativeE2SkillsStep[]): Game {
for (let n = 0; game.status === 'setup' && n < 200; n++)
game = stepDiscoveryNativeE2Skills(game, nextDiscoveryNativeE2SkillsStep(game, useActualOffers), actions);
assert.equal(game.status, 'playing');
return game;
}
export function settleDiscoveryNativeE2Skills(game: Game, actions?: DiscoveryNativeE2SkillsStep[]): Game {
return advanceDiscoveryNativeE2Skills(game, state => discoveryNativeE2SkillsClean(state) &&
!state.pendingShipment && !state.pendingTreacheryDiscard && !state.pendingChoamWorthless, actions);
}
export function holdDiscoveryNativeE2SkillsCard(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[] = []): Card {
const p = discoveryNativeE2SkillsPlayer(game, actor), held = p.hand.find(matches);
if (held) return held;
assert.ok(p.hand.length < handLimit(p));
const at = game.deck.findIndex(matches);
let card: Card;
if (at >= 0) card = game.deck.splice(at, 1)[0];
else {
const donor = game.players.find(other => other.id !== actor && other.hand.some(matches));
if (donor) {
card = donor.hand.splice(donor.hand.findIndex(matches), 1)[0];
staging.push(`Conserved original ${card.id}: ${donor.faction} held position to ${p.faction}; no earned sale or payment claimed.`);
} else {
const discarded = game.discard.findIndex(matches);
assert.ok(discarded >= 0, 'The original physical ordinary card must exist in current deck, hand or discard custody.');
card = game.discard.splice(discarded, 1)[0];
staging.push(`Controlled conserved original ${card.id}: actual public discard position to ${p.faction}'s hand; this is not an earned draw or a natural card history.`);
}
}
p.hand.push(card);
if (at >= 0) staging.push(`Conserved original ${card.id}: undealt ordinary deck to ${p.faction} hand.`);
return card;
}
function withPlacement<T>(game: Game, face: DiscoveryTokenFace, operation: () => T): T {
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
const index = eligible.findIndex(token => token.face === face);
assert.ok(index >= 0);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = Math.floor((index + 0.5) * 0x100000000 / eligible.length);
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Human path: original ready lobby → all14 choices → Traitor choices → first
* Storm → Discovery blow → zero-bid green cache acquisition → collector ships
* one real force → Collection. Stops BEFORE inspect/reveal. No wallets, phases,
* receipts, offers, Tech owners or paid marker histories are assigned. */
export function createDiscoveryNativeE2SkillsFixture(options: DiscoveryNativeE2SkillsOptions = {}): DiscoveryNativeE2SkillsFixture {
const kind = options.kind ?? 'marker';
let game = options.initial ? structuredClone(options.initial)
: createGame('DISCOVERYNATIVEE2SKILLS', newPlayer('richese', 'Richese', 'richese'), options.advanced ?? false, ['choam']);
if (!options.initial) for (const faction of ['choam', 'emperor', 'guild'] as const)
joinGame(game, newPlayer(faction, faction, faction));
assert.deepEqual(game.expansions, ['choam']);
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(!game.homeworlds && !game.nexusCards && !game.spiceBankerIncomePreview && !game.mentatQuestionPreview);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
game.leaderSkills?.assignments.length === 0 && game.players.every(p =>
p.hand.length <= (p.faction === 'harkonnen' ? 2 : 1) && p.traitors.length === 0 && p.traitorChoices.length === 0),
'Only a fresh lobby or its original unassigned skill setup can enter; printed starting hands remain authoritative.');
const seat = (faction: string) => { const p = game.players.find(p => p.faction === faction); assert.ok(p); return p.id; };
const richese = seat('richese'), choam = seat('choam'), guild = seat('guild'), observer = seat('emperor');
const actions: DiscoveryNativeE2SkillsStep[] = [], staging: string[] = [];
if (game.status === 'lobby') {
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;
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
}
assert.equal(game.discoveryEnabled, true);
assert.ok(!game.strongholdCards || game.advanced);
const initial = structuredClone(game);
if (game.status === 'lobby') {
const passive: LeaderSkillId[] = ['warmaster', 'swordmaster-of-ginaz', 'killer-medic'];
const selected = game.players.map(p => requestedSkill(p.faction) ?? passive.shift()!);
game = options.useActualOffers
? initializeLeaderSkillsGameForAudit(game)
: withOffers(selected, () => initializeLeaderSkillsGameForAudit(game));
if (!options.useActualOffers) staging.push('Controlled only the original all14 offer shuffle; native subsequent deals and offers are authoritative.');
}
const setup = structuredClone(game);
game = completeDiscoveryNativeE2SkillsSetup(game, options.useActualOffers, actions);
const afterSetup = structuredClone(game);
const face: DiscoveryTokenFace = kind === 'stash' ? 'treachery-card-stash' : 'shrine';
const printed = DISCOVERY_TOKEN_BY_ID[face].type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north';
const placement = DISCOVERY_CARD_PLACEMENTS[printed];
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed);
assert.ok(index >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
for (let position = 1; position < 4; position++) {
const ordinary = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
assert.ok(ordinary >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]);
}
staging.push('Reordered four original unplayed Spice Cards and selected the original physical Discovery placement lottery.');
game = advanceDiscoveryNativeE2Skills(game, g => g.phase === 1 && discoveryNativeE2SkillsClean(g), actions);
const afterFirstStorm = structuredClone(game);
game = withPlacement(game, face, () => advanceDiscoveryNativeE2Skills(game,
g => g.discoveries!.tokens.some(token => token.face === face && token.status === 'placed'), actions));
const collector = kind === 'stash' ? choam : guild;
game = advanceDiscoveryNativeE2Skills(game, g => g.phase === 5 && g.active === collector && discoveryNativeE2SkillsClean(g), actions);
assert.ok(discoveryNativeE2SkillsPlayer(game, richese).hand.some(card => card.id === greenId(game)),
'Acquire the original physical green card through Richese declaration, auction and unbid decision.');
game = stepDiscoveryNativeE2Skills(game, { actor: collector, action: {
type: 'ship', territory: placement.territory, sector: placement.sector, amount: 1, elite: 0, allyPayment: 0,
} }, actions);
game = settleDiscoveryNativeE2Skills(game, actions);
game = advanceDiscoveryNativeE2Skills(game, g => g.phase === 7 && discoveryNativeE2SkillsClean(g), actions);
const token = game.discoveries!.tokens.find(t => t.face === face)!;
assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id));
const trainers = { richese: game.leaderSkills!.assignments.find(a => a.owner === richese)!.leader,
choam: game.leaderSkills!.assignments.find(a => a.owner === choam)!.leader };
return { initial, setup, afterSetup, afterFirstStorm, game, actions, staging, kind, richese, choam, guild, observer,
collector, token: token.id, face, parent: placement.territory, parentSector: placement.sector, trainers, green: greenId(game) };
}
export function revealDiscoveryNativeE2Skills(fixture: DiscoveryNativeE2SkillsFixture): Game {
const game = stepDiscoveryNativeE2Skills(fixture.game, { actor: fixture.collector,
action: { type: 'discovery', token: fixture.token, reveal: false } }, fixture.actions);
return stepDiscoveryNativeE2Skills(game, { actor: fixture.collector,
action: { type: 'discovery', token: fixture.token, reveal: true } }, fixture.actions);
}
/** Exact next human path: inspect/reveal → original end-Mentat claims →
* next-turn free parent-to-Shrine entry → Storm/Bidding → requested Movement.
* Entitlements are never prefilled. */
export function discoveryNativeE2SkillsMovementWindow(fixture: DiscoveryNativeE2SkillsFixture, actor = fixture.richese): DiscoveryNativeE2SkillsCheckpoint {
assert.notEqual(fixture.kind, 'stash');
let game = revealDiscoveryNativeE2Skills(fixture);
game = advanceDiscoveryNativeE2Skills(game, g => g.turn === 1 && g.phase === 8 && discoveryNativeE2SkillsClean(g), fixture.actions);
let beforeFirstMentat = structuredClone(game), firstMentatStep = nextDiscoveryNativeE2SkillsStep(game);
while (game.turn === 1) {
beforeFirstMentat = structuredClone(game);
firstMentatStep = nextDiscoveryNativeE2SkillsStep(game);
game = stepDiscoveryNativeE2Skills(game, firstMentatStep, fixture.actions);
}
const afterFirstMentat = structuredClone(game);
game = advanceDiscoveryNativeE2Skills(game, g => g.decision?.kind === 'discoveryEntry' && g.decision.player === fixture.collector, fixture.actions);
const offer = viewGame(game, fixture.collector).discoveryEntry!;
const entry = discoveryEntryMoveAction(viewGame(game, fixture.collector), offer.sources);
assert.ok(entry);
game = stepDiscoveryNativeE2Skills(game, { actor: fixture.collector, action: entry }, fixture.actions);
game = settleDiscoveryNativeE2Skills(game, fixture.actions);
assert.equal(discoveryNativeE2SkillsPlayer(game, fixture.collector).forces[`${fixture.face}:0`], 1);
game = advanceDiscoveryNativeE2Skills(game, g => g.turn === 2 && g.phase === 5 && g.active === actor && discoveryNativeE2SkillsClean(g), fixture.actions);
return { beforeFirstMentat, firstMentatStep, afterFirstMentat, game };
}
export function discoveryNativeE2SkillsInvoiceAction(game: Game): { action: Action; key: string } {
const target = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm) &&
game.players.every(p => Object.entries(p.forces).every(([key, n]) => !n || !key.startsWith(`${t.id}:`))));
assert.ok(target);
return { action: { type: 'ship', territory: target.id, sector: target.sectors[0], amount: 6, elite: 0, allyPayment: 0, smuggler: true },
key: `${target.id}:${target.sectors[0]}` };
}
/** Marker-only: no companion, no ordinary Richese forces at the battle site.
* Optional reserve cap conserves counters in Polar Sink. The original green
* cache card is committed ONLY as Planetologist weapon, never its native power. */
export function prepareDiscoveryNativeE2SkillsMarkerBattle(fixture: DiscoveryNativeE2SkillsFixture,
options: Pick<DiscoveryNativeE2SkillsOptions, 'marker' | 'reserveCap'> = {}): DiscoveryNativeE2MarkerBattle {
assert.ok(fixture.game.leaderSkills!.assignments.some(a => a.owner === fixture.richese && a.skill === 'planetologist'),
'The specific green consumer requires a genuinely offered and selected Planetologist; the human marker window does not manufacture it.');
const window = discoveryNativeE2SkillsMovementWindow(fixture);
let game = window.game;
const player = discoveryNativeE2SkillsPlayer(game, fixture.richese), value = options.marker ?? 5;
if (options.reserveCap !== undefined) {
assert.ok(options.reserveCap >= 0 && options.reserveCap <= player.reserves);
const outside = player.reserves - options.reserveCap;
player.forces['polar_sink:0'] = (player.forces['polar_sink:0'] ?? 0) + outside;
player.reserves -= outside;
fixture.staging.push(`Conserved ${outside} original Richese reserve counters into Polar Sink outside the nested battle.`);
}
const physical = player.noField!.tokens.find(token => token.value === value)!;
const defense = holdDiscoveryNativeE2SkillsCard(game, fixture.richese, card => card.kind === 'shield', fixture.staging);
const beforeShipment = structuredClone(game);
const shipmentStep: DiscoveryNativeE2SkillsStep = { actor: fixture.richese, action: {
type: 'ship', territory: fixture.face, sector: 0, noField: physical.id, event: player.noFieldEvent, allyPayment: 0,
} };
game = stepDiscoveryNativeE2Skills(game, shipmentStep, fixture.actions);
game = settleDiscoveryNativeE2Skills(game, fixture.actions);
const afterShipment = structuredClone(game);
const enemy = discoveryNativeE2SkillsPlayer(game, fixture.guild);
assert.ok(enemy.reserves >= 7);
enemy.reserves -= 7;
enemy.forces[`${fixture.face}:0`] += 7;
fixture.staging.push('Conserved seven original Guild reserve counters into its actually entered Shrine, providing eight physical opposing battle counters.');
game = advanceDiscoveryNativeE2Skills(game, g => g.phase === 6 && discoveryNativeE2SkillsClean(g), fixture.actions);
game = openSkillsTechBattle(game, fixture.richese, fixture.guild, fixture.face, true);
const assigned = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const trainer = discoveryNativeE2SkillsPlayer(game, fixture.richese).leaders.find(l => l.id === fixture.trainers.richese)!;
const opponent = discoveryNativeE2SkillsPlayer(game, fixture.guild).leaders.filter(l => !l.dead && !l.usedAt && !assigned.has(l.id) &&
l.strength <= trainer.strength + 1 && l.strength >= trainer.strength - 3).sort((a, b) => b.strength - a.strength)[0];
assert.ok(opponent);
const opposingDial = trainer.strength + 1 - opponent.strength;
assert.ok(opposingDial * (game.advanced ? 2 : 1) <= 8);
const materialized = Math.min(value, discoveryNativeE2SkillsPlayer(game, fixture.richese).reserves);
return { beforeShipment, shipmentStep, afterShipment, game, token: physical.id, value, materialized, key: `${fixture.face}:0`,
ownerPlan: { type: 'battlePlan', leader: fixture.trainers.richese, dial: 0, support: 0, weapon: fixture.green, defense: defense.id },
opponentPlan: { type: 'battlePlan', leader: opponent.id, dial: opposingDial, support: 0 } };
}
export function finishDiscoveryNativeE2SkillsBattle(game: Game, stop: 'cleanup' | 'complete' = 'complete'): Game {
return finishSkillsTechBattle(game, stop);
}
+390
View File
@@ -0,0 +1,390 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game, type Player,
} from '../game/engine';
import { territory } from '../game/board';
import type { Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { nextMoritaniSkillsModulesStep, moritaniSkillsModulesPolicy } from './fixture-moritani-skills-modules';
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
export type DiscoveryNativeE3SkillsStep = { actor: string; action: Action };
export type DiscoveryNativeE3SkillsOptions = {
/** Authenticated fresh lobby or its ORIGINAL undealt setup, never a played save. */
initial?: Game;
rules?: 'basic' | 'advanced';
tech?: boolean;
strongholds?: boolean;
normalCall?: boolean;
battle?: 'nested' | 'tueks_sietch';
};
export type DiscoveryNativeE3SkillsFixture = {
initial: Game; setup: Game; afterSetup: Game; afterFirstStorm: Game;
beforeReveal: Game; afterReveal: Game;
beforeFirstMentat: Game; firstMentatStep: DiscoveryNativeE3SkillsStep; afterFirstMentat: Game;
entry: { before: Game; step: DiscoveryNativeE3SkillsStep; after: Game };
shipment: { before: Game; step: DiscoveryNativeE3SkillsStep; after: Game };
beforeBattle: Game; battleStep: DiscoveryNativeE3SkillsStep; game: Game; revealed: Game; pending: Game;
moritani: string; opponent: string; target: string; moritaniLeader: string; opponentLeader: string;
token: string; source: string; location: string; battleTerritory: string;
moritaniCards: string[]; opponentCards: string[]; plans: DiscoveryNativeE3SkillsStep[];
actions: DiscoveryNativeE3SkillsStep[]; staging: string[];
};
export function discoveryNativeE3SkillsPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
}
export const discoveryNativeE3SkillsClean = (game: Game) =>
!game.response && !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard;
export const discoveryNativeE3SkillsPolicy = moritaniSkillsModulesPolicy;
export function nextDiscoveryNativeE3SkillsStep(game: Game): DiscoveryNativeE3SkillsStep | null {
if (!game.response && !game.phaseOpening && game.decision?.kind === 'discoveryEntry')
return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, accept: false } };
if (!game.response && !game.phaseOpening && game.decision?.kind === 'sukRescue') {
const d = game.decision, 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) } };
}
return nextMoritaniSkillsModulesStep(game);
}
function act(game: Game, step: DiscoveryNativeE3SkillsStep, actions?: DiscoveryNativeE3SkillsStep[]): Game {
actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
}
export function advanceDiscoveryNativeE3Skills(state: Game, until: (g: Game) => boolean,
actions?: DiscoveryNativeE3SkillsStep[]): Game {
let game = state;
for (let n = 0; n < 2200; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextDiscoveryNativeE3SkillsStep(game); assert.ok(next, 'Original sealed plans need their actual owners.');
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = 0;
game = act(game, next, actions);
}
throw Error('Original Discovery/Moritani skill chronology did not reach its boundary.');
}
/** Explicit zero-support original plans finish the deliberately separate conflict
* before later-turn proofs. Immediate primary-battle receipts remain separate. */
export function finishDiscoveryNativeE3SkillsBattles(state: Game,
actions?: DiscoveryNativeE3SkillsStep[]): Game {
let game = state;
for (let n = 0; n < 300; n++) {
if (game.phase !== 6) return game;
const next = nextDiscoveryNativeE3SkillsStep(game);
if (next) { game = act(game, next, actions); continue; }
assert.ok(game.battle && !game.battle.revealed);
for (const actor of [game.battle.attacker, game.battle.defender]) {
if (game.battle!.plans[actor]) continue;
const owner = discoveryNativeE3SkillsPlayer(game, actor);
const living = owner.leaders.filter(leader => !leader.dead &&
!game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id) &&
(!leader.usedAt || leader.usedAt === game.battle!.territory));
living.sort((a, b) => owner.faction === 'moritani' ? b.strength - a.strength : a.strength - b.strength);
assert.ok(living[0]);
game = act(game, { actor, action: { type: 'battlePlan', leader: living[0].id,
dial: 0, support: 0, weapon: null, defense: null } }, actions);
}
}
throw Error('Original remaining conflict did not complete.');
}
function prepareDiscoveryNativeE3HiddenPlans(state: Game, actions: DiscoveryNativeE3SkillsStep[]): Game {
let game = state;
for (let n = 0; n < 160; n++) {
const next = nextDiscoveryNativeE3SkillsStep(game);
if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; }
if (next.action.type === 'leaderSkillVisibility') next.action.hide = true;
game = act(game, next, actions);
}
throw Error('Original trained-disc battle plans did not open.');
}
function entropy<T>(operation: () => T, choose: (index: number) => number): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let index = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
const value = choose(index++);
if (value < 0) return Reflect.apply(original, crypto, [array]) as V;
assert.ok(array instanceof Uint32Array && array.length === 1); array[0] = value; return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Physical first all14 shuffle only. No injected/replaced offers or hidden hands. */
function initializeOffers(game: Game): Game {
const current = LEADER_SKILL_CARDS.map(c => c.id) as LeaderSkillId[];
const skills = game.players.map(p => p.faction === 'guild' ? 'suk-graduate' :
p.faction === 'moritani' ? 'warmaster' :
(['killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz', 'master-of-assassins'] as LeaderSkillId[])[
game.players.filter(q => !['guild', 'moritani'].includes(q.faction)).findIndex(q => q.id === p.id)]);
const desired = Array<LeaderSkillId>(current.length); skills.forEach((skill, n) => { desired[n * 2] = skill; });
const rest = current.filter(c => !skills.includes(c));
for (let n = 0; n < desired.length; n++) if (!desired[n]) desired[n] = rest.shift()!;
let index = current.length - 1;
return entropy(() => initializeLeaderSkillsGameForAudit(game), () => {
if (index < 1) return -1;
const swap = current.indexOf(desired[index]); assert.ok(swap <= index);
const scalar = Math.floor((swap + 0.5) / (index + 1) * 0x100000000);
[current[index], current[swap]] = [current[swap], current[index]]; index--; return scalar;
});
}
/** Ready/initialize this original lobby, or preserve an original undealt setup. */
export function initializeDiscoveryNativeE3Skills(options: DiscoveryNativeE3SkillsOptions = {}): Game {
const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true;
const tech = options.tech ?? !!options.initial?.techTokens;
const strongholds = options.strongholds ?? !!options.initial?.strongholdCards;
let game = options.initial ? structuredClone(options.initial)
: createGame('DISCOVERYNATIVEE3SKILLS', newPlayer('moritani', 'Moritani', 'moritani'), advanced, ['ecaz']);
if (!options.initial) for (const faction of ['guild', 'emperor'] as FactionId[]) joinGame(game, newPlayer(faction, faction, faction));
assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']);
assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6);
assert.equal(game.players.filter(p => p.faction === 'moritani').length, 1);
assert.equal(game.players.filter(p => p.faction === 'guild').length, 1);
assert.equal(new Set(game.players.map(p => p.faction)).size, game.players.length);
assert.ok(game.players.every(p => ['moritani', 'guild', 'atreides', 'emperor', 'fremen', 'beneGesserit',
...(advanced ? [] : ['harkonnen'])].includes(p.faction)));
assert.ok(!strongholds || advanced);
assert.ok(!game.nexusCards && !game.homeworlds && !game.ecazTreachery && !game.spiceBankerIncomePreview && !game.mentatQuestionPreview);
if (game.status === 'lobby') {
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
game.discoveryEnabled = true;
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
return initializeOffers(game);
}
assert.ok(game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
game.leaderSkills?.assignments.length === 0 && game.players.every(p =>
p.hand.length <= (p.faction === 'harkonnen' ? 2 : 1) && p.traitors.length === 0 && p.traitorChoices.length === 0),
'Only an ORIGINAL unassigned native setup may be continued; printed starting hands are preserved.');
assert.ok(game.discoveryEnabled && game.discoveries && game.leaderSkills);
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
return game;
}
export function completeDiscoveryNativeE3SkillsSetup(state: Game, actions?: DiscoveryNativeE3SkillsStep[]): Game {
let game = state;
for (let n = 0; game.status === 'setup' && n < 240; n++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!, p = discoveryNativeE3SkillsPlayer(game, actor);
const requested = p.faction === 'guild' ? 'suk-graduate' : undefined;
const available = (c: LeaderSkillId) => !own.unavailableSkills?.[c];
const skill = requested && offer.cards.includes(requested) && available(requested) ? requested
: offer.cards.find(c => !['mentat', 'spice-banker', 'diplomat'].includes(c) && available(c))
?? offer.cards.find(available);
const leader = p.faction === 'guild' ? own.eligibleLeaders.find(l => l.name === 'Master Bewt') : own.eligibleLeaders[0];
assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader,
'Actual original offers must supply this consumer; never substitute an injected offer.');
game = act(game, { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }, actions);
} else { const next = nextDiscoveryNativeE3SkillsStep(game); assert.ok(next); game = act(game, next, actions); }
}
assert.equal(game.status, 'playing'); return game;
}
function reserveExcept(game: Game, keep: string[]): void {
for (const p of game.players) for (const [key, amount] of Object.entries(p.forces)) {
if (keep.includes(key)) continue;
p.reserves += amount; delete p.forces[key];
if (p.elites) { p.elites.reserves += p.elites.forces[key] ?? 0; delete p.elites.forces[key]; }
if (p.advisors) delete p.advisors[key.split(':')[0]];
}
}
function place(game: Game, actor: string, location: string, amount: number): void {
const p = discoveryNativeE3SkillsPlayer(game, actor); assert.ok(p.reserves - (p.elites?.reserves ?? 0) >= amount);
p.reserves -= amount; p.forces[location] = (p.forces[location] ?? 0) + amount;
}
function held(game: Game, actor: string, matches: (c: Card) => boolean, staging: string[], excluded: string[] = []): string {
const p = discoveryNativeE3SkillsPlayer(game, actor), match = (c: Card) => matches(c) && !excluded.includes(c.id);
const owned = p.hand.find(match); if (owned) return owned.id;
assert.ok(p.hand.length < handLimit(p));
const index = game.deck.findIndex(match); assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0]; p.hand.push(card);
staging.push(`Conserved original Ecaz33 ${card.id}: remaining deck → ${actor}'s hand.`); return card.id;
}
export function revealDiscoveryNativeE3SkillsPlans(game: Game, plans: DiscoveryNativeE3SkillsStep[],
actions?: DiscoveryNativeE3SkillsStep[]): Game {
for (const plan of plans) {
game = act(game, plan, actions);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const next = nextDiscoveryNativeE3SkillsStep(game); assert.ok(next); game = act(game, next, actions);
}
}
assert.ok(game.battle?.revealed); return game;
}
export function resolveDiscoveryNativeE3SkillsBattle(game: Game, moritani: string, normalCall = false,
actions?: DiscoveryNativeE3SkillsStep[]): Game {
for (let n = 0; n < 180; n++) {
if (game.decision?.kind === 'moritaniAssassinate' || !game.battle && discoveryNativeE3SkillsClean(game)) return game;
const next = nextDiscoveryNativeE3SkillsStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani;
game = act(game, next, actions);
}
throw Error('Original nested battle suffix did not settle.');
}
/** Human sequence: native training/Traitors/start → actual printed blow → Guild
* shipment/inspect/reveal → first END Mentat claims → original free entry →
* Weather Control zero → paid Moritani nested shipment → Battle plans → Suk →
* after-loss assassination → winner cards/Tech → actual Mentat replacement.
* Explicit physical lottery/card/board staging below is not a natural history. */
export function createDiscoveryNativeE3SkillsFixture(options: DiscoveryNativeE3SkillsOptions = {}): DiscoveryNativeE3SkillsFixture {
let game = initializeDiscoveryNativeE3Skills(options);
const initial = options.initial ? structuredClone(options.initial) : structuredClone(game);
const setup = structuredClone(game), actions: DiscoveryNativeE3SkillsStep[] = [], staging: string[] = [];
game = completeDiscoveryNativeE3SkillsSetup(game, actions); const afterSetup = structuredClone(game);
const moritani = game.players.find(p => p.faction === 'moritani')!.id, opponent = game.players.find(p => p.faction === 'guild')!.id;
const printed = 'discovery-hagga-basin', placement = DISCOVERY_CARD_PLACEMENTS[printed];
const source = `${placement.territory}:${placement.sector}`;
const index = game.spiceDeck.findIndex(c => 'territory' in c && c.discovery === printed); assert.ok(index >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
for (let position = 1; position < 14; position++) {
const ordinary = game.spiceDeck.findIndex((c, i) => i >= position && 'territory' in c && !c.discovery);
assert.ok(ordinary >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]);
}
reserveExcept(game, []);
staging.push('Conserved native board counters through own reserves; reordered existing unplayed printed Discovery and ordinary Spice Cards only.');
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 1 && discoveryNativeE3SkillsClean(g), actions);
const afterFirstStorm = structuredClone(game);
const tokens = game.discoveries!.tokens.filter(t => t.status === 'supply' && t.type === 'hiereg');
const selected = tokens.findIndex(t => t.face === 'cistern'); assert.ok(selected >= 0);
game = entropy(() => advanceDiscoveryNativeE3Skills(game,
g => g.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed'), actions),
() => Math.floor((selected + 0.5) / tokens.length * 0x100000000));
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 5 && g.active === opponent && discoveryNativeE3SkillsClean(g), actions);
game = act(game, { actor: opponent, action: { type: 'ship', territory: placement.territory, sector: placement.sector, amount: 3 } }, actions);
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 7 && discoveryNativeE3SkillsClean(g), actions);
const token = game.discoveries!.tokens.find(t => t.face === 'cistern')!.id, beforeReveal = structuredClone(game);
game = act(game, { actor: opponent, action: { type: 'discovery', token, reveal: false } }, actions);
game = act(game, { actor: opponent, action: { type: 'discovery', token, reveal: true } }, actions);
const afterReveal = structuredClone(game);
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 8 && discoveryNativeE3SkillsClean(g), actions);
reserveExcept(game, [source]);
place(game, moritani, `tueks_sietch:${territory('tueks_sietch').sectors[0]}`, 1);
place(game, opponent, `carthag:${territory('carthag').sectors[0]}`, 1);
staging.push('Before actual first END Mentat: one conserved claim counter per combatant; preserve three real Guild Discovery-source arrivals.');
let beforeFirstMentat: Game | undefined, firstMentatStep: DiscoveryNativeE3SkillsStep | undefined;
while (game.turn === 1) {
const next = nextDiscoveryNativeE3SkillsStep(game); assert.ok(next);
const before = structuredClone(game); game = act(game, next, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep); const afterFirstMentat = structuredClone(game);
game = advanceDiscoveryNativeE3Skills(game, g => g.decision?.kind === 'discoveryEntry' && g.decision.player === opponent, actions);
const entryBefore = structuredClone(game), own = viewGame(game, opponent);
const entryAction = discoveryEntryMoveAction(own, own.discoveryEntry!.sources); assert.ok(entryAction);
const entryStep = { actor: opponent, action: entryAction }; game = act(game, entryStep, actions);
const entry = { before: entryBefore, step: entryStep, after: structuredClone(game) };
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 0 && discoveryNativeE3SkillsClean(g), actions);
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
staging.push('Returned original physical hands to their same Ecaz33 deck before source-local card transfers; no face manufacture.');
const weather = held(game, moritani, c => c.effect === 'weather', staging);
game = act(game, { actor: moritani, action: { type: 'card', card: weather, amount: 0 } }, actions);
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 5 && g.active === moritani && discoveryNativeE3SkillsClean(g), actions);
const shipmentBefore = structuredClone(game), shipmentStep: DiscoveryNativeE3SkillsStep = {
actor: moritani, action: { type: 'ship', territory: 'cistern', sector: 0, amount: 1 } };
game = act(game, shipmentStep, actions);
game = advanceDiscoveryNativeE3Skills(game, g => !g.pendingShipment && discoveryNativeE3SkillsClean(g), actions);
const shipment = { before: shipmentBefore, step: shipmentStep, after: structuredClone(game) };
const battleTerritory = options.battle === 'tueks_sietch' ? 'tueks_sietch' : 'cistern';
const location = `${battleTerritory}:${battleTerritory === 'cistern' ? 0 : territory(battleTerritory).sectors[0]}`;
reserveExcept(game, ['cistern:0']);
for (const actor of [moritani, opponent]) place(game, actor, location, 6 - (discoveryNativeE3SkillsPlayer(game, actor).forces[location] ?? 0));
staging.push(`Before actual movement completion: top up ${location} to six conserved ordinary counters per combatant; preserve original free/paid Cistern entry and earned card custody.`);
const secondTerritory = 'wind_pass';
const secondSector = territory(secondTerritory).sectors.find(sector => sector !== game.storm)!;
for (const actor of [moritani, opponent]) place(game, actor, `${secondTerritory}:${secondSector}`, 1);
staging.push('Conserved one ordinary counter per combatant at Wind Pass for a second real conflict; keep immediate battle receipts separate from later Collection.');
const moritaniCards = [held(game, moritani, c => c.kind === 'worthless', staging)];
moritaniCards.push(held(game, moritani, c => c.kind === 'worthless', staging, moritaniCards));
const opponentCards = [held(game, opponent, c => c.kind === 'worthless', staging)];
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const ownLeader = discoveryNativeE3SkillsPlayer(game, moritani).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
const enemyLeader = discoveryNativeE3SkillsPlayer(game, opponent).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
const target = game.leaderSkills!.assignments.find(a => a.owner === opponent)!.leader;
assert.ok(ownLeader && enemyLeader && enemyLeader.id !== target);
holdMoritaniStrongholdsTraitor(game, moritani, options.normalCall ? enemyLeader.id : target, staging);
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 6 && discoveryNativeE3SkillsClean(g), actions);
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok([moritani, opponent].includes(actor));
const battleStep: DiscoveryNativeE3SkillsStep = { actor, action: { type: 'chooseBattle', territory: battleTerritory, target: actor === moritani ? opponent : moritani } };
game = act(game, battleStep, actions);
game = advanceDiscoveryNativeE3Skills(game, g => !!g.battle && !g.battle.revealed && !nextDiscoveryNativeE3SkillsStep(g), actions);
const plans: DiscoveryNativeE3SkillsStep[] = [
{ actor: moritani, action: { type: 'battlePlan', leader: ownLeader.id, dial: 1, support: game.advanced ? 1 : 0, weapon: moritaniCards[0], defense: moritaniCards[1] } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 3, support: game.advanced ? 3 : 0, weapon: opponentCards[0] } },
];
const prepared = structuredClone(game), revealed = revealDiscoveryNativeE3SkillsPlans(game, plans, actions);
const pending = resolveDiscoveryNativeE3SkillsBattle(structuredClone(revealed), moritani, options.normalCall, actions);
validateLeaderSkills(pending.leaderSkills!, pending.players); validateDiscoveryState(pending.discoveries!);
return { initial, setup, afterSetup, afterFirstStorm, beforeReveal, afterReveal, beforeFirstMentat, firstMentatStep,
afterFirstMentat, entry, shipment, beforeBattle, battleStep, game: prepared, revealed, pending,
moritani, opponent, target, moritaniLeader: ownLeader.id, opponentLeader: enemyLeader.id,
token, source, location, battleTerritory, moritaniCards, opponentCards, plans, actions, staging };
}
export type DiscoveryNativeE3SkillsDeath = {
before: Game; revealed: Game; after: Game; territory: string; leader: string; winnerLeader: string;
battleStep: DiscoveryNativeE3SkillsStep; plans: DiscoveryNativeE3SkillsStep[];
};
export type DiscoveryNativeE3SkillsRevivalFixture = {
discovery: DiscoveryNativeE3SkillsFixture; beforeDeaths: Game; deaths: DiscoveryNativeE3SkillsDeath[];
revivalWindow: Game; revivalStep: DiscoveryNativeE3SkillsStep; offered: Game;
moritani: string; opponent: string; leader: string; weapon: string;
actions: DiscoveryNativeE3SkillsStep[]; staging: string[];
};
/** Five REAL battles in one original Battle phase kill each original Moritani
* disc once. Both sides use five distinct native discs; the same physical Guild
* poison is retained after each zero-dial win. No injected deaths, wallet,
* revival-cycle, phase or receipt. The next original Revival pays printed cost. */
export function createDiscoveryNativeE3SkillsRevivalFixture(): DiscoveryNativeE3SkillsRevivalFixture {
const discovery = createDiscoveryNativeE3SkillsFixture();
const { moritani, opponent } = discovery, actions = [...discovery.actions], staging = [...discovery.staging];
let game = structuredClone(discovery.pending);
const d = game.decision; assert.equal(d?.kind, 'moritaniAssassinate');
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected actual original loss opportunity.');
game = act(game, { actor: moritani, action: { type: 'decision', event: d.event, decline: true } }, actions);
game = finishDiscoveryNativeE3SkillsBattles(game, actions);
game = advanceDiscoveryNativeE3Skills(game, g => g.turn === 3 && g.phase === 5 && discoveryNativeE3SkillsClean(g), actions);
reserveExcept(game, []);
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
const weapon = held(game, opponent, c => c.kind === 'poison', staging);
const territories = ['cielago_east', 'wind_pass', 'plastic_basin', 'pasty_mesa', 'false_wall_west'];
const moritaniLeaders = discoveryNativeE3SkillsPlayer(game, moritani).leaders.filter(l => !l.dead);
const guildLeaders = discoveryNativeE3SkillsPlayer(game, opponent).leaders.filter(l => !l.dead);
assert.equal(moritaniLeaders.length, 5); assert.equal(guildLeaders.length, 5);
for (const site of territories) {
const sector = territory(site).sectors.find(s => s !== game.storm); assert.notEqual(sector, undefined);
place(game, moritani, `${site}:${sector}`, 1); place(game, opponent, `${site}:${sector}`, 1);
}
staging.push('Before real turn-three Movement completes: conserved one ordinary counter per combatant at each of five original territories; one original Guild poison remains reusable through winner retention.');
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 6 && discoveryNativeE3SkillsClean(g), actions);
const beforeDeaths = structuredClone(game), deaths: DiscoveryNativeE3SkillsDeath[] = [];
for (const [index, site] of territories.entries()) {
const before = structuredClone(game), actor = game.active!; assert.ok([moritani, opponent].includes(actor));
const battleStep: DiscoveryNativeE3SkillsStep = { actor, action: { type: 'chooseBattle', territory: site,
target: actor === moritani ? opponent : moritani } };
game = act(game, battleStep, actions);
game = prepareDiscoveryNativeE3HiddenPlans(game, actions);
const plans: DiscoveryNativeE3SkillsStep[] = [
{ actor: moritani, action: { type: 'battlePlan', leader: moritaniLeaders[index].id, dial: 0, support: 0 } },
{ actor: opponent, action: { type: 'battlePlan', leader: guildLeaders[index].id, dial: 0, support: 0, weapon } },
];
game = revealDiscoveryNativeE3SkillsPlans(game, plans, actions); const revealed = structuredClone(game);
game = resolveDiscoveryNativeE3SkillsBattle(game, moritani, false, actions);
if (game.decision?.kind === 'moritaniAssassinate') game = act(game, { actor: moritani,
action: { type: 'decision', event: game.decision.event, decline: true } }, actions);
game = advanceDiscoveryNativeE3Skills(game, g => !g.battle && discoveryNativeE3SkillsClean(g), actions);
deaths.push({ before, revealed, after: structuredClone(game), territory: site,
leader: moritaniLeaders[index].id, winnerLeader: guildLeaders[index].id, battleStep, plans });
}
assert.ok(discoveryNativeE3SkillsPlayer(game, moritani).leaders.every(l => l.dead));
const assignment = beforeDeaths.leaderSkills!.assignments.find(a => a.owner === moritani); assert.ok(assignment);
game = advanceDiscoveryNativeE3Skills(game, g => g.turn === 4 && g.phase === 4 && discoveryNativeE3SkillsClean(g), actions);
const revivalWindow = structuredClone(game), revivalStep: DiscoveryNativeE3SkillsStep = {
actor: moritani, action: { type: 'reviveLeader', leader: assignment.leader } };
game = act(game, revivalStep, actions);
game = advanceDiscoveryNativeE3Skills(game, g => g.decision?.kind === 'leaderSkillRevival' && g.decision.player === moritani, actions);
return { discovery, beforeDeaths, deaths, revivalWindow, revivalStep, offered: game,
moritani, opponent, leader: assignment.leader, weapon, actions, staging };
}