Support cohort-eligible Ambassador leader and KH repeats
Replace lifetime first-death caps with the selected provisional composition: first returns remain immediate, while later native leaders and KH must satisfy their existing ordinary death/cohort conditions. Extract one phase/slot/price-independent cohort quote for ordinary Revival and the independent Ambassador. Preserve ordinary timing, payment, prevention, native early service and one-leader controls. Snapshot an opened cohort before a free return makes a leader alive, preserving remaining ordinary returns. Keep Ecaz identity tracking and the shared Duke permission separate. KH retains activation/loss history and five native discs; update its marker above the actual opened owner cycle and old marker. Independent grants retain ordinary allowance and income policies. Explain eligibility in persistent controls and source-labelled reference. Verify related rules/controls, types/lint/build, controlled native/copied free-return-to-next-ordinary-Revival programmes layered on original public Basic paid-entry sources, controlled Advanced KH actual entry/return, income allow/cancel and twelve minimal legal policy continuations. Assigned repeat/cohort facts are explicitly controlled, not claimed original battle histories. Full modes, wider custody/module combinations, comprehensive assurance and deployed acceptance remain unfinished. No strategic calibration, saved-room operation, reset, restart or deployment.
This commit is contained in:
1 parent
16f654071a
commit
152039be5b
12 files changed
+719
-51
No files matched your search
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { createDukeAmbassadorFixture, actDukeAmbassador as act,
|
||||
advanceDukeAmbassador as advance, dukeAmbassadorClean as clean } from './fixture-duke-ambassador';
|
||||
import { createAmbassadorRevivalIncomeFixture, actAmbassadorRevivalIncome as incomeAct } from './fixture-ambassador-revival-income';
|
||||
import { createControlledAmbassadorKhRepeatFixture } from './fixture-ambassador-repeat-leaders';
|
||||
|
||||
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
/** Original public Basic source through the paid Guild arrival, then explicit
|
||||
* controlled death/cohort/alliance inputs. These are not earned repeated deaths
|
||||
* or a genuine original full-game history. All choices/continuations are real. */
|
||||
function scenario({ copied = false, owner = 'harkonnen', deaths = 2, open = true,
|
||||
used = false, poor = false } = {}) {
|
||||
const f = createDukeAmbassadorFixture({ copied });
|
||||
let game = structuredClone(f.offer);
|
||||
const p = player(game, owner);
|
||||
if (owner !== f.owner) {
|
||||
player(game, f.owner).ally = owner; p.ally = f.owner;
|
||||
}
|
||||
p.revivalCycle = deaths - 1;
|
||||
p.leaderRevived = used;
|
||||
if (poor) p.spice = 0;
|
||||
for (const [index, leader] of p.leaders.entries()) {
|
||||
leader.dead = open || index === 0;
|
||||
leader.deaths = open || index === 0 ? deaths : 0;
|
||||
delete leader.usedAt;
|
||||
}
|
||||
if (owner === 'ecaz') p.ecazRevivalCohort = {
|
||||
cycle: deaths - 1, returned: deaths > 1 ? p.leaders.map(l => l.id) : [],
|
||||
};
|
||||
const event = game.pendingAmbassador!.event;
|
||||
game = act(game, f.owner, { type: 'decision', event, trigger: true, beneficiary: owner, choice: 'effect' });
|
||||
if (copied) game = act(game, owner, { type: 'decision', event, effect: 'tleilaxu' });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'revival');
|
||||
return { f, game, owner, event, leader: p.leaders[0].id };
|
||||
}
|
||||
function reject(g: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
|
||||
}
|
||||
const counters = (g: Game, id: string) => {
|
||||
const p = player(g, id);
|
||||
return { spice: p.spice, tanks: p.tanks, reserves: p.reserves, hand: p.hand,
|
||||
revived: p.revived, freeForcesRevived: p.freeForcesRevived, leaderRevived: p.leaderRevived };
|
||||
};
|
||||
const revive = (s: ReturnType<typeof scenario>, leader = s.leader) =>
|
||||
act(s.game, s.owner, { type: 'decision', event: s.event, leader });
|
||||
|
||||
void test('a controlled all-first-dead Ambassador return preserves the newly opened ordinary cohort', () => {
|
||||
const s = scenario({ deaths: 1 });
|
||||
const done = revive(s);
|
||||
assert.equal(player(done, s.owner).revivalCycle, 1);
|
||||
assert.equal(player(done, s.owner).leaders.find(l => l.id === s.leader)!.dead, false);
|
||||
const ordinary = advance(done, g => g.turn === 3 && g.phase === 4 && clean(g));
|
||||
assert.equal(viewGame(ordinary, s.owner).revival.leaders.length, 4);
|
||||
});
|
||||
|
||||
for (const copied of [false, true]) void test(`controlled ${copied ? 'copied' : 'native'} Ambassador returns an eligible repeated leader and retains the next ordinary return`, () => {
|
||||
const s = scenario({ copied, used: true }), before = structuredClone(s.game);
|
||||
assert.ok(viewGame(s.game, s.owner).ambassadorEntry!.revival!.leaders.some(l => l.id === s.leader));
|
||||
const done = revive(s), returned = player(done, s.owner).leaders.find(l => l.id === s.leader)!;
|
||||
assert.equal(returned.dead, false); assert.equal(returned.deaths, 2); assert.equal(returned.usedAt, undefined);
|
||||
assert.equal(player(done, s.owner).revivalCycle, 2);
|
||||
assert.deepEqual(counters(done, s.owner), counters(before, s.owner));
|
||||
assert.equal(done.pendingAmbassador, null); assert.equal(done.active, s.f.entrant);
|
||||
reject(done, s.owner, { type: 'decision', event: s.event, leader: s.leader });
|
||||
let ordinary = advance(done, g => g.turn === 3 && g.phase === 4 && clean(g));
|
||||
const choices = viewGame(ordinary, s.owner).revival.leaders;
|
||||
assert.equal(choices.length, 4);
|
||||
const choice = choices.find(l => l.affordable); assert.ok(choice);
|
||||
const balance = player(ordinary, s.owner).spice;
|
||||
ordinary = act(ordinary, s.owner, { type: 'reviveLeader', leader: choice.id });
|
||||
assert.equal(player(ordinary, s.owner).leaders.find(l => l.id === choice.id)!.dead, false);
|
||||
assert.equal(player(ordinary, s.owner).spice, balance - choice.cost);
|
||||
assert.equal(player(ordinary, s.owner).leaderRevived, true);
|
||||
});
|
||||
|
||||
void test('a controlled closed repeated cohort stays unavailable while a closed first-death leader retains the independent grant', () => {
|
||||
const blocked = scenario({ open: false });
|
||||
assert.equal(viewGame(blocked.game, blocked.owner).ambassadorEntry!.revival!.leaders.some(l => l.id === blocked.leader), false);
|
||||
reject(blocked.game, blocked.owner, { type: 'decision', event: blocked.event, leader: blocked.leader });
|
||||
const first = scenario({ open: false, deaths: 1, used: true, poor: true });
|
||||
const before = counters(first.game, first.owner), done = revive(first);
|
||||
assert.equal(player(done, first.owner).revivalCycle, 0, 'An independent first death must not open a closed cohort');
|
||||
assert.deepEqual(counters(done, first.owner), before);
|
||||
});
|
||||
|
||||
void test('a controlled Ecaz repeated Ambassador return records the native identity before clearing death', () => {
|
||||
const s = scenario({ owner: 'ecaz' }), before = counters(s.game, s.owner);
|
||||
assert.ok(viewGame(s.game, s.owner).ambassadorEntry!.revival!.leaders.some(l => l.id === s.leader));
|
||||
const done = revive(s), p = player(done, s.owner);
|
||||
assert.equal(p.leaders.find(l => l.id === s.leader)!.deaths, 2);
|
||||
assert.equal(p.revivalCycle, 2);
|
||||
assert.deepEqual(p.ecazRevivalCohort, { cycle: 2, returned: [s.leader] });
|
||||
assert.deepEqual(counters(done, s.owner), before);
|
||||
assert.equal(p.leaders.length, 5);
|
||||
const ordinary = advance(done, g => g.turn === 3 && g.phase === 4 && clean(g));
|
||||
assert.equal(viewGame(ordinary, s.owner).revival.leaders.filter(l => l.id !== 'duke-vidal').length, 4);
|
||||
});
|
||||
|
||||
void test('controlled repeated choices preserve event ownership, exclusive alternatives and zero price', () => {
|
||||
const s = scenario({ used: true, poor: true });
|
||||
reject(s.game, s.owner, { type: 'decision', event: 'expired', leader: s.leader });
|
||||
reject(s.game, s.f.entrant, { type: 'decision', event: s.event, leader: s.leader });
|
||||
reject(s.game, s.owner, { type: 'decision', event: s.event, leader: s.leader, forces: 1 });
|
||||
const done = revive(s);
|
||||
assert.equal(player(done, s.owner).leaders.find(l => l.id === s.leader)!.dead, false);
|
||||
assert.equal(player(done, s.owner).spice, 0);
|
||||
assert.equal(player(done, s.owner).leaderRevived, true);
|
||||
});
|
||||
|
||||
void test('all four existing profiles select a legal repeated return when every beneficiary option is repeated', () => {
|
||||
for (const copied of [false, true]) {
|
||||
const s = scenario({ copied });
|
||||
// Controlled physical reallocation isolates the new leader alternative;
|
||||
// ordinary policy prefers a force return whenever one remains available.
|
||||
const p = player(s.game, s.owner); p.reserves += p.tanks; p.tanks = 0;
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(s.game, s.owner); view.players.find(p => p.id === s.owner)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.equal(action.type, 'decision'); assert.ok(action.leader);
|
||||
const done = act(s.game, s.owner, action);
|
||||
assert.equal(player(done, s.owner).leaders.find(l => l.id === action.leader)!.dead, false);
|
||||
assert.equal(player(done, s.owner).leaders.find(l => l.id === action.leader)!.deaths, 2);
|
||||
assert.equal(done.pendingAmbassador, null); assert.equal(done.active, s.f.entrant);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled repeated returns retain their original death receipt through native and copied income allowance or cancellation', () => {
|
||||
for (const copied of [false, true]) {
|
||||
const f = createAmbassadorRevivalIncomeFixture({ copied }), source = structuredClone(f.game);
|
||||
const p = player(source, f.owner);
|
||||
for (const leader of p.leaders) { leader.dead = true; leader.deaths = 2; }
|
||||
p.revivalCycle = 1; p.ecazRevivalCohort = { cycle: 1, returned: p.leaders.map(l => l.id) };
|
||||
p.leaderRevived = true;
|
||||
const leader = p.leaders[0].id, bankIncome = player(source, f.incomeOwner).spice;
|
||||
const earned = incomeAct(source, f.owner, { type: 'decision', event: f.event, leader });
|
||||
assert.deepEqual(earned.pendingAmbassador!.revivalReceipt!.returned, { kind: 'leader', leader, deaths: 2 });
|
||||
assert.equal(earned.response?.source, 'ambassadorRevival');
|
||||
for (const canceled of [false, true]) {
|
||||
let done = earned;
|
||||
if (canceled) done = incomeAct(done, f.cancelActor, { type: 'card', card: f.cancelCard, mode: 'cancel' });
|
||||
while (done.response) done = incomeAct(done,
|
||||
done.players.find(p => !done.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
|
||||
assert.equal(done.pendingAmbassador, null);
|
||||
assert.equal(player(done, f.owner).leaders.find(l => l.id === leader)!.dead, false);
|
||||
assert.equal(player(done, f.owner).leaders.find(l => l.id === leader)!.deaths, 2);
|
||||
assert.equal(player(done, f.owner).revivalCycle, 2);
|
||||
assert.equal(player(done, f.owner).leaderRevived, true);
|
||||
assert.equal(player(done, f.incomeOwner).spice, bankIncome + (canceled ? 0 : 1));
|
||||
done = incomeAct(done, f.entrantContinuation.actor, f.entrantContinuation.action);
|
||||
assert.equal(done.movementRemaining?.includes(f.entrant) ?? false, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('a controlled repeated KH return advances past the actual newly opened cohort rather than its stale owner marker', () => {
|
||||
const { game, event } = createControlledAmbassadorKhRepeatFixture(), before = counters(game, 'atreides');
|
||||
assert.ok(viewGame(game, 'atreides').ambassadorEntry!.revival!.leaders.some(l => l.id === 'kwisatz'));
|
||||
const done = act(game, 'atreides', { type: 'decision', event, leader: 'kwisatz' });
|
||||
const p = player(done, 'atreides');
|
||||
assert.equal(p.revivalCycle, 3); assert.equal(p.kwisatz!.revivalCycle, 4);
|
||||
assert.equal(p.kwisatz!.dead, false); assert.equal(p.kwisatz!.usedAt, undefined);
|
||||
assert.equal(p.battleLosses, 10); assert.equal(p.leaders.length, 5);
|
||||
assert.ok(p.leaders.every(l => l.dead && l.deaths === 3));
|
||||
assert.deepEqual(counters(done, 'atreides'), before);
|
||||
assert.equal(done.pendingAmbassador, null); assert.equal(done.active, 'guild');
|
||||
const blocked = createControlledAmbassadorKhRepeatFixture(4);
|
||||
assert.equal(viewGame(blocked.game, 'atreides').ambassadorEntry!.revival!.leaders.some(l => l.id === 'kwisatz'), false);
|
||||
reject(blocked.game, 'atreides', { type: 'decision', event: blocked.event, leader: 'kwisatz' });
|
||||
});
|
||||
|
||||
void test('all existing profiles use the controlled repeated KH alternative without adding a native leader or ordinary slot', () => {
|
||||
const { game } = createControlledAmbassadorKhRepeatFixture(2, true);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(game, 'atreides'); view.players.find(p => p.id === 'atreides')!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.equal(action.leader, 'kwisatz');
|
||||
const done = act(game, 'atreides', action), p = player(done, 'atreides');
|
||||
assert.equal(p.kwisatz!.revivalCycle, 4); assert.equal(p.leaders.length, 5);
|
||||
assert.equal(p.leaderRevived, true); assert.equal(p.battleLosses, 10);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, newPlayer, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createAmbassadors, validateAmbassadors } from '../game/ecaz-ambassadors';
|
||||
import { createDukeVidal } from '../game/duke-vidal';
|
||||
import { actDukeAmbassador as act, type DukeAmbassadorStep } from './fixture-duke-ambassador';
|
||||
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
|
||||
/** Controlled Advanced source, with the original actual shipment/trigger path.
|
||||
* These assigned cohort/loss facts are not earned gameplay or start proof. */
|
||||
export function createControlledAmbassadorKhRepeatFixture(marker = 2, live = false) {
|
||||
const actions: DukeAmbassadorStep[] = [];
|
||||
const step = (game: Game, actor: string, action: Parameters<typeof act>[2]) => act(game, actor, action, actions);
|
||||
let game = createGame('AMBASSADORKHREPEAT', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
|
||||
game.players.push(newPlayer('atreides', 'Atreides', 'atreides'), newPlayer('guild', 'Guild', 'guild'));
|
||||
Object.assign(game, { status: 'playing', turn: 2, phase: 5, storm: 18, active: 'guild',
|
||||
order: ['guild', 'ecaz', 'atreides'], movementRemaining: ['guild', 'ecaz', 'atreides'], deck: baseDeck() });
|
||||
for (const p of game.players) { p.hand = []; p.forces = {}; p.reserves = 20; p.spice = 12; }
|
||||
game.dukeVidal = createDukeVidal();
|
||||
const state = createAmbassadors(() => 0);
|
||||
const cohort = ['tleilaxu', ...state.tokens.map(t => t.effect).filter(e => e !== 'tleilaxu' && e !== 'ecaz')].slice(0, 5);
|
||||
state.cohort = state.tokens.filter(t => cohort.includes(t.effect)).map(t => t.id);
|
||||
for (const token of state.tokens) { token.zone = token.effect === 'ecaz' || state.cohort.includes(token.id) ? 'supply' : 'pool'; token.location = null; }
|
||||
const token = state.tokens.find(t => t.effect === 'tleilaxu')!; token.zone = 'placed'; token.location = 'arrakeen';
|
||||
validateAmbassadors(state); game.ecazAmbassadors = state;
|
||||
const p = player(game, 'atreides'); p.ally = 'ecaz'; player(game, 'ecaz').ally = p.id;
|
||||
p.revivalCycle = live ? 3 : 2; p.leaderRevived = true; p.battleLosses = 10;
|
||||
p.kwisatz = { dead: true, revivalCycle: marker, usedAt: 'imperial_basin' };
|
||||
for (const leader of p.leaders) { leader.dead = !live; leader.deaths = 3; }
|
||||
game = step(game, 'guild', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 });
|
||||
while (game.response) game = step(game, game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
|
||||
if (game.decision?.kind === 'guildShipment') {
|
||||
game = step(game, game.decision.player, { type: 'decision', allow: true });
|
||||
while (game.response) game = step(game, game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.pendingAmbassador?.stage, 'offer');
|
||||
const event = game.pendingAmbassador!.event;
|
||||
game = step(game, 'ecaz', { type: 'decision', event, trigger: true, beneficiary: 'atreides', choice: 'effect' });
|
||||
return { game, event, actions, sourceActionCount: actions.length };
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { leaders } from '../game/cards';
|
||||
import { createAuditorLeader, leaders } from '../game/cards';
|
||||
import type { Player } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { quoteLeaderRevivalCohort } from '../game/revival';
|
||||
import {
|
||||
tleilaxuAmbassadorLeaderChoices,
|
||||
tleilaxuAmbassadorLeaderOpening,
|
||||
tleilaxuAmbassadorLeaderSelection,
|
||||
} from '../game/tleilaxu-ambassador-leaders';
|
||||
|
||||
@@ -19,6 +23,19 @@ function fixture(advanced = false, deaths = 1) {
|
||||
return { context, player };
|
||||
}
|
||||
|
||||
function cohortFixture(faction: FactionId = 'emperor', advanced = false) {
|
||||
const player: Pick<Player, 'id' | 'faction' | 'leaders' | 'kwisatz' | 'battleLosses' |
|
||||
'revivalCycle' | 'ecazRevivalCohort' | 'leaderRevived' | 'spice'> = {
|
||||
id: 'owner', faction, leaders: leaders(faction), battleLosses: 0,
|
||||
revivalCycle: 0, leaderRevived: false, spice: 0,
|
||||
};
|
||||
const context = {
|
||||
advanced, status: 'playing' as const, turn: 3, players: [player],
|
||||
dukeVidal: faction === 'ecaz' ? createDukeVidal() : undefined,
|
||||
};
|
||||
return { context, player };
|
||||
}
|
||||
|
||||
// These are pure identity/custody inputs. Actual Ambassador acquisition, free
|
||||
// return, cohort recording and native arrival continuation belong to engine tests.
|
||||
void test('Basic and Advanced Ecaz receive the canonical shared Duke alternative with set-aside destination', () => {
|
||||
@@ -34,7 +51,7 @@ void test('Basic and Advanced Ecaz receive the canonical shared Duke alternative
|
||||
}
|
||||
});
|
||||
|
||||
void test('independent Duke permission preserves repeated deaths without extending ordinary Ambassador repeat returns', () => {
|
||||
void test('independent Duke permission preserves repeated deaths without inventing a sparse native cohort', () => {
|
||||
for (const deaths of [2, 4]) {
|
||||
const { context, player } = fixture(false, deaths);
|
||||
const first = player.leaders[0], repeated = player.leaders[1];
|
||||
@@ -54,6 +71,207 @@ void test('independent Duke permission preserves repeated deaths without extendi
|
||||
}
|
||||
});
|
||||
|
||||
void test('first-death grant remains immediate while only a real opened cohort is snapshotted', () => {
|
||||
for (const faction of ['emperor', 'ecaz'] as const) {
|
||||
const { context, player } = cohortFixture(faction);
|
||||
const first = player.leaders[0];
|
||||
first.dead = true; first.deaths = 1;
|
||||
const closed = structuredClone(context);
|
||||
assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 0);
|
||||
assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, []);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, first.id), first);
|
||||
assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), null);
|
||||
assert.deepEqual(context, closed, 'The immediate grant never opens a closed ordinary cohort');
|
||||
|
||||
for (const leader of player.leaders) { leader.dead = true; leader.deaths = 1; }
|
||||
const opened = structuredClone(context);
|
||||
const opening = tleilaxuAmbassadorLeaderOpening(context, player);
|
||||
assert.equal(opening, 1);
|
||||
assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, player.leaders.map(leader => leader.id));
|
||||
assert.deepEqual(context, opened, 'Quoting the all-five-first-dead opening does not record it');
|
||||
if (faction === 'emperor') {
|
||||
first.dead = false;
|
||||
assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 0,
|
||||
'Clearing death before recording the actual opening would lose it');
|
||||
player.revivalCycle = opening!;
|
||||
assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, player.leaders.slice(1).map(leader => leader.id));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('repeated native leaders wait for their own ordinary death cohort and then become choices', () => {
|
||||
const { context, player } = cohortFixture();
|
||||
player.revivalCycle = 1;
|
||||
for (const leader of player.leaders) { leader.dead = true; leader.deaths = 1; }
|
||||
const repeated = player.leaders[0];
|
||||
repeated.deaths = 2;
|
||||
assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, player.leaders.slice(1).map(leader => leader.id));
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, repeated.id), null);
|
||||
assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), 1);
|
||||
|
||||
for (const leader of player.leaders) leader.deaths = 2;
|
||||
const before = structuredClone(context);
|
||||
assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 2);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, player).map(choice => choice.id), player.leaders.map(leader => leader.id));
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, repeated.id), repeated);
|
||||
assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), 2);
|
||||
assert.deepEqual(context, before);
|
||||
|
||||
player.revivalCycle = 2;
|
||||
for (const leader of player.leaders.slice(1)) { leader.dead = false; leader.deaths = 1; }
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, repeated.id), repeated,
|
||||
'An already opened second cycle survives the other leaders returning alive');
|
||||
});
|
||||
|
||||
void test('unavailable captured and foreign-ghola roster members retain the ordinary cohort exception', () => {
|
||||
const { context, player } = cohortFixture('atreides', true);
|
||||
const repeated = player.leaders[0];
|
||||
repeated.dead = true; repeated.deaths = 2;
|
||||
player.revivalCycle = 1;
|
||||
for (const leader of player.leaders.slice(1)) {
|
||||
leader.deaths = 1;
|
||||
leader.capturedBy = 'other';
|
||||
}
|
||||
delete player.leaders[4].capturedBy;
|
||||
player.leaders[4].gholaBy = 'other';
|
||||
assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 2);
|
||||
assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, [repeated.id]);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, repeated.id), repeated);
|
||||
for (const leader of player.leaders.slice(1))
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, leader.id), null);
|
||||
});
|
||||
|
||||
void test('Ambassador repeats ignore ordinary phase slot price and prevention without importing early service', () => {
|
||||
for (const faction of ['emperor', 'tleilaxu'] as const) {
|
||||
const { context, player } = cohortFixture(faction);
|
||||
player.revivalCycle = 2;
|
||||
player.leaderRevived = true;
|
||||
for (const leader of player.leaders) { leader.dead = true; leader.deaths = 2; }
|
||||
const expected = quoteLeaderRevivalCohort(context, player);
|
||||
for (const phase of [0, 2, 4, 5, 9]) {
|
||||
const gated = { ...context, phase, revivalPrevention: { turn: 3, player: player.id } };
|
||||
const before = structuredClone(gated);
|
||||
assert.deepEqual(quoteLeaderRevivalCohort(gated, player), expected);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(gated, player, player.leaders[0].id), player.leaders[0]);
|
||||
assert.equal(tleilaxuAmbassadorLeaderOpening(gated, player), 2);
|
||||
assert.equal(player.spice, 0);
|
||||
assert.equal(player.leaderRevived, true);
|
||||
assert.deepEqual(gated, before);
|
||||
}
|
||||
player.revivalCycle = 0;
|
||||
for (const leader of player.leaders.slice(1)) { leader.dead = false; leader.deaths = 0; }
|
||||
assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, []);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, player.leaders[0].id), null);
|
||||
assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), null);
|
||||
assert.equal(quoteLeaderRevivalCohort(context, player, true).cycle, 1,
|
||||
'The explicit absent-faction card waiver remains available to its original caller');
|
||||
assert.deepEqual(quoteLeaderRevivalCohort(context, player, true).leaders, [],
|
||||
'Even an explicit first-opening waiver does not waive a second death');
|
||||
}
|
||||
});
|
||||
|
||||
void test('opened native cohorts still reject foreign captured and foreign-ghola dead identities', () => {
|
||||
const { context, player } = cohortFixture();
|
||||
player.revivalCycle = 2;
|
||||
for (const leader of player.leaders) { leader.dead = true; leader.deaths = 2; }
|
||||
const captive = player.leaders[1], ghola = player.leaders[2];
|
||||
captive.capturedBy = 'other'; ghola.gholaBy = 'other';
|
||||
const foreign = leaders('harkonnen')[0];
|
||||
foreign.dead = true; foreign.deaths = 2;
|
||||
player.leaders[3] = foreign;
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, player).map(choice => choice.id),
|
||||
[player.leaders[0].id, player.leaders[4].id]);
|
||||
for (const leader of [captive, ghola, foreign])
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, leader.id), null);
|
||||
});
|
||||
|
||||
void test('Advanced Auditor repeated eligibility remains independent of the five ordinary CHOAM discs', () => {
|
||||
const { context, player } = cohortFixture('choam', true);
|
||||
const auditor = createAuditorLeader();
|
||||
auditor.dead = true; auditor.deaths = 4;
|
||||
player.leaders.push(auditor);
|
||||
const before = structuredClone(context);
|
||||
assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 0);
|
||||
assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, [auditor.id]);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, auditor.id), auditor);
|
||||
assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), null);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection({ ...context, advanced: false }, player, auditor.id), null);
|
||||
assert.deepEqual(context, before);
|
||||
|
||||
for (const leader of player.leaders.slice(0, 5)) { leader.dead = true; leader.deaths = 2; }
|
||||
auditor.dead = false;
|
||||
assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 2);
|
||||
assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, player.leaders.slice(0, 5).map(leader => leader.id));
|
||||
assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), 2,
|
||||
'A living or more often killed Auditor neither opens nor delays the ordinary cycle');
|
||||
});
|
||||
|
||||
void test('Ecaz repeated permission follows identity cohorts rather than native or Duke death-number equality', () => {
|
||||
const { context, player } = cohortFixture('ecaz');
|
||||
player.revivalCycle = 3;
|
||||
player.ecazRevivalCohort = { cycle: 3, returned: player.leaders.slice(0, 4).map(leader => leader.id) };
|
||||
for (const leader of player.leaders) { leader.dead = true; leader.deaths = 8; }
|
||||
const pending = player.leaders[4];
|
||||
pending.deaths = 2;
|
||||
context.dukeVidal!.leader.dead = true;
|
||||
context.dukeVidal!.leader.deaths = 7;
|
||||
const before = structuredClone(context);
|
||||
const quote = quoteLeaderRevivalCohort(context, player);
|
||||
assert.equal(quote.cycle, 3);
|
||||
assert.deepEqual(quote.leaders, [pending.id]);
|
||||
assert.equal(quote.dukeAvailable, true);
|
||||
assert.equal(quote.dukeBlocked, null);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, pending.id), pending);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, player.leaders[0].id), null);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, player).map(choice => choice.id), [pending.id, DUKE_VIDAL_ID]);
|
||||
assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), 3);
|
||||
assert.deepEqual(context, before);
|
||||
|
||||
player.ecazRevivalCohort.returned.push(pending.id);
|
||||
assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 4);
|
||||
assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, player.leaders.map(leader => leader.id));
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, pending.id), pending);
|
||||
assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), 4);
|
||||
});
|
||||
|
||||
void test('KH repeats require their own marker to be met by an actual ordinary cohort', () => {
|
||||
const { context, player } = cohortFixture('atreides', true);
|
||||
player.battleLosses = 7;
|
||||
for (const leader of player.leaders) { leader.dead = true; leader.deaths = 2; }
|
||||
player.kwisatz = { dead: true, revivalCycle: 2 };
|
||||
assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 2);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, 'kwisatz'), 'kwisatz');
|
||||
player.kwisatz.revivalCycle = 3;
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, 'kwisatz'), null);
|
||||
player.kwisatz.revivalCycle = 1;
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, 'kwisatz'), 'kwisatz');
|
||||
});
|
||||
|
||||
void test('missing ordinary cohort facts preserve first-death choices without inventing repeat permission', () => {
|
||||
const { context, player } = cohortFixture('ecaz');
|
||||
player.revivalCycle = 2;
|
||||
player.ecazRevivalCohort = { cycle: 2, returned: [] };
|
||||
for (const leader of player.leaders) { leader.dead = true; leader.deaths = 2; }
|
||||
const first = player.leaders[0], repeated = player.leaders[1];
|
||||
first.deaths = 1;
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, repeated.id), repeated,
|
||||
'The complete source actually permits this repeated identity');
|
||||
const contexts = [
|
||||
{ advanced: false },
|
||||
{ ...context, turn: undefined },
|
||||
{ ...context, players: [] },
|
||||
];
|
||||
for (const sparse of contexts) {
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(sparse, player, first.id), first);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(sparse, player, repeated.id), null);
|
||||
assert.equal(tleilaxuAmbassadorLeaderOpening(sparse, player), null);
|
||||
}
|
||||
const legacy = { ...player, revivalCycle: undefined };
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, legacy, first.id), first);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, legacy, repeated.id), null);
|
||||
assert.equal(tleilaxuAmbassadorLeaderOpening(context, legacy), null);
|
||||
});
|
||||
|
||||
void test('a non-Ecaz beneficiary or unseated Ecaz identity cannot inherit shared Duke revival', () => {
|
||||
const { context, player } = fixture();
|
||||
for (const faction of ['moritani', 'tleilaxu', 'emperor'] as const) {
|
||||
|
||||
Reference in new issue
Block a user