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); });