From 40c9651b80d9dcbc6d678e8440f66d6f326baf82 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Thu, 29 Oct 2026 00:05:12 +1100
Subject: [PATCH] Support group Fremen worm protection
---
components/game-table.tsx | 4 +-
docs/CURRENT_STATUS.md | 11 +-
docs/OPTIONAL_ALLIANCE_VICTORY.md | 14 ++
docs/RULE_DECISIONS.md | 6 +
game/bots.ts | 2 +-
game/coalition-worm-protection.ts | 124 +++++++++++++++
game/disaster-preflight.ts | 7 +-
game/disaster-rules.ts | 5 +-
game/engine.ts | 90 +++++++++--
game/reference.ts | 4 +-
tests/coalition-fremen-worm.test.ts | 230 ++++++++++++++++++++++++++++
tests/summoned-worm.test.ts | 6 +-
12 files changed, 479 insertions(+), 24 deletions(-)
create mode 100644 game/coalition-worm-protection.ts
create mode 100644 tests/coalition-fremen-worm.test.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 89eea884..86d763c0 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -4023,15 +4023,17 @@ export function GameTable({
Shai-Hulud has appeared in{' '}
{combatName(g.decision.territory)}. You may protect your
- ally’s forces from being devoured.
+ ally {g.players.find(player => player.id === (g.decision as {ally:string}).ally)?.name ?? 'at this location'}’s forces from being devoured.
{actionButton('Protect my ally', {
type: 'decision',
accept: true,
+ ...(g.decision.event ? {event:g.decision.event, ally:g.decision.ally} : {}),
})}
{actionButton('Do not protect', {
type: 'decision',
accept: false,
+ ...(g.decision.event ? {event:g.decision.event, ally:g.decision.ally} : {}),
})}
>
) : g.decision.kind === 'nexusFremenCunningOffer' ? (
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index a9bb093a..a16a01f8 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -1,6 +1,6 @@
# Current development status
-Updated 28 October 2026. **The full twelve-faction goal is unfinished. Basic
+Updated 29 October 2026. **The full twelve-faction goal is unfinished. Basic
is playable. Advanced, expansion-faction and supported optional-module starts
are labelled previews; complete rules/publication acceptance remains gated.**
@@ -17,6 +17,15 @@ open mode gates.
## Current checkpoint and work
+**Staged group Fremen worm protection — 29 October. Prototyped / Partial.**
+Fremen can separately protect either or both threatened allies before one
+worm resolves, with an independent Karama response for each and the original
+Fremen survival response afterward. The multi-ally order is provisional; other
+group interactions and normal lobby access remain unfinished. Six related
+files pass 64 cases, including a real worm after staged setup, both-ally
+choices, prevention, stale choice and rendered controls.
+[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-fremen-worm-protection--29-october-2026).
+
**Staged group bribe and deal eligibility — 28 October. Prototyped / Partial.**
Bribes, deal offers and acceptance recognize both group allies. Existing
agreements retain their history and release controls; an unpaid deferred bribe
diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md
index 059d3408..01c5c47a 100644
--- a/docs/OPTIONAL_ALLIANCE_VICTORY.md
+++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md
@@ -1,5 +1,19 @@
# Optional three-stronghold alliance victory
+## Group Fremen worm protection — 29 October 2026
+
+**Staged Basic prototype, not a selectable game mode.** When a worm threatens
+either or both of Fremen's group allies, Fremen decides separately for each
+threatened ally in storm order. Each accepted protection has its own Karama
+response; the worm resolves only after all those choices and Fremen's separate
+survival response finish. Canceling one protection does not erase another.
+The choices bind the original worm event and ally, and the existing force
+loss, spice removal and continuation occur once. Controls name the current
+ally and minimal bots make the same legal choices. The printed single-ally
+power does not specify this multi-ally sequence, so its order and separate
+responses are provisional. Advanced, expansions and optional modules remain
+outside this staged path; group games are not yet available from the lobby.
+
## Group Guild transport — 28 October 2026
**Staged Basic prototype, not a selectable game mode.** Both Guild group
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 30348c04..050a7cd6 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,11 @@
# Rule decision index
+## Group Fremen worm protection — 29 October 2026
+
+The staged Basic group prototype gives Fremen a separate protection choice
+and Karama response for each threatened ally in storm order. This multi-ally
+ordering is provisional. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-fremen-worm-protection--29-october-2026).
+
## Group bribes and public deals — 28 October 2026
Each member of a staged group is treated as allied to both others for the
diff --git a/game/bots.ts b/game/bots.ts
index 1aad17a2..11f735e8 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -2934,7 +2934,7 @@ function policyActions(g: GameView): Action[] {
return [...candidates, decline];
}
if (d.kind === 'wormProtection')
- return [{ type: 'decision', accept: true }];
+ return [{ type: 'decision', accept: true, ...(d.event ? {event:d.event, ally:d.ally} : {}) }];
if (d.kind === 'stormLosses')
return [
{ type: 'decision', elite: level === 0 ? d.maxElite : d.minElite },
diff --git a/game/coalition-worm-protection.ts b/game/coalition-worm-protection.ts
new file mode 100644
index 00000000..cb96d853
--- /dev/null
+++ b/game/coalition-worm-protection.ts
@@ -0,0 +1,124 @@
+import { TERRITORIES } from './board';
+import { SPICE_CARDS } from './cards';
+import { coalitionOf, CoalitionAllianceError } from './coalition-alliance';
+import { presenceAt } from './force-presence';
+import type { Game } from './engine';
+
+/** A single worm's group decisions are resolved before it destroys forces.
+ * A separate native Fremen-survival response may follow the complete frame. */
+export type CoalitionWormProtectionOutcome = {
+ ally: string;
+ outcome: 'declined' | 'allowed' | 'canceled';
+};
+export type CoalitionWormProtectionFrame = {
+ event: string;
+ turn: number;
+ territory: string;
+ owner: string;
+ /** Threatened group allies in the original public storm order. */
+ order: string[];
+ outcomes: CoalitionWormProtectionOutcome[];
+ stage: 'decision' | 'response' | 'complete';
+};
+
+export class CoalitionWormProtectionError extends Error {
+ constructor(message: string) { super(message); this.name = 'CoalitionWormProtectionError'; }
+}
+function requireProtection(value: unknown, message: string): asserts value {
+ if (!value) throw new CoalitionWormProtectionError(message);
+}
+const id = (value: unknown): value is string =>
+ typeof value === 'string' && value.length > 0 && value.trim() === value;
+const object = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+const classic = new Set(['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen']);
+const validTerritory = (value: string) => TERRITORIES.some(row => row.id === value &&
+ (row.type === 'sand' || SPICE_CARDS.some(([territory]) => territory === value)));
+
+/** Only the existing unexposed bare Basic classic group profile is supported.
+ * Return null for all legacy pair games and other profiles. */
+function threatened(g: Game, owner: string, territory: string): string[] | null {
+ if (!g.coalitionState || g.status !== 'playing' || g.phase !== 1 || g.advanced ||
+ g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
+ g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
+ !g.players.every(player => classic.has(player.faction))) return null;
+ const fremen = g.players.find(player => player.id === owner);
+ if (!fremen || fremen.faction !== 'fremen' ||
+ g.players.filter(player => player.faction === 'fremen').length !== 1) return null;
+ let group;
+ try { group = coalitionOf(g.players, g.coalitionState, owner); }
+ catch (error) {
+ if (error instanceof CoalitionAllianceError) throw new CoalitionWormProtectionError(error.message);
+ throw error;
+ }
+ if (!group || group.members.length !== 3) return null;
+ requireProtection(Array.isArray(g.order) && g.order.length === g.players.length &&
+ new Set(g.order).size === g.order.length && g.order.every(seat => g.players.some(player => player.id === seat)),
+ 'Group worm protection requires the current complete storm order.');
+ return g.order.filter(seat => seat !== owner && group!.members.includes(seat) &&
+ presenceAt(g.players.find(player => player.id === seat)!, territory) > 0);
+}
+
+export function createCoalitionWormProtection(
+ g: Game, owner: string, territory: string, event: string,
+): CoalitionWormProtectionFrame | null {
+ requireProtection(id(owner) && id(event) && id(territory) && validTerritory(territory) &&
+ Number.isSafeInteger(g.turn) && g.turn > 0, 'Choose the original worm occurrence and Fremen owner.');
+ const order = threatened(g, owner, territory);
+ if (!order?.length) return null;
+ return {event, turn:g.turn, territory, owner, order, outcomes:[], stage:'decision'};
+}
+
+/** Recheck the captured occurrence and exact threatened order before every
+ * transition; no card, casualty or force transfer is performed here. */
+export function validateCoalitionWormProtection(g: Game, frame: CoalitionWormProtectionFrame): void {
+ requireProtection(object(frame) && Object.keys(frame).sort().join(',') === 'event,order,outcomes,owner,stage,territory,turn' &&
+ id(frame.event) && id(frame.owner) && id(frame.territory) && validTerritory(frame.territory) &&
+ Number.isSafeInteger(frame.turn) && frame.turn === g.turn && Array.isArray(frame.order) &&
+ frame.order.length > 0 && frame.order.length <= 2 && frame.order.every(id) &&
+ new Set(frame.order).size === frame.order.length && Array.isArray(frame.outcomes) &&
+ ['decision', 'response', 'complete'].includes(frame.stage),
+ 'The group worm protection has lost its original occurrence.');
+ const current = threatened(g, frame.owner, frame.territory);
+ requireProtection(current && JSON.stringify(current) === JSON.stringify(frame.order),
+ 'The original group worm allies or storm order changed.');
+ requireProtection(frame.outcomes.length <= frame.order.length && frame.outcomes.every((outcome, index) =>
+ object(outcome) && Object.keys(outcome).sort().join(',') === 'ally,outcome' &&
+ outcome.ally === frame.order[index] && ['declined', 'allowed', 'canceled'].includes(outcome.outcome)) &&
+ (frame.stage === 'complete' ? frame.outcomes.length === frame.order.length : frame.outcomes.length < frame.order.length),
+ 'The group worm protection lost its ordered decisions.');
+}
+
+/** The current ally is public; the decision remains Fremen's. */
+export function currentCoalitionWormAlly(g: Game, frame: CoalitionWormProtectionFrame): string | null {
+ validateCoalitionWormProtection(g, frame);
+ return frame.stage === 'complete' ? null : frame.order[frame.outcomes.length];
+}
+
+export function decideCoalitionWormProtection(
+ g: Game, frame: CoalitionWormProtectionFrame, owner: string, event: string, ally: string, accept: boolean,
+): CoalitionWormProtectionFrame {
+ const current = currentCoalitionWormAlly(g, frame);
+ requireProtection(frame.stage === 'decision' && owner === frame.owner && event === frame.event &&
+ ally === current && typeof accept === 'boolean', 'Choose the current original Fremen ally decision.');
+ if (accept) return {...frame, stage:'response'};
+ const outcomes: CoalitionWormProtectionOutcome[] = [...frame.outcomes, {ally, outcome:'declined'}];
+ return {...frame, outcomes, stage:outcomes.length === frame.order.length ? 'complete' : 'decision'};
+}
+
+export function resolveCoalitionWormProtection(
+ g: Game, frame: CoalitionWormProtectionFrame, owner: string, event: string, ally: string, canceled: boolean,
+): CoalitionWormProtectionFrame {
+ const current = currentCoalitionWormAlly(g, frame);
+ requireProtection(frame.stage === 'response' && owner === frame.owner && event === frame.event &&
+ ally === current && typeof canceled === 'boolean', 'Resolve the original group ally Karama response.');
+ const outcomes: CoalitionWormProtectionOutcome[] = [...frame.outcomes,
+ {ally, outcome:canceled ? 'canceled' : 'allowed'}];
+ return {...frame, outcomes, stage:outcomes.length === frame.order.length ? 'complete' : 'decision'};
+}
+
+export function protectedCoalitionWormAllies(g: Game, frame: CoalitionWormProtectionFrame): string[] {
+ validateCoalitionWormProtection(g, frame);
+ requireProtection(frame.stage === 'complete', 'Finish all original group worm decisions first.');
+ return frame.outcomes.filter(outcome => outcome.outcome === 'allowed').map(outcome => outcome.ally);
+}
diff --git a/game/disaster-preflight.ts b/game/disaster-preflight.ts
index f0e1f0aa..1dc38b2e 100644
--- a/game/disaster-preflight.ts
+++ b/game/disaster-preflight.ts
@@ -192,7 +192,7 @@ export function validateDiscoveryBlow(g: Game, t: string) {
export function validateWormDevouring(
g: Game,
t: string,
- protectedAlly?: string,
+ protectedAlly?: string | readonly string[],
protectFremen = true,
) {
requireDisaster(validTerritory(t), 'The worm needs a valid territory.');
@@ -277,10 +277,13 @@ export function validateWormCancellation(g: Game, response: ResponseWindow) {
}
// Canceling allied protection may first open the separate survival decision;
// validate the affected army now without executing that future window.
+ const group = response.coalitionWormEvent && g.coalitionWormProtection?.event === response.coalitionWormEvent
+ ? g.coalitionWormProtection.outcomes.filter(outcome => outcome.outcome === 'allowed').map(outcome => outcome.ally)
+ : undefined;
validateWormDevouring(
g,
response.location,
- response.kind === 'wormSurvival' ? response.recipient : undefined,
+ group ?? (response.kind === 'wormSurvival' ? response.recipient : undefined),
response.kind === 'wormAllyProtection',
);
}
diff --git a/game/disaster-rules.ts b/game/disaster-rules.ts
index f1be6e1d..990b7459 100644
--- a/game/disaster-rules.ts
+++ b/game/disaster-rules.ts
@@ -12,8 +12,9 @@ export function stormExposesTerritory(id: string, shieldWallDestroyed = false) {
(shieldWallDestroyed && ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id));
}
-export function wormConsumesForces(player: { id: string; faction: FactionId }, protectedAlly?: string, protectFremen = true) {
- return (!protectFremen || player.faction !== 'fremen') && player.id !== protectedAlly;
+export function wormConsumesForces(player: { id: string; faction: FactionId }, protectedAlly?: string | readonly string[], protectFremen = true) {
+ const protectedIds = typeof protectedAlly === 'string' ? [protectedAlly] : protectedAlly ?? [];
+ return (!protectFremen || player.faction !== 'fremen') && !protectedIds.includes(player.id);
}
/** A territory card adds to existing spice unless its printed sector is under storm. */
diff --git a/game/engine.ts b/game/engine.ts
index 7f1a6819..519086c7 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -754,6 +754,9 @@ import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipmen
import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionAllies, coalitionGroups, coalitionOf,
leaveCoalition, CoalitionAllianceError, type CoalitionState, type CoalitionTransition } from './coalition-alliance';
import { coalitionBattleBeneficiary } from './coalition-battle-preparation';
+import { createCoalitionWormProtection, currentCoalitionWormAlly, decideCoalitionWormProtection,
+ resolveCoalitionWormProtection, protectedCoalitionWormAllies, validateCoalitionWormProtection,
+ CoalitionWormProtectionError, type CoalitionWormProtectionFrame } from './coalition-worm-protection';
import { reconcileCoalitionEscrow, setCoalitionPledge, type CoalitionEscrowMembership,
type CoalitionEscrowState } from './coalition-escrow';
import { CoalitionFundingError, quoteCoalitionFunding, settleCoalitionFunding,
@@ -1187,7 +1190,7 @@ export type Decision =
resume: 'storm' | 'shipment';
}
| { kind: 'wormPlacement'; player: string }
- | { kind: 'wormProtection'; player: string; territory: string; ally: string }
+ | { kind: 'wormProtection'; player: string; territory: string; ally: string; event?: string }
| { kind: 'nexusFremenProtection'; player: string; event: string }
| { kind: 'nexusFremenRideBetrayal'; player: string; event: string }
| { kind: 'discoveryDiscard'; player: string; event: string }
@@ -1247,6 +1250,7 @@ export type ResponseWindow = {
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
guildRateEvent?: string;
guildTransportEvent?: string;
+ coalitionWormEvent?: string;
advisorResume?: 'preparation' | 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry' | 'planetologistGather';
advisorGatherMove?: { turn: number; move: number; order: MovementOrder };
discoveryEntry?: DiscoveryEntryArrivalChild;
@@ -1446,6 +1450,7 @@ export type Game = {
/** Unexposed group-alliance integration state; absent in existing games. */
coalitionState?: CoalitionState;
coalitionEscrow?: CoalitionEscrowState;
+ coalitionWormProtection?: CoalitionWormProtectionFrame;
/** Public written agreements. Their prose is honored by players, not parsed. */
deals?: PublicDeal[];
/** A human-to-human lobby offer, pending the recipient's consent. */
@@ -13706,7 +13711,7 @@ function drawSpice(g: Game) {
function devour(
g: Game,
t: string,
- protectedAlly?: string,
+ protectedAlly?: string | readonly string[],
protectFremen = true,
) {
disasterPreflight(() =>
@@ -13722,6 +13727,27 @@ function devour(
g.wormRides.push(t);
log(g, `Shai-Hulud appeared in ${territory(t).name}.`);
}
+function coalitionWormRule(operation: () => T): T {
+ try { return operation(); }
+ catch (error) { if (error instanceof CoalitionWormProtectionError) throw new RuleError(error.message); throw error; }
+}
+function advanceCoalitionWormProtection(g: Game) {
+ const frame = g.coalitionWormProtection;
+ requireRule(frame, 'The original group worm protection is missing.');
+ const ally = coalitionWormRule(() => currentCoalitionWormAlly(g, frame));
+ if (ally) {
+ requireRule(frame.stage === 'decision', 'Finish the current worm protection response first.');
+ g.decision = {kind:'wormProtection', player:frame.owner, territory:frame.territory, ally, event:frame.event};
+ } else wormSurvival(g, frame.territory);
+}
+function finishCoalitionWorm(g: Game, t: string, protectFremen: boolean) {
+ const frame = g.coalitionWormProtection;
+ requireRule(frame && frame.territory === t, 'The original group worm occurrence changed.');
+ const protectedAllies = coalitionWormRule(() => protectedCoalitionWormAllies(g, frame));
+ devour(g, t, protectedAllies, protectFremen);
+ delete g.coalitionWormProtection;
+ afterWorm(g);
+}
function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summoned' = 'natural') {
const fremen = byFaction(g, 'fremen');
if (!fremen && g.nexusCards?.cards && g.players.some(p =>
@@ -13773,7 +13799,12 @@ function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summo
return;
}
}
- if (fremen?.ally && at(getPlayer(g, fremen.ally), t)) {
+ const group = fremen && coalitionWormRule(() => createCoalitionWormProtection(g, fremen.id, t, crypto.randomUUID()));
+ if (group) {
+ requireRule(!g.coalitionWormProtection, 'Finish the previous group worm before another appearance.');
+ g.coalitionWormProtection = group;
+ advanceCoalitionWormProtection(g);
+ } else if (fremen?.ally && at(getPlayer(g, fremen.ally), t)) {
g.decision = {
kind: 'wormProtection',
player: fremen.id,
@@ -13892,11 +13923,12 @@ function wormSurvival(g: Game, t: string, protectedAlly?: string) {
owner: fremen.id,
location: t,
recipient: protectedAlly,
+ ...(g.coalitionWormProtection ? {coalitionWormEvent:g.coalitionWormProtection.event} : {}),
passed: [],
};
} else {
- devour(g, t, protectedAlly);
- afterWorm(g);
+ if (g.coalitionWormProtection) finishCoalitionWorm(g, t, true);
+ else { devour(g, t, protectedAlly); afterWorm(g); }
}
}
function afterWorm(g: Game) {
@@ -30893,6 +30925,22 @@ function atomicsIntegrity(g: Game) {
'Atomics Aftermath has lost its revealed token, original targets or hand-limit custody.');
}
function marketGholaIntegrity(g: Game) {
+ const groupWorm = g.coalitionWormProtection;
+ if (groupWorm) {
+ coalitionWormRule(() => validateCoalitionWormProtection(g, groupWorm));
+ const pending = g.pendingKarama?.use.kind === 'cancel' ? g.pendingKarama.use.response : null;
+ const response = g.response ?? pending;
+ if (groupWorm.stage === 'decision') {
+ const decision = g.decision;
+ requireRule(decision?.kind === 'wormProtection' && decision.event === groupWorm.event &&
+ decision.player === groupWorm.owner && decision.territory === groupWorm.territory &&
+ decision.ally === coalitionWormRule(() => currentCoalitionWormAlly(g, groupWorm)),
+ 'The group worm has lost its current Fremen decision.');
+ } else requireRule(response?.coalitionWormEvent === groupWorm.event && response.owner === groupWorm.owner &&
+ response.location === groupWorm.territory && (groupWorm.stage === 'response'
+ ? response.kind === 'wormAllyProtection' && response.recipient === coalitionWormRule(() => currentCoalitionWormAlly(g, groupWorm))
+ : response.kind === 'wormSurvival'), 'The group worm has lost its original response.');
+ }
saphoCyclicIntegrity(g);
battleOrderIntegrity(g);
saphoAggressorIntegrity(g);
@@ -37059,14 +37107,20 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
continueSpice(g);
} else beginWorm(g, response.location!, 'additional');
} else if (response.kind === 'wormAllyProtection') {
- wormSurvival(
- g,
- response.location!,
- canceled ? undefined : response.recipient,
- );
+ if (response.coalitionWormEvent) {
+ const frame = g.coalitionWormProtection;
+ requireRule(frame && response.location === frame.territory && response.recipient,
+ 'The original group worm response is missing its ally.');
+ g.coalitionWormProtection = coalitionWormRule(() => resolveCoalitionWormProtection(g, frame,
+ response.owner, response.coalitionWormEvent!, response.recipient!, canceled));
+ advanceCoalitionWormProtection(g);
+ } else wormSurvival(g, response.location!, canceled ? undefined : response.recipient);
} else if (response.kind === 'wormSurvival') {
- devour(g, response.location!, response.recipient, !canceled);
- afterWorm(g);
+ if (response.coalitionWormEvent) {
+ requireRule(g.coalitionWormProtection?.event === response.coalitionWormEvent,
+ 'The original group worm survival response is missing.');
+ finishCoalitionWorm(g, response.location!, !canceled);
+ } else { devour(g, response.location!, response.recipient, !canceled); afterWorm(g); }
} else if (response.kind === 'stormProtection') {
if (response.resume === 'storm') {
g.stormResolution!.protected = !canceled;
@@ -43640,7 +43694,17 @@ function applyActionInner(
typeof action.accept === 'boolean',
'Choose whether to protect your ally.',
);
- if (action.accept) {
+ if (decision.event) {
+ const frame = g.coalitionWormProtection;
+ requireRule(frame && frame.territory === decision.territory && action.event === decision.event &&
+ action.ally === decision.ally && Object.keys(action).sort().join(',') === 'accept,ally,event,type',
+ 'Choose the original group worm event and threatened ally.');
+ g.coalitionWormProtection = coalitionWormRule(() => decideCoalitionWormProtection(g, frame, id,
+ decision.event!, decision.ally, action.accept as boolean));
+ if (action.accept) g.response = {kind:'wormAllyProtection', owner:id, location:decision.territory,
+ recipient:decision.ally, coalitionWormEvent:decision.event, passed:[]};
+ else advanceCoalitionWormProtection(g);
+ } else if (action.accept) {
g.response = {
kind: 'wormAllyProtection',
owner: id,
diff --git a/game/reference.ts b/game/reference.ts
index 6c730bf4..f9a515a3 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4501,11 +4501,11 @@ export const RULE_TOPICS: RuleTopic[] = [
example:
'At the final turn with neither Guild nor Fremen, two players each hold two qualifying strongholds and a third holds one. The two leaders of the count share the win.',
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding, native Guild transport, Emperor and Fremen aid, Voice, Prescience and Harkonnen traitor support; other group benefits remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding, Guild transport, Emperor and Fremen aid, Fremen worm protection, Voice, Prescience and Harkonnen traitor support; other group benefits remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-worm-protection.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, Guild transport, Emperor and Fremen aid, battle preparation and allied traitor votes; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
+ { area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
],
related: [
'mentat',
diff --git a/tests/coalition-fremen-worm.test.ts b/tests/coalition-fremen-worm.test.ts
new file mode 100644
index 00000000..623f2392
--- /dev/null
+++ b/tests/coalition-fremen-worm.test.ts
@@ -0,0 +1,230 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { registerHooks } from 'node:module';
+import { readFileSync } from 'node:fs';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { baseDeck, spiceDeck } from '../game/cards';
+import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
+import { botActions } from '../game/bots';
+import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+
+const aliases = registerHooks({
+ resolve(specifier, context, next) {
+ return next(specifier === 'next/image' ? 'vinext/shims/image' :
+ specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
+ },
+ load(url, context, next) {
+ if (!url.endsWith('.module.css')) return next(url, context);
+ const css = readFileSync(new URL(url), 'utf8');
+ const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
+ return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
+ },
+});
+const {GameTable} = await import('../components/game-table');
+aliases.deregister();
+
+const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
+const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
+const key = 'red_chasm:7';
+
+/** The option is not exposed in a lobby. Stage a Basic Nexus, form the pair
+ * and group through actual actions, then place conserved forces and the next
+ * real worm card at a staged Spice Blow. */
+function source(group = true, fremenPresent = false) {
+ let game = createGame('GROUPWORM', newPlayer('f', 'Fremen', 'fremen'), false);
+ game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'),
+ newPlayer('h', 'Harkonnen', 'harkonnen'));
+ Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true,
+ order:['f','e','a','h'], ready:[], response:null, decision:null,
+ phaseOpening:null, storm:18, deck:baseDeck()});
+ game.coalitionState = emptyCoalitionState();
+ game = act(game, 'f', {type:'alliance', target:'e'});
+ game = act(game, 'e', {type:'alliance', target:'f'});
+ if (group) {
+ const pair = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(pair);
+ game = act(game, 'f', {type:'coalitionPropose', groupId:pair.id, entrant:'a',
+ expectedRevision:game.coalitionState!.revision});
+ for (const id of ['e','a']) {
+ const proposal = game.coalitionState!.proposal!;
+ game = act(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
+ turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
+ }
+ }
+ Object.assign(game, {phase:1, turn:3, nexus:false, ready:[], response:null, decision:null,
+ phaseOpening:null, storm:18, active:null, stormPending:null});
+ for (const player of game.players) {
+ player.hand = []; player.forces = {}; player.reserves = 20; player.tanks = 0;
+ player.traitors = []; player.traitorChoices = [];
+ }
+ for (const id of ['e','a','h', ...(fremenPresent ? ['f'] : [])]) {
+ seat(game,id).forces = {[key]:3}; seat(game,id).reserves = 17;
+ }
+ const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
+ const [karama] = game.deck.splice(index,1); seat(game,'h').hand.push(karama);
+ const spice = spiceDeck();
+ const land = spice.find(card => 'territory' in card && card.territory === 'red_chasm');
+ const worm = spice.find(card => 'worm' in card);
+ assert.ok(land && worm);
+ game.spiceDiscard = [[land], []];
+ game.spiceDeck = [worm, ...spice.filter(card => card !== land && card !== worm && 'territory' in card)];
+ game.spice = {[key]:8};
+ for (const player of game.players) game = act(game, player.id, {type:'ready'});
+ assert.equal(game.decision?.kind,'wormProtection');
+ return {game, karama};
+}
+
+function pass(game: Game, kind: string): Game {
+ for (let n = 0; game.response?.kind === kind && n < 24; n++) {
+ const next = game.players.find(player => !game.response!.passed.includes(player.id));
+ assert.ok(next);
+ game = act(game, next.id, {type:'passResponse'});
+ }
+ assert.notEqual(game.response?.kind,kind);
+ return game;
+}
+function conserved(game: Game) {
+ for (const player of game.players)
+ assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a,b) => a+b,0),20);
+}
+function chooseGroupProtection(game: Game, accept: boolean): Game {
+ assert.equal(game.decision?.kind,'wormProtection');
+ assert.ok(game.decision.event);
+ return act(game,'f',{type:'decision',accept,event:game.decision.event,ally:game.decision.ally});
+}
+
+for (const protectedIds of [[], ['a'], ['e'], ['a','e']] as const)
+ void test(`Fremen may protect exactly ${protectedIds.length ? protectedIds.join(' and ') : 'neither group ally'} from one worm`, () => {
+ let {game} = source();
+ const seen: string[] = [];
+ while (game.decision?.kind === 'wormProtection') {
+ const ally = game.decision.ally; seen.push(ally);
+ game = chooseGroupProtection(game,protectedIds.includes(ally as never));
+ game = pass(game,'wormAllyProtection');
+ }
+ assert.deepEqual(seen.sort(),['a','e']);
+ for (const ally of ['a','e']) {
+ assert.equal(seat(game,ally).forces[key] ?? 0, protectedIds.includes(ally as never) ? 3 : 0);
+ assert.equal(seat(game,ally).tanks, protectedIds.includes(ally as never) ? 0 : 3);
+ }
+ assert.equal(seat(game,'h').tanks,3);
+ assert.equal(game.spice[key],undefined);
+ assert.equal(game.nexus,true);
+ conserved(game);
+ });
+
+void test('Karama prevents only the selected group ally protection and the other can still be protected', () => {
+ const initial = source();
+ let {game} = initial;
+ const {karama} = initial;
+ assert.equal(game.decision?.kind,'wormProtection');
+ const denied = game.decision.ally;
+ game = chooseGroupProtection(game,true);
+ assert.equal(game.response?.kind,'wormAllyProtection');
+ assert.equal(game.response?.recipient,denied);
+ game = act(game,'h',{type:'card',mode:'cancel',card:karama.id});
+ game = pass(game,'wormAllyProtection');
+ assert.equal(game.decision?.kind,'wormProtection');
+ const allowed = game.decision.ally;
+ assert.notEqual(allowed,denied);
+ game = chooseGroupProtection(game,true);
+ game = pass(game,'wormAllyProtection');
+ assert.equal(seat(game,denied).tanks,3);
+ assert.equal(seat(game,allowed).forces[key],3);
+ assert.equal(game.discard.filter(card => card.id === karama.id).length,1);
+ assert.equal(game.nexus,true);
+ conserved(game);
+});
+
+void test('Fremen survival remains a separate response after protecting both group allies', () => {
+ let {game} = source(true,true);
+ while (game.decision?.kind === 'wormProtection') {
+ game = chooseGroupProtection(game,true);
+ game = pass(game,'wormAllyProtection');
+ }
+ assert.equal(game.response?.kind,'wormSurvival');
+ game = pass(game,'wormSurvival');
+ for (const id of ['f','a','e']) assert.equal(seat(game,id).forces[key],3);
+ assert.equal(seat(game,'h').tanks,3);
+ assert.deepEqual(game.wormRides,['red_chasm']);
+ conserved(game);
+});
+
+void test('Karama cancels Fremen survival without undoing either group ally protection', () => {
+ const initial = source(true,true);
+ let {game} = initial;
+ const {karama} = initial;
+ while (game.decision?.kind === 'wormProtection') {
+ game = chooseGroupProtection(game,true);
+ game = pass(game,'wormAllyProtection');
+ }
+ assert.equal(game.response?.kind,'wormSurvival');
+ game = act(game,'h',{type:'card',mode:'cancel',card:karama.id});
+ game = pass(game,'wormSurvival');
+ assert.equal(seat(game,'f').forces[key] ?? 0,0);
+ assert.equal(seat(game,'f').tanks,3);
+ for (const id of ['a','e']) {
+ assert.equal(seat(game,id).forces[key],3);
+ assert.equal(seat(game,id).tanks,0);
+ }
+ assert.equal(seat(game,'h').tanks,3);
+ assert.equal(game.discard.filter(card => card.id === karama.id).length,1);
+ assert.equal(game.spice[key],undefined);
+ assert.deepEqual(game.wormRides,[]);
+ assert.equal(game.nexus,true);
+ conserved(game);
+});
+
+void test('the Fremen table names the current threatened ally and offers both protection choices', () => {
+ const {game} = source();
+ assert.equal(game.decision?.kind,'wormProtection');
+ const name = seat(game,game.decision.ally).name;
+ const html = renderToStaticMarkup(createElement(GameTable,{
+ game:viewGame(game,'f'), busy:false, send:async () => {}, onExit() {},
+ }));
+ assert.match(html, new RegExp(`ally ${name}’s forces`));
+ assert.match(html, /Protect my ally/);
+ assert.match(html, /Do not protect/);
+});
+
+void test('a stale group protection choice cannot answer the next ally', () => {
+ let {game} = source();
+ assert.equal(game.decision?.kind,'wormProtection');
+ const first = {type:'decision',accept:false,event:game.decision.event,ally:game.decision.ally} as Action;
+ game = act(game,'f',first);
+ assert.equal(game.decision?.kind,'wormProtection');
+ assert.notEqual(game.decision.ally,first.ally);
+ const before = structuredClone(game);
+ assert.throws(() => act(game,'f',first));
+ assert.deepEqual(game,before);
+ game = chooseGroupProtection(game,false);
+ assert.equal(game.nexus,true);
+ conserved(game);
+});
+
+void test('the original pair keeps one Fremen ally choice', () => {
+ let {game} = source(false);
+ assert.equal(game.decision?.kind,'wormProtection');
+ assert.equal(game.decision.ally,'e');
+ game = act(game,'f',{type:'decision',accept:true});
+ game = pass(game,'wormAllyProtection');
+ assert.equal(seat(game,'e').forces[key],3);
+ assert.equal(seat(game,'a').tanks,3);
+ assert.equal(seat(game,'h').tanks,3);
+ conserved(game);
+});
+
+void test('a Medium Fremen bot can make each legal group protection choice', () => {
+ let {game} = source();
+ for (let n = 0; n < 2; n++) {
+ assert.equal(game.decision?.kind,'wormProtection');
+ const view = viewGame(game,'f');
+ view.players.find(player => player.id === 'f')!.bot = 'Medium';
+ const decision = botActions(view).find(action => action.type === 'decision' && typeof action.accept === 'boolean');
+ assert.ok(decision);
+ game = act(game,'f',decision);
+ game = pass(game,'wormAllyProtection');
+ }
+ assert.equal(game.nexus,true);
+ conserved(game);
+});
diff --git a/tests/summoned-worm.test.ts b/tests/summoned-worm.test.ts
index 25ce8579..ae6e4d71 100644
--- a/tests/summoned-worm.test.ts
+++ b/tests/summoned-worm.test.ts
@@ -120,7 +120,8 @@ void test('before-draw summon defers Nexus until the first blow is accepted, the
/Nexus/,
);
g = ready(g);
- assert.deepEqual(g.spiceWindow, { ...lands[0], harvested: false });
+ assert.ok(g.spiceWindow?.event);
+ assert.deepEqual(g.spiceWindow, { ...lands[0], event:g.spiceWindow.event, harvested: false });
assert.equal(g.nexus, true);
g = ready(g);
assert.equal(g.spiceWindow, null);
@@ -137,7 +138,8 @@ void test('before-draw summon defers Nexus until the first blow is accepted, the
});
assert.equal(g.players[0].forces['polar_sink:0'], 3);
assert.equal(g.players[0].elites?.forces['polar_sink:0'], 1);
- assert.deepEqual(g.spiceWindow, { ...lands[1], harvested: false });
+ assert.ok(g.spiceWindow?.event);
+ assert.deepEqual(g.spiceWindow, { ...lands[1], event:g.spiceWindow.event, harvested: false });
g = ready(g);
assert.equal(g.phase, 2);
});