Retain printed native identity and valid original custody while projecting only uniform potential bounty before commitment. Resolve living/dead targets once, clear selected capture/concealment and retain ordinary Ghola return custody. Selected concealed-outcome disclosure is explicitly provisional. Verified: 14 affected offline files100/100, types/lint and production build; six actual choice/gameplay/GameTable SSR journeys and twelve profile paths. Native capture source47 actions; actual paid Ghola prefix140 actions followed by explicit conserved seven-action battle. No saved-room/D1/reset/restart or deployment operation. Runtime-only driver fixes reused unchanged passed checks.
365 lines
26 KiB
TypeScript
365 lines
26 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame,
|
|
type Action, type Game,
|
|
} from '../game/engine';
|
|
import { leaders, treacheryDeck } from '../game/cards';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
|
import { validateLeaderSkills } from '../game/leader-skills';
|
|
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
|
import { STRONGHOLD_CARDS, strongholdControllers } from '../game/stronghold-cards';
|
|
import { ownedTech } from '../game/tech-tokens';
|
|
import { assassinationPhysical } from './moritani-assassinate-fixture';
|
|
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
|
|
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
|
|
import {
|
|
advanceMoritaniSkillsModules as advance, completeMoritaniSkillsModulesSetup,
|
|
createMoritaniSkillsModulesFixture as fixture, initializeMoritaniSkillsModulesOffers,
|
|
moritaniSkillsModulesClean as clean, moritaniSkillsModulesPlayer as player,
|
|
moritaniSkillsModulesPolicy as policy, nextMoritaniSkillsModulesStep,
|
|
rejectMoritaniSkillsModulesAction as reject, resolveMoritaniSkillsModulesBattle,
|
|
revealMoritaniSkillsModulesPlans, stageMoritaniSkillsModulesBattle,
|
|
type MoritaniSkillsModulesOptions,
|
|
} from './fixture-moritani-skills-modules';
|
|
|
|
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
|
function custody(game: Game) {
|
|
validateLeaderSkills(game.leaderSkills!, game.players);
|
|
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());
|
|
assert.equal(cards.length, 33);
|
|
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());
|
|
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((sum, n) => sum + n, 0), 20);
|
|
if (p.elites) {
|
|
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'emperor' ? 5 : 3);
|
|
for (const [location, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[location] ?? 0));
|
|
}
|
|
}
|
|
}
|
|
function assassinate(game: Game, card: string | null) {
|
|
const d = game.decision; assert.equal(d?.kind, 'moritaniAssassinate');
|
|
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected original loss opportunity');
|
|
return applyAction(game, d.player, { type: 'decision', event: d.event, ...(card === null ? { decline: true } : { card }) });
|
|
}
|
|
function finishBattle(game: Game) {
|
|
return advance(game, s => !s.battle && clean(s) && !s.pendingTreacheryDiscard);
|
|
}
|
|
|
|
void test('original standalone Moritani retains native offers and authenticated roster across Basic Tech and Advanced module seat boundaries', () => {
|
|
const cases: { options: MoritaniSkillsModulesOptions; counts: number[] }[] = [
|
|
{ options: { rules: 'basic', tech: true, strongholds: false }, counts: [3, 4, 5, 6] },
|
|
{ options: { rules: 'advanced', tech: true, strongholds: false }, counts: [3, 4, 5, 6] },
|
|
{ options: { rules: 'advanced', tech: false, strongholds: true }, counts: [2, 3, 4, 5, 6] },
|
|
{ options: { rules: 'advanced', tech: true, strongholds: true }, counts: [3, 4, 5, 6] },
|
|
];
|
|
const factions: FactionId[] = ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit'];
|
|
for (const { options, counts } of cases) for (const count of counts) {
|
|
const lobby = createGame(`AUTHMORISKILLS${count}`, newPlayer('authenticated-m', 'Original Moritani', 'moritani'), options.rules === 'advanced', ['ecaz']);
|
|
for (let i = 1; i < count; i++) joinGame(lobby, newPlayer(`authenticated-${i}`, `Original ${factions[i]}`, factions[i]));
|
|
const before = structuredClone(lobby), offered = initializeMoritaniSkillsModulesOffers({ ...options, initial: lobby });
|
|
assert.deepEqual(lobby, before);
|
|
assert.deepEqual(offered.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.equal(offered.setupStage, 'leaderSkills');
|
|
assert.deepEqual(offered.players.map(p => p.traitors), before.players.map(() => []));
|
|
const guild = offered.players.find(p => p.faction === 'guild')!.id;
|
|
assert.ok(viewGame(offered, guild).leaderSkills!.offer!.cards.includes('suk-graduate'));
|
|
for (const p of offered.players) assert.equal(viewGame(offered, p.id).leaderSkills!.offer!.cards.length, 2);
|
|
if (offered.strongholdCards) assert.ok(Object.values(offered.strongholdCards.owners).every(owner => owner === null));
|
|
assert.equal(!!offered.moritaniAssassinate, options.rules === 'advanced');
|
|
const saved = initializeMoritaniSkillsModulesOffers({ ...options, initial: reload(offered) });
|
|
assert.deepEqual(saved, offered, 'An original skill setup is continued, not redealt');
|
|
const setup = completeMoritaniSkillsModulesSetup(offered);
|
|
assert.equal(setup.players.find(p => p.faction === 'moritani')!.reserves, 14);
|
|
const moritani = setup.players.find(p => p.faction === 'moritani')!;
|
|
const placement = Object.entries(moritani.forces);
|
|
assert.equal(placement.length, 1);
|
|
assert.equal(placement[0][1], 6, 'Native setup places its six original counters together.');
|
|
const start = placement[0][0].split(':')[0];
|
|
assert.ok(setup.players.filter(p => p.id !== moritani.id).every(p =>
|
|
!Object.entries(p.forces).some(([key, count]) => count > 0 && key.startsWith(`${start}:`))),
|
|
'Original setup must leave other fighters and advisors outside the Moritani starting territory.');
|
|
const assigned = setup.leaderSkills!.assignments.find(a => a.owner === guild)!;
|
|
assert.equal(assigned.skill, 'suk-graduate');
|
|
assert.equal(player(setup, guild).leaders.find(l => l.id === assigned.leader)!.name, 'Master Bewt');
|
|
custody(setup);
|
|
}
|
|
});
|
|
|
|
void test('actual first Storm assigns Tech, a real native shipment pays phase-end income, and END Mentat alone claims the original Stronghold cards', () => {
|
|
const f = fixture();
|
|
assert.ok(Object.values(f.initial.techTokens!).every(token => token.owner === null));
|
|
assert.ok(Object.values(f.afterFirstStorm.techTokens!).every(token => token.owner !== null));
|
|
for (const p of f.afterFirstStorm.players) assert.equal(ownedTech(f.afterFirstStorm.techTokens, p.id).length, 1);
|
|
const shipping = advance(applyAction(reload(f.beforeShipment), f.shipmentStep.actor, f.shipmentStep.action),
|
|
s => s.phase === 5 && clean(s) && !s.pendingShipment);
|
|
assert.deepEqual(shipping.techTokens, f.afterShipment.techTokens);
|
|
const token = f.afterShipment.techTokens!.heighliners;
|
|
assert.equal(token.triggeredTurn, 2); assert.equal(token.spice, 1);
|
|
assert.equal(player(shipping, f.moritani).reserves, player(f.beforeShipment, f.moritani).reserves - 1);
|
|
assert.equal(player(shipping, f.moritani).forces[`${String(f.shipmentStep.action.territory)}:${String(f.shipmentStep.action.sector)}`], 1);
|
|
assert.equal(f.afterMovement.techTokens!.heighliners.spice, 0);
|
|
for (const p of shipping.players) assert.equal(player(f.afterMovement, p.id).spice, p.spice + (p.id === token.owner ? 1 : 0), 'Exactly the printed token holder collects at the real phase boundary');
|
|
assert.equal(f.initial.strongholdCards!.claimedTurn, 0);
|
|
assert.ok(Object.values(f.initial.strongholdCards!.owners).every(owner => owner === null));
|
|
const claimed = applyAction(reload(f.beforeFirstMentat), f.firstMentatStep.actor, f.firstMentatStep.action);
|
|
assert.equal(claimed.turn, 2);
|
|
assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(f.beforeFirstMentat.players, false));
|
|
assert.deepEqual(claimed.strongholdCards, f.afterFirstMentat.strongholdCards);
|
|
assert.equal(claimed.strongholdCards!.owners.tueks_sietch, f.opponent);
|
|
assert.deepEqual(Object.keys(claimed.strongholdCards!.owners), STRONGHOLD_CARDS.map(c => c.id));
|
|
for (const g of [f.afterSetup, f.afterFirstStorm, f.afterFirstMentat, f.afterShipment, f.pending]) custody(g);
|
|
});
|
|
|
|
for (const hasKarama of [false, true]) void test(`the normal Guild Suk rescue and held Master Bewt assassination preserve cleanup ${hasKarama ? 'after the original Moritani special-Karama decline' : 'without a Moritani Karama'}`, () => {
|
|
const f = fixture();
|
|
const prepared = structuredClone(f.game), moritani = player(prepared, f.moritani);
|
|
// Control only the unused physical Karama, before the real sealed plans.
|
|
// A random original hand must not silently select a different cleanup path.
|
|
for (const card of moritani.hand.filter(c => c.effect === 'karama')) {
|
|
moritani.hand.splice(moritani.hand.indexOf(card), 1);
|
|
prepared.deck.push(card);
|
|
}
|
|
if (hasKarama) {
|
|
const zone = [prepared.deck, ...prepared.players.filter(p => p.id !== f.moritani).map(p => p.hand)]
|
|
.find(cards => cards.some(card => card.effect === 'karama'));
|
|
assert.ok(zone);
|
|
const index = zone.findIndex(card => card.effect === 'karama');
|
|
assert.ok(moritani.hand.length < 4);
|
|
moritani.hand.push(zone.splice(index, 1)[0]);
|
|
}
|
|
f.revealed = revealMoritaniSkillsModulesPlans(prepared, f.planActions);
|
|
const pending = resolveMoritaniSkillsModulesBattle(structuredClone(f.revealed), f.moritani);
|
|
assert.equal(pending.lastBattleContext?.result, 'normal'); assert.equal(pending.lastBattleContext?.winner, f.opponent);
|
|
assert.equal(pending.decision?.kind, 'moritaniAssassinate');
|
|
assert.equal(pending.lastBattleContext?.sukRescue?.completed, true);
|
|
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.equal(target.dead, false);
|
|
assert.notEqual(target.id, f.opponentLeader);
|
|
assert.equal(player(pending, f.opponent).tanks, player(f.revealed, f.opponent).tanks + 2, 'Guild dial3 loses only two counters to Tanks after normal rescue1');
|
|
assert.equal(player(pending, f.opponent).reserves, player(f.revealed, f.opponent).reserves + 1);
|
|
assert.equal(Object.values(player(pending, f.opponent).forces).reduce((sum, n) => sum + n, 0), 3);
|
|
assert.equal(player(pending, f.moritani).tanks, player(f.revealed, f.moritani).tanks + 6);
|
|
assert.deepEqual(quoteStrongholdFactionsBattle(f.revealed).strongholdIncome, [{ player: f.opponent, amount: 2 }]);
|
|
const moriBalance = player(f.revealed, f.moritani).spice - 1;
|
|
const winnerBalance = player(f.revealed, f.opponent).spice - 3 + 2;
|
|
assert.equal(player(pending, f.moritani).spice, moriBalance);
|
|
assert.equal(player(pending, f.opponent).spice, winnerBalance);
|
|
const originalForces = pending.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites }));
|
|
const retained = structuredClone(pending.leaderSkills!.assignments.filter(a => a.leader !== f.target));
|
|
const reserve = [...pending.traitorReserve!], traitors = [...player(pending, f.moritani).traitors];
|
|
const inventory = assassinationPhysical(pending);
|
|
const d = pending.decision;
|
|
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected original loss choice');
|
|
const action: Action = { type: 'decision', event: d.event, card: f.target };
|
|
const privateMenu = viewGame(pending, f.moritani).moritaniAssassinate!.pending!.cards;
|
|
assert.equal(privateMenu.find(c => c.card === f.target)!.potentialBounty, 3);
|
|
assert.equal(privateMenu.some(c => c.card === f.opponentLeader), false);
|
|
reject(pending, f.opponent, action); reject(pending, f.moritani, { ...action, event: `${d.event}:stale` });
|
|
reject(pending, f.moritani, { ...action, card: f.opponentLeader });
|
|
let game = applyAction(reload(pending), f.moritani, action);
|
|
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.moritaniAssassinate!.opportunities.at(-1)!.bounty, 3);
|
|
assert.deepEqual(game.leaderSkills!.assignments, retained);
|
|
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
|
|
assert.equal(player(game, f.moritani).spice, moriBalance + 3); assert.equal(player(game, f.opponent).spice, winnerBalance);
|
|
assert.deepEqual(game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites })), originalForces);
|
|
assert.deepEqual(game.strongholdCards, pending.strongholdCards);
|
|
assert.deepEqual(game.techTokens, pending.techTokens, 'Assassination does not replay or undo original phase income');
|
|
if (hasKarama) {
|
|
assert.equal(game.decision?.kind, 'moritaniSpecialKarama');
|
|
const choice = game.decision!;
|
|
assert.equal(choice.player, f.moritani);
|
|
assert.ok(viewGame(game, f.moritani).moritaniSpecialKarama);
|
|
reject(game, f.opponent, { type: 'decision', event: choice.event, decline: true });
|
|
game = applyAction(reload(game), f.moritani, { type: 'decision', event: choice.event, decline: true });
|
|
assert.deepEqual(game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites })), originalForces);
|
|
assert.equal(player(game, f.moritani).spice, moriBalance + 3);
|
|
assert.equal(player(game, f.opponent).spice, winnerBalance);
|
|
assert.equal(player(game, f.moritani).hand.some(c => c.effect === 'karama'), true);
|
|
}
|
|
assert.equal(game.decision?.kind, 'battleCards'); assert.equal(game.decision?.player, f.opponent);
|
|
reject(game, f.moritani, action);
|
|
game = applyAction(reload(game), f.opponent, { type: 'decision', discard: f.opponentCards });
|
|
game = finishBattle(game);
|
|
for (const card of [...f.moritaniCards, ...f.opponentCards]) assert.equal(game.discard.filter(c => c.id === card).length, 1);
|
|
const lostTech = ownedTech(pending.techTokens, f.moritani)[0]; assert.ok(lostTech);
|
|
assert.equal(game.techTokens![lostTech].owner, f.opponent, 'Winner captures one real defeated-faction token only after original winner cleanup');
|
|
for (const token of ownedTech(pending.techTokens, f.opponent)) assert.equal(game.techTokens![token].owner, f.opponent, 'Original winning Tech custody is retained alongside the captured token');
|
|
const afterBattleForces = game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks }));
|
|
assert.deepEqual(afterBattleForces, pending.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks })));
|
|
game = advance(reload(game), s => s.phase === 8);
|
|
const receipt = game.moritaniAssassinate!.opportunities.at(-1)!;
|
|
assert.equal(receipt.stage, 'replaced'); assert.equal(receipt.replacement, reserve[0]);
|
|
assert.deepEqual(game.traitorReserve, reserve.slice(1));
|
|
assert.deepEqual(player(game, f.moritani).traitors, [...traitors.filter(c => c !== f.target), reserve[0]]);
|
|
const collected = quoteSpiceCollection(pending).receipts.find(r => r.player === f.opponent)!;
|
|
assert.equal(player(game, f.moritani).spice, moriBalance + 3);
|
|
assert.equal(player(game, f.opponent).spice, winnerBalance + collected.strongholds + collected.collected,
|
|
'Ordinary Collection remains separate from original battle income and assassination bounty.');
|
|
assert.equal('replacement' in viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
|
|
assert.equal('traitors' in viewGame(game, f.opponent).players.find(p => p.id === f.moritani)!, false);
|
|
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
|
|
assert.deepEqual(assassinationPhysical(game), inventory);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
|
reject(game, f.moritani, action); custody(game);
|
|
game = advance(game, s => s.turn === 3 && s.phase === 4 && clean(s));
|
|
assert.equal(game.traitorReserve!.length, reserve.length - 1, 'The next real END Mentat does not replace a second time');
|
|
reject(game, f.opponent, { type: 'reviveLeader', leader: f.target });
|
|
assert.equal(player(game, f.opponent).leaders.filter(l => l.dead).length, 1);
|
|
assert.equal(game.leaderSkills!.offers[f.opponent], undefined, 'One original death must not bypass the native all-five-disc revival prerequisite or grant training');
|
|
custody(game);
|
|
});
|
|
|
|
void test('losing Moritani retains printed Tuek Worthless income and one real support debit even after Guild rescue, assassination, card cleanup and Tech transfer', () => {
|
|
const f = fixture({ holder: 'moritani' });
|
|
assert.deepEqual(quoteStrongholdFactionsBattle(f.revealed).strongholdIncome, [{ player: f.moritani, amount: 4 }]);
|
|
const own = player(f.revealed, f.moritani).spice - 1 + 4;
|
|
const enemy = player(f.revealed, f.opponent).spice - 3;
|
|
assert.equal(player(f.pending, f.moritani).spice, own); assert.equal(player(f.pending, f.opponent).spice, enemy);
|
|
assert.equal(f.pending.lastBattleContext?.sukRescue?.completed, true);
|
|
let game = assassinate(reload(f.pending), f.target);
|
|
game = finishBattle(game);
|
|
game = advance(game, s => s.phase === 8);
|
|
const collected = quoteSpiceCollection(f.pending).receipts.find(r => r.player === f.opponent)!;
|
|
assert.equal(player(game, f.moritani).spice, own + 3);
|
|
assert.equal(player(game, f.opponent).spice, enemy + collected.strongholds + collected.collected);
|
|
assert.equal(player(game, f.moritani).tanks, 6); assert.equal(player(game, f.opponent).tanks, 2);
|
|
assert.equal(player(game, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
|
|
for (const card of f.moritaniCards) assert.equal(game.discard.filter(c => c.id === card).length, 1);
|
|
assert.equal(game.strongholdCards!.owners.tueks_sietch, f.moritani);
|
|
assert.equal(strongholdControllers(game.players, false).tueks_sietch, f.opponent);
|
|
custody(game);
|
|
});
|
|
|
|
void test('selected winning disc is not an assassination target, and declining a real empty native menu neither kills Suk nor schedules replacement', () => {
|
|
const f = fixture();
|
|
let game = reload(f.game);
|
|
holdMoritaniStrongholdsTraitor(game, f.moritani, f.opponentLeader);
|
|
game = revealMoritaniSkillsModulesPlans(game, f.planActions);
|
|
game = resolveMoritaniSkillsModulesBattle(game, f.moritani);
|
|
assert.equal(game.lastBattleContext?.winner, f.opponent);
|
|
assert.deepEqual(viewGame(game, f.moritani).moritaniAssassinate!.pending!.cards, []);
|
|
const d = game.decision;
|
|
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected original empty loss choice');
|
|
reject(game, f.moritani, { type: 'decision', event: d.event, card: f.opponentLeader });
|
|
const skills = structuredClone(game.leaderSkills), reserve = [...game.traitorReserve!];
|
|
const balance = player(game, f.moritani).spice;
|
|
game = finishBattle(assassinate(game, null));
|
|
game = advance(game, s => s.phase === 8);
|
|
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
|
|
assert.deepEqual(game.leaderSkills, skills); assert.deepEqual(game.traitorReserve, reserve);
|
|
assert.equal(player(game, f.moritani).spice, balance);
|
|
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, false);
|
|
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, false);
|
|
custody(game);
|
|
});
|
|
|
|
void test('a real normal Traitor reveal keeps the original entire-game forfeiture across a native turn, not merely the first modules battle', () => {
|
|
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(f.pending.moritaniAssassinateCallEvents!.length, 1);
|
|
assert.deepEqual(f.pending.moritaniAssassinate!.opportunities, []);
|
|
assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, true);
|
|
let game = advance(f.pending, s => s.turn === 3 && s.phase === 5 && clean(s));
|
|
stageMoritaniSkillsModulesBattle(game, f.moritani, f.opponent);
|
|
holdMoritaniStrongholdsTraitor(game, f.moritani, f.target);
|
|
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
|
const winner = player(game, f.opponent).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
|
const loser = player(game, f.moritani).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
|
|
assert.ok(winner && loser);
|
|
game = advance(game, s => s.phase === 6 && clean(s));
|
|
const actor = game.active!;
|
|
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === f.moritani ? f.opponent : f.moritani });
|
|
game = advance(game, s => !!s.battle && !s.battle.revealed && !nextMoritaniSkillsModulesStep(s));
|
|
game = revealMoritaniSkillsModulesPlans(game, [
|
|
{ actor: f.moritani, action: { type: 'battlePlan', leader: loser.id, dial: 0, support: 0 } },
|
|
{ actor: f.opponent, action: { type: 'battlePlan', leader: winner.id, dial: 3, support: 3 } },
|
|
]);
|
|
game = finishBattle(resolveMoritaniSkillsModulesBattle(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('all four minimal legal policies reach real reveal or empty-menu decline outcomes without changing native skill, income or casualty custody twice', () => {
|
|
const f = fixture();
|
|
let empty = reload(f.game);
|
|
holdMoritaniStrongholdsTraitor(empty, f.moritani, f.opponentLeader);
|
|
empty = revealMoritaniSkillsModulesPlans(empty, f.planActions);
|
|
empty = resolveMoritaniSkillsModulesBattle(empty, f.moritani);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const action = policy(reload(f.pending), f.moritani, difficulty)[0]; assert.ok(action, difficulty);
|
|
let game = applyAction(reload(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(player(game, f.opponent).spice, player(f.pending, f.opponent).spice);
|
|
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
|
|
game = finishBattle(game);
|
|
assert.equal(player(game, f.opponent).tanks, 2); assert.equal(player(game, f.moritani).tanks, 6);
|
|
custody(game);
|
|
const decline = policy(reload(empty), f.moritani, difficulty)[0]; assert.ok(decline, difficulty);
|
|
const declined = applyAction(reload(empty), f.moritani, decline);
|
|
assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
|
|
assert.equal(player(declined, f.opponent).leaders.find(l => l.id === f.target)!.dead, false);
|
|
assert.deepEqual(declined.leaderSkills, empty.leaderSkills);
|
|
assert.equal(player(declined, f.moritani).spice, player(empty, f.moritani).spice);
|
|
custody(finishBattle(declined));
|
|
}
|
|
});
|
|
|
|
void test('Basic Tech keeps original normal Suk and token capture without Advanced assassination, while each single Advanced module preserves the real loss aftermath', () => {
|
|
const basic = fixture({ rules: 'basic', strongholds: false });
|
|
assert.equal(basic.pending.lastBattleContext?.result, 'normal');
|
|
assert.equal(basic.pending.lastBattleContext?.winner, basic.opponent);
|
|
assert.equal(basic.pending.moritaniAssassinate, undefined);
|
|
assert.ok(!basic.pending.strongholdCards);
|
|
assert.equal(basic.pending.lastBattleContext?.sukRescue?.completed, true);
|
|
assert.equal(player(basic.pending, basic.opponent).tanks, 2);
|
|
assert.equal(player(basic.pending, basic.moritani).tanks, 6);
|
|
assert.equal(player(basic.pending, basic.opponent).spice, player(basic.revealed, basic.opponent).spice, 'Basic dials do not acquire an Advanced support debit');
|
|
assert.equal(player(basic.pending, basic.moritani).spice, player(basic.revealed, basic.moritani).spice);
|
|
const lost = ownedTech(basic.revealed.techTokens, basic.moritani)[0]; assert.ok(lost);
|
|
assert.equal(basic.pending.techTokens![lost].owner, basic.opponent);
|
|
assert.equal(basic.pending.leaderSkills!.assignments.some(a => a.leader === basic.target && a.skill === 'suk-graduate'), true);
|
|
custody(basic.pending);
|
|
for (const tech of [false, true]) {
|
|
const f = fixture({ rules: 'advanced', tech, strongholds: !tech });
|
|
assert.equal(f.pending.lastBattleContext?.winner, f.opponent);
|
|
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
|
|
assert.equal(f.pending.lastBattleContext?.sukRescue?.completed, true);
|
|
assert.equal(player(f.pending, f.opponent).tanks, 2);
|
|
const balance = player(f.pending, f.moritani).spice;
|
|
let game = finishBattle(assassinate(reload(f.pending), f.target));
|
|
game = advance(game, s => s.phase === 8);
|
|
assert.equal(player(game, f.moritani).spice, balance + 3);
|
|
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
|
|
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
|
|
if (tech) {
|
|
const captured = ownedTech(f.pending.techTokens, f.moritani)[0]; assert.ok(captured);
|
|
assert.equal(game.techTokens![captured].owner, f.opponent);
|
|
assert.ok(!game.strongholdCards);
|
|
} else {
|
|
assert.ok(!game.techTokens);
|
|
assert.equal(game.strongholdCards!.owners.tueks_sietch, f.opponent);
|
|
assert.equal(player(f.pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3 + 2);
|
|
}
|
|
custody(game);
|
|
}
|
|
});
|