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">
|
<p className="muted">
|
||||||
Shai-Hulud has appeared in{' '}
|
Shai-Hulud has appeared in{' '}
|
||||||
{combatName(g.decision.territory)}. You may protect your
|
{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>
|
</p>
|
||||||
{actionButton('Protect my ally', {
|
{actionButton('Protect my ally', {
|
||||||
type: 'decision',
|
type: 'decision',
|
||||||
accept: true,
|
accept: true,
|
||||||
|
...(g.decision.event ? {event:g.decision.event, ally:g.decision.ally} : {}),
|
||||||
})}
|
})}
|
||||||
{actionButton('Do not protect', {
|
{actionButton('Do not protect', {
|
||||||
type: 'decision',
|
type: 'decision',
|
||||||
accept: false,
|
accept: false,
|
||||||
|
...(g.decision.event ? {event:g.decision.event, ally:g.decision.ally} : {}),
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
) : g.decision.kind === 'nexusFremenCunningOffer' ? (
|
) : g.decision.kind === 'nexusFremenCunningOffer' ? (
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,6 @@
|
|||||||
# Current development status
|
# 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
|
is playable. Advanced, expansion-faction and supported optional-module starts
|
||||||
are labelled previews; complete rules/publication acceptance remains gated.**
|
are labelled previews; complete rules/publication acceptance remains gated.**
|
||||||
|
|
||||||
@@ -17,6 +17,15 @@ open mode gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## 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.**
|
**Staged group bribe and deal eligibility — 28 October. Prototyped / Partial.**
|
||||||
Bribes, deal offers and acceptance recognize both group allies. Existing
|
Bribes, deal offers and acceptance recognize both group allies. Existing
|
||||||
agreements retain their history and release controls; an unpaid deferred bribe
|
agreements retain their history and release controls; an unpaid deferred bribe
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# Optional three-stronghold alliance victory
|
# 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
|
## Group Guild transport — 28 October 2026
|
||||||
|
|
||||||
**Staged Basic prototype, not a selectable game mode.** Both Guild group
|
**Staged Basic prototype, not a selectable game mode.** Both Guild group
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Rule decision index
|
# 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
|
## Group bribes and public deals — 28 October 2026
|
||||||
|
|
||||||
Each member of a staged group is treated as allied to both others for the
|
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];
|
return [...candidates, decline];
|
||||||
}
|
}
|
||||||
if (d.kind === 'wormProtection')
|
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')
|
if (d.kind === 'stormLosses')
|
||||||
return [
|
return [
|
||||||
{ type: 'decision', elite: level === 0 ? d.maxElite : d.minElite },
|
{ 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(
|
export function validateWormDevouring(
|
||||||
g: Game,
|
g: Game,
|
||||||
t: string,
|
t: string,
|
||||||
protectedAlly?: string,
|
protectedAlly?: string | readonly string[],
|
||||||
protectFremen = true,
|
protectFremen = true,
|
||||||
) {
|
) {
|
||||||
requireDisaster(validTerritory(t), 'The worm needs a valid territory.');
|
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;
|
// Canceling allied protection may first open the separate survival decision;
|
||||||
// validate the affected army now without executing that future window.
|
// 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(
|
validateWormDevouring(
|
||||||
g,
|
g,
|
||||||
response.location,
|
response.location,
|
||||||
response.kind === 'wormSurvival' ? response.recipient : undefined,
|
group ?? (response.kind === 'wormSurvival' ? response.recipient : undefined),
|
||||||
response.kind === 'wormAllyProtection',
|
response.kind === 'wormAllyProtection',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,8 +12,9 @@ export function stormExposesTerritory(id: string, shieldWallDestroyed = false) {
|
|||||||
(shieldWallDestroyed && ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id));
|
(shieldWallDestroyed && ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wormConsumesForces(player: { id: string; faction: FactionId }, protectedAlly?: string, protectFremen = true) {
|
export function wormConsumesForces(player: { id: string; faction: FactionId }, protectedAlly?: string | readonly string[], protectFremen = true) {
|
||||||
return (!protectFremen || player.faction !== 'fremen') && player.id !== protectedAlly;
|
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. */
|
/** 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,
|
import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionAllies, coalitionGroups, coalitionOf,
|
||||||
leaveCoalition, CoalitionAllianceError, type CoalitionState, type CoalitionTransition } from './coalition-alliance';
|
leaveCoalition, CoalitionAllianceError, type CoalitionState, type CoalitionTransition } from './coalition-alliance';
|
||||||
import { coalitionBattleBeneficiary } from './coalition-battle-preparation';
|
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,
|
import { reconcileCoalitionEscrow, setCoalitionPledge, type CoalitionEscrowMembership,
|
||||||
type CoalitionEscrowState } from './coalition-escrow';
|
type CoalitionEscrowState } from './coalition-escrow';
|
||||||
import { CoalitionFundingError, quoteCoalitionFunding, settleCoalitionFunding,
|
import { CoalitionFundingError, quoteCoalitionFunding, settleCoalitionFunding,
|
||||||
@@ -1187,7 +1190,7 @@ export type Decision =
|
|||||||
resume: 'storm' | 'shipment';
|
resume: 'storm' | 'shipment';
|
||||||
}
|
}
|
||||||
| { kind: 'wormPlacement'; player: string }
|
| { 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: 'nexusFremenProtection'; player: string; event: string }
|
||||||
| { kind: 'nexusFremenRideBetrayal'; player: string; event: string }
|
| { kind: 'nexusFremenRideBetrayal'; player: string; event: string }
|
||||||
| { kind: 'discoveryDiscard'; 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. */
|
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
|
||||||
guildRateEvent?: string;
|
guildRateEvent?: string;
|
||||||
guildTransportEvent?: string;
|
guildTransportEvent?: string;
|
||||||
|
coalitionWormEvent?: string;
|
||||||
advisorResume?: 'preparation' | 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry' | 'planetologistGather';
|
advisorResume?: 'preparation' | 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry' | 'planetologistGather';
|
||||||
advisorGatherMove?: { turn: number; move: number; order: MovementOrder };
|
advisorGatherMove?: { turn: number; move: number; order: MovementOrder };
|
||||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||||
@@ -1446,6 +1450,7 @@ export type Game = {
|
|||||||
/** Unexposed group-alliance integration state; absent in existing games. */
|
/** Unexposed group-alliance integration state; absent in existing games. */
|
||||||
coalitionState?: CoalitionState;
|
coalitionState?: CoalitionState;
|
||||||
coalitionEscrow?: CoalitionEscrowState;
|
coalitionEscrow?: CoalitionEscrowState;
|
||||||
|
coalitionWormProtection?: CoalitionWormProtectionFrame;
|
||||||
/** Public written agreements. Their prose is honored by players, not parsed. */
|
/** Public written agreements. Their prose is honored by players, not parsed. */
|
||||||
deals?: PublicDeal[];
|
deals?: PublicDeal[];
|
||||||
/** A human-to-human lobby offer, pending the recipient's consent. */
|
/** A human-to-human lobby offer, pending the recipient's consent. */
|
||||||
@@ -13706,7 +13711,7 @@ function drawSpice(g: Game) {
|
|||||||
function devour(
|
function devour(
|
||||||
g: Game,
|
g: Game,
|
||||||
t: string,
|
t: string,
|
||||||
protectedAlly?: string,
|
protectedAlly?: string | readonly string[],
|
||||||
protectFremen = true,
|
protectFremen = true,
|
||||||
) {
|
) {
|
||||||
disasterPreflight(() =>
|
disasterPreflight(() =>
|
||||||
@@ -13722,6 +13727,27 @@ function devour(
|
|||||||
g.wormRides.push(t);
|
g.wormRides.push(t);
|
||||||
log(g, `Shai-Hulud appeared in ${territory(t).name}.`);
|
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') {
|
function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summoned' = 'natural') {
|
||||||
const fremen = byFaction(g, 'fremen');
|
const fremen = byFaction(g, 'fremen');
|
||||||
if (!fremen && g.nexusCards?.cards && g.players.some(p =>
|
if (!fremen && g.nexusCards?.cards && g.players.some(p =>
|
||||||
@@ -13773,7 +13799,12 @@ function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summo
|
|||||||
return;
|
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 = {
|
g.decision = {
|
||||||
kind: 'wormProtection',
|
kind: 'wormProtection',
|
||||||
player: fremen.id,
|
player: fremen.id,
|
||||||
@@ -13892,11 +13923,12 @@ function wormSurvival(g: Game, t: string, protectedAlly?: string) {
|
|||||||
owner: fremen.id,
|
owner: fremen.id,
|
||||||
location: t,
|
location: t,
|
||||||
recipient: protectedAlly,
|
recipient: protectedAlly,
|
||||||
|
...(g.coalitionWormProtection ? {coalitionWormEvent:g.coalitionWormProtection.event} : {}),
|
||||||
passed: [],
|
passed: [],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
devour(g, t, protectedAlly);
|
if (g.coalitionWormProtection) finishCoalitionWorm(g, t, true);
|
||||||
afterWorm(g);
|
else { devour(g, t, protectedAlly); afterWorm(g); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function afterWorm(g: Game) {
|
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.');
|
'Atomics Aftermath has lost its revealed token, original targets or hand-limit custody.');
|
||||||
}
|
}
|
||||||
function marketGholaIntegrity(g: Game) {
|
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);
|
saphoCyclicIntegrity(g);
|
||||||
battleOrderIntegrity(g);
|
battleOrderIntegrity(g);
|
||||||
saphoAggressorIntegrity(g);
|
saphoAggressorIntegrity(g);
|
||||||
@@ -37059,14 +37107,20 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
|||||||
continueSpice(g);
|
continueSpice(g);
|
||||||
} else beginWorm(g, response.location!, 'additional');
|
} else beginWorm(g, response.location!, 'additional');
|
||||||
} else if (response.kind === 'wormAllyProtection') {
|
} else if (response.kind === 'wormAllyProtection') {
|
||||||
wormSurvival(
|
if (response.coalitionWormEvent) {
|
||||||
g,
|
const frame = g.coalitionWormProtection;
|
||||||
response.location!,
|
requireRule(frame && response.location === frame.territory && response.recipient,
|
||||||
canceled ? undefined : 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') {
|
} else if (response.kind === 'wormSurvival') {
|
||||||
devour(g, response.location!, response.recipient, !canceled);
|
if (response.coalitionWormEvent) {
|
||||||
afterWorm(g);
|
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') {
|
} else if (response.kind === 'stormProtection') {
|
||||||
if (response.resume === 'storm') {
|
if (response.resume === 'storm') {
|
||||||
g.stormResolution!.protected = !canceled;
|
g.stormResolution!.protected = !canceled;
|
||||||
@@ -43640,7 +43694,17 @@ function applyActionInner(
|
|||||||
typeof action.accept === 'boolean',
|
typeof action.accept === 'boolean',
|
||||||
'Choose whether to protect your ally.',
|
'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 = {
|
g.response = {
|
||||||
kind: 'wormAllyProtection',
|
kind: 'wormAllyProtection',
|
||||||
owner: id,
|
owner: id,
|
||||||
|
|||||||
+2
-2
@@ -4501,11 +4501,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
example:
|
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.',
|
'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: [
|
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: '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: '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: '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: [
|
related: [
|
||||||
'mentat',
|
'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/,
|
/Nexus/,
|
||||||
);
|
);
|
||||||
g = ready(g);
|
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);
|
assert.equal(g.nexus, true);
|
||||||
g = ready(g);
|
g = ready(g);
|
||||||
assert.equal(g.spiceWindow, null);
|
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].forces['polar_sink:0'], 3);
|
||||||
assert.equal(g.players[0].elites?.forces['polar_sink:0'], 1);
|
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);
|
g = ready(g);
|
||||||
assert.equal(g.phase, 2);
|
assert.equal(g.phase, 2);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user