Prototype Jacurutu income, Testing Station and Shrine effects

Connect automatic unambiguous Jacurutu physical-force income, a sole-occupant
Testing Station storm choice and Shrine reciprocal physical card roles to
engine actions, player controls, legal AI and saved continuation. Preserve
Weather Control exclusion, exact card/force custody, private hand boundaries
and committed effects after restart. Shared role checks cover existing ordinary
and special Karama paths; reserved cards cannot enter another pending use.

Keep Orgiz, mixed-force Jacurutu allocation and contested Cistern/Testing
Station benefits explicitly pending. These are bounded prototypes; complete
Discovery/Advanced/expansion and publication gates remain closed.

Validation: types, lint and 4,345 offline tests passed (32 added), production
build passed, and 40 HTTP tests passed. Independent cross-reviews repaired
reserved-card deadlock, physical-card selection, alternate-role permission,
storm source/token binding and public-entitlement bot ordering. The first broad
run found five failures; focused repairs preceded the stable repeated check.

Browser A5EJQ2W4 used genuine Discovery setup/reveal/free entry with targeted QA
positioning. A human increased storm movement from two to three sectors,
moving sector 6 to 9. Refresh restored station forces and the private Snooper.
This was targeted control/recovery evidence, not an unmodified full game.
All 187 opening saved rooms remained unchanged; 31 QA/HTTP rooms were added
(218 total). Reused the running server; no restart or save reset.

Source-bound report:
/home/quintine/.local/share/dune/checkpoints/20260913-discovery-location-effects/verification-final/report.json
Source fingerprint: beecb85ab7ec39319115389c115434d161591e8549802c542a6343a0fc19bea6
Durations: check 140540ms; build 6741ms; integration 24956ms.
Aggregate goal counter 63,116,444 to 64,295,091 at final verification:
1,178,647 tokens before final Git/push overhead for three bounded functions.
Standing authorization: Push all from now on.
This commit is contained in:
Quintine committed 2026-09-13 14:50:09 +10:00
1 parent ab0ab52849
commit f0d3d9c464
31 files changed
+2524 -138

No files matched your search

