Support group Fremen worm protection
Verify and publish checkpoint container / container (push) Successful in 10m58s
Verify and publish checkpoint container / container (push) Successful in 10m58s
This commit is contained in:
1 parent
6e26ceb216
commit
40c9651b80
12 files changed
+479
-24
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user