Compose native Ecaz Betrayal and Moritani Atomics
Retain original selected modules, typed native returns, advisors and hidden No-Field recall. Preserve a full unspent Guild/Sapho opening, paid ordered arrivals, capped provisional No-Field destruction and native World Sneak withdrawal. Bind desert Aftermath to its original Moritani Cunning placement. Validation: frozen 16-file115/115, types/lint/build, eight finite original-action and GameTable SSR programmes, four minimal legal policies. Document provisional overlaps and corrected genuine recipes. No stored-game change or deployment; full combinations, comprehensive assurance and deployed acceptance remain required.
This commit is contained in:
1 parent
8793e23d6b
commit
bb9d292c4c
23 files changed
+1824
-88
No files matched your search
+58
-19
@@ -419,6 +419,7 @@ import {
|
||||
validateRicheseNoFieldPair,
|
||||
moveRicheseNoField,
|
||||
revealRicheseNoField,
|
||||
recallRicheseNoField,
|
||||
shipAlliedRicheseNoField,
|
||||
projectRicheseNoField,
|
||||
type RicheseNoField,
|
||||
@@ -3248,13 +3249,20 @@ function finishEcazBetrayalResponse(g: Game, response: ResponseWindow, canceled:
|
||||
delete ally.forces[key];
|
||||
if (ally.elites) delete ally.elites.forces[key];
|
||||
}
|
||||
ally.reserves += record.snapshot.total.normal + record.snapshot.total.elite;
|
||||
if (ally.elites) ally.elites.reserves += record.snapshot.total.elite;
|
||||
if (ally.advisors) delete ally.advisors[record.snapshot.territory];
|
||||
if (record.snapshot.noField) {
|
||||
const source = record.snapshot.noField;
|
||||
const supplier = getPlayer(g, source.supplier);
|
||||
supplier.noField = noFieldRule(() => recallRicheseNoField(supplier.noField!, source.tokenId));
|
||||
supplier.noFieldEvent = crypto.randomUUID();
|
||||
}
|
||||
const amount = record.snapshot.total.normal + record.snapshot.total.elite;
|
||||
if (amount) addNativeReserves(g, ally, amount, record.snapshot.total.elite);
|
||||
observeOccupation(g);
|
||||
}
|
||||
log(g, canceled
|
||||
? `Karama prevented the Ecaz Nexus return from ${territory(record.snapshot.territory).name}; the Nexus card remains spent.`
|
||||
: `${ally.name} returned ${record.snapshot.total.normal + record.snapshot.total.elite} forces from ${territory(record.snapshot.territory).name} to reserves under Ecaz Nexus Betrayal.`,
|
||||
: `${ally.name} returned ${record.snapshot.total.normal + record.snapshot.total.elite} physical forces${record.snapshot.noField ? ' and recalled the concealed No-Field face down' : ''} from ${territory(record.snapshot.territory).name} under Ecaz Nexus Betrayal.`,
|
||||
{ faction: ally.faction, name: 'Ecaz Nexus Betrayal' });
|
||||
}
|
||||
function playEcazBetrayal(g: Game, p: Player, action: Action) {
|
||||
@@ -4941,28 +4949,50 @@ function atomicsRule<T>(run: () => T): T {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Only the clean printed-territory entry is connected; other arrival parents remain gated. */
|
||||
/** Optional modules do not remove the native all-forces effect. Their setup and
|
||||
* original arrival rules retain their own admission and continuation checks. */
|
||||
function atomicsModeSupported(g: Game): boolean {
|
||||
return !!byFaction(g, 'moritani') && !byFaction(g, 'ecaz') &&
|
||||
if (!byFaction(g, 'moritani')) return false;
|
||||
const original = !byFaction(g, 'ecaz') &&
|
||||
!byFaction(g, 'guild') &&
|
||||
g.players.every(p => CLASSIC_FACTIONS[p.faction] === true || p.faction === 'moritani') &&
|
||||
g.expansions.every(expansion => expansion === 'ecaz') &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills &&
|
||||
!g.discoveryEnabled && !g.techTokens && !g.strongholdCards &&
|
||||
!g.ecazTreachery && !g.pendingArrivalOverlap;
|
||||
!g.ecazTreachery;
|
||||
return original || (typeof g.advanced === 'boolean' &&
|
||||
g.players.length >= (g.techTokens ? 3 : 2) && g.players.length <= 6 &&
|
||||
new Set(g.players.map(player => player.faction)).size === g.players.length &&
|
||||
g.expansions.includes('ecaz') && g.expansions.length <= 3 &&
|
||||
g.expansions.every((family,index) => ['ecaz','ix','choam'].includes(family) && g.expansions.indexOf(family) === index) &&
|
||||
(!g.strongholdCards || g.advanced) &&
|
||||
g.players.every(player => FACTIONS.some(faction => faction.id === player.faction &&
|
||||
(faction.expansion === 'base' || g.expansions.includes(faction.expansion)))));
|
||||
}
|
||||
function currentAtomicsQuote(
|
||||
g: Game,
|
||||
entry: NonNullable<Game['pendingTerrorEntry']>,
|
||||
): AtomicsQuote {
|
||||
requireRule(atomicsModeSupported(g) && !g.moritaniAtomics && !g.moritaniExtortion,
|
||||
'Atomics with these combined modules or another unresolved Terror effect is not yet supported.');
|
||||
requireRule(atomicsModeSupported(g) && !g.moritaniAtomics,
|
||||
'Atomics requires its original native roster and unused Aftermath token.');
|
||||
const blocked = homeworldTerrorEntryBlock(g, entry.entrant, entry.amount, 'reveal');
|
||||
requireRule(!blocked, blocked ?? 'This Terror reveal is unavailable.');
|
||||
const token = g.moritaniTerror?.tokens.find(row => row.id === entry.token);
|
||||
requireRule(token, 'The Atomics token has lost its original stronghold.');
|
||||
requireRule(terrorEntryLocationAllowed(g,entry), 'Atomics lost its original physical placement source.');
|
||||
// Provisional explosion composition: quote the same capped physical reveal
|
||||
// that allowance will perform. Forecasts never change or expose a live marker.
|
||||
const forecast = g.players.some(player => player.noField?.deployed?.location.territory === entry.territory)
|
||||
? structuredClone(g) : g;
|
||||
if (forecast !== g) for (const player of forecast.players)
|
||||
if (player.noField?.deployed?.location.territory === entry.territory)
|
||||
revealPlayerNoField(forecast, player, 'atomics');
|
||||
return atomicsRule(() => quoteMoritaniAtomics({
|
||||
token, territory: entry.territory, moritaniId: byFaction(g, 'moritani')!.id,
|
||||
turn: g.turn, aftermath: g.moritaniAtomics ?? null,
|
||||
players: g.players.map(player => ({
|
||||
...(!TERROR_STRONGHOLDS.includes(entry.territory)
|
||||
? {nexusPlacement:entry.nexusPlacements?.[token.id]} : {}),
|
||||
players: forecast.players.map(player => ({
|
||||
...player, handSize: player.hand.length, baseHandLimit: handLimit(player),
|
||||
})),
|
||||
}));
|
||||
@@ -4972,6 +5002,9 @@ function resolveAtomics(
|
||||
entry: NonNullable<Game['pendingTerrorEntry']>,
|
||||
quote: AtomicsQuote,
|
||||
) {
|
||||
for (const player of g.players)
|
||||
if (player.noField?.deployed?.location.territory === entry.territory)
|
||||
revealPlayerNoField(g, player, 'atomics');
|
||||
g.moritaniAtomics = quote.aftermath;
|
||||
for (const casualty of quote.casualties) {
|
||||
const player = getPlayer(g, casualty.playerId);
|
||||
@@ -6153,7 +6186,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
const box = continuation?.kind === 'nullentropyDiscard';
|
||||
const truth = continuation?.kind === 'truthtranceDiscard';
|
||||
const ambassadorTerror =
|
||||
continuation?.kind === 'terrorDiscard' &&
|
||||
(continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics') &&
|
||||
continuation.entry.cause === 'ambassador';
|
||||
requireRule(
|
||||
(g.status === 'playing' || (truth && g.status === 'setup')) &&
|
||||
@@ -7035,16 +7068,20 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
} else if (continuation?.kind === 'terrorAtomics') {
|
||||
const { entry, aftermath, handBefore } = continuation;
|
||||
atomicsIntegrity(g);
|
||||
if (entry?.cause === 'ambassador') ambassadorRelocationIntegrity(g);
|
||||
const owner = byFaction(g, 'moritani');
|
||||
const token = g.moritaniTerror?.tokens.find(row => row.id === entry?.token);
|
||||
requireRule(!g.pendingTerrorEntry && !g.pendingExchange && !g.summonedWorm &&
|
||||
entry && entry.turn === g.turn && entry.phase === g.phase &&
|
||||
entry.stage === 'offer' && entry.resume === (entry.cause === 'wormRide' ? 'wormRide' : 'none') &&
|
||||
(entry.cause !== 'wormRide' || (g.phase === 1 &&
|
||||
g.players.some(player => player.id === entry.entrant && player.faction === 'fremen'))) &&
|
||||
['shipment', 'movement', 'advisor', 'wormRide'].includes(entry.cause) &&
|
||||
entry.stage === 'offer' && ['none','wormRide','ambassador'].includes(entry.resume) &&
|
||||
(entry.cause === 'ambassador' ? ambassadorTerrorEntryMatches(g,entry)
|
||||
: entry.cause === 'wormRide' ?
|
||||
(entry.resume === 'wormRide' || overlapOwnsWormTerror(g,entry)) && g.phase === 1 &&
|
||||
g.players.some(player => player.id === entry.entrant && player.faction === 'fremen')
|
||||
: entry.resume === 'none') &&
|
||||
['shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'ambassador'].includes(entry.cause) &&
|
||||
entry.entrant !== owner?.id && entry.territory === aftermath.territory &&
|
||||
validGameLocation(g, entry.territory, entry.sector) &&
|
||||
terrorEntryLocationAllowed(g,entry) && validGameLocation(g, entry.territory, entry.sector) &&
|
||||
token?.kind === 'atomics' && token.status === 'removed' &&
|
||||
token.location === null &&
|
||||
batch.cause === 'terror:atomics' && batch.entries.length > 0 &&
|
||||
@@ -16445,7 +16482,7 @@ function arrivalOverlapIntegrity(g: Game) {
|
||||
const ambassador = g.pendingAmbassador ??
|
||||
(continuation?.kind === 'ambassador' ? continuation.entry : null);
|
||||
const terror = g.pendingTerrorEntry ??
|
||||
(continuation?.kind === 'terrorDiscard' ? continuation.entry : null);
|
||||
(continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics' ? continuation.entry : null);
|
||||
const active = record.stage === 'first' ? record.first :
|
||||
record.first === 'ambassador' ? 'terror' : 'ambassador';
|
||||
requireRule(g.status === 'playing' && arrivalOverlapModeSupported(g) &&
|
||||
@@ -16713,7 +16750,7 @@ function ambassadorRelocationIntegrity(g: Game) {
|
||||
);
|
||||
const terror =
|
||||
g.pendingTerrorEntry ??
|
||||
(continuation?.kind === 'terrorDiscard' ? continuation.entry : null);
|
||||
(continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics' ? continuation.entry : null);
|
||||
const relatedResponses = responses.filter(
|
||||
(response) =>
|
||||
response.advisorResume === 'ambassador' ||
|
||||
@@ -18980,7 +19017,7 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
!options.blocked,
|
||||
options.blocked ?? 'This arrival is unavailable.',
|
||||
);
|
||||
p.reserves -= amount;
|
||||
withdrawNativeReserves(g, p, amount, 0);
|
||||
place(p, entry.territory, entry.sector, amount);
|
||||
observeOccupation(g);
|
||||
// A positive Sneak Attack transfers Moritani's expressly off-planet
|
||||
@@ -21864,7 +21901,9 @@ function atomicsIntegrity(g: Game) {
|
||||
const token = g.moritaniTerror?.tokens.find(row => row.kind === 'atomics');
|
||||
requireRule(atomicsModeSupported(g) && owner?.id === aftermath.moritaniId &&
|
||||
Number.isSafeInteger(aftermath.turn) && aftermath.turn >= 1 &&
|
||||
aftermath.turn <= g.turn && TERROR_STRONGHOLDS.includes(aftermath.territory) &&
|
||||
aftermath.turn <= g.turn && (TERROR_STRONGHOLDS.includes(aftermath.territory) ||
|
||||
(token && typeof aftermath.nexusPlacement === 'string' &&
|
||||
terrorLocationAllowed(g,token.id,aftermath.territory,aftermath.nexusPlacement,false,true))) &&
|
||||
token?.status === 'removed' && token.location === null &&
|
||||
aftermath.alliancePolicy === 'activation-targets' &&
|
||||
(aftermath.allyAtActivation === null || g.players.some(player =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { splitLocation, validLocation } from './board';
|
||||
import { TERRITORIES, splitLocation, validLocation } from './board';
|
||||
import type { Player } from './engine';
|
||||
import type { TerrorToken } from './moritani-terror';
|
||||
import { TERROR_STRONGHOLDS } from './moritani-terror';
|
||||
@@ -11,6 +11,8 @@ export type AtomicsAftermath = Readonly<{
|
||||
allyAtActivation: string | null;
|
||||
/** Provisional permanent targets: Moritani and its ally at activation. */
|
||||
alliancePolicy: 'activation-targets';
|
||||
/** Original Moritani Cunning grant for an otherwise non-stronghold target. */
|
||||
nexusPlacement?: string;
|
||||
}>;
|
||||
|
||||
export type AtomicsPlayer = Pick<
|
||||
@@ -49,11 +51,15 @@ export function quoteMoritaniAtomics(input: {
|
||||
moritaniId: string;
|
||||
turn: number;
|
||||
aftermath: AtomicsAftermath | null;
|
||||
/** The engine authenticates the original physical Cunning placement. */
|
||||
nexusPlacement?: string;
|
||||
}): AtomicsQuote {
|
||||
const { token, players, moritaniId, turn, territory } = input;
|
||||
requireAtomics(token.kind === 'atomics' && token.status === 'placed' &&
|
||||
token.location === territory && TERROR_STRONGHOLDS.includes(territory),
|
||||
'Atomics requires its still-placed token in the entered ordinary stronghold.');
|
||||
token.location === territory && (TERROR_STRONGHOLDS.includes(territory) ||
|
||||
(typeof input.nexusPlacement === 'string' && input.nexusPlacement.length > 0 &&
|
||||
TERRITORIES.some(entry => entry.id === territory))),
|
||||
'Atomics requires its still-placed token and an ordinary stronghold or original Nexus placement.');
|
||||
requireAtomics(input.aftermath === null, 'Atomics Aftermath is already on the board.');
|
||||
requireAtomics(Number.isSafeInteger(turn) && turn >= 1, 'Atomics needs a valid turn.');
|
||||
requireAtomics(players.length > 0 && new Set(players.map(p => p.id)).size === players.length &&
|
||||
@@ -70,8 +76,8 @@ export function quoteMoritaniAtomics(input: {
|
||||
(whole(p.elites.tanks) && p.elites.tanks <= p.tanks)),
|
||||
'Atomics needs valid Tanks custody.');
|
||||
let total = 0, eliteTotal = 0;
|
||||
// A marker with zero counters is not a casualty. A concealed No-Field with
|
||||
// physical counters needs its own reveal/custody ruling before this effect.
|
||||
// The engine materializes any caught No-Field in a detached forecast before
|
||||
// quoting this physical effect. The pure calculator never invents counters.
|
||||
requireAtomics(p.noField?.deployed?.location.territory !== token.location,
|
||||
'Atomics with a concealed No-Field awaits its reveal and casualty rules.');
|
||||
for (const key of new Set([...Object.keys(p.forces),
|
||||
@@ -82,7 +88,7 @@ export function quoteMoritaniAtomics(input: {
|
||||
const elite = p.elites?.forces[key] ?? 0;
|
||||
requireAtomics(key === `${territory}:${sector}` && validLocation(territory, sector) &&
|
||||
whole(amount) && whole(elite) && elite <= amount,
|
||||
'Atomics needs valid typed forces in the stronghold.');
|
||||
'Atomics needs valid typed forces in the territory.');
|
||||
total += amount;
|
||||
eliteTotal += elite;
|
||||
if (amount) casualties.push({ playerId: p.id, location: key,
|
||||
@@ -103,7 +109,8 @@ export function quoteMoritaniAtomics(input: {
|
||||
return {
|
||||
casualties,
|
||||
aftermath: { territory: token.location, turn, moritaniId,
|
||||
allyAtActivation: ally?.id ?? null, alliancePolicy: 'activation-targets' },
|
||||
allyAtActivation: ally?.id ?? null, alliancePolicy: 'activation-targets',
|
||||
...(!TERROR_STRONGHOLDS.includes(territory) ? { nexusPlacement: input.nexusPlacement! } : {}) },
|
||||
handReductions,
|
||||
};
|
||||
}
|
||||
|
||||
+114
-31
@@ -1,13 +1,19 @@
|
||||
import { TERRITORIES, splitLocation, validLocation } from './board';
|
||||
import { gameTerritories, splitLocation, validGameLocation } from './board';
|
||||
import type { Game, Player } from './engine';
|
||||
import { nexusCoreProfile } from './faction-module-profile';
|
||||
import { mixedE3NexusLeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import { nexusCardMode, validateNexusCards } from './nexus-cards';
|
||||
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
import { validateRicheseNoField } from './richese-no-field';
|
||||
|
||||
export type EcazBetrayalCounts = Readonly<{ normal: number; elite: number }>;
|
||||
export type EcazBetrayalTerritory = Readonly<{
|
||||
territory: string;
|
||||
/** Complete physical ally group, keyed by printed board location. */
|
||||
/** Visible physical ally group, keyed by current board location. */
|
||||
sectors: Readonly<Record<string, EcazBetrayalCounts>>;
|
||||
total: EcazBetrayalCounts;
|
||||
/** One public concealed marker; its denomination is never a force count here. */
|
||||
concealed?: true;
|
||||
}>;
|
||||
export type EcazBetrayalOffer = Readonly<{
|
||||
event: string;
|
||||
@@ -24,6 +30,14 @@ export type EcazBetrayalSnapshot = Readonly<{
|
||||
territory: string;
|
||||
sectors: Readonly<Record<string, EcazBetrayalCounts>>;
|
||||
total: EcazBetrayalCounts;
|
||||
/** Private source for a face-down recall, not a reveal/materialization quote. */
|
||||
noField?: EcazBetrayalNoFieldSource;
|
||||
}>;
|
||||
export type EcazBetrayalNoFieldSource = Readonly<{
|
||||
supplier: string;
|
||||
event: string;
|
||||
tokenId: string;
|
||||
location: Readonly<{ territory: string; sector: number }>;
|
||||
}>;
|
||||
|
||||
const allowedFactions: Record<string, true> = {
|
||||
@@ -38,16 +52,44 @@ const count = (value: unknown): value is number =>
|
||||
const exactKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
|
||||
plain(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
||||
function openingReady(g: Game): boolean {
|
||||
const remaining = g.movementRemaining;
|
||||
const guildFirst = g.advanced && g.guildTimingGranted === true && !g.guildTimingLocked &&
|
||||
g.players.some(player => player.faction === 'guild' && player.id === g.active);
|
||||
return g.status === 'playing' && g.phase === 5 &&
|
||||
Array.isArray(g.order) && g.order.length === g.players.length &&
|
||||
new Set(g.order).size === g.players.length &&
|
||||
g.players.every(p => g.order.includes(p.id) && p.shipped === false && p.moved === 0) &&
|
||||
g.active === g.order[0] && Array.isArray(g.movementRemaining) &&
|
||||
g.movementRemaining.length === g.order.length &&
|
||||
g.movementRemaining.every((id, index) => id === g.order[index]);
|
||||
Array.isArray(remaining) && remaining.length === g.order.length &&
|
||||
new Set(remaining).size === remaining.length && remaining.every(id => g.order.includes(id)) &&
|
||||
remaining.includes(g.active!) && (g.active === remaining[0] || guildFirst);
|
||||
}
|
||||
|
||||
function locations(player: Player): Map<string, Record<string, EcazBetrayalCounts>> {
|
||||
/** Original native E3 envelopes and the selected-independent Nexus core share
|
||||
* the same effect. Quiet timing and actual alliance/card custody are separate. */
|
||||
export function ecazBetrayalModeSupported(g: Game): boolean {
|
||||
if (!g.nexusCards?.cards || !g.players.some(p => p.faction === 'ecaz') || g.sandtrout) return false;
|
||||
if (nexusCoreProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g) ||
|
||||
(g.leaderSkills && (standaloneE3NexusLeaderSkillsProfile(g) || pairedE3NexusLeaderSkillsProfile(g) ||
|
||||
mixedE3NexusLeaderSkillsProfile(g)))) return true;
|
||||
// Preserve the original unselected-deck audit/runtime shape. Fresh combined
|
||||
// configurations use the canonical predicates above, not this legacy seam.
|
||||
return g.expansions.length === 0 && g.players.length >= 2 && g.players.length <= 6 &&
|
||||
new Set(g.players.map(p => p.faction)).size === g.players.length &&
|
||||
g.players.every(p => Object.hasOwn(allowedFactions, p.faction)) &&
|
||||
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
|
||||
!g.discoveryStash && !g.greatMaker && !g.ecazTreachery && !g.techTokens && !g.strongholdCards &&
|
||||
!g.semutaPreview && !g.advancedPreview && !g.kullPreview && !g.nexusKullPreview &&
|
||||
!g.guildBetrayalPreview && !g.richeseBetrayalPreview && !g.nexusIxianReplacementPreview &&
|
||||
!g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview;
|
||||
}
|
||||
|
||||
function boardLocation(g: Game, key: string): boolean {
|
||||
const { territory, sector } = splitLocation(key);
|
||||
return Number.isSafeInteger(sector) && key === `${territory}:${sector}` &&
|
||||
gameTerritories(g).some(t => t.id === territory) && validGameLocation(g, territory, sector);
|
||||
}
|
||||
|
||||
function locations(g: Game, player: Player): Map<string, Record<string, EcazBetrayalCounts>> {
|
||||
if (!plain(player.forces) || (player.elites && !plain(player.elites.forces)))
|
||||
throw new Error('Ecaz Betrayal needs plain physical force custody.');
|
||||
const elites = player.elites?.forces;
|
||||
@@ -57,10 +99,8 @@ function locations(player: Player): Map<string, Record<string, EcazBetrayalCount
|
||||
}
|
||||
const result = new Map<string, Record<string, EcazBetrayalCounts>>();
|
||||
for (const [key, total] of Object.entries(player.forces)) {
|
||||
const { territory, sector } = splitLocation(key);
|
||||
if (!TERRITORIES.some(t => t.id === territory) ||
|
||||
!Number.isSafeInteger(sector) || !validLocation(territory, sector) ||
|
||||
key !== `${territory}:${sector}` || !count(total))
|
||||
const { territory } = splitLocation(key);
|
||||
if (!boardLocation(g, key) || !count(total))
|
||||
throw new Error('Ecaz Betrayal has invalid physical board custody.');
|
||||
const elite = elites && Object.hasOwn(elites, key) ? elites[key] : 0;
|
||||
if (!count(elite) || elite > total)
|
||||
@@ -73,6 +113,39 @@ function locations(player: Player): Map<string, Record<string, EcazBetrayalCount
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Public presence only. Do not inspect the selected token or its value when
|
||||
* constructing offers, even for a zero marker or a controller with no reserves. */
|
||||
function markerSupplier(g: Game, ally: string, territory: string): Player | undefined {
|
||||
const suppliers = g.players.filter(p => p.noField?.deployed?.controller === ally &&
|
||||
p.noField.deployed.location.territory === territory);
|
||||
if (suppliers.length > 1) throw new Error('Ecaz Betrayal has multiple concealed marker sources.');
|
||||
return suppliers[0];
|
||||
}
|
||||
|
||||
function markerSource(g: Game, ally: string, territory: string): EcazBetrayalNoFieldSource | undefined {
|
||||
const supplier = markerSupplier(g, ally, territory);
|
||||
if (!supplier) return undefined;
|
||||
const marker = supplier.noField!.deployed!;
|
||||
validateRicheseNoField(supplier.noField!);
|
||||
if (supplier.faction !== 'richese' || typeof supplier.noFieldEvent !== 'string' || !supplier.noFieldEvent ||
|
||||
!boardLocation(g, `${marker.location.territory}:${marker.location.sector}`))
|
||||
throw new Error('Ecaz Betrayal has invalid concealed marker custody.');
|
||||
return { supplier: supplier.id, event: supplier.noFieldEvent, tokenId: marker.tokenId,
|
||||
location: { ...marker.location } };
|
||||
}
|
||||
|
||||
function allyLocations(g: Game, ally: Player): Map<string, Record<string, EcazBetrayalCounts>> {
|
||||
const result = locations(g, ally);
|
||||
for (const supplier of g.players) {
|
||||
const marker = supplier.noField?.deployed;
|
||||
if (!marker || marker.controller !== ally.id) continue;
|
||||
if (!boardLocation(g, `${marker.location.territory}:${marker.location.sector}`))
|
||||
throw new Error('Ecaz Betrayal has invalid concealed board custody.');
|
||||
if (!result.has(marker.location.territory)) result.set(marker.location.territory, {});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function territoryQuote(territory: string, sectors: Record<string, EcazBetrayalCounts>): EcazBetrayalTerritory {
|
||||
let normal = 0, elite = 0;
|
||||
for (const group of Object.values(sectors)) {
|
||||
@@ -100,13 +173,8 @@ export function ecazBetrayalOffer(g: Game, owner: string, automaticPending = fal
|
||||
else if (!openingReady(g))
|
||||
blocked = 'Use Ecaz Betrayal before the first Shipment and Movement action.';
|
||||
else if (!ally) blocked = 'Ecaz needs a real reciprocal alliance.';
|
||||
else if (g.expansions.some(expansion => expansion !== 'ecaz') ||
|
||||
g.players.some(p => !Object.hasOwn(allowedFactions, p.faction)) ||
|
||||
g.homeworlds || g.leaderSkills || g.discoveryEnabled || g.discoveries ||
|
||||
g.discoveryStash || g.greatMaker || g.ecazTreachery || g.techTokens ||
|
||||
g.strongholdCards || g.sandtrout || g.players.some(p => p.noField ||
|
||||
(p.advisors && Object.keys(p.advisors).length > 0)))
|
||||
blocked = 'Combined modules, No-Fields and advisor stances remain unavailable.';
|
||||
else if (!ecazBetrayalModeSupported(g))
|
||||
blocked = 'This Ecaz Betrayal roster and module combination is unavailable.';
|
||||
else if (automaticPending || g.response || g.decision || g.truthtrance ||
|
||||
g.phaseOpening || g.battle || g.pendingTreacheryDiscard || g.pendingExchange ||
|
||||
g.pendingKarama || g.pendingRevival || g.pendingAmbassador || g.pendingCapture ||
|
||||
@@ -117,9 +185,10 @@ export function ecazBetrayalOffer(g: Game, owner: string, automaticPending = fal
|
||||
if (!blocked) validateNexusCards(cards, g.players);
|
||||
const territories: EcazBetrayalTerritory[] = [];
|
||||
if (!blocked && ally) {
|
||||
const occupied = locations(ecaz);
|
||||
for (const [territory, sectors] of locations(ally))
|
||||
if (occupied.has(territory)) territories.push(territoryQuote(territory, sectors));
|
||||
const occupied = locations(g, ecaz);
|
||||
for (const [territory, sectors] of allyLocations(g, ally))
|
||||
if (occupied.has(territory)) territories.push({ ...territoryQuote(territory, sectors),
|
||||
...(markerSupplier(g, ally.id, territory) ? { concealed: true as const } : {}) });
|
||||
if (!territories.length) blocked = 'Ecaz and its ally share no territory containing physical forces.';
|
||||
}
|
||||
return { event, blocked, ally: ally?.id ?? null, territories };
|
||||
@@ -134,9 +203,10 @@ export function quoteEcazBetrayal(g: Game, owner: string, event: string, territo
|
||||
throw new Error(offer?.blocked ?? 'This Ecaz Betrayal opportunity is stale.');
|
||||
const selected = offer.territories.find(row => row.territory === territory);
|
||||
if (!selected) throw new Error('Choose one shared territory with Ecaz and its ally.');
|
||||
const noField = markerSource(g, offer.ally!, territory);
|
||||
return { event, owner, ally: offer.ally!, turn: g.turn, territory,
|
||||
discardIndex: g.nexusCards!.cards!.discard.length,
|
||||
sectors: selected.sectors, total: selected.total };
|
||||
sectors: selected.sectors, total: selected.total, ...(noField ? { noField } : {}) };
|
||||
}
|
||||
|
||||
/** Validate a saved pre-return quote against frozen authoritative physical
|
||||
@@ -145,20 +215,28 @@ export function quoteEcazBetrayal(g: Game, owner: string, event: string, territo
|
||||
export function validateEcazBetrayalSnapshot(
|
||||
g: Game, snapshot: EcazBetrayalSnapshot, pending = false,
|
||||
): void {
|
||||
if (!exactKeys(snapshot, ['event', 'owner', 'ally', 'turn', 'territory', 'discardIndex', 'sectors', 'total']) ||
|
||||
if (!exactKeys(snapshot, ['event', 'owner', 'ally', 'turn', 'territory', 'discardIndex', 'sectors', 'total',
|
||||
...(snapshot?.noField !== undefined ? ['noField'] : [])]) ||
|
||||
typeof snapshot.event !== 'string' || typeof snapshot.owner !== 'string' ||
|
||||
typeof snapshot.ally !== 'string' || typeof snapshot.territory !== 'string' ||
|
||||
!Number.isSafeInteger(snapshot.turn) || snapshot.turn !== g.turn ||
|
||||
!Number.isSafeInteger(snapshot.discardIndex) || snapshot.discardIndex < 0 ||
|
||||
!exactKeys(snapshot.total, ['normal', 'elite']) ||
|
||||
!count(snapshot.total.normal) || !count(snapshot.total.elite) ||
|
||||
!plain(snapshot.sectors) || !Object.keys(snapshot.sectors).length)
|
||||
!plain(snapshot.sectors) || (!Object.keys(snapshot.sectors).length && !snapshot.noField) ||
|
||||
!gameTerritories(g).some(t => t.id === snapshot.territory))
|
||||
throw new Error('Ecaz Betrayal has an invalid saved force snapshot.');
|
||||
if (snapshot.noField !== undefined && (!exactKeys(snapshot.noField, ['supplier', 'event', 'tokenId', 'location']) ||
|
||||
typeof snapshot.noField.supplier !== 'string' || !snapshot.noField.supplier ||
|
||||
typeof snapshot.noField.event !== 'string' || !snapshot.noField.event ||
|
||||
typeof snapshot.noField.tokenId !== 'string' || !snapshot.noField.tokenId ||
|
||||
!exactKeys(snapshot.noField.location, ['territory', 'sector']) ||
|
||||
snapshot.noField.location.territory !== snapshot.territory ||
|
||||
!boardLocation(g, `${snapshot.noField.location.territory}:${snapshot.noField.location.sector}`)))
|
||||
throw new Error('Ecaz Betrayal has an invalid saved concealed marker.');
|
||||
for (const [key, group] of Object.entries(snapshot.sectors)) {
|
||||
const { territory, sector } = splitLocation(key);
|
||||
if (territory !== snapshot.territory || !TERRITORIES.some(t => t.id === territory) ||
|
||||
!Number.isSafeInteger(sector) || !validLocation(territory, sector) ||
|
||||
key !== `${territory}:${sector}` ||
|
||||
const { territory } = splitLocation(key);
|
||||
if (territory !== snapshot.territory || !boardLocation(g, key) ||
|
||||
!exactKeys(group, ['normal', 'elite']) || !count(group.normal) || !count(group.elite) ||
|
||||
group.normal + group.elite <= 0 || !Number.isSafeInteger(group.normal + group.elite))
|
||||
throw new Error('Ecaz Betrayal has an invalid saved sector group.');
|
||||
@@ -169,7 +247,7 @@ export function validateEcazBetrayalSnapshot(
|
||||
const ecaz = g.players.find(p => p.faction === 'ecaz');
|
||||
const ally = g.players.find(p => p.id === snapshot.ally);
|
||||
const holder = g.players.find(p => p.id === snapshot.owner);
|
||||
if (!openingReady(g) || !cards || cards.hands[snapshot.owner] !== null ||
|
||||
if (!openingReady(g) || !ecazBetrayalModeSupported(g) || !cards || cards.hands[snapshot.owner] !== null ||
|
||||
cards.discard[snapshot.discardIndex] !== 'ecaz' || !ecaz || !ally ||
|
||||
ecaz.ally !== ally.id || ally.ally !== ecaz.id || !holder ||
|
||||
holder.ally || holder.faction === 'ecaz' ||
|
||||
@@ -177,16 +255,21 @@ export function validateEcazBetrayalSnapshot(
|
||||
snapshot.owner, ally.id, snapshot.discardIndex]))
|
||||
throw new Error('Ecaz Betrayal has lost its pending card or alliance.');
|
||||
validateNexusCards(cards, g.players);
|
||||
const occupied = locations(ecaz);
|
||||
const selected = locations(ally).get(snapshot.territory);
|
||||
const occupied = locations(g, ecaz);
|
||||
const selected = allyLocations(g, ally).get(snapshot.territory);
|
||||
if (!occupied.has(snapshot.territory) || !selected)
|
||||
throw new Error('Ecaz Betrayal has lost the shared physical territory.');
|
||||
const noField = markerSource(g, ally.id, snapshot.territory);
|
||||
fresh = { event: snapshot.event, turn: g.turn, owner: snapshot.owner,
|
||||
ally: ally.id, discardIndex: snapshot.discardIndex,
|
||||
...territoryQuote(snapshot.territory, selected) };
|
||||
...territoryQuote(snapshot.territory, selected), ...(noField ? { noField } : {}) };
|
||||
} else fresh = quoteEcazBetrayal(g, snapshot.owner, snapshot.event, snapshot.territory);
|
||||
if (snapshot.ally !== fresh.ally || snapshot.discardIndex !== fresh.discardIndex ||
|
||||
snapshot.total.normal !== fresh.total.normal || snapshot.total.elite !== fresh.total.elite ||
|
||||
snapshot.noField?.supplier !== fresh.noField?.supplier || snapshot.noField?.event !== fresh.noField?.event ||
|
||||
snapshot.noField?.tokenId !== fresh.noField?.tokenId ||
|
||||
snapshot.noField?.location.territory !== fresh.noField?.location.territory ||
|
||||
snapshot.noField?.location.sector !== fresh.noField?.location.sector ||
|
||||
Object.keys(snapshot.sectors).length !== Object.keys(fresh.sectors).length ||
|
||||
Object.entries(fresh.sectors).some(([key, group]) =>
|
||||
!Object.hasOwn(snapshot.sectors, key) ||
|
||||
|
||||
+19
-10
@@ -1470,7 +1470,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The mobile stronghold can receive a non-Ixian group only from the territory it points to. Ecaz and its ally may co-occupy under Occupy. Advisors keep their applicable stance and accompaniment locks; an eligible arrival can request fighters. CHOAM may use Baliset during Shipment and Movement; if allowed, choose another legal destination. Intrusion or Terror resolves before the original entrant continues. A destination that causes both settles the Intrusion first and then offers the committed Terror entry; no payment or force transfer replays. There is no extra decline after triggering; if no legal move remains, the effect finishes without moving pieces and its token stays consumed.',
|
||||
'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.',
|
||||
'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.',
|
||||
'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. A Terror entry now waits for the arrival’s own interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor choice, or Fremen storm protection — and then opens without replaying payment or force transfer. Ambassador-created Fremen/Guild child arrivals remain gated. Atomics Aftermath, Homeworlds, Nexus and Discovery combinations do not gain coverage from this overlap path.',
|
||||
'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. A Terror entry now waits for the arrival’s own interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor choice, or Fremen storm protection — and then opens without replaying payment or force transfer. Ambassador-created child arrivals retain their own contracts. The native-module extension now composes ordinary paid arrival order with Atomics, Homeworlds and Nexus; other combinations and complete rules remain unfinished.',
|
||||
'The Tleilaxu Ambassador offers one first-death own-leader revival or up to four physical forces for free. Atreides may choose its separate first-killed Kwisatz Haderach under the base leader-revival rule; this does not add a disc to the ordinary roster, reset battle losses or spend the ordinary leader allowance. Native and Bene Gesserit copied grants share the same private eligibility. Every existing KH return advances its original cycle marker; a prior return remains outside this first-death preview. The force/leader choices are exclusive and ordinary allowance independence stays provisional. Repeat-return, shared-leader and revival-income interpretations remain separate.',
|
||||
'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. Advanced combined Occupy has a separate bounded Development Verified guide, and Basic even-force Occupy now composes the same shared controls. Remaining effects, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.',
|
||||
],
|
||||
@@ -3040,7 +3040,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'Six hidden tokens threaten stronghold entrants. Bounded Atomics revelation is available in classic-plus-Moritani tables; complete Moritani starts remain unavailable.',
|
||||
'Six hidden tokens threaten stronghold entrants. Native Atomics now composes the selected modules; complete Moritani rules remain unfinished.',
|
||||
steps: [
|
||||
'Placement and isolated entry decisions have controls for Robbery, Sabotage, Sneak Attack, Extortion, ordinary native-leader Assassination and bounded Atomics. You may instead leave a token hidden. Supported native Moritani starts use labelled previews; unsupported competing entry reactions remain guarded.',
|
||||
'Begin with all six Terror tokens in your hidden supply. Once during Mentat Pause, you may either place one from supply or move one already placed to another eligible stronghold. There is no spice cost.',
|
||||
@@ -3071,7 +3071,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'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.',
|
||||
'Atomics sends every faction’s forces in the territory to the Tanks and places public Aftermath. Native revelation retains original Guild invoices, Ambassador/Terror arrival order and selected modules. A caught No-Field provisionally materializes capped reserves before all physical losses. 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.',
|
||||
'Sabotage: randomly draw and discard a Treachery Card from the entrant if possible. Then you may give that player a card of your choice from your own hand. Giving a card is optional.',
|
||||
@@ -3101,7 +3101,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary:
|
||||
'Bounded Atomics reveals destroy the territory’s forces, prohibit future shipment there, and reduce hand limits.',
|
||||
steps: [
|
||||
'On a supported classic-plus-Moritani table without Guild, Ecaz or optional modules, revealing Atomics sends every faction’s forces in the triggered territory to the Tanks and places a public Atomics Aftermath marker there. Hidden tokens remain face down until revealed.',
|
||||
'On a supported native Moritani table with original selected modules, revealing Atomics sends every faction’s forces in the triggered territory to the Tanks and places a public Atomics Aftermath marker there. Hidden tokens remain face down until revealed.',
|
||||
'An original Moritani Nexus Cunning placement may put Atomics in a printed Arrakis desert. The later explosion and Aftermath retain that actual placement grant after the token is removed. This does not permit ordinary desert placement, Homeworld, HMS or Discovery targets.',
|
||||
'A caught concealed No-Field provisionally reveals its reserve-capped native forces before the explosion sends them to Tanks. This composition is labelled provisional; it is not a located publisher clarification.',
|
||||
'Aftermath permanently prohibits shipping forces into that territory, including Fremen reinforcements. The restriction is on shipment: it does not prohibit otherwise legal ordinary movement into the territory.',
|
||||
'Sneak Attack expressly permits its reserve forces to enter despite Aftermath. Its storm and occupancy restrictions still apply.',
|
||||
'Beginning on the activation turn, Moritani’s hand limit and its ally’s at activation are each reduced by one. If either hand exceeds its reduced limit on resolution, discard one random card per excess card. The public Aftermath notice identifies the affected players; their individual projected hand limits reflect the penalty.',
|
||||
@@ -3109,6 +3111,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The Atomics Terror token and Family Atomics Treachery Card are separate components with different effects. Aftermath is not the destroyed Shield Wall. Complete Moritani games and public expansion starts remain unavailable.',
|
||||
],
|
||||
related: ['moritani-terror-effects', 'movement', 'card-atomics', 'bidding'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Native optional-module Atomics uses original typed casualties, capped No-Field reveal, fixed activation hand targets and suspended arrival continuation. Desert Aftermath retains the actual Cunning placement history. Complete combinations remain open.', evidence: ['game/moritani-atomics.ts', 'game/engine.ts', 'game/terror-location.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Original Reveal Atomics and public Aftermath controls explain the destructive effect, shipment ban, random overflow and provisional policies.', evidence: ['components/moritani-terror.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Minimal legal original Terror decisions and Sneak Attack use the original projected controls; strategy and difficulty calibration remain deferred.', evidence: ['game/bots.ts', 'tests/moritani-atomics-modules.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed Cunning placement and provisional concealed destruction/activation targets are explicit; preview and complete-mode gates stay separate.', evidence: ['docs/MORITANI_TERROR_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused original-action module and Cunning desert scenarios supplement the earlier native proof; source-bound owner verification is recorded in the linked contract. Comprehensive assurance and deployed acceptance remain required.', evidence: ['tests/moritani-atomics-modules.test.ts', 'tests/fixture-moritani-atomics-modules.ts', 'tests/nexus-moritani-atomics-runtime.test.ts', 'tests/fixture-nexus-moritani-atomics.ts', 'tests/moritani-atomics.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'moritani-extortion',
|
||||
@@ -4682,7 +4691,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws and selected Atreides, Harkonnen, Tleilaxu, Ixian, Bene Gesserit, Emperor, Fremen, CHOAM, Moritani, Richese and Guild effects are connected. Other effects and complete module play remain in development.',
|
||||
steps: [
|
||||
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
|
||||
'Fresh classic2–6 Nexus core admits base33/independent36. Any selected E1/E2/E3 decks compose the independent three-card variant with Nexus12, optionally all14, original Homeworlds, Tech3+ and Advanced Strongholds2+. A selected deck need not seat its native faction. Ordinary stock is36/38/50 plus any native Richese cache10. Native Ecaz Occupy, Advanced Moritani Assassinate, Ambassador/Terror arrival and shared-Duke Cunning retain their original physical owners and continuation. Advanced actual E3 with Harkonnen and Skills remains guarded. Native Ixians get Betrayal; only their absence permits purchased-card Secret Ally replacement. Two seats can select Nexus but cannot earn a hand without the printed alliance condition. Ecaz Betrayal, Moritani Atomics, Discovery/Semuta and other reaction envelopes remain separate required work; this is not complete Nexus acceptance.',
|
||||
'Fresh classic2–6 Nexus core admits base33/independent36. Any selected E1/E2/E3 decks compose the independent three-card variant with Nexus12, optionally all14, original Homeworlds, Tech3+ and Advanced Strongholds2+. A selected deck need not seat its native faction. Ordinary stock is36/38/50 plus any native Richese cache10. Native Ecaz Occupy, Advanced Moritani Assassinate, Ambassador/Terror arrival and shared-Duke Cunning retain their original physical owners and continuation. Advanced actual E3 with Harkonnen and Skills remains guarded. Native Ixians get Betrayal; only their absence permits purchased-card Secret Ally replacement. Two seats can select Nexus but cannot earn a hand without the printed alliance condition. Ecaz Betrayal now returns typed native groups, advisors and concealed markers; Moritani Atomics follows actual original placements, including a Cunning-granted printed desert. Discovery/Semuta and other reaction envelopes remain required; this is not complete Nexus acceptance.',
|
||||
'Recruits changes native Revival rates and the ordinary seven ceiling, not the fixed Tleilaxu Secret Ally group or its one-spice price. Five paid card counters may leave two ordinary free returns, using the one leader slot once. Emperor Cunning still makes five ordinary board counters temporarily Sardaukar: independent reserve fees use separate physical reserves, Harass returns actual undialed counters, and native Karama changes only the original Nexus effect. Native winner/inspection and Collection continue without extra cleanup prompts.',
|
||||
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
|
||||
'Keep your card secret until it is used. You may hold at most one Nexus Card. It is separate from your Treachery hand and does not occupy a Treachery hand slot.',
|
||||
@@ -4735,7 +4744,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'With Moritani seated, an unallied holder of its Nexus card may spend Betrayal at a clean play boundary to return one placed Terror token to hidden Moritani supply without revealing its face. Choose the public location and token, not its secret effect; this does not use Moritani’s Mentat placement or trigger that token.',
|
||||
'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.',
|
||||
'With actual Advanced Stronghold Cards, Arrakeen bank support changes the real payer debit, not personal income. Emperor Cunning temporary Sardaukar remain physically ordinary counters and retain ordinary Tanks custody. Original losing Moritani retention precedes mandatory original-winner Tech, then CHOAM inspection excludes used opposing cards even if retained. Ordinary city Collection remains separate; none of these windows repeats the original support, card cost or casualties.',
|
||||
'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported.',
|
||||
'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Original native and selected-independent Nexus profiles retain World, training and phase modules. Advisors return as native counters; a concealed No-Field provisionally returns face down without materializing counters. The full unspent opening remains available after an actual Guild first/wait or Sapho reorder.',
|
||||
'Ecaz Cunning lets an unallied native holder spend its physical Nexus at an original quiet boundary in any phase to take the existing Duke for this turn, including living control, original captured custody or the set-aside disc in Tanks. Finish committed auctions, Battle, responses, Ambassador and other physical continuations first; no chooser or phase is stolen. New version3 records bind actual phase and custody; legacy ordinary/version2 Battle history remains strict. Tanks preserve deaths and independently clear old use without spice or ordinary allowance; living already-used Duke gains no fresh use. Standalone no-Skills Ecaz/Advanced Harkonnen connects original capture and provisional permanent liberation; one battle or unused current-turn expiry sets Duke aside, never back to the captor. Actual Ecaz Ghola returns set aside before separate Cunning; foreign-Ghola revival remains forbidden. Roster/module and shared-training guards remain.',
|
||||
'Fremen Betrayal has two bounded advance-declaration alternatives for an unallied holder in a classic Nexus table. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; combined modules and reactive post-worm timing remain gated.',
|
||||
'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.',
|
||||
@@ -4752,11 +4761,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
|
||||
checklist: [
|
||||
{area:"Implementation",status:'Partial',detail:"Selected E1/E2/E3 independent/Nexus now shares fresh setup and original native consumers: typed Ix fees/Harass/substitution, CHOAM fuel, Richese paired invoices, native Occupy/Assassinate, shared arrival order and Duke Cunning. Existing trained-capture and unfinished individual-effect envelopes remain. Native cross/return permission and rate remain separate; Cunning preserves its opportunity. Independent typed purchases and explicit immune Junction source choice retain their original tariffs/payees. Richese Secret Ally now keeps one billed counter and all five physical forces through native Guild rate allowance/cancellation; signed pending/settled sources stay bound. Unfunded release retains spent Nexus without debt/refund/reusable coupon and preserves ordinary shipment. Marker approvals still cannot silently increase; full acceptance remains open.",evidence:['tests/e3-independent-nexus-occupy.test.ts','tests/fixture-e3-independent-nexus-occupy.ts','tests/e3-independent-nexus-cards.test.ts','tests/fixture-e3-independent-nexus-cards.ts','tests/e3-independent-nexus-duke.test.ts','tests/fixture-e3-independent-nexus-duke.ts','tests/e3-independent-nexus-arrival.test.ts','tests/fixture-e3-independent-nexus-arrival.ts','game/faction-module-profile.ts','tests/selected-independent-nexus-entry.test.ts','tests/fixture-selected-independent-nexus-entry.ts','tests/selected-independent-nexus-battles.test.ts','tests/fixture-selected-independent-nexus-battles.ts','tests/selected-independent-nexus-cards.test.ts','tests/fixture-selected-independent-nexus-cards.ts','tests/selected-independent-ixian-betrayal.test.ts','game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts", "game/junction-transport-options.ts"]},
|
||||
{area:"Player controls",status:'Partial',detail:"Selected E3 table SSR retains original lead/plan, assassination/revival, Duke and arrival controls; CSS stubs mean no visual/browser acceptance. Four earlier original-action390px journeys prove native Junction5/full2, poor1/full2 release plus ordinary one-force replacement, still-held source sand2 disabled then five-force stronghold1, and independent purchased1. The response explains the spent-card consequence. No overflow, browser error or application exception; two tool-only stale-node errors were resolved by fresh evidence without replay. Earlier Junction/loan/refund surfaces remain scoped evidence.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx", "components/junction-transport.tsx"]},
|
||||
{area:"AI",status:'Partial',detail:"All four policies execute original selected E3 Duke Cunning and unused expiry, and legal Occupy plans with real World sources. All four original policies also execute a legal still-held Richese full-rate one-counter alternative after native cancellation, with actual physical delivery and card cost. Shared human/AI quotes no longer promise a canceled discount. Native permission alternatives, recipient-specific lender prices and immune Junction source choice remain. Optional non-reoffer is not forced. No strategic first-candidate pin, tuning or calibration.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
|
||||
{area:"Implementation",status:'Partial',detail:"Ecaz Betrayal now composes typed World returns, advisors, actual HMS and provisional face-down marker recall; Guild/Sapho timing preserves a full unspent opening. Native module Atomics retains actual paid arrival and original Cunning desert grants. Selected E1/E2/E3 independent/Nexus now shares fresh setup and original native consumers: typed Ix fees/Harass/substitution, CHOAM fuel, Richese paired invoices, native Occupy/Assassinate, shared arrival order and Duke Cunning. Existing trained-capture and unfinished individual-effect envelopes remain. Native cross/return permission and rate remain separate; Cunning preserves its opportunity. Independent typed purchases and explicit immune Junction source choice retain their original tariffs/payees. Richese Secret Ally now keeps one billed counter and all five physical forces through native Guild rate allowance/cancellation; signed pending/settled sources stay bound. Unfunded release retains spent Nexus without debt/refund/reusable coupon and preserves ordinary shipment. Marker approvals still cannot silently increase; full acceptance remains open.",evidence:['tests/e3-independent-nexus-occupy.test.ts','tests/fixture-e3-independent-nexus-occupy.ts','tests/e3-independent-nexus-cards.test.ts','tests/fixture-e3-independent-nexus-cards.ts','tests/e3-independent-nexus-duke.test.ts','tests/fixture-e3-independent-nexus-duke.ts','tests/e3-independent-nexus-arrival.test.ts','tests/fixture-e3-independent-nexus-arrival.ts','game/faction-module-profile.ts','tests/selected-independent-nexus-entry.test.ts','tests/fixture-selected-independent-nexus-entry.ts','tests/selected-independent-nexus-battles.test.ts','tests/fixture-selected-independent-nexus-battles.ts','tests/selected-independent-nexus-cards.test.ts','tests/fixture-selected-independent-nexus-cards.ts','tests/selected-independent-ixian-betrayal.test.ts','game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts", "game/junction-transport-options.ts"]},
|
||||
{area:"Player controls",status:'Partial',detail:"Native Betrayal/Atomics table SSR retains typed/concealed return, revelation, ordered arrival, Aftermath and desert controls; CSS stubs mean no visual/browser acceptance. Earlier selected E3 lead/plan, assassination/revival and Duke controls retain their dated proof. Four earlier original-action390px journeys prove native Junction5/full2, poor1/full2 release plus ordinary one-force replacement, still-held source sand2 disabled then five-force stronghold1, and independent purchased1. The response explains the spent-card consequence. No overflow, browser error or application exception; two tool-only stale-node errors were resolved by fresh evidence without replay. Earlier Junction/loan/refund surfaces remain scoped evidence.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx", "components/junction-transport.tsx"]},
|
||||
{area:"AI",status:'Partial',detail:"All four policies retain minimal legal Ecaz Betrayal and original Aftermath/Sneak participation. All four policies execute original selected E3 Duke Cunning and unused expiry, and legal Occupy plans with real World sources. All four original policies also execute a legal still-held Richese full-rate one-counter alternative after native cancellation, with actual physical delivery and card cost. Shared human/AI quotes no longer promise a canceled discount. Native permission alternatives, recipient-specific lender prices and immune Junction source choice remain. Optional non-reoffer is not forced. No strategic first-candidate pin, tuning or calibration.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
|
||||
{area:"Documentation",status:'Partial',detail:"Selected E3 scope, printed two-seat no-alliance draw limit and remaining individual-effect envelopes are explicit in the canonical Nexus contract. Richese printed one-counter pricing and native Guild eligibility stay distinct from absent-faction cancellation, physical force count and independent purchased price. Unfunded spent-card/no-refund release is selected existing no-debt composition, not a new publisher worked example. Immune Junction and earlier permission/refund/loan interpretations remain explicit. Full rules and public gates stay open.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md", "docs/JUNCTION_TRANSPORT_RULES.md", "docs/JUNCTION_TRANSPORT_RUNTIME.md"]},
|
||||
{area:"Verification",status:'Partial',detail:"Selected E3 independent entry, Occupy, Assassinate/Recruits, Duke and arrival paths pass frozen17-file168/168, types/lint/build and seven finite original-action programmes. Earlier selected E1/E2 native card/cancellation proof remains dated in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','Line truncated
|
||||
{area:"Verification",status:'Partial',detail:"Native Betrayal/Atomics composition passes frozen16-file115/115, types/lint/build and eight finite original-action/table-SSR programmes, including original Cunning desert Aftermath; visual/browser and deployed acceptance remain open. Earlier selected E3 entry, Occupy, Assassinate/Recruits, Duke and arrival proof remains dated in the canonical contract. Earlier selected E1/E2 native card/cancellation proof remains dated in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/ecaz-betrayal-modules.test.ts','tests/fixture-ecaz-betrayal-modules.ts','tests/richese-no-field-hidden-return.test.ts','tests/nexus-ecaz-betrayal-quotes.test.ts','tests/moritani-atomics-modules.test.ts','tests/nexus-moritani-atomics-runtime.test.ts','tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-proteLine truncated
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,6 +14,8 @@ export type RicheseNoField = {
|
||||
/** Shared own/allied shipment history; revelation never resets it. */
|
||||
lastShipped: string | null;
|
||||
deployed: NoFieldDeployment | null;
|
||||
/** A forced face-down recall retains the last shipment without revealing it. */
|
||||
hiddenReturn?: true;
|
||||
};
|
||||
export type NoFieldRevealCause =
|
||||
| 'voluntary'
|
||||
@@ -22,7 +24,8 @@ export type NoFieldRevealCause =
|
||||
| 'worm'
|
||||
| 'gamont'
|
||||
| 'beforeAlly'
|
||||
| 'allyShipment';
|
||||
| 'allyShipment'
|
||||
| 'atomics';
|
||||
export type NoFieldReveal = {
|
||||
state: RicheseNoField;
|
||||
tokenId: string;
|
||||
@@ -93,6 +96,9 @@ export function validateRicheseNoField(state: RicheseNoField): void {
|
||||
(typeof state.deployed === 'object' && !!state.deployed),
|
||||
'Invalid deployed No-Field custody.',
|
||||
);
|
||||
requireNoField(state.hiddenReturn === undefined ||
|
||||
(state.hiddenReturn === true && state.deployed === null && state.lastShipped !== null),
|
||||
'A hidden No-Field return needs its previous shipment and no deployed token.');
|
||||
if (state.deployed) {
|
||||
requireNoField(
|
||||
state.tokens.some((token) => token.id === state.deployed!.tokenId) &&
|
||||
@@ -151,6 +157,7 @@ export function deployRicheseNoField(
|
||||
): RicheseNoField {
|
||||
validateDeployment(previous, deployment);
|
||||
const state = structuredClone(previous);
|
||||
delete state.hiddenReturn;
|
||||
state.lastShipped = deployment.tokenId;
|
||||
state.deployed = structuredClone(deployment);
|
||||
return state;
|
||||
@@ -192,6 +199,7 @@ export function deployRicheseNoFieldPair(
|
||||
): { state: RicheseNoField; revealed: NoFieldReveal } {
|
||||
const token = validateRicheseNoFieldPair(previous, concealed, revealedTokenId, reserves);
|
||||
const state = structuredClone(previous);
|
||||
delete state.hiddenReturn;
|
||||
state.lastShipped = concealed.tokenId;
|
||||
state.deployed = structuredClone(concealed);
|
||||
return {
|
||||
@@ -252,6 +260,7 @@ export function revealRicheseNoField(
|
||||
'gamont',
|
||||
'beforeAlly',
|
||||
'allyShipment',
|
||||
'atomics',
|
||||
].includes(input.cause),
|
||||
'Choose a valid No-Field reveal cause.',
|
||||
);
|
||||
@@ -260,6 +269,7 @@ export function revealRicheseNoField(
|
||||
(candidate) => candidate.id === input.tokenId,
|
||||
)!;
|
||||
const state = structuredClone(previous);
|
||||
delete state.hiddenReturn;
|
||||
state.deployed = null;
|
||||
return {
|
||||
state,
|
||||
@@ -272,6 +282,14 @@ export function revealRicheseNoField(
|
||||
};
|
||||
}
|
||||
|
||||
/** Provisional Ecaz Betrayal composition: return the marker face down, without
|
||||
* revealing or materializing its denomination. The last-shipment ban remains. */
|
||||
export function recallRicheseNoField(previous: RicheseNoField, tokenId: string): RicheseNoField {
|
||||
validateRicheseNoField(previous);
|
||||
requireNoField(previous.deployed?.tokenId === tokenId, 'That No-Field is not deployed.');
|
||||
return {...structuredClone(previous), deployed:null, hiddenReturn:true};
|
||||
}
|
||||
|
||||
/** Any existing marker must already have been revealed by the authorized caller. */
|
||||
export function shipAlliedRicheseNoField(
|
||||
previous: RicheseNoField,
|
||||
@@ -312,7 +330,7 @@ export function projectRicheseNoField(
|
||||
}
|
||||
: null,
|
||||
lastUsed:
|
||||
!state.deployed && state.lastShipped !== null
|
||||
!state.deployed && !state.hiddenReturn && state.lastShipped !== null
|
||||
? state.tokens.find((token) => token.id === state.lastShipped)!.value
|
||||
: null,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user