Connect Ecaz Betrayal and Fremen worm Cunning with build revision
This commit is contained in:
1 parent
1e690b7062
commit
07c739fcf7
30 files changed
+2878
-25
No files matched your search
@@ -95,6 +95,8 @@ const decisions = {
|
||||
greatMakerVote: true,
|
||||
greatMakerRide: true,
|
||||
wormRide: true,
|
||||
nexusFremenCunningOffer: true,
|
||||
nexusFremenCunningRide: true,
|
||||
moritaniRetention: true,
|
||||
battleCards: true,
|
||||
} satisfies Record<Decision['kind'], true>;
|
||||
@@ -134,6 +136,8 @@ const responses = {
|
||||
nexusAdvisorFlip: true,
|
||||
nexusSardaukar: true,
|
||||
nexusGuildCunning: true,
|
||||
nexusFremenCunning: true,
|
||||
nexusEcazBetrayal: true,
|
||||
bgCharity: true,
|
||||
choamCharity: true,
|
||||
choamInflation: true,
|
||||
|
||||
+45
-4
@@ -14,6 +14,8 @@ import { nexusChoamTradeBotActions } from './nexus-choam-trade-options';
|
||||
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options';
|
||||
import { nexusMoritaniBotActions } from './nexus-moritani-options';
|
||||
import { moritaniBetrayalBotActions } from './nexus-moritani-betrayal-options';
|
||||
import { ecazBetrayalBotActions } from './nexus-ecaz-betrayal-options';
|
||||
import { ecazBetrayalOffer } from './nexus-ecaz-betrayal';
|
||||
import { ECAZ_START_FORCES, ECAZ_START_LOCATIONS, quoteEcazStartingForces } from './ecaz-setup';
|
||||
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
|
||||
import { nexusCardBotActions } from './nexus-card-options';
|
||||
@@ -1445,13 +1447,19 @@ function policyActions(g: GameView): Action[] {
|
||||
fighterCount(me, splitLocation(g.response.location).territory) > 0) ||
|
||||
(g.response.kind === 'nexusAdvisorFlip' &&
|
||||
!!g.nexusAdvisors?.pending?.territories.some((territory) => fighterCount(me, territory) > 0));
|
||||
const ecazBetrayalThreat =
|
||||
g.response.kind === 'nexusEcazBetrayal' &&
|
||||
!!g.response.location &&
|
||||
fighterCount(me, g.response.location) > 0 &&
|
||||
!!me.ally &&
|
||||
g.players.some(player => player.id === me.ally && player.faction === 'ecaz');
|
||||
const card = me.hand?.find((c) =>
|
||||
g.responseControls?.cancelCards.includes(c.id),
|
||||
);
|
||||
if (
|
||||
card &&
|
||||
benefitEnemy &&
|
||||
((level >= 2 && (targeted || movementThreat || auditThreat)) ||
|
||||
((level >= 2 && (targeted || movementThreat || ecazBetrayalThreat || auditThreat)) ||
|
||||
(level === 3 &&
|
||||
([
|
||||
'harkonnenBonus',
|
||||
@@ -2500,6 +2508,31 @@ function policyActions(g: GameView): Action[] {
|
||||
if (d.kind === 'discoveryEntry') return discoveryEntryBotActions(g);
|
||||
if (d.kind === 'ecologicalStorm') return discoveryStormActions(g);
|
||||
if (d.kind === 'greatMakerVote' || d.kind === 'greatMakerRide') return greatMakerBotActions(g);
|
||||
if (d.kind === 'nexusFremenCunningOffer')
|
||||
return [{ type: 'decision', event: d.event,
|
||||
accept: g.nexusCards?.card === 'fremen' && me.faction === 'fremen' &&
|
||||
!me.ally && Object.entries(me.forces).some(([key, count]) =>
|
||||
count > 0 && splitLocation(key).sector !== g.storm &&
|
||||
TERRITORIES.some(t => t.id === splitLocation(key).territory && t.type === 'sand')) }];
|
||||
if (d.kind === 'nexusFremenCunningRide') {
|
||||
const decline: Action = { type: 'decision', event: d.event, accept: false };
|
||||
const candidates: Action[] = [];
|
||||
for (const [key, count] of Object.entries(me.forces)) {
|
||||
if (count <= 0 || splitLocation(key).sector === g.storm) continue;
|
||||
const source = splitLocation(key).territory;
|
||||
if (!TERRITORIES.some(t => t.id === source && t.type === 'sand')) continue;
|
||||
const elite = me.elites?.forces[key] ?? 0;
|
||||
const forces = { [key]: { normal: count - elite, elite } };
|
||||
for (const to of destinations(g)) {
|
||||
if (to.t === source || !botEntryAllowed(g, me, to.t, to.s, 'move') ||
|
||||
territoryEntryBlock(g.players, me.id, to.t)) continue;
|
||||
candidates.push({ type: 'decision', event: d.event, accept: true,
|
||||
source, forces, territory: to.t, sector: to.s });
|
||||
if (candidates.length >= 16) return [...candidates, decline];
|
||||
}
|
||||
}
|
||||
return [...candidates, decline];
|
||||
}
|
||||
if (d.kind === 'wormProtection')
|
||||
return [{ type: 'decision', accept: true }];
|
||||
if (d.kind === 'stormLosses')
|
||||
@@ -3798,6 +3831,8 @@ export function botActions(g: GameView): Action[] {
|
||||
if (trade.length) return trade;
|
||||
const moritaniBetrayal = moritaniBetrayalBotActions(g);
|
||||
if (moritaniBetrayal.length) return moritaniBetrayal;
|
||||
const ecazBetrayal = ecazBetrayalBotActions(g);
|
||||
if (ecazBetrayal.length) return ecazBetrayal;
|
||||
const sardaukar = nexusSardaukarBotActions(g);
|
||||
if (sardaukar.length) return sardaukar;
|
||||
const advisorConversion = nexusAdvisorBotActions(g);
|
||||
@@ -3919,9 +3954,15 @@ export function runBots(state: Game, limit = 96): Game {
|
||||
for (let step = 0; step < limit; step++) {
|
||||
let next: Game | undefined;
|
||||
const actors = g.players.filter((p) => p.bot ?? p.autopilot);
|
||||
// Give an automated optional supplier a chance before a seated-earlier
|
||||
// recipient spends the shipment. Humans retain their ordinary actions.
|
||||
if (g.phase === 5 && junctionSponsor(g) && !currentJunctionOffer(g))
|
||||
// A card holder's only pre-shipment declaration must run before an
|
||||
// earlier-seated movement bot consumes the shared opening.
|
||||
const ecazHolder = g.phase === 5 && g.nexusCards?.cards
|
||||
? actors.find(actor => g.nexusCards!.cards!.hands[actor.id] === 'ecaz' &&
|
||||
ecazBetrayalOffer(g, actor.id)?.blocked === null)
|
||||
: undefined;
|
||||
if (ecazHolder)
|
||||
actors.sort((a, b) => Number(b.id === ecazHolder.id) - Number(a.id === ecazHolder.id));
|
||||
else if (g.phase === 5 && junctionSponsor(g) && !currentJunctionOffer(g))
|
||||
actors.sort((a, b) => Number(b.faction === 'guild') - Number(a.faction === 'guild'));
|
||||
for (const p of actors) {
|
||||
for (const action of botActions(viewGame(g, p.id))) {
|
||||
|
||||
+349
-9
@@ -34,6 +34,7 @@ import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type Great
|
||||
import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade';
|
||||
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
|
||||
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, fremenNexusRevivalPools, fremenNexusRevivalSignature, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
|
||||
import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
|
||||
import { truthKnowledgeOf } from './truthtrance-knowledge';
|
||||
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
|
||||
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
|
||||
@@ -48,6 +49,7 @@ import { quoteNexusAdvisors, createNexusAdvisors, validateNexusAdvisors, type Ne
|
||||
import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceipt } from './nexus-sardaukar';
|
||||
import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, type NexusMoritaniReceipt } from './nexus-moritani';
|
||||
import { moritaniBetrayalOffer } from './nexus-moritani-betrayal';
|
||||
import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal';
|
||||
import { nexusMoritaniRecordSignature, terrorLocationAllowed, terrorEntryLocationAllowed } from './terror-location';
|
||||
import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam';
|
||||
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
|
||||
@@ -863,6 +865,8 @@ export type Decision =
|
||||
| { kind: 'greatMakerVote'; player: string; event: string }
|
||||
| { kind: 'greatMakerRide'; player: string; event: string }
|
||||
| { kind: 'wormRide'; player: string; territory: string }
|
||||
| { kind: 'nexusFremenCunningOffer'; player: string; event: string }
|
||||
| { kind: 'nexusFremenCunningRide'; player: string; event: string }
|
||||
| {
|
||||
kind: 'moritaniRetention';
|
||||
owner: string;
|
||||
@@ -931,6 +935,8 @@ export type ResponseWindow = {
|
||||
| 'advisorFlip'
|
||||
| 'nexusAdvisorFlip'
|
||||
| 'nexusSardaukar'
|
||||
| 'nexusFremenCunning'
|
||||
| 'nexusEcazBetrayal'
|
||||
| 'nexusGuildCunning'
|
||||
| 'bgCharity'
|
||||
| 'choamCharity'
|
||||
@@ -1450,6 +1456,16 @@ export type Game = {
|
||||
/** Optional independent module; complete effect coverage is still release-gated. */
|
||||
nexusCards?: { cards: NexusState | null; phase: NexusCardPhase | null } | null;
|
||||
nexusTraitorExchanges?: NexusTraitorExchange[];
|
||||
/** An initially empty actual worm occurrence; the optional card is not disclosed by this offer. */
|
||||
nexusFremenCunningOffer?: { occurrence: FremenCunningOccurrence; owner: string } | null;
|
||||
/** Accepted appearances wait for post-Nexus riding, then retain completion custody. */
|
||||
nexusFremenCunningRides?: {
|
||||
occurrence: FremenCunningOccurrence;
|
||||
authorization: FremenCunningRideAuthorization;
|
||||
stage: 'pending' | 'queued' | 'select' | 'complete';
|
||||
}[];
|
||||
/** A spent Ecaz card binds one exact allied force group until Karama settles. */
|
||||
nexusEcazBetrayalHistory?: { snapshot: EcazBetrayalSnapshot; stage: 'pending' | 'complete' }[];
|
||||
nexusFaceDancerHistory?: NexusFaceDancerReceipt[];
|
||||
nexusSuboidHistory?: NexusSuboidReceipt[];
|
||||
nexusSuboidLast?: { event: string; owner: string; turn: number };
|
||||
@@ -1480,6 +1496,8 @@ export type Game = {
|
||||
summonedNexusBeforeRides?: boolean;
|
||||
summonedWorm?: {
|
||||
territory: string;
|
||||
/** Server-created identity for this one physical special-Karama appearance. */
|
||||
event: string;
|
||||
resume: Pick<
|
||||
Game,
|
||||
| 'response'
|
||||
@@ -1791,6 +1809,119 @@ function playNexusEmperorRevive(g:Game,p:Player,action:Action) {
|
||||
log(g,`${p.name} spent Emperor Nexus Secret Ally to return three forces${elite ? `, including ${elite} Fedaykin` : ''} from Tanks to reserves for free. Ordinary force and free-revival allowances are unchanged.`,
|
||||
{faction:p.faction,name:'Emperor Nexus revival'});
|
||||
}
|
||||
function nexusEcazBetrayalIntegrity(g: Game) {
|
||||
const history = g.nexusEcazBetrayalHistory;
|
||||
if (history === undefined) return;
|
||||
requireRule(Array.isArray(history) && history.length > 0 && g.nexusCards?.cards,
|
||||
'Ecaz Betrayal has lost its spent card history.');
|
||||
const events = new Set<string>();
|
||||
let pending = 0;
|
||||
for (const record of history) {
|
||||
const snapshot = record?.snapshot;
|
||||
requireRule(snapshot && typeof snapshot.event === 'string' &&
|
||||
!events.has(snapshot.event) &&
|
||||
Number.isSafeInteger(snapshot.turn) && snapshot.turn > 0 && snapshot.turn <= g.turn &&
|
||||
Number.isSafeInteger(snapshot.discardIndex) && snapshot.discardIndex >= 0 &&
|
||||
g.players.some(player => player.id === snapshot.owner && player.faction !== 'ecaz') &&
|
||||
g.players.some(player => player.id === snapshot.ally) &&
|
||||
['pending', 'complete'].includes(record.stage),
|
||||
'Ecaz Betrayal has lost its unique owner, ally or turn.');
|
||||
events.add(snapshot.event);
|
||||
if (record.stage === 'pending') {
|
||||
pending++;
|
||||
requireRule(pending === 1 && record === history.at(-1) &&
|
||||
((g.response?.kind === 'nexusEcazBetrayal' &&
|
||||
g.response.owner === snapshot.owner &&
|
||||
g.response.intent === snapshot.event &&
|
||||
g.response.location === snapshot.territory) ||
|
||||
(g.pendingKarama?.use.kind === 'cancel' &&
|
||||
g.pendingKarama.use.response.kind === 'nexusEcazBetrayal' &&
|
||||
g.pendingKarama.use.response.intent === snapshot.event)),
|
||||
'Ecaz Betrayal has lost its pending Karama response.');
|
||||
nexusRule(() => validateEcazBetrayalSnapshot(g, snapshot, true));
|
||||
} else if (snapshot.turn === g.turn) {
|
||||
requireRule(g.nexusCards!.cards!.discard[snapshot.discardIndex] === 'ecaz',
|
||||
'Ecaz Betrayal has reopened its spent card.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function nexusFremenCunningIntegrity(g: Game) {
|
||||
const offer = g.nexusFremenCunningOffer;
|
||||
const rides = g.nexusFremenCunningRides;
|
||||
if (offer === undefined && rides === undefined) return;
|
||||
requireRule(g.nexusCards?.cards && (!rides || Array.isArray(rides)),
|
||||
'Fremen Cunning has lost its physical Nexus module or saved rides.');
|
||||
const suspended = g.summonedWorm?.resume;
|
||||
if (offer) {
|
||||
const occurrence = offer.occurrence;
|
||||
const decision = g.decision?.kind === 'nexusFremenCunningOffer'
|
||||
? g.decision : suspended?.decision;
|
||||
requireRule(g.status === 'playing' && g.phase === 1 &&
|
||||
occurrence.turn === g.turn && occurrence.initiallyEmpty &&
|
||||
occurrence.event && byFaction(g, 'fremen')?.id === offer.owner &&
|
||||
decision?.kind === 'nexusFremenCunningOffer' &&
|
||||
decision.player === offer.owner && decision.event === occurrence.event,
|
||||
'The empty worm appearance has lost its original public offer.');
|
||||
if (occurrence.origin === 'summoned') requireRule(
|
||||
occurrence.parent && g.summonedWorm?.event === occurrence.parent &&
|
||||
g.summonedWorm?.territory === occurrence.territory,
|
||||
'The empty summoned worm has lost its original special Karama parent.',
|
||||
);
|
||||
}
|
||||
const events = new Set<string>();
|
||||
for (const ride of rides ?? []) {
|
||||
const { occurrence, authorization, stage } = ride;
|
||||
requireRule(occurrence.initiallyEmpty && occurrence.event &&
|
||||
!events.has(occurrence.event) && authorization.event === occurrence.event &&
|
||||
authorization.turn === occurrence.turn && occurrence.turn <= g.turn &&
|
||||
authorization.owner && getPlayer(g, authorization.owner).faction === 'fremen' &&
|
||||
['pending', 'queued', 'select', 'complete'].includes(stage),
|
||||
'Fremen Cunning has lost its unique accepted worm appearance.');
|
||||
requireRule(
|
||||
['natural', 'additional', 'summoned'].includes(occurrence.origin) &&
|
||||
(occurrence.origin !== 'summoned' ||
|
||||
(typeof occurrence.parent === 'string' && occurrence.parent.length > 0 &&
|
||||
(stage !== 'pending' || (g.summonedWorm?.event === occurrence.parent &&
|
||||
g.summonedWorm?.territory === occurrence.territory)))),
|
||||
'Fremen Cunning has lost its summoned worm parent.',
|
||||
);
|
||||
events.add(occurrence.event);
|
||||
if (stage === 'complete') continue;
|
||||
requireRule(g.status === 'playing' && g.phase === 1 &&
|
||||
occurrence.turn === g.turn && g.nexusCards!.cards!.discard.includes('fremen'),
|
||||
'A pending Fremen ride has lost its spent physical card.');
|
||||
if (stage === 'select') {
|
||||
const decision = g.decision?.kind === 'nexusFremenCunningRide'
|
||||
? g.decision : suspended?.decision;
|
||||
requireRule(decision?.kind === 'nexusFremenCunningRide' &&
|
||||
decision.player === authorization.owner &&
|
||||
decision.event === occurrence.event,
|
||||
'Fremen Cunning has lost its current rider choice.');
|
||||
}
|
||||
if (stage === 'pending') {
|
||||
const live = g.response;
|
||||
const prior = suspended?.response;
|
||||
const liveKarama = g.pendingKarama;
|
||||
const priorKarama = suspended?.pendingKarama;
|
||||
requireRule(
|
||||
(live?.kind === 'nexusFremenCunning' &&
|
||||
live.owner === authorization.owner && live.intent === occurrence.event) ||
|
||||
(prior?.kind === 'nexusFremenCunning' &&
|
||||
prior.owner === authorization.owner && prior.intent === occurrence.event) ||
|
||||
(liveKarama?.use.kind === 'cancel' &&
|
||||
liveKarama.use.response.kind === 'nexusFremenCunning' &&
|
||||
liveKarama.use.response.intent === occurrence.event) ||
|
||||
(priorKarama?.use.kind === 'cancel' &&
|
||||
priorKarama.use.response.kind === 'nexusFremenCunning' &&
|
||||
priorKarama.use.response.intent === occurrence.event),
|
||||
'Fremen Cunning has lost its pending cancellation window.');
|
||||
}
|
||||
}
|
||||
requireRule(!offer || !events.has(offer.occurrence.event),
|
||||
'A worm occurrence cannot be both offered and already spent.');
|
||||
}
|
||||
|
||||
function nexusFremenRevivalIntegrity(g: Game) {
|
||||
const history = g.nexusFremenRevivalHistory, events = g.nexusFremenRevivalEvents;
|
||||
if (history === undefined) {
|
||||
@@ -1869,6 +2000,52 @@ function playMoritaniBetrayal(g: Game, p: Player, action: Action) {
|
||||
log(g, `${p.name} spent Moritani Nexus Betrayal to return a hidden Terror token from ${territory(target.territory).name} to Moritani's supply without revealing its face.`,
|
||||
{ faction: p.faction, name: 'Moritani Nexus Betrayal' });
|
||||
}
|
||||
function currentEcazBetrayalResponse(g: Game, response: ResponseWindow) {
|
||||
const record = g.nexusEcazBetrayalHistory?.find(row =>
|
||||
row.stage === 'pending' && row.snapshot.event === response.intent);
|
||||
requireRule(response.kind === 'nexusEcazBetrayal' && record &&
|
||||
response.owner === record.snapshot.owner &&
|
||||
response.location === record.snapshot.territory,
|
||||
'The Ecaz Betrayal interruption has lost its spent card or territory.');
|
||||
nexusRule(() => validateEcazBetrayalSnapshot(g, record.snapshot, true));
|
||||
return record;
|
||||
}
|
||||
function finishEcazBetrayalResponse(g: Game, response: ResponseWindow, canceled: boolean) {
|
||||
const record = currentEcazBetrayalResponse(g, response);
|
||||
const ally = getPlayer(g, record.snapshot.ally);
|
||||
if (!canceled) validateReserveReturnCounters(ally, record.snapshot.total, 'Ecaz Betrayal');
|
||||
record.stage = 'complete';
|
||||
if (!canceled) {
|
||||
for (const key of Object.keys(record.snapshot.sectors)) {
|
||||
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;
|
||||
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.`,
|
||||
{ faction: ally.faction, name: 'Ecaz Nexus Betrayal' });
|
||||
}
|
||||
function playEcazBetrayal(g: Game, p: Player, action: Action) {
|
||||
const offer = ecazBetrayalOffer(g, p.id,
|
||||
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g));
|
||||
requireRule(offer && !offer.blocked &&
|
||||
Object.keys(action).sort().join(',') === 'event,territory,type' &&
|
||||
typeof action.event === 'string' && typeof action.territory === 'string',
|
||||
offer?.blocked ?? 'Choose one currently shared Ecaz territory.');
|
||||
const snapshot = nexusRule(() =>
|
||||
quoteEcazBetrayal(g, p.id, stringField(action.event), stringField(action.territory)));
|
||||
g.nexusCards!.cards = nexusRule(() =>
|
||||
discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
(g.nexusEcazBetrayalHistory ??= []).push({ snapshot, stage: 'pending' });
|
||||
g.response = { kind: 'nexusEcazBetrayal', owner: p.id, intent: snapshot.event,
|
||||
location: snapshot.territory, passed: [] };
|
||||
log(g, `${p.name} spent Ecaz Nexus Betrayal to require Ecaz’s ally to return all forces from ${territory(snapshot.territory).name}, unless Karama prevents the return.`,
|
||||
{ faction: p.faction, name: 'Ecaz Nexus Betrayal' });
|
||||
}
|
||||
function markNexusOccurred(g: Game) {
|
||||
if (!g.nexusCards) return;
|
||||
const phase = g.nexusCards.phase;
|
||||
@@ -6391,8 +6568,40 @@ function devour(
|
||||
if (fremen && at(fremen, t)) g.wormRides.push(t);
|
||||
log(g, `Shai-Hulud appeared in ${territory(t).name}.`);
|
||||
}
|
||||
function beginWorm(g: Game, t: string) {
|
||||
function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summoned' = 'natural') {
|
||||
const fremen = byFaction(g, 'fremen');
|
||||
// An empty appearance offers the same decision with or without the private
|
||||
// Fremen card; the offer itself must not disclose its custody.
|
||||
if ((origin !== 'summoned' || (g.summonedWorm?.event &&
|
||||
!g.nexusFremenCunningOffer &&
|
||||
!g.nexusFremenCunningRides?.some(row => row.stage === 'pending' || row.stage === 'select'))) &&
|
||||
!g.greatMaker && g.nexusCards?.cards &&
|
||||
!g.expansions.length && !g.homeworlds && !g.leaderSkills &&
|
||||
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash &&
|
||||
!g.techTokens && !g.strongholdCards &&
|
||||
g.players.every(seat => ['atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit', 'fremen'].includes(seat.faction)) &&
|
||||
fremen && !fremen.ally && g.phase === 1 &&
|
||||
Object.entries(fremen.forces).some(([key, amount]) => {
|
||||
if (amount <= 0) return false;
|
||||
const source = splitLocation(key);
|
||||
return source.sector !== g.storm &&
|
||||
TERRITORIES.some(row => row.id === source.territory && row.type === 'sand');
|
||||
})) {
|
||||
const occurrence = captureFremenCunningOccurrence(g, {
|
||||
event: crypto.randomUUID(), territory: t, origin,
|
||||
...(origin === 'summoned'
|
||||
? { parent: g.summonedWorm!.event }
|
||||
: { pile: g.spiceSequence?.pile }),
|
||||
});
|
||||
if (occurrence.initiallyEmpty) {
|
||||
requireRule(!g.nexusFremenCunningOffer,
|
||||
'Finish the previous worm appearance before another Cunning offer.');
|
||||
g.nexusFremenCunningOffer = { occurrence, owner: fremen.id };
|
||||
g.decision = { kind: 'nexusFremenCunningOffer', player: fremen.id,
|
||||
event: occurrence.event };
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (fremen?.ally && at(getPlayer(g, fremen.ally), t)) {
|
||||
g.decision = {
|
||||
kind: 'wormProtection',
|
||||
@@ -6402,6 +6611,27 @@ function beginWorm(g: Game, t: string) {
|
||||
};
|
||||
} else wormSurvival(g, t);
|
||||
}
|
||||
function currentFremenCunningResponse(g: Game, response: ResponseWindow) {
|
||||
const ride = g.nexusFremenCunningRides?.find(row =>
|
||||
row.stage === 'pending' && row.occurrence.event === response.intent);
|
||||
requireRule(response.kind === 'nexusFremenCunning' &&
|
||||
ride && response.owner === ride.authorization.owner &&
|
||||
response.location === ride.occurrence.territory &&
|
||||
ride.occurrence.turn === g.turn && g.phase === 1 &&
|
||||
g.nexusCards?.cards?.discard.includes('fremen'),
|
||||
'The Fremen Nexus interruption has lost its spent card or worm appearance.');
|
||||
return ride;
|
||||
}
|
||||
function finishFremenCunningResponse(g: Game, response: ResponseWindow, canceled: boolean) {
|
||||
const ride = currentFremenCunningResponse(g, response);
|
||||
ride.stage = canceled ? 'complete' : 'queued';
|
||||
log(g, canceled
|
||||
? 'Karama prevented the Fremen Nexus remote ride; the original worm still resolves.'
|
||||
: 'Fremen Nexus Cunning will ride from one desert territory after this Nexus.',
|
||||
{ faction: 'fremen', name: 'Fremen Nexus Cunning' });
|
||||
wormSurvival(g, ride.occurrence.territory);
|
||||
}
|
||||
|
||||
function wormSurvival(g: Game, t: string, protectedAlly?: string) {
|
||||
const fremen = byFaction(g, 'fremen');
|
||||
if (fremen && at(fremen, t)) {
|
||||
@@ -6453,6 +6683,13 @@ function afterWorm(g: Game) {
|
||||
if (g.decision?.kind === 'wormRide') {
|
||||
g.wormRides.unshift(g.decision.territory);
|
||||
g.decision = null;
|
||||
} else if (g.decision?.kind === 'nexusFremenCunningRide') {
|
||||
const event = g.decision.event;
|
||||
const ride = g.nexusFremenCunningRides?.find(row =>
|
||||
row.stage === 'select' && row.occurrence.event === event);
|
||||
requireRule(ride, 'The summoned worm has lost its interrupted Fremen Cunning ride.');
|
||||
ride.stage = 'queued';
|
||||
g.decision = null;
|
||||
} else if (
|
||||
g.pendingAmbassador?.resume === 'wormRide' ||
|
||||
g.pendingTerrorEntry?.resume === 'wormRide' ||
|
||||
@@ -6950,6 +7187,21 @@ function nextWormRide(g: Game) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const ride of g.nexusFremenCunningRides ?? []) {
|
||||
if (ride.stage !== 'queued' || ride.occurrence.turn !== g.turn) continue;
|
||||
const source = nexusRule(() => quoteFremenCunningRide(
|
||||
g, ride.authorization.owner, ride.occurrence, ride.authorization,
|
||||
));
|
||||
if (!source) {
|
||||
ride.stage = 'complete';
|
||||
log(g, 'Fremen Nexus Cunning has no remaining legal desert force source.');
|
||||
continue;
|
||||
}
|
||||
ride.stage = 'select';
|
||||
g.decision = { kind: 'nexusFremenCunningRide',
|
||||
player: ride.authorization.owner, event: ride.occurrence.event };
|
||||
return;
|
||||
}
|
||||
g.nexus = false;
|
||||
finishSpicePass(g);
|
||||
}
|
||||
@@ -8603,6 +8855,10 @@ function validateKaramaUse(
|
||||
if (use.response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, use.response);
|
||||
if (use.response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, use.response);
|
||||
if (use.response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, use.response);
|
||||
if (use.response.kind === 'nexusFremenCunning')
|
||||
currentFremenCunningResponse(g, use.response);
|
||||
if (use.response.kind === 'nexusEcazBetrayal')
|
||||
currentEcazBetrayalResponse(g, use.response);
|
||||
if (isCombatResponseKind(use.response.kind))
|
||||
currentCombatResponseQuote(g, {
|
||||
kind: 'response',
|
||||
@@ -12555,7 +12811,7 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
|
||||
!lot.acted.includes(p.id)
|
||||
) {
|
||||
const remaining = lot.order.filter((id) => !lot.acted.includes(id));
|
||||
if (!lot.acted.length && remaining[0] !== p.id)
|
||||
if (remaining[0] !== p.id)
|
||||
options.push({ scope: 'onceAround', event: lot.event, mode: 'first' });
|
||||
if (remaining.at(-1) !== p.id)
|
||||
options.push({ scope: 'onceAround', event: lot.event, mode: 'last' });
|
||||
@@ -13869,17 +14125,18 @@ function currentHarassWithdrawQuote(g: Game, p: Player, plan: Pick<Plan, 'dial'
|
||||
const selection = receipt?.player === p.id && receipt.stage === 'selected' ? receipt.selection! :
|
||||
g.battle?.harassAllocationVersion === 1 ? defaultHarassWithdrawAllocation(context, plan.dial, plan.support) : undefined;
|
||||
const quote = quoteHarassWithdraw(context, plan.dial, plan.support, selection);
|
||||
validateHarassReturnCounters(p, quote.returned);
|
||||
validateReserveReturnCounters(p, quote.returned);
|
||||
return quote;
|
||||
}
|
||||
catch (error) { if (error instanceof HarassWithdrawError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function validateHarassReturnCounters(p: Player, returned: { normal: number; elite: number }) {
|
||||
function validateReserveReturnCounters(p: Player, returned: { normal: number; elite: number },
|
||||
source = 'Harass & Withdraw') {
|
||||
requireRule(Number.isSafeInteger(p.reserves) && p.reserves >= 0 &&
|
||||
Number.isSafeInteger(p.reserves + returned.normal + returned.elite) &&
|
||||
(!p.elites || (Number.isSafeInteger(p.elites.reserves) && p.elites.reserves >= 0 &&
|
||||
p.elites.reserves <= p.reserves && Number.isSafeInteger(p.elites.reserves + returned.elite))),
|
||||
'Harass & Withdraw needs valid reserve and elite counters before any forces return.');
|
||||
`${source} needs valid reserve and elite counters before any forces return.`);
|
||||
}
|
||||
function harassWithdrawPreview(g: Game, p: Player): HarassWithdrawPreview | null {
|
||||
const b = g.battle;
|
||||
@@ -14562,6 +14819,8 @@ function marketGholaIntegrity(g: Game) {
|
||||
nexusCardsIntegrity(g);
|
||||
nexusEmperorSecretIntegrity(g);
|
||||
nexusFremenRevivalIntegrity(g);
|
||||
nexusFremenCunningIntegrity(g);
|
||||
nexusEcazBetrayalIntegrity(g);
|
||||
nexusTraitorIntegrity(g);
|
||||
nexusFaceDancerIntegrity(g);
|
||||
nexusSuboidIntegrity(g);
|
||||
@@ -15830,7 +16089,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
pendingRetentionPresent: !!g.moritaniRetention,
|
||||
});
|
||||
for (const withdrawal of quote.harassWithdraw ?? [])
|
||||
validateHarassReturnCounters(getPlayer(g, withdrawal.player), withdrawal.returned);
|
||||
validateReserveReturnCounters(getPlayer(g, withdrawal.player), withdrawal.returned);
|
||||
if (quote.sukGraduate) requireRule(
|
||||
!g.advanced || getPlayer(g, quote.winner!).faction !== 'atreides',
|
||||
'Suk Graduate rescue for Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.');
|
||||
@@ -17654,6 +17913,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
|
||||
if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response);
|
||||
if (response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, response);
|
||||
if (response.kind === 'nexusFremenCunning') currentFremenCunningResponse(g, response);
|
||||
if (response.kind === 'nexusEcazBetrayal') currentEcazBetrayalResponse(g, response);
|
||||
if (response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,response);
|
||||
if (response.kind === 'moritaniPlacement') {
|
||||
// Both outcomes need the same physical inventory and declared source.
|
||||
@@ -17695,6 +17956,14 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
: null;
|
||||
if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return;
|
||||
g.response = null;
|
||||
if (response.kind === 'nexusEcazBetrayal') {
|
||||
finishEcazBetrayalResponse(g, response, canceled);
|
||||
return;
|
||||
}
|
||||
if (response.kind === 'nexusFremenCunning') {
|
||||
finishFremenCunningResponse(g, response, canceled);
|
||||
return;
|
||||
}
|
||||
if (response.kind === 'nexusGuildCunning') { settleGuildCunning(g,response,canceled); return; }
|
||||
if (response.kind === 'nexusAdvisorFlip') {
|
||||
finishNexusAdvisors(g, canceled);
|
||||
@@ -18504,7 +18773,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
if (canceled) {
|
||||
g.wormPlacementCanceledTurn = g.turn;
|
||||
continueSpice(g);
|
||||
} else beginWorm(g, response.location!);
|
||||
} else beginWorm(g, response.location!, 'additional');
|
||||
} else if (response.kind === 'wormAllyProtection') {
|
||||
wormSurvival(
|
||||
g,
|
||||
@@ -20580,6 +20849,7 @@ export function executeSpecialKaramaIntent(
|
||||
p.specialKaramaUsed = true;
|
||||
g.summonedWorm = {
|
||||
territory: t,
|
||||
event: crypto.randomUUID(),
|
||||
resume: {
|
||||
response: g.response,
|
||||
decision: g.decision,
|
||||
@@ -20605,7 +20875,7 @@ export function executeSpecialKaramaIntent(
|
||||
g,
|
||||
`${p.name} used special Karama to summon Shai-Hulud in ${territory(t).name}.`,
|
||||
);
|
||||
beginWorm(g, t);
|
||||
beginWorm(g, t, 'summoned');
|
||||
} else if (intent.kind === 'atreides') {
|
||||
const b = g.battle!,
|
||||
target = intent.target;
|
||||
@@ -21209,6 +21479,7 @@ function applyActionInner(
|
||||
if (t === 'nexusEmperorRevive') { playNexusEmperorRevive(g,p,action); return g; }
|
||||
if (t === 'nexusFremenRevive') { playNexusFremenRevive(g,p,action); return g; }
|
||||
if (t === 'nexusMoritaniBetrayal') { playMoritaniBetrayal(g, p, action); return g; }
|
||||
if (t === 'nexusEcazBetrayal') { playEcazBetrayal(g, p, action); return g; }
|
||||
if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; }
|
||||
requireRule(
|
||||
!(
|
||||
@@ -22222,7 +22493,7 @@ function applyActionInner(
|
||||
let quote;
|
||||
try { quote = quoteHarassWithdraw(offer.context, offer.dial, offer.support, action.returns as HarassWithdrawSelection); }
|
||||
catch (error) { if (error instanceof HarassWithdrawError) throw new RuleError(error.message); throw error; }
|
||||
validateHarassReturnCounters(p, quote.returned);
|
||||
validateReserveReturnCounters(p, quote.returned);
|
||||
receipt.selection = structuredClone(quote.locations); receipt.stage = 'selected';
|
||||
receipt.signature = harassAllocationSignature(receipt);
|
||||
log(g, `${p.name} selected ${quote.returned.normal} ordinary and ${quote.returned.elite} elite undialed forces for Harass & Withdraw: ${Object.entries(quote.locations).map(([key, group]) => `${key} (${group.normal} ordinary, ${group.elite} elite)`).join('; ')}. The return waits for traitor declarations; an opposing successful call cancels it.`);
|
||||
@@ -22583,6 +22854,32 @@ function applyActionInner(
|
||||
passed: [],
|
||||
};
|
||||
} else continueSpice(g);
|
||||
} else if (decision.kind === 'nexusFremenCunningOffer') {
|
||||
const pending = g.nexusFremenCunningOffer;
|
||||
requireRule(pending && pending.owner === id &&
|
||||
pending.occurrence.event === decision.event &&
|
||||
action.event === decision.event &&
|
||||
typeof action.accept === 'boolean' &&
|
||||
Object.keys(action).every(key => ['type', 'event', 'accept'].includes(key)),
|
||||
'Choose whether to use the current empty-worm Nexus opportunity.');
|
||||
g.nexusFremenCunningOffer = null;
|
||||
if (action.accept) {
|
||||
requireRule(nexusRule(() => quoteFremenCunning(g, id, pending.occurrence)),
|
||||
'Fremen Cunning needs your own unallied Nexus card and a desert force source.');
|
||||
g.nexusCards!.cards = nexusRule(() =>
|
||||
discardNexusCard(g.nexusCards!.cards!, id, g.players));
|
||||
g.nexusFremenCunningRides ??= [];
|
||||
g.nexusFremenCunningRides.push({
|
||||
occurrence: pending.occurrence,
|
||||
authorization: { event: decision.event, turn: g.turn, owner: id },
|
||||
stage: 'pending',
|
||||
});
|
||||
g.response = { kind: 'nexusFremenCunning', owner: id,
|
||||
intent: decision.event, location: pending.occurrence.territory,
|
||||
passed: [] };
|
||||
log(g, `${p.name} spent Fremen Nexus Cunning on an empty worm appearance; a Karama response precedes the remote ride.`,
|
||||
{ faction: p.faction, name: 'Fremen Nexus Cunning' });
|
||||
} else wormSurvival(g, pending.occurrence.territory);
|
||||
} else if (decision.kind === 'wormProtection') {
|
||||
requireRule(
|
||||
typeof action.accept === 'boolean',
|
||||
@@ -22612,6 +22909,47 @@ function applyActionInner(
|
||||
decideGreatMakerVote(g,p,action);
|
||||
} else if (decision.kind === 'greatMakerRide') {
|
||||
decideGreatMakerRide(g,p,action);
|
||||
} else if (decision.kind === 'nexusFremenCunningRide') {
|
||||
const ride = g.nexusFremenCunningRides?.find(row =>
|
||||
row.stage === 'select' && row.occurrence.event === decision.event);
|
||||
requireRule(ride && ride.authorization.owner === id &&
|
||||
action.event === decision.event && typeof action.accept === 'boolean',
|
||||
'Choose the pending Fremen Cunning remote worm ride.');
|
||||
if (action.accept) {
|
||||
requireRule(Object.keys(action).every(key =>
|
||||
['type', 'event', 'accept', 'source', 'forces', 'territory', 'sector'].includes(key)),
|
||||
'Choose only this Cunning source, forces and destination.');
|
||||
const selection = {
|
||||
source: stringField(action.source),
|
||||
forces: action.forces as FremenCunningSelection['forces'],
|
||||
destination: {
|
||||
territory: stringField(action.territory),
|
||||
sector: integer(action.sector, 0, 18, 'Sector'),
|
||||
},
|
||||
};
|
||||
const chosen = nexusRule(() => validateFremenCunningSelection(
|
||||
g, id, ride.occurrence, selection, ride.authorization));
|
||||
allowedEntry(g, p, selection.destination.territory, selection.destination.sector);
|
||||
const entries = Object.entries(selection.forces);
|
||||
removeGroup(p, entries.map(([key, count]) => [key, count.normal + count.elite] as [string, number]),
|
||||
Object.fromEntries(entries.map(([key, count]) => [key, count.elite])));
|
||||
place(p, selection.destination.territory, selection.destination.sector,
|
||||
chosen.total, chosen.elite);
|
||||
ride.stage = 'complete';
|
||||
log(g, `${p.name} used Fremen Nexus Cunning to ride ${chosen.total} forces from ${territory(selection.source).name} to ${territory(selection.destination.territory).name}.`,
|
||||
{ faction: p.faction, name: 'Fremen Nexus Cunning' });
|
||||
const intruded = intrusion(g, p, selection.destination.territory, { wormRide: true });
|
||||
if (openTerritoryEntry(g, p, selection.destination.territory,
|
||||
selection.destination.sector, chosen.total, chosen.elite,
|
||||
'wormRide', 'wormRide') || intruded) return g;
|
||||
} else {
|
||||
requireRule(Object.keys(action).every(key =>
|
||||
['type', 'event', 'accept'].includes(key)),
|
||||
'Declining Cunning does not move forces.');
|
||||
ride.stage = 'complete';
|
||||
log(g, `${p.name} declined the Fremen Nexus remote ride.`);
|
||||
}
|
||||
nextWormRide(g);
|
||||
} else if (decision.kind === 'wormRide') {
|
||||
requireRule(
|
||||
typeof action.accept === 'boolean',
|
||||
@@ -25059,6 +25397,8 @@ export function viewGame(state: Game, id: string) {
|
||||
nexusMoritani: nexusMoritaniOffer(g,id),
|
||||
nexusMoritaniBetrayal: moritaniBetrayalOffer(g,id,
|
||||
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
|
||||
nexusEcazBetrayal: ecazBetrayalOffer(g, id,
|
||||
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
|
||||
nexusRichese: nexusRicheseOffer(g,id),
|
||||
nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id),
|
||||
nexusGuildCunning: projectedNexusGuildCunning(g,id),
|
||||
|
||||
@@ -84,6 +84,16 @@ const cancellationSources = {
|
||||
...movementSource(g),
|
||||
conversion: g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null,
|
||||
}),
|
||||
nexusFremenCunning: (g, response) => ({
|
||||
...wormSource(g),
|
||||
appearance: g.nexusFremenCunningRides?.find(ride =>
|
||||
ride.stage === 'pending' && ride.occurrence.event === response.intent) ?? null,
|
||||
}),
|
||||
nexusEcazBetrayal: (g, response) => ({
|
||||
...movementSource(g),
|
||||
return: g.nexusEcazBetrayalHistory?.find(record =>
|
||||
record.stage === 'pending' && record.snapshot.event === response.intent) ?? null,
|
||||
}),
|
||||
nexusGuildCunning: (g) => ({cunning:g.nexusGuildCunningHistory?.at(-1),active:g.active,remaining:g.movementRemaining}),
|
||||
nexusSardaukar: (g) => ({
|
||||
battle: battleSource(g),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** The private offer binds the exact territory and current card to this turn. */
|
||||
export function ecazBetrayalAction(game: GameView, territory: string): Action | null {
|
||||
const offer = game.nexusEcazBetrayal;
|
||||
const owner = game.players.find(player => player.id === game.me);
|
||||
if (!offer || offer.blocked || !owner || owner.ally || owner.faction === 'ecaz' ||
|
||||
game.nexusCards?.card !== 'ecaz' || game.automaticContinuationPending ||
|
||||
!offer.territories.some(choice => choice.territory === territory)) return null;
|
||||
return { type: 'nexusEcazBetrayal', event: offer.event, territory };
|
||||
}
|
||||
|
||||
export function ecazBetrayalBotActions(game: GameView): Action[] {
|
||||
const territory = game.nexusEcazBetrayal?.territories[0]?.territory;
|
||||
const action = territory ? ecazBetrayalAction(game, territory) : null;
|
||||
return action ? [action] : [];
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { TERRITORIES, splitLocation, validLocation } from './board';
|
||||
import type { Game, Player } from './engine';
|
||||
import { nexusCardMode, validateNexusCards } from './nexus-cards';
|
||||
|
||||
export type EcazBetrayalCounts = Readonly<{ normal: number; elite: number }>;
|
||||
export type EcazBetrayalTerritory = Readonly<{
|
||||
territory: string;
|
||||
/** Complete physical ally group, keyed by printed board location. */
|
||||
sectors: Readonly<Record<string, EcazBetrayalCounts>>;
|
||||
total: EcazBetrayalCounts;
|
||||
}>;
|
||||
export type EcazBetrayalOffer = Readonly<{
|
||||
event: string;
|
||||
blocked: string | null;
|
||||
ally: string | null;
|
||||
territories: readonly EcazBetrayalTerritory[];
|
||||
}>;
|
||||
export type EcazBetrayalSnapshot = Readonly<{
|
||||
event: string;
|
||||
owner: string;
|
||||
ally: string;
|
||||
turn: number;
|
||||
discardIndex: number;
|
||||
territory: string;
|
||||
sectors: Readonly<Record<string, EcazBetrayalCounts>>;
|
||||
total: EcazBetrayalCounts;
|
||||
}>;
|
||||
|
||||
const allowedFactions: Record<string, true> = {
|
||||
atreides: true, harkonnen: true, emperor: true, fremen: true,
|
||||
guild: true, beneGesserit: true, ecaz: true, moritani: true,
|
||||
};
|
||||
const plain = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value) &&
|
||||
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
||||
const count = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
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 {
|
||||
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]);
|
||||
}
|
||||
|
||||
function locations(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;
|
||||
for (const [key, elite] of Object.entries(elites ?? {})) {
|
||||
if (!count(elite) || (elite > 0 && !Object.hasOwn(player.forces, key)))
|
||||
throw new Error('Ecaz Betrayal has invalid elite custody.');
|
||||
}
|
||||
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))
|
||||
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)
|
||||
throw new Error('Ecaz Betrayal has invalid typed force custody.');
|
||||
if (!total) continue;
|
||||
const sectors = result.get(territory) ?? {};
|
||||
sectors[key] = { normal: total - elite, elite };
|
||||
result.set(territory, sectors);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function territoryQuote(territory: string, sectors: Record<string, EcazBetrayalCounts>): EcazBetrayalTerritory {
|
||||
let normal = 0, elite = 0;
|
||||
for (const group of Object.values(sectors)) {
|
||||
normal += group.normal;
|
||||
elite += group.elite;
|
||||
}
|
||||
if (!Number.isSafeInteger(normal) || !Number.isSafeInteger(elite))
|
||||
throw new Error('Ecaz Betrayal force totals exceed physical custody.');
|
||||
return { territory, sectors, total: { normal, elite } };
|
||||
}
|
||||
|
||||
/** Public-only offer for an unallied holder of Ecaz's actual Betrayal card.
|
||||
* The engine separately authorizes card custody and handles Karama response. */
|
||||
export function ecazBetrayalOffer(g: Game, owner: string, automaticPending = false): EcazBetrayalOffer | null {
|
||||
const holder = g.players.find(p => p.id === owner);
|
||||
const ecaz = g.players.find(p => p.faction === 'ecaz');
|
||||
const cards = g.nexusCards?.cards;
|
||||
if (!holder || !ecaz || holder.id === ecaz.id || holder.faction === 'ecaz' ||
|
||||
!cards || !Object.hasOwn(cards.hands, owner) || cards.hands[owner] !== 'ecaz' ||
|
||||
nexusCardMode('ecaz', holder.faction, g.players.map(p => p.faction)) !== 'betrayal') return null;
|
||||
const ally = g.players.find(p => p.id === ecaz.ally && p.ally === ecaz.id);
|
||||
const event = JSON.stringify(['nexusEcazBetrayal', g.turn, owner, ally?.id ?? null, cards.discard.length]);
|
||||
let blocked: string | null = null;
|
||||
if (holder.ally) blocked = 'An allied player cannot hold or play a Nexus card.';
|
||||
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 (automaticPending || g.response || g.decision || g.truthtrance ||
|
||||
g.phaseOpening || g.battle || g.pendingTreacheryDiscard || g.pendingExchange ||
|
||||
g.pendingKarama || g.pendingRevival || g.pendingAmbassador || g.pendingCapture ||
|
||||
g.pendingFremenMove || g.pendingMobileMove || g.pendingIxMove || g.pendingNullentropy ||
|
||||
g.pendingTerrorEntry || g.pendingMoritaniPlacement || g.pendingRicheseGift ||
|
||||
g.nexusTraitorPending || g.nexusCards?.phase?.stage === 'drawing')
|
||||
blocked = 'Finish the current interaction before using Ecaz Betrayal.';
|
||||
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));
|
||||
if (!territories.length) blocked = 'Ecaz and its ally share no territory containing physical forces.';
|
||||
}
|
||||
return { event, blocked, ally: ally?.id ?? null, territories };
|
||||
}
|
||||
|
||||
/** Quote an accepted *entire* ally group before spending the card or returning
|
||||
* forces. All inputs are the authoritative live Game and authenticated owner,
|
||||
* plus the submitted event and one offered public territory. Throws if stale. */
|
||||
export function quoteEcazBetrayal(g: Game, owner: string, event: string, territory: string): EcazBetrayalSnapshot {
|
||||
const offer = ecazBetrayalOffer(g, owner);
|
||||
if (!offer || offer.blocked || event !== offer.event)
|
||||
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.');
|
||||
return { event, owner, ally: offer.ally!, turn: g.turn, territory,
|
||||
discardIndex: g.nexusCards!.cards!.discard.length,
|
||||
sectors: selected.sectors, total: selected.total };
|
||||
}
|
||||
|
||||
/** Validate a saved pre-return quote against frozen authoritative physical
|
||||
* custody. pending=true is only for the engine's own saved, already-spent card
|
||||
* response frame; the caller must authenticate that frame separately. */
|
||||
export function validateEcazBetrayalSnapshot(
|
||||
g: Game, snapshot: EcazBetrayalSnapshot, pending = false,
|
||||
): void {
|
||||
if (!exactKeys(snapshot, ['event', 'owner', 'ally', 'turn', 'territory', 'discardIndex', 'sectors', 'total']) ||
|
||||
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)
|
||||
throw new Error('Ecaz Betrayal has an invalid saved force snapshot.');
|
||||
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}` ||
|
||||
!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.');
|
||||
}
|
||||
let fresh: EcazBetrayalSnapshot;
|
||||
if (pending) {
|
||||
const cards = g.nexusCards?.cards;
|
||||
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 ||
|
||||
cards.discard[snapshot.discardIndex] !== 'ecaz' || !ecaz || !ally ||
|
||||
ecaz.ally !== ally.id || ally.ally !== ecaz.id || !holder ||
|
||||
holder.ally || holder.faction === 'ecaz' ||
|
||||
snapshot.event !== JSON.stringify(['nexusEcazBetrayal', g.turn,
|
||||
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);
|
||||
if (!occupied.has(snapshot.territory) || !selected)
|
||||
throw new Error('Ecaz Betrayal has lost the shared physical territory.');
|
||||
fresh = { event: snapshot.event, turn: g.turn, owner: snapshot.owner,
|
||||
ally: ally.id, discardIndex: snapshot.discardIndex,
|
||||
...territoryQuote(snapshot.territory, selected) };
|
||||
} 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 ||
|
||||
Object.keys(snapshot.sectors).length !== Object.keys(fresh.sectors).length ||
|
||||
Object.entries(fresh.sectors).some(([key, group]) =>
|
||||
!Object.hasOwn(snapshot.sectors, key) ||
|
||||
snapshot.sectors[key].normal !== group.normal || snapshot.sectors[key].elite !== group.elite))
|
||||
throw new Error('Ecaz Betrayal no longer matches its original physical ally group.');
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { TERRITORIES, splitLocation, validGameLocation } from './board';
|
||||
import type { Game, Player } from './engine';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { nexusCardMode, validateNexusCards } from './nexus-cards';
|
||||
import { territoryEntryBlock } from './occupancy';
|
||||
|
||||
/** The engine allocates a distinct event for each accepted worm appearance, even
|
||||
* when two worms visit the same territory. Capture before any devouring. */
|
||||
export type FremenCunningOccurrence = Readonly<{
|
||||
event: string;
|
||||
turn: number;
|
||||
territory: string;
|
||||
origin: 'natural' | 'additional' | 'summoned';
|
||||
pile?: 0 | 1;
|
||||
parent?: string;
|
||||
initiallyEmpty: boolean;
|
||||
}>;
|
||||
|
||||
export type FremenCunningCounts = Readonly<{ normal: number; elite: number }>;
|
||||
export type FremenCunningSource = Readonly<{
|
||||
territory: string;
|
||||
sectors: Readonly<Record<string, FremenCunningCounts>>;
|
||||
}>;
|
||||
export type FremenCunningSelection = Readonly<{
|
||||
source: string;
|
||||
forces: Readonly<Record<string, FremenCunningCounts>>;
|
||||
destination: Readonly<{ territory: string; sector: number }>;
|
||||
}>;
|
||||
export type FremenCunningQuote = Readonly<{
|
||||
event: string;
|
||||
sources: readonly FremenCunningSource[];
|
||||
}>;
|
||||
/** Persisted by the engine only after spending the owner's actual card.
|
||||
* The caller must authenticate this receipt against its saved event history. */
|
||||
export type FremenCunningRideAuthorization = Readonly<{
|
||||
event: string;
|
||||
turn: number;
|
||||
owner: string;
|
||||
}>;
|
||||
|
||||
/** Call only from the accepted appearance branch, before wormSurvival/devour.
|
||||
* The caller persists and authenticates this record; it is not a card-draw hook. */
|
||||
export function captureFremenCunningOccurrence(
|
||||
g: Game,
|
||||
occurrence: Omit<FremenCunningOccurrence, 'turn' | 'initiallyEmpty'>,
|
||||
): FremenCunningOccurrence {
|
||||
if (g.status !== 'playing' || g.phase !== 1 || !occurrence.event ||
|
||||
!['natural', 'additional', 'summoned'].includes(occurrence.origin) ||
|
||||
!TERRITORIES.some(t => t.id === occurrence.territory) ||
|
||||
(occurrence.origin === 'summoned'
|
||||
? !occurrence.parent || g.summonedWorm?.event !== occurrence.parent ||
|
||||
g.summonedWorm?.territory !== occurrence.territory
|
||||
: occurrence.parent !== undefined))
|
||||
throw new Error('Capture Cunning at an actual Arrakis worm appearance.');
|
||||
return {
|
||||
...occurrence,
|
||||
turn: g.turn,
|
||||
initiallyEmpty: g.players.every(p => presenceAt(p, occurrence.territory) === 0),
|
||||
};
|
||||
}
|
||||
|
||||
function physicalDesertSources(g: Game, p: Player): FremenCunningSource[] {
|
||||
const sources = new Map<string, Record<string, FremenCunningCounts>>();
|
||||
for (const [key, total] of Object.entries(p.forces)) {
|
||||
if (!Number.isSafeInteger(total) || total <= 0) continue;
|
||||
const { territory: id, sector } = splitLocation(key);
|
||||
const terrain = TERRITORIES.find(t => t.id === id);
|
||||
if (terrain?.type !== 'sand' || !validGameLocation(g, id, sector) || sector === g.storm) continue;
|
||||
const elite = p.elites?.forces[key] ?? 0;
|
||||
if (!Number.isSafeInteger(elite) || elite < 0 || elite > total)
|
||||
throw new Error('Cunning source has invalid physical force custody.');
|
||||
const sectors = sources.get(id) ?? {};
|
||||
sectors[key] = { normal: total - elite, elite };
|
||||
sources.set(id, sectors);
|
||||
}
|
||||
return [...sources].map(([territory, sectors]) => ({ territory, sectors }));
|
||||
}
|
||||
|
||||
/** Null means this seat/appearance cannot use native Fremen Cunning. No
|
||||
* post-devour presence check may replace the occurrence's original fact. */
|
||||
export function quoteFremenCunning(
|
||||
g: Game, owner: string, occurrence: FremenCunningOccurrence,
|
||||
): FremenCunningQuote | null {
|
||||
const p = g.players.find(player => player.id === owner);
|
||||
const cards = g.nexusCards?.cards;
|
||||
if (!p || g.status !== 'playing' || occurrence.turn !== g.turn ||
|
||||
!occurrence.event || !occurrence.initiallyEmpty ||
|
||||
!TERRITORIES.some(t => t.id === occurrence.territory) ||
|
||||
(occurrence.origin === 'summoned' &&
|
||||
(!occurrence.parent || g.summonedWorm?.event !== occurrence.parent ||
|
||||
g.summonedWorm?.territory !== occurrence.territory)) ||
|
||||
p.faction !== 'fremen' || p.ally ||
|
||||
cards?.hands[owner] !== 'fremen' ||
|
||||
nexusCardMode('fremen', p.faction, g.players.map(seat => seat.faction)) !== 'cunning') return null;
|
||||
validateNexusCards(cards, g.players);
|
||||
const sources = physicalDesertSources(g, p);
|
||||
return sources.length ? { event: occurrence.event, sources } : null;
|
||||
}
|
||||
|
||||
/** A spent card is in the physical discard, not the rider's hand. The engine
|
||||
* must verify this authorization is its own saved receipt, never action input. */
|
||||
export function quoteFremenCunningRide(
|
||||
g: Game, owner: string, occurrence: FremenCunningOccurrence,
|
||||
authorization: FremenCunningRideAuthorization,
|
||||
): FremenCunningQuote | null {
|
||||
const p = g.players.find(player => player.id === owner);
|
||||
const cards = g.nexusCards?.cards;
|
||||
if (!p || p.faction !== 'fremen' || g.status !== 'playing' ||
|
||||
!occurrence.initiallyEmpty || !occurrence.event || occurrence.turn !== g.turn ||
|
||||
!TERRITORIES.some(t => t.id === occurrence.territory) ||
|
||||
(occurrence.origin === 'summoned' && !occurrence.parent) ||
|
||||
!authorization || authorization.event !== occurrence.event ||
|
||||
authorization.turn !== occurrence.turn || authorization.owner !== owner ||
|
||||
!cards?.discard.includes('fremen') || cards.hands[owner] === 'fremen')
|
||||
return null;
|
||||
validateNexusCards(cards, g.players);
|
||||
const sources = physicalDesertSources(g, p);
|
||||
return sources.length ? { event: occurrence.event, sources } : null;
|
||||
}
|
||||
|
||||
/** Same structural, storm and occupation restrictions as ordinary riding.
|
||||
* Dynamic Homeworld/Discovery/HMS arrival needs its own dedicated adapter. */
|
||||
export function fremenCunningDestinationBlock(
|
||||
g: Game, owner: string, occurrence: FremenCunningOccurrence,
|
||||
source: string, destination: FremenCunningSelection['destination'],
|
||||
): string | null {
|
||||
const { territory: to, sector } = destination;
|
||||
if (!TERRITORIES.some(t => t.id === to) || !validGameLocation(g, to, sector))
|
||||
return 'Choose an Arrakis board sector.';
|
||||
if (to === source)
|
||||
return 'Choose another destination territory.';
|
||||
if (sector !== 0 && sector === g.storm) return 'That sector is in storm.';
|
||||
return territoryEntryBlock(g.players, owner, to);
|
||||
}
|
||||
|
||||
/** Requote immediately before committing, while the authenticated appearance
|
||||
* remains pending. This only validates; the engine performs typed movement. */
|
||||
export function validateFremenCunningSelection(
|
||||
g: Game, owner: string, occurrence: FremenCunningOccurrence,
|
||||
selection: FremenCunningSelection,
|
||||
authorization: FremenCunningRideAuthorization,
|
||||
): Readonly<{ total: number; elite: number }> {
|
||||
const quote = quoteFremenCunningRide(g, owner, occurrence, authorization);
|
||||
if (!quote) throw new Error('Fremen Cunning is not available for this appearance.');
|
||||
const source = quote.sources.find(row => row.territory === selection.source);
|
||||
if (!source || !selection.forces || typeof selection.forces !== 'object' ||
|
||||
Array.isArray(selection.forces)) throw new Error('Choose one occupied desert territory.');
|
||||
let total = 0, elite = 0;
|
||||
for (const [key, count] of Object.entries(selection.forces)) {
|
||||
const available = Object.hasOwn(source.sectors, key) ? source.sectors[key] : undefined;
|
||||
if (!available || !count || Object.keys(count).sort().join(',') !== 'elite,normal' ||
|
||||
!Number.isSafeInteger(count.normal) || !Number.isSafeInteger(count.elite) ||
|
||||
count.normal < 0 || count.elite < 0 ||
|
||||
count.normal > available.normal || count.elite > available.elite)
|
||||
throw new Error('Choose available typed physical forces in the source territory.');
|
||||
total += count.normal + count.elite;
|
||||
elite += count.elite;
|
||||
}
|
||||
if (!Number.isSafeInteger(total) || !total)
|
||||
throw new Error('Choose at least one physical force to ride.');
|
||||
if (!selection.destination || typeof selection.destination.territory !== 'string' ||
|
||||
!Number.isSafeInteger(selection.destination.sector))
|
||||
throw new Error('Choose an Arrakis destination sector.');
|
||||
const blocked = fremenCunningDestinationBlock(
|
||||
g, owner, occurrence, selection.source, selection.destination,
|
||||
);
|
||||
if (blocked) throw new Error(blocked);
|
||||
return { total, elite };
|
||||
}
|
||||
+9
-7
@@ -480,7 +480,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary:
|
||||
'Choose an available order change or become battle aggressor before plans, then discard Sapho. Later intervention and other timing modes remain unfinished.',
|
||||
steps: [
|
||||
'In a current Once Around auction, first is available before anyone bids. Last is available before your own bid, including after Richese. You still get only one bidding opportunity and must outbid the current high bid to win.',
|
||||
'In a current Once Around auction, first or last can reorder remaining bidders while you have not yet bid. Earlier bids stay committed; a position you already hold is unavailable. You still get only one bidding opportunity and must outbid the current high bid to win.',
|
||||
'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. A completed bid or pass cannot be repeated, and a finished lot cannot reopen.',
|
||||
'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, the Guild must have finished or be absent. Last stays after the Guild even if the Guild later chooses to wait.',
|
||||
'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.',
|
||||
@@ -4008,12 +4008,14 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Bene Gesserit Secret Ally borrowed Voice remains unavailable pending its cancellation and timing interpretation. Its printed permission does not make the other unfinished effects available.',
|
||||
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
|
||||
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. The separate Secret Ally revival has its own connected controls; other Emperor modes remain unfinished.',
|
||||
'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This first path requires base factions and Nexus alone, before any normal force returns, without combined revival effects. Partial returns, prior returns, higher free rates and the separate sandworm protection and Cunning effects remain unavailable.',
|
||||
'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This first path requires base factions and Nexus alone, before any normal force returns, without combined revival effects. Partial returns, prior returns, higher free rates and separate sandworm protection remain unavailable.',
|
||||
'Native Fremen Cunning has a bounded Nexus-only path: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Great Maker and combined optional modules await integration.',
|
||||
'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. Its after-victory inspection remains unfinished.',
|
||||
'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont are supported; Kull Wahad’s Karama-prevention reaction remains unavailable.',
|
||||
'One Karama response covers the CHOAM effect. Cancellation leaves the chosen Treachery Card in hand and the Nexus spent. An allowed use discards the actual card once, retaining its printed identity and applicable discard consequences; it does not create a named Worthless card or a turn-long conversion permission.',
|
||||
'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.',
|
||||
'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.',
|
||||
'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; Duke Cunning and Secret Ally inquiry remain unavailable.',
|
||||
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
|
||||
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable.',
|
||||
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
|
||||
@@ -4022,11 +4024,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar, Fremen Secret Ally ordinary free-three revival in a bounded base-faction path, five source-aware CHOAM Cunning effects, Moritani Cunning placement and Betrayal token return, and Richese Secret Ally reserve shipping priced as one. Borrowed Voice, other Betrayal reactions, Tleilaxu revival, Fremen worm protection/Cunning and remaining effects are unfinished.', evidence: ['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/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects with native Karama responses, Moritani Betrayal token selection, and Richese shipment pricing. Remaining effect controls are unfinished.', 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'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All profiles handle integrated effects through private offers, including Moritani Betrayal public-token selection. Complete module strategy and calibration remain unfinished.', 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-richese-options.ts', 'game/nexus-guild-cunning-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', 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_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_GUILD_CUNNING_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions include Moritani Betrayal; Emperor Cunning browser checks pass while complete module games remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.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-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar, bounded Fremen Secret Ally free-three revival and native empty-worm Cunning, five source-aware CHOAM Cunning effects, Moritani Cunning placement and Betrayal token return, Ecaz Betrayal allied-force return, and Richese Secret Ally reserve shipping priced as one. Borrowed Voice, other Betrayal reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['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/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects with native Karama responses, Moritani Betrayal token selection, Ecaz Betrayal shared-territory choice, Richese shipment pricing and Fremen Cunning offer/remote ride. Remaining effect controls are unfinished.', 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/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All profiles handle integrated effects through private offers, including Moritani Betrayal public-token selection, Ecaz Betrayal public-territory selection and legal Fremen Cunning offer/ride decisions. Complete module strategy and calibration remain unfinished.', 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-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and integrated effect families retain explicit unresolved boundaries, including Ecaz Duke/inquiry, a second Cunning offer during an interrupted Fremen control, Great Maker and combined modules, fresh advisors, storm and borrowed Voice.', 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_GUILD_CUNNING_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and in-memory SQLite recovery regressions include Moritani Betrayal, bounded Fremen Cunning with a genuine special summon, and Ecaz Betrayal. Local natural and summoned Fremen offer/ride and Ecaz holder/Karama response browser surfaces were checked with isolated fixture views without a room write. Full module games and combined interactions remain separate.', evidence: ['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-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-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -76,6 +76,8 @@ const DECISIONS = {
|
||||
greatMakerVote: true,
|
||||
greatMakerRide: true,
|
||||
wormRide: true,
|
||||
nexusFremenCunningOffer: true,
|
||||
nexusFremenCunningRide: true,
|
||||
moritaniRetention: true,
|
||||
battleCards: true,
|
||||
} satisfies Record<Decision['kind'], true>;
|
||||
@@ -92,6 +94,8 @@ const RESPONSES = {
|
||||
nexusAdvisorFlip: true,
|
||||
nexusSardaukar: true,
|
||||
nexusGuildCunning: true,
|
||||
nexusFremenCunning: true,
|
||||
nexusEcazBetrayal: true,
|
||||
advisor: true,
|
||||
emperorIncome: true,
|
||||
richesePurchaseIncome: true,
|
||||
|
||||
Reference in new issue
Block a user