Compose native Ecaz skills with original Ambassador and Duke paths

This commit is contained in:
Quintine committed 2026-10-03 18:34:40 +10:00
1 parent df8f335b98
commit f17f4e703e
14 files changed
+845 -24

No files matched your search

+3 -3
View File
@@ -10,12 +10,12 @@ import { newRevivalRules } from '../game/revival';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type AdvancedNativeSkillsOptions = {
family: 'ixians' | 'choam' | 'tleilaxu' | 'moritani' | 'richese';
family: 'ixians' | 'choam' | 'tleilaxu' | 'moritani' | 'richese' | 'ecaz';
requestedSkill?: LeaderSkillId;
skillOwner?: FactionId;
opponents?: readonly FactionId[];
bankerIncome?: boolean;
/** Richese also exercises its already-admitted Basic native rule band. */
/** Richese and Ecaz also exercise their Basic native rule bands. */
rules?: 'basic' | 'advanced';
/** Continue this actual authenticated lobby/setup without replacing its seats. */
initial?: Game;
@@ -55,7 +55,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 === 'moritani' ? 'ecaz' :
const expansion = options.family === 'moritani' || options.family === 'ecaz' ? 'ecaz' :
options.family === 'choam' || options.family === 'richese' ? 'choam' : 'ix';
let game: Game;
if (options.initial) game = structuredClone(options.initial);
+156
View File
@@ -0,0 +1,156 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { leaders, treacheryDeck, type Card } from '../game/cards';
import { traitorDeck } from '../game/traitors';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { validateAmbassadors } from '../game/ecaz-ambassadors';
import {
advancedNativePlayer as player, advancedNativeStep, assertAdvancedNativeCustody,
advanceAdvancedNativeSkillsToPhase, completedAdvancedNativeSkillsGame,
stageAdvancedNativeSkillBattle,
} from './fixture-advanced-native-skills';
export const reloadEcazDukeSkills = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
export const ecazDukeSkillsAction = (game: Game, actor: string, action: Action): Game =>
applyAction(reloadEcazDukeSkills(game), actor, action);
export const ecazDukeSkillsTrainer = (game: Game) => {
const assignment = game.leaderSkills!.assignments.find(a => a.owner === player(game, 'ecaz').id)!;
assert.equal(assignment.skill, 'warmaster');
return assignment;
};
export function assertEcazDukeSkillsCustody(game: Game): void {
assertAdvancedNativeCustody(game);
const physical = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id);
assert.deepEqual(physical.sort(), treacheryDeck(game.expansions).map(c => c.id).sort());
for (const seat of game.players)
assert.deepEqual(seat.leaders.map(l => l.id).sort(), leaders(seat.faction).map(l => l.id).sort());
assert.equal(player(game, 'ecaz').leaders.length, 5);
assert.equal(game.dukeVidal!.leader.id, DUKE_VIDAL_ID);
assert.equal(game.dukeVidal!.leader.strength, 6);
assert.equal(game.dukeVidal!.leader.faction, 'ecaz');
assert.equal(game.players.some(p => p.leaders.some(l => l.id === DUKE_VIDAL_ID)), false);
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === DUKE_VIDAL_ID), false);
const traits = [...game.traitorReserve!, ...game.players.flatMap(p => p.traitors),
...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : [])];
assert.deepEqual(traits.sort(), traitorDeck(game.players, game.expansions.includes('ix')).sort());
assert.equal(traits.includes(DUKE_VIDAL_ID), false);
validateAmbassadors(game.ecazAmbassadors!);
}
function responses(state: Game): Game {
let game = state;
for (let i = 0; game.response && i < 100; i++)
game = advancedNativeStep(reloadEcazDukeSkills(game));
assert.equal(game.response, null);
return game;
}
/** Original Ecaz setup, original end-of-Revival paid token placement, actual
* entrant shipment and owner acquisition. The only controlled board position
* returns deployed forces to their own reserves and puts the storm at sector18
* after genuine setup/phase progression. Cards, wallets, discs, traitors and
* the original random Ambassador cohort are never replaced or cherry-picked. */
export function acquireNativeEcazSkillDuke(rules: 'basic' | 'advanced' = 'advanced'): Game {
let game = completedAdvancedNativeSkillsGame({
family: 'ecaz', requestedSkill: 'warmaster', opponents: ['emperor'], rules,
});
const actor = player(game, 'ecaz').id, entrant = player(game, 'emperor').id;
const assignment = structuredClone(ecazDukeSkillsTrainer(game));
assert.equal(game.dukeVidal!.controller, null);
assertEcazDukeSkillsCustody(game);
game = advanceAdvancedNativeSkillsToPhase(game, 4);
// Explicit conserved position, not a claim of played movement history.
for (const seat of game.players) {
seat.reserves += Object.values(seat.forces).reduce((sum, n) => sum + n, 0);
seat.forces = {};
if (seat.elites) {
seat.elites.reserves += Object.values(seat.elites.forces).reduce((sum, n) => sum + n, 0);
seat.elites.forces = {};
}
}
game.storm = 18;
for (let i = 0; game.decision?.kind !== 'ecazPlacement' && i < 100; i++)
game = advancedNativeStep(reloadEcazDukeSkills(game));
assert.equal(game.decision?.kind, 'ecazPlacement');
const token = game.ecazAmbassadors!.tokens.find(t => t.effect === 'ecaz')!;
const cohort = structuredClone(game.ecazAmbassadors!.cohort);
const wallet = player(game, 'ecaz').spice;
game = ecazDukeSkillsAction(game, actor, { type: 'decision', token: token.id, territory: 'arrakeen' });
game = responses(game);
assert.equal(player(game, 'ecaz').spice, wallet - 1);
assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token.id)!.zone, 'placed');
game = ecazDukeSkillsAction(game, actor, { type: 'decision', decline: true });
game = responses(game);
assert.equal(game.phase, 5);
for (let i = 0; game.active !== entrant && i < 10; i++) {
assert.ok(game.active);
game = responses(ecazDukeSkillsAction(game, game.active, { type: 'endMovement' }));
}
assert.equal(game.active, entrant);
const before = reloadEcazDukeSkills(game);
game = ecazDukeSkillsAction(game, entrant, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1 });
game = responses(game);
assert.equal(game.pendingAmbassador!.owner, actor);
assert.equal(game.pendingAmbassador!.entrant, entrant);
assert.equal(viewGame(game, actor).ambassadorEntry!.dukeAcquisition!.blocked, null);
assert.equal(player(game, 'emperor').reserves, player(before, 'emperor').reserves - 1);
assert.equal(player(game, 'emperor').forces['arrakeen:10'], 1);
assert.equal(player(game, 'emperor').spice, player(before, 'emperor').spice - 1);
const disc = structuredClone(game.dukeVidal!.leader);
const shippedPlayers = structuredClone(game.players);
game = ecazDukeSkillsAction(game, actor, {
type: 'decision', event: game.pendingAmbassador!.event,
trigger: true, beneficiary: actor, choice: 'duke',
});
assert.deepEqual(game.players, shippedPlayers);
assert.deepEqual(game.dukeVidal!.leader, disc);
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn],
[actor, 'ecaz', game.turn]);
assert.equal(game.pendingAmbassador, null);
assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token.id)!.zone, 'supply');
assert.deepEqual(game.ecazAmbassadors!.cohort, cohort);
assert.deepEqual(ecazDukeSkillsTrainer(game), assignment);
assertEcazDukeSkillsCustody(game);
return game;
}
/** Controlled subsequent native battle position after real token acquisition.
* Transfer original deck cards, not fabricated cards; original Duke custody and
* its separate normal-disc trainer survive the conserved board relocation. */
export function stageNativeEcazDukeSkillBattle(state: Game, poison = false): {
game: Game; actor: string; enemy: string; worthless: Card; weapon?: Card; defender: string;
} {
const actor = player(state, 'ecaz').id, enemy = player(state, 'emperor').id;
let game = stageAdvancedNativeSkillBattle(state, actor, enemy);
// Retain the original interrupted movement suffix; only the conserved board
// position is staged. Both armies now have a real pending native battle.
Object.assign(game, { phase: state.phase, active: state.active, order: [...state.order], ready: [...state.ready] });
for (let i = 0; game.phase === 5 && i < 20; i++) {
assert.ok(game.active);
game = responses(ecazDukeSkillsAction(game, game.active, { type: 'endMovement' }));
}
assert.equal(game.phase, 6);
assert.equal(game.dukeVidal!.controller, actor);
// Explicit controlled chooser order at the genuine Battle boundary gives
// Ecaz the attacker's printed tie-break; neither army or battle is removed.
game.order = [actor, enemy];
game.active = actor;
const take = (owner: string, kind: Card['kind']): Card => {
const index = game.deck.findIndex(c => c.kind === kind);
assert.ok(index >= 0, 'Transfer an original physical native-deck card.');
const card = game.deck.splice(index, 1)[0];
game.players.find(p => p.id === owner)!.hand.push(card);
return card;
};
const worthless = take(actor, 'worthless');
const weapon = poison ? take(enemy, 'poison') : undefined;
// The real setup attaches the Emperor's card to first-disc Fenring6,
// leaving this printed5 disc available while the native trainers stay face up.
const defender = player(game, 'emperor').leaders.find(l => l.strength === 5)!;
assert.notEqual(game.leaderSkills!.assignments.find(a => a.owner === enemy)!.leader, defender.id);
assertEcazDukeSkillsCustody(game);
return { game, actor, enemy, worthless, weapon, defender: defender.id };
}
+150
View File
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import {
advancedNativePlayer as player, advancedNativeStep, finishAdvancedNativeSkillAftermath,
openAdvancedNativeSkillBattle, rejectAdvancedNativeAction as reject,
} from './fixture-advanced-native-skills';
import {
acquireNativeEcazSkillDuke, assertEcazDukeSkillsCustody,
ecazDukeSkillsAction as act, ecazDukeSkillsTrainer as trainer,
reloadEcazDukeSkills as reload, stageNativeEcazDukeSkillBattle,
} from './fixture-ecaz-duke-skills';
function resolve(game: Game, actor: string, enemy: string): Game {
game = act(game, actor, { type: 'traitorCall', call: false });
return act(game, enemy, { type: 'traitorCall', call: false });
}
for (const rules of ['basic', 'advanced'] as const) {
void test(`${rules} original Ecaz Ambassador acquisition gives separate Duke6 only the native face-up Warmaster's normal +1`, () => {
const acquired = acquireNativeEcazSkillDuke(rules);
const assignment = structuredClone(trainer(acquired));
const native = structuredClone(player(acquired, 'ecaz').leaders);
for (const [visible, dial, expectedWinner] of [
[true, 2, 'ecaz'], [false, 2, 'emperor'], [true, 3, 'emperor'],
] as const) {
const staged = stageNativeEcazDukeSkillBattle(reload(acquired));
let game = openAdvancedNativeSkillBattle(reload(staged.game), staged.actor, staged.enemy, !visible);
assert.equal(game.dukeVidal!.controller, staged.actor);
assert.equal(game.dukeVidal!.source, 'ecaz');
assert.equal(game.dukeVidal!.leader.strength, 6);
assert.equal(player(game, 'ecaz').leaders.find(l => l.id === assignment.leader)!.dead, false);
assert.equal(game.battle!.leaderSkillHidden![staged.actor], !visible);
assert.equal(viewGame(game, staged.actor).leaderSkills!.eligibleLeaders.some(l => l.id === DUKE_VIDAL_ID), false);
game = act(game, staged.actor, {
type: 'battlePlan', leader: DUKE_VIDAL_ID, dial: 0, support: 0, weapon: staged.worthless.id,
});
game = act(game, staged.enemy, {
type: 'battlePlan', leader: staged.defender, dial, support: dial,
});
assert.equal(game.battle!.plans[staged.actor].leader, DUKE_VIDAL_ID);
assert.equal(game.battle!.plans[staged.actor].weapon, staged.worthless.id);
const enemyWallet = player(game, 'emperor').spice;
game = resolve(game, staged.actor, staged.enemy);
// Duke6+normal1 ties Captain5+2: Ecaz attacks and wins. Hidden6 loses;
// visible7 loses to5+3. Giving Duke the trainer's selected-disc+3 would
// incorrectly win the last case. Every dial is paid/allocated natively.
assert.equal(game.lastBattleContext!.winner, player(game, expectedWinner).id);
assert.equal(player(game, 'emperor').spice, enemyWallet - (game.advanced ? dial : 0));
assert.equal(game.dukeVidal!.leader.dead, false);
assert.equal(game.dukeVidal!.leader.deaths, 0);
assert.equal(game.dukeVidal!.leader.usedAt, 'wind_pass');
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn],
[null, null, null]);
assert.deepEqual(trainer(game), assignment);
assert.deepEqual(player(game, 'ecaz').leaders, native);
assert.equal(game.leaderSkills!.deck.includes('warmaster'), false);
game = finishAdvancedNativeSkillAftermath(reload(game));
assert.equal(game.battle, null);
assert.equal(game.decision, null);
assertEcazDukeSkillsCustody(game);
const refreshed = reload(game);
assert.deepEqual(refreshed.dukeVidal, game.dukeVidal);
assert.deepEqual(viewGame(refreshed, staged.actor).dukeVidal, viewGame(game, staged.actor).dukeVidal);
assert.deepEqual(trainer(refreshed), assignment);
}
});
void test(`${rules} real Duke weapon death pays printed6 only and never kills or returns his separate native trainer`, () => {
const staged = stageNativeEcazDukeSkillBattle(acquireNativeEcazSkillDuke(rules), true);
const assignment = structuredClone(trainer(staged.game));
const native = structuredClone(player(staged.game, 'ecaz').leaders);
const skills = structuredClone(staged.game.leaderSkills);
let game = openAdvancedNativeSkillBattle(reload(staged.game), staged.actor, staged.enemy, false);
game = act(game, staged.actor, {
type: 'battlePlan', leader: DUKE_VIDAL_ID, dial: 0, support: 0, weapon: staged.worthless.id,
});
game = act(game, staged.enemy, {
type: 'battlePlan', leader: staged.defender, dial: 0, support: 0, weapon: staged.weapon!.id,
});
const wallets = Object.fromEntries(game.players.map(p => [p.id, p.spice]));
game = resolve(game, staged.actor, staged.enemy);
assert.equal(game.lastBattleContext!.winner, staged.enemy);
assert.deepEqual(Object.fromEntries(game.players.map(p => [p.id, p.spice])),
{ ...wallets, [staged.enemy]: wallets[staged.enemy] + 6 });
assert.equal(game.dukeVidal!.leader.dead, true);
assert.equal(game.dukeVidal!.leader.deaths, 1);
assert.equal(game.dukeVidal!.leader.strength, 6);
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn],
[null, null, null]);
assert.deepEqual(player(game, 'ecaz').leaders, native);
assert.deepEqual(game.leaderSkills, skills);
assert.deepEqual(trainer(game), assignment);
assert.equal(game.leaderSkills!.deck.includes('warmaster'), false);
game = finishAdvancedNativeSkillAftermath(reload(game));
assert.equal(game.battle, null);
assert.equal(game.decision, null);
assert.equal(player(game, 'emperor').hand.some(c => c.id === staged.weapon!.id), true,
'Native winner elects to keep the actual weapon.');
assert.equal(game.discard.some(c => c.id === staged.worthless.id), true,
'Native loser returns the real played Worthless to discard.');
assertEcazDukeSkillsCustody(game);
// Genuine next-turn phase transitions retain the actual battle death and
// original wallet; no fake Tanks placement or hand/deck replacement.
const deathTurn = game.turn;
for (let i = 0; (game.turn === deathTurn || game.phase !== 4 || game.phaseOpening || game.response || game.decision)
&& i < 1000; i++) {
if (game.decision?.kind === 'ecazPlacement')
game = act(game, game.decision.player, { type: 'decision', decline: true });
else game = advancedNativeStep(reload(game));
}
assert.equal(game.turn, deathTurn + 1);
assert.equal(game.phase, 4);
assert.equal(game.decision, null);
const funds = player(game, 'ecaz').spice;
const option = viewGame(game, staged.actor).revival.leaders.find(l => l.id === DUKE_VIDAL_ID)!;
assert.equal(option.normalCost, 5);
assert.equal(option.cost, 5);
assert.equal(option.affordable, true);
assert.equal(viewGame(game, staged.enemy).revival.leaders.some(l => l.id === DUKE_VIDAL_ID), false);
reject(game, staged.enemy, { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
game = act(game, staged.actor, { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
for (let i = 0; (game.response || game.pendingRevival) && i < 100; i++)
game = advancedNativeStep(reload(game));
assert.equal(game.pendingRevival, null);
assert.equal(player(game, 'ecaz').spice, funds - 5);
assert.equal(player(game, 'ecaz').leaderRevived, true);
assert.equal(game.dukeVidal!.leader.dead, false);
assert.equal(game.dukeVidal!.leader.deaths, 1);
assert.equal(game.dukeVidal!.leader.strength, 6);
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn],
[null, null, null]);
assert.equal(game.leaderSkills!.offers[staged.actor], undefined);
assert.notEqual(game.decision?.kind, 'leaderSkillRevival');
assert.deepEqual(trainer(game), assignment);
assert.deepEqual(game.leaderSkills, skills);
assert.equal(viewGame(game, staged.actor).leaderSkills!.eligibleLeaders.some(l => l.id === DUKE_VIDAL_ID), false);
assertEcazDukeSkillsCustody(game);
const refreshed = reload(game);
assert.deepEqual(refreshed.dukeVidal, game.dukeVidal);
// A subsequent original action accepts the refreshed paid-revival custody.
assert.ok(refreshed.players.some(p => !refreshed.ready.includes(p.id)));
const actor = refreshed.players.find(p => !refreshed.ready.includes(p.id))!.id;
game = applyAction(refreshed, actor, { type: 'ready' });
assertEcazDukeSkillsCustody(game);
assert.deepEqual(trainer(game), assignment);
});
}
+314
View File
@@ -0,0 +1,314 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, 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 { validateAmbassadors } from '../game/ecaz-ambassadors';
import { newRevivalRules } from '../game/revival';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
advancedNativePlayer as player, advancedNativeStep, advanceAdvancedNativeSkillsToPhase,
assertAdvancedNativeCustody, completedAdvancedNativeSkillsGame,
initializeAdvancedNativeSkillsSetup, openAdvancedNativeSkillBattle,
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 trainer = (game: Game, actor: string) => game.leaderSkills!.assignments.find(a => a.owner === actor)!;
const wallets = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice]));
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) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
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, ...p.traitorChoices]),
...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : [])];
assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort());
if (game.ecazAmbassadors) validateAmbassadors(game.ecazAmbassadors);
}
function take(game: Game, actor: string, matches: (card: Card) => boolean): Card {
const index = game.deck.findIndex(matches);
assert.ok(index >= 0, 'Controlled card staging transfers a real remaining native card.');
const card = game.deck.splice(index, 1)[0];
game.players.find(p => p.id === actor)!.hand.push(card);
return card;
}
function enemyLeader(game: Game): string {
const enemy = player(game, 'emperor');
const selected = enemy.leaders.filter(l => !l.dead && l.id !== trainer(game, enemy.id).leader)
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(selected);
return selected.id;
}
function resolve(game: Game, actor: string, target: string): Game {
return act(act(game, actor, { type: 'traitorCall', call: false }), target, { type: 'traitorCall', call: false });
}
function responses(state: Game): Game {
let game = state;
for (let i = 0; (game.response || game.pendingTreacheryDiscard) && i < 100; i++) game = advancedNativeStep(game);
assert.equal(game.response, null);
return game;
}
void test('native Basic and Advanced Ecaz deal original starting cards, public skills, loyalty and traitors before six-force placement', () => {
for (const rules of ['basic', 'advanced'] as const) {
let game = initializeAdvancedNativeSkillsSetup({ family: 'ecaz', requestedSkill: 'warmaster', rules });
const actor = player(game, 'ecaz').id;
assert.equal(game.setupStage, 'leaderSkills');
assert.deepEqual(game.players.map(p => p.hand.length), [1, 1, 1]);
assert.deepEqual(game.players.map(p => p.traitorChoices), [[], [], []]);
assert.equal(player(game, 'ecaz').reserves, 20);
const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards)];
assert.deepEqual(skills.sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
assert.deepEqual(viewGame(game, actor).leaderSkills!.eligibleLeaders.map(l => l.id).sort(),
leaders('ecaz').map(l => l.id).sort());
for (const seat of game.players) {
const own = viewGame(game, seat.id).leaderSkills!;
const offer = own.offer!;
const skill = seat.id === actor ? 'warmaster' : 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(trainer(game, actor).skill, 'warmaster');
for (const seat of game.players) {
assert.equal(seat.traitorChoices.length, 4);
assert.equal(viewGame(game, seat.id).leaderSkills!.assignments.find(a => a.owner === actor)!.leader, trainer(game, actor).leader);
}
if (rules === 'advanced') {
const loyal = game.ecazLoyalty!.card!;
assert.ok(leaders('ecaz').some(l => l.id === loyal));
assert.equal([...game.traitorReserve!, ...game.players.flatMap(p => p.traitorChoices)].includes(loyal), false);
assert.equal(player(game, 'ecaz').leaders.find(l => l.id === loyal)!.dead, false);
for (const seat of game.players) assert.deepEqual(viewGame(game, seat.id).ecazLoyalty, { player: actor, card: loyal });
} else assert.equal(viewGame(game, actor).ecazLoyalty, null);
custody(game);
for (const seat of game.players) {
const choice = viewGame(game, seat.id).players.find(p => p.id === seat.id)!.traitorChoices![0];
assert.ok(choice);
game = act(game, seat.id, { type: 'traitor', leader: choice });
}
assert.equal(game.setupStage, 'forces');
assert.deepEqual(game.players.map(p => p.traitors.length), [1, 1, 1]);
assert.equal(player(game, 'ecaz').reserves, 20);
game = act(game, actor, { type: 'ecazSetup', placements: { 'imperial_basin:9': 2, 'imperial_basin:10': 4 } });
assert.equal(game.status, 'playing');
assert.equal(game.phase, 0);
assert.equal(player(game, 'ecaz').reserves, 14);
assert.deepEqual(player(game, 'ecaz').forces, { 'imperial_basin:9': 2, 'imperial_basin:10': 4 });
custody(game);
}
});
void test('native Ecaz normal Poison role and selected skilled role change the winner without increasing the printed death bounty', () => {
for (const rules of ['basic', 'advanced'] as const) {
const setup = completedAdvancedNativeSkillsGame({ family: 'ecaz', requestedSkill: 'master-of-assassins', rules });
const actor = player(setup, 'ecaz').id, target = player(setup, 'emperor').id;
for (const hidden of [false, true]) {
let game = stageAdvancedNativeSkillBattle(setup, actor, target);
const poison = take(game, actor, c => c.kind === 'poison');
const selected = hidden ? player(game, 'ecaz').leaders.find(l => l.id === trainer(game, actor).leader)!
: player(game, 'ecaz').leaders.find(l => l.id !== trainer(game, actor).leader && l.strength === 4)!;
assert.equal(selected.strength, 4);
const enemy = player(game, 'emperor').leaders.find(l => l.id === enemyLeader(game))!;
const funds = wallets(game), enemyReserves = player(game, 'emperor').reserves;
const normalSuk = !hidden && trainer(game, target).skill === 'suk-graduate' ? 1 : 0;
game = openAdvancedNativeSkillBattle(game, actor, target, hidden);
game = act(game, actor, { type: 'battlePlan', dial: 1, support: rules === 'advanced' ? 1 : 0, leader: selected.id, weapon: poison.id });
game = act(game, target, { type: 'battlePlan', dial: 7, support: rules === 'advanced' ? 7 : 0, leader: enemy.id });
game = finishAdvancedNativeSkillAftermath(resolve(game, actor, target));
assert.equal(game.lastBattleContext!.winner, hidden ? actor : target);
assert.equal(player(game, 'ecaz').leaders.find(l => l.id === selected.id)!.dead, false);
assert.equal(player(game, 'emperor').leaders.find(l => l.id === enemy.id)!.dead, true);
assert.equal(player(game, 'ecaz').spice, funds[actor] - (rules === 'advanced' ? 1 : 0) + (hidden ? enemy.strength : 0));
assert.equal(player(game, 'emperor').spice, funds[target] - (rules === 'advanced' ? 7 : 0) + (hidden ? 0 : enemy.strength));
assert.equal(player(game, 'ecaz').tanks, hidden ? 1 : 8);
assert.equal(player(game, 'ecaz').forces['wind_pass:14'] ?? 0, hidden ? 7 : 0);
assert.equal(player(game, 'emperor').tanks, hidden ? 8 : 7 - normalSuk);
assert.equal(player(game, 'emperor').reserves, enemyReserves + normalSuk);
assert.equal(game.discard.filter(c => c.id === poison.id).length, hidden ? 0 : 1);
assert.equal(player(game, 'ecaz').hand.some(c => c.id === poison.id), hidden);
custody(game);
}
}
});
void test('native Ecaz Suk uses actual ordinary casualties: face-up one-to-reserves versus selected skilled keep-one and return-two', () => {
for (const rules of ['basic', 'advanced'] as const) {
const setup = completedAdvancedNativeSkillsGame({ family: 'ecaz', requestedSkill: 'suk-graduate', rules });
const actor = player(setup, 'ecaz').id, target = player(setup, 'emperor').id;
for (const hidden of [false, true]) {
let game = stageAdvancedNativeSkillBattle(setup, actor, target);
const selected = hidden ? trainer(game, actor).leader
: player(game, 'ecaz').leaders.find(l => l.id !== trainer(game, actor).leader && l.strength === 4)!.id;
const enemy = enemyLeader(game), funds = wallets(game), reserves = player(game, 'ecaz').reserves;
game = openAdvancedNativeSkillBattle(game, actor, target, hidden);
game = act(game, actor, { type: 'battlePlan', dial: 5, support: rules === 'advanced' ? 5 : 0, leader: selected });
game = act(game, target, { type: 'battlePlan', dial: 0, support: 0, leader: enemy });
game = finishAdvancedNativeSkillAftermath(resolve(game, actor, target));
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(player(game, 'ecaz').spice, funds[actor] - (rules === 'advanced' ? 5 : 0));
if (hidden) {
assert.equal(game.decision?.kind, 'sukRescue');
if (game.decision?.kind !== 'sukRescue') throw new Error('Expected native ordinary casualty rescue.');
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 5, elite: 0, paidNormal: rules === 'advanced' ? 5 : 0, paidElite: 0 });
const pending = game;
const choice = game.decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.key === 'wind_pass:14');
assert.ok(choice >= 0);
game = finishAdvancedNativeSkillAftermath(act(game, actor, { type: 'decision', event: game.decision.event, choice }));
assert.equal(player(game, 'ecaz').tanks, 2);
assert.equal(player(game, 'ecaz').forces['wind_pass:14'], 4);
assert.equal(player(game, 'ecaz').reserves, reserves + 2);
for (const difficulty of DIFFICULTIES) {
const own = viewGame(reload(pending), actor);
own.players.find(p => p.id === actor)!.bot = difficulty;
const rescued = finishAdvancedNativeSkillAftermath(act(pending, actor, botActions(own)[0]));
assert.equal(player(rescued, 'ecaz').tanks, 2);
assert.equal(player(rescued, 'ecaz').spice, funds[actor] - (rules === 'advanced' ? 5 : 0));
assert.equal(player(rescued, 'ecaz').forces['wind_pass:14'], 4);
assert.equal(player(rescued, 'ecaz').reserves, reserves + 2);
custody(rescued);
}
} else {
assert.equal(player(game, 'ecaz').tanks, 4);
assert.equal(player(game, 'ecaz').forces['wind_pass:14'], 3);
assert.equal(player(game, 'ecaz').reserves, reserves + 1);
}
assert.equal(player(game, 'emperor').tanks, 8);
custody(game);
}
}
});
void test('real native Ecaz trainer death returns its skill; eligible own ordinary and physical Ghola revival offer optional replacement', () => {
for (const rules of ['basic', 'advanced'] as const) {
const setup = completedAdvancedNativeSkillsGame({ family: 'ecaz', requestedSkill: 'warmaster', rules });
const actor = player(setup, 'ecaz').id, target = player(setup, 'emperor').id;
const assigned = structuredClone(trainer(setup, actor));
let dead = stageAdvancedNativeSkillBattle(setup, actor, target);
const poison = take(dead, target, c => c.kind === 'poison');
const printed = player(dead, 'ecaz').leaders.find(l => l.id === assigned.leader)!.strength;
const funds = wallets(dead);
dead = openAdvancedNativeSkillBattle(dead, actor, target);
dead = act(dead, actor, { type: 'battlePlan', dial: 0, support: 0, leader: assigned.leader });
dead = act(dead, target, { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader(dead), weapon: poison.id });
dead = finishAdvancedNativeSkillAftermath(resolve(dead, actor, target));
assert.equal(dead.lastBattleContext!.winner, target);
assert.equal(player(dead, 'ecaz').leaders.find(l => l.id === assigned.leader)!.dead, true);
assert.equal(dead.leaderSkills!.assignments.some(a => a.owner === actor), false);
assert.equal(dead.leaderSkills!.deck.filter(c => c === assigned.skill).length, 1);
assert.deepEqual(wallets(dead), { ...funds, [target]: funds[target] + printed });
assert.equal(player(dead, 'ecaz').tanks, 8);
custody(dead);
for (const mode of ['ordinary', 'ghola'] as const) {
let game = reload(dead);
// Explicit conserved Revival position, not a claimed played turn history.
// Ordinary revival stages the other four original discs dead to open the
// native five-disc first cohort. Ghola instead uses only the actual death.
Object.assign(game, { phase: 4, active: null, ready: [], decision: null, response: null,
phaseOpening: null, revivalRules: newRevivalRules() });
for (const seat of game.players) seat.leaderRevived = false;
if (mode === 'ordinary') for (const leader of player(game, 'ecaz').leaders) if (leader.id !== assigned.leader) {
leader.dead = true; leader.deaths = 1;
}
const before = wallets(game);
let ghola: Card | undefined;
if (mode === 'ordinary') {
assert.equal(viewGame(game, actor).revival.leaders.find(l => l.id === assigned.leader)!.cost, printed);
game = act(game, actor, { type: 'reviveLeader', leader: assigned.leader });
} else {
ghola = take(game, actor, c => c.effect === 'ghola');
assert.ok(viewGame(game, actor).ghola.leaders.some(l => l.id === assigned.leader));
game = responses(act(game, actor, { type: 'card', card: ghola.id, leader: assigned.leader }));
}
assert.equal(game.decision?.kind, 'leaderSkillRevival');
assert.equal(player(game, 'ecaz').leaders.find(l => l.id === assigned.leader)!.dead, false);
assert.equal(player(game, 'ecaz').leaders.find(l => l.id === assigned.leader)!.deaths, 1);
assert.equal(player(game, 'ecaz').leaderRevived, mode === 'ordinary');
assert.deepEqual(wallets(game), { ...before, [actor]: before[actor] - (mode === 'ordinary' ? printed : 0) });
if (ghola) {
assert.equal(player(game, 'ecaz').hand.some(c => c.id === ghola!.id), false);
assert.equal(game.discard.filter(c => c.id === ghola!.id).length, 1);
}
const event = viewGame(game, actor).leaderSkills!.offer!.event;
const declined = act(game, actor, { type: 'leaderSkill', event, mode: 'decline' });
assert.equal(declined.leaderSkills!.assignments.some(a => a.owner === actor), false);
assert.equal(player(declined, 'ecaz').leaders.find(l => l.id === assigned.leader)!.dead, false);
custody(declined);
game = act(game, actor, { type: 'leaderSkill', event, mode: 'draw' });
const own = viewGame(game, actor).leaderSkills!;
const offered = own.offer!.cards;
assert.equal(offered.length, 2);
const skill = offered.find(c => !own.unavailableSkills?.[c])!;
game = act(game, actor, { type: 'leaderSkill', event, skill, leader: assigned.leader });
assert.deepEqual(trainer(game, actor), { owner: actor, leader: assigned.leader, skill });
assert.equal(game.leaderSkills!.deck.includes(offered.find(c => c !== skill)!), true);
assert.deepEqual(wallets(game), wallets(declined));
custody(game);
}
}
});
void test('original Emperor Ambassador charges placement then pays five from the Bank and resumes the real entrant with Ecaz training intact', () => {
for (const rules of ['basic', 'advanced'] as const) {
let game = completedAdvancedNativeSkillsGame({ family: 'ecaz', requestedSkill: 'warmaster', rules });
const actor = player(game, 'ecaz').id, entrant = player(game, 'atreides').id;
const assigned = structuredClone(trainer(game, actor));
const originalTokens = game.ecazAmbassadors!.tokens.map(t => ({ id: t.id, effect: t.effect }));
// Explicit controlled, conserved post-setup cohort and board position. This
// is not natural Ambassador history or a second source of fixed entropy.
// Swap custody only: retain all eleven original token identities/effects.
const state = game.ecazAmbassadors!;
const token = state.tokens.find(t => t.effect === 'emperor')!;
if (!state.cohort.includes(token.id)) {
const replaced = state.tokens.find(t => t.id === state.cohort[0])!;
state.cohort[0] = token.id;
replaced.zone = 'pool';
token.zone = 'supply';
}
for (const seat of game.players) {
seat.reserves += Object.values(seat.forces).reduce((sum, n) => sum + n, 0);
seat.forces = {};
if (seat.elites) {
seat.elites.reserves += Object.values(seat.elites.forces).reduce((sum, n) => sum + n, 0);
seat.elites.forces = {};
}
}
custody(game);
game = advanceAdvancedNativeSkillsToPhase(game, 4);
const before = wallets(game);
for (const seat of game.players) game = act(game, seat.id, { type: 'ready' });
assert.equal(game.decision?.kind, 'ecazPlacement');
game = responses(act(game, actor, { type: 'decision', token: token.id, territory: 'carthag' }));
assert.equal(player(game, 'ecaz').spice, before[actor] - 1);
assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token.id)!.location, 'carthag');
game = act(game, actor, { type: 'decision', decline: true });
for (let i = 0; (game.phaseOpening || game.response || game.decision || game.active !== entrant) && i < 100; i++) game = advancedNativeStep(game);
const entrantFunds = player(game, 'atreides').spice, entrantReserves = player(game, 'atreides').reserves;
game = responses(act(game, entrant, { type: 'ship', territory: 'carthag', sector: 11, amount: 1 }));
assert.equal(game.decision?.kind, 'ecazAmbassador');
assert.equal(viewGame(game, actor).ambassadorEntry!.stage, 'offer');
game = responses(act(game, actor, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: actor }));
assert.equal(player(game, 'ecaz').spice, before[actor] - 1 + 5);
assert.equal(player(game, 'atreides').spice, entrantFunds - 1);
assert.equal(player(game, 'atreides').reserves, entrantReserves - 1);
assert.equal(player(game, 'atreides').forces['carthag:11'], 1);
assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token.id)!.zone, 'used');
assert.deepEqual(game.ecazAmbassadors!.tokens.map(t => ({ id: t.id, effect: t.effect })), originalTokens);
assert.deepEqual(trainer(game, actor), assigned);
assert.equal(game.active, entrant);
assert.equal(player(game, 'atreides').shipped, true);
game = act(game, entrant, { type: 'endMovement' });
assert.notEqual(game.active, entrant);
custody(game);
}
});