Files
Dune/tests/paired-e3-nexus-modules-runtime.test.ts

472 lines
33 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame } from '../game/engine';
import type { Action, Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { DIFFICULTIES } from '../game/bot-profiles';
import { leaders } from '../game/cards';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { territory } from '../game/board';
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import {
advancePairedE3Nexus as advance, createPairedE3NexusProgramme as programme,
createPairedE3NexusSetup as setup, nextPairedE3NexusStep,
openPairedE3NexusBattle as openBattle, pairedE3NexusClean as clean,
pairedE3NexusInventory as inventory, pairedE3NexusPlayer as player,
pairedE3NexusPolicy as policy, pairedE3NexusReload as reload,
quietPairedE3NexusChoice as quiet, quotePairedE3NexusBattle as quote,
revealPairedE3Nexus as reveal, shipPairedE3Nexus as ship, stepPairedE3Nexus as step,
} from './fixture-paired-e3-nexus-modules';
import type { PairedE3NexusOptions, PairedE3NexusStep } from './fixture-paired-e3-nexus-modules';
function reject(g: Game, actor: string, action: Action, error?: RegExp): void {
const before = structuredClone(g);
if (error) assert.throws(() => applyAction(g, actor, action), error);
else assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before, 'A rejected consumer cannot spend, kill, place or transfer original pieces');
}
function policies(g: Game, actor: string, effect: (after: Game, action: Action) => void): void {
for (const difficulty of DIFFICULTIES) {
const action = policy(reload(g), actor, difficulty)[0]; assert.ok(action, difficulty);
const after = step(reload(g), { actor, action }); effect(after, action); inventory(after);
}
}
function settlementChoice(g: Game): PairedE3NexusStep | null {
const d = g.decision; if (!d || g.response || g.phaseOpening) return null;
if (d.kind === 'battleLosses') return { actor: d.player, action: { type: 'decision', choice: 0 } };
if (d.kind === 'battleCards') return { actor: d.player, action: { type: 'decision', discard: [] } };
if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
if (d.kind === 'moritaniRetention') return { actor: d.player, action: { type: 'decision', keep: null,
...(d.source === 'nexus' ? { event: d.event } : {}) } };
if (d.kind === 'sukRescue') {
const choice = d.options.map((o, index) => ({ index, saved: o.normal + o.elite, kept: !!o.kept }))
.sort((a, b) => b.saved - a.saved || Number(b.kept) - Number(a.kept))[0].index;
return { actor: d.player, action: { type: 'decision', event: d.event, choice } };
}
return quiet(g);
}
function settle(g: Game, stopAssassination = false, normalCaller?: string): Game {
for (let n = 0; n < 400; n++) {
if (!g.response && !g.phaseOpening && (stopAssassination && g.decision?.kind === 'moritaniAssassinate' ||
!g.battle && clean(g))) return g;
const next = nextPairedE3NexusStep(g) ?? settlementChoice(g); assert.ok(next);
if (next.action.type === 'traitorCall' && next.actor === normalCaller) next.action.call = true;
g = step(g, next);
}
throw Error('Original battle settlement did not finish');
}
function collectionIncome(g: Game, actor: string): number {
return g.phase === 7 ? quoteSpiceCollection(g).receipts.find(r => r.player === actor)?.strongholds ?? 0 : 0;
}
function shipWhenActive(g: Game, actor: string, location: string, amount: number): Game {
g = advance(g, s => s.phase === 5 && s.active === actor && clean(s), undefined, quiet);
return ship(g, actor, location, amount).after;
}
function guildLoss(options: PairedE3NexusOptions = {}, normalCall = false) {
const f = programme(options), guild = f.game.players.find(p => p.faction === 'guild')!.id;
let g = shipWhenActive(f.game, f.moritani, options.homeworlds && !normalCall ? 'homeworld:guild' : 'tueks_sietch:5', 1);
const location = options.homeworlds && !normalCall ? 'homeworld:guild' : 'tueks_sietch';
g = openBattle(g, location);
g = advance(g, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false,
undefined, s => s.decision?.kind === 'leaderSkillVisibility' && s.decision.player === guild && normalCall
? { actor: guild, action: { type: 'leaderSkillVisibility', event: s.decision.event, hide: true } } : quiet(s));
const own = player(g, f.moritani).leaders.filter(l => !l.dead && !l.usedAt &&
!g.leaderSkills?.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0];
const target = leaders('guild').find(l => l.name === 'Master Bewt')!.id;
const enemy = normalCall ? player(g, guild).leaders.find(l => l.id === target)!
: player(g, guild).leaders.filter(l => !l.dead && !l.usedAt && l.id !== target).sort((a, b) => b.strength - a.strength)[0];
assert.ok(own && enemy && player(g, f.moritani).traitors.includes(target));
const plans: PairedE3NexusStep[] = [
{ actor: f.moritani, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0,
weapon: player(g, f.moritani).hand.find(c => c.kind === 'worthless')?.id } },
{ actor: guild, action: { type: 'battlePlan', leader: enemy.id, dial: 2, support: g.advanced ? 2 : 0,
weapon: player(g, guild).hand.find(c => c.kind === 'worthless')?.id } },
];
const revealed = reveal(g, plans), pending = settle(revealed, true, normalCall ? f.moritani : undefined);
return { f, guild, target, location, revealed, pending, enemy: enemy.id };
}
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} native assassination against Ecaz consumes an original different disc and printed bounty`, () => {
const f = programme({ advanced: true, skills, tech: true, assassinationFaction: 'ecaz' });
const arrivals = new Set([f.ecaz, f.moritani]);
let g = reload(f.game);
while (arrivals.size) {
g = advance(g, s => s.turn === 2 && s.phase === 5 && clean(s) && arrivals.has(s.active!), undefined, quiet);
const actor = g.active!;
g = ship(g, actor, `rock_outcroppings:${territory('rock_outcroppings').sectors[0]}`, actor === f.ecaz ? 2 : 1).after;
arrivals.delete(actor);
if (arrivals.size) g = step(g, { actor, action: { type: 'endMovement' } });
}
g = openBattle(g, 'rock_outcroppings');
g = advance(g, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, undefined, quiet);
const ecaz = player(g, f.ecaz), target = ecaz.leaders.find(l => player(g, f.moritani).traitors.includes(l.id))!;
assert.ok(target && !target.dead);
const winner = ecaz.leaders.find(l => !l.dead && !l.usedAt && l.id !== target.id &&
!g.leaderSkills?.assignments.some(a => a.leader === l.id))!;
const loser = player(g, f.moritani).leaders.filter(l => !l.dead && !l.usedAt &&
!g.leaderSkills?.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(winner && loser);
const revealed = reveal(g, [
{ actor: f.ecaz, action: { type: 'battlePlan', leader: winner.id, dial: 1, support: 1 } },
{ actor: f.moritani, action: { type: 'battlePlan', leader: loser.id, dial: 0, support: 0 } },
]);
const pending = settle(revealed, true);
assert.equal(pending.lastBattleContext!.winner, f.ecaz);
const before = reload(pending);
const killed = step(pending, { actor: f.moritani, action: {
type: 'decision', event: pending.lastBattleContext!.event, card: target.id,
} });
assert.equal(player(killed, f.ecaz).leaders.find(l => l.id === target.id)!.dead, true);
assert.equal(player(killed, f.ecaz).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1);
assert.equal(player(killed, f.moritani).spice, player(before, f.moritani).spice + target.strength);
const done = settle(killed);
const later = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
assert.equal(player(later, f.moritani).traitors.includes(target.id), false);
assert.equal(later.traitorReserve!.includes(target.id), false);
inventory(later);
});
void test('original paired E3 Basic/Advanced two-to-six-seat setup conserves ecaz33, cache0, all12 and optional all14', () => {
const factions: FactionId[] = ['ecaz', 'moritani', 'guild', 'emperor', 'atreides', 'fremen'];
for (const advanced of [false, true]) for (const skills of [false, true]) for (let count = 2; count <= 6; count++) {
const f = setup({ advanced, skills, roster: factions.slice(0, count), tech: count >= 3, strongholds: advanced });
assert.equal(player(f.afterSetup, f.ecaz).leaders.length, 5);
assert.equal(player(f.afterSetup, f.moritani).leaders.length, 5);
assert.equal(player(f.afterSetup, f.ecaz).reserves, 14);
assert.equal(player(f.afterSetup, f.moritani).reserves, 14);
assert.equal(player(f.afterSetup, f.moritani).traitors.length, 1);
assert.equal(f.afterSetup.dukeVidal!.controller, null);
assert.equal(f.afterSetup.dukeVidal!.leader.dead, false);
assert.ok(!f.afterSetup.leaderSkills?.assignments.some(a => a.leader === DUKE_VIDAL_ID));
assert.equal(f.afterSetup.moritaniTerror!.tokens.filter(t => t.status === 'available').length, 6);
inventory(reload(f.afterSetup));
}
const basic = setup({ advanced: false, skills: true, roster: ['ecaz', 'moritani', 'harkonnen'] });
assert.equal(player(basic.afterSetup, 'harkonnen').traitors.length, 4); inventory(basic.afterSetup);
});
void test('original paired E3 skill offers reject shared Duke and foreign training immutably', () => {
let lobby = createGame('ORIGINALPAIREDE3', newPlayer('original-e', 'Ecaz', 'ecaz'), true, ['ecaz']);
for (const faction of ['moritani', 'guild', 'emperor'] as const) joinGame(lobby, newPlayer(`original-${faction}`, faction, faction));
lobby.nexusCards = { cards: null, phase: null };
for (const p of lobby.players) lobby = applyAction(lobby, p.id, { type: 'ready' });
const offered = initializeLeaderSkillsGameForAudit(lobby), actor = Object.keys(offered.leaderSkills!.offers)[0];
const own = viewGame(offered, actor).leaderSkills!, skill = own.offer!.cards.find(c => !own.unavailableSkills?.[c]); assert.ok(skill);
reject(offered, actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: DUKE_VIDAL_ID });
const foreignDisc = offered.players.find(p => p.id !== actor)!.leaders[0].id;
reject(offered, actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: foreignDisc });
});
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} qualifying original Great Maker/both-pile closing deals both native Cunning singletons`, () => {
const f = programme({ advanced: true, skills, discovery: true, homeworlds: true, tech: true, strongholds: true,
roster: ['ecaz', 'moritani', 'guild', 'fremen'] });
assert.ok(f.maker && f.alliance && f.drawing);
assert.equal(player(f.alliance, f.others[0]).ally, f.others[1]);
assert.equal(player(f.alliance, f.others[1]).ally, f.others[0]);
assert.equal(player(f.alliance, f.ecaz).ally, null); assert.equal(player(f.alliance, f.moritani).ally, null);
assert.ok(Object.values(f.afterSetup.nexusCards!.cards!.hands).every(c => c === null));
assert.ok(f.drawing.spiceDiscard[0].some(c => 'territory' in c));
assert.ok(f.drawing.spiceDiscard[1].some(c => 'territory' in c));
assert.equal(f.game.nexusCards!.cards!.hands[f.ecaz], 'ecaz');
assert.equal(f.game.nexusCards!.cards!.hands[f.moritani], 'moritani');
for (const draw of f.draws) {
reject(draw.after, draw.step.actor, draw.step.action);
policies(draw.before, draw.step.actor, after => {
const original = draw.before.nexusCards!.cards!, current = after.nexusCards!.cards!;
const held = current.hands[draw.step.actor];
assert.equal(current.deck.length, original.deck.length - (held === null ? 0 : 1));
if (held !== null) assert.equal(current.deck.includes(held), false);
});
}
assert.ok(f.firstMentat && f.claim);
assert.equal(f.game.strongholdCards!.owners.sietch_tabr, f.ecaz);
assert.equal(player(f.claim.after, f.ecaz).reserves, player(f.claim.before, f.ecaz).reserves - 1);
inventory(f.game);
});
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} quiet living-Duke Cunning is separate from training and allied Arrakis Occupy`, () => {
const f = programme({ skills, tech: true });
let g = shipWhenActive(f.game, f.ecaz, 'tueks_sietch:5', 1);
g = advance(g, s => s.phase === 6 && clean(s) && !s.battle, undefined, quiet);
const action = nexusEcazDukeAction(viewGame(g, f.ecaz)); assert.ok(action);
reject(g, f.moritani, action); reject(g, f.ecaz, { ...action, event: 'stale' });
policies(g, f.ecaz, after => {
assert.equal(after.dukeVidal!.controller, f.ecaz); assert.equal(after.dukeVidal!.leader.strength, 6);
assert.equal(after.nexusCards!.cards!.hands[f.ecaz], null);
assert.ok(!after.leaderSkills?.assignments.some(a => a.leader === DUKE_VIDAL_ID));
assert.equal(player(after, f.ecaz).leaders.length, 5);
assert.equal(player(after, f.ecaz).spice, player(g, f.ecaz).spice);
});
const claimed = step(g, { actor: f.ecaz, action });
reject(claimed, f.ecaz, action); inventory(claimed);
});
for (const skills of [false, true]) for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic even-FORCE'} ${skills ? 'all14' : 'No-Skills'} coalition pays the selected leader, kills each physical owner and transfers only the loser Tech`, () => {
const f = programme({ skills, advanced, tech: true, strongholds: advanced });
let g = advance(f.game, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
const at = g.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed); assert.ok(at >= 0);
g.spiceDeck.unshift(g.spiceDeck.splice(at, 1)[0]);
g = advance(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && clean(s), undefined, quiet);
const ally = f.others[1], opponent = f.others[0];
g = step(g, { actor: ally, action: { type: 'alliance', target: null } });
for (const [actor, target] of [[f.ecaz, ally], [ally, f.ecaz]]) g = step(g, { actor, action: { type: 'alliance', target } });
const amounts: Record<string, number> = { [f.ecaz]: advanced ? 4 : 3, [ally]: 4, [opponent]: 6 };
const shipped = new Set<string>();
for (let n = 0; !(g.phase === 6 && clean(g)) && n < 600; n++) {
if (g.phase === 5 && clean(g) && g.active && amounts[g.active] && !shipped.has(g.active)) {
const actor = g.active, arrival = ship(g, actor, 'sietch_tabr:14', amounts[actor]);
assert.equal(player(arrival.after, actor).reserves, player(arrival.before, actor).reserves - amounts[actor]);
assert.equal(player(arrival.after, actor).forces['sietch_tabr:14'],
(player(arrival.before, actor).forces['sietch_tabr:14'] ?? 0) + amounts[actor]);
assert.ok(player(arrival.after, actor).spice < player(arrival.before, actor).spice);
shipped.add(actor); g = arrival.after;
} else {
const next = nextPairedE3NexusStep(g) ?? quiet(g); assert.ok(next); g = step(g, next);
}
}
assert.equal(g.phase, 6); assert.equal(shipped.size, 3);
const opened = openBattle(g, 'sietch_tabr'), d = opened.decision; assert.ok(d?.kind === 'ecazBattleLead');
reject(opened, ally, { type: 'decision', event: d.event, lead: f.ecaz });
reject(opened, f.ecaz, { type: 'decision', event: d.event, lead: opponent });
const run = (state: Game, lead: string) => {
state = advance(state, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false,
undefined, s => s.decision?.kind === 'leaderSkillVisibility' && s.decision.player === lead
? { actor: lead, action: { type: 'leaderSkillVisibility', event: s.decision.event, hide: true } } : quiet(s));
const profile = viewGame(state, lead).battle!.ecazOccupy!.profile!;
const assignment = state.leaderSkills?.assignments.find(a => a.owner === lead);
const own = assignment ? player(state, lead).leaders.find(l => l.id === assignment.leader)!
: player(state, lead).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const enemy = player(state, opponent).leaders.filter(l => !l.dead &&
!state.leaderSkills?.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0];
const revealed = reveal(state, [
{ actor: lead, action: { type: 'battlePlan', leader: own.id, dial: profile.fixedEcazDial + 3, support: advanced ? 3 : 0,
weapon: player(state, lead).hand.find(c => c.kind === 'worthless')?.id } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: advanced ? 1 : 0 } },
]), q = quote(revealed), done = settle(revealed);
assert.equal(q.winner, lead); assert.equal(done.lastBattleContext!.winner, lead);
if (skills) {
const selected = revealed.battle!.attacker === lead ? q.leaderSkillBonuses.attacker : q.leaderSkillBonuses.defender;
assert.equal(selected.bonus, lead === f.ecaz ? 3 : 0,
'Only the actual selected Warmaster disc with its original Worthless gets +3; the ally cannot borrow that training');
}
const holder = revealed.strongholdCards?.owners.sietch_tabr;
assert.deepEqual(q.strongholdIncome, advanced && holder === lead ? [{ player: lead, amount: 1 }] : []);
const fixed = advanced ? 3 : 2;
assert.equal(player(done, f.ecaz).tanks - player(revealed, f.ecaz).tanks, fixed);
assert.equal(player(done, ally).tanks - player(revealed, ally).tanks, 3);
assert.equal(player(done, opponent).tanks - player(revealed, opponent).tanks, 6);
for (const payment of q.payments) {
const income = q.strongholdIncome.filter(i => i.player === payment.player).reduce((a, i) => a + i.amount, 0);
assert.equal(player(done, payment.player).spice, player(revealed, payment.player).spice - payment.ownPayment + income + collectionIncome(done, payment.player));
}
assert.equal(done.techTokens![ownedTech(revealed.techTokens, opponent)[0]].owner, lead);
for (const original of revealed.players.filter(p => p.id !== opponent))
for (const token of ownedTech(revealed.techTokens, original.id))
assert.equal(done.techTokens![token].owner, original.id, 'Only actual loser tokens transfer.');
inventory(done);
};
policies(opened, f.ecaz, (after, action) => run(after, String(action.lead)));
for (const lead of [f.ecaz, ally]) run(step(opened, { actor: f.ecaz, action: { type: 'decision', event: d.event, lead } }), lead);
});
void test('native all14 post-loss assassination waits for real winner casualties, Suk effects and support, then pays printed death bounty and replaces privately at actual Mentat', () => {
const b = guildLoss({ skills: true, advanced: true, tech: true, strongholds: true });
const { pending, revealed, f, guild, target } = b, q = quote(revealed);
assert.equal(q.winner, guild); assert.ok(pending.decision?.kind === 'moritaniAssassinate');
assert.equal(player(pending, f.moritani).tanks, player(revealed, f.moritani).tanks + 1);
assert.equal(player(pending, guild).tanks, player(revealed, guild).tanks + 1);
assert.equal(player(pending, guild).reserves, player(revealed, guild).reserves + 1);
const income = q.strongholdIncome.filter(i => i.player === guild).reduce((a, i) => a + i.amount, 0);
assert.equal(player(pending, guild).spice, player(revealed, guild).spice - 2 + income);
assert.equal(pending.techTokens![ownedTech(revealed.techTokens, f.moritani)[0]].owner, f.moritani,
'Assassination pauses the winner suffix before the original automatic single-token transfer');
const d = pending.decision; assert.ok(d?.kind === 'moritaniAssassinate');
const action: Action = { type: 'decision', event: d.event, card: target };
reject(pending, guild, action); reject(pending, f.moritani, { ...action, card: b.enemy });
const beforeTarget = player(pending, guild).leaders.find(l => l.id === target)!;
let killed = step(pending, { actor: f.moritani, action });
assert.equal(player(killed, guild).leaders.find(l => l.id === target)!.dead, true);
assert.equal(player(killed, guild).leaders.find(l => l.id === target)!.deaths, beforeTarget.deaths + 1);
assert.equal(player(killed, f.moritani).spice, player(pending, f.moritani).spice + beforeTarget.strength);
assert.ok(!killed.leaderSkills!.assignments.some(a => a.leader === target));
assert.equal(killed.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
killed = settle(killed);
assert.equal(killed.techTokens![ownedTech(revealed.techTokens, f.moritani)[0]].owner, guild);
const retired = target;
const later = advance(killed, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
assert.ok(!player(later, f.moritani).traitors.includes(retired));
assert.equal(player(later, f.moritani).traitors.length, 1);
assert.ok(!later.traitorReserve!.includes(retired));
assert.equal(later.moritaniAssassinate!.opportunities.filter(r => r.card === retired && r.stage === 'replaced').length, 1);
inventory(later);
policies(pending, f.moritani, after => {
const finished = settle(after); assert.equal(finished.lastBattleContext!.winner, guild);
assert.equal(finished.techTokens![ownedTech(revealed.techTokens, f.moritani)[0]].owner, guild);
const dead = player(after, guild).leaders.find(l => l.id === target)!.dead;
assert.equal(player(after, f.moritani).spice, player(pending, f.moritani).spice + (dead ? beforeTarget.strength : 0));
assert.equal(player(finished, guild).spice, player(pending, guild).spice + collectionIncome(finished, guild));
});
});
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} real Arrakis normal Traitor call forfeits native assassination for the rest of the game`, () => {
const b = guildLoss({ skills, advanced: true, tech: true }, true);
assert.equal(b.pending.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(b.pending.lastBattleContext!.winner, b.f.moritani);
assert.ok(!b.pending.moritaniAssassinate!.opportunities.some(r => r.stage === 'choice'));
inventory(b.pending);
});
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} foreign Homeworld excludes normal Traitor calls but retains separate native assassination`, () => {
const b = guildLoss({ skills, advanced: true, tech: true, homeworlds: true });
reject(b.revealed, b.f.moritani, { type: 'traitorCall', call: true });
assert.equal(b.pending.lastBattleContext!.winner, b.guild);
const before = reload(b.pending), target = player(before, b.guild).leaders.find(l => l.id === b.target)!;
const action: Action = { type: 'decision', event: before.lastBattleContext!.event, card: b.target };
const killed = step(before, { actor: b.f.moritani, action });
assert.equal(player(killed, b.guild).leaders.find(l => l.id === b.target)!.dead, true);
assert.equal(player(killed, b.guild).leaders.find(l => l.id === b.target)!.deaths, target.deaths + 1);
assert.equal(player(killed, b.f.moritani).spice, player(before, b.f.moritani).spice + target.strength);
const done = settle(killed);
const home = homeworldForceGroups(homeworldContext(done), done.homeworlds!.custody!).find(h => h.id === 'homeworld:guild')!;
assert.equal(home.forces[b.f.moritani]?.normal ?? 0, 0);
assert.equal(player(done, b.f.moritani).tanks - player(b.revealed, b.f.moritani).tanks, 1);
inventory(done);
});
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} supply-only Moritani Cunning uses actual placement and entrant reveal after original Grumman declines`, () => {
const f = programme({ skills, advanced: true, tech: true });
let g = advance(f.game, s => !s.response && !s.phaseOpening && s.decision?.kind === 'moritaniPlacement', undefined, quiet);
const token = g.moritaniTerror!.tokens.find(t => t.kind === 'robbery' && t.status === 'available'); assert.ok(token);
const offer = viewGame(g, f.moritani).nexusMoritani; assert.ok(offer);
const action: Action = { type: 'decision', token: token.id, territory: 'sihaya_ridge', nexus: offer.event };
policies(g, f.moritani, after => {
const allowed = advance(after, s => !s.response && !s.phaseOpening, undefined, quiet);
assert.equal(player(allowed, f.moritani).spice, player(g, f.moritani).spice);
assert.equal(player(allowed, f.moritani).reserves, player(g, f.moritani).reserves);
assert.equal(allowed.moritaniTerror!.tokens.length, 6);
assert.ok(allowed.moritaniTerror!.tokens.filter(t => t.status === 'placed').every(t =>
t.location !== 'grumman' && !t.location?.startsWith('homeworld:')));
});
reject(g, f.ecaz, action); reject(g, f.moritani, { ...action, territory: 'grumman' });
reject(g, f.moritani, { ...action, territory: 'hidden_mobile_stronghold' });
reject(g, f.moritani, { ...action, territory: 'homeworld:guild' });
const before = reload(g);
g = step(g, { actor: f.moritani, action });
g = advance(g, s => !s.response && !s.phaseOpening, undefined, quiet);
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.status, 'placed');
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.location, 'sihaya_ridge');
assert.equal(g.nexusCards!.cards!.hands[f.moritani], null);
assert.equal(player(g, f.moritani).spice, player(before, f.moritani).spice);
assert.equal(player(g, f.moritani).reserves, player(before, f.moritani).reserves);
const guild = f.others[0];
g = advance(g, s => s.turn === 3 && s.phase === 5 && s.active === guild && clean(s), undefined, quiet);
const shipped = step(g, { actor: guild, action: { type: 'ship', territory: 'sihaya_ridge', sector: 9, amount: 1, allyPayment: 0 } });
g = advance(shipped, s => !s.response && !s.phaseOpening && s.decision?.kind === 'moritaniTerror', undefined, quiet);
const d = g.decision; assert.ok(d?.kind === 'moritaniTerror');
const moriBefore = player(g, f.moritani).spice, entrantBefore = player(g, guild).spice;
const revealAction: Action = { type: 'decision', reveal: true };
reject(g, guild, revealAction);
g = step(g, { actor: f.moritani, action: revealAction });
g = step(g, { actor: f.moritani, action: { type: 'decision', choice: 'spice' } });
g = advance(g, s => !s.response && !s.phaseOpening && !s.pendingTerrorEntry && clean(s), undefined, quiet);
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.status, 'removed');
assert.ok(player(g, f.moritani).spice > moriBefore);
assert.equal(player(g, f.moritani).spice - moriBefore, entrantBefore - player(g, guild).spice);
assert.equal(player(g, f.moritani).spice - moriBefore, Math.ceil(entrantBefore / 2));
inventory(g);
});
void test('No-Skills original assassination pause retains the unfinished winner suffix, which settles every physical loss and payment exactly once', () => {
const b = guildLoss({ advanced: true, skills: false, tech: true, strongholds: true });
const q = quote(b.revealed), d = b.pending.decision; assert.ok(d?.kind === 'moritaniAssassinate');
const target = player(b.pending, b.guild).leaders.find(l => l.id === b.target)!;
const acted = step(b.pending, { actor: b.f.moritani, action: { type: 'decision', event: d.event, card: b.target } });
const done = settle(acted);
assert.equal(player(done, b.f.moritani).tanks - player(b.revealed, b.f.moritani).tanks, 1);
assert.equal(player(done, b.guild).tanks - player(b.revealed, b.guild).tanks, 2);
assert.equal(player(done, b.guild).forces['tueks_sietch:5'], 3);
assert.equal(player(done, b.guild).leaders.find(l => l.id === b.target)!.dead, true);
for (const payment of q.payments) {
const income = q.strongholdIncome.filter(i => i.player === payment.player).reduce((a, i) => a + i.amount, 0);
assert.equal(player(done, payment.player).spice, player(b.revealed, payment.player).spice - payment.ownPayment +
income + (payment.player === b.f.moritani ? target.strength : 0) + collectionIncome(done, payment.player));
}
assert.equal(done.techTokens![ownedTech(b.revealed.techTokens, b.f.moritani)[0]].owner, b.guild);
inventory(done);
const later = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
assert.ok(!player(later, b.f.moritani).traitors.includes(b.target));
assert.equal(player(later, b.f.moritani).traitors.length, 1);
assert.equal(later.moritaniAssassinate!.opportunities.filter(r => r.card === b.target && r.stage === 'replaced').length, 1);
inventory(later);
});
void test('real Basic odd Ecaz FORCE admits provisional ceiling losses, floor survivors and actual coalition rewards', () => {
const f = programme({ advanced: false, skills: false, tech: true });
let g = advance(f.game, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
const at = g.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed); assert.ok(at >= 0);
g.spiceDeck.unshift(g.spiceDeck.splice(at, 1)[0]);
g = advance(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && clean(s), undefined, quiet);
const ally = f.others[1], opponent = f.others[0];
g = step(g, { actor: ally, action: { type: 'alliance', target: null } });
for (const [actor, target] of [[f.ecaz, ally], [ally, f.ecaz]]) g = step(g, { actor, action: { type: 'alliance', target } });
const amounts: Record<string, number> = { [f.ecaz]: 2, [ally]: 2, [opponent]: 2 }, shipped = new Set<string>();
for (let n = 0; !(g.phase === 6 && clean(g)) && n < 600; n++) {
if (g.phase === 5 && clean(g) && g.active && amounts[g.active] && !shipped.has(g.active)) {
const actor = g.active; g = ship(g, actor, 'sietch_tabr:14', amounts[actor]).after; shipped.add(actor);
} else {
const next = nextPairedE3NexusStep(g) ?? quiet(g); assert.ok(next); g = step(g, next);
}
}
assert.equal(player(g, f.ecaz).forces['sietch_tabr:14'], 3);
const choice = viewGame(g, g.active!).battleChoices.find(c => c.territory === 'sietch_tabr'); assert.ok(choice);
const before = reload(g);
g = step(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'sietch_tabr',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } });
assert.ok(g.battle);
const d = g.decision; assert.ok(d?.kind === 'ecazBattleLead');
inventory(g);
for (const lead of [f.ecaz, ally]) {
const chosen = step(g, { actor: f.ecaz, action: { type: 'decision', event: d.event, lead } });
const ready = advance(chosen, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false,
undefined, quiet);
const profile = viewGame(ready, lead).battle!.ecazOccupy!.profile!;
assert.equal(profile.ecazForces.normal, 3);
assert.equal(profile.fixedEcazDial, Math.ceil(3 / 2));
assert.equal(profile.planOwner, lead);
assert.equal(profile.payer, lead);
assert.equal(profile.forceOwner, ally);
const own = player(ready, lead).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
const enemy = player(ready, opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
assert.ok(own && enemy);
const revealed = reveal(ready, [
{ actor: lead, action: { type: 'battlePlan', leader: own.id, dial: profile.fixedEcazDial + 2, support: 0 } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } },
]), q = quote(revealed), done = settle(revealed);
assert.equal(q.winner, lead);
assert.equal(done.lastBattleContext!.winner, lead);
assert.deepEqual(q.fixedLosses, [{ owner: f.ecaz, normal: Math.ceil(3 / 2), elite: 0 }]);
assert.equal(q.casualties!.owner, ally);
assert.deepEqual(q.casualties!.options, [{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 }]);
assert.ok(q.payments.every(payment => payment.ownPayment === 0));
assert.deepEqual(q.strongholdIncome, []);
for (const payment of q.payments)
assert.equal(player(done, payment.player).spice,
player(revealed, payment.player).spice + collectionIncome(done, payment.player));
assert.equal(player(done, f.ecaz).tanks - player(before, f.ecaz).tanks, Math.ceil(3 / 2));
assert.equal(player(done, f.ecaz).forces['sietch_tabr:14'], Math.floor(3 / 2));
assert.equal(player(done, ally).tanks - player(before, ally).tanks, 2);
assert.equal(player(done, ally).forces['sietch_tabr:14'] ?? 0, 0);
assert.equal(player(done, opponent).tanks - player(before, opponent).tanks, 2);
const token = ownedTech(before.techTokens, opponent)[0]; assert.ok(token);
assert.equal(done.techTokens![token].owner, lead);
for (const owner of [f.ecaz, ally])
for (const retained of ownedTech(before.techTokens, owner))
assert.equal(done.techTokens![retained].owner, owner, 'Only the loser’s Tech transfers.');
inventory(done);
}
});