Compose native trained Skills Nexus Discovery modules
This commit is contained in:
1 parent
958d7b3436
commit
2270c07f97
18 files changed
+2510
-18
No files matched your search
@@ -57,7 +57,7 @@ void test('Discovery Skills cannot silently drop unsupported native families or
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
|
||||
const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
|
||||
overlays[0].homeworlds = { custody: null };
|
||||
overlays[1] = lobby(['ixians', 'tleilaxu', 'guild'], ['ix']);
|
||||
overlays[1] = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam']);
|
||||
overlays[1].nexusCards = { cards: null, phase: null };
|
||||
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
|
||||
overlays[3].mentatQuestionPreview = true;
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, 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, splitLocation } from '../game/board';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { pairedDiscoveryNexusClean as clean, placePairedDiscoveryNexus, reservePairedDiscoveryNexusBoard } from './fixture-discovery-paired-nexus';
|
||||
import {
|
||||
advanceDiscoveryPairedNexusE1Skills as advance, beginDiscoveryPairedNexusE1SkillsCunning as cunning,
|
||||
closeDiscoveryPairedNexusE1Skills as close, createDiscoveryPairedNexusE1SkillsBattle as battle,
|
||||
createDiscoveryPairedNexusE1SkillsEntry as entry, discoveryPairedNexusE1SkillsPlayer as player,
|
||||
discoveryPairedNexusE1SkillsRangeSource as rangeSource, finishDiscoveryPairedNexusE1SkillsBattle as finish,
|
||||
initializeDiscoveryPairedNexusE1SkillsSetup as setup, prepareDiscoveryPairedNexusE1SkillsPlans as prepare,
|
||||
revealDiscoveryPairedNexusE1SkillsBattle as reveal, stepDiscoveryPairedNexusE1Skills as step,
|
||||
type DiscoveryPairedNexusE1SkillsBattle,
|
||||
} from './fixture-discovery-paired-nexus-e1-skills';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected original action leaves the whole original input untouched');
|
||||
}
|
||||
function custody(game: Game, original: Game): void {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? [])];
|
||||
assert.deepEqual(cards.map(c => c.id).sort(), treacheryDeck(['ix']).map(c => c.id).sort());
|
||||
const identities = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => [
|
||||
...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(c => c.leader),
|
||||
])];
|
||||
assert.deepEqual(identities.sort(), traitorDeck(game.players, true).sort());
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
assert.deepEqual([...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards),
|
||||
...game.leaderSkills!.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
|
||||
validateDiscoveryState(game.discoveries!); validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
|
||||
if (p.elites) {
|
||||
const previous = player(original, p.id).elites!;
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
||||
previous.reserves + previous.tanks + Object.values(previous.forces).reduce((sum, n) => sum + n, 0));
|
||||
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
|
||||
for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Select real Cunning commitment, never assign a loss receipt. */
|
||||
function rescueWindow(f: DiscoveryPairedNexusE1SkillsBattle, revealed = reveal(f)): Game {
|
||||
let game = finish(revealed, 'losses');
|
||||
if (game.decision?.kind === 'battleLosses') {
|
||||
const choice = game.decision.options.findIndex(o => o.normal === 4 && o.elite === 2); assert.ok(choice >= 0);
|
||||
game = step(game, { actor: game.decision.player, action: { type: 'decision', choice } });
|
||||
}
|
||||
game = finish(game, 'rescue');
|
||||
assert.equal(game.decision?.kind, 'sukRescue');
|
||||
return game;
|
||||
}
|
||||
function rescueAndSubstitute(f: DiscoveryPairedNexusE1SkillsBattle, pending: Game): Game {
|
||||
assert.ok(pending.decision?.kind === 'sukRescue');
|
||||
const choice = pending.decision.options.findIndex(o => o.normal === 2 && o.elite === 1 && o.kept?.kind === 'normal');
|
||||
assert.ok(choice >= 0);
|
||||
const rescued = step(pending, { actor: f.owner, action: { type: 'decision', event: pending.decision.event, choice } });
|
||||
assert.equal(rescued.decision?.kind, 'ixSubstitution');
|
||||
return advance(step(rescued, { actor: f.owner, action: { type: 'decision',
|
||||
sources: { [f.location]: 1 }, recover: { [f.location]: 1 } } }),
|
||||
game => clean(game) || game.decision?.kind === 'battleCards' || game.decision?.kind === 'faceDance');
|
||||
}
|
||||
|
||||
const MODES = [
|
||||
{ advanced: false, tech: false, strongholds: false },
|
||||
{ advanced: false, tech: true, strongholds: false },
|
||||
{ advanced: true, tech: false, strongholds: false },
|
||||
{ advanced: true, tech: true, strongholds: false },
|
||||
{ advanced: true, tech: false, strongholds: true },
|
||||
{ advanced: true, tech: true, strongholds: true },
|
||||
] as const;
|
||||
for (const mode of MODES) void test(`paired E1 Skills ${JSON.stringify(mode)}: original hands, printed collection, END Mentat, Maker and qualified closing`, () => {
|
||||
const f = entry(mode);
|
||||
assert.deepEqual(f.offered.players.map(p => [p.id, p.hand]), f.afterSetup.players.map(p => [p.id, p.hand]));
|
||||
assert.ok(f.offered.players.every(p => !p.traitors.length && !p.traitorChoices.length));
|
||||
assert.equal(player(f.offered, f.native).faceDancers, undefined);
|
||||
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(f.setup.spiceDeck.filter(c => 'worm' in c && c.greatMaker).length, 1);
|
||||
assert.equal(f.setup.spiceDeck.filter(c => 'sandtrout' in c && c.sandtrout).length, 1);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0); assert.equal(f.game.decision?.kind, 'discoveryEntry');
|
||||
assert.ok(Object.values(f.game.nexusCards!.cards!.hands).every(c => c === null));
|
||||
assert.equal(player(f.game, f.owner).forces[f.source], 3);
|
||||
assert.equal(player(f.game, f.owner).elites!.forces[f.source], 1);
|
||||
if (mode.strongholds) {
|
||||
assert.equal(f.firstMentat.before.strongholdCards!.owners.arrakeen, null);
|
||||
assert.equal(f.firstMentat.after.strongholdCards!.owners.arrakeen, f.owner);
|
||||
}
|
||||
const c = close(f), before = player(c.entry.before, f.owner), arrived = player(c.entry.after, f.owner);
|
||||
assert.equal(arrived.forces['shrine:0'], 2); assert.equal(arrived.elites!.forces['shrine:0'], 1);
|
||||
assert.equal(arrived.forces[f.source], 1); assert.equal(arrived.elites!.forces[f.source] ?? 0, 0);
|
||||
assert.equal(arrived.spice, before.spice); assert.equal(arrived.reserves, before.reserves);
|
||||
assert.equal(arrived.shipped, before.shipped); assert.equal(arrived.moved, before.moved);
|
||||
assert.deepEqual(c.entry.after.leaderSkills, c.entry.before.leaderSkills);
|
||||
assert.equal(player(c.vote, f.native).tanks, player(f.game, f.native).tanks + 2);
|
||||
assert.equal(player(c.vote, f.native).forces['hagga_basin:12'] ?? 0, 0);
|
||||
assert.ok(Object.values(c.vote.nexusCards!.cards!.hands).every(card => card === null));
|
||||
assert.equal(player(c.ride.after, f.fremen).forces['polar_sink:0'], 2);
|
||||
assert.equal(player(c.ride.after, f.fremen).elites?.forces['polar_sink:0'] ?? 0, mode.advanced ? 1 : 0);
|
||||
assert.equal(player(c.ride.after, f.fremen).spice, player(c.ride.before, f.fremen).spice);
|
||||
assert.notEqual(c.ride.after.nexusCards!.phase?.stage, 'drawing');
|
||||
assert.equal(player(c.drawing, f.guild).ally, f.fremen); assert.equal(player(c.drawing, f.fremen).ally, f.guild);
|
||||
assert.deepEqual([...c.drawing.nexusCards!.phase!.eligible].sort(), [f.owner, f.native].sort());
|
||||
if (mode.advanced) assert.ok(c.drawing.spiceDiscard[1].some(card => 'territory' in card && card.territory === 'rock_outcroppings'));
|
||||
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.owner], 'ixians');
|
||||
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.native], 'tleilaxu');
|
||||
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.guild], null);
|
||||
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.fremen], null);
|
||||
for (const game of [f.afterSetup, f.game, c.entry.after, c.vote, c.ride.after, c.afterDraw]) custody(game, f.afterSetup);
|
||||
});
|
||||
|
||||
void test('paired E1 authenticated original human actors, existing first hands and actual offers survive continuation without requiring Suk', () => {
|
||||
const lobby = createGame('HUMANPAIREDE1SKILLS', 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(lobby, newPlayer(id, faction, faction));
|
||||
const fresh = setup({ initial: lobby, skill: 'planetologist', tech: true, strongholds: true });
|
||||
assert.deepEqual(fresh.initial, lobby);
|
||||
const original = structuredClone(fresh.offered), offered = original.leaderSkills!.offers[fresh.owner];
|
||||
const continued = entry({ initial: original });
|
||||
assert.deepEqual(continued.initial, original);
|
||||
assert.deepEqual(continued.offered.leaderSkills!.offers, original.leaderSkills!.offers);
|
||||
assert.deepEqual(continued.afterSetup.players.map(p => [p.id, p.name, p.hand]), original.players.map(p => [p.id, p.name, p.hand]));
|
||||
assert.deepEqual(continued.afterSetup.players.map(p => p.id), ['human-ix', 'human-tl', 'human-gu', 'human-fr']);
|
||||
assert.ok(offered.cards.includes(continued.skill));
|
||||
reject(original, fresh.owner, { type: 'leaderSkill', event: offered.event, skill: offered.cards[0],
|
||||
leader: player(original, fresh.native).leaders[0].id });
|
||||
const absent = LEADER_SKILL_CARDS.find(c => !offered.cards.includes(c.id))!.id;
|
||||
assert.throws(() => entry({ initial: original, skill: absent }));
|
||||
assert.deepEqual(original, fresh.offered);
|
||||
custody(continued.game, continued.afterSetup);
|
||||
});
|
||||
|
||||
void test('paired E1 source-clear free entry rejects foreign actors and HMS companions; real Planetologist crosses the printed nested range', () => {
|
||||
const f = entry({ advanced: true, tech: true, strongholds: true, skill: 'planetologist' });
|
||||
assert.ok(f.game.decision?.kind === 'discoveryEntry');
|
||||
const action: Action = { type: 'decision', event: f.game.decision.event, accept: true,
|
||||
groups: [{ source: f.source, normal: 1, elite: 1 }] };
|
||||
reject(f.game, f.native, action);
|
||||
reject(f.game, f.owner, { ...action, groups: [{ source: f.source, normal: 1, elite: 2 }] });
|
||||
reject(f.game, f.owner, { ...action, groups: [{ source: MOBILE_LOCATION, normal: 1, elite: 1 }] });
|
||||
const entered = step(f.game, { actor: f.owner, action });
|
||||
reject(entered, f.owner, action);
|
||||
const c = close(f);
|
||||
let game = advance(c.afterDraw, g => g.phase === 5 && g.active === f.owner && clean(g));
|
||||
reservePairedDiscoveryNexusBoard(game, [MOBILE_LOCATION]);
|
||||
const source = rangeSource(game);
|
||||
assert.equal(gameDistance(game, source, 'shrine:0', key => splitLocation(key).sector === game.storm), 3);
|
||||
placePairedDiscoveryNexus(game, f.owner, source, 3, 1);
|
||||
const before = structuredClone(game), move: Action = { type: 'move', territory: 'shrine', sector: 0,
|
||||
forces: { [source]: 2 }, eliteForces: { [source]: 1 }, planetologist: 'range' };
|
||||
reject(game, f.owner, { ...move, planetologist: undefined });
|
||||
reject(game, f.owner, { ...move, territory: 'shrine', sector: 1 });
|
||||
game = advance(step(game, { actor: f.owner, action: move }), clean);
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 2);
|
||||
assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 1);
|
||||
assert.equal(player(game, f.owner).forces[source], 1);
|
||||
assert.equal(player(game, f.owner).moved, 1);
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
|
||||
assert.equal(player(game, f.owner).forces[MOBILE_LOCATION], player(before, f.owner).forces[MOBILE_LOCATION]);
|
||||
assert.deepEqual(game.leaderSkills, before.leaderSkills);
|
||||
custody(game, f.afterSetup);
|
||||
});
|
||||
|
||||
for (const mode of MODES) void test(`paired E1 Skills ${JSON.stringify(mode)}: full Cunning, trained Suk rescue then genuine equal Cyborg substitution`, () => {
|
||||
const f = battle(mode), prepared = prepare(f), offer = viewGame(prepared, f.owner).nexusSuboids!.offer!;
|
||||
reject(prepared, f.opponent, { type: 'nexusSuboids', event: offer.event });
|
||||
const sealed = step(prepared, f.plans[1]);
|
||||
const ownSealed = step(prepared, { actor: f.owner, action: { ...f.plans[0].action, dial: 6, support: mode.advanced ? 1 : 0 } });
|
||||
reject(ownSealed, f.owner, { type: 'nexusSuboids', event: offer.event });
|
||||
const active = cunning(f, sealed);
|
||||
assert.equal(active.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(active.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
|
||||
assert.deepEqual(player(active, f.owner).forces, player(sealed, f.owner).forces);
|
||||
reject(active, f.owner, { type: 'nexusSuboids', event: offer.event });
|
||||
const revealed = reveal(f), pending = rescueWindow(f, revealed);
|
||||
assert.equal(viewGame(revealed, f.owner).battle!.strongholdEffects[f.owner], null,
|
||||
'An actually retained named Arrakeen Card cannot subsidize the nested site');
|
||||
assert.equal(pending.lastBattleContext!.winner, f.owner);
|
||||
assert.deepEqual(pending.pendingSukRescue!.losses,
|
||||
{ normal: 4, elite: 2, paidNormal: 0, paidElite: mode.advanced ? 2 : 0 });
|
||||
assert.equal(player(pending, f.owner).forces[f.location], 8);
|
||||
assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks);
|
||||
assert.equal(pending.pendingIxSubstitution, undefined);
|
||||
if (mode.strongholds) assert.equal(pending.strongholdCards!.owners.arrakeen, f.owner);
|
||||
const choice = pending.decision!.kind === 'sukRescue'
|
||||
? pending.decision.options.findIndex(o => o.normal === 2 && o.elite === 1 && o.kept?.kind === 'normal') : -1;
|
||||
assert.ok(choice >= 0);
|
||||
reject(pending, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.location]: 1 } });
|
||||
const saved = step(pending, { actor: f.owner, action: { type: 'decision', event: pending.decision!.kind === 'sukRescue' ? pending.decision.event : '', choice } });
|
||||
assert.equal(saved.lastBattleContext!.sukRescue!.completed, true);
|
||||
assert.equal(saved.decision?.kind, 'ixSubstitution');
|
||||
assert.equal(player(saved, f.owner).forces[f.location], 3);
|
||||
assert.equal(player(saved, f.owner).elites!.forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(saved, f.owner).reserves, player(pending, f.owner).reserves + 2);
|
||||
assert.equal(player(saved, f.owner).elites!.reserves, player(pending, f.owner).elites!.reserves + 1);
|
||||
assert.equal(player(saved, f.owner).tanks, player(pending, f.owner).tanks + 3);
|
||||
assert.equal(player(saved, f.owner).elites!.tanks, player(pending, f.owner).elites!.tanks + 1);
|
||||
assert.deepEqual(saved.pendingIxSubstitution!.losses, { [f.location]: 1 });
|
||||
reject(saved, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.replacementSource]: 1 } });
|
||||
reject(saved, f.owner, { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 2 } });
|
||||
const substituted = advance(step(saved, { actor: f.owner, action: { type: 'decision',
|
||||
sources: { [f.location]: 1 }, recover: { [f.location]: 1 } } }),
|
||||
game => clean(game) || game.decision?.kind === 'battleCards' || game.decision?.kind === 'faceDance');
|
||||
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).elites!.tanks, player(pending, f.owner).elites!.tanks);
|
||||
assert.equal(player(substituted, f.owner).spice, player(saved, f.owner).spice);
|
||||
assert.equal(substituted.lastBattleContext!.ixSubstitution!.completed, true);
|
||||
custody(substituted, f.entry.afterSetup);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`paired E1 ${advanced ? 'Advanced' : 'Basic'}: physical winner cleanup and original Tech precede Face Dance, partial Cunning retains Mentat rights`, () => {
|
||||
const f = battle({ advanced, tech: true, strongholds: advanced });
|
||||
assert.ok(f.weapon, 'Controlled original first-hand offer supplies an actually selected weapon');
|
||||
const revealed = reveal(f), rescued = rescueAndSubstitute(f, rescueWindow(f, revealed));
|
||||
const cards = finish(rescued, 'cards'); assert.equal(cards.decision?.kind, 'battleCards');
|
||||
const premature: Action = { type: 'decision', reveal: true, sector: 0, sources: { reserves: 1, [f.replacementSource]: 2 } };
|
||||
reject(cards, f.opponent, premature);
|
||||
assert.equal(cards.lastBattleContext!.sukRescue!.completed, true);
|
||||
assert.equal(cards.lastBattleContext!.ixSubstitution!.completed, true);
|
||||
const cleaned = step(cards, { actor: f.owner, action: { type: 'decision', discard: [f.weapon] } });
|
||||
const face = finish(cleaned, 'faceDance');
|
||||
assert.ok(face.discard.some(c => c.id === f.weapon));
|
||||
assert.ok(!player(face, f.owner).hand.some(c => c.id === f.weapon));
|
||||
assert.equal(player(face, f.opponent).leaders.find(l => l.id === f.enemy)!.dead, true);
|
||||
const defeatedTech = ownedTech(revealed.techTokens, f.opponent); assert.equal(defeatedTech.length, 1);
|
||||
assert.equal(face.techTokens![defeatedTech[0]].owner, f.owner);
|
||||
assert.ok(!face.pendingTech);
|
||||
const winner = structuredClone(player(face, f.owner)), native = structuredClone(player(face, f.opponent));
|
||||
const dance: Action = { type: 'decision', reveal: true, sector: 0, sources: { reserves: 1, [f.replacementSource]: 2 } };
|
||||
reject(face, f.opponent, { ...dance, sector: 1 });
|
||||
reject(face, f.opponent, { ...dance, sources: { [MOBILE_LOCATION]: 3 } });
|
||||
reject(face, f.opponent, { ...dance, sources: { reserves: 4 } });
|
||||
const danced = step(face, { actor: f.opponent, action: dance });
|
||||
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).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(danced, f.owner).reserves, winner.reserves + 3);
|
||||
assert.equal(player(danced, f.owner).elites!.reserves, winner.elites!.reserves + 1);
|
||||
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, native.reserves - 1);
|
||||
assert.deepEqual(danced.techTokens, face.techTokens);
|
||||
assert.deepEqual(player(danced, f.owner).hand, winner.hand);
|
||||
for (const p of face.players) assert.equal(player(danced, p.id).spice, p.spice);
|
||||
reject(danced, f.opponent, dance);
|
||||
const projection = viewGame(danced, f.opponent);
|
||||
const renew = nexusFaceDancersAction(projection, projection.nexusTleilaxu!.cunning!.event); assert.ok(renew);
|
||||
const revealedDancer = player(danced, f.opponent).faceDancers!.find(c => c.leader === f.trainer)!;
|
||||
assert.equal(revealedDancer.revealed, true);
|
||||
reject(danced, f.opponent, { ...renew, leaders: [f.trainer] });
|
||||
const reserve = [...danced.traitorReserve!], allowance = player(danced, f.opponent).faceDancerReplacedTurn;
|
||||
const renewed = step(danced, { actor: f.opponent, action: renew });
|
||||
assert.deepEqual(player(renewed, f.opponent).faceDancers!.filter(c => !native.faceDancers!.some(old => old.leader === c.leader)),
|
||||
[{ leader: reserve[0], revealed: false }]);
|
||||
assert.deepEqual([...renewed.traitorReserve!].sort(), [...reserve.slice(1), f.trainer].sort());
|
||||
assert.equal(player(renewed, f.opponent).faceDancerReplacedTurn, allowance);
|
||||
assert.equal(renewed.nexusCards!.cards!.hands[f.opponent], null);
|
||||
assert.equal(renewed.nexusCards!.cards!.discard.filter(c => c === 'tleilaxu').length, 1);
|
||||
assert.equal(renewed.leaderSkills!.assignments.some(a => a.owner === f.opponent && a.leader === f.trainer), false);
|
||||
reject(renewed, f.opponent, renew);
|
||||
reservePairedDiscoveryNexusBoard(renewed, [f.location, f.replacementSource, MOBILE_LOCATION]);
|
||||
const mentat = advance(renewed, g => g.phase === 8 && clean(g));
|
||||
const untouched = player(mentat, f.opponent).faceDancers!.find(c => !c.revealed && c.leader !== reserve[0]); assert.ok(untouched);
|
||||
const replaced = advance(step(mentat, { actor: f.opponent, action: { type: 'replaceFaceDancer', leader: untouched.leader } }), clean);
|
||||
assert.equal(player(replaced, f.opponent).faceDancerReplacedTurn, replaced.turn);
|
||||
reject(replaced, f.opponent, { type: 'replaceFaceDancer', leader: player(replaced, f.opponent).faceDancers![0].leader });
|
||||
const nextTurn = advance(replaced, g => g.turn > replaced.turn);
|
||||
assert.equal(viewGame(nextTurn, f.owner).nexusSuboids!.active, false);
|
||||
assert.equal(nextTurn.leaderSkills!.deck.filter(s => s === f.skill).length, 1);
|
||||
if (!advanced) {
|
||||
const revival = advance(nextTurn, g => g.turn === 3 && g.phase === 4 && clean(g));
|
||||
reject(revival, f.opponent, { type: 'reviveForeignGhola', leader: f.trainer });
|
||||
assert.equal(player(revival, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
|
||||
assert.equal(revival.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
|
||||
custody(revival, f.entry.afterSetup);
|
||||
}
|
||||
custody(danced, f.entry.afterSetup); custody(replaced, f.entry.afterSetup); custody(nextTurn, f.entry.afterSetup);
|
||||
});
|
||||
|
||||
void test('all four minimal paired E1 policies use eligible original actors and produce entry, vote, Cunning, trained rescue and Face Dance receipts', () => {
|
||||
const f = entry(), c = close(f), b = battle(), prepared = prepare(b), revealed = reveal(b);
|
||||
const pending = rescueWindow(b, revealed);
|
||||
const face = finish(rescueAndSubstitute(b, pending), 'faceDance');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
for (const current of [
|
||||
{ game: f.game, actor: f.owner, type: 'decision' },
|
||||
{ game: c.vote, actor: c.vote.decision!.player, type: 'decision' },
|
||||
{ game: c.ride.before, actor: f.fremen, type: 'decision' },
|
||||
{ game: c.drawing, actor: f.owner, type: 'nexusCardChoice' },
|
||||
{ game: prepared, actor: b.owner, type: 'nexusSuboids' },
|
||||
{ game: pending, actor: b.owner, type: 'decision' },
|
||||
{ game: face, actor: b.opponent, type: 'decision' },
|
||||
]) {
|
||||
const projection = viewGame(current.game, current.actor);
|
||||
projection.players.find(p => p.id === current.actor)!.bot = difficulty;
|
||||
const action = botActions(projection).find(a => a.type === current.type); assert.ok(action);
|
||||
const acted = step(current.game, { actor: current.actor, action });
|
||||
const after = current.type === 'nexusSuboids' ? advance(acted, clean) : acted;
|
||||
if (current.game.decision?.kind === 'discoveryEntry') {
|
||||
assert.equal(after.discoveryEntry, undefined);
|
||||
const own = player(after, current.actor), old = player(current.game, current.actor);
|
||||
assert.equal((own.forces[f.source] ?? 0) + (own.forces['shrine:0'] ?? 0), old.forces[f.source]);
|
||||
assert.equal((own.elites!.forces[f.source] ?? 0) + (own.elites!.forces['shrine:0'] ?? 0), 1);
|
||||
assert.equal(own.spice, old.spice);
|
||||
} else if (current.game.decision?.kind === 'greatMakerVote')
|
||||
assert.equal(after.greatMaker!.votes.length, current.game.greatMaker!.votes.length + 1);
|
||||
else if (current.game.decision?.kind === 'greatMakerRide') {
|
||||
assert.equal(after.greatMaker!.stage, 'complete');
|
||||
assert.equal(player(after, f.fremen).spice, player(current.game, f.fremen).spice);
|
||||
} else if (current.type === 'nexusCardChoice') {
|
||||
assert.ok(after.nexusCards!.phase!.done.includes(current.actor));
|
||||
assert.equal(after.nexusCards!.cards!.hands[current.actor], 'ixians');
|
||||
assert.equal(after.nexusCards!.cards!.deck.length, current.game.nexusCards!.cards!.deck.length - 1);
|
||||
} else if (current.type === 'nexusSuboids') {
|
||||
assert.equal(after.nexusCards!.cards!.hands[b.owner], null);
|
||||
assert.equal(after.nexusCards!.cards!.discard.filter(card => card === 'ixians').length, 1);
|
||||
assert.deepEqual(player(after, b.owner).forces, player(current.game, b.owner).forces);
|
||||
assert.equal(viewGame(after, b.owner).nexusSuboids!.active, true);
|
||||
const own = viewGame(after, b.owner); own.players.find(p => p.id === b.owner)!.bot = difficulty;
|
||||
const plan = botActions(own).find(a => a.type === 'battlePlan'); assert.ok(plan);
|
||||
const committed = step(after, { actor: b.owner, action: plan });
|
||||
assert.equal(committed.battle!.plans[b.owner].leader, plan.leader);
|
||||
assert.equal(player(committed, b.owner).spice, player(after, b.owner).spice);
|
||||
custody(committed, b.entry.afterSetup);
|
||||
} else if (current.game.decision?.kind === 'sukRescue') {
|
||||
assert.equal(after.pendingSukRescue, null);
|
||||
assert.equal(after.lastBattleContext!.sukRescue!.completed, true);
|
||||
assert.equal(player(after, b.owner).forces[b.location], 3);
|
||||
assert.equal(player(after, b.owner).tanks, player(pending, b.owner).tanks + 3);
|
||||
assert.equal(player(after, b.owner).reserves, player(pending, b.owner).reserves + 2);
|
||||
} else {
|
||||
assert.equal(player(after, b.owner).forces[b.location] ?? 0, 0);
|
||||
assert.equal(player(after, b.owner).leaders.find(l => l.id === b.trainer)!.dead, true);
|
||||
assert.equal(after.leaderSkills!.assignments.some(a => a.leader === b.trainer), false);
|
||||
assert.deepEqual(after.techTokens, face.techTokens);
|
||||
}
|
||||
custody(after, current.actor === b.owner || current.actor === b.opponent ? b.entry.afterSetup : f.afterSetup);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, handLimit, joinGame, newPlayer, 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 type { Card } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { validGameLocation, territory } from '../game/board';
|
||||
import { validateDiscoveryState } from '../game/discoveries';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { validateRicheseNoField } from '../game/richese-no-field';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { placePairedSkillsForce, stagePairedSkillsPlanetologistRoute } from './fixture-paired-choam-nexus-skills';
|
||||
import {
|
||||
advanceDiscoveryPairedNexusE2Skills as advance, closeDiscoveryPairedNexusE2Skills as close,
|
||||
completeDiscoveryPairedNexusE2SkillsSetup as completeSetup,
|
||||
createDiscoveryPairedNexusE2SkillsFixture as fixture,
|
||||
discoveryPairedNexusE2SkillsClean as clean,
|
||||
discoveryPairedNexusE2SkillsCunningAction as pairAction,
|
||||
discoveryPairedNexusE2SkillsInvoiceAction as invoice,
|
||||
discoveryPairedNexusE2SkillsPlayer as player,
|
||||
finishDiscoveryPairedNexusE2SkillsBattle as finishBattle,
|
||||
initializeDiscoveryPairedNexusE2Skills as initialize,
|
||||
prepareDiscoveryPairedNexusE2SkillsBattle as prepareBattle,
|
||||
settleDiscoveryPairedNexusE2Skills as settle,
|
||||
stepDiscoveryPairedNexusE2Skills as step,
|
||||
type DiscoveryPairedNexusE2SkillsFixture,
|
||||
} from './fixture-discovery-paired-nexus-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, 'Rejected human declarations cannot spend cards, invoice, counters or training.');
|
||||
}
|
||||
function custody(game: Game): void {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
|
||||
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(['choam']), ...richeseCards()].map(c => c.id).sort());
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, count) => n + count, 0), 20);
|
||||
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((n, count) => n + count, 0), p.faction === 'fremen' ? 3 : 5);
|
||||
if (p.noField) validateRicheseNoField(p.noField);
|
||||
}
|
||||
}
|
||||
function acquiredCard(f: DiscoveryPairedNexusE2SkillsFixture, buyer: string, matches: (card: Card) => boolean): Card {
|
||||
const receipt = f.acquisitions.find(a => a.buyer === buyer && matches(a.card));
|
||||
assert.ok(receipt, 'The intended eligible actor must actually acquire this original card through Bidding.');
|
||||
const { before, after, card } = receipt, auction = before.auction!;
|
||||
assert.equal(before.phase, 3);
|
||||
assert.equal(before.turn, 1);
|
||||
assert.equal(auction.cards[auction.index].id, card.id);
|
||||
assert.equal(auction.bidder, buyer);
|
||||
assert.equal(auction.bid, 1);
|
||||
assert.ok(player(before, buyer).hand.length < handLimit(player(before, buyer)));
|
||||
assert.ok(f.afterSetup.deck.some(c => c.id === card.id));
|
||||
assert.deepEqual(player(after, buyer).hand, [...player(before, buyer).hand, card]);
|
||||
assert.equal(player(after, buyer).spice, player(before, buyer).spice - 1);
|
||||
assert.ok(f.actions.some(s => s.actor === buyer && s.action.type === 'bid' && s.action.amount === 1));
|
||||
return card;
|
||||
}
|
||||
const modules = [
|
||||
{ advanced: false, tech: false, strongholds: false },
|
||||
{ advanced: false, tech: true, strongholds: false },
|
||||
{ advanced: true, tech: false, strongholds: false },
|
||||
{ advanced: true, tech: true, strongholds: false },
|
||||
{ advanced: true, tech: false, strongholds: true },
|
||||
{ advanced: true, tech: true, strongholds: true },
|
||||
] as const;
|
||||
|
||||
for (const options of modules) void test(`original paired E2 Discovery/Skills ${JSON.stringify(options)}: paid collection, real end-Mentat entry, Maker and qualified closing draws`, () => {
|
||||
const f = fixture(options), ending = close(f);
|
||||
const ordinary = [...f.initial.deck, ...f.initial.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.equal(f.initial.richeseCache!.length, 10);
|
||||
assert.equal(f.initial.spiceDeck.length, spiceDeck().length + 7);
|
||||
assert.equal(f.initial.discoveries!.tokens.length, 8);
|
||||
assert.equal(f.initial.leaderSkills!.deck.length + Object.values(f.initial.leaderSkills!.offers).reduce((n, o) => n + o.cards.length, 0), 14);
|
||||
assert.equal(f.initial.nexusCards!.cards!.deck.length, 12);
|
||||
assert.equal(player(f.previousMarker.after, f.richese).spice, player(f.previousMarker.before, f.richese).spice - 1);
|
||||
assert.equal(player(f.previousMarker.after, f.richese).reserves, player(f.previousMarker.before, f.richese).reserves);
|
||||
assert.equal(player(f.collection, f.collector).forces[f.source], 3);
|
||||
assert.equal(validGameLocation(f.collection, 'shrine', 0), false);
|
||||
assert.ok(f.firstMentat);
|
||||
assert.equal(f.firstMentat.before.turn, 1);
|
||||
assert.equal(f.firstMentat.before.phase, 8);
|
||||
assert.equal(f.firstMentat.after.turn, 2);
|
||||
assert.equal(ending.entry.before.phase, 0);
|
||||
assert.equal(ending.entry.before.decision?.player, f.collector);
|
||||
assert.equal(player(ending.entry.after, f.collector).forces[f.source] ?? 0, 0);
|
||||
assert.equal(player(ending.entry.after, f.collector).forces['shrine:0'], 3);
|
||||
assert.equal(player(ending.entry.after, f.collector).spice, player(ending.entry.before, f.collector).spice);
|
||||
assert.equal(validGameLocation(ending.game, 'shrine', 0), true);
|
||||
assert.equal(player(ending.vote, f.choam).forces['hagga_basin:12'] ?? 0, 0);
|
||||
assert.equal(player(ending.vote, f.choam).tanks, player(ending.entry.before, f.choam).tanks + 2);
|
||||
assert.equal(ending.ride.before.decision?.player, f.fremen);
|
||||
assert.equal(player(ending.ride.after, f.fremen).forces['polar_sink:0'], 2);
|
||||
assert.equal(player(ending.ride.after, f.fremen).reserves, player(ending.ride.before, f.fremen).reserves - 2);
|
||||
if (options.advanced) assert.equal(player(ending.ride.after, f.fremen).elites!.forces['polar_sink:0'], 1);
|
||||
assert.equal(ending.alliance.spiceWindow, null);
|
||||
assert.equal(ending.alliance.spiceResolution, null);
|
||||
assert.equal(player(ending.alliance, f.guild).ally, f.fremen);
|
||||
assert.equal(player(ending.alliance, f.fremen).ally, f.guild);
|
||||
assert.equal(ending.drawing.phase, 1);
|
||||
for (const draw of ending.draws) {
|
||||
assert.equal(draw.before.nexusCards!.cards!.hands[draw.step.actor], null);
|
||||
assert.equal(draw.after.nexusCards!.cards!.hands[draw.step.actor], player(draw.after, draw.step.actor).faction);
|
||||
reject(draw.after, draw.step.actor, draw.step.action);
|
||||
}
|
||||
assert.equal(ending.game.nexusCards!.cards!.hands[f.guild], null);
|
||||
assert.equal(ending.game.nexusCards!.cards!.hands[f.fremen], null);
|
||||
assert.deepEqual(ending.game.leaderSkills!.assignments, f.afterSetup.leaderSkills!.assignments);
|
||||
if (options.strongholds) {
|
||||
assert.equal(f.firstMentat.before.strongholdCards!.owners.arrakeen, null);
|
||||
assert.equal(f.firstMentat.after.strongholdCards!.owners.arrakeen, f.choam);
|
||||
}
|
||||
custody(ending.game);
|
||||
});
|
||||
|
||||
void test('saved authenticated original skill offers and starting hands survive continuation without demanding a random consumer', () => {
|
||||
const setup = initialize({ advanced: true, tech: true, strongholds: true,
|
||||
seatIds: ['human-richese', 'human-choam', 'human-guild', 'human-fremen'] });
|
||||
const before = structuredClone(setup), continued = initialize({ initial: reload(setup) });
|
||||
assert.deepEqual(continued, before);
|
||||
const done = completeSetup(continued);
|
||||
for (const assignment of done.leaderSkills!.assignments)
|
||||
assert.ok(before.leaderSkills!.offers[assignment.owner].cards.includes(assignment.skill));
|
||||
assert.deepEqual(done.players.map(p => [p.id, p.hand]), before.players.map(p => [p.id, p.hand]));
|
||||
assert.equal(Object.keys(done.leaderSkills!.offers).length, 0);
|
||||
assert.equal(done.status, 'playing');
|
||||
assert.equal(done.leaderSkills!.assignments.length, 4);
|
||||
custody(done);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6]) void test(`${advanced ? 'Advanced' : 'Basic'} human ${seats}-seat original paired programme trains only original offers with legal native modules`, () => {
|
||||
const factions = ['richese', 'choam', 'guild', 'fremen', 'emperor', 'atreides'] as const;
|
||||
const lobby = createGame(`HUMANE2${seats}`, newPlayer('authenticated-r', 'R', 'richese'), advanced, ['choam']);
|
||||
for (const faction of factions.slice(1, seats)) joinGame(lobby, newPlayer(`authenticated-${faction}`, faction, faction));
|
||||
const setup = initialize({ initial: lobby, tech: seats >= 3, strongholds: advanced });
|
||||
const done = completeSetup(setup);
|
||||
assert.equal(done.players.length, seats);
|
||||
assert.equal(done.leaderSkills!.assignments.length, seats);
|
||||
assert.deepEqual(done.players.map(p => p.id), lobby.players.map(p => p.id));
|
||||
assert.equal(done.status, 'playing');
|
||||
custody(done);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} nested signed two-marker Cunning keeps one physical invoice through native responses/Guild and private reserve caps`, () => {
|
||||
const f = fixture({ advanced, tech: true, strongholds: advanced }), ending = close(f), before = ending.game;
|
||||
const karama = acquiredCard(f, f.fremen, c => c.effect === 'karama');
|
||||
assert.ok(player(before, f.fremen).hand.some(c => c.id === karama.id));
|
||||
const own = player(before, f.richese), previous = own.noField!.tokens.find(t => t.value === 0)!;
|
||||
const request = pairAction(before, f.richese);
|
||||
reject(before, f.richese, { ...request, noField: previous.id });
|
||||
reject(before, f.richese, { ...request, revealedToken: request.noField });
|
||||
reject(before, f.richese, { ...request, nexus: 'stale-own-pair' });
|
||||
reject(before, f.richese, { ...request, territory: 'treachery-card-stash', sector: 0 });
|
||||
reject(before, f.richese, { ...request, sector: 1 });
|
||||
reject(before, f.richese, { ...request, amount: 8 });
|
||||
reject(before, f.richese, { ...request, smuggler: true });
|
||||
for (const cap of [2, 20]) {
|
||||
const capped = reload(before), p = player(capped, f.richese);
|
||||
placePairedSkillsForce(capped, f.richese, 'polar_sink:0', p.reserves - cap, f.staging);
|
||||
const pending = applyAction(capped, f.richese, pairAction(capped, f.richese));
|
||||
assert.equal(pending.response?.kind, 'richeseNoField');
|
||||
assert.equal(pending.pendingShipment!.cost, 1);
|
||||
assert.equal(player(pending, f.richese).spice, p.spice);
|
||||
assert.equal(player(pending, f.richese).reserves, cap);
|
||||
const corrupted = reload(pending), holder = player(corrupted, f.fremen);
|
||||
corrupted.pendingShipment!.cost = 0;
|
||||
reject(corrupted, holder.id, { type: 'passResponse' });
|
||||
const retargeted = reload(pending);
|
||||
retargeted.pendingShipment!.territory = 'arrakeen';
|
||||
reject(retargeted, holder.id, { type: 'passResponse' });
|
||||
const guild = advance(pending, g => g.decision?.kind === 'guildShipment' || clean(g));
|
||||
if (advanced) {
|
||||
assert.equal(guild.decision?.player, f.guild);
|
||||
assert.equal(player(guild, f.richese).spice, p.spice);
|
||||
reject(guild, f.choam, { type: 'decision', allow: true });
|
||||
reject(guild, f.guild, { type: 'decision', allow: false });
|
||||
}
|
||||
const shipped = settle(guild);
|
||||
assert.equal(player(shipped, f.richese).spice, p.spice - 1);
|
||||
assert.equal(player(shipped, f.guild).spice, player(capped, f.guild).spice + 1);
|
||||
assert.equal(player(shipped, f.richese).reserves, cap - Math.min(5, cap));
|
||||
assert.equal(player(shipped, f.richese).forces['shrine:0'], Math.min(5, cap));
|
||||
assert.equal(player(shipped, f.richese).noField!.deployed!.tokenId, request.noField);
|
||||
assert.equal(player(shipped, f.richese).noField!.lastShipped, request.noField);
|
||||
assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null);
|
||||
assert.deepEqual(player(shipped, f.richese).noField!.tokens, own.noField!.tokens);
|
||||
reject(shipped, f.richese, request);
|
||||
assert.equal(player(settle(shipped), f.guild).spice, player(shipped, f.guild).spice);
|
||||
custody(shipped);
|
||||
}
|
||||
reject(before, f.richese, { ...pairAction(before, f.richese), smuggler: true });
|
||||
});
|
||||
|
||||
void test('actual original Karama holder cancels a nested pair without a fee, counter materialization or ordinary substitute entitlement', () => {
|
||||
const f = fixture({ advanced: true, tech: true }), before = close(f).game;
|
||||
const card = acquiredCard(f, f.fremen, c => c.effect === 'karama'), holder = player(before, f.fremen);
|
||||
assert.ok(holder.hand.some(c => c.id === card.id));
|
||||
const request = pairAction(before, f.richese), pending = applyAction(reload(before), f.richese, request);
|
||||
assert.equal(pending.response?.kind, 'richeseNoField');
|
||||
const stopped = settle(applyAction(pending, holder.id, { type: 'card', mode: 'cancel', card: card.id }));
|
||||
assert.equal(stopped.nexusRicheseCunningLast!.stage, 'stopped');
|
||||
assert.equal(stopped.nexusCards!.cards!.hands[f.richese], null);
|
||||
assert.equal(stopped.discard.filter(c => c.id === card.id).length, 1);
|
||||
assert.equal(player(stopped, holder.id).hand.some(c => c.id === card.id), false);
|
||||
for (const p of before.players) {
|
||||
assert.equal(player(stopped, p.id).spice, p.spice);
|
||||
assert.deepEqual(player(stopped, p.id).forces, p.forces);
|
||||
assert.equal(player(stopped, p.id).reserves, p.reserves);
|
||||
}
|
||||
assert.equal(player(stopped, f.richese).noField!.lastShipped, player(before, f.richese).noField!.lastShipped);
|
||||
reject(stopped, f.richese, request);
|
||||
reject(stopped, f.richese, { type: 'ship', amount: 6, territory: 'shrine', sector: 0, smuggler: true });
|
||||
custody(stopped);
|
||||
});
|
||||
|
||||
void test('all four minimal policies pass the actual Karama-holder response then consume the native two-marker invoice once', () => {
|
||||
const f = fixture({ advanced: true, tech: true, strongholds: true }), before = close(f).game;
|
||||
const card = acquiredCard(f, f.fremen, c => c.effect === 'karama');
|
||||
const holder = player(before, f.fremen), request = pairAction(before, f.richese);
|
||||
assert.ok(holder.hand.some(c => c.id === card.id));
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const pending = applyAction(reload(before), f.richese, request), view = viewGame(pending, holder.id);
|
||||
assert.equal(pending.response?.kind, 'richeseNoField');
|
||||
view.players.find(p => p.id === holder.id)!.bot = difficulty;
|
||||
const action = botActions(view).find(a => a.type === 'passResponse');
|
||||
assert.ok(action, `${difficulty} must act as the actual eligible Karama holder.`);
|
||||
const shipped = settle(applyAction(pending, holder.id, action));
|
||||
assert.equal(player(shipped, f.richese).forces['shrine:0'], 5);
|
||||
assert.equal(player(shipped, f.richese).spice, player(before, f.richese).spice - 1);
|
||||
assert.equal(player(shipped, f.guild).spice, player(before, f.guild).spice + 1);
|
||||
assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped');
|
||||
assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null);
|
||||
assert.deepEqual(player(shipped, holder.id).hand, player(before, holder.id).hand);
|
||||
custody(shipped);
|
||||
}
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} original CHOAM Stash draws its one genuine next card without changing its five-card capacity or training`, () => {
|
||||
const f = fixture({ advanced, kind: 'stash' }), before = f.collection;
|
||||
const hand = player(before, f.choam).hand.map(c => c.id), drawn = before.deck[0]; assert.ok(drawn);
|
||||
assert.equal(player(f.game, f.choam).hand.length, hand.length + 1);
|
||||
assert.deepEqual(player(f.game, f.choam).hand.map(c => c.id), [...hand, drawn.id]);
|
||||
assert.equal(f.game.deck.length, before.deck.length - 1);
|
||||
assert.equal(player(f.game, f.choam).spice, player(before, f.choam).spice);
|
||||
assert.equal(f.game.discoveries!.tokens.find(t => t.id === f.token)!.status, 'removed');
|
||||
assert.deepEqual(f.game.leaderSkills!.assignments, before.leaderSkills!.assignments);
|
||||
reject(f.game, f.choam, { type: 'discovery', token: f.token, reveal: true });
|
||||
custody(f.game);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} own CHOAM Cunning consumes actual auction-acquired Special fuel, separate from the trained Smuggler invoice`, () => {
|
||||
const f = fixture({ advanced, tech: true, strongholds: advanced }), ending = close(f);
|
||||
const before = advance(ending.movementStart, g => g.phase === 5 && g.active === f.choam && clean(g));
|
||||
const fuel = acquiredCard(f, f.choam, c => c.kind === 'special' && c.effect !== 'karama');
|
||||
assert.ok(player(before, f.choam).hand.some(c => c.id === fuel.id));
|
||||
const offer = viewGame(before, f.choam).choamWorthless!.plays.find(p => p.source === 'nexus' && p.card.id === fuel.id && p.effect === 'kulon');
|
||||
assert.ok(offer && !offer.blocked && offer.source === 'nexus');
|
||||
const request: Action = { type: 'card', mode: 'choam', card: fuel.id, effect: 'kulon', nexus: offer.event };
|
||||
reject(before, f.choam, { ...request, nexus: 'stale-fuel' });
|
||||
const declared = applyAction(reload(before), f.choam, request);
|
||||
if (declared.response) assert.equal(declared.response.kind, 'choamWorthless');
|
||||
assert.equal(declared.nexusCards!.cards!.hands[f.choam], null);
|
||||
if (declared.response) assert.equal(player(declared, f.choam).hand.some(c => c.id === fuel.id), true);
|
||||
const fueled = settle(declared);
|
||||
assert.equal(fueled.discard.filter(c => c.id === fuel.id).length, 1);
|
||||
assert.equal(player(fueled, f.choam).hand.some(c => c.id === fuel.id), false);
|
||||
assert.equal(fueled.choamMovement!.bonus, 1);
|
||||
reject(fueled, f.choam, request);
|
||||
const shipment = invoice(fueled), paid = settle(applyAction(reload(fueled), f.choam, shipment.action));
|
||||
assert.equal(player(paid, f.choam).forces[shipment.key], 6);
|
||||
assert.equal(player(paid, f.choam).reserves, player(fueled, f.choam).reserves - 6);
|
||||
assert.equal(player(paid, f.choam).spice, player(fueled, f.choam).spice - 5);
|
||||
assert.equal(player(paid, f.guild).spice, player(fueled, f.guild).spice + 5);
|
||||
assert.equal(paid.discard.filter(c => c.id === fuel.id).length, 1);
|
||||
assert.deepEqual(paid.leaderSkills!.assignments, before.leaderSkills!.assignments);
|
||||
custody(paid);
|
||||
});
|
||||
|
||||
void test('all four minimal card policies spend actual physical fuel for Kulon before a separate ordinary skill move', () => {
|
||||
const f = fixture({ advanced: true, choamSkill: 'planetologist', tech: true, strongholds: true }), ending = close(f);
|
||||
const before = advance(ending.movementStart, g => g.phase === 5 && g.active === f.choam && clean(g));
|
||||
const route = stagePairedSkillsPlanetologistRoute(before, f.choam, f.staging);
|
||||
reject(before, f.choam, route.action);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(before, f.choam); view.players.find(p => p.id === f.choam)!.bot = difficulty;
|
||||
const action = botActions(view).find(a => a.type === 'card' && a.mode === 'choam' && a.effect === 'kulon');
|
||||
assert.ok(action);
|
||||
const fuel = player(before, f.choam).hand.find(c => c.id === action.card); assert.ok(fuel);
|
||||
const used = settle(applyAction(reload(before), f.choam, action));
|
||||
assert.equal(used.discard.filter(c => c.id === fuel.id).length, 1);
|
||||
assert.equal(player(used, f.choam).hand.some(c => c.id === fuel.id), false);
|
||||
const moved = settle(applyAction(used, f.choam, route.action));
|
||||
assert.equal(player(moved, f.choam).forces[route.from] ?? 0, 0);
|
||||
assert.equal(player(moved, f.choam).forces[route.to], 1);
|
||||
assert.equal(player(moved, f.choam).moved, 1);
|
||||
assert.equal(moved.discard.filter(c => c.id === fuel.id).length, 1);
|
||||
assert.deepEqual(moved.leaderSkills!.assignments, before.leaderSkills!.assignments);
|
||||
custody(moved);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four eligible Suk decisions follow voluntary nested pair reveal and settled physical casualties before optional Tech transfer', () => {
|
||||
const f = fixture({ advanced: true, tech: true, strongholds: true }), before = close(f).game;
|
||||
const shipped = settle(applyAction(reload(before), f.richese, pairAction(before, f.richese)));
|
||||
const battle = prepareBattle(f, shipped);
|
||||
let game = battle.game;
|
||||
for (const plan of battle.plans) game = settle(step(game, plan));
|
||||
const revealed = reload(game), loserTokens = ownedTech(game.techTokens, f.guild);
|
||||
assert.equal(player(game, f.richese).forces[battle.key], 8);
|
||||
game = advance(game, g => g.decision?.kind === 'sukRescue');
|
||||
assert.equal(game.decision?.player, f.richese);
|
||||
if (game.decision?.kind !== 'sukRescue') throw Error('The genuinely trained battle must reach its actual rescue decision.');
|
||||
const pending = game, decision = game.decision;
|
||||
reject(pending, f.guild, { type: 'decision', event: decision.event, choice: 0 });
|
||||
reject(pending, f.richese, { type: 'decision', event: 'stale-rescue', choice: 0 });
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending, f.richese); view.players.find(p => p.id === f.richese)!.bot = difficulty;
|
||||
const action = botActions(view).find(a => a.type === 'decision' && typeof a.choice === 'number'); assert.ok(action && typeof action.choice === 'number');
|
||||
const rescue = decision.options[action.choice]; assert.ok(rescue);
|
||||
const done = finishBattle(applyAction(reload(pending), f.richese, action));
|
||||
const kept = rescue.kept?.kind === 'normal' ? 1 : 0;
|
||||
assert.equal(player(done, f.richese).forces[battle.key], 6 + kept);
|
||||
assert.equal(player(done, f.richese).reserves, 12 + rescue.normal - kept);
|
||||
assert.equal(player(done, f.richese).tanks, 2 - rescue.normal);
|
||||
assert.equal(player(done, f.guild).tanks, player(revealed, f.guild).tanks + 3);
|
||||
assert.equal(done.lastBattleContext!.winner, f.richese);
|
||||
assert.equal(player(done, f.richese).noField!.deployed, null);
|
||||
assert.deepEqual(done.leaderSkills!.assignments, before.leaderSkills!.assignments);
|
||||
for (const token of loserTokens) assert.equal(done.techTokens![token].owner, f.richese);
|
||||
custody(done);
|
||||
}
|
||||
});
|
||||
|
||||
void test('held Arrakeen support affects only its proper payer leg, not an unrelated nested Shrine entitlement', () => {
|
||||
const f = fixture({ advanced: true, tech: true, strongholds: true, arrakeenOwner: 'richese' }), ending = close(f);
|
||||
assert.equal(ending.game.strongholdCards!.owners.arrakeen, f.richese);
|
||||
const nested = settle(applyAction(reload(ending.game), f.richese, pairAction(ending.game, f.richese)));
|
||||
const battle = prepareBattle(f, nested);
|
||||
let game = battle.game;
|
||||
for (const plan of battle.plans) game = settle(step(game, plan));
|
||||
const wallet = player(game, f.richese).spice;
|
||||
const committed = advance(game, g => g.decision?.kind === 'sukRescue');
|
||||
assert.equal(player(committed, f.richese).spice, wallet - 2, 'Arrakeen held card cannot fund support at another source.');
|
||||
assert.equal(committed.strongholdCards!.owners.arrakeen, f.richese);
|
||||
const done = finishBattle(committed);
|
||||
assert.equal(done.lastBattleContext!.winner, f.richese);
|
||||
assert.equal(player(done, f.richese).forces['shrine:0'], 7);
|
||||
custody(done);
|
||||
});
|
||||
|
||||
void test('actual held Arrakeen bank support changes its payer debit without increasing trained dial or rescue', () => {
|
||||
const f = fixture({ advanced: true, tech: true, strongholds: true, arrakeenOwner: 'richese' }), before = close(f).game;
|
||||
const key = `arrakeen:${territory('arrakeen').sectors[0]}`, own = player(before, f.richese);
|
||||
assert.equal(before.strongholdCards!.owners.arrakeen, f.richese);
|
||||
own.reserves += own.forces[key]; delete own.forces[key];
|
||||
f.staging.push('Conserve the original Arrakeen claiming counter back to native reserves after its real END Mentat claim; retain the actually held card, never assign custody.');
|
||||
const shipped = settle(applyAction(reload(before), f.richese, pairAction(before, f.richese, 'arrakeen')));
|
||||
const battle = prepareBattle(f, shipped);
|
||||
let game = battle.game;
|
||||
reject(game, f.richese, { ...battle.plans[0].action, dial: 9, support: 9 });
|
||||
for (const plan of battle.plans) game = settle(step(game, plan));
|
||||
const wallet = player(game, f.richese).spice;
|
||||
const committed = advance(game, g => g.decision?.kind === 'sukRescue');
|
||||
assert.equal(player(committed, f.richese).spice, wallet, 'Held Arrakeen funds exactly the original declared support leg.');
|
||||
assert.equal(committed.decision?.player, f.richese);
|
||||
const done = finishBattle(committed);
|
||||
assert.equal(done.lastBattleContext!.winner, f.richese);
|
||||
assert.equal(player(done, f.richese).forces[key], 7);
|
||||
assert.equal(player(done, f.richese).tanks, 0);
|
||||
assert.equal(player(done, f.guild).tanks, 3);
|
||||
assert.equal(done.strongholdCards!.owners.arrakeen, f.richese);
|
||||
custody(done);
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import {
|
||||
initializeDiscoveryStandaloneNexusE3Skills as initialize,
|
||||
completeDiscoveryStandaloneNexusE3SkillsSetup as complete,
|
||||
createDiscoveryStandaloneNexusE3SkillsFixture as fixture,
|
||||
closeDiscoveryStandaloneNexusE3Skills as close,
|
||||
enterDiscoveryStandaloneNexusE3Skills as enter,
|
||||
cunningDiscoveryStandaloneNexusE3Skills as cunning,
|
||||
openDiscoveryStandaloneNexusE3SkillsBattle as open,
|
||||
revealDiscoveryStandaloneNexusE3SkillsBattle as reveal,
|
||||
settleDiscoveryStandaloneNexusE3SkillsBattle as boundary,
|
||||
finishDiscoveryStandaloneNexusE3SkillsBattle as finish,
|
||||
advanceDiscoveryStandaloneNexusE3Skills as advance,
|
||||
stepDiscoveryStandaloneNexusE3Skills as step,
|
||||
discoveryStandaloneNexusE3SkillsPlayer as player,
|
||||
discoveryStandaloneNexusE3SkillsPolicy as policy,
|
||||
discoveryStandaloneNexusE3SkillsClean as clean,
|
||||
assertDiscoveryStandaloneNexusE3SkillsCustody as custody,
|
||||
type DiscoveryStandaloneNexusE3SkillsOptions,
|
||||
} from './fixture-discovery-standalone-nexus-e3-skills';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
/** Consume actual physical loss/rescue choices; do not hide the later owner choice. */
|
||||
function aftermath(state: Game, callers: readonly string[] = []): Game {
|
||||
let next = boundary(state, callers);
|
||||
for (let count = 0; count < 20 && (next.kind === 'losses' || next.kind === 'rescue'); count++) {
|
||||
const d = next.game.decision!;
|
||||
if (d.kind === 'battleLosses') next = boundary(step(next.game, { actor: d.player, action: { type: 'decision', choice: 0 } }), callers);
|
||||
else if (d.kind === 'sukRescue') {
|
||||
const max = Math.max(...d.options.map(o => o.normal + o.elite));
|
||||
const choice = d.options.findIndex(o => o.normal + o.elite === max);
|
||||
next = boundary(step(next.game, { actor: d.player, action: { type: 'decision', event: d.event, choice } }), callers);
|
||||
}
|
||||
}
|
||||
return next.game;
|
||||
}
|
||||
|
||||
void test('original one-native Basic/Advanced 2–6 setup trains eligible own discs and preserves supplied original hands and offers', () => {
|
||||
for (const native of ['ecaz', 'moritani'] as const) for (const rules of ['basic', 'advanced'] as const)
|
||||
for (const count of [2, 3, 4, 5, 6]) {
|
||||
const factions: FactionId[] = [native, 'guild', 'emperor', 'fremen', 'beneGesserit', rules === 'basic' ? 'harkonnen' : 'atreides'];
|
||||
const options: DiscoveryStandaloneNexusE3SkillsOptions = { native, rules, factions: factions.slice(0, count),
|
||||
seatIds: factions.slice(0, count).map((f, n) => `${native}-${rules}-${count}-${n}-${f}`),
|
||||
tech: count >= 3, strongholds: rules === 'advanced' };
|
||||
if (native === 'ecaz' && rules === 'basic' && count % 2) { assert.throws(() => initialize(options)); continue; }
|
||||
const offered = advance(initialize(options), g => g.setupStage === 'leaderSkills');
|
||||
const owner = offered.players.find(p => p.faction === native)!.id;
|
||||
const original = structuredClone(offered), continued = initialize({ initial: offered });
|
||||
assert.deepEqual(continued, original);
|
||||
const trained = complete(continued, { initial: offered });
|
||||
assert.deepEqual(trained.players.map(p => p.id), original.players.map(p => p.id));
|
||||
for (const p of original.players) assert.deepEqual(player(trained, p.id).hand, p.hand);
|
||||
assert.ok(trained.leaderSkills!.assignments.every(a => original.leaderSkills!.offers[a.owner].cards.includes(a.skill) &&
|
||||
player(original, a.owner).leaders.some(l => l.id === a.leader)));
|
||||
const assignment = trained.leaderSkills!.assignments.find(a => a.owner === owner)!;
|
||||
assert.notEqual(assignment.leader, DUKE_VIDAL_ID);
|
||||
if (rules === 'basic') assert.equal(trained.moritaniAssassinate, undefined);
|
||||
if (count === 2) {
|
||||
const f = fixture({ initial: offered });
|
||||
assert.equal(viewGame(f.game, owner).discoveryEntry!.owner, owner);
|
||||
if (rules === 'advanced') {
|
||||
assert.equal(f.firstMentat.before.strongholdCards!.owners[f.claim], null);
|
||||
assert.equal(f.game.strongholdCards!.owners[f.claim], owner);
|
||||
}
|
||||
assert.throws(() => close(f), /two other real alliance seats/);
|
||||
}
|
||||
custody(trained);
|
||||
}
|
||||
});
|
||||
|
||||
void test('source guards reject Advanced Harkonnen, paired/mixed E3, variants, shared Duke and foreign/captured training without consuming offers', () => {
|
||||
for (const native of ['ecaz', 'moritani'] as const) {
|
||||
for (const other of ['harkonnen', native === 'ecaz' ? 'moritani' : 'ecaz', 'ixians', 'choam', 'richese', 'tleilaxu'] as FactionId[])
|
||||
assert.throws(() => initialize({ native, factions: [native, 'guild', other] }));
|
||||
assert.throws(() => initialize({ native, rules: 'basic', strongholds: true }));
|
||||
const offered = initialize({ native }), owner = offered.players.find(p => p.faction === native)!.id;
|
||||
const own = viewGame(offered, owner).leaderSkills!, skill = own.offer!.cards.find(s => !own.unavailableSkills?.[s])!;
|
||||
const foreign = offered.players.find(p => p.id !== owner)!.leaders[0].id;
|
||||
for (const leader of [DUKE_VIDAL_ID, foreign]) reject(offered, owner,
|
||||
{ type: 'leaderSkill', event: own.offer!.event, skill, leader });
|
||||
const captured = structuredClone(offered), disc = player(captured, owner).leaders.find(l => l.id === own.eligibleLeaders[0].id)!;
|
||||
// Rejected counterfactual only: no captured state is accepted as played history.
|
||||
disc.capturedBy = offered.players.find(p => p.id !== owner)!.id;
|
||||
reject(captured, owner, { type: 'leaderSkill', event: own.offer!.event, skill, leader: disc.id });
|
||||
assert.throws(() => initialize({ initial: { ...offered, ecazTreachery: true } }));
|
||||
assert.throws(() => initialize({ initial: { ...offered, homeworlds: {} as NonNullable<Game['homeworlds']> } }));
|
||||
custody(offered);
|
||||
}
|
||||
});
|
||||
|
||||
void test('human lobby/setup actors and first hands survive the actual Discovery reveal and pre-Storm free entry, not a redeal or a paid shipment', () => {
|
||||
for (const native of ['ecaz', 'moritani'] as const) {
|
||||
const lobby = createGame(`HUMAN-${native}`, newPlayer(`human-${native}`, native, native), true, ['ecaz']);
|
||||
joinGame(lobby, newPlayer('human-guild', 'Guild', 'guild')); joinGame(lobby, newPlayer('human-fremen', 'Fremen', 'fremen'));
|
||||
const original = structuredClone(lobby), offered = initialize({ initial: lobby }), f = fixture({ initial: offered });
|
||||
assert.deepEqual(lobby, original); assert.deepEqual(f.setup, offered);
|
||||
assert.equal(f.owner, `human-${native}`); assert.equal(f.opponent, 'human-guild');
|
||||
for (const p of offered.players) assert.deepEqual(player(f.game, p.id).hand, p.hand);
|
||||
const own = viewGame(f.game, f.owner), source = player(f.game, f.owner).forces[f.source];
|
||||
assert.equal(own.discoveryEntry!.owner, f.owner);
|
||||
assert.equal(f.firstMentat.before.turn, 1); assert.equal(f.firstMentat.before.phase, 8);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0);
|
||||
reject(f.game, f.opponent, { type: 'decision', event: own.discoveryEntry!.event, accept: false });
|
||||
reject(f.firstMentat.before, f.owner, { type: 'nexusCardChoice', turn: 1, card: null, choice: 'draw', ownRedraws: 0 });
|
||||
const entered = enter(f), funds = player(f.game, f.owner).spice;
|
||||
assert.equal(player(entered, f.owner).forces['cistern:0'], 3);
|
||||
assert.equal(player(entered, f.owner).forces[f.source] ?? 0, source - 3);
|
||||
assert.equal(player(entered, f.owner).spice, funds);
|
||||
assert.equal(player(entered, f.owner).shipped, player(f.game, f.owner).shipped);
|
||||
assert.deepEqual(entered.leaderSkills, f.game.leaderSkills); custody(entered);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Maker losses, all votes, typed reserve ride, settled alliances and both Advanced piles precede the only native closing draw', () => {
|
||||
for (const native of ['ecaz', 'moritani'] as const) {
|
||||
const f = fixture({ native, tech: true, strongholds: true }), closing = close(f);
|
||||
assert.equal(player(closing.vote, f.owner).tanks, player(closing.entered, f.owner).tanks + 6);
|
||||
assert.equal(player(closing.vote, f.owner).forces[f.wormSource] ?? 0, 0);
|
||||
assert.equal(player(closing.vote, f.owner).forces['cistern:0'], 3);
|
||||
assert.equal(closing.vote.nexusCards!.cards!.hands[f.owner], null);
|
||||
reject(closing.vote, f.owner, closing.draw.step.action);
|
||||
const ride = closing.ride; assert.ok(ride && f.fremen);
|
||||
const rider = player(ride.before, f.fremen);
|
||||
assert.equal(player(ride.after, f.fremen).reserves, rider.reserves - 2);
|
||||
assert.equal(player(ride.after, f.fremen).elites!.reserves, rider.elites!.reserves - 1);
|
||||
assert.equal(player(ride.after, f.fremen).forces['polar_sink:0'], 2);
|
||||
assert.equal(player(ride.after, f.fremen).elites!.forces['polar_sink:0'], 1);
|
||||
assert.equal(player(ride.after, f.fremen).spice, rider.spice);
|
||||
assert.equal(closing.draw.before.phase, 1); assert.equal(closing.draw.before.spiceWindow, null);
|
||||
assert.equal(closing.draw.before.spiceResolution, null); assert.equal(closing.draw.before.nexus, false);
|
||||
assert.equal(player(closing.alliance, f.owner).ally, null);
|
||||
assert.equal(player(closing.alliance, f.opponent).ally, f.fremen);
|
||||
assert.equal(closing.draw.after.nexusCards!.cards!.hands[f.owner], native);
|
||||
assert.equal(closing.draw.after.nexusCards!.cards!.deck.includes(native), false);
|
||||
reject(closing.draw.after, f.owner, closing.draw.step.action);
|
||||
assert.equal(f.firstMentat.before.strongholdCards!.owners[f.claim], null);
|
||||
assert.equal(f.firstMentat.after.strongholdCards!.owners[f.claim], f.owner);
|
||||
assert.equal(ownedTech(f.afterFirstStorm.techTokens, f.owner).length, 1);
|
||||
custody(closing.game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('borrowed Emperor free-return remains source guarded for either native even with real Maker casualties and an actually drawn card', () => {
|
||||
for (const native of ['ecaz', 'moritani'] as const) {
|
||||
const f = fixture({ native }), closing = close(f, 'emperor');
|
||||
const revival = advance(closing.game, g => g.phase === 4 && clean(g));
|
||||
assert.equal(player(revival, f.owner).tanks, 6);
|
||||
const offer = viewGame(revival, f.owner).nexusEmperorSecretAlly; assert.ok(offer && offer.revival.blocked);
|
||||
const action: Action = { type: 'nexusEmperorRevive', event: offer.event, elite: 0 };
|
||||
reject(revival, f.owner, action);
|
||||
assert.equal(revival.nexusCards!.cards!.hands[f.owner], 'emperor');
|
||||
assert.equal(player(revival, f.owner).freeForcesRevived, 0);
|
||||
custody(revival);
|
||||
}
|
||||
});
|
||||
|
||||
void test('quiet native Ecaz Cunning acquires the living unclaimed Duke separately from training, and rejects exceptional custody or an already-open battle', () => {
|
||||
const f = fixture({ native: 'ecaz' }), closing = close(f), acquired = cunning(f, closing.game);
|
||||
assert.equal(acquired.before.status, 'playing');
|
||||
assert.equal(acquired.before.turn, closing.game.turn);
|
||||
assert.equal(acquired.before.phase, 6);
|
||||
assert.equal(acquired.before.battle, null);
|
||||
assert.ok(viewGame(acquired.before, f.owner).battleChoices.length > 0,
|
||||
'Physical opponents keep the original Battle chooser open before quiet Cunning.');
|
||||
assert.equal(acquired.before.dukeVidal!.controller, null);
|
||||
assert.equal(acquired.after.dukeVidal!.controller, f.owner);
|
||||
assert.equal(acquired.after.dukeVidal!.leader.dead, false);
|
||||
assert.equal(acquired.after.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.deepEqual(acquired.after.leaderSkills, acquired.before.leaderSkills);
|
||||
assert.ok(acquired.after.leaderSkills!.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
|
||||
for (const property of ['dead', 'capturedBy', 'gholaBy', 'usedAt'] as const) {
|
||||
const input = structuredClone(acquired.before);
|
||||
if (property === 'dead') input.dukeVidal!.leader.dead = true;
|
||||
else if (property === 'usedAt') input.dukeVidal!.leader.usedAt = 'cistern';
|
||||
else input.dukeVidal!.leader[property] = f.opponent;
|
||||
reject(input, f.owner, acquired.step.action);
|
||||
}
|
||||
const battle = open(f, closing.game);
|
||||
reject(battle.game, f.owner, acquired.step.action);
|
||||
const revealed = reveal(battle), cleanup = aftermath(revealed);
|
||||
assert.equal(cleanup.decision?.kind, 'battleCards'); assert.equal(cleanup.decision!.player, f.owner);
|
||||
assert.equal(player(cleanup, f.owner).forces[battle.location], 5);
|
||||
assert.equal(player(cleanup, f.opponent).forces[battle.location] ?? 0, 0);
|
||||
assert.equal(player(cleanup, f.opponent).tanks, player(revealed, f.opponent).tanks + 6);
|
||||
assert.equal(cleanup.dukeVidal!.controller, f.owner, 'The actual native trained disc was used; the separate Duke was not set aside.');
|
||||
const done = finish(step(cleanup, { actor: f.owner, action: { type: 'decision', discard: [] } }));
|
||||
assert.equal(player(done, f.owner).hand.some(c => c.id === battle.card), true);
|
||||
assert.equal(player(done, f.owner).leaders.find(l => l.id === f.leader)!.usedAt, 'cistern');
|
||||
custody(done);
|
||||
});
|
||||
|
||||
void test('mandatory selected-lead Occupy stays a real battle with owner-labelled commitments, native skill custody, payments, cleanup and Tech', () => {
|
||||
for (const lead of ['native', 'ally'] as const) {
|
||||
const f = fixture({ native: 'ecaz', factions: ['ecaz', 'guild', 'emperor'], tech: true, strongholds: true }), closing = close(f);
|
||||
const acquired = cunning(f, closing.game), battle = open(f, acquired.after, { occupy: true, lead });
|
||||
const choice = battle.leadChoice; assert.ok(choice);
|
||||
assert.equal(choice.before.status, 'playing');
|
||||
assert.equal(choice.before.turn, acquired.after.turn + 1);
|
||||
assert.equal(choice.before.decision!.kind, 'ecazBattleLead'); assert.equal(choice.before.decision!.player, f.owner);
|
||||
reject(choice.before, f.opponent, choice.step.action);
|
||||
reject(choice.before, f.owner, { ...choice.step.action, lead: f.opponent });
|
||||
assert.equal(battle.game.battle!.ecazOccupy!.lead, battle.actor);
|
||||
assert.equal(battle.game.battle!.plans[battle.actor], undefined);
|
||||
assert.notEqual(battle.leader, DUKE_VIDAL_ID);
|
||||
if (lead === 'ally') reject(battle.game, battle.actor, { ...battle.plans[0].action, leader: f.leader });
|
||||
const assignments = structuredClone(battle.game.leaderSkills!.assignments), revealed = reveal(battle);
|
||||
const cleanup = aftermath(revealed);
|
||||
assert.equal(cleanup.decision?.kind, 'battleCards'); assert.equal(cleanup.decision!.player, battle.actor);
|
||||
assert.equal(player(cleanup, f.owner).forces[battle.location], 1);
|
||||
assert.equal(player(cleanup, f.owner).tanks, player(revealed, f.owner).tanks + 2);
|
||||
const ally = player(revealed, f.owner).ally!;
|
||||
assert.equal(player(cleanup, ally).forces[battle.location], 5);
|
||||
assert.equal(player(cleanup, ally).tanks, player(revealed, ally).tanks + 1);
|
||||
assert.equal(player(cleanup, f.opponent).tanks, player(revealed, f.opponent).tanks + 6);
|
||||
assert.equal(player(cleanup, battle.actor).spice, player(revealed, battle.actor).spice - 1);
|
||||
const companion = battle.actor === f.owner ? ally : f.owner;
|
||||
assert.equal(player(cleanup, companion).spice, player(revealed, companion).spice);
|
||||
assert.equal(player(cleanup, f.opponent).spice, player(revealed, f.opponent).spice - 1);
|
||||
assert.deepEqual(cleanup.techTokens, revealed.techTokens, 'Actual winner card custody precedes optional winner Tech.');
|
||||
const done = finish(step(cleanup, { actor: battle.actor, action: { type: 'decision', discard: [battle.card] } }));
|
||||
assert.equal(done.discard.filter(c => c.id === battle.card).length, 1);
|
||||
assert.equal(player(done, battle.actor).hand.some(c => c.id === battle.card), false);
|
||||
assert.deepEqual(done.leaderSkills!.assignments.map(a => ({ owner: a.owner, leader: a.leader, skill: a.skill })),
|
||||
assignments.map(a => ({ owner: a.owner, leader: a.leader, skill: a.skill })));
|
||||
for (const token of ownedTech(revealed.techTokens, f.opponent)) assert.equal(done.techTokens![token].owner, battle.actor);
|
||||
assert.equal(done.strongholdCards!.owners.arrakeen, f.owner);
|
||||
custody(done);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced Moritani after-loss assassination kills the actual unused trained disc, pays printed bounty, returns its skill and replaces only the private Traitor at actual Mentat', () => {
|
||||
const f = fixture({ native: 'moritani', tech: true, strongholds: true }), closing = close(f), battle = open(f, closing.game);
|
||||
assert.ok(f.target);
|
||||
assert.ok(player(f.trained, f.owner).traitors.includes(f.target));
|
||||
assert.ok(f.actions.some(s => s.actor === f.owner && s.action.type === 'traitor' && s.action.leader === f.target),
|
||||
'The trained enemy disc is selected through the original pending Traitor offer.');
|
||||
const revealed = reveal(battle), pending = aftermath(revealed);
|
||||
assert.equal(pending.decision?.kind, 'moritaniAssassinate'); assert.equal(pending.decision!.player, f.owner);
|
||||
assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks + 6);
|
||||
const target = player(pending, f.opponent).leaders.find(l => l.id === f.target)!;
|
||||
const originalSkill = pending.leaderSkills!.assignments.find(a => a.leader === target.id)!.skill;
|
||||
assert.equal(target.dead, false); assert.equal(target.usedAt, undefined);
|
||||
const decision = pending.decision; assert.ok(decision?.kind === 'moritaniAssassinate');
|
||||
const action: Action = { type: 'decision', event: decision.event, card: target.id };
|
||||
reject(pending, f.opponent, action); reject(pending, f.owner, { ...action, card: battle.enemyLeader });
|
||||
const reserve = [...pending.traitorReserve!], held = [...player(pending, f.owner).traitors];
|
||||
const quote = viewGame(pending, f.owner).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!;
|
||||
assert.equal(quote.bounty, 3);
|
||||
const killed = step(pending, { actor: f.owner, action });
|
||||
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.dead, true);
|
||||
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1);
|
||||
assert.equal(player(killed, f.owner).spice, player(pending, f.owner).spice + 3);
|
||||
assert.equal(killed.leaderSkills!.assignments.some(a => a.leader === target.id), false);
|
||||
assert.equal(killed.leaderSkills!.deck.filter(s => s === originalSkill).length, 1);
|
||||
assert.equal(killed.decision?.kind, 'battleCards'); assert.equal(killed.decision!.player, f.opponent);
|
||||
assert.deepEqual(killed.techTokens, pending.techTokens);
|
||||
let done = finish(step(killed, { actor: f.opponent, action: { type: 'decision', discard: [battle.enemyCard!] } }));
|
||||
for (const card of [battle.card, battle.enemyCard!]) assert.equal(done.discard.filter(c => c.id === card).length, 1);
|
||||
for (const token of ownedTech(revealed.techTokens, f.owner)) assert.equal(done.techTokens![token].owner, f.opponent);
|
||||
done = advance(done, g => g.decision?.kind === 'moritaniPlacement');
|
||||
assert.deepEqual(done.traitorReserve, reserve.slice(1));
|
||||
assert.deepEqual(player(done, f.owner).traitors, [...held.filter(c => c !== target.id), reserve[0]]);
|
||||
assert.equal('replacement' in viewGame(done, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
|
||||
assert.equal(done.leaderSkills!.offers[f.opponent], undefined);
|
||||
reject(done, f.owner, action);
|
||||
const placed = cunning(f, done);
|
||||
assert.equal(placed.after.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(placed.after.moritaniTerror!.tokens.find(t => t.kind === 'robbery')!.location, 'red_chasm');
|
||||
assert.deepEqual(placed.after.leaderSkills, done.leaderSkills);
|
||||
custody(placed.after);
|
||||
});
|
||||
|
||||
void test('normal Traitor call forfeits Advanced assassination; Basic losses never grant its power, while original Cunning remains available', () => {
|
||||
const advanced = fixture({ native: 'moritani' }), closing = close(advanced);
|
||||
const called = open(advanced, closing.game, { normalCall: true }), revealed = reveal(called);
|
||||
assert.ok(player(advanced.trained, advanced.owner).traitors.includes(called.enemyLeader));
|
||||
assert.equal(called.enemyLeader, advanced.target);
|
||||
let done = aftermath(revealed, [advanced.owner]);
|
||||
assert.notEqual(done.decision?.kind, 'moritaniAssassinate');
|
||||
done = finish(done);
|
||||
assert.equal(player(done, advanced.opponent).leaders.find(l => l.id === called.enemyLeader)!.dead, true);
|
||||
const held = [...player(done, advanced.owner).traitors], reserve = [...done.traitorReserve!];
|
||||
done = advance(done, g => g.decision?.kind === 'moritaniPlacement');
|
||||
assert.deepEqual(player(done, advanced.owner).traitors, held); assert.deepEqual(done.traitorReserve, reserve);
|
||||
custody(done);
|
||||
const basic = fixture({ native: 'moritani', rules: 'basic', tech: true }), basicClose = close(basic);
|
||||
const battle = open(basic, basicClose.game), basicReveal = reveal(battle), loss = aftermath(basicReveal);
|
||||
assert.equal(loss.moritaniAssassinate, undefined);
|
||||
assert.equal(player(loss, basic.owner).spice, player(basicReveal, basic.owner).spice);
|
||||
reject(loss, basic.owner, { type: 'decision', event: 'not-earned', card: basic.target });
|
||||
assert.equal(player(loss, basic.opponent).leaders.find(l => l.id === basic.target)!.dead, false);
|
||||
const finished = finish(loss), placed = cunning(basic, finished);
|
||||
assert.equal(placed.after.nexusCards!.cards!.hands[basic.owner], null);
|
||||
assert.equal(placed.after.moritaniTerror!.tokens.find(t => t.kind === 'robbery')!.location, 'red_chasm');
|
||||
assert.equal(placed.after.moritaniAssassinate, undefined); custody(placed.after);
|
||||
});
|
||||
|
||||
void test('all four minimal policies act for the eligible native Cunning/assassination and cleanup owner with physical consumer outcomes', () => {
|
||||
const ecaz = fixture({ native: 'ecaz' }), ecazClosing = close(ecaz), ecazCunning = cunning(ecaz, ecazClosing.game);
|
||||
const moritani = fixture({ native: 'moritani', tech: true }), moritaniClosing = close(moritani);
|
||||
const battle = open(moritani, moritaniClosing.game), pending = aftermath(reveal(battle));
|
||||
assert.equal(pending.decision?.kind, 'moritaniAssassinate');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const ecazAction = policy(ecazCunning.before, ecaz.owner, difficulty)[0]; assert.ok(ecazAction);
|
||||
const acquired = step(structuredClone(ecazCunning.before), { actor: ecaz.owner, action: ecazAction });
|
||||
assert.equal(acquired.dukeVidal!.controller, ecaz.owner); assert.equal(acquired.nexusCards!.cards!.hands[ecaz.owner], null);
|
||||
assert.deepEqual(acquired.leaderSkills, ecazCunning.before.leaderSkills);
|
||||
const action = policy(pending, pending.decision!.player, difficulty)[0]; assert.ok(action);
|
||||
let game = step(structuredClone(pending), { actor: pending.decision!.player, action });
|
||||
assert.equal(player(game, moritani.opponent).leaders.find(l => l.id === moritani.target)!.dead, true);
|
||||
assert.equal(player(game, moritani.owner).spice, player(pending, moritani.owner).spice + 3);
|
||||
assert.equal(game.decision?.kind, 'battleCards');
|
||||
const cleanupActor = game.decision!.player, cleanup = policy(game, cleanupActor, difficulty)[0]; assert.ok(cleanup);
|
||||
game = finish(step(game, { actor: cleanupActor, action: cleanup }));
|
||||
assert.equal(game.battle, null); assert.equal(player(game, moritani.owner).hand.some(c => c.id === battle.card), false);
|
||||
game = advance(game, g => g.decision?.kind === 'moritaniPlacement');
|
||||
assert.equal(player(game, moritani.owner).traitors.includes(moritani.target!), false);
|
||||
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === moritani.target), false);
|
||||
custody(game);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { gameDistance, gameTerritories, MOBILE_LOCATION, splitLocation, territory } from '../game/board';
|
||||
import { isAuditorLeader } from '../game/cards';
|
||||
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
|
||||
import { greatMakerRideAction } from '../game/great-maker-options';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { pairedNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { nextPairedIxNexusSkillsModulesStep } from './fixture-paired-ix-nexus-skills-modules';
|
||||
import {
|
||||
pairedDiscoveryNexusClean as clean, pairedDiscoveryNexusPlayer,
|
||||
placePairedDiscoveryNexus, reservePairedDiscoveryNexusBoard,
|
||||
type PairedDiscoveryNexusClosing, type PairedDiscoveryNexusFixture,
|
||||
type PairedDiscoveryNexusTransition,
|
||||
} from './fixture-discovery-paired-nexus';
|
||||
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type DiscoveryPairedNexusE1SkillsStep = { actor: string; action: Action };
|
||||
export type DiscoveryPairedNexusE1SkillsOptions = {
|
||||
/** Authenticated original lobby or unassigned original turn-one setup. */
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
/** An explicit discipline must belong to the actual original offer. */
|
||||
skill?: LeaderSkillId;
|
||||
};
|
||||
export type DiscoveryPairedNexusE1SkillsSetup = {
|
||||
initial: Game; setup: Game; offered: Game; afterSetup: Game; game: Game;
|
||||
owner: string; native: string; guild: string; fremen: string;
|
||||
trainer: string; skill: LeaderSkillId;
|
||||
actions: DiscoveryPairedNexusE1SkillsStep[]; staging: string[];
|
||||
};
|
||||
export type DiscoveryPairedNexusE1SkillsEntry = PairedDiscoveryNexusFixture & {
|
||||
offered: Game; trainer: string; skill: LeaderSkillId;
|
||||
};
|
||||
export type DiscoveryPairedNexusE1SkillsBattle = {
|
||||
entry: DiscoveryPairedNexusE1SkillsEntry; closing: PairedDiscoveryNexusClosing;
|
||||
beforeBattle: Game; battleStep: DiscoveryPairedNexusE1SkillsStep; game: Game;
|
||||
owner: string; opponent: string; trainer: string; skill: LeaderSkillId;
|
||||
enemy: string; location: 'shrine:0'; replacementSource: string;
|
||||
/** Only original, actually dealt cards are eligible; none is injected. */
|
||||
weapon: string | null; defense: string | null;
|
||||
plans: DiscoveryPairedNexusE1SkillsStep[];
|
||||
actions: DiscoveryPairedNexusE1SkillsStep[]; staging: string[];
|
||||
};
|
||||
export type DiscoveryPairedNexusE1SkillsBoundary =
|
||||
'losses' | 'rescue' | 'substitution' | 'cards' | 'tech' | 'faceDance' | 'complete';
|
||||
export const discoveryPairedNexusE1SkillsPlayer = pairedDiscoveryNexusPlayer;
|
||||
|
||||
/** Original schedulers handle shared responses first. Returned controls never
|
||||
* choose an owned free entry, vote, ride, Cunning, private plan or Face Dance. */
|
||||
export function nextDiscoveryPairedNexusE1SkillsStep(game: Game): DiscoveryPairedNexusE1SkillsStep | null {
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'discoveryEntry' || d?.kind === 'greatMakerRide')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
|
||||
if (d?.kind === 'greatMakerVote')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, yes: false } };
|
||||
if (d?.kind === 'discoveryDiscard') {
|
||||
const card = pairedDiscoveryNexusPlayer(game, d.player).hand[0]; assert.ok(card);
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, card: card.id } };
|
||||
}
|
||||
if (d?.kind === 'faceDance' || d?.kind === 'strongholdCopy') return null;
|
||||
}
|
||||
return nextPairedIxNexusSkillsModulesStep(game);
|
||||
}
|
||||
export function stepDiscoveryPairedNexusE1Skills(game: Game, step: DiscoveryPairedNexusE1SkillsStep,
|
||||
actions?: DiscoveryPairedNexusE1SkillsStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function advanceDiscoveryPairedNexusE1Skills(state: Game, until: (game: Game) => boolean,
|
||||
actions?: DiscoveryPairedNexusE1SkillsStep[]): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let n = 0; n < 2400; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextDiscoveryPairedNexusE1SkillsStep(game);
|
||||
assert.ok(step, 'Original paired E1 needs its owned human plan, copy or matching Face Dance');
|
||||
game = stepDiscoveryPairedNexusE1Skills(game, step, actions);
|
||||
}
|
||||
throw Error('Original paired E1 Discovery/Skills programme did not reach its human boundary');
|
||||
}
|
||||
|
||||
/** Bounded original shuffle control. Byte/UUID entropy is forwarded unchanged. */
|
||||
function withShuffle<T>(operation: () => T, source: string[], desired: string[]): T {
|
||||
assert.deepEqual([...source].sort(), [...desired].sort());
|
||||
const working = [...source], rolls: number[] = [];
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const from = working.indexOf(desired[i]); assert.ok(from >= 0 && from <= i);
|
||||
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (i + 1)));
|
||||
[working[i], working[from]] = [working[from], working[i]];
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array) for (let i = 0; i < array.length; i++) array[i] = rolls[cursor++] ?? 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Starting hands precede Skills. Supplied hands/offers and actor IDs are
|
||||
* authoritative. Only an original undealt shuffle is controlled, never redealt. */
|
||||
export function initializeDiscoveryPairedNexusE1SkillsSetup(
|
||||
options: DiscoveryPairedNexusE1SkillsOptions = {},
|
||||
): DiscoveryPairedNexusE1SkillsSetup {
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
const tech = options.tech ?? (options.initial ? !!options.initial.techTokens : true);
|
||||
const strongholds = options.strongholds ?? (options.initial ? !!options.initial.strongholdCards : advanced);
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('DISCOVERYPAIREDE1SKILLS', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']);
|
||||
if (!options.initial) for (const faction of ['tleilaxu', 'guild', 'fremen'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), ['ixians', 'tleilaxu', 'guild', 'fremen'].sort());
|
||||
assert.deepEqual(game.expansions, ['ix']); assert.equal(game.advanced, advanced);
|
||||
assert.ok(!game.homeworlds && !game.ecazTreachery);
|
||||
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
|
||||
game.leaderSkills?.assignments.length === 0 && game.players.every(p => !p.traitors.length && !p.faceDancers?.length &&
|
||||
p.leaders.every(l => !l.dead && !l.usedAt)), 'Only a genuine original lobby or unassigned native setup may continue');
|
||||
const initial = structuredClone(game), actions: DiscoveryPairedNexusE1SkillsStep[] = [], staging: string[] = [];
|
||||
const seat = (faction: string) => game.players.find(p => p.faction === faction)!.id;
|
||||
const owner = seat('ixians'), native = seat('tleilaxu'), guild = seat('guild'), fremen = seat('fremen');
|
||||
const requested = options.skill ?? 'suk-graduate';
|
||||
const passives: LeaderSkillId[] = (['sandmaster', 'smuggler', 'prana-bindu-adept', 'killer-medic'] as LeaderSkillId[])
|
||||
.filter(skill => skill !== requested);
|
||||
const selected: LeaderSkillId[] = game.players.map(p => p.id === owner ? requested : passives.shift()!);
|
||||
if (game.status === 'lobby') {
|
||||
assert.ok(!game.leaderSkills);
|
||||
for (const [type, enabled, present] of [
|
||||
['techTokens', tech, !!game.techTokens], ['strongholdCards', strongholds, !!game.strongholdCards],
|
||||
] as const) if (enabled !== present)
|
||||
game = stepDiscoveryPairedNexusE1Skills(game, { actor: game.host, action: { type, enabled } }, actions);
|
||||
game.discoveryEnabled = true; game.nexusCards ??= { cards: null, phase: null };
|
||||
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
|
||||
for (const p of game.players) if (!p.ready)
|
||||
game = stepDiscoveryPairedNexusE1Skills(game, { actor: p.id, action: { type: 'ready' } }, actions);
|
||||
const source = LEADER_SKILL_CARDS.map(c => c.id), remainder = source.filter(s => !selected.includes(s));
|
||||
const desired = selected.flatMap(s => [s, remainder.shift()!]);
|
||||
game = withShuffle(() => initializeLeaderSkillsGameForAudit(game), source, [...desired, ...remainder]);
|
||||
staging.push('Controlled original all14 shuffle before original starting-card selection; native all47 first hands and actual two-card offers are never replaced.');
|
||||
}
|
||||
assert.ok(pairedNexusLeaderSkillsProfile(game)); assert.equal(game.discoveryEnabled, true);
|
||||
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
|
||||
assert.ok(game.discoveries && game.nexusCards?.cards && game.leaderSkills);
|
||||
const setup = structuredClone(game); let offered = structuredClone(game), trainer = '';
|
||||
for (let n = 0; game.status === 'setup' && n < 250; n++) {
|
||||
let step: DiscoveryPairedNexusE1SkillsStep;
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
if (offered.setupStage !== 'leaderSkills') offered = structuredClone(game);
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
|
||||
const projection = viewGame(game, actor).leaderSkills!;
|
||||
const legal = offer.cards.filter(c => !projection.unavailableSkills?.[c]);
|
||||
const desired = selected[game.players.findIndex(p => p.id === actor)];
|
||||
const skill = actor === owner && options.skill ? legal.find(c => c === options.skill)
|
||||
: options.initial?.status === 'setup' ? legal[0] : legal.find(c => c === desired) ?? legal[0];
|
||||
const eligible = new Set(projection.eligibleLeaders.map(l => l.id));
|
||||
const leader = pairedDiscoveryNexusPlayer(game, actor).leaders.filter(l => eligible.has(l.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(skill && leader, 'Explicit training must be available in the original offer on an original eligible living disc');
|
||||
if (actor === owner) trainer = leader.id;
|
||||
step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
|
||||
} else {
|
||||
const next = nextDiscoveryPairedNexusE1SkillsStep(game); assert.ok(next); step = next;
|
||||
if (game.setupStage === 'traitors' && step.action.type === 'traitor') {
|
||||
const card = pairedDiscoveryNexusPlayer(game, step.actor).traitorChoices.find(id => id !== trainer);
|
||||
assert.ok(card); step.action.leader = card;
|
||||
}
|
||||
}
|
||||
if (game.setupStage === 'forces') {
|
||||
const held = new Set(game.players.flatMap(p => p.traitors));
|
||||
const source = traitorDeck(game.players, true).filter(id => !held.has(id));
|
||||
assert.ok(source.includes(trainer)); actions.push(structuredClone(step));
|
||||
game = withShuffle(() => applyAction(game, step.actor, step.action), source, [trainer, ...source.filter(id => id !== trainer)]);
|
||||
staging.push('Controlled original undealt Traitor remainder shuffle before actual native three-Face-Dancer draw; no held identity, reveal flag or replacement history is assigned.');
|
||||
} else game = stepDiscoveryPairedNexusE1Skills(game, step, actions);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner)!;
|
||||
return { initial, setup, offered, afterSetup: structuredClone(game), game, owner, native, guild, fremen,
|
||||
trainer: assignment.leader, skill: assignment.skill, actions, staging };
|
||||
}
|
||||
|
||||
/** Printed blow, actual paid typed collection and real inspection/reveal precede
|
||||
* first END Mentat. The endpoint is free entry BEFORE next Storm, not a fake clock. */
|
||||
export function createDiscoveryPairedNexusE1SkillsEntry(
|
||||
options: DiscoveryPairedNexusE1SkillsOptions = {},
|
||||
): DiscoveryPairedNexusE1SkillsEntry {
|
||||
const s = initializeDiscoveryPairedNexusE1SkillsSetup(options);
|
||||
const { owner, native, guild, fremen, trainer, skill, actions, staging } = s;
|
||||
let game = s.game;
|
||||
const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => {
|
||||
const at = game.spiceDeck.findIndex(matches); assert.ok(at >= 0); return game.spiceDeck.splice(at, 1)[0];
|
||||
};
|
||||
const ordered = [take(c => 'territory' in c && c.discovery === 'discovery-hagga-basin')];
|
||||
if (game.advanced) ordered.push(take(c => 'territory' in c && !c.discovery && c.territory === 'broken_land'));
|
||||
ordered.push(take(c => 'worm' in c && !!c.greatMaker));
|
||||
ordered.push(take(c => 'territory' in c && c.discovery === 'discovery-sihaya-ridge'));
|
||||
if (game.advanced) ordered.push(take(c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings'));
|
||||
game.spiceDeck.unshift(...ordered);
|
||||
staging.push('Conserved original unplayed Spice order: printed Hagga Basin and optional Advanced Broken Land, then Great Maker, Sihaya Ridge and optional Advanced Rock Outcroppings.');
|
||||
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 1 && clean(g), actions);
|
||||
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
|
||||
const step = nextDiscoveryPairedNexusE1SkillsStep(game); assert.ok(step); actions.push(structuredClone(step));
|
||||
game = withClassicDiscoveryNexusToken(game, 'shrine', () => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
const token = game.discoveries!.tokens.find(t => t.face === 'shrine')!; assert.equal(token.status, 'placed');
|
||||
staging.push('Actual original supply-token lottery selects Shrine at the printed first blow; no token face, status or location is assigned.');
|
||||
const printed = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'], source = `${printed.territory}:${printed.sector}`;
|
||||
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 5 && g.active === owner && clean(g), actions);
|
||||
game = stepDiscoveryPairedNexusE1Skills(game, { actor: owner, action: { type: 'ship', territory: printed.territory,
|
||||
sector: printed.sector, amount: 3, elite: 1, allyPayment: 0 } }, actions);
|
||||
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 7 && clean(g), actions);
|
||||
const collection = structuredClone(game);
|
||||
for (const reveal of [false, true]) game = stepDiscoveryPairedNexusE1Skills(game,
|
||||
{ actor: owner, action: { type: 'discovery', token: token.id, reveal } }, actions);
|
||||
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 8 && clean(g), actions);
|
||||
reservePairedDiscoveryNexusBoard(game, [source, MOBILE_LOCATION]);
|
||||
placePairedDiscoveryNexus(game, owner, `arrakeen:${territory('arrakeen').sectors[0]}`, 1);
|
||||
placePairedDiscoveryNexus(game, native, 'hagga_basin:12', 2);
|
||||
staging.push('Before real END Mentat, conserve unrelated counters into original reserves, retain paid parent/HMS groups, place one Arrakeen claim and two native Maker-source counters. No wallet, earned Card, training or phase is assigned.');
|
||||
let firstMentat: PairedDiscoveryNexusTransition | undefined;
|
||||
while (game.turn === 1) {
|
||||
const before = structuredClone(game), step = nextDiscoveryPairedNexusE1SkillsStep(game); assert.ok(step);
|
||||
game = stepDiscoveryPairedNexusE1Skills(game, step, actions);
|
||||
if (game.turn === 2) firstMentat = { before, step, after: structuredClone(game) };
|
||||
}
|
||||
assert.ok(firstMentat);
|
||||
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.decision?.kind === 'discoveryEntry', actions);
|
||||
assert.equal(game.phase, 0); assert.equal(game.decision!.player, owner);
|
||||
return { initial: s.initial, setup: s.setup, offered: s.offered, afterSetup: s.afterSetup, collection, firstMentat,
|
||||
game, family: 'ix', owner, native, collector: owner, guild, fremen, token: token.id, source, claim: 'arrakeen',
|
||||
trainer, skill, actions, staging };
|
||||
}
|
||||
|
||||
/** Human typed entry, exact storm-order votes/ride and settled classic alliance
|
||||
* precede the real end-Spice choice. Both Advanced piles are processed natively. */
|
||||
export function closeDiscoveryPairedNexusE1Skills(f: DiscoveryPairedNexusE1SkillsEntry): PairedDiscoveryNexusClosing {
|
||||
const before = structuredClone(f.game), offer = viewGame(before, f.owner).discoveryEntry!;
|
||||
const step: DiscoveryPairedNexusE1SkillsStep = { actor: f.owner, action: { type: 'decision', event: offer.event,
|
||||
accept: true, groups: [{ source: f.source, normal: 1, elite: 1 }] } };
|
||||
let game = stepDiscoveryPairedNexusE1Skills(before, step, f.actions);
|
||||
const entry = { before, step, after: structuredClone(game) };
|
||||
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.greatMaker?.stage === 'vote', f.actions);
|
||||
const vote = structuredClone(game);
|
||||
while (game.decision?.kind === 'greatMakerVote') game = stepDiscoveryPairedNexusE1Skills(game,
|
||||
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, f.actions);
|
||||
assert.equal(game.decision?.kind, 'greatMakerRide'); assert.equal(game.decision!.player, f.fremen);
|
||||
const rideBefore = structuredClone(game);
|
||||
const action = greatMakerRideAction(viewGame(game, f.fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0); assert.ok(action);
|
||||
const rideStep = { actor: f.fremen, action }; game = stepDiscoveryPairedNexusE1Skills(game, rideStep, f.actions);
|
||||
const ride = { before: rideBefore, step: rideStep, after: structuredClone(game) };
|
||||
game = advanceDiscoveryPairedNexusE1Skills(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g), f.actions);
|
||||
for (const [actor, target] of [[f.guild, f.fremen], [f.fremen, f.guild]])
|
||||
game = stepDiscoveryPairedNexusE1Skills(game, { actor, action: { type: 'alliance', target } }, f.actions);
|
||||
const alliance = structuredClone(game);
|
||||
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.nexusCards?.phase?.stage === 'drawing', f.actions);
|
||||
const cards = game.nexusCards!.cards!; assert.ok(cards.deck.includes('ixians') && cards.deck.includes('tleilaxu'));
|
||||
cards.deck = ['ixians', 'tleilaxu', ...cards.deck.filter(c => c !== 'ixians' && c !== 'tleilaxu')];
|
||||
f.staging.push('Conserved undealt all12 singleton order only; native Cunning hands are earned by actual unallied closing choices after both Spice piles and settled classic alliance.');
|
||||
const drawing = structuredClone(game);
|
||||
for (const actor of [f.owner, f.native]) game = stepDiscoveryPairedNexusE1Skills(game,
|
||||
{ actor, action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } }, f.actions);
|
||||
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.nexusCards?.phase?.stage !== 'drawing', f.actions);
|
||||
return { entry, vote, ride, alliance, drawing, afterDraw: structuredClone(game) };
|
||||
}
|
||||
|
||||
/** Turn-two physical consumer, retaining original first hands. Eight Ix counters
|
||||
* (six Suboids/two Cyborgs) make Suk rescue leave a genuine Cyborg Tank loss even
|
||||
* under full Suboid Cunning. A second conflict keeps cleanup in native Battle. */
|
||||
export function prepareDiscoveryPairedNexusE1SkillsBattle(f: DiscoveryPairedNexusE1SkillsEntry,
|
||||
closing = closeDiscoveryPairedNexusE1Skills(f)): DiscoveryPairedNexusE1SkillsBattle {
|
||||
let game = advanceDiscoveryPairedNexusE1Skills(closing.afterDraw, g => g.phase === 5 && clean(g), f.actions);
|
||||
reservePairedDiscoveryNexusBoard(game, [MOBILE_LOCATION]);
|
||||
const location = 'shrine:0';
|
||||
placePairedDiscoveryNexus(game, f.owner, location, 8, 2);
|
||||
placePairedDiscoveryNexus(game, f.native, location, 8);
|
||||
placePairedDiscoveryNexus(game, f.native, f.source, 3);
|
||||
const other = gameTerritories(game).find(t => t.type === 'sand' && t.id !== 'hagga_basin' && t.id !== 'gara_kulon' &&
|
||||
t.sectors.some(s => s >= 9 && s !== game.storm)); assert.ok(other);
|
||||
const key = `${other.id}:${other.sectors.find(s => s >= 9 && s !== game.storm)}`;
|
||||
for (const actor of [f.owner, f.native]) placePairedDiscoveryNexus(game, actor, key, 1);
|
||||
const enemy = pairedDiscoveryNexusPlayer(game, f.native).leaders.filter(l => !l.dead && !isAuditorLeader(l) &&
|
||||
!game.leaderSkills!.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemy);
|
||||
const own = pairedDiscoveryNexusPlayer(game, f.owner);
|
||||
const weapon = own.hand.find(c => c.kind === 'projectile' || c.kind === 'worthless')?.id ?? null;
|
||||
const defense = own.hand.find(c => c.kind === 'shield')?.id ?? null;
|
||||
assert.ok(pairedDiscoveryNexusPlayer(game, f.native).faceDancers!.some(c => c.leader === f.trainer && !c.revealed));
|
||||
f.staging.push('Before native Movement completion, conserve original counters into revealed Shrine, native parent replacement sources and second conflict. All held Treachery remains actually dealt; no card injection or identity exchange.');
|
||||
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 6 && clean(g), f.actions);
|
||||
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === f.owner || actor === f.native);
|
||||
const battleStep: DiscoveryPairedNexusE1SkillsStep = { actor,
|
||||
action: { type: 'chooseBattle', territory: 'shrine', target: actor === f.owner ? f.native : f.owner } };
|
||||
game = stepDiscoveryPairedNexusE1Skills(game, battleStep, f.actions);
|
||||
const plans: DiscoveryPairedNexusE1SkillsStep[] = [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 8,
|
||||
support: game.advanced ? 2 : 0, weapon, defense } },
|
||||
{ actor: f.native, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0, weapon: null, defense: null } },
|
||||
];
|
||||
return { entry: f, closing, game, beforeBattle, battleStep, owner: f.owner, opponent: f.native,
|
||||
trainer: f.trainer, skill: f.skill, enemy: enemy.id, location, replacementSource: f.source,
|
||||
weapon, defense, plans, actions: f.actions, staging: f.staging };
|
||||
}
|
||||
export function createDiscoveryPairedNexusE1SkillsBattle(options: DiscoveryPairedNexusE1SkillsOptions = {}): DiscoveryPairedNexusE1SkillsBattle {
|
||||
return prepareDiscoveryPairedNexusE1SkillsBattle(createDiscoveryPairedNexusE1SkillsEntry(options));
|
||||
}
|
||||
export function prepareDiscoveryPairedNexusE1SkillsPlans(f: DiscoveryPairedNexusE1SkillsBattle, state = f.game): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let n = 0; n < 160; n++) {
|
||||
const step = nextDiscoveryPairedNexusE1SkillsStep(game);
|
||||
if (!step) { assert.ok(game.battle && !game.battle.revealed); return game; }
|
||||
if (step.action.type === 'leaderSkillVisibility') step.action.hide = step.actor === f.owner;
|
||||
game = stepDiscoveryPairedNexusE1Skills(game, step, f.actions);
|
||||
}
|
||||
throw Error('Original paired E1 skill posture did not reach private plans');
|
||||
}
|
||||
export function beginDiscoveryPairedNexusE1SkillsCunning(f: DiscoveryPairedNexusE1SkillsBattle,
|
||||
state = prepareDiscoveryPairedNexusE1SkillsPlans(f)): Game {
|
||||
const offer = viewGame(state, f.owner).nexusSuboids?.offer; assert.ok(offer); assert.equal(offer.blocked, null);
|
||||
return stepDiscoveryPairedNexusE1Skills(state, { actor: f.owner, action: { type: 'nexusSuboids', event: offer.event } }, f.actions);
|
||||
}
|
||||
/** Suggested high-dial plans consume actual Cunning first. Humans may replace
|
||||
* either suggested plan and explicitly opt out with their own legal dial. */
|
||||
export function revealDiscoveryPairedNexusE1SkillsBattle(f: DiscoveryPairedNexusE1SkillsBattle,
|
||||
options: { state?: Game; cunning?: boolean; plans?: DiscoveryPairedNexusE1SkillsStep[] } = {}): Game {
|
||||
let game = prepareDiscoveryPairedNexusE1SkillsPlans(f, options.state ?? f.game);
|
||||
if (options.cunning !== false) game = advanceDiscoveryPairedNexusE1Skills(beginDiscoveryPairedNexusE1SkillsCunning(f, game), clean, f.actions);
|
||||
for (const step of options.plans ?? f.plans) {
|
||||
game = stepDiscoveryPairedNexusE1Skills(game, step, f.actions);
|
||||
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
|
||||
const next = nextDiscoveryPairedNexusE1SkillsStep(game); assert.ok(next);
|
||||
game = stepDiscoveryPairedNexusE1Skills(game, next, f.actions);
|
||||
}
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export function finishDiscoveryPairedNexusE1SkillsBattle(state: Game,
|
||||
stop: DiscoveryPairedNexusE1SkillsBoundary = 'complete', actions?: DiscoveryPairedNexusE1SkillsStep[]): Game {
|
||||
const kinds: Record<DiscoveryPairedNexusE1SkillsBoundary, string> = { losses: 'battleLosses', rescue: 'sukRescue',
|
||||
substitution: 'ixSubstitution', cards: 'battleCards', tech: 'techToken', faceDance: 'faceDance', complete: '' };
|
||||
return advanceDiscoveryPairedNexusE1Skills(state, game => game.decision?.kind === kinds[stop] ||
|
||||
stop === 'losses' && game.decision?.kind === 'sukRescue' ||
|
||||
stop === 'tech' && game.decision?.kind === 'faceDance' && !game.pendingTech ||
|
||||
!game.battle && clean(game) && !game.pendingTreacheryDiscard, actions);
|
||||
}
|
||||
/** Printed graph search only; the actual movement action validates its range. */
|
||||
export function discoveryPairedNexusE1SkillsRangeSource(game: Game): string {
|
||||
const source = gameTerritories(game).flatMap(t => t.type === 'sand' ? t.sectors.map(s => `${t.id}:${s}`) : [])
|
||||
.find(key => gameDistance(game, key, 'shrine:0', candidate => splitLocation(candidate).sector === game.storm) === 3);
|
||||
assert.ok(source, 'Revealed original nested graph must supply a three-territory Planetologist consumer');
|
||||
return source;
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { TERRITORIES, territory } 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 { greatMakerRideAction } from '../game/great-maker-options';
|
||||
import {
|
||||
completePairedChoamSkillsSetup, initializePairedChoamNexusSkills, nextPairedChoamSkillsStep,
|
||||
openPairedSkillsBattle, pairedSkillsBattlePlans, pairedSkillsClean,
|
||||
pairedSkillsPlayer, placePairedSkillsForce, revealPairedSkillsRichese,
|
||||
type PairedChoamNexusSkillsOptions,
|
||||
} from './fixture-paired-choam-nexus-skills';
|
||||
import { nextDiscoveryNativeE2SkillsStep } from './fixture-discovery-native-e2-skills';
|
||||
import { applyPairedDiscoveryNexusStep, reservePairedDiscoveryNexusBoard } from './fixture-discovery-paired-nexus';
|
||||
|
||||
export type DiscoveryPairedNexusE2SkillsStep = { actor: string; action: Action };
|
||||
export type DiscoveryPairedNexusE2SkillsOptions = PairedChoamNexusSkillsOptions & {
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
kind?: 'shrine' | 'stash';
|
||||
arrakeenOwner?: 'richese' | 'choam';
|
||||
};
|
||||
export type DiscoveryPairedNexusE2SkillsTransition = {
|
||||
before: Game; step: DiscoveryPairedNexusE2SkillsStep; after: Game;
|
||||
};
|
||||
export type DiscoveryPairedNexusE2SkillsAcquisition = DiscoveryPairedNexusE2SkillsTransition & {
|
||||
buyer: string; card: Card;
|
||||
};
|
||||
export type DiscoveryPairedNexusE2SkillsFixture = {
|
||||
initial: Game; afterSetup: Game; afterFirstStorm: Game; collection: Game;
|
||||
previousMarker: DiscoveryPairedNexusE2SkillsTransition;
|
||||
firstMentat: DiscoveryPairedNexusE2SkillsTransition | null;
|
||||
game: Game; richese: string; choam: string; guild: string; fremen: string;
|
||||
collector: string; token: string; face: DiscoveryTokenFace; source: string;
|
||||
trainers: { richese: string; choam: string };
|
||||
actions: DiscoveryPairedNexusE2SkillsStep[]; staging: string[];
|
||||
acquisitions: DiscoveryPairedNexusE2SkillsAcquisition[];
|
||||
};
|
||||
export type DiscoveryPairedNexusE2SkillsClosing = {
|
||||
entry: DiscoveryPairedNexusE2SkillsTransition;
|
||||
vote: Game; ride: DiscoveryPairedNexusE2SkillsTransition;
|
||||
alliance: Game; drawing: Game; draws: DiscoveryPairedNexusE2SkillsTransition[]; movementStart: Game; game: Game;
|
||||
};
|
||||
export type DiscoveryPairedNexusE2SkillsBattle = {
|
||||
game: Game; plans: DiscoveryPairedNexusE2SkillsStep[]; key: string;
|
||||
};
|
||||
export const discoveryPairedNexusE2SkillsClean = pairedSkillsClean;
|
||||
export const discoveryPairedNexusE2SkillsPlayer = pairedSkillsPlayer;
|
||||
|
||||
/** Original selections only. This entry point also serves human two-to-six-seat
|
||||
* starts; the longer paired-draw programme needs Guild and Fremen seats.
|
||||
* Existing hands, IDs and all14 offers are never replaced or redealt. */
|
||||
export function initializeDiscoveryPairedNexusE2Skills(options: DiscoveryPairedNexusE2SkillsOptions = {}): Game {
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
const factions = options.factions ?? ['richese', 'choam', 'guild', 'fremen'] as const;
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('DISCOVERYPAIREDNEXUSE2SKILLS', newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), advanced, ['choam']);
|
||||
if (!options.initial) for (const faction of factions.slice(1))
|
||||
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction));
|
||||
const tech = options.tech ?? !!game.techTokens;
|
||||
const strongholds = options.strongholds ?? !!game.strongholdCards;
|
||||
assert.equal(game.advanced, advanced);
|
||||
assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6);
|
||||
assert.ok(!strongholds || advanced, 'Original Stronghold Cards require Advanced.');
|
||||
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;
|
||||
} else {
|
||||
assert.equal(game.discoveryEnabled, true);
|
||||
assert.equal(!!game.techTokens, tech, 'Saved native module selection is authoritative.');
|
||||
assert.equal(!!game.strongholdCards, strongholds);
|
||||
}
|
||||
// The existing initializer controls only fresh original offer entropy.
|
||||
return initializePairedChoamNexusSkills({ ...options, initial: game, advanced,
|
||||
richeseSkill: options.initial ? options.richeseSkill : options.richeseSkill ?? 'suk-graduate',
|
||||
choamSkill: options.initial ? options.choamSkill : options.choamSkill ?? 'smuggler' });
|
||||
}
|
||||
|
||||
/** Reuse native response, auction, Guild, skill and original Discovery producers.
|
||||
* Owned entry, Maker and battle-plan endpoints remain explicit human actions. */
|
||||
export function nextDiscoveryPairedNexusE2SkillsStep(game: Game): DiscoveryPairedNexusE2SkillsStep | null {
|
||||
if (game.status === 'setup' && game.setupStage === 'leaderSkills') return nextPairedChoamSkillsStep(game);
|
||||
if (pairedSkillsClean(game) && game.nexusCards?.phase?.stage === 'drawing') return nextPairedChoamSkillsStep(game);
|
||||
if (!game.phaseOpening && !game.response && game.decision) {
|
||||
const d = game.decision;
|
||||
if (d.kind === 'discoveryEntry' || d.kind === 'greatMakerRide')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
|
||||
if (d.kind === 'greatMakerVote')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, yes: false } };
|
||||
if (d.kind === 'discoveryDiscard') {
|
||||
const card = pairedSkillsPlayer(game, d.player).hand[0]; assert.ok(card);
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, card: card.id } };
|
||||
}
|
||||
if (d.kind === 'faceDance') return null;
|
||||
}
|
||||
return nextDiscoveryNativeE2SkillsStep(game, true);
|
||||
}
|
||||
export function stepDiscoveryPairedNexusE2Skills(game: Game, step: DiscoveryPairedNexusE2SkillsStep,
|
||||
actions?: DiscoveryPairedNexusE2SkillsStep[]): Game {
|
||||
// Existing original scalar entropy; not a fake clock or a fabricated receipt.
|
||||
return applyPairedDiscoveryNexusStep(game, step, actions);
|
||||
}
|
||||
export function advanceDiscoveryPairedNexusE2Skills(game: Game, until: (g: Game) => boolean,
|
||||
actions?: DiscoveryPairedNexusE2SkillsStep[]): Game {
|
||||
for (let n = 0; n < 2400; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextDiscoveryPairedNexusE2SkillsStep(game);
|
||||
assert.ok(step, 'Continue the original human plan or Face Dance explicitly.');
|
||||
game = stepDiscoveryPairedNexusE2Skills(game, step, actions);
|
||||
}
|
||||
throw Error('Original paired E2 Discovery/Skills programme did not reach its boundary.');
|
||||
}
|
||||
export function settleDiscoveryPairedNexusE2Skills(game: Game, actions?: DiscoveryPairedNexusE2SkillsStep[]): Game {
|
||||
return advanceDiscoveryPairedNexusE2Skills(game, g => pairedSkillsClean(g) &&
|
||||
!g.pendingShipment && !g.pendingTreacheryDiscard && !g.pendingChoamWorthless, actions);
|
||||
}
|
||||
export function completeDiscoveryPairedNexusE2SkillsSetup(game: Game,
|
||||
options: DiscoveryPairedNexusE2SkillsOptions = {}, actions?: DiscoveryPairedNexusE2SkillsStep[]): Game {
|
||||
return completePairedChoamSkillsSetup(game, options, actions);
|
||||
}
|
||||
function withToken<T>(game: Game, face: DiscoveryTokenFace, run: () => T): T {
|
||||
const supply = game.discoveries!.tokens.filter(t => t.status === 'supply' && t.type === DISCOVERY_TOKEN_BY_ID[face].type);
|
||||
const index = supply.findIndex(t => t.face === face); assert.ok(index >= 0);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = 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 / supply.length);
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
function orderSpice(game: Game, printed: string, staging: string[]): void {
|
||||
const take = (matches: (c: Game['spiceDeck'][number]) => boolean) => {
|
||||
const index = game.spiceDeck.findIndex(matches); assert.ok(index >= 0);
|
||||
return game.spiceDeck.splice(index, 1)[0];
|
||||
};
|
||||
const cards = [take(c => 'territory' in c && c.discovery === printed)];
|
||||
if (game.advanced) cards.push(take(c => 'territory' in c && !c.discovery && c.territory === 'broken_land'));
|
||||
cards.push(take(c => 'worm' in c && !!c.greatMaker));
|
||||
cards.push(take(c => 'territory' in c && c.discovery === 'discovery-sihaya-ridge'));
|
||||
if (game.advanced) cards.push(take(c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings'));
|
||||
game.spiceDeck.unshift(...cards);
|
||||
staging.push('Conserved original unplayed printed Discovery, optional Advanced Broken Land, Great Maker, Sihaya Ridge and optional Advanced Rock Outcroppings order.');
|
||||
}
|
||||
/** Only order an undealt physical card. Actual auction bids acquire it; never
|
||||
* move cards from other hands/discards or manufacture a held support card. */
|
||||
export function orderDiscoveryPairedNexusE2SkillsCard(game: Game, matches: (c: Card) => boolean, staging: string[]): Card {
|
||||
const index = game.deck.findIndex(matches); assert.ok(index >= 0, 'The requested original card must still be undealt.');
|
||||
const card = game.deck.splice(index, 1)[0]; game.deck.unshift(card);
|
||||
staging.push(`Conserved original undealt ${card.id} at the next ordinary auction front; no held-card injection.`);
|
||||
return card;
|
||||
}
|
||||
/** Preserve first hands. Buy only still-undealt requested cards through native
|
||||
* Bidding; a genuine existing Karama holder remains the eligible responder. */
|
||||
function prepareOrdinaryAcquisitions(game: Game, choam: string, fremen: string, staging: string[]): Map<string, string> {
|
||||
const buys = new Map<string, string>();
|
||||
if (!pairedSkillsPlayer(game, choam).hand.some(c => c.name === 'Kulon' && c.kind === 'worthless')) {
|
||||
const card = game.deck.find(c => c.name === 'Kulon' && c.kind === 'worthless');
|
||||
if (card) buys.set(orderDiscoveryPairedNexusE2SkillsCard(game, c => c.id === card.id, staging).id, choam);
|
||||
}
|
||||
if (!pairedSkillsPlayer(game, choam).hand.some(c => c.kind === 'special' && c.effect !== 'karama')) {
|
||||
const card = game.deck.find(c => c.kind === 'special' && c.effect !== 'karama');
|
||||
assert.ok(card);
|
||||
buys.set(orderDiscoveryPairedNexusE2SkillsCard(game, c => c.id === card.id, staging).id, choam);
|
||||
}
|
||||
if (!game.players.some(p => p.faction !== 'richese' && p.hand.some(c => c.effect === 'karama'))) {
|
||||
const card = orderDiscoveryPairedNexusE2SkillsCard(game, c => c.effect === 'karama', staging);
|
||||
buys.set(card.id, fremen);
|
||||
}
|
||||
return buys;
|
||||
}
|
||||
function advanceWithAcquisitions(game: Game, until: (g: Game) => boolean, buys: Map<string, string>,
|
||||
actions: DiscoveryPairedNexusE2SkillsStep[], acquisitions: DiscoveryPairedNexusE2SkillsAcquisition[]): Game {
|
||||
for (let n = 0; n < 2400; n++) {
|
||||
if (until(game)) {
|
||||
assert.equal(buys.size, 0, 'Every ordered original lot must have been bought by its intended eligible actor.');
|
||||
return game;
|
||||
}
|
||||
let step = nextDiscoveryPairedNexusE2SkillsStep(game); assert.ok(step);
|
||||
const auction = game.auction, card = auction?.cards[auction.index];
|
||||
const buyer = card && !game.currentAuctionSale ? buys.get(card.id) : undefined;
|
||||
// The original no-spend producer emits passBid, not bid. Only the intended
|
||||
// buyer raises; all other eligible actors use the native passBid action.
|
||||
if (buyer && auction && (step.action.type === 'bid' || step.action.type === 'passBid'))
|
||||
step = { actor: auction.active, action: auction.active === buyer && auction.bidder !== buyer
|
||||
? { type: 'bid', amount: auction.bid + 1 } : { type: 'passBid' } };
|
||||
const before = game;
|
||||
game = stepDiscoveryPairedNexusE2Skills(game, step, actions);
|
||||
if (buyer && card && pairedSkillsPlayer(game, buyer).hand.some(c => c.id === card.id)) {
|
||||
assert.equal(auction!.bidder, buyer);
|
||||
assert.equal(auction!.bid, 1);
|
||||
assert.equal(pairedSkillsPlayer(game, buyer).spice, pairedSkillsPlayer(before, buyer).spice - 1);
|
||||
acquisitions.push({ before: structuredClone(before), step: structuredClone(step),
|
||||
after: structuredClone(game), buyer, card: structuredClone(card) });
|
||||
buys.delete(card.id);
|
||||
}
|
||||
}
|
||||
throw Error('Original card acquisition did not reach its boundary.');
|
||||
}
|
||||
|
||||
/** Skill-first original start → zero-marker invoice/reveal → real paid typed
|
||||
* collection → inspect/reveal. Stops before Stash disposal or next-turn entry.
|
||||
* Board controls are labeled conserved positions, never claimed played history. */
|
||||
export function createDiscoveryPairedNexusE2SkillsFixture(options: DiscoveryPairedNexusE2SkillsOptions = {}): DiscoveryPairedNexusE2SkillsFixture {
|
||||
let game = initializeDiscoveryPairedNexusE2Skills(options);
|
||||
const initial = structuredClone(game), actions: DiscoveryPairedNexusE2SkillsStep[] = [], staging: string[] = [
|
||||
'Fresh offers use the existing original all14 shuffle control; saved offers and first hands are authoritative. Later original scalar lotteries use the existing paired Discovery scheduler.',
|
||||
];
|
||||
const acquisitions: DiscoveryPairedNexusE2SkillsAcquisition[] = [];
|
||||
game = completeDiscoveryPairedNexusE2SkillsSetup(game, options, actions);
|
||||
const afterSetup = structuredClone(game);
|
||||
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'), fremen = seat('fremen');
|
||||
const face: DiscoveryTokenFace = options.kind === 'stash' ? 'treachery-card-stash' : 'shrine';
|
||||
const printed = face === 'shrine' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north';
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS[printed], source = `${placement.territory}:${placement.sector}`;
|
||||
const collector = face === 'shrine' ? guild : choam;
|
||||
const buys = prepareOrdinaryAcquisitions(game, choam, fremen, staging);
|
||||
orderSpice(game, printed, staging);
|
||||
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 1 && pairedSkillsClean(g), actions);
|
||||
const afterFirstStorm = structuredClone(game);
|
||||
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === face && t.status === 'placed') && n < 100; n++) {
|
||||
const step = nextDiscoveryPairedNexusE2SkillsStep(game); assert.ok(step);
|
||||
actions.push(structuredClone(step));
|
||||
// Bypass the generic scalar wrapper for this exact native placement lottery.
|
||||
game = withToken(game, face, () => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
assert.ok(game.discoveries!.tokens.some(t => t.face === face && t.status === 'placed'));
|
||||
game = advanceWithAcquisitions(game, g => g.phase === 5 && pairedSkillsClean(g), buys, actions, acquisitions);
|
||||
let previousMarker: DiscoveryPairedNexusE2SkillsTransition | undefined;
|
||||
let collectedShipment = false;
|
||||
for (let n = 0; (!previousMarker || !collectedShipment) && n < 200; n++) {
|
||||
assert.equal(game.turn, 1, 'Do not chase an already-passed original shipment seat into another turn.');
|
||||
assert.equal(game.phase, 5);
|
||||
if (pairedSkillsClean(game) && game.active === richese && !previousMarker) {
|
||||
const before = structuredClone(game), p = pairedSkillsPlayer(game, richese);
|
||||
const step: DiscoveryPairedNexusE2SkillsStep = { actor: richese, action: { type: 'ship',
|
||||
territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0],
|
||||
noField: p.noField!.tokens.find(t => t.value === 0)!.id, event: p.noFieldEvent, allyPayment: 0 } };
|
||||
game = settleDiscoveryPairedNexusE2Skills(stepDiscoveryPairedNexusE2Skills(game, step, actions), actions);
|
||||
previousMarker = { before, step, after: structuredClone(game) };
|
||||
game = revealPairedSkillsRichese(game, richese, actions);
|
||||
} else if (pairedSkillsClean(game) && game.active === collector && !collectedShipment) {
|
||||
game = stepDiscoveryPairedNexusE2Skills(game, { actor: collector, action: { type: 'ship',
|
||||
territory: placement.territory, sector: placement.sector, amount: 3, elite: 0, allyPayment: 0 } }, actions);
|
||||
game = settleDiscoveryPairedNexusE2Skills(game, actions);
|
||||
collectedShipment = true;
|
||||
} else {
|
||||
const step = nextDiscoveryPairedNexusE2SkillsStep(game); assert.ok(step);
|
||||
game = stepDiscoveryPairedNexusE2Skills(game, step, actions);
|
||||
}
|
||||
}
|
||||
assert.ok(previousMarker && collectedShipment);
|
||||
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 7 && pairedSkillsClean(g), actions);
|
||||
const collection = structuredClone(game), token = game.discoveries!.tokens.find(t => t.face === face)!;
|
||||
assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id));
|
||||
game = stepDiscoveryPairedNexusE2Skills(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
|
||||
game = stepDiscoveryPairedNexusE2Skills(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions);
|
||||
const trainers = { richese: game.leaderSkills!.assignments.find(a => a.owner === richese)!.leader,
|
||||
choam: game.leaderSkills!.assignments.find(a => a.owner === choam)!.leader };
|
||||
// The Stash branch returns the actual disposal endpoint without auto-discard.
|
||||
if (face === 'treachery-card-stash') return { initial, afterSetup, afterFirstStorm, collection, previousMarker,
|
||||
firstMentat: null, game, richese, choam, guild, fremen, collector, token: token.id, face, source, trainers, actions, staging, acquisitions };
|
||||
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 8 && pairedSkillsClean(g), actions);
|
||||
reservePairedDiscoveryNexusBoard(game, [source]);
|
||||
placePairedSkillsForce(game, choam, 'hagga_basin:12', 2, staging);
|
||||
placePairedSkillsForce(game, options.arrakeenOwner === 'richese' ? richese : choam, `arrakeen:${territory('arrakeen').sectors[0]}`, 1, staging);
|
||||
staging.push('Before actual END Mentat conserve unrelated original board groups to their native reserves; retain the paid parent group and stage CHOAM worm-source/Arrakeen counters. No custody, training or earned invoice is assigned.');
|
||||
let firstMentat: DiscoveryPairedNexusE2SkillsTransition | undefined;
|
||||
while (game.turn === 1) {
|
||||
const mentatBefore = structuredClone(game), mentatStep = nextDiscoveryPairedNexusE2SkillsStep(game); assert.ok(mentatStep);
|
||||
game = stepDiscoveryPairedNexusE2Skills(game, mentatStep, actions);
|
||||
if (game.turn === 2) firstMentat = { before: mentatBefore, step: mentatStep, after: structuredClone(game) };
|
||||
}
|
||||
assert.ok(firstMentat);
|
||||
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.decision?.kind === 'discoveryEntry', actions);
|
||||
assert.equal(game.phase, 0); assert.equal(game.decision!.player, collector);
|
||||
return { initial, afterSetup, afterFirstStorm, collection, previousMarker, firstMentat, game,
|
||||
richese, choam, guild, fremen, collector, token: token.id, face, source, trainers, actions, staging, acquisitions };
|
||||
}
|
||||
|
||||
/** Complete actual end-Mentat free entry, Maker loss/vote/Fremen typed reserve
|
||||
* ride, both Advanced Spice piles, reciprocal alliance and closing all12 draws. */
|
||||
export function closeDiscoveryPairedNexusE2Skills(f: DiscoveryPairedNexusE2SkillsFixture): DiscoveryPairedNexusE2SkillsClosing {
|
||||
assert.equal(f.face, 'shrine');
|
||||
const before = structuredClone(f.game), offer = viewGame(before, f.collector).discoveryEntry!;
|
||||
const action = discoveryEntryMoveAction(viewGame(before, f.collector), offer.sources); assert.ok(action);
|
||||
const step = { actor: f.collector, action };
|
||||
let game = stepDiscoveryPairedNexusE2Skills(before, step, f.actions);
|
||||
const entry = { before, step, after: structuredClone(game) };
|
||||
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.greatMaker?.stage === 'vote', f.actions);
|
||||
const vote = structuredClone(game);
|
||||
while (game.decision?.kind === 'greatMakerVote') game = stepDiscoveryPairedNexusE2Skills(game,
|
||||
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, f.actions);
|
||||
assert.equal(game.decision?.kind, 'greatMakerRide'); assert.equal(game.decision!.player, f.fremen);
|
||||
const rideBefore = structuredClone(game), rideAction = greatMakerRideAction(viewGame(game, f.fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0);
|
||||
assert.ok(rideAction);
|
||||
const rideStep = { actor: f.fremen, action: rideAction };
|
||||
game = stepDiscoveryPairedNexusE2Skills(game, rideStep, f.actions);
|
||||
const ride = { before: rideBefore, step: rideStep, after: structuredClone(game) };
|
||||
game = advanceDiscoveryPairedNexusE2Skills(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && pairedSkillsClean(g), f.actions);
|
||||
game = stepDiscoveryPairedNexusE2Skills(game, { actor: f.guild, action: { type: 'alliance', target: f.fremen } }, f.actions);
|
||||
game = stepDiscoveryPairedNexusE2Skills(game, { actor: f.fremen, action: { type: 'alliance', target: f.guild } }, f.actions);
|
||||
const alliance = structuredClone(game);
|
||||
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.nexusCards?.phase?.stage === 'drawing' && pairedSkillsClean(g), f.actions);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
for (const face of ['richese', 'choam'] as const) assert.ok(cards.deck.includes(face));
|
||||
cards.deck = ['richese', 'choam', ...cards.deck.filter(c => c !== 'richese' && c !== 'choam')];
|
||||
f.staging.push('Conserved original unused all12 Richese/CHOAM order before actual qualified end-Spice draws; not a held-card grant.');
|
||||
const drawing = structuredClone(game), draws: DiscoveryPairedNexusE2SkillsTransition[] = [];
|
||||
for (const actor of [f.richese, f.choam]) {
|
||||
const drawBefore = structuredClone(game), drawStep: DiscoveryPairedNexusE2SkillsStep = { actor,
|
||||
action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
|
||||
game = stepDiscoveryPairedNexusE2Skills(game, drawStep, f.actions);
|
||||
draws.push({ before: drawBefore, step: drawStep, after: structuredClone(game) });
|
||||
}
|
||||
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 5 && pairedSkillsClean(g), f.actions);
|
||||
const choam = pairedSkillsPlayer(game, f.choam), claim = `arrakeen:${territory('arrakeen').sectors[0]}`;
|
||||
if (choam.forces[claim]) {
|
||||
choam.reserves += choam.forces[claim]; delete choam.forces[claim];
|
||||
f.staging.push('Conserve the original CHOAM Arrakeen claim counter to native reserves before Movement; its actually held Stronghold Card persists until real END Mentat. This does not claim movement history or add Ornithopter/Kulon mixed entitlement.');
|
||||
}
|
||||
const movementStart = structuredClone(game);
|
||||
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 5 && g.active === f.richese && pairedSkillsClean(g), f.actions);
|
||||
return { entry, vote, ride, alliance, drawing, draws, movementStart, game };
|
||||
}
|
||||
export function discoveryPairedNexusE2SkillsCunningAction(game: Game, richese: string, destination = 'shrine'): Action {
|
||||
const p = pairedSkillsPlayer(game, richese), offer = viewGame(game, richese).nexusRicheseCunning;
|
||||
assert.ok(offer && !offer.blocked);
|
||||
return { type: 'ship', territory: destination, sector: destination === 'shrine' ? 0 : territory(destination).sectors[0],
|
||||
noField: p.noField!.tokens.find(t => t.value === 3)!.id, revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id,
|
||||
event: p.noFieldEvent, nexus: offer.event, allyPayment: 0 };
|
||||
}
|
||||
/** Keep face-up pair counters separate from the marker until actual voluntary
|
||||
* reveal; mixed marker/ordinary battle dial remains a source guard. */
|
||||
export function prepareDiscoveryPairedNexusE2SkillsBattle(f: DiscoveryPairedNexusE2SkillsFixture, shipped: Game,
|
||||
trained = true): DiscoveryPairedNexusE2SkillsBattle {
|
||||
const deployed = pairedSkillsPlayer(shipped, f.richese).noField!.deployed; assert.ok(deployed);
|
||||
const destination = deployed.location.territory, key = `${destination}:${deployed.location.sector}`;
|
||||
let game = revealPairedSkillsRichese(shipped, f.richese, f.actions);
|
||||
assert.equal(pairedSkillsPlayer(game, f.richese).noField!.deployed, null);
|
||||
if (destination !== 'shrine') placePairedSkillsForce(game, f.guild, key, 3, f.staging);
|
||||
// The actual Guild free-entry group supplies opposing nested counters.
|
||||
game = openPairedSkillsBattle(game, f.richese, f.guild, { territory: destination, band: trained ? 'skilled' : 'normal', actions: f.actions });
|
||||
return { game, key, plans: pairedSkillsBattlePlans(game, f.richese, f.guild, 2, trained) };
|
||||
}
|
||||
export function finishDiscoveryPairedNexusE2SkillsBattle(game: Game, actions?: DiscoveryPairedNexusE2SkillsStep[]): Game {
|
||||
return advanceDiscoveryPairedNexusE2Skills(game, g => !g.battle && pairedSkillsClean(g) && !g.pendingTreacheryDiscard, actions);
|
||||
}
|
||||
export function discoveryPairedNexusE2SkillsInvoiceAction(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]}` };
|
||||
}
|
||||
/** Actual holder, not a generic first actor. The receipt caller can stop on its
|
||||
* signed native response and let each minimal policy pass/cancel a real card. */
|
||||
export function discoveryPairedNexusE2SkillsKaramaHolder(game: Game, exclude?: string): Player {
|
||||
const holder = game.players.find(p => p.id !== exclude && p.hand.some(c => c.effect === 'karama'));
|
||||
assert.ok(holder, 'Acquire an actual original Karama through setup or Bidding.');
|
||||
return holder;
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { treacheryDeck } 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 { greatMakerRideAction } from '../game/great-maker-options';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import type { StrongholdId } from '../game/stronghold-cards';
|
||||
import {
|
||||
nextEcazNexusSkillsModulesStep, ecazNexusSkillsModulesCunningRequest,
|
||||
} from './fixture-ecaz-nexus-skills-modules';
|
||||
import {
|
||||
nextMoritaniNexusSkillsModulesStep, moritaniNexusSkillsModulesPolicy,
|
||||
moritaniNexusSkillsModulesRequest,
|
||||
} from './fixture-moritani-nexus-skills-modules';
|
||||
import { revealDiscoveryNativeE3SkillsPlans } from './fixture-discovery-native-e3-skills';
|
||||
import { orderDiscoveryClassicNexusSkillsSpice } from './fixture-discovery-classic-nexus-skills';
|
||||
import {
|
||||
nextClassicDiscoveryNexusStep, withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken,
|
||||
} from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type DiscoveryStandaloneNexusE3SkillsNative = 'ecaz' | 'moritani';
|
||||
export type DiscoveryStandaloneNexusE3SkillsStep = { actor: string; action: Action };
|
||||
export type DiscoveryStandaloneNexusE3SkillsTransition = {
|
||||
before: Game; step: DiscoveryStandaloneNexusE3SkillsStep; after: Game;
|
||||
};
|
||||
export type DiscoveryStandaloneNexusE3SkillsOptions = {
|
||||
/** A fresh lobby or its ORIGINAL unassigned setup. Never a played-save conversion. */
|
||||
initial?: Game;
|
||||
native?: DiscoveryStandaloneNexusE3SkillsNative;
|
||||
rules?: 'basic' | 'advanced';
|
||||
factions?: readonly FactionId[];
|
||||
seatIds?: readonly string[];
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
/** Explicit requests must be genuinely offered; absent requests accept the human lottery. */
|
||||
skill?: LeaderSkillId;
|
||||
leader?: string;
|
||||
};
|
||||
export type DiscoveryStandaloneNexusE3SkillsFixture = {
|
||||
initial: Game; setup: Game; trained: Game; afterFirstStorm: Game; collection: Game;
|
||||
firstMentat: DiscoveryStandaloneNexusE3SkillsTransition;
|
||||
game: Game; native: DiscoveryStandaloneNexusE3SkillsNative;
|
||||
owner: string; opponent: string; fremen: string | null;
|
||||
leader: string; skill: LeaderSkillId; target: string | null;
|
||||
token: string; source: string; wormSource: string; claim: StrongholdId;
|
||||
actions: DiscoveryStandaloneNexusE3SkillsStep[]; staging: string[];
|
||||
};
|
||||
export type DiscoveryStandaloneNexusE3SkillsClosing = {
|
||||
entered: Game; vote: Game; ride: DiscoveryStandaloneNexusE3SkillsTransition | null;
|
||||
alliance: Game; draw: DiscoveryStandaloneNexusE3SkillsTransition; game: Game;
|
||||
};
|
||||
export type DiscoveryStandaloneNexusE3SkillsBattle = {
|
||||
beforeBattle: Game; leadChoice: DiscoveryStandaloneNexusE3SkillsTransition | null;
|
||||
game: Game; plans: DiscoveryStandaloneNexusE3SkillsStep[];
|
||||
actor: string; opponent: string; leader: string; enemyLeader: string;
|
||||
location: string; card: string; enemyCard?: string; target: string | null;
|
||||
};
|
||||
export type DiscoveryStandaloneNexusE3SkillsBoundary = {
|
||||
game: Game; kind: 'losses' | 'rescue' | 'assassination' | 'cards' | 'tech' | 'settled';
|
||||
};
|
||||
export const discoveryStandaloneNexusE3SkillsPolicy = moritaniNexusSkillsModulesPolicy;
|
||||
export const discoveryStandaloneNexusE3SkillsClean = (game: Game): boolean =>
|
||||
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard && !game.truthtrance;
|
||||
export function discoveryStandaloneNexusE3SkillsPlayer(game: Game, actor: string): Player {
|
||||
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
|
||||
}
|
||||
const copy = (game: Game): Game => structuredClone(game);
|
||||
|
||||
/** Scalar entropy only for ONE physical shuffle; preceding scalar draws, UUIDs and later production stay native. */
|
||||
function shuffled<T>(source: readonly string[], desired: readonly string[], operation: () => T, precedingDraws = 0): T {
|
||||
assert.deepEqual([...source].sort(), [...desired].sort());
|
||||
const working = [...source], rolls: number[] = [];
|
||||
for (let index = working.length - 1; index > 0; index--) {
|
||||
const swap = working.indexOf(desired[index]); assert.ok(swap >= 0 && swap <= index);
|
||||
rolls.push(Math.floor((swap + 0.5) * 0x100000000 / (index + 1)));
|
||||
[working[index], working[swap]] = [working[swap], working[index]];
|
||||
}
|
||||
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 (array instanceof Uint32Array && array.length === 1 && precedingDraws > 0) {
|
||||
precedingDraws--; Reflect.apply(original, crypto, [array]);
|
||||
} else if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) 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');
|
||||
}
|
||||
}
|
||||
export function initializeDiscoveryStandaloneNexusE3Skills(options: DiscoveryStandaloneNexusE3SkillsOptions = {}): Game {
|
||||
const native = options.native ?? options.initial?.players.find(p => p.faction === 'ecaz' || p.faction === 'moritani')?.faction ?? 'moritani';
|
||||
const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true;
|
||||
const factions = options.factions ?? [native, 'guild', 'fremen', ...(!advanced && native === 'ecaz' ? ['emperor'] as const : [])];
|
||||
let game = options.initial ? copy(options.initial) : createGame('DISCOVERYSTANDALONEE3SKILLS',
|
||||
newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), advanced, ['ecaz']);
|
||||
if (!options.initial) for (const faction of factions.slice(1))
|
||||
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction));
|
||||
const tech = options.tech ?? !!game.techTokens, strongholds = options.strongholds ?? !!game.strongholdCards;
|
||||
assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']);
|
||||
assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6);
|
||||
assert.equal(new Set(game.players.map(p => p.faction)).size, game.players.length);
|
||||
assert.equal(game.players.filter(p => p.faction === native).length, 1);
|
||||
assert.ok(game.players.every(p => p.faction === native || ['atreides', 'guild', 'emperor', 'fremen', 'beneGesserit',
|
||||
...(advanced ? [] : ['harkonnen'])].includes(p.faction)));
|
||||
assert.ok(advanced || native !== 'ecaz' || game.players.length % 2 === 0, 'Basic odd-seat Ecaz remains source guarded.');
|
||||
assert.ok(!strongholds || advanced);
|
||||
assert.ok(!game.homeworlds && !game.ecazTreachery && !game.advancedPreview && !game.semutaPreview &&
|
||||
!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; game.nexusCards ??= { cards: null, phase: null };
|
||||
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
|
||||
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
|
||||
if (options.initial) game = initializeLeaderSkillsGameForAudit(game);
|
||||
else {
|
||||
const skill = options.skill ?? 'warmaster';
|
||||
const passive: LeaderSkillId[] = ['suk-graduate', 'sandmaster', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'swordmaster-of-ginaz']
|
||||
.filter(s => s !== skill) as LeaderSkillId[];
|
||||
const selected = game.players.map(p => p.faction === native ? skill : passive.shift()!);
|
||||
const source = LEADER_SKILL_CARDS.map(c => c.id), remainder = source.filter(s => !selected.includes(s));
|
||||
const desired = selected.flatMap(s => [s, remainder.shift()!]); desired.push(...remainder);
|
||||
game = shuffled(source, desired, () => initializeLeaderSkillsGameForAudit(game));
|
||||
}
|
||||
}
|
||||
assert.ok(game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
|
||||
['prediction', 'leaderSkills'].includes(game.setupStage!) && game.leaderSkills?.assignments.length === 0 &&
|
||||
game.players.every(p => !p.traitors.length && !p.traitorChoices.length), 'Only original unassigned setup may continue; starting hands stay held.');
|
||||
assert.ok(game.discoveryEnabled && game.discoveries && game.nexusCards?.cards);
|
||||
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
|
||||
return game;
|
||||
}
|
||||
export function nextDiscoveryStandaloneNexusE3SkillsStep(game: Game): DiscoveryStandaloneNexusE3SkillsStep | null {
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard && game.decision &&
|
||||
['discoveryEntry', 'greatMakerRide', 'greatMakerVote', 'wormRide', 'wormPlacement', 'wormProtection',
|
||||
'advisor', 'intrusion', 'discoveryDiscard'].includes(game.decision.kind)) return nextClassicDiscoveryNexusStep(game);
|
||||
if (game.decision?.kind === 'ecazBattleLead' && !game.response && !game.phaseOpening) return null;
|
||||
if (game.decision?.kind === 'rihani' && !game.response && !game.phaseOpening)
|
||||
return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, draw: false } };
|
||||
return game.players.some(p => p.faction === 'ecaz')
|
||||
? nextEcazNexusSkillsModulesStep(game) : nextMoritaniNexusSkillsModulesStep(game);
|
||||
}
|
||||
export function stepDiscoveryStandaloneNexusE3Skills(game: Game, step: DiscoveryStandaloneNexusE3SkillsStep,
|
||||
actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function advanceDiscoveryStandaloneNexusE3Skills(game: Game, until: (state: Game) => boolean,
|
||||
actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game {
|
||||
for (let count = 0; count < 2400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next, 'Actual owner must select the Occupy lead or seal a Battle Plan.');
|
||||
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = 0;
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, next, actions);
|
||||
}
|
||||
throw Error('Original standalone E3 Discovery chronology did not reach its boundary.');
|
||||
}
|
||||
export function completeDiscoveryStandaloneNexusE3SkillsSetup(state: Game,
|
||||
options: DiscoveryStandaloneNexusE3SkillsOptions = {}, actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game {
|
||||
let game = copy(state);
|
||||
const owner = game.players.find(p => p.faction === 'ecaz' || p.faction === 'moritani')!.id;
|
||||
for (let count = 0; game.status === 'setup' && count < 240; count++) {
|
||||
if (game.setupStage !== 'leaderSkills') {
|
||||
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next);
|
||||
if (game.setupStage === 'traitors' && next.actor === owner) {
|
||||
const trained = new Set(game.leaderSkills!.assignments.filter(a => a.owner !== owner).map(a => a.leader));
|
||||
const target = discoveryStandaloneNexusE3SkillsPlayer(game, owner).traitorChoices.find(id => trained.has(id));
|
||||
if (target) next.action = { type: 'traitor', leader: target };
|
||||
}
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, next, actions); continue;
|
||||
}
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
|
||||
const requested = actor === owner ? options.skill : undefined;
|
||||
const legal = own.offer!.cards.filter(s => !own.unavailableSkills?.[s]);
|
||||
const skill = requested ?? legal[0]; assert.ok(skill && legal.includes(skill));
|
||||
const discs = own.eligibleLeaders;
|
||||
const leader = actor === owner && options.leader ? discs.find(l => l.id === options.leader)
|
||||
: discoveryStandaloneNexusE3SkillsPlayer(game, actor).faction === 'guild' && skill === 'suk-graduate'
|
||||
? discs.find(l => l.name === 'Master Bewt') : discs[0];
|
||||
assert.ok(leader, 'Training uses a genuinely eligible owned native disc, never Duke or a foreign disc.');
|
||||
const step: DiscoveryStandaloneNexusE3SkillsStep = { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } };
|
||||
if (!options.initial && game.players.some(p => p.faction === 'moritani') && Object.keys(game.leaderSkills!.offers).length === 1) {
|
||||
const opponent = (game.players.find(p => p.faction === 'guild') ?? game.players.find(p => p.id !== owner))!.id;
|
||||
const target = actor === opponent ? leader.id : game.leaderSkills!.assignments.find(a => a.owner === opponent)?.leader;
|
||||
assert.ok(target, 'The original opposing trained disc must exist before the pending Traitor deal.');
|
||||
const source = traitorDeck(game.players, false), desired = source.filter(id => id !== target);
|
||||
desired.splice(game.players.findIndex(p => p.id === owner) * 4, 0, target);
|
||||
actions?.push(structuredClone(step));
|
||||
// chooseLeaderSkill first shuffles its rejected card back into the remaining
|
||||
// skill deck (old deck length draws); only THEN does advanceSetup deal Traitors.
|
||||
game = shuffled(source, desired, () => applyAction(game, actor, step.action), game.leaderSkills!.deck.length);
|
||||
} else game = stepDiscoveryStandaloneNexusE3Skills(game, step, actions);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); return game;
|
||||
}
|
||||
function reserveBoard(game: Game, staging: string[]): void {
|
||||
for (const p of game.players) {
|
||||
p.reserves += Object.values(p.forces).reduce((sum, count) => sum + count, 0); p.forces = {};
|
||||
if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((sum, count) => sum + count, 0); p.elites.forces = {}; }
|
||||
if (p.advisors) p.advisors = {};
|
||||
}
|
||||
staging.push('Conserved original board counters through OWN typed reserves; no held cards, wallets, earned custody, clocks, deaths or results assigned.');
|
||||
}
|
||||
export function positionDiscoveryStandaloneNexusE3Skills(game: Game, actor: string, location: string, normal: number,
|
||||
staging: string[]): void {
|
||||
const p = discoveryStandaloneNexusE3SkillsPlayer(game, actor);
|
||||
assert.ok(Number.isSafeInteger(normal) && normal >= 0 && p.reserves - (p.elites?.reserves ?? 0) >= normal);
|
||||
p.reserves -= normal; p.forces[location] = (p.forces[location] ?? 0) + normal;
|
||||
staging.push(`Conserved ${normal} original ordinary ${actor} counters: own reserves → ${location}.`);
|
||||
}
|
||||
export function assertDiscoveryStandaloneNexusE3SkillsCustody(game: Game): void {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players); validateDiscoveryState(game.discoveries!);
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
assert.deepEqual([...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards),
|
||||
...game.leaderSkills!.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
|
||||
const nexus = game.nexusCards!.cards!;
|
||||
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])].map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0), 20);
|
||||
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, count) => sum + count, 0), p.faction === 'emperor' ? 5 : 3);
|
||||
}
|
||||
}
|
||||
/** Human endpoint: actual reveal then first END Mentat; free entry is before next Storm. */
|
||||
export function createDiscoveryStandaloneNexusE3SkillsFixture(options: DiscoveryStandaloneNexusE3SkillsOptions = {}): DiscoveryStandaloneNexusE3SkillsFixture {
|
||||
const setup = initializeDiscoveryStandaloneNexusE3Skills(options), initial = options.initial ? copy(options.initial) : copy(setup);
|
||||
const actions: DiscoveryStandaloneNexusE3SkillsStep[] = [], staging: string[] = [];
|
||||
staging.push('Continuation uses the existing explicit scalar fixture lottery for original Storm/Spice outcomes; byte-array UUID entropy remains native. This is controlled source play, not natural random history.');
|
||||
if (!options.initial) staging.push('Controlled only the original physical all14 first shuffle; for Moritani, controlled the original pending Traitor shuffle before its real selection. No offered or held card is injected.');
|
||||
let game = completeDiscoveryStandaloneNexusE3SkillsSetup(setup, options, actions);
|
||||
const trained = copy(game), owner = game.players.find(p => p.faction === 'ecaz' || p.faction === 'moritani')!.id;
|
||||
const native = discoveryStandaloneNexusE3SkillsPlayer(game, owner).faction as DiscoveryStandaloneNexusE3SkillsNative;
|
||||
const opponent = (game.players.find(p => p.faction === 'guild') ?? game.players.find(p => p.id !== owner))!.id;
|
||||
const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner)!;
|
||||
const target = discoveryStandaloneNexusE3SkillsPlayer(game, owner).traitors.find(id =>
|
||||
game.leaderSkills!.assignments.some(a => a.owner === opponent && a.leader === id)) ?? null;
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 1 && discoveryStandaloneNexusE3SkillsClean(g), actions);
|
||||
const afterFirstStorm = copy(game); orderDiscoveryClassicNexusSkillsSpice(game, false, staging);
|
||||
for (let count = 0; !game.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed') && count < 120; count++) {
|
||||
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next); actions.push(structuredClone(next));
|
||||
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, next.actor, next.action));
|
||||
}
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 5 && discoveryStandaloneNexusE3SkillsClean(g), actions);
|
||||
reserveBoard(game, staging);
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
const source = `${placement.territory}:${placement.sector}`, wormSource = 'hagga_basin:12';
|
||||
const claim: StrongholdId = native === 'moritani' ? 'tueks_sietch' : 'arrakeen';
|
||||
positionDiscoveryStandaloneNexusE3Skills(game, owner, source, 3, staging);
|
||||
positionDiscoveryStandaloneNexusE3Skills(game, owner, wormSource, 6, staging);
|
||||
if (game.strongholdCards) positionDiscoveryStandaloneNexusE3Skills(game, owner, `${claim}:${territory(claim).sectors[0]}`, 1, staging);
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 7 && discoveryStandaloneNexusE3SkillsClean(g), actions);
|
||||
const collection = copy(game), token = game.discoveries!.tokens.find(t => t.face === 'cistern')!.id;
|
||||
for (const reveal of [false, true]) game = stepDiscoveryStandaloneNexusE3Skills(game, { actor: owner, action: { type: 'discovery', token, reveal } }, actions);
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 8 && discoveryStandaloneNexusE3SkillsClean(g), actions);
|
||||
let firstMentat: DiscoveryStandaloneNexusE3SkillsTransition | undefined;
|
||||
while (game.turn === 1) {
|
||||
const before = copy(game), step = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(step);
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, step, actions);
|
||||
if (game.turn === 2) firstMentat = { before, step, after: copy(game) };
|
||||
}
|
||||
assert.ok(firstMentat); assert.equal(game.decision?.kind, 'discoveryEntry'); assert.equal(game.decision!.player, owner);
|
||||
return { initial, setup, trained, afterFirstStorm, collection, firstMentat, game, native, owner, opponent,
|
||||
fremen: game.players.find(p => p.faction === 'fremen')?.id ?? null, leader: assignment.leader, skill: assignment.skill,
|
||||
target, token, source, wormSource, claim, actions, staging };
|
||||
}
|
||||
export function enterDiscoveryStandaloneNexusE3Skills(f: DiscoveryStandaloneNexusE3SkillsFixture, amount = 3): Game {
|
||||
const game = copy(f.game), view = viewGame(game, f.owner);
|
||||
const source = view.discoveryEntry!.sources.find(s => s.normal >= amount); assert.ok(source);
|
||||
const action = discoveryEntryMoveAction(view, [{ source: source.source, normal: amount, elite: 0 }]); assert.ok(action);
|
||||
return stepDiscoveryStandaloneNexusE3Skills(game, { actor: f.owner, action }, f.actions);
|
||||
}
|
||||
/** Original Maker deaths, all votes, actual typed reserve ride, settled alliance and BOTH piles close before drawing. */
|
||||
export function closeDiscoveryStandaloneNexusE3Skills(f: DiscoveryStandaloneNexusE3SkillsFixture,
|
||||
target: FactionId | undefined = f.native): DiscoveryStandaloneNexusE3SkillsClosing {
|
||||
assert.ok(f.game.players.length >= 3, 'A qualified native draw needs two other real alliance seats.');
|
||||
let game = enterDiscoveryStandaloneNexusE3Skills(f); const entered = copy(game);
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 1 && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
|
||||
orderDiscoveryClassicNexusSkillsSpice(game, true, f.staging);
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.greatMaker?.stage === 'vote', f.actions);
|
||||
const vote = copy(game);
|
||||
while (game.decision?.kind === 'greatMakerVote') game = stepDiscoveryStandaloneNexusE3Skills(game,
|
||||
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, f.actions);
|
||||
let ride: DiscoveryStandaloneNexusE3SkillsTransition | null = null;
|
||||
if (game.decision?.kind === 'greatMakerRide') {
|
||||
const before = copy(game), actor = game.decision.player;
|
||||
const action = greatMakerRideAction(viewGame(game, actor), 'polar_sink', 0, 2, game.advanced ? 1 : 0); assert.ok(action);
|
||||
const step = { actor, action }; game = stepDiscoveryStandaloneNexusE3Skills(game, step, f.actions);
|
||||
ride = { before, step, after: copy(game) };
|
||||
}
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.nexus && !g.spiceWindow && !g.spiceResolution && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
|
||||
const others = game.players.filter(p => p.id !== f.owner);
|
||||
for (const [actor, target] of [[others[0].id, others[1].id], [others[1].id, others[0].id]])
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, { actor, action: { type: 'alliance', target } }, f.actions);
|
||||
const alliance = copy(game);
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.nexusCards?.phase?.stage === 'drawing' && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
if (target) {
|
||||
const index = cards.deck.indexOf(target); assert.ok(index >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
f.staging.push(`Conserved UNPLAYED original ${target} Nexus singleton ordered before actual native closing choice; no held-card injection.`);
|
||||
}
|
||||
const before = copy(game), step: DiscoveryStandaloneNexusE3SkillsStep = { actor: f.owner,
|
||||
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[f.owner], choice: 'draw', ownRedraws: 0 } };
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, step, f.actions);
|
||||
return { entered, vote, ride, alliance, draw: { before, step, after: copy(game) }, game };
|
||||
}
|
||||
export function cunningDiscoveryStandaloneNexusE3Skills(f: DiscoveryStandaloneNexusE3SkillsFixture,
|
||||
state: Game): DiscoveryStandaloneNexusE3SkillsTransition {
|
||||
let game = copy(state);
|
||||
if (f.native === 'ecaz') {
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 5 && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
|
||||
reserveBoard(game, f.staging);
|
||||
if (game.strongholdCards)
|
||||
positionDiscoveryStandaloneNexusE3Skills(game, f.owner, `${f.claim}:${territory(f.claim).sectors[0]}`, 1, f.staging);
|
||||
// With no physical clash, the original engine skips Battle altogether.
|
||||
// Keep a real chooser pending, but do not open a battle before quiet Cunning.
|
||||
for (const actor of [f.owner, f.opponent])
|
||||
positionDiscoveryStandaloneNexusE3Skills(game, actor, 'cistern:0', 1, f.staging);
|
||||
}
|
||||
const phase = f.native === 'ecaz' ? 6 : 8;
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === phase &&
|
||||
(f.native === 'ecaz' ? discoveryStandaloneNexusE3SkillsClean(g) : g.decision?.kind === 'moritaniPlacement') && !g.battle, f.actions);
|
||||
const before = copy(game), step = f.native === 'ecaz' ? ecazNexusSkillsModulesCunningRequest(game, f.owner)
|
||||
: { actor: f.owner, action: moritaniNexusSkillsModulesRequest(game, f.owner, 'robbery', 'red_chasm') };
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, step, f.actions);
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => !g.response && !g.phaseOpening && !g.pendingKarama, f.actions);
|
||||
return { before, step, after: game };
|
||||
}
|
||||
/** A requested face reaches the hand ONLY through an actual original auction and payment. */
|
||||
function buyWorthless(f: DiscoveryStandaloneNexusE3SkillsFixture, state: Game, actor: string,
|
||||
enemy?: string): { game: Game; card: string; enemyCard?: string } {
|
||||
let game = advanceDiscoveryStandaloneNexusE3Skills(state, g => g.phase === 2 && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
|
||||
const wanted: { actor: string; card: string }[] = [], front: Game['deck'] = [];
|
||||
for (const id of [actor, ...(enemy ? [enemy] : [])]) {
|
||||
const held = discoveryStandaloneNexusE3SkillsPlayer(game, id).hand.find(c => c.kind === 'worthless');
|
||||
if (held) { wanted.push({ actor: id, card: held.id }); continue; }
|
||||
const index = game.deck.findIndex(c => c.kind === 'worthless'); assert.ok(index >= 0);
|
||||
const card = game.deck.splice(index, 1)[0]; front.push(card); wanted.push({ actor: id, card: card.id });
|
||||
f.staging.push(`Conserved original UNPLAYED Ecaz33 ${card.id} ordered before auction; actual bidder and payment alone acquire it.`);
|
||||
}
|
||||
game.deck.unshift(...front);
|
||||
for (let count = 0; count < 320; count++) {
|
||||
if (wanted.every(w => discoveryStandaloneNexusE3SkillsPlayer(game, w.actor).hand.some(c => c.id === w.card)))
|
||||
return { game, card: wanted[0].card, enemyCard: wanted[1]?.card };
|
||||
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next);
|
||||
if (game.phase === 3 && !game.response && !game.phaseOpening && !game.decision && game.auction && !game.auction.bid) {
|
||||
const buyer = wanted.find(w => w.card === game.auction!.cards[game.auction!.index]?.id && w.actor === game.auction!.active);
|
||||
if (buyer) next.action = { type: 'bid', amount: 1 };
|
||||
}
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, next, f.actions);
|
||||
}
|
||||
throw Error('Actual auction did not deliver its physical requested faces.');
|
||||
}
|
||||
/** For Ecaz, occupy requests a later REAL Nexus alliance and returns the mandatory lead choice.
|
||||
* No alliance is assigned and no quiet Duke custody is confused with native training. */
|
||||
export function openDiscoveryStandaloneNexusE3SkillsBattle(f: DiscoveryStandaloneNexusE3SkillsFixture, state: Game,
|
||||
options: { occupy?: boolean; lead?: 'native' | 'ally'; normalCall?: boolean } = {}): DiscoveryStandaloneNexusE3SkillsBattle {
|
||||
let game = copy(state), ally: string | null = null;
|
||||
if (options.occupy) {
|
||||
assert.equal(f.native, 'ecaz'); assert.ok(game.advanced && game.players.length >= 3);
|
||||
// Quiet Cunning left an actual, unopened clash. Resolve it with original
|
||||
// discs and zero-dial sealed plans before the later Occupy Nexus; never
|
||||
// erase its chooser or advance a fabricated Battle result.
|
||||
if (game.phase === 6 && !game.battle) {
|
||||
const chooser = game.active!;
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, { actor: chooser,
|
||||
action: { type: 'chooseBattle', territory: 'cistern', target: chooser === f.owner ? f.opponent : f.owner } }, f.actions);
|
||||
for (let count = 0; count < 160; count++) {
|
||||
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game);
|
||||
if (!next) break;
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, next, f.actions);
|
||||
}
|
||||
assert.ok(game.battle && !game.battle.revealed && discoveryStandaloneNexusE3SkillsClean(game));
|
||||
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const defender = discoveryStandaloneNexusE3SkillsPlayer(game, f.opponent).leaders
|
||||
.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(defender);
|
||||
game = revealDiscoveryNativeE3SkillsPlans(game, [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader: f.leader, dial: 0, support: 0 } },
|
||||
{ actor: f.opponent, action: { type: 'battlePlan', leader: defender.id, dial: 0, support: 0 } },
|
||||
], f.actions);
|
||||
game = finishDiscoveryStandaloneNexusE3SkillsBattle(game, f.actions);
|
||||
}
|
||||
const turn = game.turn;
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.turn > turn && g.phase === 1 && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
|
||||
const front: Game['spiceDeck'] = [];
|
||||
for (const kind of ['worm', 'land', 'land']) {
|
||||
const index = game.spiceDeck.findIndex(c => kind === 'worm' ? 'worm' in c && !c.greatMaker : 'territory' in c && !c.discovery);
|
||||
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front); f.staging.push('Conserved original unplayed ordinary worm and both replacement lands for a later actual Occupy alliance.');
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.nexus && !g.spiceWindow && !g.spiceResolution && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
|
||||
ally = game.players.find(p => p.id !== f.owner && p.id !== f.opponent)!.id;
|
||||
if (discoveryStandaloneNexusE3SkillsPlayer(game, ally).ally)
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, { actor: ally, action: { type: 'alliance', target: null } }, f.actions);
|
||||
for (const [actor, target] of [[f.owner, ally], [ally, f.owner]])
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, { actor, action: { type: 'alliance', target } }, f.actions);
|
||||
}
|
||||
const actor = options.lead === 'ally' ? ally! : f.owner;
|
||||
const bought = buyWorthless(f, game, actor, f.native === 'moritani' ? f.opponent : undefined); game = bought.game;
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 5 && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
|
||||
reserveBoard(game, f.staging);
|
||||
const site = f.native === 'moritani' && game.strongholdCards ? 'tueks_sietch' : 'cistern';
|
||||
const location = `${site}:${site === 'cistern' ? 0 : territory(site).sectors[0]}`;
|
||||
for (const id of [f.owner, f.opponent, ...(ally ? [ally] : [])])
|
||||
positionDiscoveryStandaloneNexusE3Skills(game, id, location, ally && id === f.owner ? 3 : 6, f.staging);
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 6 && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
|
||||
if (f.native === 'ecaz' && !ally && viewGame(game, f.owner).nexusEcazDuke?.blocked === null)
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, ecazNexusSkillsModulesCunningRequest(game, f.owner), f.actions);
|
||||
const beforeBattle = copy(game);
|
||||
const chooser = game.active!; assert.ok([f.owner, f.opponent, ally].includes(chooser));
|
||||
const choice = viewGame(game, chooser).battleChoices.find(c => c.chooser === chooser && c.territory === site &&
|
||||
(c.attacker === f.opponent || c.defender === f.opponent));
|
||||
assert.ok(choice, 'Choose the actual unresolved physical coalition battle at this site.');
|
||||
const target = chooser === f.opponent
|
||||
? choice.attacker === chooser ? choice.defender : choice.attacker
|
||||
: f.opponent;
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, { actor: chooser,
|
||||
action: { type: 'chooseBattle', territory: site, target } }, f.actions);
|
||||
let leadChoice: DiscoveryStandaloneNexusE3SkillsTransition | null = null;
|
||||
if (ally) {
|
||||
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.decision?.kind === 'ecazBattleLead' && !g.response && !g.phaseOpening, f.actions);
|
||||
const decision = game.decision; assert.ok(decision?.kind === 'ecazBattleLead');
|
||||
const before = copy(game), step: DiscoveryStandaloneNexusE3SkillsStep = { actor: f.owner,
|
||||
action: { type: 'decision', event: decision.event, lead: actor } };
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, step, f.actions); leadChoice = { before, step, after: copy(game) };
|
||||
}
|
||||
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const own = discoveryStandaloneNexusE3SkillsPlayer(game, actor);
|
||||
const enemy = discoveryStandaloneNexusE3SkillsPlayer(game, f.opponent);
|
||||
const leader = f.native === 'ecaz' ? game.leaderSkills!.assignments.find(a => a.owner === actor)!.leader
|
||||
: own.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0].id;
|
||||
const enemyLeader = options.normalCall ? own.traitors.find(id => enemy.leaders.some(l => l.id === id && !l.dead))
|
||||
: enemy.leaders.filter(l => !l.dead && !trained.has(l.id) && l.id !== f.target).sort((a, b) => f.native === 'ecaz' ? a.strength - b.strength : b.strength - a.strength)[0]?.id;
|
||||
assert.ok(enemyLeader, 'Normal-call case requires an actually held opposing Traitor, never a transfer.');
|
||||
for (let count = 0; count < 160; count++) {
|
||||
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game);
|
||||
if (!next) break;
|
||||
if (next.action.type === 'leaderSkillVisibility') {
|
||||
const planned = next.actor === actor ? leader : next.actor === f.opponent ? enemyLeader : null;
|
||||
next.action.hide = game.leaderSkills!.assignments.some(a => a.owner === next.actor && a.leader === planned);
|
||||
}
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, next, f.actions);
|
||||
}
|
||||
assert.ok(game.battle && !game.battle.revealed && discoveryStandaloneNexusE3SkillsClean(game));
|
||||
const dial = f.native === 'ecaz' ? 1 : 4;
|
||||
const fixedDial = viewGame(game, actor).battle!.ecazOccupy?.profile?.fixedEcazDial ?? 0;
|
||||
return { beforeBattle, leadChoice, game, actor, opponent: f.opponent, leader, enemyLeader, location, card: bought.card, enemyCard: bought.enemyCard,
|
||||
target: f.target, plans: [
|
||||
{ actor, action: { type: 'battlePlan', leader, dial: fixedDial + 1,
|
||||
support: game.advanced && discoveryStandaloneNexusE3SkillsPlayer(game, ally ?? actor).faction !== 'fremen' ? 1 : 0, weapon: bought.card } },
|
||||
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemyLeader, dial, support: game.advanced && enemy.faction !== 'fremen' ? dial : 0, weapon: bought.enemyCard } },
|
||||
] };
|
||||
}
|
||||
export function revealDiscoveryStandaloneNexusE3SkillsBattle(battle: DiscoveryStandaloneNexusE3SkillsBattle,
|
||||
actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game {
|
||||
return revealDiscoveryNativeE3SkillsPlans(copy(battle.game), battle.plans, actions);
|
||||
}
|
||||
export function settleDiscoveryStandaloneNexusE3SkillsBattle(state: Game, callers: readonly string[] = [],
|
||||
actions?: DiscoveryStandaloneNexusE3SkillsStep[]): DiscoveryStandaloneNexusE3SkillsBoundary {
|
||||
let game = copy(state);
|
||||
const choices: Partial<Record<NonNullable<Game['decision']>['kind'], DiscoveryStandaloneNexusE3SkillsBoundary['kind']>> = {
|
||||
battleLosses: 'losses', sukRescue: 'rescue', moritaniAssassinate: 'assassination', battleCards: 'cards', techToken: 'tech',
|
||||
};
|
||||
for (let count = 0; count < 240; count++) {
|
||||
const kind = game.decision && choices[game.decision.kind];
|
||||
if (kind && !game.response && !game.phaseOpening) return { game, kind };
|
||||
if (!game.battle && discoveryStandaloneNexusE3SkillsClean(game)) return { game, kind: 'settled' };
|
||||
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next);
|
||||
if (next.action.type === 'traitorCall') next.action.call = callers.includes(next.actor);
|
||||
game = stepDiscoveryStandaloneNexusE3Skills(game, next, actions);
|
||||
}
|
||||
throw Error('Original human settlement boundary did not open.');
|
||||
}
|
||||
export function finishDiscoveryStandaloneNexusE3SkillsBattle(state: Game,
|
||||
actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game {
|
||||
return advanceDiscoveryStandaloneNexusE3Skills(copy(state), g => !g.battle && discoveryStandaloneNexusE3SkillsClean(g), actions);
|
||||
}
|
||||
Reference in new issue
Block a user