Compose native Advanced Moritani skills with skill-first assassination
This commit is contained in:
1 parent
cb1f81977e
commit
d41a7e03a9
18 files changed
+1122
-88
No files matched your search
@@ -0,0 +1,455 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
advancedNativePlayer as player,
|
||||
advancedNativeStep,
|
||||
advanceAdvancedNativeSkillsToPhase,
|
||||
assertAdvancedNativeCustody as custody,
|
||||
completedAdvancedNativeSkillsGame,
|
||||
openAdvancedNativeSkillBattle,
|
||||
rejectAdvancedNativeAction as reject,
|
||||
stageAdvancedNativeSkillBattle,
|
||||
} from './fixture-advanced-native-skills';
|
||||
import { assassinationPhysical } from './moritani-assassinate-fixture';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
||||
|
||||
function assignment(game: Game, owner: string) {
|
||||
const trained = game.leaderSkills!.assignments.find(a => a.owner === owner);
|
||||
assert.ok(trained);
|
||||
return trained;
|
||||
}
|
||||
|
||||
function untrained(game: Game, owner: string, strongest = false) {
|
||||
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const candidates = game.players.find(p => p.id === owner)!.leaders
|
||||
.filter(l => !l.dead && !trained.has(l.id));
|
||||
candidates.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
|
||||
assert.ok(candidates[0]);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/** Only after genuine native setup: swap two existing physical identities,
|
||||
* following the original assassination fixture. Never manufacture a traitor. */
|
||||
function holdPhysicalTraitor(game: Game, identity: string): void {
|
||||
const owner = player(game, 'moritani');
|
||||
const old = owner.traitors[0];
|
||||
assert.ok(old);
|
||||
if (old === identity) return;
|
||||
const holder = game.players.find(p => p.id !== owner.id && p.traitors.includes(identity));
|
||||
if (holder) holder.traitors[holder.traitors.indexOf(identity)] = old;
|
||||
else {
|
||||
const index = game.traitorReserve!.indexOf(identity);
|
||||
assert.ok(index >= 0, 'The requested physical native identity must still exist.');
|
||||
game.traitorReserve![index] = old;
|
||||
}
|
||||
owner.traitors[0] = identity;
|
||||
}
|
||||
|
||||
/** All14 and the original assassination state come from the fresh production
|
||||
* initializer. Only board/card positions are controlled after native setup. */
|
||||
function stagedGame(bankerIncome = false) {
|
||||
const native = completedAdvancedNativeSkillsGame({
|
||||
family: 'moritani', skillOwner: 'guild', requestedSkill: 'warmaster',
|
||||
opponents: ['guild', 'emperor'], bankerIncome,
|
||||
});
|
||||
assert.equal(assignment(native, player(native, 'guild').id).skill, 'warmaster');
|
||||
assert.ok(native.moritaniAssassinate);
|
||||
const game = stageAdvancedNativeSkillBattle(native,
|
||||
player(native, 'guild').id, player(native, 'moritani').id);
|
||||
game.spice['wind_pass:14'] = 0; // Isolate battle/assassination income from Collection.
|
||||
return game;
|
||||
}
|
||||
|
||||
function stageCard(game: Game, owner: string, kind: 'worthless' | 'projectile'): string {
|
||||
const index = game.deck.findIndex(c => c.kind === kind);
|
||||
assert.ok(index >= 0, 'The conserved native deck must contain the staged card.');
|
||||
const [card] = game.deck.splice(index, 1);
|
||||
game.players.find(p => p.id === owner)!.hand.push(card);
|
||||
return card.id;
|
||||
}
|
||||
|
||||
function resolvePlans(game: Game, normalCall = false): Game {
|
||||
game = applyAction(game, player(game, 'guild').id, { type: 'traitorCall', call: false });
|
||||
return applyAction(game, player(game, 'moritani').id, { type: 'traitorCall', call: normalCall });
|
||||
}
|
||||
|
||||
function pendingAssassination(): { game: Game; target: string; card: string } {
|
||||
let game = stagedGame();
|
||||
const guild = player(game, 'guild').id;
|
||||
const moritani = player(game, 'moritani').id;
|
||||
const target = assignment(game, guild).leader;
|
||||
holdPhysicalTraitor(game, target);
|
||||
const card = stageCard(game, guild, 'worthless');
|
||||
const winningLeader = untrained(game, guild).id;
|
||||
const losingLeader = untrained(game, moritani).id;
|
||||
// The face-up trainer grants the different winning disc its normal +1.
|
||||
// The trainer itself remains an eligible original assassination target.
|
||||
game = openAdvancedNativeSkillBattle(game, guild, moritani, false);
|
||||
game = applyAction(game, guild, { type: 'battlePlan', leader: winningLeader, dial: 0, support: 0, weapon: card });
|
||||
game = applyAction(game, moritani, { type: 'battlePlan', leader: losingLeader, dial: 0, support: 0 });
|
||||
game = resolvePlans(game);
|
||||
assert.equal(game.lastBattleContext?.result, 'normal');
|
||||
assert.equal(game.lastBattleContext?.winner, guild);
|
||||
assert.equal(game.decision?.kind, 'moritaniAssassinate');
|
||||
return { game, target, card };
|
||||
}
|
||||
|
||||
function choose(game: Game, card: string | null): Game {
|
||||
const decision = game.decision;
|
||||
assert.equal(decision?.kind, 'moritaniAssassinate');
|
||||
if (decision?.kind !== 'moritaniAssassinate') throw new Error('Expected original assassination choice.');
|
||||
return applyAction(game, decision.player, {
|
||||
type: 'decision', event: decision.event,
|
||||
...(card === null ? { decline: true } : { card }),
|
||||
});
|
||||
}
|
||||
|
||||
function finishCards(game: Game, discard: string[] = []): Game {
|
||||
for (let i = 0; (game.decision || game.response || game.pendingTreacheryDiscard || game.battle) && i < 100; i++) {
|
||||
if (game.decision?.kind === 'battleCards') {
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', discard });
|
||||
} else game = advancedNativeStep(game);
|
||||
}
|
||||
assert.equal(game.battle, null);
|
||||
assert.equal(game.decision, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('Advanced all14 assassination kills only the held native trainer, returns its skill once and resumes real winner-card cleanup before one private Mentat replacement', () => {
|
||||
const { game: pending, target, card } = pendingAssassination();
|
||||
const moritani = player(pending, 'moritani');
|
||||
const guild = player(pending, 'guild');
|
||||
const killed = guild.leaders.find(l => l.id === target)!;
|
||||
const ownTrainer = structuredClone(assignment(pending, moritani.id));
|
||||
const survivingAssignments = pending.leaderSkills!.assignments.filter(a => a.leader !== target);
|
||||
const inventory = assassinationPhysical(pending);
|
||||
const traitors = [...moritani.traitors];
|
||||
const reserve = [...pending.traitorReserve!];
|
||||
const event = pending.moritaniAssassinate!.opportunities.at(-1)!.event;
|
||||
const action: Action = { type: 'decision', event, card: target };
|
||||
assert.equal(pending.phase, 6);
|
||||
assert.ok(pending.moritaniAssassinateResume!.continuation.cards.includes(card));
|
||||
assert.equal(guild.leaders.find(l => l.id === pending.moritaniAssassinate!.opportunities.at(-1)!.opposingLeader)!.dead, false);
|
||||
assert.equal(viewGame(pending, moritani.id).moritaniAssassinate!.pending!.cards.find(c => c.card === target)!.bounty, killed.strength);
|
||||
assert.deepEqual(viewGame(pending, guild.id).moritaniAssassinate!.pending!.cards, []);
|
||||
reject(pending, guild.id, action);
|
||||
reject(pending, moritani.id, { ...action, event: 'stale' });
|
||||
|
||||
let game = applyAction(reload(pending), moritani.id, action);
|
||||
const receipt = game.moritaniAssassinate!.opportunities.at(-1)!;
|
||||
const dead = player(game, 'guild').leaders.find(l => l.id === target)!;
|
||||
assert.equal(dead.dead, true);
|
||||
assert.equal(dead.deaths, killed.deaths + 1);
|
||||
assert.equal(dead.strength, killed.strength);
|
||||
assert.equal(receipt.bounty, killed.strength);
|
||||
assert.equal(receipt.stage, 'revealed');
|
||||
assert.equal(receipt.replacement, null);
|
||||
assert.equal(player(game, 'moritani').spice, moritani.spice + killed.strength);
|
||||
assert.equal(player(game, 'guild').spice, guild.spice);
|
||||
assert.deepEqual(game.leaderSkills!.assignments, survivingAssignments);
|
||||
assert.deepEqual(assignment(game, moritani.id), ownTrainer);
|
||||
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
|
||||
assert.deepEqual(player(game, 'guild').leaders.filter(l => l.id !== target), guild.leaders.filter(l => l.id !== target));
|
||||
assert.deepEqual(player(game, 'moritani').traitors, traitors);
|
||||
assert.deepEqual(game.traitorReserve, reserve);
|
||||
assert.equal(game.moritaniAssassinateResume, undefined);
|
||||
assert.equal(game.decision?.kind, 'battleCards');
|
||||
assert.equal(game.decision?.player, guild.id);
|
||||
assert.equal(player(game, 'guild').hand.some(c => c.id === card), true);
|
||||
custody(game);
|
||||
reject(game, moritani.id, action);
|
||||
|
||||
// Exercise the saved ORIGINAL winner cleanup rather than erasing the decision.
|
||||
game = finishCards(reload(game), [card]);
|
||||
assert.equal(player(game, 'guild').hand.some(c => c.id === card), false);
|
||||
assert.equal(game.discard.filter(c => c.id === card).length, 1);
|
||||
assert.deepEqual(assassinationPhysical(game), inventory);
|
||||
const replacement = game.traitorReserve![0];
|
||||
assert.ok(replacement && replacement !== target);
|
||||
game = advanceAdvancedNativeSkillsToPhase(reload(game), 8);
|
||||
const settled = game.moritaniAssassinate!.opportunities.at(-1)!;
|
||||
assert.equal(settled.stage, 'replaced');
|
||||
assert.equal(settled.replacement, replacement);
|
||||
assert.deepEqual(game.traitorReserve, reserve.slice(1));
|
||||
assert.deepEqual(player(game, 'moritani').traitors, [...traitors.filter(c => c !== target), replacement]);
|
||||
assert.equal(game.players.flatMap(p => p.traitors).includes(target), false);
|
||||
assert.equal(game.traitorReserve!.includes(target), false);
|
||||
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
|
||||
assert.deepEqual(assignment(game, moritani.id), ownTrainer);
|
||||
assert.equal(player(game, 'moritani').spice, moritani.spice + killed.strength);
|
||||
assert.deepEqual(assassinationPhysical(game), inventory);
|
||||
const rival = viewGame(reload(game), guild.id);
|
||||
assert.equal(rival.moritaniAssassinate!.history.at(-1)!.card, target);
|
||||
assert.equal(rival.moritaniAssassinate!.history.at(-1)!.stage, 'replaced');
|
||||
assert.equal('replacement' in rival.moritaniAssassinate!.history.at(-1)!, false);
|
||||
assert.equal('traitors' in rival.players.find(p => p.id === moritani.id)!, false);
|
||||
custody(game);
|
||||
reject(game, moritani.id, action);
|
||||
|
||||
const turn = game.turn;
|
||||
for (let i = 0; game.turn === turn && i < 1000; i++) game = advancedNativeStep(game);
|
||||
assert.equal(game.turn, turn + 1);
|
||||
while (game.phase === 0 && game.status === 'playing') game = advancedNativeStep(game);
|
||||
// The original per-faction assassination does not expire on a new turn.
|
||||
game = stageAdvancedNativeSkillBattle(reload(game), guild.id, moritani.id);
|
||||
game.spice['wind_pass:14'] = 0;
|
||||
const winner = untrained(game, guild.id, true).id;
|
||||
const loser = untrained(game, moritani.id).id;
|
||||
game = openAdvancedNativeSkillBattle(game, guild.id, moritani.id);
|
||||
game = applyAction(game, guild.id, { type: 'battlePlan', leader: winner, dial: 0, support: 0 });
|
||||
game = applyAction(game, moritani.id, { type: 'battlePlan', leader: loser, dial: 0, support: 0 });
|
||||
game = resolvePlans(game);
|
||||
assert.equal(game.lastBattleContext?.result, 'normal');
|
||||
assert.equal(game.lastBattleContext?.winner, guild.id);
|
||||
assert.equal(game.moritaniAssassinate!.opportunities.length, 1);
|
||||
assert.equal(game.moritaniAssassinate!.opportunities[0].stage, 'replaced');
|
||||
assert.notEqual(game.decision?.kind, 'moritaniAssassinate');
|
||||
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
|
||||
assert.deepEqual(assignment(game, moritani.id), ownTrainer);
|
||||
custody(finishCards(game));
|
||||
});
|
||||
|
||||
void test('declining the original loss opportunity preserves the real trainer, skill, wallet and traitor through Mentat', () => {
|
||||
const { game: pending, target } = pendingAssassination();
|
||||
const skills = structuredClone(pending.leaderSkills);
|
||||
const moritani = player(pending, 'moritani');
|
||||
const trained = structuredClone(player(pending, 'guild').leaders.find(l => l.id === target)!);
|
||||
const held = [...moritani.traitors];
|
||||
const reserve = [...pending.traitorReserve!];
|
||||
let game = finishCards(choose(reload(pending), null));
|
||||
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
|
||||
game = advanceAdvancedNativeSkillsToPhase(game, 8);
|
||||
assert.deepEqual(game.leaderSkills, skills);
|
||||
assert.deepEqual(player(game, 'guild').leaders.find(l => l.id === target), trained);
|
||||
assert.equal(player(game, 'moritani').spice, moritani.spice);
|
||||
assert.deepEqual(player(game, 'moritani').traitors, held);
|
||||
assert.deepEqual(game.traitorReserve, reserve);
|
||||
assert.deepEqual(viewGame(game, moritani.id).moritaniAssassinate!.history, []);
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('the surviving lower Warmaster wins a real loss, but the original source excludes that winning trained disc from assassination', () => {
|
||||
let game = stagedGame();
|
||||
const guild = player(game, 'guild').id;
|
||||
const moritani = player(game, 'moritani').id;
|
||||
const trainer = assignment(game, guild).leader;
|
||||
const trained = structuredClone(player(game, 'guild').leaders.find(l => l.id === trainer)!);
|
||||
holdPhysicalTraitor(game, trainer);
|
||||
const worthless = stageCard(game, guild, 'worthless');
|
||||
const loser = untrained(game, moritani, true);
|
||||
// Guild's printed 5 loses to 4+3; only the surviving lower +3 makes it win.
|
||||
assert.equal(trained.strength, 5);
|
||||
assert.equal(loser.strength, 4);
|
||||
const funds = player(game, 'moritani').spice;
|
||||
game = openAdvancedNativeSkillBattle(game, guild, moritani);
|
||||
game = applyAction(game, guild, { type: 'battlePlan', leader: trainer, dial: 0, support: 0, weapon: worthless });
|
||||
game = applyAction(game, moritani, { type: 'battlePlan', leader: loser.id, dial: 3, support: 3 });
|
||||
game = resolvePlans(game);
|
||||
assert.equal(game.lastBattleContext?.winner, guild);
|
||||
assert.equal(game.decision?.kind, 'moritaniAssassinate');
|
||||
const own = viewGame(reload(game), moritani).moritaniAssassinate!.pending!;
|
||||
assert.deepEqual(own.cards, []);
|
||||
reject(game, moritani, { type: 'decision', event: own.event, card: trainer });
|
||||
game = finishCards(choose(game, null));
|
||||
assert.equal(player(game, 'guild').leaders.find(l => l.id === trainer)!.dead, false);
|
||||
assert.equal(assignment(game, guild).skill, 'warmaster');
|
||||
assert.equal(game.leaderSkills!.deck.includes('warmaster'), false);
|
||||
assert.equal(player(game, 'moritani').spice, funds - 3);
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('weapon death removes the lower Warmaster bonus before scoring and prevents a spurious Moritani loss', () => {
|
||||
let game = stagedGame();
|
||||
const guild = player(game, 'guild').id;
|
||||
const moritani = player(game, 'moritani').id;
|
||||
const trainer = assignment(game, guild).leader;
|
||||
const printed = player(game, 'guild').leaders.find(l => l.id === trainer)!.strength;
|
||||
const ownTrainer = structuredClone(assignment(game, moritani));
|
||||
holdPhysicalTraitor(game, untrained(game, guild).id);
|
||||
const worthless = stageCard(game, guild, 'worthless');
|
||||
const weapon = stageCard(game, moritani, 'projectile');
|
||||
const attacker = untrained(game, moritani).id;
|
||||
const funds = player(game, 'moritani').spice;
|
||||
game = openAdvancedNativeSkillBattle(game, guild, moritani);
|
||||
game = applyAction(game, guild, { type: 'battlePlan', leader: trainer, dial: 0, support: 0, weapon: worthless });
|
||||
game = applyAction(game, moritani, { type: 'battlePlan', leader: attacker, dial: 0, support: 0, weapon });
|
||||
game = resolvePlans(game);
|
||||
// A dead skilled disc contributes neither printed 5 nor lower +3. If lower
|
||||
// strength were applied before survival, Guild would incorrectly win this.
|
||||
assert.equal(game.lastBattleContext?.result, 'normal');
|
||||
assert.equal(game.lastBattleContext?.winner, moritani);
|
||||
assert.equal(player(game, 'guild').leaders.find(l => l.id === trainer)!.dead, true);
|
||||
assert.equal(player(game, 'guild').leaders.find(l => l.id === trainer)!.deaths, 1);
|
||||
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === trainer), false);
|
||||
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
|
||||
assert.deepEqual(assignment(game, moritani), ownTrainer);
|
||||
assert.equal(player(game, 'moritani').spice, funds + printed);
|
||||
assert.deepEqual(game.moritaniAssassinate!.opportunities, []);
|
||||
assert.notEqual(game.decision?.kind, 'moritaniAssassinate');
|
||||
game = finishCards(reload(game), [weapon]);
|
||||
assert.equal(game.discard.filter(c => c.id === worthless).length, 1);
|
||||
assert.equal(game.discard.filter(c => c.id === weapon).length, 1);
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('an actual normal Moritani loss cannot assassinate when the winning trained disc was killed by the real weapon', () => {
|
||||
let game = stagedGame();
|
||||
const guild = player(game, 'guild').id;
|
||||
const moritani = player(game, 'moritani').id;
|
||||
const trainer = assignment(game, guild).leader;
|
||||
holdPhysicalTraitor(game, untrained(game, guild).id);
|
||||
const held = [...player(game, 'moritani').traitors];
|
||||
const funds = player(game, 'moritani').spice;
|
||||
const ownTrainer = structuredClone(assignment(game, moritani));
|
||||
const worthless = stageCard(game, guild, 'worthless');
|
||||
const weapon = stageCard(game, moritani, 'projectile');
|
||||
const attackingLeader = untrained(game, moritani).id;
|
||||
game = openAdvancedNativeSkillBattle(game, guild, moritani);
|
||||
game = applyAction(game, guild, { type: 'battlePlan', leader: trainer, dial: 4, support: 4, weapon: worthless });
|
||||
game = applyAction(game, moritani, { type: 'battlePlan', leader: attackingLeader, dial: 0, support: 0, weapon });
|
||||
game = resolvePlans(game);
|
||||
assert.equal(game.lastBattleContext?.result, 'normal');
|
||||
assert.equal(game.lastBattleContext?.winner, guild);
|
||||
assert.equal(player(game, 'guild').leaders.find(l => l.id === trainer)!.dead, true);
|
||||
assert.equal(player(game, 'guild').leaders.find(l => l.id === trainer)!.deaths, 1);
|
||||
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === trainer), false);
|
||||
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
|
||||
assert.deepEqual(assignment(game, moritani), ownTrainer);
|
||||
assert.deepEqual(game.moritaniAssassinate!.opportunities, []);
|
||||
assert.notEqual(game.decision?.kind, 'moritaniAssassinate');
|
||||
assert.deepEqual(player(game, 'moritani').traitors, held);
|
||||
assert.equal(player(game, 'moritani').spice, funds);
|
||||
game = finishCards(game, [worthless]);
|
||||
assert.equal(player(game, 'guild').tanks, 4);
|
||||
assert.equal(player(game, 'guild').forces['wind_pass:14'], 4);
|
||||
assert.equal(player(game, 'moritani').tanks, 8);
|
||||
assert.equal(game.discard.filter(c => c.id === weapon).length, 1);
|
||||
assert.equal(game.discard.filter(c => c.id === worthless).length, 1);
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('the fresh private Banker wrapper retains normal-traitor ENTIRE-GAME forfeiture through a later native turn loss', () => {
|
||||
let game = stagedGame(true);
|
||||
const guild = player(game, 'guild').id;
|
||||
const moritani = player(game, 'moritani').id;
|
||||
const trainer = assignment(game, guild).leader;
|
||||
const ownTrainer = structuredClone(assignment(game, moritani));
|
||||
holdPhysicalTraitor(game, trainer);
|
||||
const funds = player(game, 'moritani').spice;
|
||||
const printed = player(game, 'guild').leaders.find(l => l.id === trainer)!.strength;
|
||||
const held = [...player(game, 'moritani').traitors];
|
||||
const reserve = [...game.traitorReserve!];
|
||||
game = openAdvancedNativeSkillBattle(game, guild, moritani);
|
||||
game = applyAction(game, guild, { type: 'battlePlan', leader: trainer, dial: 0, support: 0 });
|
||||
game = applyAction(game, moritani, { type: 'battlePlan', leader: untrained(game, moritani).id, dial: 0, support: 0 });
|
||||
game = finishCards(resolvePlans(game, true));
|
||||
assert.equal(game.lastBattleContext?.result, 'traitor');
|
||||
assert.equal(game.moritaniAssassinate!.normalTraitorCall, true);
|
||||
assert.equal(game.moritaniAssassinateCallEvents!.length, 1);
|
||||
assert.equal(player(game, 'guild').leaders.find(l => l.id === trainer)!.dead, true);
|
||||
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
|
||||
assert.deepEqual(assignment(game, moritani), ownTrainer);
|
||||
assert.equal(player(game, 'moritani').spice, funds + printed);
|
||||
assert.deepEqual(player(game, 'moritani').traitors, held);
|
||||
assert.deepEqual(game.traitorReserve, reserve);
|
||||
const turn = game.turn;
|
||||
for (let i = 0; game.turn === turn && i < 1000; i++) game = advancedNativeStep(game);
|
||||
assert.equal(game.turn, turn + 1);
|
||||
while (game.phase === 0 && game.status === 'playing') game = advancedNativeStep(game);
|
||||
assert.deepEqual(player(game, 'moritani').traitors, held);
|
||||
assert.deepEqual(game.traitorReserve, reserve);
|
||||
const emperor = player(game, 'emperor').id;
|
||||
// Explicit second conserved battle position, after an actual native turn
|
||||
// boundary. Never simulate expiry by assigning turn or resetting the guard.
|
||||
game = stageAdvancedNativeSkillBattle(reload(game), emperor, moritani);
|
||||
game.spice['wind_pass:14'] = 0;
|
||||
const winner = untrained(game, emperor, true).id;
|
||||
const loser = untrained(game, moritani).id;
|
||||
game = openAdvancedNativeSkillBattle(game, emperor, moritani);
|
||||
game = applyAction(game, emperor, { type: 'battlePlan', leader: winner, dial: 0, support: 0 });
|
||||
game = applyAction(game, moritani, { type: 'battlePlan', leader: loser, dial: 0, support: 0 });
|
||||
game = applyAction(game, emperor, { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, moritani, { type: 'traitorCall', call: false });
|
||||
assert.equal(game.lastBattleContext?.result, 'normal');
|
||||
assert.equal(game.lastBattleContext?.winner, emperor);
|
||||
assert.equal(game.moritaniAssassinate!.normalTraitorCall, true);
|
||||
assert.equal(game.moritaniAssassinateCallEvents!.length, 1);
|
||||
assert.deepEqual(game.moritaniAssassinate!.opportunities, []);
|
||||
assert.notEqual(game.decision?.kind, 'moritaniAssassinate');
|
||||
custody(finishCards(game));
|
||||
});
|
||||
|
||||
void test('every original policy can finish the current JSON assassination choice with real death, skill return and retained physical card', () => {
|
||||
const { game: pending, target } = pendingAssassination();
|
||||
const owner = player(pending, 'moritani');
|
||||
const strength = player(pending, 'guild').leaders.find(l => l.id === target)!.strength;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const current = reload(pending);
|
||||
const view = viewGame(current, owner.id);
|
||||
view.players.find(p => p.id === owner.id)!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action, difficulty);
|
||||
const game = applyAction(current, owner.id, action);
|
||||
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed', difficulty);
|
||||
assert.equal(player(game, 'guild').leaders.find(l => l.id === target)!.dead, true);
|
||||
assert.equal(player(game, 'moritani').spice, owner.spice + strength);
|
||||
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === target), false);
|
||||
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
|
||||
assert.deepEqual(player(game, 'moritani').traitors, owner.traitors);
|
||||
assert.equal(game.decision?.kind, 'battleCards');
|
||||
custody(finishCards(game));
|
||||
}
|
||||
});
|
||||
|
||||
void test('the winner finishes original Suk rescue and Rihani choice before Moritani can assassinate, without replaying casualties', () => {
|
||||
for (const skill of ['suk-graduate', 'rihani-decipherer'] as const) {
|
||||
const native = completedAdvancedNativeSkillsGame({
|
||||
family: 'moritani', skillOwner: 'emperor', requestedSkill: skill,
|
||||
});
|
||||
const winner = player(native, 'emperor').id, loser = player(native, 'moritani').id;
|
||||
let game = stageAdvancedNativeSkillBattle(native, winner, loser);
|
||||
game.spice['wind_pass:14'] = 0;
|
||||
const leader = assignment(game, winner).leader;
|
||||
const enemy = untrained(game, loser).id;
|
||||
const support = skill === 'suk-graduate' ? 5 : 1;
|
||||
const balance = player(game, 'emperor').spice;
|
||||
game = openAdvancedNativeSkillBattle(game, winner, loser);
|
||||
game = applyAction(game, winner, { type: 'battlePlan', leader, dial: support, support });
|
||||
game = applyAction(game, loser, { type: 'battlePlan', leader: enemy, dial: 0, support: 0 });
|
||||
game = applyAction(game, winner, { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, loser, { type: 'traitorCall', call: false });
|
||||
assert.equal(game.lastBattleContext!.winner, winner);
|
||||
assert.equal(viewGame(game, loser).moritaniAssassinate!.pending, null);
|
||||
if (skill === 'suk-graduate') {
|
||||
assert.equal(game.decision?.kind, 'sukRescue');
|
||||
if (game.decision?.kind !== 'sukRescue') throw new Error('Original winner rescue must remain actionable.');
|
||||
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), winner, { type: 'decision', event: game.decision.event, choice });
|
||||
assert.equal(player(game, 'emperor').forces['wind_pass:14'], 4);
|
||||
assert.equal(player(game, 'emperor').tanks, 2);
|
||||
} else {
|
||||
assert.equal(game.decision?.kind, 'rihani');
|
||||
if (game.decision?.kind !== 'rihani') throw new Error('Original winner Rihani choice must remain actionable.');
|
||||
const held = [...player(game, 'emperor').traitors];
|
||||
game = applyAction(reload(game), winner, { type: 'decision', event: game.decision.event, draw: false });
|
||||
assert.deepEqual(player(game, 'emperor').traitors, held);
|
||||
assert.equal(player(game, 'emperor').forces['wind_pass:14'], 7);
|
||||
assert.equal(player(game, 'emperor').tanks, 1);
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'moritaniAssassinate');
|
||||
const forces = structuredClone(player(game, 'emperor').forces);
|
||||
const tanks = player(game, 'emperor').tanks;
|
||||
const reserves = player(game, 'emperor').reserves;
|
||||
game = finishCards(choose(reload(game), null));
|
||||
assert.deepEqual(player(game, 'emperor').forces, forces);
|
||||
assert.equal(player(game, 'emperor').tanks, tanks);
|
||||
assert.equal(player(game, 'emperor').reserves, reserves);
|
||||
assert.equal(player(game, 'emperor').spice, balance - support);
|
||||
custody(game);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
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);
|
||||
Object.assign(game, { 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; retain real post-shipment money and Tanks.
|
||||
Object.assign(game, { 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('new native admission preserves public, Harkonnen assassination, foreign family/module and Advanced Atreides Suk boundaries', () => {
|
||||
const make = (faction: 'atreides' | 'harkonnen' | 'ixians' | 'ecaz' = 'atreides', expansions: Game['expansions'] = ['ecaz']) => {
|
||||
const game = createGame('MORIGATE', newPlayer('original-m', 'Moritani', 'moritani'), true, expansions);
|
||||
joinGame(game, newPlayer('original-other', faction, faction));
|
||||
return game;
|
||||
};
|
||||
reject(make(), 'original-m', { type: 'start' });
|
||||
for (const game of [make('harkonnen'), make('ixians', ['ecaz', 'ix']), make('ecaz'), make('atreides', ['ecaz', 'choam'])]) {
|
||||
game.players.forEach(p => { p.ready = true; });
|
||||
const before = reload(game);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(game));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
const discoveryLobby = make();
|
||||
discoveryLobby.players.forEach(p => { p.ready = true; });
|
||||
discoveryLobby.discoveryEnabled = true;
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(discoveryLobby));
|
||||
// Basic Moritani keeps its original Harkonnen-compatible skills profile; it
|
||||
// does not acquire the Advanced-only assassination preview.
|
||||
const basicLobby = make('harkonnen');
|
||||
basicLobby.advanced = false;
|
||||
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!;
|
||||
reject(offered, actor, { type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader: own.eligibleLeaders[0].id });
|
||||
const complete = completeAdvancedNativeSkillsSetup(offered, options);
|
||||
assert.notEqual(trainer(complete, actor).skill, 'suk-graduate');
|
||||
assert.ok(complete.leaderSkills!.deck.includes('suk-graduate'));
|
||||
custody(complete);
|
||||
});
|
||||
@@ -242,10 +242,10 @@ void test('Advanced Moritani assassination samples retain their preview and phys
|
||||
assert.equal(existsSync(unsupported), false);
|
||||
});
|
||||
|
||||
void test('Moritani Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
|
||||
void test('Moritani Skills samples preserve full Basic and Advanced native decks through saved continuation', (t) => {
|
||||
const area = temporary(t);
|
||||
const out = join(area, 'skills');
|
||||
const result = run(out, '--profile', 'moritani-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
|
||||
const result = run(out, '--profile', 'moritani-skills', '--rules', 'basic', '--players', '2', '--seed', '1000', '--max-actions', '1');
|
||||
assert.equal(result.status, 1);
|
||||
assertFailedEvidence(out, 'moritani-skills-2-basic', 1016, false);
|
||||
const snapshot = join(out, 'failed-moritani-skills-2-basic.json');
|
||||
@@ -256,10 +256,14 @@ void test('Moritani Skills samples preserve their full module on saved continuat
|
||||
const resumed = join(area, 'skills-resumed');
|
||||
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
|
||||
assertFailedEvidence(resumed, 'moritani-skills-2-basic', 1016, true);
|
||||
const unsupported = join(area, 'unsupported');
|
||||
const bad = run(unsupported, '--profile', 'moritani-skills', '--rules', 'advanced');
|
||||
assert.equal(bad.status, 1);
|
||||
assert.equal(existsSync(unsupported), false);
|
||||
const advanced = join(area, 'advanced');
|
||||
assert.equal(run(advanced, '--profile', 'moritani-skills', '--rules', 'advanced', '--players', '2', '--seed', '1000', '--max-actions', '1').status, 1);
|
||||
assertFailedEvidence(advanced, 'moritani-skills-2-advanced', 1249, false);
|
||||
const state = json<typeof game>(join(advanced, 'failed-moritani-skills-2-advanced.json'));
|
||||
assert.equal(state.advanced, true);
|
||||
const cards = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
|
||||
assert.equal(new Set(cards).size, 14);
|
||||
assert.equal(cards.length, 14);
|
||||
});
|
||||
|
||||
void test('Tleilaxu Skills samples preserve full Basic and Advanced native decks through saved continuation', (t) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { newRevivalRules } from '../game/revival';
|
||||
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
|
||||
|
||||
export type AdvancedNativeSkillsOptions = {
|
||||
family: 'ixians' | 'choam' | 'tleilaxu';
|
||||
family: 'ixians' | 'choam' | 'tleilaxu' | 'moritani';
|
||||
requestedSkill?: LeaderSkillId;
|
||||
skillOwner?: FactionId;
|
||||
opponents?: readonly FactionId[];
|
||||
@@ -53,7 +53,7 @@ function withSkillShuffle<T>(skill: LeaderSkillId, ownerIndex: number, initializ
|
||||
* physical skill offers. No played save is converted and no deck is cherry-picked. */
|
||||
export function initializeAdvancedNativeSkillsSetup(options: AdvancedNativeSkillsOptions): Game {
|
||||
const ownerFaction = options.skillOwner ?? options.family;
|
||||
const expansion = options.family === 'choam' ? 'choam' : 'ix';
|
||||
const expansion = options.family === 'moritani' ? 'ecaz' : options.family === 'choam' ? 'choam' : 'ix';
|
||||
let game: Game;
|
||||
if (options.initial) game = structuredClone(options.initial);
|
||||
else {
|
||||
@@ -106,6 +106,12 @@ export function completedAdvancedNativeSkillsGame(options: AdvancedNativeSkillsO
|
||||
}
|
||||
|
||||
export function advancedNativeStep(game: Game): Game {
|
||||
// Original native Moritani choices stay real actions; decline optional Terror
|
||||
// placement/use rather than letting a policy invent fixture phase history.
|
||||
if (game.decision?.kind === 'moritaniPlacement')
|
||||
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
||||
if (game.decision?.kind === 'moritaniTerror' && ['select', 'offer'].includes(game.pendingTerrorEntry!.stage))
|
||||
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
||||
const next = nextSpiceBankerIncomeNativeStep(game);
|
||||
assert.ok(next);
|
||||
return applyAction(game, next.actor, next.action);
|
||||
@@ -225,6 +231,10 @@ export function finishAdvancedNativeSkillAftermath(state: Game): Game {
|
||||
let game = state;
|
||||
for (let i = 0; (game.response || game.decision || game.battle || game.pendingTreacheryDiscard) && i < 100; i++) {
|
||||
if (game.decision?.kind === 'sukRescue') break;
|
||||
if (game.decision?.kind === 'moritaniAssassinate') {
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', event: game.decision.event, decline: true });
|
||||
continue;
|
||||
}
|
||||
if (game.decision?.kind === 'ixSubstitution') {
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
||||
} else if (game.decision?.kind === 'battleCards') {
|
||||
|
||||
@@ -2,13 +2,6 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { basicMoritaniLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { spiceBankerModeSupported } from '../game/spice-banker';
|
||||
import { diplomatDefenseModeSupported } from '../game/diplomat-defense';
|
||||
import { sandmasterModeSupported } from '../game/sandmaster-movement';
|
||||
import { smugglerBattleModeSupported } from '../game/smuggler-battle';
|
||||
import { bureaucratPaymentModeSupported } from '../game/bureaucrat-payment';
|
||||
import { planetologistMovementModeSupported } from '../game/planetologist-movement';
|
||||
import { planetologistMoveDraft } from '../components/planetologist-movement';
|
||||
import { TERRITORIES, distance, location, splitLocation } from '../game/board';
|
||||
import { botActions } from '../game/bots';
|
||||
@@ -40,16 +33,8 @@ void test('every canonical skill can be dealt and publicly assigned to Basic Mor
|
||||
}
|
||||
});
|
||||
|
||||
void test('Moritani skill integration shares public guards and retains Advanced, Ecaz and optional-module boundaries', () => {
|
||||
const game = completedMoritaniSkillsGame();
|
||||
const gates = [basicMoritaniLeaderSkillsProfile, spiceBankerModeSupported, diplomatDefenseModeSupported,
|
||||
sandmasterModeSupported, smugglerBattleModeSupported, bureaucratPaymentModeSupported, planetologistMovementModeSupported];
|
||||
for (const gate of gates) {
|
||||
assert.equal(gate(game), true, gate.name);
|
||||
assert.equal(gate(viewGame(game, 'a')), true, `${gate.name} projected`);
|
||||
}
|
||||
void test('Moritani skill starts retain immutable Ecaz, optional-module and public-start rejection', () => {
|
||||
for (const change of [
|
||||
(g: Game) => { g.advanced = true; },
|
||||
(g: Game) => { g.players[1] = newPlayer('a', 'Ecaz', 'ecaz'); },
|
||||
(g: Game) => { g.expansions.push('choam'); },
|
||||
(g: Game) => { g.discoveryEnabled = true; },
|
||||
@@ -62,7 +47,6 @@ void test('Moritani skill integration shares public guards and retains Advanced,
|
||||
const before = JSON.stringify(lobby);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(lobby));
|
||||
assert.equal(JSON.stringify(lobby), before);
|
||||
assert.equal(basicMoritaniLeaderSkillsProfile(lobby), false);
|
||||
}
|
||||
const publicLobby = createGame('MORIPUBL', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
|
||||
joinGame(publicLobby, newPlayer('a', 'Atreides', 'atreides'));
|
||||
@@ -98,14 +82,6 @@ void test('Moritani Planetologist controls and bots use the same extended moveme
|
||||
assert.equal(player(done, 'm').forces[from] ?? 0, 0);
|
||||
assert.equal(player(done, 'm').moved, 1);
|
||||
assertMoritaniSkillsCustody(done);
|
||||
// Make the same two-step target useful; only legal availability is assessed.
|
||||
game.spice = { [to]: 8 };
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'm'); view.players.find(p => p.id === 'm')!.bot = difficulty;
|
||||
const action = botActions(view).find(a => a.type === 'move' && a.planetologist === 'range');
|
||||
assert.ok(action, difficulty);
|
||||
assert.doesNotThrow(() => applyAction(game, 'm', action));
|
||||
}
|
||||
});
|
||||
|
||||
void test('Moritani Smuggler ships the free companion once and Bureaucrat redirects a real paid bribe after restore', () => {
|
||||
|
||||
Reference in new issue
Block a user