Track repeated Ecaz revival cohorts by native disc identity
This commit is contained in:
1 parent
60f338e2e1
commit
559b42dd78
16 files changed
+645
-52
No files matched your search
@@ -0,0 +1,97 @@
|
||||
import type { Leader } from './cards';
|
||||
|
||||
/** Native ordinary-disc accounting. Duke contributes to opening, never this roster. */
|
||||
export type EcazRevivalCohort = {
|
||||
cycle: number;
|
||||
returned: string[];
|
||||
};
|
||||
type Context = {
|
||||
leaders: readonly Pick<Leader, 'id' | 'dead' | 'deaths' | 'capturedBy' | 'gholaBy'>[];
|
||||
revivalCycle: number;
|
||||
ecazRevivalCohort?: EcazRevivalCohort;
|
||||
};
|
||||
type Quote = { cycle: number; returned: readonly string[] };
|
||||
const invalid = () => { throw new Error('Ecaz revival cohort lost its original native discs.'); };
|
||||
|
||||
/** Provisional E3 composition: five actual Tanks open a native cohort; an
|
||||
* unavailable five-disc roster retains the base captured-leader exception.
|
||||
* Completed native returns, not death-counter equality, determine its remainder.
|
||||
* Duke's own death counter never indexes or delays a native cohort. */
|
||||
export function quoteEcazRevivalCohort(p: Context, dukeInTanks: boolean, waiveOpening = false): Quote {
|
||||
const saved = p.ecazRevivalCohort;
|
||||
if (p.leaders.length !== 5 || !Number.isSafeInteger(p.revivalCycle) || p.revivalCycle < 0)
|
||||
invalid();
|
||||
if (saved !== undefined) {
|
||||
if (!saved || typeof saved !== 'object' || Array.isArray(saved) ||
|
||||
!Number.isSafeInteger(saved.cycle) || saved.cycle < 0 || !Array.isArray(saved.returned))
|
||||
invalid();
|
||||
let keys = 0;
|
||||
for (const key in saved) {
|
||||
if (key !== 'cycle' && key !== 'returned') invalid();
|
||||
keys++;
|
||||
}
|
||||
if (keys !== 2) invalid();
|
||||
for (let i = 0; i < saved.returned.length; i++) {
|
||||
if (!p.leaders.some(l => l.id === saved.returned[i]) ||
|
||||
saved.returned.indexOf(saved.returned[i]) !== i) invalid();
|
||||
}
|
||||
}
|
||||
let cycle = saved?.cycle ?? p.revivalCycle;
|
||||
// Old valid Ecaz states have only the first cohort. Face-down/revived native
|
||||
// histories seed its already-used identities once, not a shared death minimum.
|
||||
let returned: readonly string[];
|
||||
if (saved) returned = saved.returned;
|
||||
else {
|
||||
const seed: string[] = [];
|
||||
for (const leader of p.leaders)
|
||||
if (leader.deaths > 1 || (!leader.dead && leader.deaths > 0)) seed.push(leader.id);
|
||||
returned = seed;
|
||||
}
|
||||
let dead = dukeInTanks ? 1 : 0, unavailable = true, pending = false;
|
||||
for (const leader of p.leaders) {
|
||||
const tanked = leader.dead && !leader.capturedBy && !leader.gholaBy;
|
||||
if (tanked) {
|
||||
dead++;
|
||||
if (!returned.includes(leader.id)) pending = true;
|
||||
}
|
||||
if (!leader.dead && !leader.capturedBy && !leader.gholaBy) unavailable = false;
|
||||
}
|
||||
const opening = dead >= 5 || unavailable;
|
||||
if (cycle === 0 && (opening || waiveOpening)) cycle = 1;
|
||||
if (cycle > 0 && opening && !pending && returned.length > 0) {
|
||||
cycle++;
|
||||
returned = [];
|
||||
}
|
||||
return { cycle, returned };
|
||||
}
|
||||
|
||||
/** Preserve an opening consumed by an independent Duke resurrection. */
|
||||
export function recordEcazRevivalOpening(p: Context, dukeInTanks: boolean): void {
|
||||
const quote = quoteEcazRevivalCohort(p, dukeInTanks);
|
||||
if (p.ecazRevivalCohort?.cycle === quote.cycle &&
|
||||
p.ecazRevivalCohort.returned === quote.returned) return;
|
||||
if (!p.ecazRevivalCohort && quote.cycle === 0 && quote.returned.length === 0) return;
|
||||
p.ecazRevivalCohort = { cycle: quote.cycle, returned: [...quote.returned] };
|
||||
p.revivalCycle = Math.max(p.revivalCycle, quote.cycle);
|
||||
}
|
||||
|
||||
/** Record only a completed native resurrection. Call before clearing dead. */
|
||||
export function completeEcazRevivalCohortUse(p: Context, dukeInTanks: boolean, leader: string): void {
|
||||
if (!p.leaders.some(l => l.id === leader && l.dead)) invalid();
|
||||
const quote = quoteEcazRevivalCohort(p, dukeInTanks);
|
||||
if (p.ecazRevivalCohort?.cycle === quote.cycle &&
|
||||
quote.returned.includes(leader)) return;
|
||||
p.ecazRevivalCohort = { cycle: quote.cycle,
|
||||
returned: quote.returned.includes(leader) ? [...quote.returned] : [...quote.returned, leader] };
|
||||
p.revivalCycle = Math.max(p.revivalCycle, quote.cycle);
|
||||
}
|
||||
|
||||
/** A first-death capture execution is physically face down. It skips the first
|
||||
* ordinary cohort; once that cohort is complete it has no extra lifetime ban. */
|
||||
export function recordEcazFirstExecution(p: Context, dukeInTanks: boolean, leader: string): void {
|
||||
const quote = quoteEcazRevivalCohort(p, dukeInTanks);
|
||||
p.ecazRevivalCohort = { cycle: quote.cycle,
|
||||
returned: quote.cycle <= 1 && !quote.returned.includes(leader)
|
||||
? [...quote.returned, leader] : [...quote.returned] };
|
||||
p.revivalCycle = Math.max(p.revivalCycle, quote.cycle);
|
||||
}
|
||||
+17
-5
@@ -533,6 +533,7 @@ import {
|
||||
} from './truthtrance';
|
||||
import type { MixedShipmentExpression } from './mixed-shipment-question';
|
||||
import { controlsLeader, nativeAvailable } from './leader-control';
|
||||
import type { EcazRevivalCohort } from './ecaz-revival-cohort';
|
||||
import {
|
||||
newRevivalRules,
|
||||
eliteRevivalRemaining,
|
||||
@@ -546,6 +547,8 @@ import {
|
||||
freeRevivalRemaining,
|
||||
normalRevivalAllowance,
|
||||
leaderRevivalOptions,
|
||||
recordEcazLeaderReturn,
|
||||
recordEcazLeaderExecution,
|
||||
type RevivalRules,
|
||||
type PendingRevival,
|
||||
} from './revival';
|
||||
@@ -662,6 +665,8 @@ export type Player = {
|
||||
freeForcesRevived?: number;
|
||||
leaderRevived: boolean;
|
||||
revivalCycle: number;
|
||||
/** Completed native disc identities in the current ordinary Ecaz cohort. */
|
||||
ecazRevivalCohort?: EcazRevivalCohort;
|
||||
shipped: boolean;
|
||||
moved: number;
|
||||
battleLosses: number;
|
||||
@@ -3138,6 +3143,7 @@ function playNexusEcazDuke(g: Game, p: Player, action: Action) {
|
||||
offer?.blocked ?? 'Choose the current Ecaz Nexus Duke acquisition.');
|
||||
const index = g.nexusEcazDukeHistory?.length ?? 0;
|
||||
const receipt = nexusRule(() => createNexusEcazDukeReceipt(g, p.id, index));
|
||||
if (g.dukeVidal!.leader.dead) recordEcazLeaderReturn(g, g.dukeVidal!.leader);
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
g.dukeVidal = nexusRule(() => acquireDuke(g.dukeVidal!, p.id, g.turn, 'ecazNexus'));
|
||||
nexusRule(() => validateNexusEcazDuke(g, receipt, index));
|
||||
@@ -3477,7 +3483,8 @@ function playNexusTleilaxuRevival(g: Game, p: Player, action: Action) {
|
||||
g.nexusCards!.cards=nexusRule(()=>discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
|
||||
g.revivalRules??=newRevivalRules();
|
||||
(g.revivalRules.nexusTleilaxu??=[]).push(p.id);
|
||||
if(quote.kind!=='duke'||ordinary.cycle>p.revivalCycle)p.revivalCycle=ordinary.cycle;
|
||||
if (p.faction !== 'ecaz' && (quote.kind !== 'duke' || ordinary.cycle > p.revivalCycle))
|
||||
p.revivalCycle = ordinary.cycle;
|
||||
// The card quote establishes a free own return; the original mutation keeps
|
||||
// Kwisatz/Duke custody, one-leader usage and skill revival lifecycle.
|
||||
finishRevival(g,{
|
||||
@@ -5213,6 +5220,7 @@ function ordinaryDiscardSignature(g: Game, next: OrdinaryDiscardContinuation) {
|
||||
shipped: p.shipped,
|
||||
revived: p.revived,
|
||||
revivalCycle: p.revivalCycle,
|
||||
...(p.ecazRevivalCohort === undefined ? {} : { ecazRevivalCohort: p.ecazRevivalCohort }),
|
||||
leaderRevived: p.leaderRevived,
|
||||
leaders: p.leaders,
|
||||
kwisatz: p.kwisatz,
|
||||
@@ -17612,6 +17620,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
|
||||
kwisatz.revivalCycle = Math.max(p.revivalCycle, kwisatz.revivalCycle ?? 1) + 1;
|
||||
name = 'Kwisatz Haderach';
|
||||
} else {
|
||||
recordEcazLeaderReturn(g, selected);
|
||||
selected.dead = false;
|
||||
delete selected.usedAt;
|
||||
name = selected.name;
|
||||
@@ -21288,6 +21297,7 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
|
||||
);
|
||||
const l = gholaLeaders(g, p).find((l) => l.id === action.leader);
|
||||
requireRule(l?.dead && !l.capturedBy, 'Choose a dead leader in your pool.');
|
||||
recordEcazLeaderReturn(g, l);
|
||||
l.dead = false;
|
||||
delete l.concealed;
|
||||
// Ghola permits a return in another battle this turn (GF9 FAQ p.9).
|
||||
@@ -24565,6 +24575,7 @@ function finishRevival(
|
||||
const leader = sharedDuke
|
||||
? g.dukeVidal!.leader
|
||||
: g.players.flatMap((owner) => owner.leaders).find((l) => l.id === revival.leader)!;
|
||||
recordEcazLeaderReturn(g, leader);
|
||||
if (revival.kind === 'foreignGhola') leader.gholaBy = p.id;
|
||||
else delete leader.gholaBy;
|
||||
leader.dead = false;
|
||||
@@ -30891,10 +30902,12 @@ function applyActionInner(
|
||||
if (l.id === DUKE_VIDAL_ID && g.dukeVidal)
|
||||
g.dukeVidal = executeCapturedDuke(g.dukeVidal, p.id, g.turn);
|
||||
else {
|
||||
const firstDeath = l.deaths === 0;
|
||||
l.dead = true;
|
||||
l.deaths = Math.max(2, l.deaths + 1);
|
||||
delete l.usedAt;
|
||||
delete l.capturedBy;
|
||||
if (firstDeath) recordEcazLeaderExecution(g, l);
|
||||
}
|
||||
p.spice += 2;
|
||||
log(g, `${p.name} executed a captive for 2 spice.`);
|
||||
@@ -32296,9 +32309,8 @@ function applyActionInner(
|
||||
const cost = dukeQuote?.cost ?? (revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost);
|
||||
requireRule(p.spice >= cost, 'Not enough spice.');
|
||||
if (cost < normalCost) checks.push('revivalDiscount');
|
||||
// Duke may be revived independently even while later six-disc native
|
||||
// cycles are gated; that request must not erase an opened first cohort.
|
||||
if (!duke || options.cycle > p.revivalCycle)
|
||||
// Ecaz records its physical cohort only after the return actually commits.
|
||||
if (p.faction !== 'ecaz' && (!duke || options.cycle > p.revivalCycle))
|
||||
p.revivalCycle = options.cycle;
|
||||
beginRevival(g, {
|
||||
player: id,
|
||||
@@ -34179,8 +34191,8 @@ export function viewGame(state: Game, id: string) {
|
||||
: null,
|
||||
leaders: leaderRevivals.leaders,
|
||||
kwisatz: leaderRevivals.kwisatz,
|
||||
cycleBlock: leaderRevivals.cycleBlock,
|
||||
...(leaderRevivals.auditorCyclePolicy ? { auditorCyclePolicy: leaderRevivals.auditorCyclePolicy } : {}),
|
||||
...(leaderRevivals.ecazCyclePolicy ? { ecazCyclePolicy: leaderRevivals.ecazCyclePolicy } : {}),
|
||||
dukeBlocked: leaderRevivals.dukeBlocked,
|
||||
prevented: revivalPrevented(g, me.id),
|
||||
eliteRemaining: eliteRevivalRemaining(me, g.advanced),
|
||||
|
||||
@@ -185,12 +185,9 @@ function ownDisc(p: Player, id: string) {
|
||||
function leaderCandidates(
|
||||
g: Game,
|
||||
p: Player,
|
||||
): { leaders: TleilaxuSecretAllyLeader[]; dukeBlocked: string | null; cycleBlock: string | null } {
|
||||
): { leaders: TleilaxuSecretAllyLeader[]; dukeBlocked: string | null } {
|
||||
const canonical = leaderRevivalOptions(g, p);
|
||||
const waived = leaderRevivalOptions(g, {
|
||||
...p,
|
||||
revivalCycle: Math.max(1, p.revivalCycle),
|
||||
});
|
||||
const waived = leaderRevivalOptions(g, p, true);
|
||||
const canonicalIds = new Set(canonical.leaders.map((option) => option.id));
|
||||
const blockedEarly = (id: string) =>
|
||||
!!g.revivalRules?.earlyBlocked?.includes(`${p.id}:${id}`);
|
||||
@@ -221,7 +218,6 @@ function leaderCandidates(
|
||||
return {
|
||||
leaders,
|
||||
dukeBlocked: canonical.dukeBlocked,
|
||||
cycleBlock: canonical.cycleBlock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,12 +271,12 @@ export function tleilaxuSecretAllyOptions(
|
||||
policy: NEXUS_TLEILAXU_POLICY,
|
||||
};
|
||||
const bounds = tleilaxuSecretAllyForceBounds(g, p);
|
||||
const { leaders, dukeBlocked, cycleBlock } = leaderCandidates(g, p);
|
||||
const { leaders, dukeBlocked } = leaderCandidates(g, p);
|
||||
return {
|
||||
event,
|
||||
blocked: leaders.length
|
||||
? null
|
||||
: dukeBlocked ?? cycleBlock ?? leaderBlock,
|
||||
: dukeBlocked ?? leaderBlock,
|
||||
leaders,
|
||||
maxForces: bounds.maxForces,
|
||||
maxElite: bounds.maxElite,
|
||||
@@ -301,11 +297,11 @@ export function quoteTleilaxuSecretAlly(
|
||||
): TleilaxuSecretAllyQuote {
|
||||
const source = sourceState(g, p);
|
||||
if (source) throw new TleilaxuSecretAllyError(source.message, source.reason);
|
||||
const { leaders, dukeBlocked, cycleBlock } = leaderCandidates(g, p);
|
||||
const { leaders, dukeBlocked } = leaderCandidates(g, p);
|
||||
const chosen = leaders.find((leader) => leader.id === selection?.leader);
|
||||
if (!chosen)
|
||||
throw new TleilaxuSecretAllyError(
|
||||
leaders.length ? 'Choose one offered own leader.' : dukeBlocked ?? cycleBlock ?? leaderBlock,
|
||||
leaders.length ? 'Choose one offered own leader.' : dukeBlocked ?? leaderBlock,
|
||||
'leader',
|
||||
);
|
||||
const amount = selection.amount;
|
||||
|
||||
+13
-2
@@ -136,6 +136,7 @@ const phaseDetails = [
|
||||
'In the base game, Fremen can take their available free force revivals but cannot buy normal force revivals. When the Tleilaxu faction is in the game, Fremen may also pay for normal returns within the current limit. The limit remains three unless Tleilaxu permits five; merely enabling the Ix expansion does not grant this exception. Emperor-funded extras and Ghola use separate rules.',
|
||||
'An allied Fremen player can grant three free force revivals during this phase.',
|
||||
'Use the leader selector to choose a dead leader. Revival cycles determine whether it is eligible; strength determines its normal cost.',
|
||||
'Ecaz ordinary revival uses a provisional native-identity cohort: five actual Tanks including Duke, or no available native leaders, opens it. Remaining native returns stay legal after another native dies again. Late first deaths do not need to match a global death count; Duke remains independently revivable outside the five native discs.',
|
||||
'In development Ecaz fixtures, finishing Revival opens Ambassador placement after any CHOAM closing market. See Ecaz Ambassadors for the supported placement controls and remaining effects. Full expansion starts remain disabled.',
|
||||
],
|
||||
[
|
||||
@@ -2480,6 +2481,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Choose a dead leader for leader revival, or leave the leader choice empty to return up to five forces. A card pays for one of these choices; it cannot revive both a leader and the Kwisatz Haderach.',
|
||||
'A binding battle answer or prescience commitment may require this preparation. The private guide identifies a Ghola play that can fulfill the answer, without spending or revealing the card until you choose to play it. Resolve any revival-income response before using that income to support a battle.',
|
||||
'Returned forces go to reserves. A leader revived with this card can fight again this turn, including in another territory. Atreides can also select a dead Kwisatz Haderach; revive it separately from its accompanying leader. Returning a controlled foreign ghola does not transfer it back to its original faction. Additional advanced and expansion interactions remain under audit.',
|
||||
'An actual Ecaz native Ghola return uses that disc in its current ordinary cohort without consuming the ordinary phase slot. A repeat waits for the remaining native cohort and another opening. Independent Duke Ghola preserves a satisfied five-Tanks opening and returns him set aside; only Ecaz may revive him.',
|
||||
'A saved play preserves the revived pieces, any earned technology income and the discarded card. Recovery restores any pending Tleilaxu income response without reviving or paying twice. Private preparation guidance waits for recovery to finish.',
|
||||
],
|
||||
checklist: [
|
||||
@@ -2487,8 +2489,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Ordinary and typed forces, controlled leaders and Kwisatz return with conserved usage and recovery. Ecaz-only dead shared Duke paid revival and physical Ghola return the same living set-aside disc; exceptional custody and broader expansion combinations remain unfinished.',
|
||||
evidence: ['game/engine.ts', 'game/ecaz-duke-revival.ts'],
|
||||
'Ordinary and typed forces, controlled leaders and Kwisatz retain original use and custody. Actual Ecaz native Ghola/foreign returns now record completed native cohort identity; independent Duke resurrection preserves its five-Tanks opening without aligning death counters. Ecaz-only living set-aside custody remains; broader combinations and assurance stay unfinished.',
|
||||
evidence: ['game/engine.ts', 'game/ecaz-duke-revival.ts', 'game/ecaz-revival-cohort.ts', 'game/revival.ts'],
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
@@ -2908,6 +2910,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
title: 'Duke Prad Vidal: shared leader',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary:
|
||||
'A separate Ecaz leader disc of strength six, with changing control and no Traitor Card.',
|
||||
steps: [
|
||||
@@ -2916,10 +2919,18 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Karama can prevent Moritani from acquiring Vidal. That cancellation does not remove him once Moritani already controls him.',
|
||||
'Ecaz acquires Vidal through its Ambassador outside Tanks/captured/ghola custody. The alternative forms a consensual alliance while both factions are unallied. Basic can then lend Duke optionally; the supplied Advanced physical p30 adds an available Duke to the new ally automatically and sets an unused disc aside at turn end.',
|
||||
'Only Ecaz may revive Vidal, including with Ghola Treachery. Ecaz may revive him for five spice regardless of how many of its leaders are in the Tanks. Five spice is his special revival cost; his printed battle strength is six. Ecaz may begin ordinary leader revival when five leaders are in the Tanks, counting Vidal, even if it still holds another leader.',
|
||||
'Ordinary Ecaz returns now use an explicit provisional native cohort. Five actual Tanked discs including Duke, or the existing unavailable-native-roster exception, opens it; completed native IDs preserve the remainder and later openings. A late first death can join a later cohort. Independent Duke deaths do not set or hold its ordinary number, and a real Duke resurrection preserves an already satisfied opening. Original native prices and one ordinary leader slot remain.',
|
||||
'An unallied native Ecaz holder may spend its actual Ecaz Nexus at a quiet boundary in any phase to take Duke for the current turn, including original captured custody or the set-aside disc in Tanks. Finish committed interactions first. One battle or unused turn expiry sets him aside. Nexus liberation permanently clears captivity; it does not return him to a former captor. Actual Ecaz Ghola returns him alive and set aside before a separate Cunning play.',
|
||||
'Standalone Ecaz/classic no-Skills Nexus connects Advanced Harkonnen capture of the same shared disc, including a surviving just-used losing Duke, original Keep/Execute and captive returns. Capture priority and restoring only valid unused prior tenure are provisional compositions. Five native discs, no Duke Traitor, strength six and physical death history remain.',
|
||||
'Development support also includes Moritani acquisition, Ecaz self-acquisition, consensual alliances, Basic optional loan, Advanced automatic new-ally grant, one-battle use and Ecaz-only revival to living set-aside custody. Loan release is provisional. Native ordinary-disc skills coexist without assigning Duke a skill. Broader captured acquisition, shared training, paired Advanced Harkonnen and wider interactions remain for refinement; complete modes are not certified.',
|
||||
],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Explicit native Ecaz cohort tracks completed disc identities instead of freezing repeat history or equating independent Duke deaths. Paid, Ghola, Ambassador/foreign returns and first-execution exclusion connect; Duke normal/Ghola/Nexus resurrection preserves opening. Native five-disc identity, Ecaz-only set-aside return, prices, slots and existing guards remain. Wider interactions/assurance are open.', evidence: ['game/ecaz-revival-cohort.ts', 'game/revival.ts', 'game/ecaz-duke-revival.ts', 'game/duke-vidal.ts', 'game/nexus-ecaz-duke.ts', 'game/nexus-tleilaxu-revival.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Original native Revival selector/buttons show remaining and second-cohort candidates with visible provisional policy. Actual styled390px Ilesa11 to8 leaves the repeat dead; Bindikk12 to10 preserves Whitmore alive/deaths0 and Duke Tanked/deaths1. One ordinary slot and original histories remain; no overflow/browser errors. Full journeys remain open.', evidence: ['components/game-table.tsx', 'components/duke-vidal.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal profiles in both original cohort positions use actual Cunning, ordinary force revival and a paid native return. Independent Duke resurrection retains open cohort permission. No hidden rival data or native duplication is used; strategic calibration remains deferred.', evidence: ['game/bots.ts', 'game/nexus-ecaz-duke-options.ts', 'game/revival.ts', 'tests/ecaz-duke-bots.test.ts', 'tests/normal-revival-bots.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed five-Tanks/own unavailable-roster opening and Ecaz-only Duke5/set-aside are separate from the provisional identity-cohort interpretation. Pending requests do not consume new history; independent card costs/allowances remain distinct. No publisher six-disc example or new numerical user ruling is claimed.', evidence: ['docs/ECAZ_REVIVAL_RULES.md', 'docs/DUKE_VIDAL_RULES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Affected19-file union230/230 and types/lint pass. Two original native death/return chronologies plus all four profiles in each and actual390px11 to8/12 to10 prove unreturned and second-cohort use. Actual Ghola completes a cohort inside the original ten-turn game; bribes, not wallet staging, fund proof. Late first-death boundary and local Tleilaxu waiver regressions remain. No saved/deployed rooms touched; broad assurance/full acceptance remain open.', evidence: ['tests/ecaz-revival-cohort.test.ts', 'tests/fixture-ecaz-revival-cohort.ts', 'tests/ecaz-revival-cohort-runtime.test.ts', 'tests/ecaz-duke-revival.test.ts', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/tleilaxu-ambassador-engine.test.ts', 'tests/foreign-gholas.test.ts'] },
|
||||
],
|
||||
related: [
|
||||
'faction-ecaz',
|
||||
'faction-moritani',
|
||||
|
||||
+40
-17
@@ -1,10 +1,15 @@
|
||||
import type { Game, Player } from './engine';
|
||||
import type { Leader } from './cards';
|
||||
import { faction, type FactionId } from './catalog';
|
||||
import { isAuditorLeader } from './choam-auditor';
|
||||
import { homeworldLowBonus } from './homeworld-benefits';
|
||||
import { recruitsRevivalAllowance } from './recruits';
|
||||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
import { ecazDukeRevivalBlock, quoteEcazDukeRevival } from './ecaz-duke-revival';
|
||||
import {
|
||||
quoteEcazRevivalCohort, completeEcazRevivalCohortUse,
|
||||
recordEcazRevivalOpening, recordEcazFirstExecution,
|
||||
} from './ecaz-revival-cohort';
|
||||
type RevivalRateContext = Pick<
|
||||
Game,
|
||||
| 'advanced'
|
||||
@@ -159,9 +164,8 @@ export function normalRevivalCycle(
|
||||
return cycle;
|
||||
}
|
||||
|
||||
/** The five-disc Ecaz opening is recorded in revivalCycle on the first
|
||||
* accepted ordinary return. Later six-disc cohorts are not established by E3:
|
||||
* keep their native returns gated rather than guessing from death counters. */
|
||||
/** Ordinary Ecaz cohorts keep native identity separate from independent Duke deaths.
|
||||
* Only the absent-Tleilaxu card quote may waive the unopened first cohort. */
|
||||
export function leaderRevivalOptions(
|
||||
g: Pick<
|
||||
Game,
|
||||
@@ -180,26 +184,20 @@ export function leaderRevivalOptions(
|
||||
| 'faction'
|
||||
| 'leaders'
|
||||
| 'revivalCycle'
|
||||
| 'ecazRevivalCohort'
|
||||
| 'leaderRevived'
|
||||
| 'spice'
|
||||
| 'kwisatz'
|
||||
>,
|
||||
waiveOpening = false,
|
||||
) {
|
||||
const shared = p.faction === 'ecaz' && g.dukeVidal;
|
||||
const dukeContext = shared && g.status === 'playing' ? g : null;
|
||||
const dukeBlocked = dukeContext ? ecazDukeRevivalBlock(dukeContext, p.id) : null;
|
||||
const dukeAvailable = !!dukeContext && !dukeBlocked;
|
||||
const repeatHistory = !!shared &&
|
||||
(g.dukeVidal!.leader.deaths > 1 || p.leaders.some((l) => l.deaths > 1) ||
|
||||
p.revivalCycle > 1);
|
||||
const firstOpening = !!shared && !repeatHistory &&
|
||||
(p.leaders.filter((l) => l.dead && !l.capturedBy && !l.gholaBy && l.deaths === 1).length +
|
||||
(g.dukeVidal!.leader.dead && dukeAvailable &&
|
||||
g.dukeVidal!.leader.deaths === 1 ? 1 : 0) >= 5 ||
|
||||
p.leaders.every((l) => l.dead || l.capturedBy || l.gholaBy));
|
||||
const cycle = shared
|
||||
? (!repeatHistory && (p.revivalCycle === 1 || firstOpening) ? 1 : 0)
|
||||
: normalRevivalCycle(p);
|
||||
const cohort = shared ? quoteEcazRevivalCohort(p, dukeAvailable, waiveOpening) : null;
|
||||
let cycle = cohort?.cycle ?? normalRevivalCycle(p);
|
||||
if (waiveOpening && !cohort) cycle = Math.max(1, cycle);
|
||||
const enabled = g.phase === 4 && !revivalPrevented(g, p.id);
|
||||
const discount = revivalDiscount(g, p);
|
||||
const leaders = p.leaders.flatMap((leader) => {
|
||||
@@ -213,7 +211,8 @@ export function leaderRevivalOptions(
|
||||
isAuditorLeader(leader);
|
||||
const normal =
|
||||
!p.leaderRevived &&
|
||||
(auditorReturn || (cycle > 0 && leader.deaths === cycle));
|
||||
(auditorReturn || (cycle > 0 && (cohort
|
||||
? !cohort.returned.includes(leader.id) : leader.deaths === cycle)));
|
||||
if (
|
||||
!normal &&
|
||||
(p.faction !== 'tleilaxu' ||
|
||||
@@ -257,8 +256,32 @@ export function leaderRevivalOptions(
|
||||
...(g.advanced && p.faction === 'choam' && p.leaders.some(isAuditorLeader)
|
||||
? { auditorCyclePolicy: 'Supplied Advanced p29 permits Auditor revival each turn. Provisional ordinary-cycle policy: only the five ordinary CHOAM discs determine revival cycles; a living or repeatedly killed Auditor neither delays nor opens them. Auditor still uses the normal one-leader allowance.' }
|
||||
: {}),
|
||||
cycleBlock: repeatHistory
|
||||
? 'Ecaz six-disc repeated revival cycles are not yet resolved.' : null };
|
||||
...(shared ? { ecazCyclePolicy: 'Provisional Ecaz cohort policy: five physical leaders in Tanks, including Duke, or no available native leaders opens ordinary returns. A completed native return waits for the remaining native cohort and another opening. A late first death may join the current cohort; independent Duke deaths do not set ordinary cohort numbers.' } : {}) };
|
||||
}
|
||||
|
||||
function ecazCohortOwner(g: Game, leader: string): Player | null {
|
||||
if (!g.dukeVidal) return null;
|
||||
const owner = g.players.find(player => player.faction === 'ecaz');
|
||||
return owner && (leader === DUKE_VIDAL_ID || owner.leaders.some(l => l.id === leader))
|
||||
? owner : null;
|
||||
}
|
||||
|
||||
/** Every actual native return, including independent Ghola/Ambassador/foreign
|
||||
* return, consumes that disc's current ordinary cohort, never an extra slot. */
|
||||
export function recordEcazLeaderReturn(g: Game, leader: Pick<Leader, 'id'>): void {
|
||||
const owner = ecazCohortOwner(g, leader.id);
|
||||
if (!owner) return;
|
||||
const dukeInTanks = g.dukeVidal!.leader.dead && !ecazDukeRevivalBlock(g, owner.id);
|
||||
if (leader.id === DUKE_VIDAL_ID) recordEcazRevivalOpening(owner, dukeInTanks);
|
||||
else completeEcazRevivalCohortUse(owner, dukeInTanks, leader.id);
|
||||
}
|
||||
|
||||
/** Only the first execution skips the first ordinary death cohort. */
|
||||
export function recordEcazLeaderExecution(g: Game, leader: Pick<Leader, 'id'>): void {
|
||||
if (leader.id === DUKE_VIDAL_ID) return;
|
||||
const owner = ecazCohortOwner(g, leader.id);
|
||||
if (owner) recordEcazFirstExecution(owner,
|
||||
g.dukeVidal!.leader.dead && !ecazDukeRevivalBlock(g, owner.id), leader.id);
|
||||
}
|
||||
export type PendingRevival = {
|
||||
player: string;
|
||||
|
||||
Reference in new issue
Block a user