import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Game } from '../game/engine'; import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { advancedNativePlayer as player, advancedNativeStep, assertAdvancedNativeCustody as custody, completedAdvancedNativeSkillsGame, finishAdvancedNativeSkillAftermath, initializeAdvancedNativeSkillsSetup, openAdvancedNativeSkillBattle, rejectAdvancedNativeAction as reject, stageAdvancedNativeSkillBattle, } from './fixture-advanced-native-skills'; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game; const trainer = (g: Game, id: string) => g.leaderSkills!.assignments.find(a => a.owner === id)!; const untrained = (g: Game, id: string) => g.players.find(p => p.id === id)!.leaders .filter(l => !l.dead && l.id !== trainer(g, id).leader).sort((a, b) => b.strength - a.strength)[0].id; /** Genuine full native setup, then explicitly conserved shipment/battle position. * Native ship creates the physical marker; no fabricated deployed token. */ function markerGame(value: 0 | 3 | 5, requestedSkill: 'suk-graduate' | 'mentat' | 'smuggler', rules: 'basic' | 'advanced' = 'advanced', reserves = 20, companion = false) { const initial = completedAdvancedNativeSkillsGame({ family: 'richese', requestedSkill, rules }); const actor = player(initial, 'richese').id, target = player(initial, 'emperor').id; let game = stageAdvancedNativeSkillBattle(initial, actor, target); const owner = player(game, 'richese'); owner.reserves += owner.forces['wind_pass:14']; owner.forces = {}; owner.tanks += owner.reserves - reserves; owner.reserves = reserves; if (companion) { const enemy = player(game, 'emperor'); enemy.reserves += enemy.forces['wind_pass:14']; enemy.forces = {}; } owner.shipped = false; owner.moved = 0; Object.assign(game, { phase: 5, active: actor, ready: [], phaseOpening: null, response: null, decision: null }); const token = owner.noField!.tokens.find(t => t.value === value)!; const before = owner.spice; game = applyAction(game, actor, { type: 'ship', noField: token.id, event: owner.noFieldEvent, territory: 'wind_pass', sector: 14, smuggler: companion }); for (let n = 0; (game.response || game.decision) && n < 100; n++) game = advancedNativeStep(game); assert.equal(player(game, 'richese').spice, before - 2); assert.ok(player(game, 'richese').noField!.deployed); if (companion) { const enemy = player(game, 'emperor'); enemy.reserves -= 8; enemy.forces['wind_pass:14'] = 8; } // Explicit later opposing position; the actual companion arrived in an empty territory. const beforeBattle = reload(game); Object.assign(game, { phase: 6, active: actor, ready: [], phaseOpening: null, response: null, decision: null }); custody(game); return { game, beforeBattle, actor, target }; } void test('native Basic and Advanced Richese retain source-ordered all14 and their separate original ten-card cache', () => { for (const rules of ['basic', 'advanced'] as const) { const offered = initializeAdvancedNativeSkillsSetup({ family: 'richese', requestedSkill: 'suk-graduate', rules }); assert.equal(offered.setupStage, 'leaderSkills'); assert.equal(offered.richeseCache!.length, 10); for (const seat of offered.players) assert.equal(seat.hand.length, 1); const physical = [...offered.leaderSkills!.deck, ...Object.values(offered.leaderSkills!.offers).flatMap(o => o.cards)]; assert.deepEqual([...physical].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort()); const rival = offered.players.find(p => p.faction !== 'richese')!; const own = player(offered, 'richese'); assert.deepEqual(viewGame(offered, rival.id).leaderSkills!.eligibleLeaders.map(l => l.id).sort(), rival.leaders.filter(l => !l.dead && !l.capturedBy && !l.gholaBy).map(l => l.id).sort()); assert.equal(viewGame(offered, own.id).leaderSkills!.offer!.cards.includes('suk-graduate'), true); custody(completedAdvancedNativeSkillsGame({ family: 'richese', requestedSkill: 'suk-graduate', rules })); } }); void test('scarce native No-Field five materializes only three after both plans and skilled Suk saves only real paid losses', () => { const staged = markerGame(5, 'suk-graduate', 'advanced', 3); let game = openAdvancedNativeSkillBattle(staged.game, staged.actor, staged.target); const trained = trainer(game, staged.actor).leader; reject(game, staged.actor, { type: 'battlePlan', dial: 4, support: 3, leader: trained }); game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 3, support: 3, leader: trained }); assert.ok(player(game, 'richese').noField!.deployed); assert.equal(player(game, 'richese').forces['wind_pass:14'] ?? 0, 0); assert.equal(viewGame(game, staged.target).richeseNoField!.private, null); game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) }); assert.equal(player(game, 'richese').noField!.deployed, null); assert.equal(player(game, 'richese').forces['wind_pass:14'], 3); game = applyAction(game, staged.actor, { type: 'traitorCall', call: false }); game = applyAction(game, staged.target, { type: 'traitorCall', call: false }); game = finishAdvancedNativeSkillAftermath(game); assert.equal(game.decision?.kind, 'sukRescue'); if (game.decision?.kind !== 'sukRescue') throw new Error('Native real casualties must retain skilled rescue.'); 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); game = applyAction(reload(game), staged.actor, { type: 'decision', event: game.decision.event, choice }); game = finishAdvancedNativeSkillAftermath(game); assert.equal(player(game, 'richese').forces['wind_pass:14'], 1); assert.equal(player(game, 'richese').reserves, 2); assert.equal(player(game, 'richese').tanks, 17); assert.equal(player(game, 'richese').spice, 0); custody(game); for (const difficulty of DIFFICULTIES) { const view = viewGame(reload(pending), staged.actor); view.players.find(p => p.id === staged.actor)!.bot = difficulty; const rescued = applyAction(reload(pending), staged.actor, botActions(view)[0]); assert.equal(player(rescued, 'richese').tanks, 17); assert.equal(player(rescued, 'richese').spice, 0); custody(finishAdvancedNativeSkillAftermath(rescued)); } }); void test('normal native Suk returns one actual No-Field casualty under both rule-band support costs', () => { for (const rules of ['basic', 'advanced'] as const) { const staged = markerGame(3, 'suk-graduate', rules); let game = openAdvancedNativeSkillBattle(staged.game, staged.actor, staged.target, false); const balance = player(game, 'richese').spice; game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 2, support: rules === 'advanced' ? 2 : 0, leader: untrained(game, staged.actor) }); game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) }); game = applyAction(game, staged.actor, { type: 'traitorCall', call: false }); game = applyAction(game, staged.target, { type: 'traitorCall', call: false }); game = finishAdvancedNativeSkillAftermath(game); assert.equal(game.lastBattleContext!.winner, staged.actor); assert.equal(player(game, 'richese').forces['wind_pass:14'], 1); assert.equal(player(game, 'richese').reserves, 18); assert.equal(player(game, 'richese').tanks, 1); assert.equal(player(game, 'richese').spice, balance - (rules === 'advanced' ? 2 : 0)); custody(game); } }); void test('zero native No-Field retains an actual skilled leader battle and no phantom forces or support', () => { const staged = markerGame(0, 'mentat'); let game = openAdvancedNativeSkillBattle(staged.game, staged.actor, staged.target); const trained = trainer(game, staged.actor).leader; const before = player(game, 'richese').spice; reject(game, staged.actor, { type: 'battlePlan', dial: 1, support: 0, leader: trained }); game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 0, support: 0, leader: trained }); game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) }); game = applyAction(game, staged.actor, { type: 'traitorCall', call: false }); game = applyAction(game, staged.target, { type: 'traitorCall', call: false }); game = finishAdvancedNativeSkillAftermath(game); assert.equal(game.lastBattleContext!.winner, staged.actor); assert.equal(player(game, 'richese').reserves, 20); assert.equal(player(game, 'richese').tanks, 0); assert.equal(player(game, 'richese').forces['wind_pass:14'] ?? 0, 0); assert.equal(player(game, 'richese').spice, before); custody(game); }); void test('Basic and Advanced Smuggler companions join capped mixed No-Field battles and lose only real counters', () => { for (const rules of ['basic', 'advanced'] as const) for (const reserves of [3, 20]) { const staged = markerGame(5, 'smuggler', rules, reserves, true); const owner = player(staged.game, 'richese'); const materialized = Math.min(5, reserves - 1), pool = 1 + materialized; assert.equal(owner.reserves, reserves - 1); assert.equal(owner.forces['wind_pass:14'], 1); let game = openAdvancedNativeSkillBattle(staged.game, staged.actor, staged.target); assert.deepEqual(game.battle!.mixedNoFieldPlayers, [staged.actor]); assert.equal(viewGame(game, staged.actor).battle!.mixedNoField, true); assert.equal(viewGame(game, staged.actor).battle!.ownForces!.normal, pool); assert.equal(viewGame(game, staged.target).battle!.mixedNoField, undefined); assert.equal(viewGame(game, staged.target).battle!.opponentForces, null); const token = owner.noField!.deployed!.tokenId; reject(game, staged.actor, { type: 'revealNoField', token, event: owner.noFieldEvent }); reject(game, staged.actor, { type: 'battlePlan', dial: pool + 1, support: 0, leader: untrained(game, staged.actor) }); game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.actor) }); assert.equal(game.battle!.revealed, false); assert.ok(player(game, 'richese').noField!.deployed); assert.equal(player(game, 'richese').forces['wind_pass:14'], 1); assert.equal(player(game, 'richese').reserves, reserves - 1); game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) }); assert.equal(game.battle!.revealed, true); assert.equal(player(game, 'richese').noField!.deployed, null); assert.equal(player(game, 'richese').forces['wind_pass:14'], pool); assert.equal(player(game, 'richese').reserves, reserves - 1 - materialized); custody(game); game = applyAction(game, staged.actor, { type: 'traitorCall', call: false }); game = applyAction(game, staged.target, { type: 'traitorCall', call: false }); game = finishAdvancedNativeSkillAftermath(game); assert.equal(game.lastBattleContext!.winner, staged.target); assert.equal(player(game, 'richese').tanks, 20 - reserves + pool); assert.equal(player(game, 'richese').forces['wind_pass:14'] ?? 0, 0); assert.equal(player(game, 'richese').reserves, reserves - 1 - materialized); custody(game); } });