Connect native Moritani and Harkonnen captive Duke rules
This commit is contained in:
1 parent
559b42dd78
commit
a5a6db7868
40 files changed
+1408
-352
No files matched your search
@@ -139,7 +139,7 @@ export function standaloneE3DiscoveryNexusProfile(game: FactionModuleProfile): b
|
||||
game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
|
||||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
|
||||
game.nexusHarkonnenBetrayalPreview) return false;
|
||||
let native: 'ecaz' | 'moritani' | null = null, harkonnen = false;
|
||||
let native: 'ecaz' | 'moritani' | null = null;
|
||||
for (let i = 0; i < game.players.length; i++) {
|
||||
const player = game.players[i];
|
||||
for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false;
|
||||
@@ -152,9 +152,8 @@ export function standaloneE3DiscoveryNexusProfile(game: FactionModuleProfile): b
|
||||
if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; }
|
||||
if (!classic) return false;
|
||||
}
|
||||
if (player.faction === 'harkonnen') harkonnen = true;
|
||||
}
|
||||
return native !== null && !(game.advanced && native === 'moritani' && harkonnen);
|
||||
return native !== null;
|
||||
}
|
||||
|
||||
/** Exactly one original E1/E2 native/classics with Nexus/Discovery and no Skills. */
|
||||
@@ -209,7 +208,7 @@ export function mixedE1E2DiscoveryNexusProfile(game: FactionModuleProfile): bool
|
||||
return ix && choam;
|
||||
}
|
||||
|
||||
/** Both original E3 natives with Nexus/Discovery and no Skills; Advanced capture exclusion remains. */
|
||||
/** Both original E3 natives with Nexus/Discovery and no Skills. */
|
||||
export function pairedE3DiscoveryNexusProfile(game: FactionModuleProfile): boolean {
|
||||
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
|
||||
!game.nexusCards || game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' ||
|
||||
@@ -227,7 +226,6 @@ export function pairedE3DiscoveryNexusProfile(game: FactionModuleProfile): boole
|
||||
if (player.faction === 'ecaz') ecaz = true;
|
||||
else if (player.faction === 'moritani') moritani = true;
|
||||
else {
|
||||
if (game.advanced && player.faction === 'harkonnen') return false;
|
||||
let classic = false;
|
||||
for (const faction of FACTIONS)
|
||||
if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; }
|
||||
|
||||
+4
-4
@@ -197,7 +197,7 @@ export function executeCapturedDuke(
|
||||
/**
|
||||
* The caller authorizes the faction power and resolves any applicable Karama.
|
||||
* Ecaz Nexus Cunning independently revives the set-aside shared disc from Tanks.
|
||||
* Ecaz Nexus Cunning liberates an actual captive; no foreign-Ghola source is legal.
|
||||
* Moritani and Ecaz Nexus Cunning liberate a living captive; no foreign-Ghola source is legal.
|
||||
*/
|
||||
export function acquireDuke(
|
||||
state: DukeState,
|
||||
@@ -217,8 +217,8 @@ export function acquireDuke(
|
||||
if (state.leader.gholaBy)
|
||||
throw new Error('Duke Vidal cannot be acquired from foreign Ghola custody.');
|
||||
if (state.leader.capturedBy) {
|
||||
if (source !== 'ecazNexus' || state.leader.dead)
|
||||
throw new Error('Only Ecaz Nexus Cunning can acquire the living captured Duke.');
|
||||
if ((source !== 'ecazNexus' && source !== 'moritani') || state.leader.dead)
|
||||
throw new Error('Only Moritani acquisition or Ecaz Nexus Cunning can take the living captured Duke.');
|
||||
validCaptive(state, turn);
|
||||
} else if ((state.leader.concealed || state.captureVisibility) &&
|
||||
!(source === 'ecazNexus' && returnedDukeCapture(state))) {
|
||||
@@ -230,7 +230,7 @@ export function acquireDuke(
|
||||
if (source === 'ecazNexus' && !state.leader.dead && state.leader.usedAt)
|
||||
throw new Error('Duke Vidal has already been used in battle this turn.');
|
||||
const acquired = cloneDuke(state);
|
||||
if (source === 'ecazNexus') {
|
||||
if (source === 'ecazNexus' || source === 'moritani') {
|
||||
delete acquired.leader.capturedBy;
|
||||
delete acquired.leader.concealed;
|
||||
delete acquired.captureVisibility;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Game } from './engine';
|
||||
import type { FactionId } from './catalog';
|
||||
import { acquireDuke, DUKE_VIDAL_ID, type DukeState } from './duke-vidal';
|
||||
import type { FactionModuleProfile } from './faction-module-profile';
|
||||
import { standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
|
||||
export type EcazDukeContext = Partial<FactionModuleProfile> & {
|
||||
status: Game['status'];
|
||||
@@ -57,7 +57,8 @@ export function quoteEcazDukeAcquisition(
|
||||
);
|
||||
requireDuke(
|
||||
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
|
||||
(!!g.expansions && standaloneE3NexusProfile(g as FactionModuleProfile)),
|
||||
(!!g.expansions && (standaloneE3NexusProfile(g as FactionModuleProfile) ||
|
||||
pairedE3NexusProfile(g as FactionModuleProfile) || mixedE3NexusProfile(g as FactionModuleProfile))),
|
||||
tableBlock,
|
||||
'table',
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Game, Player } from './engine';
|
||||
import type { Leader } from './cards';
|
||||
import { DUKE_VIDAL_ID, returnedDukeCapture, type DukeState } from './duke-vidal';
|
||||
import type { FactionModuleProfile } from './faction-module-profile';
|
||||
import { standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
|
||||
export const DUKE_REVIVAL_NORMAL_COST = 5 as const;
|
||||
export type EcazDukeRevivalContext = Partial<FactionModuleProfile> & {
|
||||
@@ -85,7 +85,8 @@ export function resolveEcazDukeRevival(
|
||||
);
|
||||
requireDuke(
|
||||
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
|
||||
(!!g.expansions && standaloneE3NexusProfile(g as FactionModuleProfile)),
|
||||
(!!g.expansions && (standaloneE3NexusProfile(g as FactionModuleProfile) ||
|
||||
pairedE3NexusProfile(g as FactionModuleProfile) || mixedE3NexusProfile(g as FactionModuleProfile))),
|
||||
'table',
|
||||
);
|
||||
const duke = g.dukeVidal;
|
||||
|
||||
+54
-71
@@ -52,6 +52,7 @@ import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningR
|
||||
import { createNexusFremenProtection, currentNexusFremenProtectionOwner, answerNexusFremenProtection, validateNexusFremenProtection, type NexusFremenProtectionFrame, type NexusFremenProtectionInput } from './nexus-fremen-protection';
|
||||
import { truthKnowledgeOf } from './truthtrance-knowledge';
|
||||
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
|
||||
import { moritaniTerrorAssassinationPool, moritaniTerrorAssassinationValue } from './moritani-terror-assassination';
|
||||
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
|
||||
import { shipmentAvailable } from './shipment-opportunity';
|
||||
import { createGuildBetrayalInvoice, createGuildBetrayalReceipt, guildBetrayalEligible, guildBetrayalResponders, validateGuildBetrayalInvoice, validateGuildBetrayalHistory, nexusGuildBetrayalSourceReceipt, type GuildBetrayalInvoice, type GuildBetrayalCursor, type GuildBetrayalReceipt, type GuildBetrayalAuthority } from './nexus-guild-betrayal';
|
||||
@@ -464,6 +465,7 @@ import {
|
||||
captureDuke,
|
||||
releaseCapturedDuke,
|
||||
executeCapturedDuke,
|
||||
returnedDukeCapture,
|
||||
type DukeReleasedCaptureWitness,
|
||||
DUKE_VIDAL_ID,
|
||||
type DukeState,
|
||||
@@ -8303,11 +8305,12 @@ function syncMoritaniAssassinateReceipt(g: Game, receipt: MoritaniAssassinateRec
|
||||
if (g.lastBattleContext?.event === receipt.event)
|
||||
g.lastBattleContext.moritaniAssassinate = {...g.lastBattleContext.moritaniAssassinate,event:receipt.event,signature:receipt.signature};
|
||||
}
|
||||
function recordMoritaniAssassinateOpportunity(g: Game, b: Battle, winner: Player | undefined, loser: Player | null) {
|
||||
function recordMoritaniAssassinateOpportunity(g: Game, b: Battle, winner: Player | undefined, loser: Player | null,
|
||||
opposingLeader: Leader | undefined) {
|
||||
const state = g.moritaniAssassinate;
|
||||
if (!state || state.normalTraitorCall || !winner || loser?.id !== state.owner ||
|
||||
state.opportunities.some(r => r.faction === winner.faction && ['revealed','replaced'].includes(r.stage))) return;
|
||||
const opposingLeader = winner.leaders.find(l => l.id === b.plans[winner.id].leader);
|
||||
if (opposingLeader?.id !== b.plans[winner.id].leader) return;
|
||||
const trigger = {advanced:g.advanced,owner:state.owner,ownerFaction:loser.faction,opponent:winner.id,
|
||||
faction:winner.faction,event:g.lastBattleContext!.event,turn:g.turn,territory:b.territory,
|
||||
opposingLeader:opposingLeader?.id ?? null,opposingLeaderSurvived:!!opposingLeader && !opposingLeader.dead,
|
||||
@@ -8348,7 +8351,13 @@ function actMoritaniAssassinate(g: Game, decision: Extract<Decision,{kind:'morit
|
||||
requireRule(Object.keys(action).every(k => ['type','event','card'].includes(k)), 'Choose one assassination Traitor Card or continue.');
|
||||
const quote = nexusRule(() => quoteMoritaniAssassinate(moritaniAssassinateContext(g,r),stringField(action.card)));
|
||||
const target = getPlayer(g,r.opponent).leaders.find(l => l.id === quote.leader)!;
|
||||
if (quote.kill) {target.dead = true;target.deaths++;delete target.usedAt;}
|
||||
if (quote.kill) {
|
||||
target.dead = true;
|
||||
target.deaths++;
|
||||
delete target.usedAt;
|
||||
if (getPlayer(g, r.opponent).faction === 'harkonnen') returnCaptives(g, getPlayer(g, r.opponent));
|
||||
finishLeaderSkillCustody(g);
|
||||
}
|
||||
const owner = getPlayer(g,r.owner);
|
||||
requireRule(Number.isSafeInteger(owner.spice + quote.bounty), 'Assassination income exceeds the supported spice balance.');
|
||||
owner.spice += quote.bounty;
|
||||
@@ -17465,8 +17474,9 @@ function ecazAllianceQuote(g: Game, owner: string, entrant: string) {
|
||||
}
|
||||
}
|
||||
function ecazAmbassadorLoanBlock(g: Game, owner: Player, entrant: Player) {
|
||||
if (g.advanced && byFaction(g, 'harkonnen') && !standaloneE3NexusProfile(g))
|
||||
return 'Advanced Duke loans with Harkonnen capture remain guarded.';
|
||||
if (g.advanced && byFaction(g, 'harkonnen') &&
|
||||
!(standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g)))
|
||||
return 'Advanced Duke loans with Harkonnen capture remain guarded in this profile.';
|
||||
if (owner.ally !== entrant.id || entrant.ally !== owner.id)
|
||||
return 'The new Ecaz alliance is no longer current.';
|
||||
const duke = g.dukeVidal;
|
||||
@@ -18021,7 +18031,7 @@ function continueTerrorDiscard(
|
||||
};
|
||||
} else finishTerrorEntry(g);
|
||||
}
|
||||
/** Keep unsupported custody out of the random draw, without leaking concealed captures. */
|
||||
/** Public effect admission only; the private living-disc pool is quoted at reveal. */
|
||||
function terrorRevealBlocked(
|
||||
g: Game,
|
||||
entry: NonNullable<Game['pendingTerrorEntry']>,
|
||||
@@ -18040,39 +18050,6 @@ function terrorRevealBlocked(
|
||||
return null;
|
||||
if (kind !== 'assassination')
|
||||
return 'This Terror effect is still being implemented.';
|
||||
if (g.advanced && byFaction(g, 'harkonnen'))
|
||||
return 'Assassination with advanced Harkonnen capture is still being implemented.';
|
||||
const entrant = getPlayer(g, entry.entrant);
|
||||
if (g.dukeVidal?.controller === entrant.id)
|
||||
return 'Assassination with Duke Vidal in the leader pool is still being implemented.';
|
||||
const catalog = leaders(entrant.faction);
|
||||
if (
|
||||
catalog.length !== 5 ||
|
||||
entrant.leaders.length !== catalog.length ||
|
||||
new Set(entrant.leaders.map((leader) => leader.id)).size !==
|
||||
catalog.length ||
|
||||
entrant.leaders.some(
|
||||
(leader) =>
|
||||
!catalog.some(
|
||||
(native) =>
|
||||
native.id === leader.id &&
|
||||
native.faction === leader.faction &&
|
||||
native.strength === leader.strength,
|
||||
),
|
||||
)
|
||||
)
|
||||
return 'Assassination requires a verified ordinary leader pool; this pool is still being implemented.';
|
||||
if (
|
||||
entrant.leaders.some((leader) => leader.capturedBy || leader.gholaBy) ||
|
||||
g.players.some(
|
||||
(other) =>
|
||||
other.id !== entrant.id &&
|
||||
other.leaders.some((leader) => controlsLeader(entrant, leader)),
|
||||
)
|
||||
)
|
||||
return 'Assassination with captured or foreign ghola leaders is still being implemented.';
|
||||
if (!entrant.leaders.some((leader) => !leader.dead))
|
||||
return 'Assassination with an empty living leader pool needs a verified ruling.';
|
||||
return null;
|
||||
}
|
||||
function sneakAttackOptions(
|
||||
@@ -18229,6 +18206,8 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
'Reveal the Terror token or leave it hidden.',
|
||||
);
|
||||
const atomics = token.kind === 'atomics' ? currentAtomicsQuote(g, entry) : null;
|
||||
const assassination = token.kind === 'assassination'
|
||||
? nexusRule(() => moritaniTerrorAssassinationPool(g, entrant.id)) : null;
|
||||
const blocked = atomics ? null : terrorRevealBlocked(g, entry, token.kind);
|
||||
requireRule(!blocked, blocked ?? 'This Terror effect is unavailable.');
|
||||
g.moritaniTerror = revealTerror(g.moritaniTerror!, token.id);
|
||||
@@ -18241,17 +18220,23 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
entry.stage = 'robbery';
|
||||
reopen();
|
||||
} else if (token.kind === 'assassination') {
|
||||
const victim = shuffle(
|
||||
entrant.leaders.filter((leader) => !leader.dead),
|
||||
)[0];
|
||||
victim.dead = true;
|
||||
victim.deaths++;
|
||||
delete victim.usedAt;
|
||||
p.spice += victim.strength;
|
||||
log(
|
||||
g,
|
||||
`${victim.name} was assassinated; ${p.name} received ${victim.strength} spice.`,
|
||||
);
|
||||
const victim = assassination!.length ? shuffle(assassination!)[0] : null;
|
||||
if (victim) {
|
||||
const bounty = moritaniTerrorAssassinationValue(victim);
|
||||
victim.dead = true;
|
||||
victim.deaths++;
|
||||
delete victim.usedAt;
|
||||
delete victim.capturedBy;
|
||||
delete victim.concealed;
|
||||
if (victim.id === DUKE_VIDAL_ID) g.dukeVidal = consumeDuke(g.dukeVidal!);
|
||||
const harkonnen = byFaction(g, 'harkonnen');
|
||||
if (harkonnen) returnCaptives(g, harkonnen);
|
||||
finishLeaderSkillCustody(g);
|
||||
p.spice += bounty;
|
||||
log(g, `${victim.name} was assassinated; ${p.name} received ${bounty} spice.`);
|
||||
} else {
|
||||
log(g, `${p.name} revealed Assassination, but the entrant controlled no living leader. No leader died and no spice was awarded.`);
|
||||
}
|
||||
finishTerrorEntry(g);
|
||||
} else if (token.kind === 'sneakAttack') {
|
||||
entry.stage = 'sneakAttack';
|
||||
@@ -18507,14 +18492,13 @@ function offerMoritaniDuke(g: Game) {
|
||||
g.dukeAcquisitionTurn = g.turn;
|
||||
g.dukeVidal ??= createDukeVidal();
|
||||
const duke = g.dukeVidal;
|
||||
if (duke.leader.dead || duke.controller === owner.id) return false;
|
||||
if (duke.leader.capturedBy || duke.leader.gholaBy) {
|
||||
log(
|
||||
g,
|
||||
'Duke Vidal acquisition with exceptional capture or ghola custody is still being implemented.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const captor = duke.leader.capturedBy ?? duke.leader.concealed?.captor;
|
||||
requireRule(!captor || (g.advanced && byFaction(g, 'harkonnen')?.id === captor),
|
||||
'Duke acquisition lost its original native Harkonnen capture source.');
|
||||
const publicDuke = projectedSharedDuke(g, '');
|
||||
if (!publicDuke || publicDuke.leader.dead ||
|
||||
(publicDuke.controller === owner.id && publicDuke.acquiredTurn === g.turn) ||
|
||||
duke.leader.gholaBy) return false;
|
||||
const strongholds = new Set(
|
||||
battles(g)
|
||||
.filter(
|
||||
@@ -20643,7 +20627,7 @@ function leaderHiddenFrom(g: Game, leader: Leader, viewer: string): boolean {
|
||||
if (!concealed) return false;
|
||||
const owner = g.players.find(player => player.faction === leader.faction);
|
||||
return ![concealed.controller ?? owner?.id, concealed.captor].includes(viewer) &&
|
||||
!(getPlayer(g, viewer).faction === 'tleilaxu' && leader.dead && !leader.capturedBy);
|
||||
!(g.players.find(player => player.id === viewer)?.faction === 'tleilaxu' && leader.dead && !leader.capturedBy);
|
||||
}
|
||||
function projectLeader(g: Game, l: Leader, viewer: string): Leader {
|
||||
const { concealed, capturedBy, ...visible } = l;
|
||||
@@ -23123,7 +23107,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
...(inspection ? { nexusChoamInspection: inspection } : {}),
|
||||
...(nexusRetention ? { nexusMoritaniRetention: nexusRetention } : {}),
|
||||
};
|
||||
recordMoritaniAssassinateOpportunity(g, b, winner, losingPlayer);
|
||||
recordMoritaniAssassinateOpportunity(g, b, winner, losingPlayer, winner === a ? al : dl);
|
||||
if (quote.smuggler) {
|
||||
const { receipt, amount } = quote.smuggler;
|
||||
const collector = getPlayer(g, receipt.player);
|
||||
@@ -25073,17 +25057,16 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
g.phase === 5 && g.dukeAcquisitionTurn === g.turn && g.dukeVidal,
|
||||
'This Duke Vidal acquisition is no longer current.',
|
||||
);
|
||||
if (!canceled) {
|
||||
g.dukeVidal = acquireDuke(
|
||||
g.dukeVidal,
|
||||
response.owner,
|
||||
g.turn,
|
||||
'moritani',
|
||||
);
|
||||
log(
|
||||
g,
|
||||
`${getPlayer(g, response.owner).name} acquired Duke Vidal for one battle this turn.`,
|
||||
);
|
||||
if (!canceled && !g.dukeVidal.leader.dead) {
|
||||
g.dukeVidal = acquireDuke(g.dukeVidal, response.owner, g.turn, 'moritani');
|
||||
log(g, `${getPlayer(g, response.owner).name} acquired Duke Vidal for one battle this turn.`);
|
||||
} else if (!canceled) {
|
||||
// A qualified public acquisition resolves the concealed disc's availability;
|
||||
// never revive a privately executed Duke or leak it through a private-only gate.
|
||||
requireRule(returnedDukeCapture(g.dukeVidal),
|
||||
'The unavailable acquisition lost the original executed Duke return.');
|
||||
g.dukeVidal = consumeDuke(g.dukeVidal);
|
||||
log(g, `${getPlayer(g, response.owner).name}'s Duke acquisition resolved with the shared disc in the Tanks. No acquisition occurred.`);
|
||||
} else
|
||||
log(g, 'Karama prevented Moritani from acquiring Duke Vidal this turn.');
|
||||
nextPhase(g);
|
||||
|
||||
@@ -85,7 +85,7 @@ export function mixedE3NexusRosterProfile(game: MixedE3NativeRoster, skills = fa
|
||||
if (expansion !== 'ecaz' && expansion !== 'ix' && expansion !== 'choam') return false;
|
||||
for (let j = 0; j < i; j++) if (expansion === game.expansions[j]) return false;
|
||||
}
|
||||
let ix = false, choam = false, e3 = false, moritani = false, harkonnen = false;
|
||||
let ix = false, choam = false, e3 = false, harkonnen = false;
|
||||
for (let i = 0; i < players.length; i++) {
|
||||
const player = players[i];
|
||||
for (let j = 0; j < i; j++) if (player.faction === players[j].faction) return false;
|
||||
@@ -96,9 +96,8 @@ export function mixedE3NexusRosterProfile(game: MixedE3NativeRoster, skills = fa
|
||||
if (family === 'ix') ix = true;
|
||||
if (family === 'choam') choam = true;
|
||||
if (family === 'ecaz') e3 = true;
|
||||
if (player.faction === 'moritani') moritani = true;
|
||||
if (player.faction === 'harkonnen') harkonnen = true;
|
||||
}
|
||||
return e3 && ix === game.expansions.includes('ix') && choam === game.expansions.includes('choam') &&
|
||||
!(game.advanced && harkonnen && (skills || moritani));
|
||||
!(game.advanced && harkonnen && skills);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { leaders as printedLeaders, type Leader } from './cards';
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
|
||||
export type MoritaniAssassinateReceipt = {
|
||||
event: string;
|
||||
@@ -63,6 +64,7 @@ const opponents: readonly FactionId[] = [
|
||||
'beneGesserit',
|
||||
'guild',
|
||||
'emperor',
|
||||
'harkonnen',
|
||||
'fremen',
|
||||
'ecaz',
|
||||
'ixians',
|
||||
@@ -95,6 +97,13 @@ function printed(faction: FactionId, id: string) {
|
||||
function ordinaryIdentity(id: string) {
|
||||
return FACTIONS.some((f) => printed(f.id, id));
|
||||
}
|
||||
/** Harkonnen may fight with a captive without changing its printed identity.
|
||||
* Actual control and survival are bound by the engine, not by this receipt. */
|
||||
function opposingDiscIdentity(faction: FactionId, id: string) {
|
||||
return !!printed(faction, id) ||
|
||||
(faction === 'harkonnen' &&
|
||||
(id === DUKE_VIDAL_ID || ordinaryIdentity(id)));
|
||||
}
|
||||
/** Consistency proof only; the engine separately binds the battle event and
|
||||
* phase continuation. No secret card identity enters this public trigger. */
|
||||
export function moritaniAssassinateTrigger(
|
||||
@@ -113,7 +122,7 @@ export function moritaniAssassinateTrigger(
|
||||
input.turn > 0 &&
|
||||
opponents.includes(input.faction) &&
|
||||
identifier(input.opposingLeader) &&
|
||||
!!printed(input.faction, input.opposingLeader) &&
|
||||
opposingDiscIdentity(input.faction, input.opposingLeader) &&
|
||||
input.opposingLeaderSurvived === true &&
|
||||
input.normalBattle === true &&
|
||||
input.lostBattle === true &&
|
||||
@@ -215,7 +224,7 @@ export function validateMoritaniAssassinate(
|
||||
(!custody || r.turn <= custody.turn) &&
|
||||
(index === 0 || r.turn >= state.opportunities[index - 1].turn) &&
|
||||
identifier(r.opposingLeader) &&
|
||||
!!printed(r.faction, r.opposingLeader) &&
|
||||
opposingDiscIdentity(r.faction, r.opposingLeader) &&
|
||||
['skills', 'choice', 'declined', 'revealed', 'replaced'].includes(r.stage) &&
|
||||
r.signature === moritaniAssassinateSignature(r),
|
||||
'Assassinate has a changed, duplicated or malformed battle opportunity.',
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
CHOAM_AUDITOR_ID,
|
||||
createAuditorLeader,
|
||||
leaders,
|
||||
type Leader,
|
||||
} from './cards';
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
import {
|
||||
createDukeVidal,
|
||||
DUKE_VIDAL_ID,
|
||||
dukeController,
|
||||
type DukeState,
|
||||
} from './duke-vidal';
|
||||
import { controlsLeader } from './leader-control';
|
||||
|
||||
export type MoritaniTerrorAssassinationContext = Readonly<{
|
||||
players: readonly Readonly<{
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
leaders: readonly Leader[];
|
||||
}>[];
|
||||
advanced?: boolean;
|
||||
dukeVidal?: Readonly<DukeState>;
|
||||
}>;
|
||||
|
||||
// Identity metadata only: candidates always come from the actual server state.
|
||||
const printed: Readonly<Record<string, Leader | undefined>> = Object.fromEntries(
|
||||
[
|
||||
...FACTIONS.flatMap((faction) => leaders(faction.id)),
|
||||
createAuditorLeader(),
|
||||
createDukeVidal().leader,
|
||||
].map((leader) => [leader.id, leader] as const),
|
||||
);
|
||||
const nativeIds: Readonly<Partial<Record<FactionId, readonly string[]>>> = Object.fromEntries(
|
||||
FACTIONS.map((faction) => [
|
||||
faction.id,
|
||||
leaders(faction.id).map((leader) => leader.id),
|
||||
] as const),
|
||||
);
|
||||
function requirePool(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
function physical(leader: Leader): Leader {
|
||||
const original = printed[leader.id];
|
||||
requirePool(
|
||||
original && leader.name === original.name &&
|
||||
leader.faction === original.faction && leader.strength === original.strength &&
|
||||
typeof leader.dead === 'boolean' &&
|
||||
Number.isSafeInteger(leader.deaths) && leader.deaths >= 0 &&
|
||||
(leader.usedAt === undefined ||
|
||||
(typeof leader.usedAt === 'string' && !!leader.usedAt.trim())),
|
||||
'Terror Assassination requires an unaltered physical leader identity and history.',
|
||||
);
|
||||
return original;
|
||||
}
|
||||
|
||||
/** Server-only provisional effective-control pool; never project or gate an offer on it.
|
||||
* Battle use does not remove a living disc. The caller alone draws, reveals and kills. */
|
||||
export function moritaniTerrorAssassinationPool(
|
||||
g: MoritaniTerrorAssassinationContext,
|
||||
entrantId: string,
|
||||
): Leader[] {
|
||||
const seats = new Map(g.players.map((player) => [player.id, player]));
|
||||
requirePool(
|
||||
seats.size === g.players.length &&
|
||||
g.players.every((player) => typeof player.id === 'string' && !!player.id.trim()) &&
|
||||
new Set(g.players.map((player) => player.faction)).size === g.players.length,
|
||||
'Terror Assassination requires unique native player custody.',
|
||||
);
|
||||
const entrant = seats.get(entrantId);
|
||||
requirePool(entrant, 'Terror Assassination requires the actual entrant.');
|
||||
const seen = new Set<string>();
|
||||
const pool: Leader[] = [];
|
||||
function custody(leader: Leader, owner: string | null) {
|
||||
physical(leader);
|
||||
requirePool(!seen.has(leader.id), 'Terror Assassination found a duplicated physical leader.');
|
||||
seen.add(leader.id);
|
||||
if (leader.gholaBy !== undefined)
|
||||
requirePool(
|
||||
g.advanced === true && leader.id !== DUKE_VIDAL_ID &&
|
||||
leader.id !== CHOAM_AUDITOR_ID && leader.gholaBy !== owner &&
|
||||
seats.get(leader.gholaBy)?.faction === 'tleilaxu',
|
||||
'Terror Assassination requires original foreign-Ghola custody; Duke revival is Ecaz-only.',
|
||||
);
|
||||
if (leader.capturedBy !== undefined)
|
||||
requirePool(
|
||||
g.advanced === true && leader.id !== CHOAM_AUDITOR_ID &&
|
||||
leader.capturedBy !== (leader.gholaBy ?? owner) &&
|
||||
seats.get(leader.capturedBy)?.faction === 'harkonnen',
|
||||
'Terror Assassination requires the actual native Harkonnen captor.',
|
||||
);
|
||||
}
|
||||
for (const player of g.players) {
|
||||
const ordinary = nativeIds[player.faction];
|
||||
const hasAuditor = player.leaders.some((leader) => leader.id === CHOAM_AUDITOR_ID);
|
||||
requirePool(
|
||||
ordinary?.length === 5 &&
|
||||
player.leaders.length === 5 + (hasAuditor ? 1 : 0) &&
|
||||
ordinary.every((id) => player.leaders.some((leader) => leader.id === id)) &&
|
||||
(!hasAuditor || (g.advanced === true && player.faction === 'choam')),
|
||||
'Terror Assassination requires the original five native discs and only the actual optional Auditor.',
|
||||
);
|
||||
for (const leader of player.leaders) {
|
||||
requirePool(
|
||||
leader.id !== DUKE_VIDAL_ID && leader.faction === player.faction &&
|
||||
(leader.controller === undefined || leader.controller === player.id),
|
||||
'Terror Assassination requires each disc in its original native roster.',
|
||||
);
|
||||
custody(leader, player.id);
|
||||
if (!leader.dead && controlsLeader(entrant, leader)) pool.push(leader);
|
||||
}
|
||||
}
|
||||
if (g.dukeVidal) {
|
||||
const duke = g.dukeVidal;
|
||||
requirePool(
|
||||
duke.leader.id === DUKE_VIDAL_ID &&
|
||||
(duke.leader.controller === undefined || duke.leader.controller === duke.controller) &&
|
||||
(duke.controller === null
|
||||
? duke.acquiredTurn === null && duke.source === null
|
||||
: seats.has(duke.controller) && Number.isSafeInteger(duke.acquiredTurn) &&
|
||||
duke.acquiredTurn! > 0 &&
|
||||
['moritani', 'ecaz', 'ecazNexus', 'ally'].includes(duke.source!)),
|
||||
'Terror Assassination requires the canonical separate shared Duke custody.',
|
||||
);
|
||||
custody(duke.leader, duke.controller);
|
||||
if (!duke.leader.dead && dukeController(duke) === entrantId) pool.push(duke.leader);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
/** Printed bounty, never Zoal's copied battle strength or a battle-skill bonus. */
|
||||
export function moritaniTerrorAssassinationValue(leader: Leader): number {
|
||||
const original = physical(leader);
|
||||
if (original.id === 'tleilaxu-0') return 3;
|
||||
if (original.id === DUKE_VIDAL_ID) return 6;
|
||||
return original.strength;
|
||||
}
|
||||
@@ -122,10 +122,7 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa
|
||||
const eventCustody = masked && duke?.captureVisibility ? duke.captureVisibility : duke;
|
||||
const roster = g.players.map(player => player.faction);
|
||||
let blocked: string | null = null;
|
||||
if (g.advanced && g.players.some(player => player.faction === 'harkonnen') &&
|
||||
!standaloneE3NexusProfile(g))
|
||||
blocked = 'Duke Vidal battle use is unavailable with Advanced Harkonnen in this profile.';
|
||||
else if (!nexusEcazDukeModeSupported(g) || nexusCardMode('ecaz', holder.faction, roster) !== 'cunning')
|
||||
if (!nexusEcazDukeModeSupported(g) || nexusCardMode('ecaz', holder.faction, roster) !== 'cunning')
|
||||
blocked = 'Ecaz Nexus Cunning requires a supported original paired or standalone Ecaz Nexus profile.';
|
||||
else if (holder.ally) blocked = 'Use Ecaz Nexus Cunning while unallied.';
|
||||
else if (g.status !== 'playing')
|
||||
@@ -162,8 +159,7 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa
|
||||
export function nexusEcazDukeModeSupported(g: Game): boolean {
|
||||
if ((mixedE3NexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) &&
|
||||
g.players.some(player => player.faction === 'ecaz') &&
|
||||
!g.sandtrout && (!(g.advanced && g.players.some(player => player.faction === 'harkonnen')) ||
|
||||
standaloneE3NexusProfile(g)))
|
||||
!g.sandtrout)
|
||||
return !!g.nexusCards?.cards && g.players.length >= 3;
|
||||
if (g.leaderSkills && (mixedE3NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3NexusLeaderSkillsProfile(g)) &&
|
||||
g.players.some(player => player.faction === 'ecaz') && !g.sandtrout)
|
||||
|
||||
@@ -78,7 +78,7 @@ export function standaloneE3NexusProfile(game: FactionModuleProfile): boolean {
|
||||
game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview ||
|
||||
game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
|
||||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
|
||||
let native: 'ecaz' | 'moritani' | null = null, harkonnen = false;
|
||||
let native: 'ecaz' | 'moritani' | null = null;
|
||||
for (let i = 0; i < game.players.length; i++) {
|
||||
const player = game.players[i];
|
||||
for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false;
|
||||
@@ -91,9 +91,8 @@ export function standaloneE3NexusProfile(game: FactionModuleProfile): boolean {
|
||||
if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; }
|
||||
if (!classic) return false;
|
||||
}
|
||||
if (player.faction === 'harkonnen') harkonnen = true;
|
||||
}
|
||||
return native !== null && !(game.advanced && native === 'moritani' && harkonnen);
|
||||
return native !== null;
|
||||
}
|
||||
|
||||
/** Exactly one original E1/E2 native/classics without Skills, with optional original modules. */
|
||||
@@ -170,7 +169,6 @@ export function pairedE3NexusProfile(game: FactionModuleProfile): boolean {
|
||||
if (player.faction === 'ecaz') ecaz = true;
|
||||
else if (player.faction === 'moritani') moritani = true;
|
||||
else {
|
||||
if (game.advanced && player.faction === 'harkonnen') return false;
|
||||
let classic = false;
|
||||
for (const faction of FACTIONS)
|
||||
if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; }
|
||||
|
||||
+24
-17
@@ -2921,15 +2921,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'Original standalone, paired and mixed-family no-Skills E3 Nexus connects Advanced Harkonnen capture of the same shared disc, surviving just-used losing Duke, Keep/Execute and captive returns. Qualifying Moritani can take a living captive permanently, preserving native identity and death/use history; one battle or unused expiry never restores the captor. Takeover, capture priority and return destinations are provisional compositions.',
|
||||
'Public Moritani qualification uses the public Duke snapshot, not secret execution status. Karama preserves existing capture if it cancels the grant. Allowed resolution publishes actual unavailability: an executed Duke stays dead in Ecaz Tanks, never revived by Moritani. This provisional public-effect policy does not promise permanent execution secrecy. Ecaz-only revival, Ambassador exclusions, Skills/Harkonnen and shared-training guards remain; 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'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Native Ecaz identity cohorts and independent Duke return remain connected. Original no-Skills E3 Nexus admits Harkonnen; qualifying Moritani takes living captive Duke without restoring the captor after use/expiry. Public snapshot qualification resolves actual execution availability without resurrection. Native five-disc identity, Ecaz-only revival, costs and Skills/shared-training guards remain.', evidence: ['game/ecaz-revival-cohort.ts', 'game/revival.ts', 'game/ecaz-duke-revival.ts', 'game/duke-vidal.ts', 'game/nexus-ecaz-duke.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Actual styled390px table finishes original movement, shows reclaimed Duke6 Ready and removes him after actual battle use. Original Karama cancels takeover; a real captive-Duke winner opens native Harkonnen Traitor reveal4 and Mentat replacement. Earlier original cohort11 to8/12 to10 controls remain. No overflow/browser errors; full journeys open.', evidence: ['components/game-table.tsx', 'components/duke-vidal.tsx', 'components/moritani-assassinate.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four original Harkonnen response policies legally pass the native acquisition window; Terror policies legally reveal or form an accepted alliance. Earlier four-profile cohort returns remain. These are minimal legal paths, not optimal counterplay, four Duke kills or strategic calibration.', evidence: ['game/bots.ts', 'game/nexus-ecaz-duke-options.ts', 'game/revival.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed native identity, take-from-controller and Ecaz-only Duke5/set-aside remain distinct from provisional takeover/return, public availability and identity-cohort compositions. Moritani never revives execution; Ambassador captive exclusion remains. No missing publisher priority FAQ or numerical user ruling is claimed.', evidence: ['docs/ECAZ_REVIVAL_RULES.md', 'docs/DUKE_VIDAL_RULES.md', 'docs/MORITANI_ASSASSINATION_SNEAK.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Current affected22-file union195/195, types/lint/build and14 actual API/policy paths pass. Real native capture, takeover/use/expiry, private execution, original Karama, captive-Duke winning identity and Terror death6 have actual390px proof. Empty Terror boundary stages preceding Guild deaths. Earlier cohort19-file230/230 proof remains historical. No saved/deployed rooms touched; combinations, assurance and full acceptance remain open.', evidence: ['tests/moritani-duke-custody.test.ts', 'tests/fixture-moritani-duke-capture.ts', 'tests/moritani-duke-captured-runtime.test.ts', 'tests/moritani-terror-assassination-runtime.test.ts', 'tests/ecaz-revival-cohort-runtime.test.ts', 'tests/ecaz-duke-revival.test.ts'] },
|
||||
],
|
||||
related: [
|
||||
'faction-ecaz',
|
||||
@@ -2975,8 +2975,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary:
|
||||
'Random assassination, territorial destruction, deferred income, theft, sabotage and reserve entry.',
|
||||
steps: [
|
||||
'Robbery, Sabotage, Sneak Attack, ordinary native-leader Assassination, Extortion and bounded Atomics have controls for supported entry reactions. After a Robbery draw, finish any required discard; after Sabotage, give one of your own cards or decline. Enemy of My Enemy has partial alliance controls. Complete Moritani games remain unfinished.',
|
||||
'Assassination: randomly select one of the entrant’s leaders and send it to the Tanks. Moritani collects spice for that leader’s value; Zoal pays three. Revealing the token resolves the random selection without letting Moritani choose a victim. This token needs neither a battle loss nor a matching Traitor Card. Current controls support ordinary native leaders; unresolved captured, foreign or special-leader pools block revelation and show Moritani the reason privately.',
|
||||
'Robbery, Sabotage, Sneak Attack, effective-control Assassination, Extortion and bounded Atomics have controls for supported entry reactions. After a Robbery draw, finish any required discard; after Sabotage, give one of your own cards or decline. Enemy of My Enemy has partial alliance controls. Complete Moritani games remain unfinished.',
|
||||
'Assassination randomly kills one actual living effectively controlled disc: native, captive, ordinary foreign Ghola, shared Duke or optional Advanced Auditor, once each. Native discs controlled elsewhere are excluded; living used discs remain eligible. The provisional public-victim policy reveals the actual killed disc and pays native printed value, Zoal3 or Duke6. Empty revelation spends the token without victim, bounty or RNG and resumes arrival. No pool/size is projected or used to gate availability; Moritani cannot choose a victim. These exceptional/empty policies are product compositions, not a located publisher FAQ.',
|
||||
'Atomics sends every faction’s forces in the territory to the Tanks and places public Aftermath. Supported revelation remains bounded to classic-plus-Moritani without Guild, Ecaz or optional modules. Later alliances now retain the original affected hand-limit targets under the explicit provisional interpretation in the Aftermath topic.',
|
||||
'Extortion: set aside five spice from the bank for collection during Mentat Pause. After collection, recover this token unless one player pays you three spice in the storm-order opportunity. The Extortion topic explains the two separate payments.',
|
||||
'Robbery: choose between taking half the entrant’s spice, rounded up, and drawing the top Treachery Card. If the draw exceeds your hand limit, choose a card to discard afterward; a full hand does not remove the draw option.',
|
||||
@@ -2985,6 +2985,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Before revealing Sneak Attack, inspect its private available maximum and any reason positive entry is blocked. You may keep it hidden. Revealing spends the token even if you then choose Send no forces. For a supported entry, select zero through the displayed maximum; forces enter the arrival’s territory and sector without consuming your ordinary shipment or movement turn. Zero remains available when positive entry is blocked. Aftermath’s printed exception allows this reserve entry even after Atomics resolves. A positive entry during Shipment and Movement is treated as an off-planet shipment for the Heighliners token, which accrues its once-per-turn income for its owner, and the committed entry finishes before Bene Gesserit may answer it with an Intrusion choice or a free spiritual-advisor shipment.',
|
||||
'The random Assassination token is separate from the advanced Assassinate Leaders advantage. Neither ability supplies the other’s selection or timing rules.',
|
||||
],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Focused canonical effective-control pool reuses original custody; original token resolution kills/releases actual discs, pays printed bounty and resumes committed arrival. Empty pool spends without RNG; malformed physical identities reject before mutation. Other Terror effects retain their own scope.', evidence: ['game/moritani-terror-assassination.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Original actual390px Reveal Assassination resolves a captive Duke death6, displays Tanks inspection and resumes native Harkonnen movement. No selectable victim or projected pool; no overflow/browser errors.', evidence: ['components/game-table.tsx', 'components/duke-vidal.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four policies make accepted original decisions: Easy reveals, Medium/Hard/Brutal form an accepted Enemy of My Enemy alliance in the actual native position. Legal participation only; no calibrated strategy claim.', evidence: ['game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Effective-control exceptional pool, public victim and spent-empty behavior are labelled provisional and separate from Advanced battle-loss Assassinate Leaders. Native printed values and no foreign-Ghola Duke remain binding.', evidence: ['docs/MORITANI_ASSASSINATION_SNEAK.md', 'docs/MORITANI_TERROR_RULES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Affected22-file195/195 and types/lint/build pass. Pure custody/identity/value boundaries plus original capture-to-Terror runtime and real table death6 pass. Empty boundary resumes genuine Guild entry but stages prior native deaths. Full combinations/deployed acceptance open.', evidence: ['tests/moritani-terror-assassination.test.ts', 'tests/moritani-terror-assassination-runtime.test.ts', 'tests/moritani-force-leader.test.ts'] },
|
||||
],
|
||||
related: [
|
||||
'moritani-terror',
|
||||
'moritani-aftermath',
|
||||
@@ -3137,15 +3144,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'You may reveal a Traitor Card for that opposing faction, but it must name a different leader from the one you just fought. If that named leader is not in the Tanks, kill it and collect spice for its value.',
|
||||
'An already-dead named leader remains a legal reveal, awarding no death or spice. During Mentat Pause, set the revealed card aside face up as a marker and draw a new Traitor Card. You can use this advantage only once against each faction in the game.',
|
||||
'The authorized revised Advanced PDF, physical page35, requires that you have not revealed your Traitor Card normally this game. A normal own-card reveal therefore forfeits Assassinate Leaders for the rest of this game, across turns and opponents. Existing state preserves that forfeiture; Karama has no effect against the ability. This is the authorized source, not a new GF9 erratum.',
|
||||
'If Harkonnen has captured your own leader and you hold its Traitor Card, you may call that traitor normally; you may not use Assassinate Leaders on that basis.',
|
||||
'A victorious Harkonnen may have played its own native disc, a captured foreign native disc including Moritani, or shared Duke. In the original no-Skills E3 Nexus composition, a surviving actual Harkonnen battle disc opens the original private ability, but eligible cards remain printed Harkonnen identities. Captivity never changes a Moritani disc or Ecaz Duke into native Harkonnen; Duke has no Traitor Card. Non-Harkonnen borrowed/shared winners and exceptional targets remain separate.',
|
||||
'Terror Assassination chooses a random leader when its token is triggered. It does not use this battle-loss condition, different-leader Traitor Card or once-per-faction allowance. Moritani starts remain unavailable in Basic and Advanced.',
|
||||
],
|
||||
checklist: [
|
||||
{area:'Implementation',status:'Partial',detail:'Explicit Advanced Moritani/base-opponent preview before any normal traitor call; native disc deaths, printed bounty, dead-target zero bounty and exact Mentat replacement.',evidence:['game/moritani-assassinate.ts','game/engine.ts']},
|
||||
{area:'Player controls',status:'Partial',detail:'Uniform private choice/decline, named target and bounty, public face-up markers and readable inspection; normal activation awaits private-step UX decision.',evidence:['components/moritani-assassinate.tsx','components/game-table.tsx']},
|
||||
{area:'AI',status:'Partial',detail:'All four profiles choose from the owner-only quote or decline; no hidden rival data or strength-calibration claim.',evidence:['game/bots.ts','tests/moritani-assassinate-engine.test.ts']},
|
||||
{area:'Documentation',status:'Partial',detail:'Printed effect and authorized Advanced game-long normal-reveal forfeiture are explicit; exceptional leader custody, private-step UX and optional-module boundaries remain open.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md','docs/RULE_DECISIONS.md']},
|
||||
{area:'Verification',status:'Partial',detail:'Rules, controls, genuine-setup staged battles, private views, authenticated recovery, concurrent writes and physical replacement chains; full faction acceptance remains open.',evidence:['tests/moritani-assassinate-quote.test.ts','tests/moritani-assassinate-engine.test.ts','tests/moritani-assassinate-controls.test.tsx','tests/moritani-assassinate-recovery.test.ts']},
|
||||
{area:'Implementation',status:'Partial',detail:'Original native Advanced loss uses surviving pre-release battle identity, including Harkonnen captive/Duke winners in no-Skills E3 Nexus. Targets remain native opposing printed-faction Traitors; game-long normal-call forfeiture, printed/dead bounty and distinct physical Mentat replacement remain.',evidence:['game/moritani-assassinate.ts','game/engine.ts']},
|
||||
{area:'Player controls',status:'Partial',detail:'Actual390px original Karama cancellation preserves capture; after real Harkonnen Duke6 victory, actual Reveal Beast Rabban pays4 and original Mentat shows distinct private Esmar Tuek. Uniform private choice/decline remains; private-step UX decision is separate.',evidence:['components/moritani-assassinate.tsx','components/game-table.tsx']},
|
||||
{area:'AI',status:'Partial',detail:'All four profiles use the owner-only choice or decline; original four-profile Harkonnen response smoke is legal pass. No optimal strategy or strength-calibration claim.',evidence:['game/bots.ts','tests/moritani-assassinate-engine.test.ts']},
|
||||
{area:'Documentation',status:'Partial',detail:'Authorized Advanced game-long forfeiture, printed battle-disc identity versus target faction and distinct random Terror policy are explicit. Exceptional targets, non-Harkonnen borrowed/shared winners, private-step UX and Skills/Harkonnen remain open.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md','docs/RULE_DECISIONS.md']},
|
||||
{area:'Verification',status:'Partial',detail:'Affected22-file195/195, types/lint/build and original native capture/cancel/Duke winner/reveal4/Mentat replacement plus actual390px controls pass. Pure native/foreign/Moritani/Duke identity and dead/once-use boundaries remain. No saved/deployed or complete-faction acceptance claim.',evidence:['tests/moritani-harkonnen-assassinate.test.ts','tests/moritani-duke-captured-runtime.test.ts','tests/moritani-assassinate-quote.test.ts','tests/moritani-assassinate-engine.test.ts']},
|
||||
],
|
||||
related: [
|
||||
'faction-moritani',
|
||||
@@ -4569,13 +4576,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Before drawing, choose whether to keep your own faction card, redraw it once, or redraw whenever it appears. Its effect is available in the reference before making this choice. The server applies that preference immediately; the table never pauses in a way that identifies a card you chose to keep.',
|
||||
'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.',
|
||||
'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.',
|
||||
'Fresh original Nexus tables may retain Homeworlds, Discovery7/8, Tech3+ Basic/Advanced and Strongholds2+ Advanced, optionally all14 through leader-skills --nexus-cards. Original roster/deck envelopes are classics/base33, single or paired one-family E1/E2, both selected Ix+CHOAM native families/union47, or standalone OR paired Ecaz/Moritani/ecaz33 with classics. Printed/native setup, first-Storm Tech, actual end-Mentat claims and all12 closing draws remain. Advanced paired E3/Moritani excludes Harkonnen; shared Duke never trains. Public starts, played-save conversion, mixed E3/three-family and pending effects remain separate.',
|
||||
'Fresh original Nexus tables may retain Homeworlds, Discovery7/8, Tech3+ Basic/Advanced and Strongholds2+ Advanced, optionally all14 through leader-skills --nexus-cards. Original roster/deck envelopes include classics/base33, selected E1/E2 natives/union47 and standalone, paired or mixed E3 native families. Printed/native setup, first-Storm Tech, actual end-Mentat claims and all12 closing draws remain. Original no-Skills E3 Nexus now admits Advanced Harkonnen; Skills retains its Harkonnen exclusion and shared Duke never trains. Public starts, played-save conversion and pending effects remain separate.',
|
||||
'Fresh nexus --discoveries uses the original no-Skills envelopes above with optional Homeworlds, Tech3+ and Advanced Strongholds. Seven Discovery Spice Cards/eight tokens, protected nested sites, next-turn physical entry and actual Maker losses/votes/reserve ride remain. The card draw follows both Advanced piles and actual settled alliance change; earlier Shai-Hulud losses never repeat. Source-clear quotas, borrowed Guild tariffs and signed native one-invoice pairs at revealed sites use original controls, not new transport adapters.',
|
||||
'Without Skills, the existing generic Nexus initializer handles single and mixed selected E1/E2 natives, while paired one-family and paired E3 entries use their original paired initializer and require BOTH original natives. Native HMS/Face Dancers/Auditor/Richese cache, Ecaz ordinary discs/Loyalty/shared Duke and Advanced Moritani assassination remain physical. New overlays do not create a first Nexus hand, free native return, arbitrary marker companion or played-game conversion.',
|
||||
'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats, optionally adding original Discovery7/8 through --discoveries. Original Tech requires three or more seats; Stronghold Cards require Advanced. Starting hands/offers, actual end-Spice closing draws, first-Storm Tech and end-Mentat held custody remain. Extra/ordinary free-three returns earn original phase-end Axlotl without changing skill custody. Emperor Cunning composes skill posture, support and Suk rescue; Arrakeen reduces payer cost, not committed support or rescue eligibility. Winner cleanup precedes mandatory Tech reward. Unrelated overlays and public starts remain guarded.',
|
||||
'The original all14 skill/Nexus envelope admits single or paired one-family E1/E2, BOTH selected Ix+CHOAM native families/union47, and standalone OR paired E3/ecaz33 with classics, Basic/Advanced2–6, optional Homeworlds/Discovery7/8/Tech3+/Advanced Strongholds. Full Suboid support composes winning typed fixed-loss Suk before equal actual Cyborg Tank substitution. Held subsidy changes debit, not rescue entitlement. Original cleanup/winner Tech precedes Face Dance; the replaced trainer dies/returns its skill once without another bounty, while earlier held rewards stay with the original winner. Original Auditor exclusion, signed Richese invoices, CHOAM fuel, native cache and pending-effect guards remain.',
|
||||
'Standalone OR paired Ecaz/Moritani original skill/Nexus entries use ecaz33/all14/all12 and classics, Basic/Advanced2–6, optional Homeworlds/Discovery/Tech3+/Advanced Strongholds. Each original roster/capture guard remains, including Advanced Harkonnen exclusion and no shared-Duke training. Quiet living/unclaimed Duke Cunning and native Advanced assassination use original distinct discs and exact continuation. In paired tables, native Ecaz may be a different opposing-faction assassination target; normal foreign-world Traitor prohibition does not become an assassination ban. Exceptional Duke custody, mixed E3/three-family, enhanced Terror/HMS/Grumman and pending effects remain separate.',
|
||||
'The original nexus entry also admits standalone Ecaz OR Moritani with classics, ecaz33/all12 and NO Skills, Basic/Advanced2–6, optionally Homeworlds, Discovery7/8, Tech3+ and Advanced Strongholds2+. Original Traitors/native setup precede a qualifying closing draw, with no starting Nexus hand. No-Skills Ecaz facing Advanced Harkonnen now connects shared-Duke acquisition, original capture/return and Cunning liberation without shared-disc training. Advanced Moritani still excludes Harkonnen, retains post-loss private assassination/normal-call forfeiture and Mentat replacement, and uses supply-only Terror Cunning. Original Ecaz-selected coalition losses/payments and source-local held benefits/Tech remain. A sole loser token transfers automatically after cleanup; a real multi-token choice remains mandatory. Other native/mixed families, borrowed free-return/companion and relocation rulings, public starts and save conversion remain separate.',
|
||||
'Original standalone, paired and mixed-family no-Skills E3 Nexus admits Advanced Harkonnen within its existing optional-module envelope. Native capture/Keep/Execute, qualifying Moritani captive takeover, original Karama, one-battle/turn release and Ecaz Cunning use one separate Duke. Takeover/public availability resolution is provisional; dead execution never becomes Moritani revival. Actual Harkonnen captive-Duke victory opens native Harkonnen Traitor assassination, not an Ecaz/Moritani target. Terror separately uses provisional living effective control/public-victim/spent-empty policies. Ecaz-only revival, Ambassador exclusions, native five-disc identity, original payments/Tech and supply-only Terror Cunning remain. Skills/Harkonnen, shared training, unrelated guards, public starts and save conversion stay separate.',
|
||||
'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.',
|
||||
'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.',
|
||||
'The No-Field restriction names Atreides, so Cunning cannot inspect that dial. A non-Atreides Secret Ally holder may request it without learning the token denomination or other plan elements.',
|
||||
|
||||
Reference in new issue
Block a user