Track repeated Ecaz revival cohorts by native disc identity

This commit is contained in:
Quintine committed 2026-10-15 14:37:15 +11:00
1 parent 60f338e2e1
commit 559b42dd78
16 files changed
+645 -52

No files matched your search

-14
View File
@@ -422,20 +422,6 @@ void test('four native deaths plus Duke open Ecaz first cohort, which stays open
assert.equal(p(g, 'ec').leaders[4].dead, false);
assert.equal(g.dukeVidal!.controller, null);
});
void test('Ecaz native repeated six-disc cycles stay gated while Duke remains independently revivable', () => {
const g = fixture();
p(g, 'ec').leaders.forEach((l) => { l.dead = true; l.deaths = 1; });
p(g, 'ec').leaders[0].deaths = 2;
p(g, 'ec').spice = 10;
p(g, 'ec').revivalCycle = 1;
const options = viewGame(g, 'ec').revival;
assert.match(options.cycleBlock!, /six-disc repeated/);
assert.deepEqual(options.leaders.map((l) => l.id), [DUKE_VIDAL_ID]);
assert.equal(options.leaders[0].cost, 5);
const returned = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
assert.equal(p(returned, 'ec').revivalCycle, 1);
assert.equal(returned.dukeVidal!.leader.deaths, 1);
});
void test('saved discounted Duke request can be canceled back to five and paid once with income', () => {
let g = fixture(true);
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { createNativeEcazCohortFixture, createNativeEcazSecondCohortFixture } from './fixture-ecaz-revival-cohort';
import { assertNexusDukeCustody, nexusDukePlayer, stepNexusDuke } from './fixture-nexus-duke-capture';
void test('five original native deaths and a real repeat death leave the unreturned Ecaz cohort revivable', () => {
const f = createNativeEcazCohortFixture();
const owner = nexusDukePlayer(f.game, f.owner);
const first = owner.leaders.find(l => l.id === f.first.leader)!;
assert.equal(first.dead, true);
assert.equal(first.deaths, 2);
const remaining = owner.leaders.filter(l => l.id !== first.id);
const options = viewGame(f.game, f.owner).revival.leaders;
assert.deepEqual(options.map(l => l.id).sort(), remaining.map(l => l.id).sort());
const next = remaining.slice().sort((a, b) => a.strength - b.strength)[0];
const after = stepNexusDuke(f.game, f.plan, { actor: f.owner,
action: { type: 'reviveLeader', leader: next.id } }, f.actions);
const revived = nexusDukePlayer(after, f.owner);
assert.equal(revived.leaders.find(l => l.id === next.id)!.dead, false);
assert.equal(revived.leaders.find(l => l.id === first.id)!.dead, true);
assert.equal(revived.spice, owner.spice - next.strength);
assert.equal(revived.leaderRevived, true);
assert.equal(revived.leaders.length, 5);
assert.equal(after.dukeVidal!.leader.capturedBy, f.captor);
assert.equal(after.dukeVidal!.leader.dead, false);
assert.equal(after.dukeVidal!.leader.deaths, 0);
assertNexusDukeCustody(after);
});
void test('a second original Ecaz cohort opens with four repeated native deaths and the same Duke in Tanks', () => {
const f = createNativeEcazSecondCohortFixture();
const owner = nexusDukePlayer(f.game, f.owner);
const returned = f.deaths.map(d => d.leader);
const options = viewGame(f.game, f.owner).revival.leaders.filter(l => l.id !== f.game.dukeVidal!.leader.id);
assert.deepEqual(options.map(l => l.id).sort(), returned.slice().sort());
assert.equal(owner.leaders.find(l => !returned.includes(l.id))!.deaths, 0);
assert.equal(owner.leaders.find(l => !returned.includes(l.id))!.dead, false);
assert.ok(returned.every(id => owner.leaders.find(l => l.id === id)!.deaths === 2));
const selected = owner.leaders.find(l => l.id === returned[0])!;
const after = stepNexusDuke(f.game, f.plan, { actor: f.owner,
action: { type: 'reviveLeader', leader: selected.id } }, f.actions);
assert.equal(nexusDukePlayer(after, f.owner).leaders.find(l => l.id === selected.id)!.dead, false);
assert.equal(nexusDukePlayer(after, f.owner).spice, owner.spice - selected.strength);
assert.equal(nexusDukePlayer(after, f.owner).leaderRevived, true);
assert.equal(after.dukeVidal!.leader.dead, true);
assert.equal(after.dukeVidal!.leader.deaths, 1);
assert.equal(after.dukeVidal!.controller, null);
assertNexusDukeCustody(after);
});
+98
View File
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, newPlayer, viewGame } from '../game/engine';
import { createDukeVidal } from '../game/duke-vidal';
import { newRevivalRules } from '../game/revival';
import { baseDeck } from '../game/cards';
function boundary() {
const g = createGame('ECAZCOHORT', newPlayer('e', 'Ecaz', 'ecaz'), false, ['ecaz']);
g.players.push(newPlayer('m', 'Moritani', 'moritani'));
Object.assign(g, { status: 'playing', phase: 4, turn: 3, order: ['e', 'm'],
dukeVidal: createDukeVidal(), revivalRules: newRevivalRules() });
g.players[0].spice = 20;
return g;
}
function dealGhola(g: ReturnType<typeof boundary>) {
g.deck = baseDeck();
const index = g.deck.findIndex(card => card.effect === 'ghola');
assert.ok(index >= 0);
const [card] = g.deck.splice(index, 1);
g.players[0].hand.push(card);
return card;
}
void test('an Ecaz repeat death does not suppress other native first-cohort returns', () => {
const g = boundary();
const owner = g.players[0];
owner.spice = 20;
owner.revivalCycle = 1;
for (const leader of owner.leaders) { leader.dead = true; leader.deaths = 1; }
owner.leaders[0].deaths = 2;
const options = viewGame(g, owner.id).revival.leaders;
assert.deepEqual(options.map(l => l.id).sort(), owner.leaders.slice(1).map(l => l.id).sort());
const selected = owner.leaders[1];
const after = applyAction(g, owner.id, { type: 'reviveLeader', leader: selected.id });
assert.equal(after.players[0].leaders.find(l => l.id === selected.id)!.dead, false);
assert.equal(after.players[0].leaders[0].dead, true);
assert.equal(after.players[0].spice, owner.spice - selected.strength);
assert.equal(after.players[0].leaderRevived, true);
assert.equal(after.players[0].leaders.length, 5);
assert.equal(after.dukeVidal!.leader.dead, false);
});
void test('a late first death joins an open later cohort without matching its generation number', () => {
const g = boundary(), owner = g.players[0];
owner.revivalCycle = 3;
owner.ecazRevivalCohort = { cycle: 3, returned: owner.leaders.slice(0, 4).map(l => l.id) };
for (const leader of owner.leaders.slice(0, 4)) { leader.dead = true; leader.deaths = 4; }
const late = owner.leaders[4];
late.dead = true;
late.deaths = 1;
const options = viewGame(g, owner.id).revival.leaders;
assert.deepEqual(options.map(l => l.id), [late.id]);
const after = applyAction(g, owner.id, { type: 'reviveLeader', leader: late.id });
assert.equal(after.players[0].leaders.find(l => l.id === late.id)!.dead, false);
assert.equal(after.players[0].spice, owner.spice - late.strength);
assert.equal(after.players[0].leaders.filter(l => l.dead).length, 4);
});
void test('an actual native Ghola return spends no ordinary slot but cannot re-enter the same cohort after dying again', () => {
const g = boundary(), owner = g.players[0];
for (const leader of owner.leaders) { leader.dead = true; leader.deaths = 1; }
const card = dealGhola(g), first = owner.leaders[0];
const returned = applyAction(g, owner.id, { type: 'card', card: card.id, leader: first.id });
assert.equal(returned.players[0].spice, owner.spice);
assert.equal(returned.players[0].leaderRevived, false);
const repeated = returned.players[0].leaders[0];
repeated.dead = true;
repeated.deaths++;
const next = viewGame(returned, owner.id).revival.leaders;
assert.deepEqual(next.map(l => l.id).sort(), owner.leaders.slice(1).map(l => l.id).sort());
const second = owner.leaders[1];
const after = applyAction(returned, owner.id, { type: 'reviveLeader', leader: second.id });
assert.equal(after.players[0].leaders.find(l => l.id === second.id)!.dead, false);
assert.equal(after.players[0].leaders[0].dead, true);
assert.equal(after.players[0].spice, owner.spice - second.strength);
});
void test('an independent Duke Ghola preserves a real five-Tanks opening without aligning its repeated deaths', () => {
const g = boundary(), owner = g.players[0];
for (const leader of owner.leaders.slice(0, 4)) { leader.dead = true; leader.deaths = 1; }
g.dukeVidal!.leader.dead = true;
g.dukeVidal!.leader.deaths = 7;
const card = dealGhola(g);
const returned = applyAction(g, owner.id, { type: 'card', card: card.id, leader: g.dukeVidal!.leader.id });
assert.equal(returned.dukeVidal!.leader.dead, false);
assert.equal(returned.dukeVidal!.leader.deaths, 7);
assert.equal(returned.dukeVidal!.controller, null);
assert.equal(returned.players[0].leaderRevived, false);
assert.equal(returned.players[0].spice, owner.spice);
const options = viewGame(returned, owner.id).revival.leaders;
assert.deepEqual(options.map(l => l.id).sort(), owner.leaders.slice(0, 4).map(l => l.id).sort());
const native = owner.leaders[0];
const after = applyAction(returned, owner.id, { type: 'reviveLeader', leader: native.id });
assert.equal(after.players[0].leaders[0].dead, false);
assert.equal(after.players[0].spice, owner.spice - native.strength);
assert.equal(after.dukeVidal!.leader.deaths, 7);
});
+148
View File
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import { viewGame, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import {
advanceNexusDuke, assertNexusDukeCustody, closeNexusDukeDraw,
createNexusDukeCaptureFixture, createNexusDukeDeathFixture, nexusDukeClean, nexusDukePlayer,
openNexusDukeBattle, sealNexusDukeBattle, stepNexusDuke,
type NexusDukePlan, type NexusDukeStep,
} from './fixture-nexus-duke-capture';
type Transition = { before: Game; after: Game; leader: string };
export type NativeEcazCohortFixture = {
game: Game; owner: string; captor: string; plan: NexusDukePlan; actions: NexusDukeStep[];
deaths: Transition[]; funding: { before: Game; after: Game; amount: number };
first: Transition; repeat: Transition;
};
function poisonDeath(game: Game, context: Pick<NativeEcazCohortFixture, 'owner' | 'captor' | 'plan' | 'actions'>,
leader: string, territory = 'imperial_basin'): Transition {
const { owner, captor, plan, actions } = context;
// Choose after the real next Storm; a storm-separated target can otherwise
// skip that entire Battle phase and run this bounded chronology past turn ten.
if (game.phase > 6)
game = advanceNexusDuke(game, g => g.phase === 4 && nexusDukeClean(g), plan, actions);
if (territory === 'imperial_basin') {
const usable = (target: typeof TERRITORIES[number]) => target.type === 'sand' &&
!target.sectors.includes(game.storm) &&
!game.players.some(p => p.id !== owner && p.id !== captor &&
Object.entries(p.forces).some(([key, amount]) => amount > 0 && key.startsWith(`${target.id}:`)));
const target = TERRITORIES.find(t => t.id === territory && usable(t)) ?? TERRITORIES.find(usable);
assert.ok(target, 'The original board must offer an uncontested non-storm desert for both shipments.');
territory = target.id;
}
plan.ship = { [owner]: { territory, amount: 1 }, [captor]: { territory, amount: 1 } };
const battle = openNexusDukeBattle(game, plan, actions, { territory, first: owner, second: captor });
const poison = nexusDukePlayer(battle.game, captor).hand.find(c => c.kind === 'poison');
assert.ok(poison, 'The original winning Harkonnen hand retains its dealt poison.');
const enemy = nexusDukePlayer(battle.game, captor).leaders
.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
assert.ok(enemy);
const before = structuredClone(battle.game);
const revealed = sealNexusDukeBattle(battle.game, plan, actions, [
{ actor: owner, leader, dial: 0, support: 0 },
{ actor: captor, leader: enemy.id, dial: 0, support: 0, weapon: poison.id },
]);
const after = advanceNexusDuke(revealed, g => !g.battle && nexusDukeClean(g), plan, actions);
assert.equal(nexusDukePlayer(after, owner).leaders.find(l => l.id === leader)!.dead, true);
assertNexusDukeCustody(after);
return { before, after, leader };
}
/** No death/history/hand/wallet assignment: original setup deal, five native
* poison deaths, a paid return and that same disc's real repeat death. */
export function createNativeEcazCohortFixture(): NativeEcazCohortFixture {
const f = createNexusDukeCaptureFixture({ variant: 'unused', deal: faction =>
faction === 'harkonnen' ? ['worthless', 'poison'] : ['worthless'] });
const { owner, captor, plan, actions } = f;
plan.capture = 'decline';
const closing = closeNexusDukeDraw(f.game, plan, actions);
const native = nexusDukePlayer(closing.game, owner).leaders
.slice().sort((a, b) => a.strength - b.strength).map(l => l.id);
let game = closing.game;
const kill = (leader: string): Transition => {
const result = poisonDeath(game, f, leader);
game = result.after;
return result;
};
const deaths = native.map(kill);
assert.ok(nexusDukePlayer(game, owner).leaders.every(l => l.dead && l.deaths === 1));
game = advanceNexusDuke(game, g => g.phase === 7 && nexusDukeClean(g), plan, actions);
const fundingBefore = structuredClone(game), amount = 15;
assert.ok(nexusDukePlayer(game, captor).spice >= amount);
game = stepNexusDuke(game, plan, { actor: captor,
action: { type: 'bribe', target: owner, amount } }, actions);
const funding = { before: fundingBefore, after: structuredClone(game), amount };
game = advanceNexusDuke(game, g => g.phase === 4 && nexusDukeClean(g), plan, actions);
const firstBefore = structuredClone(game);
const firstLeader = native[0];
assert.ok(viewGame(game, owner).revival.leaders.some(l => l.id === firstLeader));
game = stepNexusDuke(game, plan, { actor: owner,
action: { type: 'reviveLeader', leader: firstLeader } }, actions);
const first = { before: firstBefore, after: structuredClone(game), leader: firstLeader };
const repeat = kill(firstLeader);
game = advanceNexusDuke(game, g => g.phase === 4 && nexusDukeClean(g), plan, actions);
plan.ship = {};
assert.equal(nexusDukePlayer(game, owner).leaders.find(l => l.id === firstLeader)!.deaths, 2);
assertNexusDukeCustody(game);
return { game, owner, captor, plan, actions, deaths, funding, first, repeat };
}
export type NativeEcazSecondCohortFixture = Pick<NativeEcazCohortFixture, 'game' | 'owner' | 'captor' | 'plan' | 'actions'> & {
dukeDeath: Game; deaths: Transition[]; paid: Transition[]; repeats: Transition[];
ghola: Transition; funding: { before: Game; after: Game; amount: number };
};
/** The original Duke and four native discs die; three native paid returns and
* one actual Ghola complete the cohort. All four then really die again, within
* the original ten-turn limit. The never-killed fifth native remains alive. */
export function createNativeEcazSecondCohortFixture(): NativeEcazSecondCohortFixture {
const death = createNexusDukeDeathFixture({ deal: faction => faction === 'ecaz' ? ['ghola']
: faction === 'harkonnen' ? ['worthless', 'poison'] : ['worthless'] });
const { owner, captor, plan, actions } = death;
plan.capture = 'decline';
const native = nexusDukePlayer(death.window, owner).leaders
.slice().sort((a, b) => a.strength - b.strength).map(l => l.id);
let game = death.window;
const deaths: Transition[] = [];
for (let i = 0; i < 4; i++) {
const result = poisonDeath(game, death, native[i], i === 0 ? 'carthag' : 'imperial_basin');
deaths.push(result);
game = result.after;
}
game = advanceNexusDuke(game, g => g.phase === 7 && nexusDukeClean(g), plan, actions);
const fundingBefore = structuredClone(game), amount = 25;
assert.ok(nexusDukePlayer(game, captor).spice >= amount);
game = stepNexusDuke(game, plan, { actor: captor,
action: { type: 'bribe', target: owner, amount } }, actions);
const funding = { before: fundingBefore, after: structuredClone(game), amount };
const paid: Transition[] = [], repeats: Transition[] = [];
let ghola!: Transition;
for (let i = 0; i < 3; i++) {
game = advanceNexusDuke(game, g => g.phase === 4 && nexusDukeClean(g), plan, actions);
const before = structuredClone(game);
game = stepNexusDuke(game, plan, { actor: owner,
action: { type: 'reviveLeader', leader: native[i] } }, actions);
paid.push({ before, after: structuredClone(game), leader: native[i] });
if (i === 2) {
const card = nexusDukePlayer(game, owner).hand.find(c => c.effect === 'ghola');
assert.ok(card);
const before = structuredClone(game);
game = stepNexusDuke(game, plan, { actor: owner,
action: { type: 'card', card: card.id, leader: native[3] } }, actions);
ghola = { before, after: structuredClone(game), leader: native[3] };
}
const repeated = poisonDeath(game, death, native[i]);
repeats.push(repeated);
game = repeated.after;
}
const last = poisonDeath(game, death, native[3]);
repeats.push(last);
game = advanceNexusDuke(last.after, g => g.phase === 4 && nexusDukeClean(g), plan, actions);
plan.ship = {};
assert.equal(nexusDukePlayer(game, owner).leaders.find(l => l.id === native[4])!.dead, false);
assert.equal(game.dukeVidal!.leader.dead, true);
assert.equal(game.dukeVidal!.leader.deaths, 1);
assertNexusDukeCustody(game);
return { game, owner, captor, plan, actions, dukeDeath: death.game, deaths, paid, repeats, ghola, funding };
}
+17
View File
@@ -152,6 +152,23 @@ void test('the roster waiver belongs to the card alone and never opens the ordin
);
});
void test('the Tleilaxu waiver reaches an unopened saved Ecaz cohort without reopening a previously returned disc', () => {
const g = fixture(false, 'ecaz'), p = g.players[0];
g.dukeVidal = createDukeVidal();
kill(p, [0], 2);
kill(p, [1]);
p.ecazRevivalCohort = { cycle: 0, returned: [p.leaders[0].id] };
const before = structuredClone(g);
assert.deepEqual(leaderRevivalOptions(g, p).leaders, []);
assert.deepEqual(tleilaxuSecretAllyOptions(g, p).leaders.map(l => l.id), [p.leaders[1].id]);
const quote = quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[1].id, amount: 0, elite: 0 });
assert.equal(quote.kind, 'leader');
assert.equal(quote.cost, 0);
assert.throws(() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 0, elite: 0 }));
assert.deepEqual(leaderRevivalOptions(g, p).leaders, []);
assert.deepEqual(g, before);
});
void test('an opened ordinary cohort keeps canonical eligibility and is never marked early', () => {
const g = fixture();
const p = g.players[0];