Allow Ecaz Ambassador Duke acquisition for an existing ally
This commit is contained in:
1 parent
cab6b41c67
commit
34dccdfd99
21 files changed
+965
-86
No files matched your search
+6
-3
@@ -2003,7 +2003,9 @@ function policyActions(g: GameView): Action[] {
|
||||
return [{ ...action, forces, elite: Math.min(elite, forces) }];
|
||||
}
|
||||
if (entry.stage === 'offer') {
|
||||
if (entry.effect === 'ecaz')
|
||||
if (entry.effect === 'ecaz') {
|
||||
const recipient = entry.beneficiaries.find(option => !option.blocked)?.player ??
|
||||
(entry.dukeAcquisition && !entry.dukeAcquisition.blocked ? entry.owner : null);
|
||||
return [
|
||||
entry.allianceOffer && !entry.allianceOffer.blocked
|
||||
? {
|
||||
@@ -2012,15 +2014,16 @@ function policyActions(g: GameView): Action[] {
|
||||
beneficiary: entry.owner,
|
||||
choice: 'alliance',
|
||||
}
|
||||
: entry.dukeAcquisition && !entry.dukeAcquisition.blocked
|
||||
: recipient
|
||||
? {
|
||||
...action,
|
||||
trigger: true,
|
||||
beneficiary: entry.owner,
|
||||
beneficiary: recipient,
|
||||
choice: 'duke',
|
||||
}
|
||||
: { ...action, decline: true },
|
||||
];
|
||||
}
|
||||
const available = entry.beneficiaries.filter(
|
||||
(option) =>
|
||||
!option.blocked &&
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
import type { Leader } from './cards';
|
||||
|
||||
export const DUKE_VIDAL_ID = 'duke-vidal';
|
||||
/** Granting power, rather than controller faction: ordinary Ecaz acquisition
|
||||
* may belong to its existing ally and still differs from a new-alliance loan. */
|
||||
export type DukeSource = 'moritani' | 'ecaz' | 'ecazNexus' | 'ally' | 'homeworldEcaz';
|
||||
export type DukeState = {
|
||||
leader: Leader;
|
||||
@@ -291,7 +293,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
|
||||
'At the end of Shipment and Movement, Moritani gains the living Duke when its forces face battles in at least two different strongholds, excluding battles involving Ecaz. This checks the battles about to occur, rather than past battle results or several opponents in one stronghold.',
|
||||
'Moritani may use Duke Vidal for one battle. An unused living, uncaptured Duke is set aside at the end of the turn; Moritani must qualify again on a later turn to acquire him again.',
|
||||
'Karama may prevent Moritani from acquiring Duke Vidal. It does not strip him from Moritani once already controlled and does not cancel his ordinary battle use.',
|
||||
'The Ecaz Ambassador may acquire Duke Vidal when he is not dead, captured or a ghola. Ecaz keeps him until battle use or until Moritani takes him. The Ambassador’s separate alliance choice can permit a loan to the faction that has just allied with Ecaz for that turn.',
|
||||
'A triggered Ecaz Ambassador may acquire the living Duke outside captured or Ghola custody for Ecaz or its current reciprocal ally. The chosen recipient keeps him until battle use or another rule takes him away, including unused control across turns. This shared tenure is provisional. The separate newly formed alliance can instead grant a loan for the current turn.',
|
||||
'Occupied Ecaz reserves the living Duke for its occupier or reciprocal ally. A sole recipient receives him automatically; an allied occupier chooses at an otherwise quiet boundary before either Battle Plan seals. The provisional grant permits one battle and expires unused at turn end; occupation loss ends its own tenure without restoring an old holder. Only Ecaz may revive him.',
|
||||
'Battle death sends the same disc to the Tanks and preserves its death history. Only Ecaz may revive Duke Vidal, including when using the Ghola Treachery Card. His printed revival price is five spice, distinct from his battle strength of six, and this revival does not require other leaders to be dead.',
|
||||
'Ecaz can begin ordinary leader revival when five of its leaders are in the Tanks, counting Duke Vidal, even if it still has another living leader. These special rules do not create an extra native leader or an extra Traitor card.',
|
||||
@@ -304,7 +306,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
|
||||
ambassadorAcquisition: 'ecaz-self-implemented',
|
||||
ambassadorAlliance: 'consent-implemented',
|
||||
ambassadorNewAllyLoan: 'basic-prototype',
|
||||
ambassadorAllyAcquisition: 'unresolved',
|
||||
ambassadorAllyAcquisition: 'ordinary-tenure-prototype',
|
||||
allyLoanReturn: 'basic-set-aside-provisional',
|
||||
sourceAudit: 'docs/DUKE_VIDAL_RULES.md',
|
||||
}),
|
||||
|
||||
@@ -4,11 +4,11 @@ import { acquireDuke, DUKE_VIDAL_ID, type DukeState } from './duke-vidal';
|
||||
import { independentDiscoveryProfile, nexusCoreProfile, type FactionModuleProfile } from './faction-module-profile';
|
||||
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
|
||||
export type EcazDukeContext = Partial<FactionModuleProfile> & {
|
||||
export type EcazDukeContext = Omit<Partial<FactionModuleProfile>, 'players'> & {
|
||||
status: Game['status'];
|
||||
turn: number;
|
||||
advanced: boolean;
|
||||
players: readonly { id: string; faction: FactionId }[];
|
||||
players: readonly { id: string; faction: FactionId; ally?: string | null }[];
|
||||
dukeVidal?: DukeState;
|
||||
};
|
||||
type Failure = 'context' | 'custody' | 'table' | 'alreadyControlled';
|
||||
@@ -34,14 +34,17 @@ const tableBlock =
|
||||
'Duke Vidal acquisition with Advanced Harkonnen is still being implemented.';
|
||||
const alreadyBlock =
|
||||
'Acquiring Duke Vidal when Ecaz already controls him is still being implemented.';
|
||||
const allyAlreadyBlock = 'The chosen beneficiary already controls Duke Vidal.';
|
||||
const unavailable = 'Duke Vidal is unavailable for this acquisition.';
|
||||
|
||||
/** Direct Ecaz self-acquisition only. The caller owns the actual entry event,
|
||||
* physical Ambassador and explicit choice; this quote never creates a disc,
|
||||
* commits the token, grants an ally loan, or changes leader battle eligibility. */
|
||||
/** Ordinary Ambassador acquisition for Ecaz or its current reciprocal ally.
|
||||
* Both use source Ecaz's until-battle tenure, not the new-alliance turn loan.
|
||||
* The caller owns the actual entry event, physical Ambassador, occupied-Ecaz
|
||||
* priority and explicit choice. No disc or new battle permission is created. */
|
||||
export function quoteEcazDukeAcquisition(
|
||||
g: EcazDukeContext,
|
||||
ownerId: string,
|
||||
recipientId: string = ownerId,
|
||||
): DukeState {
|
||||
requireDuke(
|
||||
g.status === 'playing' &&
|
||||
@@ -50,11 +53,20 @@ export function quoteEcazDukeAcquisition(
|
||||
typeof g.advanced === 'boolean' &&
|
||||
Array.isArray(g.players) &&
|
||||
new Set(g.players.map((p) => p.id)).size === g.players.length &&
|
||||
g.players.filter((p) => p.faction === 'ecaz').length === 1 &&
|
||||
g.players.filter((p) => p.id === ownerId && p.faction === 'ecaz')
|
||||
.length === 1,
|
||||
'This acquisition needs the current seated Ecaz owner.',
|
||||
'context',
|
||||
);
|
||||
const owner = g.players.find((p) => p.id === ownerId)!;
|
||||
const recipient = g.players.find((p) => p.id === recipientId);
|
||||
requireDuke(
|
||||
recipient && (recipientId === ownerId ||
|
||||
(owner.ally === recipientId && recipient.ally === ownerId)),
|
||||
'Choose Ecaz or its current reciprocal ally for this acquisition.',
|
||||
'context',
|
||||
);
|
||||
requireDuke(
|
||||
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
|
||||
(!!g.expansions && (independentDiscoveryProfile(g as FactionModuleProfile) || nexusCoreProfile(g as FactionModuleProfile) || standaloneE3NexusProfile(g as FactionModuleProfile) ||
|
||||
@@ -86,7 +98,8 @@ export function quoteEcazDukeAcquisition(
|
||||
// This explicit unfinished case is public controller information. Check it
|
||||
// before private capture/ghola details so its projected reason cannot vary
|
||||
// with an undisclosed custody marker.
|
||||
requireDuke(duke.controller !== ownerId, alreadyBlock, 'alreadyControlled');
|
||||
requireDuke(duke.controller !== recipientId,
|
||||
recipientId === ownerId ? alreadyBlock : allyAlreadyBlock, 'alreadyControlled');
|
||||
requireDuke(
|
||||
duke.controller === null
|
||||
? duke.acquiredTurn === null && duke.source === null
|
||||
@@ -94,7 +107,7 @@ export function quoteEcazDukeAcquisition(
|
||||
whole(duke.acquiredTurn) &&
|
||||
duke.acquiredTurn > 0 &&
|
||||
duke.acquiredTurn <= g.turn &&
|
||||
['ecaz', 'moritani', 'ally'].includes(duke.source!),
|
||||
['ecaz', 'ecazNexus', 'moritani', 'ally'].includes(duke.source!),
|
||||
'The shared Duke Vidal disc has invalid current custody.',
|
||||
);
|
||||
requireDuke(
|
||||
@@ -103,21 +116,23 @@ export function quoteEcazDukeAcquisition(
|
||||
);
|
||||
// acquireDuke clones every existing leader field, including usedAt and death
|
||||
// history; retaining those fields does not permit a second battle use.
|
||||
return acquireDuke(duke, ownerId, g.turn, 'ecaz');
|
||||
return acquireDuke(duke, recipientId, g.turn, 'ecaz');
|
||||
}
|
||||
/** An owner-offer descriptor may be publicly compared. Exceptional custody
|
||||
* never produces a distinct reason that discloses a hidden captured/ghola ID. */
|
||||
export function ecazDukeAcquisitionBlock(
|
||||
g: EcazDukeContext,
|
||||
ownerId: string,
|
||||
recipientId: string = ownerId,
|
||||
): string | null {
|
||||
try {
|
||||
quoteEcazDukeAcquisition(g, ownerId);
|
||||
quoteEcazDukeAcquisition(g, ownerId, recipientId);
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof EcazDukeAcquisitionError) {
|
||||
if (error.reason === 'table') return tableBlock;
|
||||
if (error.reason === 'alreadyControlled') return alreadyBlock;
|
||||
if (error.reason === 'alreadyControlled')
|
||||
return recipientId === ownerId ? alreadyBlock : allyAlreadyBlock;
|
||||
}
|
||||
return unavailable;
|
||||
}
|
||||
|
||||
+6
-7
@@ -18562,11 +18562,10 @@ function ambassadorEffectBlock(
|
||||
return 'This Ambassador effect is still being implemented.';
|
||||
if (effect === 'ecaz') {
|
||||
const owner = byFaction(g, 'ecaz');
|
||||
if (!owner || beneficiary.id !== owner.id)
|
||||
return 'Assigning the Ecaz Ambassador effect to an ally is still being implemented.';
|
||||
if (!owner) return 'Duke Vidal is unavailable for this acquisition.';
|
||||
if (g.players.some((p) => p.leaders.some((l) => l.id === DUKE_VIDAL_ID)))
|
||||
return 'Duke Vidal is unavailable for this acquisition.';
|
||||
return occupiedEcazDukePriority(g) ?? ecazDukeAcquisitionBlock(g, owner.id);
|
||||
return occupiedEcazDukePriority(g) ?? ecazDukeAcquisitionBlock(g, owner.id, beneficiary.id);
|
||||
}
|
||||
if (
|
||||
effect === 'ixians' &&
|
||||
@@ -20924,8 +20923,8 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
|
||||
let acquiredDuke: DukeState | null = null;
|
||||
if (token.effect === 'ecaz') {
|
||||
requireRule(
|
||||
action.choice === 'duke' && beneficiary.id === owner.id,
|
||||
'Choose the Ecaz Ambassador’s direct acquisition of Duke Vidal for Ecaz.',
|
||||
action.choice === 'duke',
|
||||
'Choose the Ecaz Ambassador’s direct acquisition of Duke Vidal for its beneficiary.',
|
||||
);
|
||||
requireRule(
|
||||
canTriggerAmbassador({
|
||||
@@ -20940,7 +20939,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
|
||||
);
|
||||
try {
|
||||
requireRule(!occupiedEcazDukePriority(g), occupiedEcazDukePriority(g) ?? 'Duke Vidal is reserved by occupied Ecaz.');
|
||||
acquiredDuke = quoteEcazDukeAcquisition(g, owner.id);
|
||||
acquiredDuke = quoteEcazDukeAcquisition(g, owner.id, beneficiary.id);
|
||||
} catch (error) {
|
||||
if (error instanceof EcazDukeAcquisitionError)
|
||||
throw new RuleError(error.message);
|
||||
@@ -20959,7 +20958,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
|
||||
g.dukeVidal = acquiredDuke;
|
||||
log(
|
||||
g,
|
||||
`${owner.name} acquired Duke Prad Vidal${previous ? ` from ${getPlayer(g, previous).name}` : ''} through the Ecaz Ambassador. Ecaz keeps the shared Duke until battle use or Moritani acquisition; the entrant’s remaining actions resume.`,
|
||||
`${beneficiary.name} acquired Duke Prad Vidal${previous ? ` from ${getPlayer(g, previous).name}` : ''} through the Ecaz Ambassador. ${beneficiary.name} keeps the shared Duke until battle use or another rule takes him away; the entrant’s remaining actions resume.`,
|
||||
{ faction: owner.faction, name: 'Ecaz Ambassador — Duke Vidal' },
|
||||
);
|
||||
finishAmbassador(g);
|
||||
|
||||
@@ -297,7 +297,7 @@ export function validateNexusEcazDuke(
|
||||
(before.source === 'moritani' &&
|
||||
(g.players.find(player => player.id === before.controller)?.faction !== 'moritani' ||
|
||||
before.acquiredTurn !== receipt.turn)) ||
|
||||
((before.source === 'ecaz' || before.source === 'ecazNexus') &&
|
||||
(before.source === 'ecazNexus' &&
|
||||
g.players.find(player => player.id === before.controller)?.faction !== 'ecaz') ||
|
||||
(before.source === 'ecazNexus' && before.acquiredTurn !== receipt.turn)) ||
|
||||
before.controller === receipt.owner ||
|
||||
@@ -345,7 +345,7 @@ function validateCapturedReceipt(g: Game, receipt: CapturedReceipt, index: numbe
|
||||
: !controller || !turn(before.acquiredTurn) || before.acquiredTurn > receipt.turn ||
|
||||
!['moritani', 'ecaz', 'ecazNexus', 'ally'].includes(before.source as string) ||
|
||||
(before.source === 'moritani' && controller.faction !== 'moritani') ||
|
||||
((before.source === 'ecaz' || before.source === 'ecazNexus') && controller.faction !== 'ecaz')) ||
|
||||
(before.source === 'ecazNexus' && controller.faction !== 'ecaz')) ||
|
||||
!(hidden === null || (object(hidden) &&
|
||||
keys(hidden, 'captor,controller,dead,deaths,usedAt') &&
|
||||
hidden.captor === leader.capturedBy && hidden.dead === false &&
|
||||
|
||||
+7
-2
@@ -1651,7 +1651,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The first placement costs one spice, the second two, and so on that turn. A declaration opens a Karama response before spice is paid or the token moves. Canceling stops the remaining placement opportunity for that turn; previous placements and payments remain.',
|
||||
'Finish Ambassador placement advances the phase. On a later turn the price begins at one again and the earlier placement block expires. Pending declarations survive reload without charging twice.',
|
||||
'Destroyed tokens return to supply; triggered regular tokens are set aside, Bene Gesserit is permanently removed, and replenishment waits for all five random tokens. Storm/explosion destruction remains separate. The reusable Ecaz token returns to supply after acquisition or alliance proposal. Basic has an optional new-ally Duke loan; the supplied Advanced p30 automatically grants an available Duke after accepted consent.',
|
||||
'When an eligible entrant reaches the reusable Ecaz token, Ecaz may acquire the available shared Duke. The token returns to supply and its companions are unchanged. Unused Ecaz control persists until battle use or Moritani acquisition. Basic optional and Advanced automatic new-ally grants are connected; standing ally assignment and wider captured-loan combinations remain for refinement.',
|
||||
'When an eligible entrant reaches the reusable Ecaz token, Ecaz may acquire the available shared Duke for itself or its current reciprocal ally. The token returns to supply and its companions are unchanged. Direct acquisition persists unused until battle use or Moritani acquisition; that shared tenure is provisional. It is distinct from the optional Basic or automatic Advanced new-alliance turn loan. A fresh token entry is required, with no standing transfer action.',
|
||||
'Unallied Ecaz may propose an alliance to an unallied entrant. Acceptance activates alliance abilities immediately. Basic then offers an optional Duke loan; Advanced automatically adds an available Duke under supplied PDF p30, without another acknowledgement. The first version releases a surviving loan after one battle, provisionally; an unused living loan is set aside at turn end. Unavailable Duke never prevents consent.',
|
||||
'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave it in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Ordinary shipment and native Guild transport settle their own income, BG Intrusion and accompaniment controls before queued entry, without replaying payment or forces. Actual busy controls and unsupported paired or module combinations remain guarded.',
|
||||
'Accompaniment joining retained BG fighters creates a distinct eligible child entry before the original shipper. Declining the child leaves the live token for the original entry; consuming it does not create a token to offer again. Advisors remain exempt. This existing child-first, live-token composition is explicitly provisional, not a publisher priority ruling.',
|
||||
@@ -1730,6 +1730,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/guild-ambassador-income-arrival.test.ts',
|
||||
'tests/bg-ambassador-arrival.test.ts',
|
||||
'tests/ecaz-duke-acquisition.test.ts',
|
||||
'tests/ecaz-duke-ally-quote.test.ts',
|
||||
'tests/ecaz-duke-ally-engine.test.ts',
|
||||
'tests/ecaz-duke-ally-controls.test.ts',
|
||||
'tests/fixture-ecaz-duke-ally.ts',
|
||||
'components/ecaz-duke-acquisition-choices.tsx',
|
||||
'tests/ecaz-duke-bots.test.ts',
|
||||
'tests/ecaz-duke-recovery.test.ts',
|
||||
'tests/ecaz-alliance.test.ts',
|
||||
@@ -3358,7 +3363,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Duke Vidal is separate from both factions’ five ordinary leaders. His Ecaz identity does not mean Ecaz currently controls him. Inspect the shared disc to see its current controller, whether it is set aside, or whether it is in the Tanks.',
|
||||
'At the end of Shipping and Movement, Moritani gains Vidal if it faces battles in at least two strongholds, excluding battles involving Ecaz, and Vidal is not in the Tanks. The printed rule takes him from the current controller. Moritani has him for one battle; it may acquire him again on a later turn by meeting the condition again. At turn end, set him aside unless he is in the Tanks or captured.',
|
||||
'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.',
|
||||
'Ecaz may acquire Vidal through a triggered Ambassador for itself or its current reciprocal ally outside Tanks/captured/ghola custody. Ordinary acquisition lasts until battle use, including unused control across turns; this shared tenure is provisional. 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.',
|
||||
|
||||
Reference in new issue
Block a user