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
+25
-1
@@ -1,6 +1,6 @@
|
||||
# Current development status
|
||||
|
||||
Updated 11 October 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
Updated 13 October 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
is playable and the six classic factions have a user-authorized Advanced preview.
|
||||
Expansion starts and full rules/publication acceptance remain gated.**
|
||||
|
||||
@@ -29,6 +29,28 @@ optional skill bands, combinations and public expansion starts remain open.
|
||||
Rules implementation continues; comprehensive assurance and administration
|
||||
remain deferred until all rules are implemented.
|
||||
|
||||
**Captured Leader Skill replacement ruling — 13 October.**
|
||||
The user resolved the previously gated captured-card replacement question: the
|
||||
original faction keeps its existing Skill assignment while its trained leader is
|
||||
captured, so reviving a different eligible own leader is legal and simply draws
|
||||
no replacement card while that assignment exists. This is the selected reading
|
||||
of the authorized supplied Advanced physical p21 condition "if you have no
|
||||
Leader Skill Card"; it is not a new publisher ruling. The engine's earlier
|
||||
guard vetoed the whole ordinary/Ghola revival instead of only the replacement
|
||||
offer. `leaderSkillRevivalBlocked`/`requireLeaderSkillRevival`, the
|
||||
`gholaLeaders` early return, the view `revival.leaders: []` filter and the
|
||||
beginRevival/Ghola/Emperor-special-Karama call sites are removed;
|
||||
`offerRevivedLeaderSkill` is unchanged and still suppresses a second assignment
|
||||
or draw. The previously committed rejection assertions at
|
||||
`tests/advanced-native-skills.test.ts:258` and
|
||||
`tests/leader-skills-capture.test.ts:322` are the program-level BEFORE; both are
|
||||
rewritten to the ruling. Bounded changed-path smoke covers ordinary, Ghola and
|
||||
Emperor special-Karama revival of a different own leader with exact cost, no
|
||||
offer/draw, retained captive card/controller and a conserved all14 deck. Docs
|
||||
`LEADER_SKILLS_CAPTURE.md`, `LEADER_SKILLS_RULES.md` and `NEXUS_CARD_RULES.md`
|
||||
record the ruling. No expanded-slice, complete-mode, save/deployed or assurance
|
||||
claim follows.
|
||||
|
||||
**Ordinary Skills in Guild Betrayal — 13 October.**
|
||||
The new Bureaucrat composition had exposed all14 cards but omitted its profile
|
||||
from ordinary skill quotes. A real Sandmaster move rejected while ordinary
|
||||
@@ -2162,6 +2184,8 @@ The original stranded6-seat Collection state also finishes807 accepted actions/
|
||||
no rejection after the fix. Original Bureaucrat invoices are no longer guarded
|
||||
in this composition; Ghola/ordinary leader guidance obeys existing captured
|
||||
training prohibition without removing force Ghola or deciding replacement skills.
|
||||
(That captured-training prohibition is superseded by the
|
||||
[13 October 2026 replacement ruling](LEADER_SKILLS_CAPTURE.md#user-ruling--13-october-2026).)
|
||||
Controlled original backed-up CLI **P4D8MY3V v10 / UVHFYQB8 v8 setup**
|
||||
preserves real offers/hands/IDs after a scoped original14 shuffle.
|
||||
Human390px **P4D8MY3V v22/Collection** selects trained Fenring6/dial10/
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 13 October 2026 — captured Leader Skill replacement ruling
|
||||
|
||||
The user resolved the previously gated captured-card replacement question: the
|
||||
original faction keeps its existing Skill assignment while its trained leader
|
||||
is captured, so reviving a different eligible own leader is legal and simply
|
||||
draws no replacement card while that assignment exists. This is the selected
|
||||
reading of the authorized supplied Advanced physical p21 condition "if you have
|
||||
no Leader Skill Card"; it is not a new publisher ruling. The captor's
|
||||
lower-band-only use, the captive's card movement and death-return are unchanged.
|
||||
|
||||
The engine's earlier guard vetoed the whole ordinary/Ghola revival instead of
|
||||
only the replacement offer. `leaderSkillRevivalBlocked`/`requireLeaderSkillRevival`,
|
||||
the `gholaLeaders` early return, the view `revival.leaders: []` filter and the
|
||||
beginRevival/Ghola/Emperor-special-Karama call sites are removed.
|
||||
`offerRevivedLeaderSkill` is unchanged: one assignment per owner still suppresses
|
||||
a second offer or draw.
|
||||
|
||||
Program-level BEFORE: on the pre-cutover revision the rejected-action assertions
|
||||
at `tests/advanced-native-skills.test.ts:258` and
|
||||
`tests/leader-skills-capture.test.ts:322` required ordinary revival to throw
|
||||
`/replacement-skill ruling/`, and both passed then. The ordinary case was also
|
||||
re-confirmed from a throwaway pre-cutover `git worktree` at HEAD: the projected
|
||||
ordinary and Ghola targets were empty and both `reviveLeader` and the held Ghola
|
||||
card rejected with the replacement-skill ruling, state unchanged.
|
||||
|
||||
AFTER (bounded changed-path smoke, no fabricated state):
|
||||
|
||||
- `tests/leader-skills-capture.test.ts` (6/6): a captured skilled leader keeps
|
||||
its public assignment while another own dead leader revives for its exact
|
||||
printed cost, with no draw/offer and a conserved all14 deck; the Collection
|
||||
Medium bot plays the held Ghola to revive a dead disc, no replacement offer.
|
||||
- `tests/advanced-native-skills.test.ts`: a captured native trainer keeps its
|
||||
physical skill while another own leader revives with no replacement offer.
|
||||
- `tests/captured-skill-revival.test.ts` (`fixture-captured-skill-revival.ts`):
|
||||
native classic Atreides/Harkonnen setup; four real battles genuinely kill
|
||||
`atreides-2/3/4` and `atreides-0`; the fourth is a real Capture + Keep of the
|
||||
trained `atreides-1`; next-turn Revival offers `atreides-0`. Ordinary paid
|
||||
revival (spice 10→5) and the held Ghola free revival both succeed, no draw or
|
||||
offer, intact assignment `{owner a, leader atreides-1, skill sandmaster,
|
||||
controller h, captured true}`. Living captive, wrong actor, cross-phase,
|
||||
second-leader and consumed-Ghola attempts reject without mutation; one exact
|
||||
Easy bot ordinary target is proposed.
|
||||
- `tests/captured-skill-special-revival.test.ts`
|
||||
(`fixture-captured-skill-special-revival.ts`): native classic Emperor/
|
||||
Harkonnen/Guild setup; paid Carthag deployment and a sealed projectile battle
|
||||
genuinely kill untrained `emperor-1` while a real Capture + Keep retains
|
||||
trained `emperor-0`; next-turn Revival's free Emperor special Karama revives
|
||||
`emperor-1` (wallet 8→8, `specialKaramaUsed` true, Karama discarded once, no
|
||||
replacement offer, unchanged captive/assignment/deck) and reaches the native
|
||||
phase5 suffix. Wrong captive/foreign target, wrong phase/actor, mixed
|
||||
forces-plus-leader, replay and once-use reject immutably with one exact Easy
|
||||
bot Special action.
|
||||
- Related 16-file union 145/145; the two new files 4/4; `tsc --noEmit` and
|
||||
`oxlint` clean.
|
||||
|
||||
Docs updated: `RULE_DECISIONS.md` (new user decision),
|
||||
`LEADER_SKILLS_CAPTURE.md`, `LEADER_SKILLS_RULES.md` (boundary resolved),
|
||||
`NEXUS_CARD_RULES.md` and `CURRENT_STATUS.md`. The Ambassador free-revival
|
||||
route was unavailable in the genuine classic three-seat position and remains
|
||||
unexercised. No expanded-slice, complete-mode, save/deployed or assurance claim
|
||||
follows.
|
||||
|
||||
## 13 October 2026 — ordinary skill legality in Guild Betrayal
|
||||
|
||||
The previous composition dealt all14 cards but only admitted Bureaucrat to its
|
||||
|
||||
@@ -76,10 +76,19 @@ The persistent local source archive is
|
||||
The GF9 URLs now redirect or reject some direct clients, but their indexed
|
||||
publisher text and the byte-stable local copies agree on the quoted rules.
|
||||
|
||||
## Remaining separate ruling
|
||||
## User ruling — 13 October 2026
|
||||
|
||||
The original faction retains its existing Skill assignment while its trained
|
||||
leader is captured. Reviving a different eligible own leader is legal, but
|
||||
does not offer or draw a replacement Skill while that original assignment exists.
|
||||
This is the user's selected interpretation of the authorized supplied Advanced
|
||||
physical p21 condition "if you have no Leader Skill Card"; it is not a new
|
||||
publisher ruling. The physical card still accompanies its captive, and the
|
||||
normal band remains unavailable to both factions until the captive returns.
|
||||
The existing lower battle-band and death-return mechanics remain unchanged.
|
||||
|
||||
The engine's earlier replacement-question guard wrongly suppressed the whole
|
||||
ordinary/Ghola revival, not merely a replacement offer. The ruled implementation
|
||||
removes that revival veto while retaining the existing one-assignment-per-owner
|
||||
offer check. No second assignment, forced draw or replacement card is introduced.
|
||||
|
||||
Capture does not settle whether the original faction counts as having no skill
|
||||
card while its only card is attached to a captive, and therefore whether a
|
||||
later own-leader revival may create a replacement assignment. That entitlement
|
||||
should remain gated until separately ruled. It does not affect the custody and
|
||||
visibility conclusion above.
|
||||
+14
-10
@@ -122,7 +122,10 @@ Prescience.
|
||||
The user's requested fresh search resolves the skilled capture's public state
|
||||
through its unique, publicly assigned card's required movement. See the
|
||||
[capture interpretation and its precise limits](LEADER_SKILLS_CAPTURE.md).
|
||||
Captured-card replacement entitlement remains a separate unresolved question.
|
||||
The [13 October 2026 replacement ruling](LEADER_SKILLS_CAPTURE.md#user-ruling--13-october-2026)
|
||||
then resolves the captured-card replacement question: the original assignment
|
||||
is retained and no replacement card is drawn while it exists, while ordinary and
|
||||
Ghola revival of another own leader is unaffected.
|
||||
|
||||
When Harkonnen captures a skilled leader, the skill card travels with that
|
||||
leader. The captor may use only the lower battle effect and only when using
|
||||
@@ -151,11 +154,12 @@ The same official Q&A settles these boundaries:
|
||||
These questions should remain explicit rather than be answered by a generic
|
||||
card framework:
|
||||
|
||||
1. **Captured-card replacement:** the rules say a captured leader keeps the
|
||||
skill, while revival permits a draw when a faction “has no” skill card. They
|
||||
do not say whether the original faction still counts as having the captured
|
||||
card, or can create a second active assignment after reviving another
|
||||
leader.
|
||||
1. **Captured-card replacement — resolved 13 October 2026.** The rules say a
|
||||
captured leader keeps the skill, while revival permits a draw when a faction
|
||||
“has no” skill card. The user selected the strict reading: the original
|
||||
faction still counts as having the captured card, so reviving another leader
|
||||
yields no replacement draw and no second assignment. See
|
||||
[the ruling](LEADER_SKILLS_CAPTURE.md#user-ruling--13-october-2026).
|
||||
2. **Spice Banker escrow:** the gained spice waits in front of the shield until
|
||||
Mentat Pause. The sources do not say where it goes if the skilled leader
|
||||
dies or is captured before collection, nor whether capture transfers that
|
||||
@@ -245,9 +249,9 @@ the settled Q&A interactions and saved pending choices. Every human action
|
||||
needs the same legal AI route, and private Mentat/Rihani information must remain
|
||||
seat-scoped across save and recovery.
|
||||
|
||||
Keep the six unresolved boundaries above gated or represented as explicit
|
||||
pending decisions. In particular, do not pay Smuggler spice irreversibly at
|
||||
plan reveal, transfer Spice Banker escrow on capture, or issue a replacement
|
||||
skill while the original card is captured without a recorded ruling. This
|
||||
Keep the five remaining unresolved boundaries above gated or represented as
|
||||
explicit pending decisions. In particular, do not pay Smuggler spice irreversibly
|
||||
at plan reveal, transfer Spice Banker escrow on capture, or issue a replacement
|
||||
skill while the original card is captured. This
|
||||
source contract supports the bounded [runtime prototype](LEADER_SKILLS_RUNTIME.md);
|
||||
it is not evidence that the optional module can be enabled publicly.
|
||||
@@ -752,7 +752,7 @@ skills. Actual foreign revival gives control of the original physical disc but
|
||||
never a new skill. Own revival, native trainer normal roles, foreign death and
|
||||
negotiated return, Harkonnen capture/revelation/use-return/execution and native
|
||||
Face Dance compose through their original handlers. Explicit Mentat/Banker
|
||||
opt-ins retain their boundaries; captured replacement, Advanced Atreides Suk/KH,
|
||||
opt-ins retain their boundaries; Advanced Atreides Suk/KH,
|
||||
unrelated overlays and public starts remain guarded.
|
||||
|
||||
## Advanced native Ixian and CHOAM composition — 4 October 2026
|
||||
@@ -763,8 +763,8 @@ Select distinct `ix` and/or `choam` decks, including each native faction's
|
||||
required family. All fourteen physical skills and original native setup choices
|
||||
remain: Ixian starting-card selection and HMS, genuine traitor offers, CHOAM's
|
||||
unskilled Auditor, posture before faction powers and optional own revival.
|
||||
Basic entries and public activation are unchanged. Foreign gholas, captured
|
||||
replacement, Advanced Atreides Suk/Kwisatz Haderach, Richese mixed planning and
|
||||
Basic entries and public activation are unchanged. Foreign gholas, Advanced
|
||||
Atreides Suk/Kwisatz Haderach, Richese mixed planning and
|
||||
unrelated optional overlays retain their explicit guards.
|
||||
|
||||
Advanced cyborg support and casualties feed the existing skilled Suk rescue,
|
||||
@@ -873,10 +873,12 @@ losers and captives. Modified strength and combined modules remain guarded.
|
||||
Planetologist and Suk Graduate have the connected
|
||||
prototypes described below.
|
||||
|
||||
Captured-card replacement entitlement remains unresolved. The prototype rejects
|
||||
own-leader revival while its original skill is captured, before beginning a
|
||||
paid revival or consuming the Ghola/special-Karama action. This is an explicit
|
||||
unsupported boundary, not a ruling that the physical game forbids that revival.
|
||||
Captured-card replacement entitlement is resolved by the 13 October 2026
|
||||
[ruling](LEADER_SKILLS_CAPTURE.md#user-ruling--13-october-2026): the original
|
||||
faction keeps its assignment, and reviving a different own leader now proceeds
|
||||
for its paid/Ghola/special cost without drawing a replacement while that card
|
||||
exists. The earlier prototype rejected that revival as an explicit unsupported
|
||||
boundary; that veto is removed.
|
||||
Other expansion rosters and combined optional modules remain gated. Native
|
||||
Advanced foreign gholas now follow the bounded Tleilaxu contract above; the
|
||||
recorded source-contract timing questions remain open. Planetologist has bounded
|
||||
|
||||
@@ -738,10 +738,11 @@ loser retention keep their independent original windows.
|
||||
Genuine games exposed two integration defects: the existing Bureaucrat payment
|
||||
guard rejected ordinary invoices in this composition, and Ghola guidance
|
||||
offered leader revivals forbidden by captured training, stranding a legal
|
||||
Collection pass. Original once-per-phase Bureaucrat settlement is admitted;
|
||||
Ghola and ordinary revival projections reuse the exact existing captured
|
||||
training restriction. Force Ghola and Kwisatz alternatives are not removed,
|
||||
and no replacement-skill ruling is inferred.
|
||||
Collection pass. Original once-per-phase Bureaucrat settlement is admitted.
|
||||
Under the [13 October 2026 ruling](LEADER_SKILLS_CAPTURE.md#user-ruling--13-october-2026)
|
||||
captured training no longer vetoes ordinary or Ghola revival of a different own
|
||||
leader: the original assignment is retained and no replacement card is drawn.
|
||||
Force Ghola and Kwisatz alternatives remain available.
|
||||
|
||||
```sh
|
||||
node --import tsx tools/start-prototype.ts --profile leader-skills \
|
||||
|
||||
+15
-1
@@ -1,6 +1,20 @@
|
||||
# Rule decision index
|
||||
|
||||
Updated 11 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
|
||||
Updated 13 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
|
||||
|
||||
## User decisions — 13 October 2026
|
||||
|
||||
- **Captured Leader Skill replacement:** the original faction retains its
|
||||
existing Skill assignment while its trained leader is captured. Reviving a
|
||||
different eligible own leader is legal; it does not offer or draw a
|
||||
replacement Skill while that assignment exists, so no second assignment is
|
||||
created. This is the selected reading of the authorized supplied Advanced
|
||||
physical p21 condition "if you have no Leader Skill Card", not a new publisher
|
||||
ruling. The captor's lower-band-only use, the captive's card movement and
|
||||
death-return are unchanged.
|
||||
[Ruling](LEADER_SKILLS_CAPTURE.md#user-ruling--13-october-2026).
|
||||
|
||||
These decisions do not open public module/faction or complete-mode gates.
|
||||
|
||||
## User decisions — 11 October 2026
|
||||
|
||||
|
||||
+1
-18
@@ -8623,17 +8623,6 @@ function actBureaucratPayment(g: Game,action:Action) {
|
||||
}
|
||||
else getPlayer(g,pending.source.payee).bribes += pending.source.amount-redirected;
|
||||
}
|
||||
function leaderSkillRevivalBlocked(g: Game, owner: string): boolean {
|
||||
return !!g.leaderSkills?.assignments.some(
|
||||
(a) => a.owner === owner && leaderSkillController(g, a) !== owner,
|
||||
);
|
||||
}
|
||||
function requireLeaderSkillRevival(g: Game, owner: string) {
|
||||
requireRule(
|
||||
!leaderSkillRevivalBlocked(g, owner),
|
||||
'Reviving another leader while your Leader Skill is captured awaits the replacement-skill ruling.',
|
||||
);
|
||||
}
|
||||
function finishLeaderSkillCustody(g: Game, before?: Game) {
|
||||
if (!g.leaderSkills) return;
|
||||
const result = skillRule(() =>
|
||||
@@ -8664,7 +8653,6 @@ function finishLeaderSkillCustody(g: Game, before?: Game) {
|
||||
.find((p) => p.id === player.id)
|
||||
?.leaders.find((l) => l.id === leader.id)?.dead
|
||||
) {
|
||||
requireLeaderSkillRevival(g, player.id);
|
||||
g.leaderSkills = skillRule(() =>
|
||||
offerRevivedLeaderSkill(
|
||||
g.leaderSkills!,
|
||||
@@ -20362,7 +20350,6 @@ function normalizeBattle(g: Game) {
|
||||
/** Ghola follows current control for ordinary leaders, but Duke's printed
|
||||
* revival right belongs exclusively to Ecaz, regardless of temporary custody. */
|
||||
function gholaLeaders(g: Game, p: Player) {
|
||||
if (leaderSkillRevivalBlocked(g, p.id)) return [];
|
||||
const native = controlledLeaders(g, p).filter(
|
||||
(l) => l.id !== DUKE_VIDAL_ID && l.dead && !l.capturedBy,
|
||||
);
|
||||
@@ -20556,7 +20543,6 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
|
||||
`${p.name} revived Kwisatz Haderach with Ghola for use again this turn. No spice or normal leader-revival allowance was spent.`,
|
||||
);
|
||||
} else if (action.leader) {
|
||||
requireLeaderSkillRevival(g,p.id);
|
||||
requireRule(
|
||||
action.leader !== DUKE_VIDAL_ID || p.faction === 'ecaz',
|
||||
'Only Ecaz may revive Duke Vidal, including with Ghola.',
|
||||
@@ -23809,7 +23795,6 @@ function finishRevival(
|
||||
// Automatic Karama response chains may commit after the action's before/after
|
||||
if (revival.kind === 'leader' && g.leaderSkills && !isAuditorLeader(leader) &&
|
||||
!leader.capturedBy && p.leaders.some(own => own.id === leader.id)) {
|
||||
requireLeaderSkillRevival(g, p.id);
|
||||
g.leaderSkills = skillRule(() => offerRevivedLeaderSkill(
|
||||
g.leaderSkills!, p.id, leader.id, crypto.randomUUID()));
|
||||
}
|
||||
@@ -23837,7 +23822,6 @@ function finishRevival(
|
||||
}
|
||||
}
|
||||
function beginRevival(g: Game, revival: PendingRevival) {
|
||||
if (revival.kind === 'leader') requireLeaderSkillRevival(g,revival.player);
|
||||
requireRule(
|
||||
!revivalPrevented(g, revival.player),
|
||||
'Tleilaxu prevented this faction’s normal revivals for this turn.',
|
||||
@@ -27528,7 +27512,6 @@ export function prepareSpecialKaramaIntent(
|
||||
'The Emperor special Karama is used during Revival.',
|
||||
);
|
||||
if (action.leader) {
|
||||
requireLeaderSkillRevival(g,p.id);
|
||||
const l = p.leaders.find((l) => l.id === action.leader);
|
||||
requireRule(
|
||||
l?.dead && !l.capturedBy && !l.gholaBy,
|
||||
@@ -33269,7 +33252,7 @@ export function viewGame(state: Game, id: string) {
|
||||
g.decision?.kind === 'revivalStop'
|
||||
? homeworldRevivalKaramaBlock(g, g.decision.recipient)
|
||||
: null,
|
||||
leaders: leaderSkillRevivalBlocked(g, id) ? [] : leaderRevivals.leaders,
|
||||
leaders: leaderRevivals.leaders,
|
||||
kwisatz: leaderRevivals.kwisatz,
|
||||
cycleBlock: leaderRevivals.cycleBlock,
|
||||
...(leaderRevivals.auditorCyclePolicy ? { auditorCyclePolicy: leaderRevivals.auditorCyclePolicy } : {}),
|
||||
|
||||
@@ -234,7 +234,10 @@ export function returnDeadLeaderSkills(
|
||||
);
|
||||
return { state: next, returned };
|
||||
}
|
||||
/** The capture/replacement question stays guarded while an original skill exists. */
|
||||
/**
|
||||
* One assignment per owner: while the original Skill exists (including while
|
||||
* its trained leader is captured) a revived leader draws no replacement card.
|
||||
*/
|
||||
export function offerRevivedLeaderSkill(
|
||||
state: LeaderSkillsState,
|
||||
owner: string,
|
||||
|
||||
+3
-3
@@ -4791,7 +4791,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
|
||||
'Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept are connected. They give another leader +1 for the matching physical card role, or the surviving assigned leader +3. These bonuses do not stack to +4. They change battle strength, not the printed disc value or leader bounty.',
|
||||
'The assigned leader keeps its skill through capture and returns with it if it survives its captive battle. The known skill identifies that captured leader; Harkonnen receives only its lower battle effect. Normal effects remain unavailable to both the captor and original faction while it is captured. A killed skilled leader returns its card to the shuffled skill deck.',
|
||||
'When reviving an own leader without an assigned skill, decide whether to draw before seeing two private cards. Once drawn, keep one and assign it to that revived leader. Replacement while an earlier skill is captured remains a separate unresolved case.',
|
||||
'When reviving an own leader while you have no Skill Card, decide whether to draw before seeing two private cards. Once drawn, keep one and assign it to that revived leader. Under the 13 October 2026 ruling the original faction keeps its assignment while its trained leader is captured, so reviving a different own leader is legal but draws no replacement while that card exists.',
|
||||
'Planetologist chooses either one extra movement range (maximum three) or forces from two territories to one destination at normal range. These alternatives do not stack. The actual skilled leader can use an eligible green Special in the weapon slot for +2 if it survives; discard that Special after battle without activating its ordinary effect. Mixed advisor/fighter entry into an enemy-only territory and combined expansion modules remain unfinished.',
|
||||
'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Rescued counters still count toward Advanced Atreides’ seven battle losses for Kwisatz Haderach; combined modules remain unfinished.',
|
||||
'In the supported classic Nexus/Skills composition, Emperor Cunning counts five actual ordinary counters as temporary Sardaukar. Normal or trained Suk rescue still saves physical ordinary casualties. The original casualty allocation remains pending while the rescue choice is open; physical counters move only when rescue settles. Original support is already paid and is not refunded or charged twice. A normal save returns one to reserves; a trained save keeps one and returns the rest. Actual starred reserves and Tanks remain unchanged.',
|
||||
@@ -4814,8 +4814,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler shipping, No-Field accompaniment and unmodified battle collection, plus Sandmaster ground, worm, physical Ambassador and native HMS allocation are connected. Normal Mentat private questions and historical observations use the user-approved uniform step in new supported battles without an extra opt-in. Spice Banker lower spending and native Diplomat printed-defense copying are connected; trained retreat transfers undialed forces before losing casualties. Normal Banker income remains a separate profile; other combined interactions stay open. Bureaucrat redirects supported third-party payments with saved once-per-phase use, while user-classified gifts stay outside payment handling.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Classic Homeworld Nexus optional modules add13 meaningful cases; affected242/242, types/lint/build pass. Twenty-two original six-seat games across15 families finish14233/14233 actions with375 JSON on one unchanged source tree. Human390px original typed invasion/zero-Suk and exact-three native return retain Kaitain14/Salusa5/visitor1; actual random Shrine entry, typed Maker ride and both-pile closing Moritani draw retain original armies. Native35-case and earlier source-qualified evidence remains in canonical logs. Native compositions, pending effects, full rules/combinations and deployed acceptance remain open.',evidence:['tests/homeworld-classic-nexus-skills-runtime.test.ts','tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody. The 13 October 2026 ruling resolves the captured-card replacement question: the original assignment is retained and no replacement is drawn while it exists; other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Classic Homeworld Nexus optional modules add13 meaningful cases; affected242/242, types/lint/build pass. Twenty-two original six-seat games across15 families finish14233/14233 actions with375 JSON on one unchanged source tree. Human390px original typed invasion/zero-Suk and exact-three native return retain Kaitain14/Salusa5/visitor1; actual random Shrine entry, typed Maker ride and both-pile closing Moritani draw retain original armies. Native35-case and earlier source-qualified evidence remains in canonical logs. Native compositions, pending effects, full rules/combinations and deployed acceptance remain open.',evidence:['tests/homeworld-classic-nexus-skills-runtime.test.ts','tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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