+17 -2
View File
@@ -1,6 +1,7 @@
import { discoveryBotActions } from './discovery-options';
import { discoveryFlightBotActions } from './discovery-flight-options';
import { discoveryEntryBotActions } from './discovery-entry-options';
import { discoveryStormActions } from './discovery-storm-options';
import { greatMakerBotActions } from './great-maker-options';
import { nexusChoamTradeBotActions } from './nexus-choam-trade-options';
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options';
@@ -57,6 +58,10 @@ import {
import { ownedTech } from './tech-tokens';
import type { StrongholdProgress } from './victory-progress';
import { canUseAsKarama } from './karama';
import {
canUseAsKaramaRole,
shrineTruthtranceBotAction,
} from './shrine';
import { reserveShipmentCost } from './shipment-price';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct, nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
import { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options';
@@ -1204,7 +1209,11 @@ function policyActions(g: GameView): Action[] {
(me.faction === 'tleilaxu' && g.decision?.kind === 'revivalStop')) &&
g.advanced &&
!me.specialKaramaUsed &&
me.hand?.find((c) => c.effect === 'karama');
me.hand?.find(
(c) =>
canUseAsKaramaRole(g, me, c) &&
g.richeseGift?.pending?.card?.id !== c.id,
);
if (
specialCard &&
me.faction === 'ixians' &&
@@ -2346,6 +2355,7 @@ function policyActions(g: GameView): Action[] {
];
if (d.kind === 'discoveryDiscard') return discoveryBotActions(g);
if (d.kind === 'discoveryEntry') return discoveryEntryBotActions(g);
if (d.kind === 'ecologicalStorm') return discoveryStormActions(g);
if (d.kind === 'greatMakerVote' || d.kind === 'greatMakerRide') return greatMakerBotActions(g);
if (d.kind === 'wormProtection')
return [{ type: 'decision', accept: true }];
@@ -2382,7 +2392,7 @@ function policyActions(g: GameView): Action[] {
}
if (d.kind === 'auctionPayment') {
const karama = me.hand?.find((c) =>
canUseAsKarama(g.advanced, me.faction, c),
canUseAsKaramaRole(g, me, c),
);
const use =
!!karama &&
@@ -3415,6 +3425,11 @@ export function botActions(g: GameView): Action[] {
if (field)
return [{ type: 'nexusAtreides', event: nexus.event, mode: nexus.mode, field }];
}
const shrineTruth = shrineTruthtranceBotAction(
g,
g.players.find((player) => player.id === g.me)!,
);
if (shrineTruth) return [shrineTruth];
const intelligence = tupileIntelligenceActions(g);
if (intelligence.length) return intelligence;
const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => {
+125
View File
@@ -0,0 +1,125 @@
import {
casualtyOptions,
validCombatForces,
type Casualties,
type CombatForces,
} from './combat';
import { JACURUTU_SIETCH } from './discoveries';
/** GF9 Ecaz & Moritani rulebook, p. 13. */
export const JACURUTU_BATTLE_INCOME_RULE =
'If you win a battle in Jacurutu Sietch, gain one spice for each opposing undialed force sent to the Tanks.';
export type JacurutuTankLosses = Readonly<{
normal: number;
elite: number;
}>;
export type JacurutuBattleIncomeQuote =
| Readonly<{
kind: 'income';
winner: string;
opponent: string;
amount: number;
sentToTanks: JacurutuTankLosses;
dialed: readonly Casualties[];
}>
| Readonly<{
kind: 'allocationRequired';
winner: string;
opponent: string;
sentToTanks: JacurutuTankLosses;
choices: readonly Readonly<{ dialed: Casualties; amount: number }>[];
}>;
export type JacurutuBattleIncomeInput = Readonly<{
territory: string;
jacurutuRevealed: boolean;
winner: string | null;
opponent: string | null;
opponentForces: CombatForces | null;
opponentDial: number | null;
opponentSupport: number | null;
/** Actual opposing counters destroyed by this battle, after any future
* withdrawal or other non-Tanks movement has been removed from the pool. */
sentToTanks: JacurutuTankLosses | null;
}>;
export class JacurutuBattleIncomeError extends Error {
constructor(message: string) {
super(message);
this.name = 'JacurutuBattleIncomeError';
}
}
function whole(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0;
}
/**
* Quotes the automatic bank income from exact settled-battle evidence.
*
* A numeric Battle Wheel dial is not a physical counter count in Advanced
* combat. When different legal ordinary/elite dial allocations would change
* the income, the quote preserves those choices instead of inventing one.
*/
export function quoteJacurutuBattleIncome(
input: JacurutuBattleIncomeInput,
): JacurutuBattleIncomeQuote | null {
if (
input.territory !== JACURUTU_SIETCH ||
!input.jacurutuRevealed ||
input.winner === null
)
return null;
const forces = input.opponentForces;
const sent = input.sentToTanks;
if (
!input.opponent ||
input.opponent === input.winner ||
!forces ||
!validCombatForces(forces) ||
input.opponentDial === null ||
input.opponentSupport === null ||
!sent ||
!whole(sent.normal) ||
!whole(sent.elite) ||
sent.normal > forces.normal ||
sent.elite > forces.elite
)
throw new JacurutuBattleIncomeError(
'Jacurutu income needs the opposing plan and exact forces sent to the Tanks.',
);
const dialed = casualtyOptions(
forces,
input.opponentDial,
input.opponentSupport,
).filter(
(choice) => choice.normal <= sent.normal && choice.elite <= sent.elite,
);
if (!dialed.length)
throw new JacurutuBattleIncomeError(
'Jacurutu income has no physical dial allocation matching the settled Tanks losses.',
);
const choices = dialed.map((choice) => ({
dialed: { ...choice },
amount: sent.normal + sent.elite - choice.normal - choice.elite,
}));
const amounts = new Set(choices.map((choice) => choice.amount));
if (amounts.size > 1)
return {
kind: 'allocationRequired',
winner: input.winner,
opponent: input.opponent,
sentToTanks: { ...sent },
choices,
};
return {
kind: 'income',
winner: input.winner,
opponent: input.opponent,
amount: choices[0].amount,
sentToTanks: { ...sent },
dialed: choices.map((choice) => choice.dialed),
};
}
+25
View File
@@ -0,0 +1,25 @@
import type { Action, GameView } from './engine';
/** Both human controls and bots use only the owner's projected storm offer. */
export function discoveryStormActions(game: GameView): Action[] {
const decision = game.decision,
offer = game.ecologicalStorm;
if (
game.status !== 'playing' ||
decision?.kind !== 'ecologicalStorm' ||
decision.player !== game.me ||
!offer ||
offer.owner !== game.me ||
offer.event !== decision.event ||
offer.blocked
)
return [];
// Keeping the ordinary movement is the conservative first prototype policy.
return [...offer.options]
.sort((a, b) => Math.abs(a.delta) - Math.abs(b.delta))
.map((option) => ({
type: 'decision',
event: offer.event,
delta: option.delta,
}));
}
+250
View File
@@ -0,0 +1,250 @@
import { fighterCount } from './advisors';
import type { Game } from './engine';
import { isDiscoveryTokenId, type DiscoveryOpaqueTokenId } from './discoveries';
export class DiscoveryStormError extends Error {}
const whole = (n: unknown): n is number =>
Number.isSafeInteger(n) && (n as number) >= 0;
const record = (x: unknown): x is Record<string, unknown> =>
!!x && typeof x === 'object' && !Array.isArray(x);
function need(x: unknown, message: string): asserts x {
if (!x) throw new DiscoveryStormError(message);
}
export type StormMovementSource = {
turn: number;
kind: 'dials' | 'card' | 'weather';
distance: number;
signature: string;
};
export type DiscoveryStorm = {
event: string;
turn: number;
owner: string;
token: DiscoveryOpaqueTokenId;
from: number;
base: number;
delta: -1 | 0 | 1 | null;
stage: 'choose' | 'traversal' | 'complete';
signature: string;
};
export type DiscoveryStormContext = Pick<
Game,
| 'status'
| 'turn'
| 'phase'
| 'storm'
| 'stormPending'
| 'discoveryEnabled'
| 'discoveries'
| 'players'
> & { stormMovementSource?: StormMovementSource };
export function stormSourceSignature(
source: Omit<StormMovementSource, 'signature'>,
) {
return JSON.stringify([source.turn, source.kind, source.distance]);
}
export function createStormSource(
turn: number,
kind: StormMovementSource['kind'],
distance: number,
): StormMovementSource {
const source = { turn, kind, distance, signature: '' };
source.signature = stormSourceSignature(source);
validateStormSource(source);
return source;
}
export function validateStormSource(source: StormMovementSource) {
need(
record(source) &&
Object.keys(source).sort().join(',') === 'distance,kind,signature,turn' &&
whole(source.turn) &&
source.turn > 0 &&
['dials', 'card', 'weather'].includes(source.kind) &&
whole(source.distance) &&
source.distance <= 40 &&
source.signature === stormSourceSignature(source),
'The storm has lost its original movement source.',
);
}
export function discoveryStormSignature(
frame: Omit<DiscoveryStorm, 'signature'>,
) {
return JSON.stringify([
frame.event,
frame.turn,
frame.owner,
frame.token,
frame.from,
frame.base,
frame.delta,
frame.stage,
]);
}
export function discoveryStormOffer(g: DiscoveryStormContext) {
const token =
g.discoveryEnabled &&
g.discoveries?.tokens.find(
(t) =>
t.face === 'ecological-testing-station' &&
t.status === 'placed' &&
t.revealedTurn !== null,
);
if (!token) return null;
const occupants = g.players.filter(
(p) => fighterCount(p, 'ecological-testing-station') > 0,
);
if (occupants.length !== 1) return null;
const owner = occupants[0].id,
source = g.stormMovementSource;
if (source) validateStormSource(source);
let blocked: string | null = null;
if (g.status !== 'playing' || g.phase !== 0 || g.stormPending === null)
blocked = 'Wait until ordinary storm movement is revealed.';
else if (!source || source.turn !== g.turn)
blocked =
'This storm was already pending before the station became available. Its original movement is preserved.';
else if (source.kind === 'weather')
blocked = 'Ecological Testing Station cannot change Weather Control.';
else if (source.distance !== g.stormPending)
blocked =
'The current storm movement no longer matches this station opportunity.';
const base = g.stormPending ?? 0;
const options = ([-1, 0, 1] as const)
.filter((delta) => base + delta >= 0 && base + delta <= 40)
.map((delta) => ({ delta, distance: base + delta }));
return { owner, token: token.id, base, options, blocked };
}
export function createDiscoveryStorm(
g: DiscoveryStormContext,
event: string,
): DiscoveryStorm {
const offer = discoveryStormOffer(g);
need(
offer && !offer.blocked && typeof event === 'string' && event.length > 0,
offer?.blocked ?? 'No sole occupant may adjust this storm.',
);
const frame: DiscoveryStorm = {
event,
turn: g.turn,
owner: offer.owner,
token: offer.token,
from: g.storm,
base: offer.base,
delta: null,
stage: 'choose',
signature: '',
};
frame.signature = discoveryStormSignature(frame);
validateDiscoveryStorm(g, frame);
return frame;
}
export function validateDiscoveryStorm(
g: DiscoveryStormContext,
frame: DiscoveryStorm,
) {
need(
record(frame) &&
Object.keys(frame).sort().join(',') ===
'base,delta,event,from,owner,signature,stage,token,turn' &&
typeof frame.event === 'string' &&
frame.event.length > 0 &&
typeof frame.owner === 'string' &&
g.players.some((p) => p.id === frame.owner) &&
isDiscoveryTokenId(frame.token) &&
whole(frame.turn) &&
frame.turn > 0 &&
frame.turn <= g.turn &&
whole(frame.from) &&
frame.from <= 18 &&
whole(frame.base) &&
frame.base <= 40 &&
['choose', 'traversal', 'complete'].includes(frame.stage) &&
(frame.stage === 'choose'
? frame.delta === null
: [-1, 0, 1].includes(frame.delta as number) &&
frame.base + frame.delta! >= 0 &&
frame.base + frame.delta! <= 40) &&
frame.signature === discoveryStormSignature(frame),
'The station has lost its signed storm choice.',
);
need(
g.discoveryEnabled &&
g.discoveries?.tokens.some(
(token) =>
token.id === frame.token &&
token.face === 'ecological-testing-station' &&
token.status === 'placed' &&
token.revealedTurn !== null &&
token.revealedTurn <= frame.turn,
),
'The station choice must retain its original revealed physical token.',
);
if (frame.stage === 'complete') return;
need(
g.discoveryEnabled &&
g.status === 'playing' &&
g.phase === 0 &&
g.turn === frame.turn &&
g.storm === frame.from &&
g.stormMovementSource,
'Finish the station choice with its original storm.',
);
validateStormSource(g.stormMovementSource);
need(
g.stormMovementSource.turn === frame.turn &&
g.stormMovementSource.kind !== 'weather' &&
g.stormMovementSource.distance === frame.base,
'The station cannot replace its original storm source or affect Weather Control.',
);
if (frame.stage === 'choose') {
const offer = discoveryStormOffer(g);
need(
offer &&
!offer.blocked &&
offer.owner === frame.owner &&
offer.token === frame.token &&
offer.base === frame.base,
'The station choice no longer belongs to its original occupant and storm.',
);
} else
need(
g.stormPending === frame.base + frame.delta!,
'The adjusted storm must retain the chosen distance.',
);
}
export function chooseDiscoveryStorm(
g: DiscoveryStormContext,
frame: DiscoveryStorm,
owner: string,
event: unknown,
delta: unknown,
): DiscoveryStorm {
validateDiscoveryStorm(g, frame);
need(
frame.stage === 'choose' &&
frame.owner === owner &&
frame.event === event &&
typeof delta === 'number' &&
[-1, 0, 1].includes(delta) &&
frame.base + delta >= 0 &&
frame.base + delta <= 40,
'Choose the current station opportunity: decrease one, keep the distance, or increase one.',
);
const next = {
...frame,
delta: delta as -1 | 0 | 1,
stage: 'traversal' as const,
signature: '',
};
next.signature = discoveryStormSignature(next);
return next;
}
export function finishDiscoveryStorm(frame: DiscoveryStorm): DiscoveryStorm {
need(
frame.stage === 'traversal',
'Only a committed station adjustment can complete.',
);
const next = { ...frame, stage: 'complete' as const, signature: '' };
next.signature = discoveryStormSignature(next);
return next;
}
+145 -29
View File
@@ -1,7 +1,9 @@
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight';
import { advanceDiscoveryEntry, beginDiscoveryEntryArrival, createDiscoveryEntryRound, finishDiscoveryEntryArrival, quoteDiscoveryEntry, validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound, type DiscoveryEntryArrivalChild, type DiscoveryEntryRound, type DiscoveryEntrySelection } from './discovery-entry';
import { createDiscoveryState, validateDiscoveryState, placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken, isDiscoverySpiceCardId, isDiscoveryTokenId, DISCOVERY_SPICE_CARDS, DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryState } from './discoveries';
import { quoteJacurutuBattleIncome } from './discovery-battle';
import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type GreatMaker } from './great-maker';
import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade';
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
@@ -273,7 +275,7 @@ import {
KaramaBattlePreflightError,
} from './karama-battle-preflight';
import { canceledResponseSource, KaramaContextError } from './karama-context';
import { validateSavedShipmentQuestion } from './truthtrance';
import { validateSavedTruthtrance } from './truthtrance';
import {
reorderOrderedOpportunity,
protectOrderedOpportunityLast,
@@ -424,9 +426,11 @@ import {
import {
resolveTruthAction,
validateTruthQuestionReceipt,
queuedTruthCardMatches,
truthFactAnswer,
TruthError,
type TruthWindow,
type TruthQueueEntry,
type TruthRecord,
type TruthAnswer,
} from './truthtrance';
@@ -464,7 +468,7 @@ import {
type TechId,
type TechState,
} from './tech-tokens';
import { canUseAsKarama } from './karama';
import { canUseAsKaramaRole } from './shrine';
import {
isAdvisor,
fighterCount,
@@ -755,6 +759,7 @@ export type Decision =
| { kind: 'wormProtection'; player: string; territory: string; ally: string }
| { kind: 'discoveryDiscard'; player: string; event: string }
| { kind: 'discoveryEntry'; player: string; event: string }
| { kind: 'ecologicalStorm'; player: string; event: string }
| { kind: 'greatMakerVote'; player: string; event: string }
| { kind: 'greatMakerRide'; player: string; event: string }
| { kind: 'wormRide'; player: string; territory: string }
@@ -899,6 +904,8 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
stormMovementSource?: StormMovementSource;
ecologicalStorm?: DiscoveryStorm;
discoveryEnabled?: boolean;
discoveries?: DiscoveryState;
discoveryStash?: DiscoveryStash;
@@ -939,7 +946,7 @@ export type Game = {
}
| {
kind: 'truthtranceDiscard';
consumed: { player: string; card: string };
consumed: TruthQueueEntry;
historyIndex: number;
record: TruthRecord;
remaining: TruthWindow | null;
@@ -1415,6 +1422,7 @@ export type Game = {
stormCard: number | null;
stormCardKnown: boolean;
stormResolution: {
discoveryStorm?: string;
from: number;
distance: number;
traversed: number;
@@ -3824,10 +3832,12 @@ function treacheryDiscardIntegrity(g: Game) {
requireRule(
c.consumed &&
typeof c.consumed.player === 'string' &&
(c.consumed.source === undefined || c.consumed.source === 'shrine') &&
batch.cause === 'truthtrance' &&
batch.entries.length === 1 &&
entry.publicFace &&
entry.card.effect === 'truthtrance' &&
entry.card.effect ===
(c.consumed.source === 'shrine' ? 'karama' : 'truthtrance') &&
entry.card.id === c.consumed.card &&
entry.discardedBy === c.consumed.player &&
record &&
@@ -3860,11 +3870,10 @@ function treacheryDiscardIntegrity(g: Game) {
typeof e.card === 'string' &&
e.card.length > 0 &&
typeof e.player === 'string' &&
g.players
.find((p) => p.id === e.player)
?.hand.filter(
(card) => card.id === e.card && card.effect === 'truthtrance',
).length === 1 &&
queuedTruthCardMatches(
g.players.find((p) => p.id === e.player)?.hand ?? [],
e,
) &&
outside.filter((card) => card.id === e.card).length === 1 &&
!g.discard.some((card) => card.id === e.card),
) &&
@@ -5214,9 +5223,17 @@ function stormExposed(g: Game, key: string) {
);
}
function moveStorm(g: Game, n: number) {
g.stormResolution = { from: g.storm, distance: n, traversed: 0, pending: [] };
g.stormResolution = { from: g.storm, distance: n, traversed: 0, pending: [],
...(g.ecologicalStorm?.stage === 'traversal' ? {discoveryStorm:g.ecologicalStorm.signature} : {}) };
offerChoamStorm(g);
}
function startStormMovement(g: Game, n: number) {
const offer = nexusRule(() => discoveryStormOffer(g));
if (offer && !offer.blocked) {
g.ecologicalStorm = nexusRule(() => createDiscoveryStorm(g,crypto.randomUUID()));
g.decision = {kind:'ecologicalStorm',player:offer.owner,event:g.ecologicalStorm.event};
} else moveStorm(g,n);
}
function choamStormOptions(g: Game) {
return choamStormTerritories(g);
}
@@ -5344,6 +5361,9 @@ function continueStorm(g: Game) {
for (const key of Object.keys(g.spice))
if (splitLocation(key).sector === s) delete g.spice[key];
}
if (g.ecologicalStorm?.stage === 'traversal')
g.ecologicalStorm = nexusRule(() => finishDiscoveryStorm(g.ecologicalStorm!));
delete g.stormMovementSource;
g.storm = ((resolution.from - 1 + resolution.distance) % 18) + 1;
stormOrder(g);
if (g.turn === 1) assignRemainingTech(g);
@@ -5488,6 +5508,7 @@ function afterWorm(g: Game) {
);
}
function discoveryIntegrity(g: Game) {
ecologicalStormIntegrity(g);
discoveryEntryIntegrity(g);
if (!g.discoveryEnabled) {
requireRule(!g.discoveries && !g.discoveryStash, 'Discovery custody requires its enabled module.');
@@ -5541,6 +5562,33 @@ function discoveryIntegrity(g: Game) {
if (pending) requireRule(pending.event === record.event && pending.owner === p.id && pending.card === record.discarded,
'The stash has changed its committed discard.');
}
function ecologicalStormIntegrity(g: Game) {
const frame = g.ecologicalStorm, continuation = g.pendingTreacheryDiscard?.continuation;
if (g.stormMovementSource) {
nexusRule(() => validateStormSource(g.stormMovementSource!));
const source = g.stormMovementSource;
requireRule(g.status === 'playing' && g.phase === 0 && source.turn === g.turn &&
g.stormPending === (frame?.stage === 'traversal' ? source.distance+frame.delta! : source.distance),
'The storm must retain its current turn and original movement source.');
}
const controls = [g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,
g.pendingRichesePurchaseIncome?.resume,continuation && 'resume' in continuation ? continuation.resume : null];
const choices = controls.flatMap(c => c?.decision?.kind === 'ecologicalStorm' ? [c.decision] : []);
if (!frame) {
requireRule(!choices.length && !g.stormResolution?.discoveryStorm,'The station storm has lost its original choice.');
return;
}
nexusRule(() => validateDiscoveryStorm(g,frame));
if (frame.stage === 'choose') requireRule(choices.length === 1 && choices[0].player === frame.owner && choices[0].event === frame.event &&
!g.stormResolution && g.ready.length === g.players.length, 'The station has lost its owned choice before storm movement.');
else {
requireRule(!choices.length,'A completed station choice cannot remain open.');
if (frame.stage === 'traversal') requireRule(g.stormResolution && g.stormResolution.discoveryStorm === frame.signature &&
g.stormResolution.from === frame.from && g.stormResolution.distance === frame.base+frame.delta!,
'The storm traversal has lost its exact station adjustment.');
else requireRule(!g.stormResolution?.discoveryStorm,'The completed station choice cannot retain a traversal.');
}
}
function discoveryEntryIntegrity(g: Game) {
const frame = g.discoveryEntry;
const continuation = g.pendingTreacheryDiscard?.continuation;
@@ -6769,7 +6817,7 @@ function karamaSpendingBlock(g: Game, p: Player, card: Card): string | null {
return 'This card is reserved for the pending Richese gift.';
if (
!p.hand.some((held) => held.id === card.id) ||
!canUseAsKarama(g.advanced, p.faction, card)
!canUseAsKaramaRole(g, p, card)
)
return 'Choose a Karama in your hand.';
if (retentionReservesCard(g, p.id, card.id))
@@ -6787,7 +6835,7 @@ function karamaCard(g: Game, p: Player, id?: unknown) {
// Explicit selections retain the specific custody error from spendKarama.
const available = p.hand.filter(
(c) =>
canUseAsKarama(g.advanced, p.faction, c) &&
canUseAsKaramaRole(g, p, c) &&
(id === undefined ? !karamaSpendingBlock(g, p, c) : c.id === id),
);
return available.find((c) => c.effect === 'karama') ?? available[0];
@@ -7371,19 +7419,20 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) {
g,
`${p.name} used ${card.name} as Karama; the faction power awaits responses.`,
);
} else completeKarama(g, p, use);
} else completeKarama(g, p, use, card);
}
function completeKarama(g: Game, p: Player, use: KaramaUse) {
function completeKarama(g: Game, p: Player, use: KaramaUse, card?: Card) {
validateKaramaUse(g, p, use);
const played = card?.effect === 'truthtrance' ? 'Truthtrance as Karama' : 'Karama';
if (use.kind === 'cancel') {
g.response = use.response;
finishResponse(g, true);
log(g, `${p.name} used Karama to cancel ${use.response.kind}.`);
log(g, `${p.name} used ${played} to cancel ${use.response.kind}.`);
} else if (use.kind === 'shipment') {
g.karamaShipping = { player: use.recipient, owner: p.id, card: use.card };
log(
g,
`${p.name} used Karama for ${getPlayer(g, use.recipient).name}’s shipment at Guild rates, paid to the bank.`,
`${p.name} used ${played} for ${getPlayer(g, use.recipient).name}’s shipment at Guild rates, paid to the bank.`,
);
} else {
if (use.kind === 'purchase') g.auction!.bidder = p.id;
@@ -11425,6 +11474,7 @@ function beginStormTurn(g: Game) {
if (g.advanced && byFaction(g, 'fremen')) {
g.stormDialers = [];
g.stormPending = g.stormCard ?? shuffle([1, 2, 3, 4, 5, 6])[0];
g.stormMovementSource = nexusRule(() => createStormSource(g.turn,'card',g.stormPending!));
g.stormCard = null;
g.stormCardKnown = false;
log(g, `Storm card revealed: ${g.stormPending} sectors.`);
@@ -12863,7 +12913,7 @@ function cashInPreparationActions(g: Game, p: Player): Action[] {
if (p.faction !== 'choam' || !g.advanced || p.specialKaramaUsed) return [];
const available = cashInCards(g, p);
const actions: Action[] = [];
for (const card of available.filter((c) => c.effect === 'karama')) {
for (const card of available.filter((c) => canUseAsKaramaRole(g, p, c))) {
const others = available.filter((c) => c.id !== card.id);
for (let mask = 1; mask < 2 ** others.length; mask++)
actions.push({
@@ -12958,7 +13008,7 @@ function findReachableBattlePlan(
}
function shipmentPromiseIntegrity(g: Game) {
try {
validateSavedShipmentQuestion(g);
validateSavedTruthtrance(g);
} catch (error) {
if (error instanceof TruthError) throw new RuleError(error.message);
throw error;
@@ -13152,7 +13202,7 @@ function findShipmentCompletion(
const preparations: Action[] = [];
if (g.aid[p.id]?.amount > 0)
preparations.push({ type: 'pledgeAid', amount: 0 });
const karama = p.hand.find((c) => canUseAsKarama(g.advanced, p.faction, c));
const karama = p.hand.find((c) => canUseAsKaramaRole(g, p, c));
if (karama && !g.karamaShipping)
preparations.push({
type: 'card',
@@ -13696,6 +13746,34 @@ function resolveBattle(g: Game) {
const stoneResult = quote.stone;
const ac = quote.attackerTraitor,
dc = quote.defenderTraitor;
const losingPlayer = quote.winner
? quote.winner === a.id
? d
: a
: null;
const losingForces = losingPlayer
? combatForces(g, losingPlayer, b.territory, losingPlayer === a ? d : a)
: null;
const losingPlan = losingPlayer ? b.plans[losingPlayer.id] : null;
const jacurutuIncome = quoteJacurutuBattleIncome({
territory: b.territory,
jacurutuRevealed:
g.discoveryEnabled === true &&
!!g.discoveries?.tokens.some(
(token) =>
token.face === 'jacurutu-sietch' &&
token.status === 'placed' &&
token.revealedTurn !== null,
),
winner: quote.winner,
opponent: losingPlayer?.id ?? null,
opponentForces: losingForces,
opponentDial: losingPlan?.dial ?? null,
opponentSupport: losingPlan?.support ?? null,
sentToTanks: losingForces
? { normal: losingForces.normal, elite: losingForces.elite }
: null,
});
for (const revelation of quote.revelations) {
const holder = getPlayer(g, revelation.player),
identity = revelation.identity;
@@ -13805,6 +13883,22 @@ function resolveBattle(g: Game) {
: undefined,
);
}
if (jacurutuIncome?.kind === 'income' && jacurutuIncome.amount > 0) {
winner!.spice += jacurutuIncome.amount;
log(
g,
`${winner!.name} gained ${jacurutuIncome.amount} spice from the bank at Jacurutu Sietch for ${jacurutuIncome.amount} opposing undialed force${jacurutuIncome.amount === 1 ? '' : 's'} sent to the Tanks.`,
{ faction: winner!.faction, name: 'Jacurutu Sietch income' },
);
} else if (jacurutuIncome?.kind === 'allocationRequired') {
const amounts = [
...new Set(jacurutuIncome.choices.map((choice) => choice.amount)),
].sort((left, right) => left - right);
log(
g,
`Jacurutu Sietch income remains unpaid: ${losingPlayer!.name}'s revealed dial and support permit ${amounts.join(' or ')} undialed physical forces among the counters sent to the Tanks. The physical dial allocation needs a player choice.`,
);
}
for (const income of quote.strongholdIncome) {
const player = getPlayer(g, income.player);
player.spice += income.amount;
@@ -16102,7 +16196,7 @@ function homeworldShipmentAutomatic(g: Game): boolean {
homeworldShipmentIntegrity(g);
const guild = getPlayer(g, decision.player);
return !guild.hand.some((card) => {
if (card.effect !== 'karama') return false;
if (!canUseAsKaramaRole(g, guild, card)) return false;
try { prepareSpecialKaramaIntent(g, guild.id, {type: 'card', mode: 'special', card: card.id}); return true; }
catch (error) { if (error instanceof RuleError) return false; throw error; }
});
@@ -17470,7 +17564,7 @@ export function prepareSpecialKaramaIntent(
'Your special Karama power has already been used this game.',
);
const card = p.hand.find(
(c) => c.id === action.card && c.effect === 'karama',
(c) => c.id === action.card && canUseAsKaramaRole(g, p, c),
);
requireRule(card, 'Choose a Karama card in your hand.');
const base = { owner: p.id, card: card.id, turn: g.turn, phase: g.phase };
@@ -17538,7 +17632,9 @@ export function prepareSpecialKaramaIntent(
!g.auction ||
g.auction.bidder !== p.id ||
g.auction.bid <= p.spice + selected.length * 3 + credit ||
p.hand.some((c) => c.effect === 'karama' && !removed.has(c.id)),
p.hand.some(
(c) => canUseAsKaramaRole(g, p, c) && !removed.has(c.id),
),
'Retain enough spice or Karama to honor your current winning bid.',
);
return { ...base, kind: 'choam', cards: [...selected] };
@@ -17784,7 +17880,13 @@ export function executeSpecialKaramaIntent(
'The original battle is no longer awaiting special prescience.',
);
const p = getPlayer(g, intent.owner);
const card = { id: intent.card };
const card = p.hand.find((candidate) => candidate.id === intent.card)!;
const converted = card.effect === 'truthtrance';
if (converted)
log(
g,
`${p.name} used the physical Truthtrance as Karama for their special faction power.`,
);
if (intent.kind === 'richese') {
const selected = g.richeseCache!.findIndex((c) => c.id === intent.acquire);
discard(g, p, card.id);
@@ -17995,7 +18097,7 @@ function richeseSpecialKaramaView(g: Game, p: Player) {
return null;
const karamas = p.hand.filter(
(c) =>
c.effect === 'karama' &&
canUseAsKaramaRole(g, p, c) &&
!karamaSpendingBlock(g, p, c) &&
!(
g.richeseAuction?.source === 'blackMarket' &&
@@ -18466,6 +18568,7 @@ function applyActionInner(
bindShipment: bindShipmentTruth,
battleAnswers: battleTruthAnswers,
bindBattle: bindBattleTruth,
cardBlock: transferCardBlock,
})
)
return g;
@@ -19733,6 +19836,13 @@ function applyActionInner(
decideDiscoveryDiscard(g,p,action);
} else if (decision.kind === 'discoveryEntry') {
decideDiscoveryEntry(g,p,action);
} else if (decision.kind === 'ecologicalStorm') {
requireRule(g.ecologicalStorm && Object.keys(action).every(key => ['type','event','delta'].includes(key)),
'Choose only the current station storm adjustment.');
g.ecologicalStorm = nexusRule(() => chooseDiscoveryStorm(g,g.ecologicalStorm!,p.id,action.event,action.delta));
g.stormPending = g.ecologicalStorm.base+g.ecologicalStorm.delta!;
log(g, `${p.name} ${g.ecologicalStorm.delta === 0 ? 'kept' : g.ecologicalStorm.delta === 1 ? 'increased' : 'decreased'} storm movement ${g.ecologicalStorm.delta === 0 ? 'at' : 'by one, to'} ${g.stormPending} sectors using Ecological Testing Station.`, {faction:p.faction,name:'Ecological Testing Station'});
moveStorm(g,g.stormPending);
} else if (decision.kind === 'greatMakerVote') {
decideGreatMakerVote(g,p,action);
} else if (decision.kind === 'greatMakerRide') {
@@ -20163,6 +20273,7 @@ function applyActionInner(
);
if (g.stormDialers.every((x) => g.stormDials[x] !== undefined)) {
g.stormPending = Object.values(g.stormDials).reduce((a, b) => a + b, 0);
g.stormMovementSource = nexusRule(() => createStormSource(g.turn,'dials',g.stormPending!));
g.ready = [];
log(
g,
@@ -21543,11 +21654,11 @@ function applyActionInner(
if (t === 'card') {
const c = p.hand.find((c) => c.id === action.card);
requireRule(
c && (c.kind === 'special' || canUseAsKarama(g.advanced, p.faction, c)),
c && (c.kind === 'special' || canUseAsKaramaRole(g, p, c)),
'Select a special card in your hand.',
);
if (
canUseAsKarama(g.advanced, p.faction, c) &&
canUseAsKaramaRole(g, p, c) &&
action.mode === 'shipment'
) {
const recipient = getPlayer(g, stringField(action.target ?? id));
@@ -21563,7 +21674,7 @@ function applyActionInner(
return g;
}
if (
canUseAsKarama(g.advanced, p.faction, c) &&
canUseAsKaramaRole(g, p, c) &&
action.mode === 'purchase'
) {
requireRule(
@@ -21640,6 +21751,7 @@ function applyActionInner(
} else if (c.effect === 'weather') {
// The shared availability check validated this exact numeric selection.
g.stormPending = action.amount as number;
g.stormMovementSource = nexusRule(() => createStormSource(g.turn,'weather',g.stormPending!));
g.ready = [];
} else if (c.effect === 'atomics') {
g.shieldWallDestroyed = true;
@@ -21671,7 +21783,7 @@ function applyActionInner(
requireRule(!g.ready.includes(id), 'You are already ready.');
g.ready.push(id);
if (g.ready.length === g.players.length) {
if (g.phase === 0) moveStorm(g, g.stormPending!);
if (g.phase === 0) startStormMovement(g, g.stormPending!);
else if (g.phase === 1 && g.spiceWindow) finishSpiceWindow(g);
else if (g.phase === 1 && (!g.nexus || g.summonedBeforeBlow))
blowSpice(g);
@@ -21978,6 +22090,8 @@ export function viewGame(state: Game, id: string) {
techTokens: g.techTokens ?? null,
strongholdCards: g.strongholdCards ?? null,
discoveries: discoveryChoices(g,id,g.phase === 7 && grummanCollectionAutomatic(g)),
ecologicalStorm: g.decision?.kind === 'ecologicalStorm' && g.decision.player === id && g.ecologicalStorm?.stage === 'choose'
? {...nexusRule(() => discoveryStormOffer(g))!,event:g.ecologicalStorm.event} : null,
discoveryEntry: g.discoveryEntry?.stage === 'choose' && g.decision?.kind === 'discoveryEntry' && g.decision.player === id
? {...nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},g.discoveryEntry!,id)),event:g.decision.event} : null,
greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null,
@@ -22158,7 +22272,9 @@ export function viewGame(state: Game, id: string) {
!me.specialKaramaUsed
? {
cards: cashInCards(g, me),
karamas: cashInCards(g, me).filter((c) => c.effect === 'karama'),
karamas: cashInCards(g, me).filter((c) =>
canUseAsKaramaRole(g, me, c),
),
}
: null,
choamMarket: g.choamMarket
+16 -13
View File
@@ -3413,7 +3413,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'A development prototype connects Great Maker, six Discovery spice blows, private token inspection, stash rewards, revealed locations, next-turn entry, Cistern income and later-turn Ornithopter movement. Remaining location abilities and normal public starts stay unavailable.',
summary: 'A development prototype connects Great Maker, six Discovery spice blows, private token inspection, stash rewards, revealed locations, next-turn entry, Cistern income, Ornithopter movement, Jacurutu income, Testing Station and Shrine. Remaining interpretations and normal public starts stay gated.',
searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter',
steps: [
'The Discovery prototype starts through genuine base-faction setup in Basic or Advanced, with seven extra Spice Cards and eight physical tokens. Its normal public start gate stays closed while the remaining effects are connected and verified.',
@@ -3424,35 +3424,38 @@ export const RULE_TOPICS: RuleTopic[] = [
'Revealing Ornithopter records who carries it and the turn it was gained. On a later turn, its owner may spend it on one ordinary movement action to give that selected group a fixed range of three. It grants no extra action, adds no shipment and is removed only after the movement commits; saved arrival interactions retain the exact selected group.',
'The five revealed location tokens become separate, initially empty territories inside their surroundings. A private peek does not open the location. Normal ground movement enters through the surrounding territory, shipment uses stronghold prices, at most two factions may occupy it, and forces inside are protected from storms and sandworms. Only Jacurutu Sietch counts toward stronghold victory.',
'At the start of the next turn, before the storm and mobile stronghold movement, each eligible faction may move any positive subset of its non-advisor groups from the surrounding territory into the newly revealed location. The signed choice preserves ordinary and elite forces across sectors, normal source storm and occupancy restrictions, and costs no spice, shipment or normal movement. The printed text gives no sequence and does not restate the source-storm restriction; processing tokens in reveal order and factions in storm order while excluding storm sources are source inferences.',
'A sole occupant of Cistern receives two bank spice during Collection. The printed text does not settle which of two occupants receives this benefit, so the contested bonus is withheld without blocking the phase. Jacurutu battle income, Ecological Testing Station storm adjustment, Shrine card conversion and Orgiz collection transfer remain unavailable; Orgiz also awaits a ruling on what counts as each collected spice blow.',
'All four AI profiles use the same offered inspection, reveal, stash-discard, Great Maker, free-entry and Ornithopter choices as the player controls. Private knowledge and unfinished decisions survive saved JSON continuations. These focused paths do not establish complete variant compliance or combined-module acceptance.',
'A sole occupant of Cistern receives two bank spice during Collection. The printed text does not settle which of two occupants receives this benefit, so the contested bonus is withheld without blocking the phase. Orgiz transfer remains unavailable pending a ruling on what counts as each collected spice blow.',
'Jacurutu automatically pays one bank spice for each opposing undialed physical force sent to the Tanks. Where Advanced normal/elite allocations would produce different rewards, this prototype records the unpaid gap pending a ruling; it never guesses from the numeric wheel alone.',
'A sole non-advisor occupant of Ecological Testing Station may decrease ordinary storm movement by one, keep it, or increase it by one. Choose after the ordinary storm-card window, before movement and storm protection. Weather Control cannot be changed. Two-occupant adjustment order remains unresolved. An already saved pending storm without its original movement source keeps its distance until a new storm is produced.',
'A current non-advisor Shrine occupant may play a physical Truthtrance card as Karama, or a physical Karama as Truthtrance, within the implemented timing and effect paths. The printed identity and actual card custody remain unchanged; a committed Truthtrance conversion survives departure. This does not create missing Karama powers or settle their pending rules.',
'All four AI profiles use the same offered inspection, reveal, stash-discard, Great Maker, free-entry, Ornithopter and Testing Station choices as the player controls. Private knowledge and unfinished decisions survive saved JSON continuations. These focused paths do not establish complete variant compliance or combined-module acceptance.',
],
related: ['ecaz-modules', 'spice-blow', 'collection', 'movement', 'mentat', 'implementation-checklist'],
checklist: [
{
area: 'Implementation', status: 'Partial',
detail: 'Working prototypes cover genuine setup, physical inventory, destructive blows, Great Maker vote/ride, private inspection/reveal, stash rewards, public nested locations, signed next-turn entry, sole-occupant Cistern income and later-turn Ornithopter spending. Four location abilities remain missing.',
evidence: ['game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/discovery-flight.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'],
detail: 'Working prototypes cover genuine setup, physical inventory, destructive blows, Great Maker vote/ride, private inspection/reveal, stash rewards, public nested locations, signed next-turn entry, sole-occupant Cistern income and later-turn Ornithopter spending, unambiguous Jacurutu income, sole-occupant Testing Station and Shrine card roles. Orgiz and material interpretations remain pending.',
evidence: ['game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/discovery-flight.ts', 'game/discovery-battle.ts', 'game/discovery-storm.ts', 'game/shrine.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'],
},
{
area: 'Player controls', status: 'Partial',
detail: 'Private backs and readable authorized faces, optional inspection/reveal, owned stash discard with card inspection, ordered Great Maker controls, multi-sector free entry and carried-Ornithopter movement use shared table controls. Four location effects have no action controls.',
evidence: ['components/discoveries.tsx', 'components/discovery-entry.tsx', 'components/discovery-flight-movement.tsx', 'components/great-maker.tsx', 'components/spice-card-inspector.tsx', 'components/game-table.tsx'],
detail: 'Private backs and readable authorized faces, optional inspection/reveal, owned stash discard with card inspection, ordered Great Maker controls, multi-sector free entry and carried-Ornithopter movement use shared table controls. Testing Station offers three legal distances and Shrine uses existing card controls. Unambiguous Jacurutu income is automatic; unresolved rewards have no invented choice.',
evidence: ['components/discoveries.tsx', 'components/discovery-entry.tsx', 'components/discovery-flight-movement.tsx', 'components/discovery-storm.tsx', 'components/truthtrance.tsx', 'components/great-maker.tsx', 'components/spice-card-inspector.tsx', 'components/game-table.tsx'],
},
{
area: 'AI', status: 'Partial',
detail: 'Four profiles share the private quoted token, discard, vote, reserve-ride, free-entry and carried-Ornithopter actions. Complete Discovery strategy, combined games and strength calibration remain unfinished.',
evidence: ['game/discovery-options.ts', 'game/discovery-entry-options.ts', 'game/discovery-flight-options.ts', 'game/great-maker-options.ts', 'game/bots.ts'],
detail: 'Four profiles share the private quoted token, discard, vote, reserve-ride, free-entry, carried-Ornithopter and conservative Testing Station actions. Shrine uses the existing legal Karama and Truthtrance policies. Complete Discovery strategy, combined games and strength calibration remain unfinished.',
evidence: ['game/discovery-options.ts', 'game/discovery-entry-options.ts', 'game/discovery-flight-options.ts', 'game/discovery-storm-options.ts', 'game/shrine.ts', 'game/great-maker-options.ts', 'game/bots.ts'],
},
{
area: 'Documentation', status: 'Partial',
detail: 'Sourced identities, connected behavior and the remaining dependency queue are recorded. Great Maker first-turn/Sandtrout handling, exhausted token supply, free-entry order and source-storm handling remain identified interpretations for refinement; contested Cistern and Orgiz collection cases remain unresolved.',
detail: 'Sourced identities, connected behavior and the remaining dependency queue are recorded. Great Maker first-turn/Sandtrout handling, exhausted token supply, free-entry order and source-storm handling remain identified interpretations for refinement; contested Cistern/Testing Station, mixed-force Jacurutu and Orgiz collection cases remain unresolved.',
evidence: ['docs/DISCOVERY_COMPONENTS.md', 'docs/DISCOVERY_PROTOTYPE.md'],
},
{
area: 'Verification', status: 'Partial',
detail: 'Focused offline checks cover physical custody, private views, legal human/AI actions, immutable rejection, board admission, signed free entry, Cistern collection, carried flight and saved continuation. Broad checkpoint results and complete module/browser/multiplayer acceptance remain separate gates.',
evidence: ['tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts'],
detail: 'Focused offline checks cover physical custody, private views, legal human/AI actions, immutable rejection, board admission, signed free entry, Cistern collection, carried flight, station movement, Jacurutu income, Shrine card custody and saved continuation. Broad checkpoint results and complete module/browser/multiplayer acceptance remain separate gates.',
evidence: ['tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts'],
},
],
},
@@ -3564,7 +3567,7 @@ export const RULE_TOPICS: RuleTopic[] = [
...(id === 'ecaz-modules'
? [
'Ecaz inventory, public Ambassador inspection, end-of-Revival placement, six entry effects with Bene Gesserit copies and direct Ecaz Duke acquisition have focused support. Storm/explosion token returns are supported. Other effects and competing arrival ordering remain unfinished. See the linked feature checklist; full expansion starts remain disabled.',
'The Discovery prototype connects seven Spice Cards, eight tokens, Great Maker, private Collection inspection/reveal, stash rewards, revealed nested locations, next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement. Four location abilities and contested collection interpretations remain unfinished; see the Discovery checklist.',
'The Discovery prototype connects seven Spice Cards, eight tokens, Great Maker, private Collection inspection/reveal, stash rewards, revealed nested locations, next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement. Jacurutu income, Testing Station and Shrine now have bounded prototypes. Orgiz, mixed-force Jacurutu and contested benefits remain pending; see the Discovery checklist.',
]
: []),
'The expansion catalog is present. Expansion faction mechanics, remaining modules and their full interaction reference remain in development.',
+1
View File
@@ -62,6 +62,7 @@ const DECISIONS = {
wormProtection: true,
discoveryDiscard: true,
discoveryEntry: true,
ecologicalStorm: true,
greatMakerVote: true,
greatMakerRide: true,
wormRide: true,
+113
View File
@@ -0,0 +1,113 @@
import { fighterCount } from './advisors';
import type { Card } from './cards';
import type { FactionId } from './catalog';
import type { ForcePresence } from './force-presence';
import { canUseAsKarama } from './karama';
export const SHRINE = 'shrine';
type ShrinePlayer = {
id: string;
faction: FactionId;
advisors?: Record<string, { lockedTurn?: number }>;
} & ForcePresence;
type ShrineContext = {
advanced: boolean;
discoveries?: {
tokens: readonly {
face: string | null;
status: string;
revealedTurn: number | null;
}[];
} | null;
};
type ShrineBotContext = ShrineContext & {
status: string;
phase: number;
truthtrance?: object | null;
response?: object | null;
decision?: object | null;
phaseOpening?: object | null;
automaticContinuationPending?: boolean;
battle?: {
attacker: string;
defender: string;
revealed: boolean;
} | null;
};
/** Public board entitlement only. A private peek at Shrine does not activate it. */
export function occupiesRevealedShrine(
context: ShrineContext,
player: ShrinePlayer,
): boolean {
const revealed = context.discoveries?.tokens.some(
(token) =>
token.face === SHRINE &&
token.status === 'placed' &&
Number.isSafeInteger(token.revealedTurn) &&
(token.revealedTurn ?? 0) >= 1,
);
return !!revealed && fighterCount(player, SHRINE) > 0;
}
/** Printed identity remains unchanged; this answers only the card's role at use. */
export function canUseAsKaramaRole(
context: ShrineContext,
player: ShrinePlayer,
card: Card,
): boolean {
return (
canUseAsKarama(context.advanced, player.faction, card) ||
(card.effect === 'truthtrance' &&
occupiesRevealedShrine(context, player))
);
}
/** A committed conversion is recorded by the Truthtrance queue; this checks a new use. */
export function canUseAsTruthtranceRole(
context: ShrineContext,
player: ShrinePlayer,
card: Card,
): boolean {
return (
card.effect === 'truthtrance' ||
(card.effect === 'karama' && occupiesRevealedShrine(context, player))
);
}
/** Bounded policy: a Shrine-occupying combatant may ask through the existing AI question path. */
export function shrineTruthtranceBotAction(
context: ShrineBotContext,
player: ShrinePlayer & { hand?: readonly Card[] },
) {
const battle = context.battle;
if (
context.status !== 'playing' ||
context.phase !== 6 ||
!battle ||
battle.revealed ||
![battle.attacker, battle.defender].includes(player.id) ||
context.truthtrance ||
context.response ||
context.decision ||
context.phaseOpening ||
context.automaticContinuationPending ||
!occupiesRevealedShrine(context, player)
)
return null;
const card = player.hand?.find(
(candidate) =>
candidate.effect === 'karama' &&
canUseAsTruthtranceRole(context, player, candidate),
);
return card
? {
type: 'card',
card: card.id,
shrineTruthtrance: [card.id],
}
: null;
}
+99 -16
View File
@@ -25,6 +25,7 @@ import type { Action, Game, Player } from './engine';
import { treacheryDeck } from './cards';
import { CHEAP_HERO_TRAITOR } from './traitors';
import { DUKE_VIDAL_ID } from './duke-vidal';
import { canUseAsTruthtranceRole } from './shrine';
export type TruthFact =
| CardCountFact
@@ -44,9 +45,15 @@ export type TruthQuestion =
scope: 'fact' | 'currentTurn';
};
export type TruthAnswer = 'yes' | 'no' | 'unknown';
export type TruthQueueEntry = {
player: string;
card: string;
/** Durable authorization for a Karama committed through an occupied Shrine. */
source?: 'shrine';
};
export type TruthWindow = {
stage: 'priority' | 'ask' | 'answer' | 'unknown';
queue: { player: string; card: string }[];
queue: TruthQueueEntry[];
passed: string[];
question: TruthQuestion | null;
};
@@ -234,9 +241,33 @@ export function validateTruthQuestionReceipt(
'The completed Truthtrance question does not match its recorded opportunity.',
);
}
/** Revalidate the only new persisted future-action question before any write or projection. */
export function validateSavedShipmentQuestion(g: Game) {
/** Revalidate persisted physical card receipts before any write or projection. */
export function validateSavedTruthtrance(g: Game) {
const window = g.truthtrance;
// Older timing-only fixtures may represent the instant before the first
// declaration with an empty priority queue. No card is committed there.
if (window && window.queue.length > 0) {
check(
Array.isArray(window.queue) &&
window.queue.length > 0 &&
new Set(window.queue.map((entry) => entry?.card)).size ===
window.queue.length &&
window.queue.every((entry) => {
if (
!entry ||
typeof entry.player !== 'string' ||
typeof entry.card !== 'string' ||
(entry.source !== undefined && entry.source !== 'shrine')
)
return false;
return queuedTruthCardMatches(
g.players.find((player) => player.id === entry.player)?.hand ?? [],
entry,
);
}),
'The saved Truthtrance must retain each committed physical card in its holder’s hand.',
);
}
if (window?.question?.kind !== 'shipment') return;
check(
(window.stage === 'answer' || window.stage === 'unknown') &&
@@ -244,21 +275,33 @@ export function validateSavedShipmentQuestion(g: Game) {
window.queue.length > 0 &&
new Set(window.queue.map((entry) => entry?.card)).size ===
window.queue.length &&
window.queue.every(
(entry) =>
entry &&
typeof entry.player === 'string' &&
typeof entry.card === 'string' &&
g.players
.find((p) => p.id === entry.player)
?.hand.filter(
(c) => c.id === entry.card && c.effect === 'truthtrance',
).length === 1,
window.queue.every((entry) =>
queuedTruthCardMatches(
g.players.find((p) => p.id === entry.player)?.hand ?? [],
entry,
),
),
'The saved shipment question must retain each physical Truthtrance in its holder’s hand.',
);
parseQuestion(g, window.queue[0].player, window.question);
}
/** Historical name retained for source references and focused callers. */
export const validateSavedShipmentQuestion = validateSavedTruthtrance;
/** Receipt validation uses the printed card identity, never current Shrine occupation. */
export function queuedTruthCardMatches(
hand: readonly { id: string; effect?: string }[],
entry: TruthQueueEntry,
): boolean {
return (
hand.filter(
(card) =>
card.id === entry.card &&
card.effect === (entry.source === 'shrine' ? 'karama' : 'truthtrance'),
).length === 1
);
}
/** A fact answer never returns which disjunct matched or any underlying private cards. */
export function truthFactAnswer(
p: Pick<Player, 'hand' | 'traitors' | 'traitorChoices' | 'spice'>,
@@ -345,12 +388,23 @@ export function resolveTruthAction(
answer: boolean,
asker: string,
) => void;
cardBlock: (g: Game, p: Player, card: Player['hand'][number]) => string | null;
},
): boolean {
const selectedCard =
a.type === 'card' && typeof a.card === 'string'
? p.hand.find((card) => card.id === a.card)
: undefined;
const shrineCards = Array.isArray(a.shrineTruthtrance)
? a.shrineTruthtrance
: [];
const isPlay =
a.type === 'card' &&
!a.mode &&
p.hand.some((c) => c.id === a.card && c.effect === 'truthtrance');
!!selectedCard &&
canUseAsTruthtranceRole(g, p, selectedCard) &&
(selectedCard.effect === 'truthtrance' ||
shrineCards.includes(selectedCard.id));
if (!g.truthtrance && !isPlay && !a.type.startsWith('truth')) return false;
check(
g.status === 'playing' || g.status === 'setup',
@@ -383,12 +437,41 @@ export function resolveTruthAction(
selected.every(
(id) =>
typeof id === 'string' &&
p.hand.some((c) => c.id === id && c.effect === 'truthtrance'),
p.hand.some(
(card) =>
card.id === id &&
canUseAsTruthtranceRole(g, p, card) &&
(card.effect === 'truthtrance' || shrineCards.includes(id)),
),
),
'Each declared Truthtrance must be a different card in your hand.',
);
const selectedCards = selected.map((id) =>
p.hand.find((card) => card.id === id),
);
const reservation = selectedCards
.map((card) => (card ? effects.cardBlock(g, p, card) : null))
.find((reason): reason is string => !!reason);
check(
!reservation,
reservation ?? 'Choose uncommitted physical cards.',
);
check(
new Set(shrineCards).size === shrineCards.length &&
shrineCards.every(
(id) =>
typeof id === 'string' &&
selected.includes(id) &&
p.hand.some((card) => card.id === id && card.effect === 'karama'),
),
'Choose only selected Karama cards for the Shrine conversion.',
);
for (const card of selected)
w.queue.push({ player: p.id, card: card as string });
w.queue.push({
player: p.id,
card: card as string,
...(shrineCards.includes(card) ? { source: 'shrine' as const } : {}),
});
w.passed.push(p.id);
effects.log(
g,