Compose native Ecaz skills with original Ambassador and Duke paths
This commit is contained in:
1 parent
df8f335b98
commit
f17f4e703e
14 files changed
+845
-24
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user