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