Guard Duke grants using public capture uncertainty
Verify and publish checkpoint container / container (push) Successful in 11m21s
Verify and publish checkpoint container / container (push) Successful in 11m21s
This commit is contained in:
1 parent
5f4eeee4f1
commit
57e7611468
15 files changed
+500
-19
No files matched your search
@@ -0,0 +1,33 @@
|
||||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
|
||||
/** Only facts already public when an uncanceled capture is completed. `null`
|
||||
* means a known absent controller or leader; `undefined` means unknown. */
|
||||
export type DukeCapturePublicSource = {
|
||||
publicController: string | null | undefined;
|
||||
loser: string | undefined;
|
||||
revealedLeader: string | null | undefined;
|
||||
publicPlanKnown: boolean;
|
||||
};
|
||||
|
||||
/** Called for every completed Harkonnen capture, regardless of its concealed
|
||||
* selection. Once the public cannot rule out Duke, keep the uncertainty until
|
||||
* a separate public resolution. An unstamped legacy game remains unstamped:
|
||||
* its past capture history cannot be reconstructed from current state. */
|
||||
export function nextDukeCaptureUncertainty(
|
||||
marker: boolean | undefined,
|
||||
source: DukeCapturePublicSource,
|
||||
): boolean | undefined {
|
||||
if (marker === undefined || marker === true) return marker;
|
||||
if (marker !== false || !source || source.publicPlanKnown !== true ||
|
||||
typeof source.loser !== 'string' || !source.loser.trim() ||
|
||||
source.publicController === undefined ||
|
||||
(source.publicController !== null &&
|
||||
(typeof source.publicController !== 'string' || !source.publicController.trim())) ||
|
||||
source.revealedLeader === undefined ||
|
||||
(source.revealedLeader !== null &&
|
||||
(typeof source.revealedLeader !== 'string' || !source.revealedLeader.trim()))) return true;
|
||||
// An unused Duke in the loser's control or the Duke just released after
|
||||
// that revealed battle is part of the possible public capture pool. No
|
||||
// choice, private candidate list, or actual capture identity is consulted.
|
||||
return source.publicController === source.loser || source.revealedLeader === DUKE_VIDAL_ID;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { FactionId } from './catalog';
|
||||
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';
|
||||
import { dukeCaptureUncertaintyBlock, plainAdvancedEcazDukeProfile } from './ecaz-duke-capture-policy';
|
||||
|
||||
export type EcazDukeContext = Omit<Partial<FactionModuleProfile>, 'players'> & {
|
||||
status: Game['status'];
|
||||
@@ -10,8 +11,9 @@ export type EcazDukeContext = Omit<Partial<FactionModuleProfile>, 'players'> & {
|
||||
advanced: boolean;
|
||||
players: readonly { id: string; faction: FactionId; ally?: string | null }[];
|
||||
dukeVidal?: DukeState;
|
||||
dukeCaptureUncertain?: boolean;
|
||||
};
|
||||
type Failure = 'context' | 'custody' | 'table' | 'alreadyControlled' | 'sameHolderTable';
|
||||
type Failure = 'context' | 'custody' | 'table' | 'captureUncertain' | 'alreadyControlled' | 'sameHolderTable';
|
||||
export class EcazDukeAcquisitionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -69,9 +71,11 @@ export function quoteEcazDukeAcquisition(
|
||||
'Choose Ecaz or its current reciprocal ally for this acquisition.',
|
||||
'context',
|
||||
);
|
||||
const captureBlock = dukeCaptureUncertaintyBlock(g);
|
||||
requireDuke(!captureBlock, captureBlock ?? 'Duke capture history is unavailable.', 'captureUncertain');
|
||||
requireDuke(
|
||||
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
|
||||
(!!g.expansions && (independentDiscoveryProfile(g as FactionModuleProfile) || nexusCoreProfile(g as FactionModuleProfile) || standaloneE3NexusProfile(g as FactionModuleProfile) ||
|
||||
(plainAdvancedEcazDukeProfile(g) || !!g.expansions && (independentDiscoveryProfile(g as FactionModuleProfile) || nexusCoreProfile(g as FactionModuleProfile) || standaloneE3NexusProfile(g as FactionModuleProfile) ||
|
||||
pairedE3NexusProfile(g as FactionModuleProfile) || mixedE3NexusProfile(g as FactionModuleProfile))),
|
||||
tableBlock,
|
||||
'table',
|
||||
@@ -138,6 +142,7 @@ export function ecazDukeAcquisitionBlock(
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof EcazDukeAcquisitionError) {
|
||||
if (error.reason === 'captureUncertain') return dukeCaptureUncertaintyBlock(g);
|
||||
if (error.reason === 'table') return tableBlock;
|
||||
if (error.reason === 'sameHolderTable') return sameHolderTableBlock;
|
||||
if (error.reason === 'alreadyControlled')
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { selectedPhaseModuleRoster, type FactionModuleProfile } from './faction-module-profile';
|
||||
|
||||
/** Public, monotonic uncertainty about a possible concealed Duke capture.
|
||||
* Fresh games explicitly begin false. True, absent, or malformed values never
|
||||
* establish safety; in particular, legacy saves are not silently converted. */
|
||||
export type DukeCapturePolicyContext = Omit<Partial<FactionModuleProfile>, 'players'> & {
|
||||
advanced: boolean;
|
||||
players: readonly { faction: string }[];
|
||||
dukeCaptureUncertain?: boolean;
|
||||
};
|
||||
|
||||
export const DUKE_CAPTURE_UNCERTAIN_BLOCK =
|
||||
'Duke Vidal acquisition is unavailable while Harkonnen capture history may be unresolved.';
|
||||
|
||||
/** This result depends only on public table configuration and the public
|
||||
* uncertainty receipt, never on actual Duke custody or capture identity. */
|
||||
export function dukeCaptureUncertaintyBlock(g: DukeCapturePolicyContext): string | null {
|
||||
return g.advanced && g.players.some(player => player.faction === 'harkonnen') &&
|
||||
g.dukeCaptureUncertain !== false ? DUKE_CAPTURE_UNCERTAIN_BLOCK : null;
|
||||
}
|
||||
|
||||
/** Original native faction tables with no selected optional module. Native
|
||||
* Ecaz Occupy and Moritani assassination stamps are faction rules, not modules. */
|
||||
export function plainAdvancedEcazDukeProfile(g: DukeCapturePolicyContext): boolean {
|
||||
return g.advanced === true && Array.isArray(g.expansions) && g.expansions.includes('ecaz') &&
|
||||
selectedPhaseModuleRoster({ expansions: g.expansions, players: g.players }) &&
|
||||
g.players.some(player => player.faction === 'ecaz') &&
|
||||
g.players.some(player => player.faction === 'harkonnen') &&
|
||||
!g.leaderSkills && !g.homeworlds && !g.nexusCards && !g.techTokens && !g.strongholdCards &&
|
||||
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker &&
|
||||
!g.ecazTreachery && !g.semutaPreview && !g.advancedPreview && !g.kullPreview &&
|
||||
!g.nexusKullPreview && !g.guildBetrayalPreview && !g.richeseBetrayalPreview &&
|
||||
!g.nexusIxianReplacementPreview && !g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview &&
|
||||
!g.spiceBankerIncomePreview;
|
||||
}
|
||||
+25
-5
@@ -146,6 +146,8 @@ import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup';
|
||||
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, usesNativeBattleSpecial } from './battle-card-slots';
|
||||
import { HARASS_WITHDRAW_CARD, HarassWithdrawError, isHarassWithdraw, quoteHarassWithdraw, defaultHarassWithdrawAllocation, harassWithdrawNeedsAllocation, type HarassWithdrawSelection, type HarassWithdrawContext, type HarassWithdrawPreview } from './harass-withdraw';
|
||||
import { originalAuctionInspectionLot } from './auction-inspection-source';
|
||||
import { nextDukeCaptureUncertainty } from './duke-capture-uncertainty';
|
||||
import { dukeCaptureUncertaintyBlock, plainAdvancedEcazDukeProfile } from './ecaz-duke-capture-policy';
|
||||
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
|
||||
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, discoveryStormDistance, discoveryStormSaphoModes, reorderDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
|
||||
import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight';
|
||||
@@ -2261,6 +2263,8 @@ export type Game = {
|
||||
cost: number;
|
||||
} | null;
|
||||
dukeVidal?: DukeState;
|
||||
/** Public capture history: absent on legacy games whose past cannot be inferred. */
|
||||
dukeCaptureUncertain?: boolean;
|
||||
dukeAcquisitionTurn?: number;
|
||||
pendingTerrorEntry?: {
|
||||
victoryEvent?: string;
|
||||
@@ -5850,6 +5854,7 @@ export function createGame(
|
||||
version: 0,
|
||||
host: host.id,
|
||||
status: 'lobby',
|
||||
dukeCaptureUncertain: false,
|
||||
kullDefaultPolicy: true,
|
||||
advanced,
|
||||
expansions: [...new Set(expansions)],
|
||||
@@ -25069,6 +25074,8 @@ function ambassadorEffectBlock(
|
||||
if (effect === 'ecaz') {
|
||||
const owner = byFaction(g, 'ecaz');
|
||||
if (!owner) return 'Duke Vidal is unavailable for this acquisition.';
|
||||
const uncertainty = dukeCaptureUncertaintyBlock(g);
|
||||
if (uncertainty) return uncertainty;
|
||||
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, beneficiary.id);
|
||||
@@ -27198,8 +27205,10 @@ function ecazAllianceQuote(g: Game, owner: string, entrant: string) {
|
||||
}
|
||||
}
|
||||
function ecazAmbassadorLoanBlock(g: Game, owner: Player, entrant: Player) {
|
||||
const uncertainty = dukeCaptureUncertaintyBlock(g);
|
||||
if (uncertainty) return uncertainty;
|
||||
if (g.advanced && byFaction(g, 'harkonnen') &&
|
||||
!(independentDiscoveryProfile(g) || independentNexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g)))
|
||||
!(plainAdvancedEcazDukeProfile(g) || independentDiscoveryProfile(g) || independentNexusProfile(g) || 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.';
|
||||
@@ -39157,13 +39166,24 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
else {
|
||||
const candidates = captureCandidates(g, capture.loser, capture.territory, capture.duke);
|
||||
const selected = shuffle(candidates)[0];
|
||||
const l = selected?.id === DUKE_VIDAL_ID
|
||||
? (g.dukeVidal = captureDuke(g.dukeVidal!, capture.player, capture.loser,
|
||||
g.turn, capture.territory, capture.duke)).leader : selected;
|
||||
if (!l) {
|
||||
if (!selected) {
|
||||
finishBattle(g);
|
||||
return;
|
||||
}
|
||||
// Record only publicly observable capture circumstances, never the
|
||||
// selected concealed identity or the internal released-Duke witness.
|
||||
const publicDuke = projectedSharedDuke(g, '');
|
||||
const publicPlan = g.battle?.revealed ? g.battle.plans[capture.loser] : undefined;
|
||||
const uncertainty = nextDukeCaptureUncertainty(g.dukeCaptureUncertain, {
|
||||
publicController: publicDuke?.controller,
|
||||
loser: capture.loser,
|
||||
revealedLeader: publicPlan ? publicPlan.leader ?? null : undefined,
|
||||
publicPlanKnown: !!publicPlan,
|
||||
});
|
||||
if (uncertainty !== undefined) g.dukeCaptureUncertain = uncertainty;
|
||||
const l = selected?.id === DUKE_VIDAL_ID
|
||||
? (g.dukeVidal = captureDuke(g.dukeVidal!, capture.player, capture.loser,
|
||||
g.turn, capture.territory, capture.duke)).leader : selected;
|
||||
const owner = l.id === DUKE_VIDAL_ID ? getPlayer(g, capture.loser) :
|
||||
g.players.find((player) => player.leaders.includes(l))!;
|
||||
if (l.id !== DUKE_VIDAL_ID) {
|
||||
|
||||
@@ -1727,6 +1727,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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 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.',
|
||||
'In Advanced games with Harkonnen, a public capture that could have involved Duke makes the direct grant and automatic loan unavailable for the rest of the current prototype, regardless of which leader was secretly captured. Older games without this capture record use the same conservative guard. Before such uncertainty, supported plain faction games can use both grants. This provisional restriction protects the hidden captive identity and may prevent an otherwise legal grant.',
|
||||
'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.',
|
||||
'In supported Basic games with Ecaz and Moritani, a single entry that earns both an Ambassador and Terror opportunity retains both in their existing provisional storm order, including selected native factions and the independent Ecaz cards. A Fremen relocation or Guild shipment from the Ambassador resolves its own child arrival before the original queue continues. If that child reveals the same Terror token, it cannot trigger again for the original entrant; if it is left hidden, the original opportunity remains. Unsupported module interactions remain guarded.',
|
||||
'Supported Advanced games with both Ecaz and Moritani and other native faction decks also offer both entry reactions in provisional storm order. CHOAM may prevent an eligible move first; otherwise the move happens once before the token decisions. Fremen and Guild Ambassador effects that create another arrival remain unavailable in this Advanced overlap.',
|
||||
|
||||
Reference in new issue
Block a user