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
@@ -17,6 +17,16 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Duke Ambassador capture uncertainty — 29 October. Prototyped / Partial.**
|
||||
Fresh games track whether a public Harkonnen capture could have involved Duke,
|
||||
independent of the secret selection. When capture history is uncertain, direct
|
||||
acquisition and the automatic alliance loan are uniformly guarded; unstamped
|
||||
older games remain conservative. A plain Advanced Ecaz–Harkonnen game can use
|
||||
both grants while its public history rules out capture. Eight related files pass
|
||||
62 cases, including real setup and capture; broader combinations, resolving
|
||||
uncertainty later, and deployed verification remain open.
|
||||
[Scope](ECAZ_DUKE_ACQUISITION.md#public-capture-uncertainty--29-october-2026).
|
||||
|
||||
**Concealed Duke and Ghola guidance — 29 October. Prototyped / Partial.**
|
||||
Ecaz's Ghola choices now use the same projected Duke state as ordinary revival,
|
||||
so secretly executing another player's Duke captive no longer changes that
|
||||
|
||||
@@ -17,7 +17,10 @@ admitted Advanced Harkonnen profiles, the Ambassador acquisition descriptor
|
||||
uses actual Duke custody and may distinguish a secretly captured Duke from a
|
||||
publicly equivalent free Duke. Plain Advanced Ecaz/Harkonnen acquisition stays
|
||||
guarded until a public-safe solution is implemented. Broader capture assurance
|
||||
and deployed verification remain open.
|
||||
and deployed verification remain open. The later [public capture uncertainty
|
||||
policy](ECAZ_DUKE_ACQUISITION.md#public-capture-uncertainty--29-october-2026)
|
||||
guards the Ambassador availability issue described above, with explicit
|
||||
legacy and post-capture limitations.
|
||||
|
||||
## Occupied Ecaz grant — 26 October 2026
|
||||
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# Ecaz Ambassador: direct Duke acquisition
|
||||
|
||||
## Public capture uncertainty — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** A fresh game records whether a public Harkonnen
|
||||
capture could have involved Duke, using the losing player's public identity,
|
||||
revealed battle plan and projected Duke controller, never the selected captive.
|
||||
Once that possibility arises it stays recorded. Direct Ambassador acquisition
|
||||
and the automatic Advanced alliance loan are uniformly unavailable while the
|
||||
record is uncertain, regardless of actual hidden custody. A game saved before
|
||||
this receipt existed is also treated conservatively: its truncated history
|
||||
cannot prove that a concealed capture never occurred. No saved game is reset
|
||||
or retroactively assigned a fabricated clean history.
|
||||
|
||||
When the record is clear, a plain Advanced Ecaz–Harkonnen game can use the
|
||||
ordinary direct grant and automatic new-ally loan; the original disc, reusable
|
||||
token, existing custody checks and continuation remain. The same public
|
||||
uncertainty guard protects already supported Harkonnen profiles. This
|
||||
conservative restriction can block an otherwise available Duke after a
|
||||
possible capture and currently does not clear itself; it is a provisional
|
||||
online policy, not a publisher ruling. A more complete public resolution of
|
||||
uncertainty, broader modules and old-game coverage remain open.
|
||||
|
||||
Eight related files pass 62 cases. Genuine plain Advanced setup exercises both
|
||||
grants, and an actual capture is replayed with controlled shuffle outcomes to
|
||||
confirm the same receipt for Duke and another leader. The equivalent-custody
|
||||
privacy comparison stages the two physical underlays. Deployed verification
|
||||
and comprehensive capture assurance remain open.
|
||||
|
||||
## Same-recipient temporary tenure — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** At an actual eligible Ecaz Ambassador entry, Ecaz may
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Rule decision index
|
||||
|
||||
## Duke Ambassador capture uncertainty — 29 October 2026
|
||||
|
||||
Fresh games record whether a public capture could have involved Duke, without
|
||||
using the secret selection. Direct acquisition and automatic alliance loan
|
||||
are uniformly guarded while that history is uncertain; legacy games without
|
||||
the receipt remain conservative. This is a provisional online policy.
|
||||
[Scope](ECAZ_DUKE_ACQUISITION.md#public-capture-uncertainty--29-october-2026).
|
||||
|
||||
## Concealed Duke and Ghola guidance — 29 October 2026
|
||||
|
||||
Ecaz's Ghola guidance uses its projected Duke state, matching ordinary revival;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { nextDukeCaptureUncertainty as next, type DukeCapturePublicSource } from '../game/duke-capture-uncertainty';
|
||||
|
||||
const known: DukeCapturePublicSource = {
|
||||
publicController:'guild', loser:'guild', revealedLeader:'other-leader', publicPlanKnown:true,
|
||||
};
|
||||
|
||||
void test('publicly possible held or just-used Duke makes the capture uncertain', () => {
|
||||
assert.equal(next(false, known), true);
|
||||
assert.equal(next(false, {...known, publicController:null, revealedLeader:DUKE_VIDAL_ID}), true);
|
||||
assert.equal(next(false, {...known, publicController:'ecaz', revealedLeader:'other-leader'}), false);
|
||||
assert.equal(next(false, {...known, publicController:null, revealedLeader:null}), false);
|
||||
});
|
||||
|
||||
void test('uncertainty persists and unstamped historical games are not rewritten', () => {
|
||||
assert.equal(next(true, {...known, publicController:null}), true);
|
||||
assert.equal(next(undefined, known), undefined);
|
||||
assert.equal(next(undefined, {...known, publicController:undefined}), undefined);
|
||||
});
|
||||
|
||||
void test('incomplete or invalid public sources cannot prove Duke was excluded', () => {
|
||||
for (const source of [
|
||||
{...known, publicController:undefined}, {...known, loser:undefined},
|
||||
{...known, loser:''}, {...known, revealedLeader:undefined},
|
||||
{...known, revealedLeader:''}, {...known, publicPlanKnown:false},
|
||||
]) assert.equal(next(false, source), true);
|
||||
});
|
||||
|
||||
void test('publicly equivalent captures stay equivalent regardless of actual selection', () => {
|
||||
const dukeSelected = {...known, actualSelected:DUKE_VIDAL_ID, concealed:true};
|
||||
const otherSelected = {...known, actualSelected:'other-leader', concealed:false};
|
||||
assert.equal(next(false, dukeSelected), next(false, otherSelected));
|
||||
assert.equal(next(false, dukeSelected), true);
|
||||
});
|
||||
@@ -281,15 +281,26 @@ void test('Advanced new-ally Duke grant transfers available shared custody while
|
||||
assert.deepEqual(done.dukeVidal!.leader, duke!.leader);
|
||||
}
|
||||
}
|
||||
const g = fixture(true);
|
||||
g.players[2] = newPlayer('a', 'Harkonnen', 'harkonnen');
|
||||
const entered = enter(g);
|
||||
assert.ok(viewGame(entered, 'ec').ambassadorEntry!.dukeAcquisition!.blocked);
|
||||
assert.equal(
|
||||
viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked,
|
||||
null,
|
||||
);
|
||||
assert.equal(seat(answer(propose(entered), true), 'ec').ally, 'in');
|
||||
for (const uncertainty of [false, true, undefined] as const) {
|
||||
const g = fixture(true);
|
||||
g.players[2] = newPlayer('a', 'Harkonnen', 'harkonnen');
|
||||
if (uncertainty === undefined) delete g.dukeCaptureUncertain;
|
||||
else g.dukeCaptureUncertain = uncertainty;
|
||||
const duke = json(g).dukeVidal;
|
||||
const entered = enter(g);
|
||||
const entry = viewGame(entered, 'ec').ambassadorEntry!;
|
||||
if (uncertainty === false) assert.equal(entry.dukeAcquisition!.blocked, null);
|
||||
else assert.match(entry.dukeAcquisition!.blocked!, /Harkonnen capture history may be unresolved/);
|
||||
assert.equal(entry.allianceOffer!.blocked, null);
|
||||
const done = answer(propose(entered), true);
|
||||
assert.equal(seat(done, 'ec').ally, 'in');
|
||||
assert.equal(seat(done, 'in').ally, 'ec');
|
||||
if (uncertainty === false) {
|
||||
assert.equal(done.dukeVidal!.controller, 'in');
|
||||
assert.equal(done.dukeVidal!.source, 'ally');
|
||||
assert.deepEqual(done.dukeVidal!.leader, duke!.leader);
|
||||
} else assert.deepEqual(done.dukeVidal, duke, 'An uncertain loan leaves the physical Duke unchanged.');
|
||||
}
|
||||
});
|
||||
|
||||
void test('changing BG private prediction and unrelated private hands does not change the public alliance offer', () => {
|
||||
|
||||
@@ -192,7 +192,7 @@ void test('pure source/canonical identity/custody checks reject safely, with one
|
||||
EcazDukeAcquisitionError,
|
||||
);
|
||||
});
|
||||
void test('redundant same-owner and Advanced Harkonnen cases keep public reasons independent of private custody', () => {
|
||||
void test('redundant same-owner and uncertain Advanced Harkonnen cases keep public reasons independent of private custody', () => {
|
||||
const own = fixture();
|
||||
own.dukeVidal = acquireDuke(own.dukeVidal!, 'ec', 1, 'ecaz');
|
||||
const reason = ecazDukeAcquisitionBlock(own, 'ec');
|
||||
@@ -200,10 +200,15 @@ void test('redundant same-owner and Advanced Harkonnen cases keep public reasons
|
||||
own.dukeVidal.leader.capturedBy = 'in';
|
||||
assert.equal(ecazDukeAcquisitionBlock(own, 'ec'), reason);
|
||||
const hark = fixture('harkonnen', true);
|
||||
assert.equal(hark.dukeCaptureUncertain, false);
|
||||
assert.equal(ecazDukeAcquisitionBlock(hark, 'ec'), null);
|
||||
hark.dukeCaptureUncertain = true;
|
||||
const blocked = ecazDukeAcquisitionBlock(hark, 'ec');
|
||||
assert.match(blocked!, /Advanced Harkonnen/);
|
||||
assert.match(blocked!, /Harkonnen capture history may be unresolved/);
|
||||
hark.dukeVidal!.leader.capturedBy = 'in';
|
||||
assert.equal(ecazDukeAcquisitionBlock(hark, 'ec'), blocked);
|
||||
delete hark.dukeCaptureUncertain;
|
||||
assert.equal(ecazDukeAcquisitionBlock(hark, 'ec'), blocked);
|
||||
const basic = fixture('harkonnen', false);
|
||||
assert.equal(ecazDukeAcquisitionBlock(basic, 'ec'), null);
|
||||
});
|
||||
@@ -316,6 +321,7 @@ void test('explicit Duke choice/owner/event and eligibility are checked before t
|
||||
(b: Game) => {
|
||||
b.advanced = true;
|
||||
p(b, 'in').faction = 'harkonnen';
|
||||
b.dukeCaptureUncertain = true;
|
||||
},
|
||||
]) {
|
||||
const bad = reload(g);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { acquireDuke, captureDuke, DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { createNexusDukeCaptureFixture } from './fixture-nexus-duke-capture';
|
||||
|
||||
/** The Ambassador placement and offer are reached by real actions. We stage
|
||||
* two alternate custody outcomes for the same physical disc and parent event:
|
||||
* a living third-party Duke and its legal concealed Harkonnen capture. */
|
||||
function paired() {
|
||||
const source = createNexusDukeCaptureFixture();
|
||||
const free = structuredClone(source.arrival.before);
|
||||
const priorController = source.others.find(id => id !== source.captor && id !== source.entrant);
|
||||
assert.ok(priorController, 'A third party owns the staged prior Duke');
|
||||
const prior = acquireDuke(free.dukeVidal!, priorController, free.turn, 'ecaz');
|
||||
free.dukeVidal = prior;
|
||||
const captured: Game = structuredClone(free);
|
||||
captured.dukeVidal = captureDuke(prior, source.captor, priorController, free.turn, 'carthag');
|
||||
// Public history records a possible hidden Duke capture, regardless of
|
||||
// whether this physical disc was the captive actually selected.
|
||||
Object.assign(free, { dukeCaptureUncertain: true });
|
||||
Object.assign(captured, { dukeCaptureUncertain: true });
|
||||
assert.equal(captured.pendingAmbassador?.event, free.pendingAmbassador?.event);
|
||||
const freeView = viewGame(free, source.owner), capturedView = viewGame(captured, source.owner);
|
||||
for (const key of Object.keys(freeView) as (keyof typeof freeView)[]) {
|
||||
if (key !== 'ambassadorEntry') assert.deepEqual(capturedView[key], freeView[key], `Public-equivalent ${key}`);
|
||||
}
|
||||
return { source, free, captured };
|
||||
}
|
||||
|
||||
void test('Ecaz Ambassador availability must not reveal a concealed Duke captured from another controller', () => {
|
||||
const {source, free, captured} = paired();
|
||||
const freeEntry = viewGame(free, source.owner).ambassadorEntry;
|
||||
const hiddenEntry = viewGame(captured, source.owner).ambassadorEntry;
|
||||
assert.ok(freeEntry && hiddenEntry);
|
||||
const action = {type:'decision', event:free.pendingAmbassador!.event, trigger:true, beneficiary:source.owner, choice:'duke'};
|
||||
for (const game of [free, captured]) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, source.owner, action));
|
||||
assert.deepEqual(game, before, 'The unavailable action preserves the physical Ambassador and Duke');
|
||||
}
|
||||
assert.deepEqual(hiddenEntry, freeEntry, 'The same public Duke and parent event must offer identical controls');
|
||||
assert.ok(freeEntry.dukeAcquisition?.blocked, 'The same public uncertainty uniformly blocks the acquisition');
|
||||
});
|
||||
|
||||
void test('an actual fresh Ambassador grant stays available before public capture uncertainty', () => {
|
||||
const source = createNexusDukeCaptureFixture(), game = structuredClone(source.arrival.before);
|
||||
assert.equal(viewGame(game, source.owner).ambassadorEntry?.dukeAcquisition?.blocked, null);
|
||||
const after = applyAction(game, source.owner, source.arrival.step.action);
|
||||
assert.equal(after.dukeVidal?.controller, source.owner);
|
||||
assert.equal(after.dukeVidal?.source, 'ecaz');
|
||||
});
|
||||
|
||||
void test('a fresh Advanced Ecaz alliance grants the unambiguous Duke without another choice', () => {
|
||||
const source = createNexusDukeCaptureFixture(), game = structuredClone(source.arrival.before);
|
||||
const event = game.pendingAmbassador!.event;
|
||||
const offered = applyAction(game, source.owner, {type:'decision', event, trigger:true,
|
||||
beneficiary:source.owner, choice:'alliance'});
|
||||
assert.equal(offered.pendingAmbassador?.stage, 'allianceReply');
|
||||
const accepted = applyAction(offered, source.entrant, {type:'decision', event, accept:true});
|
||||
assert.equal(accepted.players.find(player => player.id === source.owner)?.ally, source.entrant);
|
||||
assert.equal(accepted.dukeVidal?.controller, source.entrant);
|
||||
assert.equal(accepted.dukeVidal?.source, 'ally');
|
||||
assert.notEqual(accepted.pendingAmbassador?.stage, 'loan');
|
||||
});
|
||||
|
||||
void test('an Advanced alliance does not reveal uncertain Duke custody through its automatic grant', () => {
|
||||
const {source, free, captured} = paired();
|
||||
const results: Game[] = [];
|
||||
for (const game of [free, captured]) {
|
||||
const originalDuke = structuredClone(game.dukeVidal);
|
||||
const event = game.pendingAmbassador!.event;
|
||||
const offered = applyAction(game, source.owner, {type:'decision', event, trigger:true,
|
||||
beneficiary:source.owner, choice:'alliance'});
|
||||
const accepted = applyAction(offered, source.entrant, {type:'decision', event, accept:true});
|
||||
assert.equal(accepted.players.find(player => player.id === source.owner)?.ally, source.entrant);
|
||||
assert.deepEqual(accepted.dukeVidal, originalDuke, 'No automatic grant changes either actual custody');
|
||||
assert.notEqual(accepted.pendingAmbassador?.stage, 'loan');
|
||||
results.push(accepted);
|
||||
}
|
||||
assert.deepEqual(viewGame(results[0], source.owner), viewGame(results[1], source.owner));
|
||||
assert.deepEqual(viewGame(results[0], source.entrant), viewGame(results[1], source.entrant));
|
||||
});
|
||||
|
||||
void test('a real Duke-possible capture marks uncertainty for either concealed selection', () => {
|
||||
const source = createNexusDukeCaptureFixture({variant:'used'});
|
||||
assert.ok(source.offer);
|
||||
assert.ok(source.candidates.some(leader => leader.id === DUKE_VIDAL_ID));
|
||||
assert.ok(source.candidates.some(leader => leader.id !== DUKE_VIDAL_ID));
|
||||
assert.equal(source.offer.before.dukeCaptureUncertain, false);
|
||||
assert.equal(source.offer.after.decision?.kind, 'capturedLeader');
|
||||
if (source.offer.after.decision?.kind !== 'capturedLeader') throw Error('Missing capture selection');
|
||||
assert.equal(source.offer.after.decision.leader, DUKE_VIDAL_ID);
|
||||
assert.equal(source.offer.after.dukeCaptureUncertain, true);
|
||||
assert.equal(source.game.dukeCaptureUncertain, true);
|
||||
|
||||
// Select a different original candidate solely by controlled shuffle entropy,
|
||||
// replaying the same real public capture and preserving all physical discs.
|
||||
const saved = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
try {
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array) { array.fill(0); return array; }
|
||||
return Reflect.apply(native, crypto, [array]);
|
||||
};
|
||||
const different = applyAction(structuredClone(source.offer.before), source.offer.step.actor, source.offer.step.action);
|
||||
assert.equal(different.decision?.kind, 'capturedLeader');
|
||||
if (different.decision?.kind !== 'capturedLeader') throw Error('Missing capture selection');
|
||||
assert.notEqual(different.decision.leader, DUKE_VIDAL_ID);
|
||||
assert.equal(different.dukeCaptureUncertain, true, 'The public marker cannot depend on hidden selection');
|
||||
} finally {
|
||||
if (saved) Object.defineProperty(crypto, 'getRandomValues', saved);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
});
|
||||
|
||||
void test('legacy games without the capture marker uniformly guard equivalent Duke custody', () => {
|
||||
const {source, free, captured} = paired();
|
||||
delete free.dukeCaptureUncertain;
|
||||
delete captured.dukeCaptureUncertain;
|
||||
const freeEntry = viewGame(free, source.owner).ambassadorEntry;
|
||||
const hiddenEntry = viewGame(captured, source.owner).ambassadorEntry;
|
||||
assert.ok(freeEntry && hiddenEntry);
|
||||
assert.deepEqual(hiddenEntry, freeEntry);
|
||||
assert.ok(freeEntry.dukeAcquisition?.blocked, 'Unknown legacy capture history must not be assumed safe');
|
||||
const action = {type:'decision', event:free.pendingAmbassador!.event, trigger:true, beneficiary:source.owner, choice:'duke'};
|
||||
for (const game of [free, captured]) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, source.owner, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, newPlayer, type Game } from '../game/engine';
|
||||
import { acquireDuke, captureDuke, createDukeVidal } from '../game/duke-vidal';
|
||||
import { ecazDukeAcquisitionBlock, quoteEcazDukeAcquisition } from '../game/ecaz-duke-acquisition';
|
||||
import { DUKE_CAPTURE_UNCERTAIN_BLOCK, dukeCaptureUncertaintyBlock, plainAdvancedEcazDukeProfile } from '../game/ecaz-duke-capture-policy';
|
||||
|
||||
function table(): Game {
|
||||
const game = createGame('DUKEPOLICY', newPlayer('e', 'Ecaz', 'ecaz'), true, ['ecaz']);
|
||||
game.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild'));
|
||||
game.status = 'playing'; game.turn = 2; game.dukeVidal = createDukeVidal();
|
||||
game.dukeCaptureUncertain = false;
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('a known-safe plain Advanced table can acquire the original living Duke', () => {
|
||||
const game = table(), before = structuredClone(game);
|
||||
assert.equal(plainAdvancedEcazDukeProfile(game), true);
|
||||
assert.equal(dukeCaptureUncertaintyBlock(game), null);
|
||||
const next = quoteEcazDukeAcquisition(game, 'e');
|
||||
assert.equal(next.controller, 'e'); assert.equal(next.leader.id, game.dukeVidal!.leader.id);
|
||||
assert.deepEqual(game, before, 'A quote does not change custody.');
|
||||
const ecaz = game.players.find(player => player.id === 'e')!;
|
||||
const guild = game.players.find(player => player.id === 'g')!;
|
||||
ecaz.ally = 'g'; guild.ally = 'e';
|
||||
assert.equal(quoteEcazDukeAcquisition(game, 'e', 'g').controller, 'g');
|
||||
});
|
||||
|
||||
void test('uncertain and legacy Advanced tables block all hidden custody identically', () => {
|
||||
const free = table();
|
||||
free.dukeVidal = acquireDuke(free.dukeVidal!, 'g', free.turn, 'ecaz');
|
||||
const captured = structuredClone(free);
|
||||
captured.dukeVidal = captureDuke(captured.dukeVidal!, 'h', 'g', captured.turn, 'arrakeen');
|
||||
for (const uncertain of [true, undefined] as const) {
|
||||
for (const game of [free, captured]) {
|
||||
if (uncertain === undefined) delete game.dukeCaptureUncertain;
|
||||
else game.dukeCaptureUncertain = uncertain;
|
||||
const before = structuredClone(game);
|
||||
assert.equal(ecazDukeAcquisitionBlock(game, 'e'), DUKE_CAPTURE_UNCERTAIN_BLOCK);
|
||||
assert.throws(() => quoteEcazDukeAcquisition(game, 'e'));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('uncertainty does not change Basic or tables without Harkonnen', () => {
|
||||
const basic = table(); basic.advanced = false; delete basic.dukeCaptureUncertain;
|
||||
assert.equal(dukeCaptureUncertaintyBlock(basic), null);
|
||||
assert.equal(quoteEcazDukeAcquisition(basic, 'e').controller, 'e');
|
||||
const noHarkonnen = table(); noHarkonnen.players = noHarkonnen.players.filter(player => player.id !== 'h');
|
||||
delete noHarkonnen.dukeCaptureUncertain;
|
||||
assert.equal(dukeCaptureUncertaintyBlock(noHarkonnen), null);
|
||||
assert.equal(quoteEcazDukeAcquisition(noHarkonnen, 'e').controller, 'e');
|
||||
});
|
||||
|
||||
void test('the plain profile keeps optional modules separate and validates the selected roster', () => {
|
||||
const game = table();
|
||||
assert.equal(plainAdvancedEcazDukeProfile(game), true);
|
||||
const mixed = structuredClone(game); mixed.expansions.push('ix');
|
||||
mixed.players.push(newPlayer('i', 'Ixians', 'ixians'));
|
||||
assert.equal(plainAdvancedEcazDukeProfile(mixed), true);
|
||||
mixed.sandtrout = true;
|
||||
assert.equal(plainAdvancedEcazDukeProfile(mixed), true, 'Active native Sandtrout is not an optional selection.');
|
||||
mixed.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
|
||||
assert.equal(plainAdvancedEcazDukeProfile(mixed), false);
|
||||
assert.match(ecazDukeAcquisitionBlock(mixed, 'e')!, /Advanced Harkonnen/);
|
||||
delete mixed.leaderSkills; mixed.expansions = ['ecaz'];
|
||||
assert.equal(plainAdvancedEcazDukeProfile(mixed), false, 'An unselected expansion cannot seat Ixians.');
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { actDukeAmbassador, advanceDukeAmbassador, dukeAmbassadorClean } from './fixture-duke-ambassador';
|
||||
|
||||
/** Genuine Advanced setup, placement and shipment. Only unopened Spice cards
|
||||
* and the original scalar lotteries are controlled to avoid a worm detour. */
|
||||
function nativeArrival() {
|
||||
let game = createGame('PLAINECAZHARKONNENDUKE', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
|
||||
joinGame(game, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
|
||||
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
||||
const actions: { actor:string; action:Action }[] = [];
|
||||
const act = (actor:string, action:Action) => { game = actDukeAmbassador(game, actor, action, actions); };
|
||||
const reach = (until:(state:Game)=>boolean) => { game = advanceDukeAmbassador(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, {type:'ready'});
|
||||
act(game.host, {type:'start', advancedPreview:true});
|
||||
reach(state => state.status === 'playing');
|
||||
assert.equal(game.advanced, true);
|
||||
assert.deepEqual(game.expansions, ['ecaz']);
|
||||
assert.equal(game.dukeCaptureUncertain, false);
|
||||
assert.ok(!game.nexusCards && !game.homeworlds && !game.leaderSkills && !game.discoveryEnabled);
|
||||
const token = game.ecazAmbassadors!.tokens.find(item => item.effect === 'ecaz');
|
||||
assert.ok(token && token.zone === 'supply');
|
||||
const originalDuke = structuredClone(game.dukeVidal!.leader);
|
||||
for (const position of [0,1]) {
|
||||
const index = game.spiceDeck.findIndex((card,i) => i >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(index >= position);
|
||||
[game.spiceDeck[position], game.spiceDeck[index]] = [game.spiceDeck[index], game.spiceDeck[position]];
|
||||
}
|
||||
reach(state => state.phase === 4 && !state.response && state.decision?.kind === 'ecazPlacement');
|
||||
act('ecaz', {type:'decision', token:token.id, territory:'arrakeen'});
|
||||
reach(state => state.phase === 4 && !state.response && state.decision?.kind === 'ecazPlacement');
|
||||
act('ecaz', {type:'decision', decline:true});
|
||||
reach(state => state.phase === 5 && state.active === 'harkonnen' && dukeAmbassadorClean(state));
|
||||
const beforeSpice = game.players.find(player => player.id === 'harkonnen')!.spice;
|
||||
act('harkonnen', {type:'ship', amount:1, territory:'arrakeen', sector:10});
|
||||
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
assert.equal(game.pendingAmbassador!.token, token.id);
|
||||
return { game, token, originalDuke, beforeSpice, actions };
|
||||
}
|
||||
|
||||
void test('plain Advanced Ecaz may acquire the original Duke on a real Harkonnen arrival before any capture', () => {
|
||||
const source = nativeArrival();
|
||||
let game = source.game;
|
||||
const {token, originalDuke, beforeSpice, actions} = source;
|
||||
const act = (actor:string, action:Action) => { game = actDukeAmbassador(game, actor, action, actions); };
|
||||
const reach = (until:(state:Game)=>boolean) => { game = advanceDukeAmbassador(game, until, actions); };
|
||||
const offer = viewGame(game, 'ecaz').ambassadorEntry;
|
||||
assert.ok(offer);
|
||||
assert.equal(offer.dukeAcquisition?.blocked, null);
|
||||
const event = game.pendingAmbassador!.event;
|
||||
act('ecaz', {type:'decision', event, trigger:true, beneficiary:'ecaz', choice:'duke'});
|
||||
reach(state => dukeAmbassadorClean(state) && !state.pendingAmbassador);
|
||||
assert.deepEqual(game.dukeVidal!.leader, originalDuke);
|
||||
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source], ['ecaz','ecaz']);
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(item => item.id === token.id)!.zone, 'supply');
|
||||
assert.equal(game.players.find(player => player.id === 'harkonnen')!.forces['arrakeen:10'], 1);
|
||||
assert.ok(game.players.find(player => player.id === 'harkonnen')!.spice < beforeSpice);
|
||||
assert.equal(game.active, 'harkonnen', 'The original shipment and movement turn resumes');
|
||||
act('harkonnen', {type:'endMovement'});
|
||||
assert.notEqual(game.active, 'harkonnen');
|
||||
assert.ok(game.players.every(player => player.leaders.every(leader => leader.id !== DUKE_VIDAL_ID)));
|
||||
});
|
||||
|
||||
void test('plain Advanced Ecaz alliance automatically lends the original Duke on Harkonnen acceptance', () => {
|
||||
const source = nativeArrival();
|
||||
let game = source.game;
|
||||
const {token, originalDuke, actions} = source;
|
||||
const act = (actor:string, action:Action) => { game = actDukeAmbassador(game, actor, action, actions); };
|
||||
const reach = (until:(state:Game)=>boolean) => { game = advanceDukeAmbassador(game, until, actions); };
|
||||
const event = game.pendingAmbassador!.event;
|
||||
act('ecaz', {type:'decision', event, trigger:true, beneficiary:'ecaz', choice:'alliance'});
|
||||
assert.equal(game.pendingAmbassador?.stage, 'allianceReply');
|
||||
act('harkonnen', {type:'decision', event, accept:true});
|
||||
reach(state => dukeAmbassadorClean(state) && !state.pendingAmbassador);
|
||||
assert.equal(game.players.find(player => player.id === 'ecaz')!.ally, 'harkonnen');
|
||||
assert.equal(game.players.find(player => player.id === 'harkonnen')!.ally, 'ecaz');
|
||||
assert.deepEqual(game.dukeVidal!.leader, originalDuke);
|
||||
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source], ['harkonnen','ally']);
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(item => item.id === token.id)!.zone, 'supply');
|
||||
assert.equal(game.players.find(player => player.id === 'harkonnen')!.forces['arrakeen:10'], 1);
|
||||
assert.equal(game.active, 'harkonnen', 'The original movement turn resumes without a loan acknowledgement');
|
||||
assert.ok(game.players.every(player => player.leaders.every(leader => leader.id !== DUKE_VIDAL_ID)));
|
||||
});
|
||||
Reference in new issue
Block a user