import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game, } from '../game/engine'; import { leaders, treacheryDeck, type Card } from '../game/cards'; import { traitorDeck } from '../game/traitors'; import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; import { newRevivalRules } from '../game/revival'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { advancedNativePlayer as player, advancedNativeStep, assertAdvancedNativeCustody, completedAdvancedNativeSkillsGame, completeAdvancedNativeSkillsSetup, initializeAdvancedNativeSkillsSetup, openAdvancedNativeSkillBattle, rejectAdvancedNativeAction as reject, stageAdvancedNativeSkillBattle, finishAdvancedNativeSkillAftermath, } from './fixture-advanced-native-skills'; const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game; const act = (game: Game, actor: string, action: Action): Game => applyAction(reload(game), actor, action); const wallets = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice])); const trainer = (game: Game, actor: string) => game.leaderSkills!.assignments.find(a => a.owner === actor)!; function custody(game: Game): void { assertAdvancedNativeCustody(game); const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), ...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id); assert.deepEqual(cards.sort(), treacheryDeck(['ecaz']).map(c => c.id).sort()); assert.equal(cards.length, 33); assert.deepEqual(game.players.flatMap(p => p.leaders.map(l => l.id)).sort(), game.players.flatMap(p => leaders(p.faction).map(l => l.id)).sort()); const traitors = [...game.traitorReserve!, ...game.players.flatMap(p => p.traitors), ...(game.moritaniAssassinate?.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!) ?? [])]; assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort()); assert.equal(new Set(traitors).size, traitors.length); } function take(game: Game, actor: string, matches: (card: Card) => boolean): Card { const index = game.deck.findIndex(matches); assert.ok(index >= 0, 'Staged card transfers must use the genuine native deck.'); const card = game.deck.splice(index, 1)[0]; game.players.find(p => p.id === actor)!.hand.push(card); return card; } function allow(state: Game): Game { let game = state; for (let i = 0; game.response && i < 30; i++) game = advancedNativeStep(game); assert.equal(game.response, null); return game; } /** Explicit phase controls after real setup, not a claim of natural phase history. * Placement is the original free native action; no wallet or inventory is edited. */ function placeTerror(state: Game, kind: 'assassination' | 'robbery'): Game { let game = reload(state); Object.assign(game, { phase: 7, active: null, ready: [], decision: null, response: null, phaseOpening: null }); for (const seat of game.players) game = act(game, seat.id, { type: 'ready' }); assert.equal(game.decision?.kind, 'moritaniPlacement'); const owner = player(game, 'moritani').id; const token = game.moritaniTerror!.tokens.find(t => t.kind === kind)!; const funds = wallets(game); game = allow(act(game, owner, { type: 'decision', token: token.id, territory: 'carthag' })); assert.equal(game.moritaniTerror!.tokens.find(t => t.id === token.id)!.location, 'carthag'); assert.deepEqual(wallets(game), funds, 'Native Terror placement is free.'); custody(game); return game; } function enterTerror(state: Game, actor: string): Game { const game = reload(state); // Placement ends a turn; entering the next Shipment phase starts a later turn, // keeping the mandatory Banker income ledger chronological. Object.assign(game, { turn: game.phase > 5 ? game.turn + 1 : game.turn, phase: 5, storm: 18, active: actor, ready: [], decision: null, response: null, phaseOpening: null, movementRemaining: [...game.order] }); const entrant = game.players.find(p => p.id === actor)!; entrant.shipped = false; entrant.moved = 0; return act(game, actor, { type: 'ship', territory: 'carthag', sector: 11, amount: 1 }); } function resolve(state: Game, actor: string, target: string): Game { let game = act(state, actor, { type: 'traitorCall', call: false }); game = act(game, target, { type: 'traitorCall', call: false }); return game; } void test('Advanced native setup deals starting cards, full14 skills, traitors and original six-force placement in source order', () => { const lobby = createGame('MORISKIL', newPlayer('original-m', 'Moritani', 'moritani'), true, ['ecaz']); joinGame(lobby, newPlayer('original-a', 'Atreides', 'atreides')); joinGame(lobby, newPlayer('original-e', 'Emperor', 'emperor')); const options = { family: 'moritani' as const, requestedSkill: 'warmaster' as const, initial: lobby }; let game = initializeAdvancedNativeSkillsSetup(options); assert.deepEqual(game.players.map(p => p.id), lobby.players.map(p => p.id)); assert.equal(game.setupStage, 'leaderSkills'); assert.equal(game.leaderSkills!.deck.length, 8); assert.deepEqual(game.players.map(p => p.hand.length), [1, 1, 1]); assert.deepEqual(game.players.map(p => p.traitors), [[], [], []]); assert.equal(player(game, 'moritani').reserves, 20); for (const seat of game.players) { const own = viewGame(game, seat.id).leaderSkills!; assert.equal(own.offer!.cards.length, 2); assert.equal(own.assignments.length, 0); assert.equal(own.offer!.event, game.leaderSkills!.offers[seat.id].event); } for (const seat of game.players) { const offer = game.leaderSkills!.offers[seat.id]; const own = viewGame(game, seat.id).leaderSkills!; const skill = offer.cards.find(c => !own.unavailableSkills?.[c])!; game = act(game, seat.id, { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id }); } assert.equal(game.setupStage, 'traitors'); assert.equal(player(game, 'moritani').reserves, 20); while (game.status === 'setup' && game.decision?.kind !== 'moritaniSetup') game = advancedNativeStep(game); assert.equal(game.decision?.kind, 'moritaniSetup'); assert.deepEqual(game.players.map(p => p.traitors.length), [1, 1, 1]); game = completeAdvancedNativeSkillsSetup(game, options); assert.equal(game.phase, 0); assert.equal(player(game, 'moritani').reserves, 14); assert.equal(Object.values(player(game, 'moritani').forces).reduce((a, b) => a + b, 0), 6); assert.equal(game.moritaniAssassinate!.owner, 'original-m'); custody(game); // An admitted original setup must retain its physical offers and native deck. const offered = initializeAdvancedNativeSkillsSetup(options); const continued = initializeAdvancedNativeSkillsSetup({ ...options, initial: reload(offered), requestedSkill: 'suk-graduate' }); assert.deepEqual(continued, offered); }); void test('all fourteen native skills assign genuine Moritani discs and all four policies consume private legal offers', () => { for (const card of LEADER_SKILL_CARDS) { const game = completedAdvancedNativeSkillsGame({ family: 'moritani', requestedSkill: card.id }); assert.equal(trainer(game, player(game, 'moritani').id).skill, card.id); assert.equal(player(game, 'moritani').leaders.find(l => l.id === trainer(game, player(game, 'moritani').id).leader)!.dead, false); custody(game); } const offered = initializeAdvancedNativeSkillsSetup({ family: 'moritani', requestedSkill: 'warmaster' }); const actor = player(offered, 'moritani').id; for (const difficulty of DIFFICULTIES) { const view = viewGame(reload(offered), actor); view.players.find(p => p.id === actor)!.bot = difficulty; const action = botActions(view)[0]; assert.equal(action?.type, 'leaderSkill', difficulty); const selected = act(offered, actor, action); assert.ok(offered.leaderSkills!.offers[actor].cards.includes(trainer(selected, actor).skill)); assert.equal(selected.leaderSkills!.offers[actor], undefined); assert.equal(trainer(selected, actor).leader, action.leader); assertAdvancedNativeCustody(selected); } }); void test('native Terror kills a real trained disc, returns only its card, and eligible paid own revival offers one private optional draw', () => { let game = completedAdvancedNativeSkillsGame({ family: 'moritani', skillOwner: 'emperor', requestedSkill: 'warmaster' }); const owner = player(game, 'moritani').id, actor = player(game, 'emperor').id; const assignment = structuredClone(trainer(game, actor)); // Controlled leader availability makes the native random victim unambiguous; // no trained card, force or wallet is fabricated or removed by this staging. for (const leader of player(game, 'emperor').leaders) if (leader.id !== assignment.leader) { leader.dead = true; leader.deaths = 1; } game = placeTerror(game, 'assassination'); const beforeEntry = reload(game); game = enterTerror(game, actor); assert.equal(player(game, 'emperor').reserves, player(beforeEntry, 'emperor').reserves - 1); assert.equal(player(game, 'emperor').forces['carthag:11'], 1); assert.equal(viewGame(game, owner).terrorEntry!.kind, 'assassination'); assert.equal('kind' in viewGame(game, actor).terrorEntry!, false); const others = game.leaderSkills!.assignments.filter(a => a.owner !== actor); const funds = wallets(game); const strength = player(game, 'emperor').leaders.find(l => l.id === assignment.leader)!.strength; game = act(game, owner, { type: 'decision', reveal: true }); assert.equal(player(game, 'emperor').leaders.find(l => l.id === assignment.leader)!.dead, true); assert.equal(game.leaderSkills!.assignments.some(a => a.owner === actor), false); assert.deepEqual(game.leaderSkills!.assignments, others); assert.equal(game.leaderSkills!.deck.filter(c => c === assignment.skill).length, 1); assert.deepEqual(wallets(game), { ...funds, [owner]: funds[owner] + strength }); custody(game); reject(game, owner, { type: 'decision', reveal: true }); // Explicit conserved Revival phase of the next turn; retain real post-shipment money and Tanks. Object.assign(game, { turn: game.turn + 1, phase: 4, active: null, ready: [], decision: null, response: null, phaseOpening: null, revivalRules: newRevivalRules() }); const paidBefore = wallets(game); const offered = act(game, actor, { type: 'reviveLeader', leader: assignment.leader }); assert.equal(offered.decision?.kind, 'leaderSkillRevival'); assert.equal(player(offered, 'emperor').leaders.find(l => l.id === assignment.leader)!.dead, false); assert.deepEqual(wallets(offered), { ...paidBefore, [actor]: paidBefore[actor] - strength }); assert.deepEqual(viewGame(offered, actor).leaderSkills!.offer!.cards, []); for (const seat of offered.players.filter(p => p.id !== actor)) assert.equal(viewGame(offered, seat.id).leaderSkills!.offer, null); const event = offered.leaderSkills!.offers[actor].event; reject(offered, owner, { type: 'leaderSkill', event, mode: 'draw' }); reject(offered, actor, { type: 'leaderSkill', event: `${event}:stale`, mode: 'draw' }); const declined = act(offered, actor, { type: 'leaderSkill', event, mode: 'decline' }); assert.equal(declined.leaderSkills!.assignments.some(a => a.owner === actor), false); assert.equal(declined.leaderSkills!.offers[actor], undefined); custody(declined); const drawn = act(offered, actor, { type: 'leaderSkill', event, mode: 'draw' }); const own = viewGame(drawn, actor).leaderSkills!; assert.equal(own.offer!.cards.length, 2); for (const seat of drawn.players.filter(p => p.id !== actor)) assert.equal(viewGame(drawn, seat.id).leaderSkills!.offer, null); const skill = own.offer!.cards.find(c => !own.unavailableSkills?.[c])!; const chosen = act(drawn, actor, { type: 'leaderSkill', event, skill, leader: assignment.leader }); assert.deepEqual(trainer(chosen, actor), { owner: actor, leader: assignment.leader, skill }); assert.deepEqual(wallets(chosen), wallets(offered)); custody(chosen); reject(chosen, actor, { type: 'leaderSkill', event, skill, leader: assignment.leader }); for (const difficulty of DIFFICULTIES) { const view = viewGame(reload(offered), actor); view.players.find(p => p.id === actor)!.bot = difficulty; const next = act(offered, actor, botActions(view)[0]); assert.deepEqual(wallets(next), wallets(offered)); assert.equal(player(next, 'emperor').leaders.find(l => l.id === assignment.leader)!.dead, false); assert.ok(next.leaderSkills!.offers[actor]?.cards.length === 2 || next.leaderSkills!.offers[actor] === undefined); custody(next); } }); void test('native Suk distinguishes one normal casualty return from the concealed three-force rescue and all four policies keep paid loss custody', () => { const setup = completedAdvancedNativeSkillsGame({ family: 'moritani', requestedSkill: 'suk-graduate' }); const actor = player(setup, 'moritani').id, target = player(setup, 'emperor').id; for (const hidden of [false, true]) { let game = stageAdvancedNativeSkillBattle(setup, actor, target); const funds = wallets(game), reserves = player(game, 'moritani').reserves; const leader = player(game, 'moritani').leaders.find(l => hidden ? l.id === trainer(game, actor).leader : l.id !== trainer(game, actor).leader && l.strength === 4)!; const enemy = player(game, 'emperor').leaders.find(l => l.id !== trainer(game, target).leader)!; game = openAdvancedNativeSkillBattle(game, actor, target, hidden); game = act(game, actor, { type: 'battlePlan', dial: 5, support: 5, leader: leader.id }); game = act(game, target, { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id }); game = finishAdvancedNativeSkillAftermath(resolve(game, actor, target)); assert.equal(game.lastBattleContext!.winner, actor); assert.equal(player(game, 'moritani').spice, funds[actor] - 5); assert.equal(player(game, 'emperor').spice, funds[target]); if (hidden) { assert.equal(game.decision?.kind, 'sukRescue'); if (game.decision?.kind !== 'sukRescue') throw new Error('Expected the original native rescue decision.'); const pending = game; const choice = game.decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.key === 'wind_pass:14'); assert.ok(choice >= 0); reject(game, target, { type: 'decision', event: game.decision.event, choice }); game = act(game, actor, { type: 'decision', event: game.decision.event, choice }); assert.equal(player(game, 'moritani').tanks, 2); assert.equal(player(game, 'moritani').forces['wind_pass:14'], 4); assert.equal(player(game, 'moritani').reserves, reserves + 2); for (const difficulty of DIFFICULTIES) { const view = viewGame(reload(pending), actor); view.players.find(p => p.id === actor)!.bot = difficulty; const rescued = act(pending, actor, botActions(view)[0]); assert.equal(rescued.pendingSukRescue, null); assert.equal(player(rescued, 'moritani').tanks, 2); assert.equal(player(rescued, 'moritani').spice, funds[actor] - 5); custody(rescued); } } else { assert.equal(game.pendingSukRescue, null); assert.equal(player(game, 'moritani').tanks, 4); assert.equal(player(game, 'moritani').forces['wind_pass:14'], 3); assert.equal(player(game, 'moritani').reserves, reserves + 1); } custody(game); } }); void test('native normal +1 and concealed trainer +3 change real winners while paid dials produce physical casualties and mandatory card cleanup', () => { const roles = [ ['warmaster', 'worthless'], ['master-of-assassins', 'poison'], ['swordmaster-of-ginaz', 'projectile'], ['killer-medic', 'snooper'], ['prana-bindu-adept', 'shield'], ] as const; for (const [skill, kind] of roles) for (const hidden of [false, true]) { const setup = completedAdvancedNativeSkillsGame({ family: 'moritani', requestedSkill: skill }); const actor = player(setup, 'moritani').id, target = player(setup, 'emperor').id; let game = stageAdvancedNativeSkillBattle(setup, actor, target); assert.deepEqual(wallets(game), wallets(setup)); const role = take(game, actor, c => c.kind === kind); const own = player(game, 'moritani'); const selected = hidden ? own.leaders.find(l => l.id === trainer(game, actor).leader)! : own.leaders.find(l => l.id !== trainer(game, actor).leader && l.strength === 4)!; const enemy = player(game, 'emperor').leaders.filter(l => l.id !== trainer(game, target).leader && !own.traitors.includes(l.id)) .sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemy); // Opponent finishes exactly two strength above our unbonused plan: normal // +1 loses and trained +3 wins. Dial/support and casualties are physical. const lethal = kind === 'poison' || kind === 'projectile'; const enemyDial = selected.strength + 3 - (lethal ? 0 : enemy.strength); assert.ok(enemyDial >= 0 && enemyDial <= 8); const funds = wallets(game); const normalEnemyRescue = !hidden && trainer(game, target).skill === 'suk-graduate' && enemyDial > 0 ? 1 : 0; const enemyReserves = player(game, 'emperor').reserves; game = openAdvancedNativeSkillBattle(game, actor, target, hidden); if (!hidden) reject(game, actor, { type: 'battlePlan', dial: 1, support: 1, leader: trainer(game, actor).leader, weapon: role.id }); const slot = kind === 'snooper' || kind === 'shield' ? 'defense' : 'weapon'; game = act(game, actor, { type: 'battlePlan', dial: 1, support: 1, leader: selected.id, [slot]: role.id }); game = act(game, target, { type: 'battlePlan', dial: enemyDial, support: enemyDial, leader: enemy.id }); game = finishAdvancedNativeSkillAftermath(resolve(game, actor, target)); assert.equal(game.lastBattleContext!.result, 'normal'); assert.equal(game.lastBattleContext!.winner, hidden ? actor : target, `${skill}/${hidden}`); assert.equal(player(game, 'moritani').tanks, hidden ? 1 : 8); assert.equal(player(game, 'moritani').forces['wind_pass:14'] ?? 0, hidden ? 7 : 0); assert.equal(player(game, 'emperor').tanks, hidden ? 8 : enemyDial - normalEnemyRescue); assert.equal(player(game, 'emperor').reserves, enemyReserves + normalEnemyRescue); assert.equal(player(game, 'moritani').leaders.find(l => l.id === selected.id)!.dead, false); assert.equal(player(game, 'emperor').leaders.find(l => l.id === enemy.id)!.dead, lethal); assert.equal(player(game, 'moritani').spice, funds[actor] - 1 + (hidden && lethal ? enemy.strength : 0)); assert.equal(player(game, 'emperor').spice, funds[target] - enemyDial + (!hidden && lethal ? enemy.strength : 0)); assert.equal(game.discard.filter(c => c.id === role.id).length, hidden ? 0 : 1); assert.equal(player(game, 'moritani').hand.some(c => c.id === role.id), hidden); custody(game); } }); void test('original Enemy of My Enemy and ally retention exclude a consumed native Planetologist Special', () => { let game = completedAdvancedNativeSkillsGame({ family: 'moritani', skillOwner: 'emperor', requestedSkill: 'planetologist' }); const m = player(game, 'moritani').id, actor = player(game, 'emperor').id, target = player(game, 'atreides').id; game = enterTerror(placeTerror(game, 'robbery'), actor); game = allow(act(game, m, { type: 'decision', alliance: true })); game = act(game, actor, { type: 'decision', accept: true }); assert.equal(player(game, 'moritani').ally, actor); assert.equal(player(game, 'emperor').ally, m); game = stageAdvancedNativeSkillBattle(game, actor, target); const mandatory = take(game, actor, c => c.effect === 'atomics'); const retained = take(game, actor, c => c.kind === 'snooper'); const enemyDefense = take(game, target, c => c.kind === 'snooper'); game = openAdvancedNativeSkillBattle(game, actor, target); game = act(game, actor, { type: 'battlePlan', dial: 0, support: 0, leader: trainer(game, actor).leader, weapon: mandatory.id, defense: retained.id }); const enemy = player(game, 'atreides').leaders.find(l => l.id !== trainer(game, target).leader)!; game = act(game, target, { type: 'battlePlan', dial: 5, support: 5, leader: enemy.id, defense: enemyDefense.id }); game = resolve(game, actor, target); for (let i = 0; game.decision?.kind !== 'moritaniRetention' && (game.response || game.decision || game.pendingTreacheryDiscard) && i < 30; i++) { if (game.decision?.kind === 'battleCards') game = act(game, game.decision.player, { type: 'decision', discard: [] }); else game = advancedNativeStep(game); } assert.equal(game.lastBattleContext!.winner, target); assert.equal(game.decision?.kind, 'moritaniRetention'); assert.deepEqual(game.moritaniRetention!.eligible, [retained.id]); assert.equal(game.discard.filter(c => c.id === mandatory.id).length, 1); reject(game, actor, { type: 'decision', keep: mandatory.id }); reject(game, m, { type: 'decision', keep: retained.id }); for (const difficulty of DIFFICULTIES) { const view = viewGame(reload(game), actor); view.players.find(p => p.id === actor)!.bot = difficulty; const done = allow(act(game, actor, botActions(view)[0])); assert.equal(done.moritaniRetention, null); assert.equal(done.discard.filter(c => c.id === mandatory.id).length, 1); custody(done); } game = allow(act(game, actor, { type: 'decision', keep: retained.id })); assert.equal(game.moritaniRetention, null); assert.equal(player(game, 'emperor').hand.some(c => c.id === retained.id), true); custody(game); assert.deepEqual(normalizeAutomaticGame(reload(game)), game); }); void test('Basic native Harkonnen keeps four Traitors and Advanced Atreides can assign an offered Suk Graduate', () => { const basicLobby = createGame('MORIGATE', newPlayer('original-m', 'Moritani', 'moritani'), false, ['ecaz']); joinGame(basicLobby, newPlayer('original-other', 'Harkonnen', 'harkonnen')); basicLobby.players.forEach(p => { p.ready = true; }); const basic = completeAdvancedNativeSkillsSetup(initializeLeaderSkillsGameForAudit(basicLobby), { family: 'moritani' }); assert.equal(basic.advanced, false); assert.equal(basic.moritaniAssassinatePreview, undefined); assert.equal(player(basic, 'harkonnen').traitors.length, 4); assert.equal(trainer(basic, 'original-m').owner, 'original-m'); custody(basic); const options = { family: 'moritani' as const, skillOwner: 'atreides' as const, requestedSkill: 'suk-graduate' as const }; const offered = initializeAdvancedNativeSkillsSetup(options); const actor = player(offered, 'atreides').id, offer = offered.leaderSkills!.offers[actor]; const own = viewGame(offered, actor).leaderSkills!; // User ruling 7 October 2026: Suk is available to Advanced Atreides. const assigned = applyAction(offered, actor, { type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader: own.eligibleLeaders[0].id }); assert.equal(trainer(assigned, actor).skill, 'suk-graduate'); assert.ok(!assigned.leaderSkills!.deck.includes('suk-graduate')); const complete = completeAdvancedNativeSkillsSetup(assigned, options); assert.equal(trainer(complete, actor).skill, 'suk-graduate'); custody(complete); });