Let captured Leader Skill owners revive other leaders
User ruling 13 October 2026: the original faction keeps its existing Skill assignment while its trained leader is captured. Reviving a different eligible own leader is legal and simply draws no replacement card while that assignment exists (selected reading of authorized supplied Advanced physical p21 "if you have no Leader Skill Card"). Not a new publisher ruling; captor lower-band use, card movement and death-return unchanged. Remove the replacement-question veto that suppressed the entire ordinary/Ghola revival: leaderSkillRevivalBlocked/requireLeaderSkillRevival, the gholaLeaders early return, the view revival.leaders: [] filter and the beginRevival/Ghola/ Emperor-special-Karama call sites are gone. offerRevivedLeaderSkill is unchanged and still suppresses a second assignment or draw. Rewrote the two committed rejection assertions (tests/advanced-native-skills.test.ts, tests/leader-skills-capture.test.ts) and added native regressions covering ordinary paid, held-Ghola and Emperor special Karama revival from a genuinely captured skilled state: exact cost, no draw or offer, retained captive/card/controller and a conserved all14 deck. Docs: RULE_DECISIONS, LEADER_SKILLS_CAPTURE, LEADER_SKILLS_RULES, LEADER_SKILLS_RUNTIME, NEXUS_CARD_RULES, CURRENT_STATUS, IMPLEMENTATION_STATUS and the game/reference.ts leader-skills topic.
This commit is contained in:
1 parent
ea12a68e56
commit
adb00358dd
16 files changed
+852
-77
No files matched your search
@@ -243,7 +243,7 @@ void test('Advanced native admission assigns Atreides Suk and rejects a forged I
|
||||
reject(game, actor, { type: 'ready' });
|
||||
});
|
||||
|
||||
void test('captured native trainer blocks replacement revival, retaining the physical skill and Tanks leader', () => {
|
||||
void test('a captured native trainer keeps its physical skill while another own leader revives with no replacement offer', () => {
|
||||
let game = completedAdvancedNativeSkillsGame({ family: 'ixians', requestedSkill: 'warmaster', opponents: ['harkonnen', 'emperor'] });
|
||||
game = advanceAdvancedNativeSkillsToPhase(game, 4);
|
||||
const owner = player(game, 'ixians');
|
||||
@@ -254,10 +254,21 @@ void test('captured native trainer blocks replacement revival, retaining the phy
|
||||
leader.dead = true;
|
||||
leader.deaths = 1;
|
||||
} // Controlled captured trainer and all other native leaders in Tanks.
|
||||
owner.spice = 20;
|
||||
owner.leaderRevived = false;
|
||||
// User ruling 13 October 2026: the original assignment survives the capture;
|
||||
// only the replacement draw is suppressed, so the ordinary revival is legal.
|
||||
assert.ok(viewGame(game, owner.id).revival.leaders.some(l => l.id === dead.id));
|
||||
const skills = structuredClone(game.leaderSkills);
|
||||
reject(game, owner.id, { type: 'reviveLeader', leader: dead.id }, /replacement-skill ruling/);
|
||||
assert.equal(dead.dead, true);
|
||||
assert.equal(trained.capturedBy, player(game, 'harkonnen').id);
|
||||
assert.deepEqual(game.leaderSkills, skills);
|
||||
custody(game);
|
||||
let revived = applyAction(game, owner.id, { type: 'reviveLeader', leader: dead.id });
|
||||
while (revived.response) {
|
||||
const responder = revived.players.find(p => !revived.response!.passed.includes(p.id));
|
||||
assert.ok(responder);
|
||||
revived = applyAction(revived, responder.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(player(revived, 'ixians').leaders.find(l => l.id === dead.id)!.dead, false);
|
||||
assert.equal(player(revived, 'ixians').leaders.find(l => l.id === trained.id)!.capturedBy, player(revived, 'harkonnen').id);
|
||||
assert.deepEqual(revived.leaderSkills, skills);
|
||||
assert.equal(revived.leaderSkills!.offers[owner.id], undefined);
|
||||
custody(revived);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
assertCapturedSkillInventory, capturedSkillPlayer, createCapturedSkillRevivalFixture,
|
||||
} from './fixture-captured-skill-revival';
|
||||
|
||||
/** Program-level BEFORE. On the pre-cutover revision (HEAD before this change)
|
||||
* `tests/advanced-native-skills.test.ts` and `tests/leader-skills-capture.test.ts`
|
||||
* asserted that a faction whose original Leader Skill card was captured could not
|
||||
* revive a different own leader at all (`/replacement-skill ruling/`), and the
|
||||
* projected revival/Ghola controls hid every own-leader target. The ruling keeps
|
||||
* the original assignment with the living captive and lets others revive normally.
|
||||
* Physical p21 makes a replacement draw conditional on having no Skill Card;
|
||||
* `offerRevivedLeaderSkill` already suppresses that draw while an assignment exists. */
|
||||
const retainedAssignment = (game: Game) =>
|
||||
viewGame(game, 'a').leaderSkills!.assignments.find(entry => entry.owner === 'a')!;
|
||||
/** The captured original card stays one unique assignment owned by Atreides and
|
||||
* controlled by Harkonnen; only its top effect is unavailable to the captor. */
|
||||
function assertRetainedCaptiveAssignment(game: Game, skill: string): void {
|
||||
const entry = retainedAssignment(game);
|
||||
assert.equal(entry.owner, 'a');
|
||||
assert.equal(entry.leader, 'atreides-1');
|
||||
assert.equal(entry.skill, skill);
|
||||
assert.equal(entry.controller, 'h');
|
||||
assert.equal(entry.captured, true);
|
||||
assert.equal(entry.faceUp, false);
|
||||
}
|
||||
|
||||
void test('the ordinary paid revival of another own leader leaves the captured original skill card in place', () => {
|
||||
const fixture = createCapturedSkillRevivalFixture();
|
||||
const before = fixture.game;
|
||||
const view = viewGame(before, fixture.actor);
|
||||
const offered = view.revival.leaders.find(entry => entry.id === fixture.dead);
|
||||
assert.ok(offered, 'The real cycle must offer the untrained dead leader');
|
||||
assert.equal(offered.normalCost, fixture.deadCost);
|
||||
assert.equal(offered.cost, fixture.deadCost);
|
||||
assert.equal(offered.affordable, true);
|
||||
const viewIndex = view.players.findIndex(player => player.id === fixture.actor);
|
||||
view.players[viewIndex].bot = 'Easy';
|
||||
// The existing policy surfaces the real own-leader target. Its Revival-phase
|
||||
// priority keeps the paid/free revival actions ahead of the standalone Ghola
|
||||
// suggestion, so only the ordinary target is a policy recommendation here.
|
||||
assert.ok(botActions(view).some(action => action.type === 'reviveLeader' && action.leader === fixture.dead));
|
||||
const spice = capturedSkillPlayer(before, fixture.actor).spice;
|
||||
const deck = [...before.leaderSkills!.deck];
|
||||
const after = applyAction(before, fixture.actor, { type: 'reviveLeader', leader: fixture.dead });
|
||||
const revived = capturedSkillPlayer(after, fixture.actor).leaders.find(disc => disc.id === fixture.dead)!;
|
||||
assert.equal(revived.dead, false);
|
||||
assert.equal(revived.deaths, 1, 'The genuine death count survives the ordinary return');
|
||||
assert.equal(capturedSkillPlayer(after, fixture.actor).spice, spice - fixture.deadCost);
|
||||
assert.equal(capturedSkillPlayer(after, fixture.actor).leaders.find(disc => disc.id === 'atreides-2')!.dead, true);
|
||||
assert.equal(capturedSkillPlayer(after, fixture.actor).leaders.find(disc => disc.id === fixture.captive)!.capturedBy, 'h');
|
||||
assert.deepEqual([...after.leaderSkills!.deck], deck, 'No replacement Leader Skill card was drawn');
|
||||
assert.equal(after.leaderSkills!.offers[fixture.actor], undefined, 'No replacement offer was opened');
|
||||
assert.deepEqual(after.leaderSkills!.assignments, before.leaderSkills!.assignments);
|
||||
assertRetainedCaptiveAssignment(after, fixture.assignment.skill);
|
||||
assertCapturedSkillInventory(after);
|
||||
});
|
||||
|
||||
void test('the held physical Ghola revives another own leader for free from the same captured state', () => {
|
||||
const fixture = createCapturedSkillRevivalFixture();
|
||||
const before = fixture.game;
|
||||
const spice = capturedSkillPlayer(before, fixture.actor).spice;
|
||||
const deck = [...before.leaderSkills!.deck];
|
||||
const after = applyAction(before, fixture.actor, { type: 'card', card: fixture.ghola, leader: fixture.dead });
|
||||
const revived = capturedSkillPlayer(after, fixture.actor).leaders.find(disc => disc.id === fixture.dead)!;
|
||||
assert.equal(revived.dead, false);
|
||||
assert.equal(capturedSkillPlayer(after, fixture.actor).spice, spice, 'Ghola spends no spice and no allowance');
|
||||
assert.equal(capturedSkillPlayer(after, fixture.actor).hand.some(card => card.id === fixture.ghola), false,
|
||||
'The physical Ghola card is consumed');
|
||||
assert.deepEqual([...after.leaderSkills!.deck], deck);
|
||||
assert.equal(after.leaderSkills!.offers[fixture.actor], undefined);
|
||||
assert.deepEqual(after.leaderSkills!.assignments, before.leaderSkills!.assignments);
|
||||
assertRetainedCaptiveAssignment(after, fixture.assignment.skill);
|
||||
assertCapturedSkillInventory(after);
|
||||
});
|
||||
|
||||
void test('captive, wrong-actor, cross-phase, second-leader and consumed-Ghola attempts stay rejected', () => {
|
||||
const fixture = createCapturedSkillRevivalFixture();
|
||||
const before = fixture.game;
|
||||
const snapshot = structuredClone(before);
|
||||
// The living captive is not a revival target for its owner.
|
||||
assert.throws(() => applyAction(before, fixture.actor, { type: 'reviveLeader', leader: fixture.captive }));
|
||||
assert.deepEqual(before, snapshot);
|
||||
// Another faction cannot revive someone else's leader.
|
||||
assert.throws(() => applyAction(before, fixture.observer, { type: 'reviveLeader', leader: fixture.dead }));
|
||||
assert.deepEqual(before, snapshot);
|
||||
// A different phase cannot open the ordinary cycle.
|
||||
assert.throws(() => applyAction(fixture.afterSetup, fixture.actor, { type: 'reviveLeader', leader: fixture.dead }));
|
||||
assert.ok(fixture.afterSetup.phase !== 4);
|
||||
// One ordinary leader revival per phase; a second ordinary request is stale.
|
||||
const revived = applyAction(before, fixture.actor, { type: 'reviveLeader', leader: fixture.dead });
|
||||
const revivedSnapshot = structuredClone(revived);
|
||||
assert.throws(() => applyAction(revived, fixture.actor, { type: 'reviveLeader', leader: 'atreides-2' }));
|
||||
assert.deepEqual(revived, revivedSnapshot);
|
||||
// A consumed Ghola cannot revive again.
|
||||
const gholaGame = applyAction(before, fixture.actor, { type: 'card', card: fixture.ghola, leader: fixture.dead });
|
||||
assert.throws(() => applyAction(gholaGame, fixture.actor, { type: 'card', card: fixture.ghola, leader: 'atreides-2' }));
|
||||
assertCapturedSkillInventory(gholaGame);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { leaderRevivalOptions } from '../game/revival';
|
||||
import {
|
||||
advanceCapturedSkillSpecial, capturedSkillSpecialClean,
|
||||
capturedSkillSpecialInventory, capturedSkillSpecialPlayer as player,
|
||||
capturedSkillSpecialStock, createCapturedSkillSpecialRevivalFixture,
|
||||
type CapturedSkillSpecialStep,
|
||||
} from './fixture-captured-skill-special-revival';
|
||||
|
||||
void test('native Emperor Special revives a different own dead disc without replacing the captive Skill Card', () => {
|
||||
const f = createCapturedSkillSpecialRevivalFixture();
|
||||
const before = structuredClone(f.game);
|
||||
const emperor = player(before, 'e');
|
||||
const dead = emperor.leaders.find(l => l.id === f.dead)!;
|
||||
const captive = emperor.leaders.find(l => l.id === f.captive)!;
|
||||
assert.equal(dead.dead, true);
|
||||
assert.equal(dead.deaths, 1);
|
||||
assert.equal(dead.capturedBy, undefined);
|
||||
assert.equal(captive.dead, false);
|
||||
assert.equal(captive.capturedBy, 'h');
|
||||
assert.equal(emperor.leaderRevived, false);
|
||||
assert.equal(emperor.revived, 0);
|
||||
assert.equal(emperor.specialKaramaUsed, undefined);
|
||||
assert.equal(before.turn, 2);
|
||||
assert.equal(before.phase, 4);
|
||||
assert.ok(capturedSkillSpecialClean(before));
|
||||
assert.equal(player(f.setup, 'h').forces['carthag:11'], 10);
|
||||
assert.equal(player(f.afterShipment, 'e').forces['carthag:11'], 2);
|
||||
assert.equal(player(f.afterShipment, 'e').spice, player(f.beforeShipment, 'e').spice - 2);
|
||||
assert.ok(f.actions.some(step => step.actor === 'h' && step.action.type === 'decision' && step.action.mode === 'keep'));
|
||||
|
||||
const view = viewGame(before, 'e');
|
||||
const original = view.leaderSkills!.assignments.find(a => a.owner === 'e')!;
|
||||
assert.deepEqual(original, { skill: 'sandmaster', leader: f.captive, owner: 'e',
|
||||
controller: 'h', captured: true, faceUp: false });
|
||||
assert.equal(view.leaderSkills!.offer, null);
|
||||
assert.deepEqual(view.revival.leaders, leaderRevivalOptions(before, emperor).leaders,
|
||||
'Ordinary cycle eligibility remains native; Special uses the original dead roster, not a fabricated ordinary menu');
|
||||
assert.equal(view.players.find(p => p.id === 'e')!.leaders.find(l => l.id === f.dead)!.dead, true);
|
||||
assert.equal(view.players.find(p => p.id === 'e')!.leaders.find(l => l.id === f.captive)!.capturedBy, 'h');
|
||||
view.players.find(p => p.id === 'e')!.bot = 'Easy';
|
||||
const recommendations = botActions(view);
|
||||
assert.equal(recommendations.length, 1);
|
||||
assert.deepEqual(recommendations[0], f.action,
|
||||
'Existing Easy policy already selects the one legal printed Special; no policy strategy is introduced');
|
||||
|
||||
assert.throws(() => applyAction(before, 'e', { ...f.action, leader: f.captive }), /dead leaders/);
|
||||
assert.throws(() => applyAction(before, 'e', { ...f.action, target: 'h' }), /own forces or leaders/);
|
||||
assert.throws(() => applyAction(f.beforeBattle, 'e', f.action), /during Revival/);
|
||||
assert.throws(() => applyAction(f.initial, 'e', f.action), /setup|advanced game|phase opening/i);
|
||||
assert.throws(() => applyAction(before, 'e', { ...f.action, card: f.weapon }), /Karama card in your hand/);
|
||||
assert.throws(() => applyAction(before, 'h', f.action), /Karama card in your hand/);
|
||||
assert.throws(() => applyAction(before, 'e', { ...f.action, amount: 1 }), /forces or one leader/);
|
||||
assert.deepEqual(f.game, before, 'Rejected selections cannot consume the original right or physical card');
|
||||
|
||||
// This printed Special is immediate: it creates no generic paid-revival
|
||||
// stop/cancellation observer or second suffix. One real declaration is enough.
|
||||
const after = applyAction(before, 'e', recommendations[0]);
|
||||
const revived = player(after, 'e');
|
||||
assert.equal(revived.leaders.find(l => l.id === f.dead)!.dead, false);
|
||||
assert.equal(revived.leaders.find(l => l.id === f.dead)!.deaths, dead.deaths);
|
||||
assert.equal(revived.spice, emperor.spice, 'Full printed free revival never debits the leader strength');
|
||||
assert.equal(revived.specialKaramaUsed, true);
|
||||
assert.equal(revived.leaderRevived, emperor.leaderRevived);
|
||||
assert.equal(revived.revived, emperor.revived);
|
||||
assert.equal(revived.tanks, emperor.tanks);
|
||||
assert.equal(revived.reserves, emperor.reserves);
|
||||
assert.deepEqual(revived.forces, emperor.forces);
|
||||
assert.deepEqual(revived.elites, emperor.elites);
|
||||
assert.equal(revived.hand.filter(c => c.id === f.karama).length, 0);
|
||||
assert.equal(after.discard.filter(c => c.id === f.karama).length, 1);
|
||||
assert.deepEqual(capturedSkillSpecialStock(after), f.stock);
|
||||
assert.deepEqual(after.leaderSkills, before.leaderSkills,
|
||||
'One unique original assignment and unchanged physical all14 deck: no replacement offer or draw');
|
||||
assert.deepEqual(revived.leaders.find(l => l.id === f.captive), captive);
|
||||
assert.deepEqual(viewGame(after, 'e').leaderSkills!.assignments.find(a => a.owner === 'e'), original);
|
||||
assert.equal(viewGame(after, 'e').leaderSkills!.offer, null);
|
||||
assert.equal(after.pendingRevival, undefined);
|
||||
assert.equal(after.response, null);
|
||||
assert.equal(after.decision, null);
|
||||
assert.equal(after.pendingTreacheryDiscard, undefined);
|
||||
assert.equal(after.phase, before.phase);
|
||||
assert.equal(after.turn, before.turn);
|
||||
const receipt = `Emperor used special Karama to revive ${dead.name} for free.`;
|
||||
assert.equal(after.log.filter(entry => entry.text === receipt).length, 1);
|
||||
capturedSkillSpecialInventory(after);
|
||||
assert.throws(() => applyAction(after, 'e', f.action), /already been used/);
|
||||
assert.throws(() => applyAction(after, 'e', { ...f.action, amount: 1, leader: undefined }), /already been used/);
|
||||
assert.equal(botActions(viewGame(after, 'e')).filter(a => a.type === 'card' && a.mode === 'special').length, 0);
|
||||
|
||||
const suffix: CapturedSkillSpecialStep[] = [];
|
||||
const continued = advanceCapturedSkillSpecial(after,
|
||||
g => g.phase === 5 && capturedSkillSpecialClean(g), suffix);
|
||||
assert.equal(continued.turn, 2);
|
||||
assert.equal(continued.phase, 5);
|
||||
assert.ok(suffix.length > 0);
|
||||
assert.ok(suffix.every(step => step.action.type !== 'card'));
|
||||
assert.equal(continued.log.filter(entry => entry.text === receipt).length, 1);
|
||||
assert.equal(continued.discard.filter(c => c.id === f.karama).length, 1);
|
||||
assert.deepEqual(continued.leaderSkills, before.leaderSkills);
|
||||
assert.deepEqual(player(continued, 'e').leaders.find(l => l.id === f.captive), captive);
|
||||
assert.deepEqual(capturedSkillSpecialStock(continued), f.stock);
|
||||
capturedSkillSpecialInventory(continued);
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, battles, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import type { Card, SpiceCard } from '../game/cards';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { nextClassicSkillsStrongholdStep } from './fixture-classic-skills-stronghold';
|
||||
|
||||
export type CapturedSkillRevivalStep = { actor: string; action: Action };
|
||||
|
||||
export function capturedSkillPlayer(game: Game, actor: string): Player {
|
||||
const player = game.players.find(seat => seat.id === actor);
|
||||
assert.ok(player, `Missing original classic seat ${actor}`);
|
||||
return player;
|
||||
}
|
||||
/** The real windows that must be closed before a new native boundary is meaningful. */
|
||||
export function capturedSkillClean(game: Game): boolean {
|
||||
return !game.phaseOpening && !game.response && !game.decision &&
|
||||
!game.pendingTreacheryDiscard && !game.pendingRevival;
|
||||
}
|
||||
|
||||
/** Entropy control is confined to the original fourteen-card Leader Skill
|
||||
* shuffle. Every other native random request keeps its own behavior. */
|
||||
function shuffledOffers<T>(selected: LeaderSkillId[], run: () => T): T {
|
||||
const working: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id);
|
||||
const remainder = working.filter(skill => !selected.includes(skill));
|
||||
const desired = selected.flatMap(skill => [skill, remainder.shift()!]);
|
||||
desired.push(...remainder);
|
||||
const rolls: number[] = [];
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const j = working.indexOf(desired[i]);
|
||||
assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (cursor < rolls.length) {
|
||||
assert.ok(array instanceof Uint32Array && array.length === 1);
|
||||
array[0] = rolls[cursor++];
|
||||
} else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the native continuation the shared fixture already models is consumed. */
|
||||
export function advanceCapturedSkill(game: Game, until: (state: Game) => boolean,
|
||||
actions: CapturedSkillRevivalStep[] = []): Game {
|
||||
let current = game;
|
||||
for (let n = 0; n < 1200; n++) {
|
||||
if (until(current)) return current;
|
||||
assert.notEqual(current.status, 'finished');
|
||||
const next = nextClassicSkillsStrongholdStep(current);
|
||||
assert.ok(next, 'The original continuation stopped at an unsealed human plan');
|
||||
actions.push(structuredClone(next));
|
||||
current = applyAction(current, next.actor, next.action);
|
||||
}
|
||||
throw Error('The original captive continuation did not reach its boundary');
|
||||
}
|
||||
/** Conserve original on-board counters into their own reserves; no subtype total changes. */
|
||||
function reserveBoard(game: Game, staging: string[]): void {
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
|
||||
player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
|
||||
player.elites.forces = {};
|
||||
}
|
||||
if (player.advisors) player.advisors = {};
|
||||
}
|
||||
staging.push('Conserved every original on-board counter into its own reserves before the labeled placements.');
|
||||
}
|
||||
function place(game: Game, actor: string, location: string, count: number): void {
|
||||
const player = capturedSkillPlayer(game, actor);
|
||||
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= count, 'Stage ordinary physical counters only');
|
||||
player.reserves -= count;
|
||||
player.forces[location] = (player.forces[location] ?? 0) + count;
|
||||
}
|
||||
function sectorOf(game: Game, id: string): number {
|
||||
const sector = territory(id).sectors.find(value => value !== 0 && value !== game.storm);
|
||||
assert.ok(sector !== undefined, `The original ${id} sector must stay out of the storm`);
|
||||
return sector;
|
||||
}
|
||||
function holdUnplayed(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[]): string {
|
||||
const owner = capturedSkillPlayer(game, actor);
|
||||
const held = owner.hand.find(matches);
|
||||
if (held) return held.id;
|
||||
const index = game.deck.findIndex(matches);
|
||||
const donor = index < 0 ? game.players.find(seat => seat.id !== actor && seat.hand.some(matches)) : undefined;
|
||||
assert.ok(index >= 0 || donor, 'The required original card must still be unplayed');
|
||||
assert.ok(owner.hand.length < handLimit(owner));
|
||||
const card = index >= 0 ? game.deck.splice(index, 1)[0]
|
||||
: donor!.hand.splice(donor!.hand.findIndex(matches), 1)[0];
|
||||
owner.hand.push(card);
|
||||
staging.push(`Conserved original unplayed ${card.id}: ${donor?.id ?? 'deck'} → ${actor} hand`);
|
||||
return card.id;
|
||||
}
|
||||
/** Four original leader-killing weapons are conserved for the winner's plans.
|
||||
* The winner keeps its played weapon, so one physical projectile covers all four. */
|
||||
const BATTLE_SITES: { territory: string; atreides: string; harkonnen: string; capture?: boolean }[] = [
|
||||
{ territory: 'arsunt', atreides: 'atreides-2', harkonnen: 'harkonnen-0' },
|
||||
{ territory: 'tsimpo', atreides: 'atreides-3', harkonnen: 'harkonnen-1' },
|
||||
{ territory: 'south_mesa', atreides: 'atreides-4', harkonnen: 'harkonnen-2' },
|
||||
{ territory: 'arrakeen', atreides: 'atreides-0', harkonnen: 'harkonnen-4', capture: true },
|
||||
];
|
||||
|
||||
export function assertCapturedSkillInventory(game: Game): void {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
const physical = [
|
||||
...game.leaderSkills!.deck,
|
||||
...game.leaderSkills!.assignments.map(assignment => assignment.skill),
|
||||
...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards),
|
||||
];
|
||||
assert.equal(physical.length, 14);
|
||||
assert.equal(new Set(physical).size, 14);
|
||||
assert.deepEqual([...physical].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||
for (const player of game.players)
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20,
|
||||
'Every original physical counter stays in reserves, Tanks or on the board');
|
||||
}
|
||||
|
||||
/** Genuine classic Advanced setup, four actual Movement/Combat battles that kill
|
||||
* three untrained Atreides leaders plus one more in the capture battle, an actual
|
||||
* Harkonnen Capture offer with Keep, then the real next Revival phase. Only
|
||||
* labeled conserved board counters and unplayed cards are relocated; no death,
|
||||
* capture identity, assignment, phase, wallet or outcome is written. */
|
||||
export function createCapturedSkillRevivalFixture() {
|
||||
const actions: CapturedSkillRevivalStep[] = [], staging: string[] = [];
|
||||
let game = createGame('CAPTUREDSKILLREVIVAL', newPlayer('a', 'Atreides', 'atreides'), true);
|
||||
joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
||||
const act = (actor: string, action: Action): void => {
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
game = applyAction(game, actor, action);
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const selected: LeaderSkillId[] = ['sandmaster', 'smuggler', 'prana-bindu-adept'];
|
||||
game = shuffledOffers(selected, () => initializeLeaderSkillsGameForAudit(game));
|
||||
const initial = structuredClone(game);
|
||||
for (let n = 0; game.status === 'setup' && n < 200; n++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
||||
const offer = game.leaderSkills!.offers[actor];
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
const skill = selected[game.players.findIndex(player => player.id === actor)];
|
||||
assert.ok(view.offer && view.offer.cards.includes(skill));
|
||||
const leader = actor === 'a' ? 'atreides-1' : actor === 'h' ? 'harkonnen-3' : view.eligibleLeaders[0].id;
|
||||
assert.ok(capturedSkillPlayer(game, actor).leaders.some(disc => disc.id === leader));
|
||||
act(actor, { type: 'leaderSkill', event: offer.event, skill, leader });
|
||||
} else {
|
||||
const next = nextClassicSkillsStrongholdStep(game);
|
||||
assert.ok(next);
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
// Conserved original undealt Spice Card order for the two-turn suffix.
|
||||
const land = game.spiceDeck.filter(
|
||||
(card): card is Extract<SpiceCard, { territory: string }> => 'territory' in card);
|
||||
const front = land.slice(0, 4);
|
||||
game.spiceDeck = [...front, ...game.spiceDeck.filter(card => !front.includes(card as typeof front[number]))];
|
||||
staging.push('Ordered four conserved original unplayed non-worm Spice Cards; this is not a natural shuffle history');
|
||||
game = advanceCapturedSkill(game, state => state.phase === 5 && capturedSkillClean(state), actions);
|
||||
for (let n = 0; game.phase === 5 && game.active !== 'h' && n < 40; n++) {
|
||||
const next = nextClassicSkillsStrongholdStep(game);
|
||||
assert.ok(next);
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.phase, 5);
|
||||
assert.equal(game.active, 'h');
|
||||
reserveBoard(game, staging);
|
||||
act('h', { type: 'ship', territory: 'arrakeen', sector: sectorOf(game, 'arrakeen'), amount: 3 });
|
||||
staging.push('Harkonnen shipped three ordinary reserve counters into Arrakeen through the real Movement phase');
|
||||
const location = Object.fromEntries(BATTLE_SITES.map(site => [site.territory, `${site.territory}:${sectorOf(game, site.territory)}`]));
|
||||
for (const site of BATTLE_SITES) if (!site.capture) place(game, 'h', location[site.territory], 3);
|
||||
for (const site of BATTLE_SITES) place(game, 'a', location[site.territory], 3);
|
||||
staging.push('Conserved three ordinary counters per combatant at each of the four labeled battle sectors; no elite, wallet or phase assigned');
|
||||
const weapon = holdUnplayed(game, 'h', card => card.kind === 'projectile', staging);
|
||||
const ghola = holdUnplayed(game, 'a', card => card.effect === 'ghola', staging);
|
||||
game = advanceCapturedSkill(game, state => state.phase === 6 && capturedSkillClean(state) &&
|
||||
BATTLE_SITES.every(site => battles(state).some(entry => entry.territory === site.territory)), actions);
|
||||
let afterCapture: Game | undefined;
|
||||
for (const site of BATTLE_SITES) {
|
||||
const listing = battles(game).find(entry => entry.territory === site.territory);
|
||||
assert.ok(listing, `A real battle must remain open in ${site.territory}`);
|
||||
assert.equal(listing.chooser, game.active);
|
||||
act(listing.chooser, { type: 'chooseBattle', territory: site.territory,
|
||||
target: listing.attacker === listing.chooser ? listing.defender : listing.attacker });
|
||||
game = advanceCapturedSkill(game, state => !nextClassicSkillsStrongholdStep(state), actions);
|
||||
act('h', { type: 'battlePlan', leader: site.harkonnen, dial: 1, support: 0, weapon, defense: null });
|
||||
game = advanceCapturedSkill(game, state => !state.response && !state.phaseOpening && !state.decision, actions);
|
||||
act('a', { type: 'battlePlan', leader: site.atreides, dial: 0, support: 0, weapon: null, defense: null });
|
||||
game = advanceCapturedSkill(game, state => site.capture
|
||||
? state.decision?.kind === 'captureOffer'
|
||||
: !state.battle && capturedSkillClean(state), actions);
|
||||
if (!site.capture) continue;
|
||||
const offer = game.decision;
|
||||
assert.ok(offer && offer.kind === 'captureOffer');
|
||||
act('h', { type: 'decision', accept: true });
|
||||
for (let n = 0; game.response && n < 20; n++) {
|
||||
const remaining = game.players.find(player => !game.response!.passed.includes(player.id));
|
||||
assert.ok(remaining);
|
||||
act(remaining.id, { type: 'passResponse' });
|
||||
}
|
||||
const captured = game.decision;
|
||||
assert.ok(captured && captured.kind === 'capturedLeader');
|
||||
assert.equal(captured.leader, 'atreides-1');
|
||||
act('h', { type: 'decision', mode: 'keep' });
|
||||
game = advanceCapturedSkill(game, state => !state.battle && capturedSkillClean(state), actions);
|
||||
afterCapture = structuredClone(game);
|
||||
}
|
||||
assert.ok(afterCapture);
|
||||
game = advanceCapturedSkill(game, state => state.turn === 2 && state.phase === 4 && capturedSkillClean(state), actions);
|
||||
const assignment = game.leaderSkills!.assignments.find(entry => entry.owner === 'a');
|
||||
assert.ok(assignment);
|
||||
assert.equal(assignment.leader, 'atreides-1');
|
||||
assert.equal(capturedSkillPlayer(game, 'a').leaders.find(disc => disc.id === 'atreides-1')?.capturedBy, 'h');
|
||||
assert.equal(capturedSkillPlayer(game, 'a').leaders.find(disc => disc.id === assignment.leader)?.dead, false);
|
||||
assertCapturedSkillInventory(game);
|
||||
return { initial, afterSetup, afterCapture, game, actor: 'a', captor: 'h', observer: 'g',
|
||||
captive: 'atreides-1', dead: 'atreides-0', deadCost: 5, ghola, weapon,
|
||||
assignment: structuredClone(assignment), actions, staging };
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit,
|
||||
joinGame, newPlayer, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { Card } from '../game/cards';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { nextClassicNexusSkillsPaymentsNativeStep } from './fixture-classic-nexus-skills-payments';
|
||||
|
||||
export type CapturedSkillSpecialStep = { actor: string; action: Action };
|
||||
export const capturedSkillSpecialPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(p => p.id === actor);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
};
|
||||
export const capturedSkillSpecialClean = (game: Game) =>
|
||||
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Override only the requested original shuffle draws, forwarding every later draw. */
|
||||
function draws<T>(rolls: number[], operation: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (cursor < rolls.length) {
|
||||
assert.ok(array instanceof Uint32Array && array.length === 1);
|
||||
array[0] = rolls[cursor++];
|
||||
} else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
function skillRolls(selected: LeaderSkillId[]): number[] {
|
||||
const working = LEADER_SKILL_CARDS.map(c => c.id);
|
||||
const remainder = working.filter(skill => !selected.includes(skill));
|
||||
const desired = selected.flatMap(skill => [skill, remainder.shift()!]);
|
||||
desired.push(...remainder);
|
||||
const rolls: number[] = [];
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const j = working.indexOf(desired[i]);
|
||||
assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
return rolls;
|
||||
}
|
||||
export function capturedSkillSpecialStock(game: Game): string[] {
|
||||
return [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id).sort();
|
||||
}
|
||||
export function capturedSkillSpecialInventory(game: Game): void {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
assert.deepEqual([...game.leaderSkills!.deck,
|
||||
...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards),
|
||||
...game.leaderSkills!.assignments.map(assignment => assignment.skill)].sort(),
|
||||
LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||
}
|
||||
export function advanceCapturedSkillSpecial(state: Game, until: (game: Game) => boolean,
|
||||
actions: CapturedSkillSpecialStep[] = []): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 1200; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextClassicNexusSkillsPaymentsNativeStep(game);
|
||||
assert.ok(step, 'Only a real human battle plan may stop this native continuation');
|
||||
actions.push(structuredClone(step));
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('Native captured-skill Special continuation did not reach its boundary');
|
||||
}
|
||||
|
||||
/** Original Advanced classic E/H/G setup and paid deployment, original battle
|
||||
* plans, capture offer/random draw/Keep, and next-turn Revival. No board,
|
||||
* wallet, death, capture, assignment, phase or continuation is manufactured. */
|
||||
export function createCapturedSkillSpecialRevivalFixture() {
|
||||
const actions: CapturedSkillSpecialStep[] = [], staging: string[] = [];
|
||||
let game = createGame('CAPTUREDSKILLSPECIAL', newPlayer('e', 'Emperor', 'emperor'), true, []);
|
||||
joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
||||
const act = (actor: string, action: Action) => {
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
game = applyAction(game, actor, action);
|
||||
};
|
||||
for (const seat of game.players) act(seat.id, { type: 'ready' });
|
||||
const skills: LeaderSkillId[] = ['sandmaster', 'smuggler', 'prana-bindu-adept'];
|
||||
game = draws(skillRolls(skills), () => initializeLeaderSkillsGameForAudit(game));
|
||||
staging.push('Only original all14 Skill Card shuffle draws controlled; other initializer entropy native.');
|
||||
const initial = structuredClone(game);
|
||||
while (game.status === 'setup') {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
||||
const offer = game.leaderSkills!.offers[actor];
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
|
||||
const leaders = capturedSkillSpecialPlayer(game, actor).leaders.filter(l => eligible.has(l.id))
|
||||
.sort((a, b) => actor === 'h' ? a.strength - b.strength : b.strength - a.strength);
|
||||
const skill = skills[game.players.findIndex(p => p.id === actor)];
|
||||
assert.ok(offer.cards.includes(skill) && !view.unavailableSkills?.[skill]);
|
||||
act(actor, { type: 'leaderSkill', event: offer.event, skill, leader: leaders[0].id });
|
||||
} else {
|
||||
const step = nextClassicNexusSkillsPaymentsNativeStep(game);
|
||||
assert.ok(step);
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
}
|
||||
const setup = structuredClone(game);
|
||||
const stock = capturedSkillSpecialStock(game);
|
||||
const captive = game.leaderSkills!.assignments.find(a => a.owner === 'e')!.leader;
|
||||
assert.equal(capturedSkillSpecialPlayer(game, 'e').leaders.find(l => l.id === captive)!.name, 'Hasimir Fenring');
|
||||
// Unplayed original land cards avoid an unrelated first/second-turn Nexus.
|
||||
const land = game.spiceDeck.filter(c => 'territory' in c).slice(0, 4);
|
||||
assert.equal(land.length, 4);
|
||||
for (const [index, card] of land.entries())
|
||||
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
staging.push('Four conserved original unplayed land Spice Cards ordered before their native draws.');
|
||||
const hold = (actor: string, matches: (card: Card) => boolean): string => {
|
||||
const player = capturedSkillSpecialPlayer(game, actor);
|
||||
const held = player.hand.find(matches);
|
||||
if (held) return held.id;
|
||||
const index = game.deck.findIndex(matches);
|
||||
const donor = index < 0 ? game.players.find(p => p.id !== actor && p.hand.some(matches)) : undefined;
|
||||
assert.ok(index >= 0 || donor, 'Only original unused physical cards may change custody');
|
||||
assert.ok(player.hand.length < handLimit(player));
|
||||
const card = index >= 0 ? game.deck.splice(index, 1)[0]
|
||||
: donor!.hand.splice(donor!.hand.findIndex(matches), 1)[0];
|
||||
player.hand.push(card);
|
||||
staging.push(`Conserved original unused ${card.id}: ${donor?.id ?? 'deck'} to ${actor}.`);
|
||||
return card.id;
|
||||
};
|
||||
const karama = hold('e', c => c.effect === 'karama');
|
||||
const weapon = hold('h', c => c.kind === 'projectile');
|
||||
game = advanceCapturedSkillSpecial(game, g => g.phase === 5 && g.active === 'e' && capturedSkillSpecialClean(g), actions);
|
||||
const beforeShipment = structuredClone(game);
|
||||
assert.equal(capturedSkillSpecialPlayer(game, 'h').forces['carthag:11'], 10);
|
||||
act('e', { type: 'ship', territory: 'carthag', sector: 11, amount: 2, elite: 0, allyPayment: 0 });
|
||||
game = advanceCapturedSkillSpecial(game, capturedSkillSpecialClean, actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
assert.equal(capturedSkillSpecialPlayer(game, 'e').spice, capturedSkillSpecialPlayer(beforeShipment, 'e').spice - 2);
|
||||
act('e', { type: 'endMovement' });
|
||||
game = advanceCapturedSkillSpecial(game, g => g.phase === 6 && capturedSkillSpecialClean(g), actions);
|
||||
const beforeBattle = structuredClone(game);
|
||||
const chooser = game.active!;
|
||||
assert.ok(chooser === 'e' || chooser === 'h');
|
||||
act(chooser, { type: 'chooseBattle', territory: 'carthag', target: chooser === 'e' ? 'h' : 'e' });
|
||||
game = advanceCapturedSkillSpecial(game, g => !!g.battle && !g.battle.preparation &&
|
||||
(!g.battle.preLeader || g.battle.preLeader.closed) && capturedSkillSpecialClean(g), actions);
|
||||
const winner = capturedSkillSpecialPlayer(game, 'h').leaders.filter(l => !l.dead)
|
||||
.sort((a, b) => b.strength - a.strength)[0].id;
|
||||
const dead = capturedSkillSpecialPlayer(game, 'e').leaders.filter(l => l.id !== captive && !l.dead)
|
||||
.sort((a, b) => b.strength - a.strength)[0].id;
|
||||
const battle = game.battle!;
|
||||
for (const actor of [battle.attacker, battle.defender]) act(actor, {
|
||||
type: 'battlePlan', dial: actor === 'h' ? 1 : 0, support: actor === 'h' ? 1 : 0,
|
||||
leader: actor === 'h' ? winner : dead, ...(actor === 'h' ? { weapon } : {}),
|
||||
});
|
||||
for (const actor of [battle.attacker, battle.defender]) act(actor, { type: 'traitorCall', call: false });
|
||||
game = advanceCapturedSkillSpecial(game, g => g.decision?.kind === 'captureOffer', actions);
|
||||
const captureOffer = structuredClone(game);
|
||||
assert.equal(game.decision?.player, 'h');
|
||||
assert.equal(capturedSkillSpecialPlayer(game, 'e').leaders.find(l => l.id === dead)!.dead, true);
|
||||
act('h', { type: 'decision', accept: true });
|
||||
assert.equal(game.response?.kind, 'capture');
|
||||
// Fenring is the first original live candidate. Identity-preserving shuffle
|
||||
// rolls control only the original capture draw, never the resulting identity.
|
||||
const live = capturedSkillSpecialPlayer(game, 'e').leaders.filter(l => !l.dead && (!l.usedAt || l.usedAt === 'carthag'));
|
||||
assert.equal(live[0].id, captive);
|
||||
while (game.response?.kind === 'capture') {
|
||||
const remaining = game.players.filter(p => !game.response!.passed.includes(p.id) &&
|
||||
viewGame(game, p.id).responseControls!.cancelCards.length > 0);
|
||||
assert.ok(remaining.length);
|
||||
const actor = remaining[0].id;
|
||||
const action: Action = { type: 'passResponse' };
|
||||
if (remaining.length === 1) {
|
||||
actions.push({ actor, action });
|
||||
game = draws(Array(live.length - 1).fill(0xffffffff), () => applyAction(game, actor, action));
|
||||
} else act(actor, action);
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'capturedLeader');
|
||||
if (game.decision?.kind === 'capturedLeader') assert.equal(game.decision.leader, captive);
|
||||
staging.push('Only original RANDOM CAPTURE shuffle draws controlled to retain original first live Fenring candidate.');
|
||||
act('h', { type: 'decision', mode: 'keep' });
|
||||
const afterCapture = structuredClone(game);
|
||||
game = advanceCapturedSkillSpecial(game, g => g.turn === 2 && g.phase === 4 && capturedSkillSpecialClean(g), actions);
|
||||
assert.equal(capturedSkillSpecialPlayer(game, 'e').leaders.find(l => l.id === captive)!.capturedBy, 'h');
|
||||
assert.equal(capturedSkillSpecialPlayer(game, 'e').specialKaramaUsed, undefined);
|
||||
assert.equal(capturedSkillSpecialPlayer(game, 'e').leaderRevived, false);
|
||||
capturedSkillSpecialInventory(game);
|
||||
assert.deepEqual(capturedSkillSpecialStock(game), stock);
|
||||
const action: Action = { type: 'card', mode: 'special', card: karama, leader: dead };
|
||||
return { initial, setup, beforeShipment, afterShipment, beforeBattle, captureOffer,
|
||||
afterCapture, game, captive, dead, karama, weapon, action, actions, staging, stock };
|
||||
}
|
||||
@@ -269,7 +269,7 @@ void test('the genuine audit setup offers eligible leaders to all six base facti
|
||||
assert.doesNotThrow(() => finishSetup(g, 'emperor-1'));
|
||||
});
|
||||
|
||||
void test('a captured skilled leader and attached card become public while replacement revival remains rejected atomically', () => {
|
||||
void test('a captured skilled leader and attached card become public while another own leader revives without a replacement offer', () => {
|
||||
const g = acceptCapture(captureOffer());
|
||||
for (const [viewer] of SEATS) {
|
||||
const view = viewGame(g, viewer);
|
||||
@@ -312,17 +312,27 @@ void test('a captured skilled leader and attached card become public while repla
|
||||
const emperor = kept.players.find((player) => player.id === 'e')!;
|
||||
emperor.spice = 20;
|
||||
emperor.leaderRevived = false;
|
||||
const before = structuredClone(kept);
|
||||
assert.throws(
|
||||
() =>
|
||||
applyAction(kept, 'e', {
|
||||
type: 'reviveLeader',
|
||||
leader: 'emperor-0',
|
||||
}),
|
||||
/replacement-skill ruling/,
|
||||
// User ruling 13 October 2026: the original assignment survives the capture,
|
||||
// so the ordinary revival proceeds with no replacement draw.
|
||||
const skills = structuredClone(kept.leaderSkills);
|
||||
const revived = passResponses(
|
||||
applyAction(kept, 'e', { type: 'reviveLeader', leader: 'emperor-0' }),
|
||||
);
|
||||
assert.deepEqual(kept, before);
|
||||
assertSkillCustody(kept);
|
||||
assert.equal(
|
||||
revived.players
|
||||
.find((player) => player.id === 'e')!
|
||||
.leaders.find((leader) => leader.id === 'emperor-0')!.dead,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
revived.players
|
||||
.find((player) => player.id === 'e')!
|
||||
.leaders.find((leader) => leader.id === 'emperor-1')!.capturedBy,
|
||||
'h',
|
||||
);
|
||||
assert.deepEqual(revived.leaderSkills, skills);
|
||||
assert.equal(revived.leaderSkills!.offers.e, undefined);
|
||||
assertSkillCustody(revived);
|
||||
});
|
||||
|
||||
void test('the captor gets only the lower battle bonus and a surviving captive returns with the same skill', () => {
|
||||
@@ -490,7 +500,7 @@ void test('capturing an unskilled leader preserves the existing private identity
|
||||
assertSkillCustody(g);
|
||||
});
|
||||
|
||||
void test('captured training excludes blocked Ghola and ordinary leader revivals while preserving a legal Collection pass', () => {
|
||||
void test('a captured trained leader still offers a legal Ghola revival while the physical skill and captive stay put', () => {
|
||||
const kept = applyAction(acceptCapture(captureOffer()), 'h', { type: 'decision', mode: 'keep' });
|
||||
kept.phase = 7;
|
||||
kept.decision = null;
|
||||
@@ -501,17 +511,29 @@ void test('captured training excludes blocked Ghola and ordinary leader revivals
|
||||
const ghola = baseDeck().find(card => card.effect === 'ghola')!;
|
||||
placeFixtureHand(kept, kept.players.indexOf(emperor), [ghola]);
|
||||
emperor.bot = 'Medium';
|
||||
const before = structuredClone(kept);
|
||||
const skills = structuredClone(kept.leaderSkills);
|
||||
const view = viewGame(kept, 'e');
|
||||
assert.deepEqual(view.ghola.leaders, []);
|
||||
assert.deepEqual(view.revival.leaders, []);
|
||||
// User ruling 13 October 2026: the capture no longer hides Ghola targets.
|
||||
// Only the captured emperor-1 is excluded; the four dead discs are offered.
|
||||
const dead = ['emperor-0', 'emperor-2', 'emperor-3', 'emperor-4'];
|
||||
assert.deepEqual(view.ghola.leaders.map(leader => leader.id).sort(), dead);
|
||||
assert.equal(view.ghola.available, true);
|
||||
assert.equal(view.ghola.maxForces, 5, 'Captured training does not prohibit physical force Ghola');
|
||||
const action = botActions(view).find(action => action.type === 'ready');
|
||||
assert.ok(action);
|
||||
const action = botActions(view).find(
|
||||
(candidate) => candidate.type === 'card' && candidate.card === ghola.id,
|
||||
);
|
||||
assert.ok(action, 'Bot custody no longer strands the legal Ghola revival');
|
||||
const done = applyAction(kept, 'e', action);
|
||||
assert.equal(done.phase, 8);
|
||||
assert.equal(done.players.find(player => player.id === 'e')!.hand.some(card => card.id === ghola.id), true);
|
||||
assert.deepEqual(kept, before);
|
||||
assert.equal(
|
||||
done.players
|
||||
.find((player) => player.id === 'e')!
|
||||
.leaders.find((leader) => leader.id === action.leader)!.dead,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
done.players.find((player) => player.id === 'e')!.hand.some((card) => card.id === ghola.id),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(done.leaderSkills, skills);
|
||||
assertSkillCustody(done);
|
||||
});
|
||||
Reference in new issue
Block a user