Support group Fremen worm protection
Verify and publish checkpoint container / container (push) Successful in 10m58s

This commit is contained in:
Quintine committed 2026-10-29 00:05:12 +11:00
1 parent 6e26ceb216
commit 40c9651b80
12 files changed
+479 -24

No files matched your search

+1 -1
View File
@@ -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 },
+124
View File
@@ -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<string, unknown> =>
!!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);
}
+5 -2
View File
@@ -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',
);
}
+3 -2
View File
@@ -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. */
+77 -13
View File
@@ -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<T>(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,
+2 -2
View File
@@ -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',