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

+3 -1
View File
@@ -4023,15 +4023,17 @@ export function GameTable({
<p className="muted">
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.
</p>
{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' ? (
+10 -1
View File
@@ -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
+14
View File
@@ -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
+6
View File
@@ -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
+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',
+230
View File
@@ -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);
});
+4 -2
View File
@@ -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);
});