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
+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',
|
||||
|
||||
Reference in new issue
Block a user