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

+2 -2
View File
@@ -29,9 +29,9 @@ export function ChoamCashIn({
<label>
Karama to spend{' '}
<select value={card.id} onChange={(e) => setActivation(e.target.value)}>
{cash.karamas.map((c, i) => (
{cash.karamas.map((c) => (
<option key={c.id} value={c.id}>
Karama {i + 1}
{c.name}
</option>
))}
</select>
+1 -1
View File
@@ -92,7 +92,7 @@ export function DiscoveryPanel({ game, act, busy }: Props) {
);
})}
</div>
<p className="fine">Inspection and reveal are optional. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype. Jacurutu battle income, Ecological Testing Station, Shrine and Orgiz abilities remain in development.</p>
<p className="fine">Inspection and reveal are optional. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype. Jacurutu income, Testing Station storm choices and Shrine card conversion are prototyped. Orgiz, mixed-force Jacurutu rewards and contested Cistern/Testing Station benefits remain pending.</p>
</section>
);
}
+53
View File
@@ -0,0 +1,53 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { discoveryStormActions } from '@/game/discovery-storm-options';
import { Button } from './ui/button';
export function DiscoveryStormDecision({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const actions = discoveryStormActions(game),
offer = game.ecologicalStorm;
if (!offer || !actions.length) return null;
const me = game.players.find((player) => player.id === game.me)!;
return (
<section aria-label="Ecological Testing Station" className="space-y-3">
<p>
Your forces occupy Ecological Testing Station. The storm will move{' '}
{offer.base} sectors. You may decrease or increase this by one, or keep
the ordinary movement.
</p>
<p className="fine">This choice cannot change Weather Control.</p>
<div className="flex flex-wrap gap-2">
{offer.options.map((option) => {
const action = actions.find(
(candidate) => candidate.delta === option.delta,
)!;
return (
<Button
key={option.delta}
className="game-action"
variant={option.delta === 0 ? 'outline' : 'default'}
disabled={busy || !!me.autopilot}
onClick={() => act(action)}
>
{option.delta === -1
? 'Decrease'
: option.delta === 1
? 'Increase'
: 'Keep'}
: {option.distance} {option.distance === 1 ? 'sector' : 'sectors'}
</Button>
);
})}
</div>
</section>
);
}
+52 -23
View File
@@ -15,6 +15,7 @@ import { NexusTraitors } from './nexus-traitors';
import { GreatMakerDecision } from './great-maker';
import { DiscoveryPanel, DiscoveryDiscardDecision } from './discoveries';
import { DiscoveryEntryDecision } from './discovery-entry';
import { DiscoveryStormDecision } from './discovery-storm';
import { NexusChoamTrade } from './nexus-choam-trade';
import { NexusTleilaxu } from './nexus-tleilaxu';
import { NexusSuboids } from './nexus-suboids';
@@ -111,7 +112,10 @@ import {
import { stoneBurnerPlanBlock } from '@/game/stone-burner';
import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens';
import Link from 'next/link';
import { canUseAsKarama } from '@/game/karama';
import {
canUseAsKaramaRole,
canUseAsTruthtranceRole,
} from '@/game/shrine';
import { LeaderPortrait } from './leader-portrait';
import { ShipmentQuote } from './shipment-quote';
import { reserveShipmentCost } from '@/game/shipment-price';
@@ -295,7 +299,7 @@ export function GameTable({
bidDraft.auction === auctionKey ? bidDraft.value : minimumBid,
);
const maximumBid = me.hand?.some((c) =>
canUseAsKarama(g.advanced, me.faction, c),
canUseAsKaramaRole(g, me, c),
)
? Number.MAX_SAFE_INTEGER
: (me.spice ?? 0) + g.aid.available;
@@ -2213,6 +2217,8 @@ export function GameTable({
.kind ===
'discoveryEntry'
? 'Enter a Discovery location'
: g.decision.kind === 'ecologicalStorm'
? 'Ecological Testing Station'
: g
.decision
.kind ===
@@ -2408,7 +2414,7 @@ export function GameTable({
<p role="status">{g.revival.specialKaramaBlock}</p>
)}
{me.hand
?.filter((c) => c.effect === 'karama')
?.filter((c) => canUseAsKaramaRole(g, me, c))
.map((c) => (
<div key={c.id}>
{actionButton(
@@ -2507,7 +2513,7 @@ export function GameTable({
</p>
<HelpTip topic="fullPlan" />
{me.hand
?.filter((c) => c.effect === 'karama')
?.filter((c) => canUseAsKaramaRole(g, me, c))
.flatMap((c) =>
[g.battle!.attacker, g.battle!.defender].map((id) => (
<div key={`${c.id}-${id}`}>
@@ -2540,7 +2546,7 @@ export function GameTable({
</p>
<HelpTip topic="guildShipment" />
{actionButton('Allow Homeworld shipment', { type: 'decision', event: g.decision.event, allow: true })}
{me.hand?.filter((c) => c.effect === 'karama').map((c) => (
{me.hand?.filter((c) => canUseAsKaramaRole(g, me, c)).map((c) => (
<div key={c.id}>{actionButton('Spend Karama · stop shipment', { type: 'card', mode: 'special', card: c.id })}</div>
))}
</>
@@ -2567,7 +2573,7 @@ export function GameTable({
allow: true,
})}
{me.hand
?.filter((c) => c.effect === 'karama')
?.filter((c) => canUseAsKaramaRole(g, me, c))
.map((c) => (
<div key={c.id}>
{actionButton('Spend Karama · stop shipment', {
@@ -2872,7 +2878,7 @@ export function GameTable({
(g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available,
)}
{me.hand
?.filter((c) => canUseAsKarama(g.advanced, me.faction, c))
?.filter((c) => canUseAsKaramaRole(g, me, c))
.map((c) => (
<div key={c.id}>
{actionButton(
@@ -2950,6 +2956,8 @@ export function GameTable({
</>
) : g.decision.kind === 'discoveryEntry' ? (
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
) : g.decision.kind === 'ecologicalStorm' ? (
<DiscoveryStormDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
) : g.decision.kind === 'discoveryDiscard' ? (
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? (
@@ -3694,7 +3702,7 @@ export function GameTable({
g.mobileStronghold?.location &&
(me.forces['hidden_mobile_stronghold:0'] ?? 0) > 0 &&
me.hand
?.filter((c) => c.effect === 'karama')
?.filter((c) => canUseAsKaramaRole(g, me, c))
.slice(0, 1)
.map((c) => (
<details key={c.id}>
@@ -4607,7 +4615,7 @@ export function GameTable({
: richese?.card.effect === 'nullentropyBox'
? 'Use the Nullentropy Box panel above to begin the paid search.'
: richeseCardActionBlock(c)) ??
(canUseAsKarama(g.advanced, me.faction, c)
(canUseAsKaramaRole(g, me, c)
? g.response
? !g.responseControls?.cancelCards.includes(c.id)
? 'This card cannot cancel the current power.'
@@ -4705,9 +4713,12 @@ export function GameTable({
? '◇'
: '†'}
</span>
{c.effect === 'truthtrance' && (
{canUseAsTruthtranceRole(g, me, c) && (
<>
<p className="fine">
{c.effect === 'karama'
? 'Shrine lets you use this physical Karama as Truthtrance while you occupy it. '
: ''}
Ask another player a public yes/no question, including
during another player’s decision. Competing plays
follow storm order.
@@ -4719,12 +4730,22 @@ export function GameTable({
!!g.truthtrance ||
!['setup', 'playing'].includes(g.status)
}
onClick={() => act({ type: 'card', card: c.id })}
onClick={() =>
act({
type: 'card',
card: c.id,
...(c.effect === 'karama'
? { shrineTruthtrance: [c.id] }
: {}),
})
}
>
Declare Truthtrance
{c.effect === 'karama'
? 'Use Karama as Truthtrance'
: 'Declare Truthtrance'}
</Button>
{(me.hand?.filter(
(other) => other.effect === 'truthtrance',
(other) => canUseAsTruthtranceRole(g, me, other),
).length ?? 0) > 1 && (
<Button
variant="outline"
@@ -4740,10 +4761,17 @@ export function GameTable({
cards: me
.hand!.filter(
(other) =>
other.effect === 'truthtrance' &&
canUseAsTruthtranceRole(g, me, other) &&
other.id !== c.id,
)
.map((other) => other.id),
shrineTruthtrance: me
.hand!.filter(
(other) =>
canUseAsTruthtranceRole(g, me, other) &&
other.effect === 'karama',
)
.map((other) => other.id),
})
}
>
@@ -4819,7 +4847,7 @@ export function GameTable({
</>
)}
{g.advanced &&
c.effect === 'karama' &&
canUseAsKaramaRole(g, me, c) &&
!me.specialKaramaUsed &&
me.faction === 'harkonnen' &&
g.phase === 3 && (
@@ -4875,7 +4903,7 @@ export function GameTable({
</>
)}
{g.advanced &&
c.effect === 'karama' &&
canUseAsKaramaRole(g, me, c) &&
!me.specialKaramaUsed &&
me.faction === 'fremen' &&
g.phase === 1 && (
@@ -4926,7 +4954,7 @@ export function GameTable({
</>
)}
{g.advanced &&
c.effect === 'karama' &&
canUseAsKaramaRole(g, me, c) &&
!me.specialKaramaUsed &&
me.faction === 'emperor' &&
g.phase === 4 && (
@@ -5007,7 +5035,7 @@ export function GameTable({
/>
</label>
)}
{canUseAsKarama(g.advanced, me.faction, c) &&
{canUseAsKaramaRole(g, me, c) &&
g.phase === 5 &&
g.active &&
(g.active === me.id ? shipmentAvailable : !g.players.find((p) => p.id === g.active)?.shipped) && (
@@ -5033,9 +5061,10 @@ export function GameTable({
{g.players.find((p) => p.id === g.active)?.name}
</Button>
)}
{c.effect !== 'truthtrance' &&
{(c.effect !== 'truthtrance' ||
canUseAsKaramaRole(g, me, c)) &&
(c.kind === 'special' ||
canUseAsKarama(g.advanced, me.faction, c)) && (
canUseAsKaramaRole(g, me, c)) && (
<Button
variant="outline"
aria-describedby={
@@ -5052,7 +5081,7 @@ export function GameTable({
!(
g.status === 'setup' &&
g.response &&
canUseAsKarama(g.advanced, me.faction, c)
canUseAsKaramaRole(g, me, c)
)) ||
(!!g.phaseOpening &&
(c.effect !== 'amal' ||
@@ -5068,7 +5097,7 @@ export function GameTable({
type: 'card',
elite: eliteAmount,
card: c.id,
mode: canUseAsKarama(g.advanced, me.faction, c)
mode: canUseAsKaramaRole(g, me, c)
? g.response
? 'cancel'
: 'purchase'
@@ -5083,7 +5112,7 @@ export function GameTable({
})
}
>
{canUseAsKarama(g.advanced, me.faction, c)
{canUseAsKaramaRole(g, me, c)
? g.response
? 'Cancel this power'
: 'Take current auction card'
+27 -18
View File
@@ -16,6 +16,7 @@ import {
type TruthQuestion,
} from '@/game/truthtrance';
import type { Action, GameView } from '@/game/engine';
import { canUseAsTruthtranceRole } from '@/game/shrine';
export function Truthtrance({
game: g,
@@ -29,6 +30,20 @@ export function Truthtrance({
const w = g.truthtrance!;
const me = g.players.find((p) => p.id === g.me)!;
const current = w.queue[0];
const truthCards =
me.hand?.filter((card) => canUseAsTruthtranceRole(g, me, card)) ?? [];
const declaration = (cards: typeof truthCards): Action => ({
type: 'card',
card: cards[0].id,
...(cards.length > 1 ? { cards: cards.slice(1).map((card) => card.id) } : {}),
...(cards.some((card) => card.effect === 'karama')
? {
shrineTruthtrance: cards
.filter((card) => card.effect === 'karama')
.map((card) => card.id),
}
: {}),
});
const [target, setTarget] = useState(
g.players.find((p) => p.id !== me.id)!.id,
);
@@ -135,27 +150,21 @@ export function Truthtrance({
</ul>
{!w.passed.includes(me.id) && (
<>
{me.hand
?.filter((c) => c.effect === 'truthtrance')
.slice(0, 1)
.map((c) => (
{truthCards.map((c) => (
<div key={c.id}>
{button('Declare my Truthtrance', {
type: 'card',
card: c.id,
})}
{button(
c.effect === 'karama'
? 'Use my Karama as Truthtrance'
: 'Declare my Truthtrance',
declaration([c]),
)}
</div>
))}
{(me.hand?.filter((c) => c.effect === 'truthtrance').length ??
0) > 1 &&
button('Declare both Truthtrances', {
type: 'card',
card: me.hand!.find((c) => c.effect === 'truthtrance')!.id,
cards: me
.hand!.filter((c) => c.effect === 'truthtrance')
.slice(1)
.map((c) => c.id),
})}
{truthCards.length > 1 &&
button(
`Declare all ${truthCards.length} as Truthtrance`,
declaration(truthCards),
)}
{button('Pass Truthtrance priority', { type: 'truthPass' })}
</>
)}
+16 -15
View File
@@ -4,17 +4,18 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid
## Evidence and source of truth
- The current [Discovery prototype](DISCOVERY_PROTOTYPE.md) adds
signed next-turn entry, sole-occupant Cistern income and later-turn carried
Ornithopter movement to the first batch. Focused lifecycle, engine, recovery,
private control and AI paths are present. Targeted browser room `CA782FQE`
verified partial free entry and a later range-three token move from a genuine
Discovery setup after QA state positioning; it was not an unmodified full-game
journey. Refresh and a subsequent power-outage restart restored its forces,
private hand, spice and spent token; all 157 saved rooms survived unchanged.
Required broad results are recorded in the checkpoint commit. Four location abilities
remain unavailable, and contested Cistern and Orgiz collection interpretations
remain unresolved. This is still **Prototyped**, not complete-module verification.
- The current [Discovery prototype](DISCOVERY_PROTOTYPE.md) adds unambiguous
Jacurutu battle income, sole-occupant Ecological Testing Station adjustment and
Shrine card conversion. They connect engine effects, private controls, legal
AI and saved continuation. Orgiz, mixed-force Jacurutu payouts and contested
Cistern/Testing Station benefits remain pending. Required broad checkpoint
results are recorded in the commit; this is **Prototyped**, not full compliance.
- Discovery checkpoint `ab0ab52` added signed next-turn entry, sole-occupant
Cistern income and later-turn carried Ornithopter movement. It passed **4,313
offline tests**, build and **40 HTTP tests**, preserving all 156 opening rooms
(187 after QA/checks). Browser room `CA782FQE` exercised free entry and carried
flight; refresh and recovery from a user-confirmed power outage retained the
private hand, forces, spice and spent token. Those were targeted scenarios.
- [First Discovery prototype](DISCOVERY_PROTOTYPE.md): genuine base-faction
Basic/Advanced setup, seven Spice Cards, eight tokens, Great Maker, Collection
inspection/rewards and revealed nested locations are connected. Checkpoint
@@ -54,7 +55,7 @@ restoration after necessary restarts. No recurring automation is installed.
| --- | --- | --- |
| Basic core | Playable loop; shared setup, Fremen movement cancellation, structured Truthtrance facts and bounded Basic/no-Guild Advanced reserve-shipment promises. | Ordinary Guild rate/transport cancellation; wider truthful questions/action commitments; integrated rules and human/AI acceptance. |
| Six-faction Advanced | Genuine offline setup across 456 configurations; four-profile full-game simulations and JSON continuations. | Base gaps plus provisional special-Karama outcomes, remaining timing audits and authentic multiplayer/browser acceptance. Public starts remain disabled. |
| Twelve factions and optional modules | Many bounded faction, card, Homeworld and Nexus slices have controls, bots and recovery evidence; Discovery now includes free location entry, sole-occupant Cistern income and later-turn carried Ornithopter movement. | Every unfinished effect and interaction, occupation entitlement, four remaining Discovery location abilities, contested collection rulings and complete combined games. Inventory or fixture coverage does not enable a module. |
| Twelve factions and optional modules | Many bounded faction, card, Homeworld and Nexus slices have controls, bots and recovery evidence; Discovery includes free entry, Cistern, carried flight and bounded Jacurutu, Testing Station and Shrine effects. | Every unfinished effect and interaction, occupation entitlement, Orgiz, mixed physical dial allocation and contested Discovery benefits and complete combined games. Inventory or fixture coverage does not enable a module. |
| AI difficulty | Four profiles consume private legal views; recorded Basic and Advanced studies complete. | Demonstrate a consistent Medium → Hard → Brutal strength ordering, improve strategic gaps, and repeat targeted studies after relevant policy/rule changes. |
| Multiplayer | Versioned writes, saved seats, owner recovery kits, uncertain-request retry and voluntary own-seat AI control. | Intentional transfer, unprepared abandoned-seat recovery, unattended continuation and remaining network-failure acceptance. Preserve privacy and existing games. |
| Player experience | Internal component inspectors and many desktop/phone feature journeys verified. | Complete end-to-end games, accessible controls, responsive layout, clear rules/errors and recovery across all supported combinations. |
@@ -75,9 +76,9 @@ now connects the [sourced components](DISCOVERY_COMPONENTS.md) to Great Maker,
destructive Discovery blows, private inspection/reveal, stash rewards, carried
Ornithopter custody and later-turn movement, five revealed nested locations,
signed free entry on the following turn and sole-occupant Cistern income. Only
Jacurutu counts toward stronghold victory. Next dependencies are Jacurutu battle
income, Ecological Testing Station, Shrine and Orgiz, plus unresolved contested
Cistern and collected-spice-blow interpretations. Complete variant acceptance
Jacurutu counts toward stronghold victory. Jacurutu income, Testing Station and Shrine now have bounded connected
prototypes. Next dependencies are Orgiz and the unresolved contested benefits,
physical dial allocation and collected-spice-blow interpretations. Complete variant acceptance
and normal public starts remain gated.
The user changed the development order on 13 September: get working prototypes
+63 -11
View File
@@ -1,6 +1,6 @@
# Discovery functional prototype
13 September 2026. The first connected Discovery batch is **Prototyped**, with
13 September 2026. The connected Discovery batches are **Prototyped**, with
partial rules coverage. It connects physical components, real actions, private
controls, AI choices and saved continuation. Normal public Discovery and
Advanced start gates, and the publication gate, remain closed.
@@ -73,7 +73,23 @@ to redeal a started game. The default `ix` profile remains available.
factions occupy Cistern, the unresolved benefit is withheld without blocking
Collection; this boundary does not decide which occupant should receive it.
Great Maker vote/ride, Discovery token/discard, next-turn entry and carried
- **Jacurutu:** winning a battle here automatically grants one bank spice per
opposing undialed physical force sent to the Tanks. Winner casualties and
pre-existing Tanks do not count. Ordinary and unambiguous Advanced plans are
playable. Mixed normal/elite plans with different possible physical counts
leave an explicit unpaid gap pending the allocation ruling below.
- **Ecological Testing Station:** a sole non-advisor occupant receives an owned
decrease/keep/increase choice after the ordinary storm-card window closes,
before traversal and CHOAM/Fremen protection. The original natural dial/card
source, turn, physical station and chosen distance remain bound through saved
protection and typed loss continuations. Weather Control bypasses the station.
- **Shrine:** current non-advisor occupation of the publicly revealed location
authorizes Truthtrance as Karama or Karama as Truthtrance in the existing effect
paths. Cards retain their physical identity and custody. A committed converted
Truthtrance retains its use receipt through departure, answering and discard.
This does not implement missing special powers or resolve their pending rules.
Great Maker vote/ride, Discovery token/discard, next-turn entry, station and carried
Ornithopter controls use the same private legal choices as the four AI profiles.
Token backs expose only permitted information; authorized faces have readable
explanations of all eight effects. Busy and autopilot states disable human
@@ -109,6 +125,25 @@ settle whether a stacked board deposit represents one or several collected
spice blows; contested occupancy and unresolved Ecaz shared collection add
further unanswered cases.
The Testing Station's two-occupant case leaves ordering and cumulative adjustment
unresolved. The prototype opens only the sole-occupant path and continues an
ordinary storm without that contested benefit. Choosing after the ordinary
storm-card window is an implementation timing interpretation: the printed
source specifies the Storm phase and excludes Weather Control, but does not
supply a separate station/card priority order. A legacy pending storm without
new source metadata keeps its existing movement; a subsequent naturally
produced storm can use the station. No source is inferred from display logs.
Jacurutu's text says opposing **undialed forces**, not a numeric wheel difference.
Advanced combat permits different physical allocations: an Emperor with one
ordinary force and one Sardaukar, dial two and one paid support, can dial the
supported Sardaukar alone or the supported ordinary plus unsupported Sardaukar.
Those choices leave one or zero undialed counters. The existing loser resolution
has no physical allocation choice. The user question asks whether the defeated
player should select those counters; it remains pending. The prototype records
the unpaid gap instead of silently choosing a reward. Source: publisher-authored
Ecaz & Moritani rulebook p13, with no clarification in its bundled Q&A.
## Evidence and next dependencies
Focused suites cover component inventory and private projection
@@ -124,7 +159,8 @@ engine, saved interaction and human/AI paths are covered by
and disposal are covered by `tests/discovery-flight.test.ts`. They exercise
immutable rejections, physical custody, hidden information and JSON restoration.
They do not certify a complete module, all combined routes or difficulty strength
ordering. Required broad checkpoint checks are pending for this expanded batch.
ordering. The free-entry/flight checkpoint `ab0ab52` passed 4,313 offline tests,
build and 40 HTTP tests, preserving all 156 opening rooms (187 after checks).
Browser room `CA782FQE` began through the genuine Discovery setup with one human
and two AI seats, then used a targeted QA state to reach the new controls without
@@ -158,11 +194,27 @@ The room is paused at acceptance of that blow. No other saved room was changed
and no server restart was needed. Required broad verification results and the
source-bound report are recorded in the checkpoint commit.
1. Connect the four remaining special location benefits: Jacurutu battle income,
Ecological Testing Station storm adjustment, Shrine card conversion and Orgiz
collection transfer. Ordinary Jacurutu victory counting does not implement its
battle income; Orgiz awaits the collected-pile ruling above.
2. Resolve contested Cistern ownership, then integrate combined modules and
complete games before refining or opening gates.
3. Run the required broad checkpoint, browser and saved-room verification for
this expanded prototype. Focused evidence does not open normal public starts.
The location-effects batch adds focused Jacurutu engine/SQLite tests, Testing
Station Basic/Advanced producers, Weather exclusion, saved Fremen losses,
private controls, all four AI paths and concurrent SQLite choices. Shrine tests
cover ordinary/special card substitution, reserved-card rejection, individual
physical-card controls and committed JSON/SQLite continuation. Concurrent
declarations preserve one exact physical discard and resume the prior effect. Independent
reviews check all three effects and required broad results are recorded in the
checkpoint commit and its source-bound report.
Browser room `A5EJQ2W4` began as a fresh three-player lobby through the backed-up
Discovery starter, then used real setup, reveal and free-entry actions in a
focused QA scenario. Its human Atreides occupant chose to increase an ordinary
two-sector storm to three; the storm moved from sector six to nine and proceeded
to Spice Blow. Refresh restored the same station forces and private Snooper.
This is targeted control/recovery evidence, not an unmodified complete game.
The 187 opening saved rooms remain preserved; no server restart was needed.
1. Resolve Orgiz's collected-blow interpretation, contested Cistern/Testing
Station benefits and Jacurutu's mixed physical dial allocation; complete their
remaining controls and effects when settled.
2. Integrate combined modules and complete games, then refine interaction,
strategy and presentation coverage before opening normal start gates.
3. Use the existing prototype starter and saved-room verification for further
batches. Focused evidence does not establish complete module compliance.
+19
View File
@@ -117,3 +117,22 @@ browser room uses genuine setup followed by explicitly targeted scenarios; the
156 opening rooms are preserved. Required stable checks and the aggregate endpoint
are recorded in the checkpoint commit, without counting these prototypes as
complete Discovery rules compliance.
The next location-effects batch begins at aggregate counter 63,116,444. Two
workhorse helpers implement Jacurutu and Shrine, then cross-review Shrine and
review the owner's Testing Station. Reuse of the existing prototype starter,
card-effect paths and SQLite fixtures connects three bounded functions. Review
found a reserved-card Shrine deadlock, incomplete alternate-role permission and
missing physical-card selection; these require fixes before broad verification.
Station review strengthened restored source and physical-token binding. One
focused rendered-control assertion was corrected to distinguish the native
`disabled` attribute from the component's `data-disabled` attribute. A fresh
browser scenario checks station controls and refresh; all 187 opening rooms
remain the preservation baseline. The first broad run reached 4,345 offline tests with five failures: two exposed
an unnecessary hand read before public Shrine entitlement, two old overlay
fixtures queued nonexistent cards, and one assertion expected older rejection
wording. The policy now checks public entitlement first; physical fixtures and
wording are corrected without weakening custody validation. Focused regressions
pass before the repeated stable broad run. Final aggregate endpoint and stable
checks belong in the verified checkpoint commit. Pending rulings and complete-mode
gates remain explicit rather than inflating accepted capability counts.
+20
View File
@@ -1,5 +1,25 @@
# Dune implementation status
## 13 September 2026 — Discovery location effects prototype
Adds automatic unambiguous Jacurutu battle income, an owned Testing Station
storm adjustment and physical Shrine card substitution through existing Karama
and Truthtrance paths. Decisions and committed effects preserve JSON/SQLite
continuation, physical custody and private information. Testing Station cannot
change Weather Control; Jacurutu counts opposing physical losses, not the wheel
alone. See the [prototype scope and rulings](DISCOVERY_PROTOTYPE.md).
Orgiz, contested Cistern/Testing Station and mixed-force Jacurutu remain pending.
Normal module/Advanced starts and publication stay gated. Targeted browser room
`A5EJQ2W4` exercises station choice and refresh after real setup/reveal/entry with
QA positioning. Independent review and focused regressions cover all three
functions; required broad results, source fingerprint and preservation of all
187 opening rooms are recorded in the checkpoint commit and private report.
The preceding `ab0ab52` entry/flight checkpoint passed 4,313 offline tests, build
and 40 HTTP tests. Its outage recovery and 156-room opening baseline are historical
results; the current batch keeps every game created afterward.
## 13 September 2026 — First connected Discovery prototype
Connects genuine base-faction Basic/Advanced audit setup to all seven sourced
+3 -1
View File
@@ -15,7 +15,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi
| Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) |
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) |
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter spending are prototyped; four special location effects remain missing. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine have bounded prototypes; Orgiz and contested/mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
| Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) |
@@ -32,6 +32,8 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Occupied Homeworlds | Entitlement/expiry after departure, replacement or contest; unique powers and shared income. The first-occupier/current-occupier question was already sent. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md) |
| Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and the individual faction source/runtime documents above |
| Orgiz collection | User question pending: take one spice per positive board pile collected, or leave the effect pending? The publisher does not clarify stacked Spice Blows. The draft quote remains opt-in and is not enabled by the engine. Contested Cistern income also remains outside the ordinary sole-occupant prototype. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Jacurutu physical dial | The user question asks whether the losing player should choose the physical normal/elite forces represented by its dial. When legal Advanced allocations produce different rewards, income remains explicitly unpaid; numeric wheel subtraction is not a ruling. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Testing Station contest/timing | Only sole non-advisor occupation is prototyped. Two-occupant priority/stacking remains unresolved; opening after the ordinary storm-card window is an explicit source-timing inference. Weather Control is excluded by the printed text. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Discovery prototype inferences | Free entry uses storm order and ordinary source-storm restrictions; these are general-rule inferences. Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Other Homeworld combinations | Population/force splitting, removal custody, replacement shortages, income accounting and placement ordering remain local to their detailed contracts. | [Homeworld rules](HOMEWORLD_RULES.md), [occupation](HOMEWORLD_OCCUPATION_RULES.md) and the effect documents above |
+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,
+6 -2
View File
@@ -9,6 +9,7 @@ import {
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { placeFixtureHand } from './fixture-hand';
const cards = baseDeck();
const shield = cards.find((c) => c.kind === 'shield')!;
const karama = cards.find((c) => c.effect === 'karama')!;
@@ -191,13 +192,16 @@ void test('normalization does not invent an ally split or bypass provisional unf
void test('Truthtrance, phase opening and genuine responses retain priority over a singleton payment', () => {
for (const overlay of ['truth', 'opening', 'response'] as const) {
const g = pendingPayment(auction());
if (overlay === 'truth')
if (overlay === 'truth') {
const truth = cards.find((card) => card.effect === 'truthtrance')!;
placeFixtureHand(g, 2, [truth]);
g.truthtrance = {
stage: 'ask',
queue: [{ player: 'a', card: 'truth' }],
queue: [{ player: 'a', card: truth.id }],
passed: [],
question: null,
};
}
if (overlay === 'opening')
g.phaseOpening = { initialize: false, passed: [] };
if (overlay === 'response') {
+6 -3
View File
@@ -10,6 +10,7 @@ import {
} from '../game/engine';
import { baseDeck, type Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { placeFixtureHand } from './fixture-hand';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const cards = baseDeck();
@@ -264,14 +265,16 @@ void test('pending conversion never offers a nested Worthless conversion; real K
void test('Truthtrance and phase-opening overlays pause normalization without payment or response alteration', () => {
for (const overlay of ['truth', 'phase'] as const) {
const g = fixture();
if (overlay === 'truth')
if (overlay === 'truth') {
const truth = cards.find((card) => card.effect === 'truthtrance')!;
placeFixtureHand(g, 1, [truth]);
g.truthtrance = {
stage: 'ask',
queue: [{ player: 'b', card: 'spent-truth' }],
queue: [{ player: 'b', card: truth.id }],
passed: [],
question: null,
};
else g.phaseOpening = { initialize: false, passed: [] };
} else g.phaseOpening = { initialize: false, passed: [] };
const next = normalizeAutomaticGame(g);
assert.deepEqual(next.response, g.response);
assert.equal(next.players[0].spice, 10);
+112
View File
@@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { DatabaseSync } from 'node:sqlite';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { discoveryBattleBeforeFinalVote } from './fixture-discovery-battle';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('SQLite restoration and competing final votes commit Jacurutu income exactly once', async () => {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
try {
const created = await store.rooms.createRoom(
'Jacurutu recovery',
'atreides',
false,
[],
);
const joined = await store.rooms.joinRoom(
created.view.code,
'Guild',
'guild',
);
const third = await store.rooms.joinRoom(
created.view.code,
'Fremen',
'fremen',
);
const tokens = [created.token, joined.token!, third.token!];
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(created.view.code, token)),
);
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
const stored = await store.rooms.readRoom(created.view.code);
const { game, attacker, defender } = discoveryBattleBeforeFinalVote(
false,
ids,
);
game.code = created.view.code;
game.version = stored.version;
sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(game), game.version, game.code);
const seats = sqlite
.prepare('SELECT * FROM seats ORDER BY player_id')
.all();
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
store.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
try {
const results = await Promise.allSettled(
[0, 1].map(() =>
store
.restart()
.act(
game.code,
auths[1],
game.version,
{ type: 'traitorCall', call: false },
clock,
),
),
);
assert.equal(arrivals, 2);
assert.equal(
results.filter((result) => result.status === 'fulfilled').length,
1,
);
} finally {
clearTimeout(timer);
delete store.hooks.beforeWrite;
}
const done = await store.restart().readRoom(game.code);
assert.equal(done.version, game.version + 1);
assert.equal(
done.players.find((player) => player.id === attacker)!.spice,
23,
);
assert.equal(
done.players.find((player) => player.id === defender)!.tanks,
5,
);
assert.equal(
done.log.filter(
(entry) => entry.automatic?.name === 'Jacurutu Sietch income',
).length,
1,
);
const stable = JSON.stringify(done);
await store.restart().continueRoomAutomatic(game.code, clock);
assert.equal(
JSON.stringify(await store.restart().readRoom(game.code)),
stable,
);
assert.deepEqual(
sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
seats,
);
} finally {
sqlite.close();
}
});
+200
View File
@@ -0,0 +1,200 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
quoteJacurutuBattleIncome,
JACURUTU_BATTLE_INCOME_RULE,
} from '../game/discovery-battle';
import { applyAction, normalizeAutomaticGame, type Game } from '../game/engine';
import { JACURUTU_SIETCH } from '../game/discoveries';
import { discoveryBattleBeforeFinalVote } from './fixture-discovery-battle';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
void test('the source-bound contract quotes only opposing undialed counters actually sent to the Tanks', () => {
assert.match(JACURUTU_BATTLE_INCOME_RULE, /opposing undialed force/);
const ordinary = {
normal: 5,
elite: 0,
eliteStrength: 1 as const,
freeSupport: true,
};
assert.deepEqual(
quoteJacurutuBattleIncome({
territory: JACURUTU_SIETCH,
jacurutuRevealed: true,
winner: 'winner',
opponent: 'loser',
opponentForces: ordinary,
opponentDial: 2,
opponentSupport: 0,
sentToTanks: { normal: 5, elite: 0 },
}),
{
kind: 'income',
winner: 'winner',
opponent: 'loser',
amount: 3,
sentToTanks: { normal: 5, elite: 0 },
dialed: [{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 }],
},
);
const withdrawn = quoteJacurutuBattleIncome({
territory: JACURUTU_SIETCH,
jacurutuRevealed: true,
winner: 'winner',
opponent: 'loser',
opponentForces: ordinary,
opponentDial: 2,
opponentSupport: 0,
sentToTanks: { normal: 2, elite: 0 },
});
assert.equal(withdrawn?.kind, 'income');
assert.equal(withdrawn?.kind === 'income' ? withdrawn.amount : -1, 0);
});
void test('mixed Advanced allocations remain explicit when they imply different physical payouts', () => {
const quote = quoteJacurutuBattleIncome({
territory: JACURUTU_SIETCH,
jacurutuRevealed: true,
winner: 'winner',
opponent: 'emperor',
opponentForces: {
normal: 5,
elite: 1,
eliteStrength: 2,
freeSupport: false,
},
opponentDial: 3,
opponentSupport: 1,
sentToTanks: { normal: 5, elite: 1 },
});
assert.equal(quote?.kind, 'allocationRequired');
if (quote?.kind !== 'allocationRequired') return;
assert.deepEqual(
[...new Set(quote.choices.map((choice) => choice.amount))].sort(
(left, right) => left - right,
),
[1, 2, 3],
);
});
void test('no winner, another territory, or a hidden Jacurutu never creates income', () => {
const partial = {
opponent: null,
opponentForces: null,
opponentDial: null,
opponentSupport: null,
sentToTanks: null,
};
assert.equal(
quoteJacurutuBattleIncome({
...partial,
territory: JACURUTU_SIETCH,
jacurutuRevealed: true,
winner: null,
}),
null,
);
assert.equal(
quoteJacurutuBattleIncome({
...partial,
territory: 'arrakeen',
jacurutuRevealed: true,
winner: 'winner',
}),
null,
);
assert.equal(
quoteJacurutuBattleIncome({
...partial,
territory: JACURUTU_SIETCH,
jacurutuRevealed: false,
winner: 'winner',
}),
null,
);
});
void test('real Basic and Advanced battles pay once from the losing plan and add a typed chronicle event', () => {
for (const advanced of [false, true]) {
const {
game: pending,
attacker,
defender,
} = discoveryBattleBeforeFinalVote(advanced);
const winnerBefore = pending.players.find(
(player) => player.id === attacker,
)!.spice;
const loser = pending.players.find((player) => player.id === defender)!;
loser.tanks = 4;
loser.reserves = 11;
const before = structuredClone(pending);
const done = applyAction(reload(pending), defender, {
type: 'traitorCall',
call: false,
});
assert.deepEqual(pending, before);
assert.equal(
done.players.find((player) => player.id === attacker)!.spice,
winnerBefore - (advanced ? 1 : 0) + 3,
);
assert.equal(
done.players.find((player) => player.id === defender)!.tanks,
9,
);
assert.equal(
done.log.filter(
(entry) => entry.automatic?.name === 'Jacurutu Sietch income',
).length,
1,
);
assert.match(done.log.at(-1)!.text, /Jacurutu|battle|Ready|won/i);
const stable = reload(done);
assert.deepEqual(normalizeAutomaticGame(reload(stable)), stable);
}
});
void test('winner casualties and pre-existing Tanks do not increase Jacurutu income', () => {
const {
game: pending,
attacker,
defender,
} = discoveryBattleBeforeFinalVote(false);
const winner = pending.players.find((player) => player.id === attacker)!;
winner.tanks = 3;
winner.reserves = 12;
const beforeSpice = winner.spice;
const done = applyAction(pending, defender, {
type: 'traitorCall',
call: false,
});
const settled = done.players.find((player) => player.id === attacker)!;
assert.equal(settled.tanks, 4);
assert.equal(settled.spice, beforeSpice + 3);
});
void test('a real traitor winner still receives only the opposing undialed Tanks income', () => {
const {
game: pending,
attacker,
defender,
} = discoveryBattleBeforeFinalVote(false, ['a', 'g', 'f'], true);
const beforeSpice = pending.players.find(
(player) => player.id === attacker,
)!.spice;
const done = applyAction(pending, defender, {
type: 'traitorCall',
call: false,
});
assert.equal(done.lastBattleContext?.result, 'traitor');
assert.equal(
done.players.find((player) => player.id === attacker)!.spice,
beforeSpice + 1 + 3,
);
assert.equal(
done.log.filter(
(entry) => entry.automatic?.name === 'Jacurutu Sietch income',
).length,
1,
);
});
+1 -1
View File
@@ -105,7 +105,7 @@ void test('all eight authorized faces have readable rules while shared location
'protected from storms and sandworms', 'before the storm and the mobile stronghold move']) assert.ok(markup.includes(text), text);
assert.doesNotMatch(markup, /href="https?:/);
assert.ok(markup.includes('Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype.'));
assert.ok(markup.includes('Jacurutu battle income, Ecological Testing Station, Shrine and Orgiz abilities remain in development.'));
assert.ok(markup.includes('Orgiz, mixed-force Jacurutu rewards and contested Cistern/Testing Station benefits remain pending.'));
});
void test('all four AI profiles inspect and then reveal through fresh private views and actual saved actions', () => {
+442
View File
@@ -0,0 +1,442 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { createDiscoveryState } from '../game/discoveries';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { truthFactAnswer } from '../game/truthtrance';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/link'
? 'vinext/shims/link'
: specifier === 'next/image'
? 'vinext/shims/image'
: 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 { Truthtrance } = await import('../components/truthtrance');
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value));
function fixture(advanced = true) {
const game = createGame('SHRINE01', newPlayer('a', 'Atreides', 'atreides'));
game.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('g', 'Guild', 'guild'),
);
Object.assign(game, {
status: 'playing',
phase: 4,
turn: 2,
active: 'a',
advanced,
discoveryEnabled: true,
discoveries: createDiscoveryState(() => 0),
order: ['a', 'e', 'g'],
deck: baseDeck(),
response: null,
decision: null,
phaseOpening: null,
});
for (const player of game.players) {
player.hand = [];
player.forces = {};
player.traitorChoices = [];
}
const shrine = game.discoveries!.tokens.find(
(token) => token.face === 'shrine',
)!;
Object.assign(shrine, {
status: 'placed',
territory: 'gara_kulon',
sector: 8,
revealedTurn: 1,
});
game.players[0].forces['shrine:0'] = 1;
game.players[0].reserves--;
return game;
}
function hold(game: Game, player: string, effect: 'karama' | 'truthtrance') {
const index = game.deck.findIndex((card) => card.effect === effect);
assert.ok(index >= 0, effect);
const card = game.deck.splice(index, 1)[0];
game.players.find((candidate) => candidate.id === player)!.hand.push(card);
return card;
}
function rejectUnchanged(game: Game, player: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, player, action));
assert.deepEqual(game, before);
}
function finishPriority(state: Game) {
let game = state;
while (game.truthtrance?.stage === 'priority') {
const player = game.players.find(
(candidate) => !game.truthtrance!.passed.includes(candidate.id),
)!;
game = applyAction(game, player.id, { type: 'truthPass' });
}
return game;
}
void test('a current Shrine occupant spends physical Truthtrance through ordinary Karama cancellation, shipment and purchase paths', () => {
for (const advanced of [false, true]) {
const cancellation = fixture(advanced);
const card = hold(cancellation, 'a', 'truthtrance');
cancellation.players[1].ally = 'g';
cancellation.players[2].ally = 'e';
cancellation.response = {
kind: 'emperorGift',
owner: 'e',
recipient: 'g',
amount: 5,
passed: [],
};
const beforeView = viewGame(cancellation, 'a');
assert.equal(beforeView.players[0].hand![0].effect, 'truthtrance');
assert.ok(beforeView.responseControls!.cancelCards.includes(card.id));
assert.equal(
truthFactAnswer(cancellation.players[0], {
kind: 'handInventory',
category: 'special',
compare: 'eq',
value: 1,
}),
'yes',
);
const canceled = applyAction(reload(cancellation), 'a', {
type: 'card',
card: card.id,
mode: 'cancel',
});
assert.equal(canceled.response, null);
assert.equal(canceled.pendingKarama ?? null, null);
assert.equal(canceled.discard.at(-1)?.id, card.id);
assert.equal(canceled.discard.at(-1)?.effect, 'truthtrance');
assert.ok(
canceled.log.some((entry) =>
entry.text.includes('used Truthtrance as Karama'),
),
);
const shipment = fixture(advanced);
const shippingCard = hold(shipment, 'a', 'truthtrance');
shipment.phase = 5;
const shipped = applyAction(shipment, 'a', {
type: 'card',
card: shippingCard.id,
mode: 'shipment',
target: 'a',
});
assert.deepEqual(shipped.karamaShipping, {
player: 'a',
owner: 'a',
card: shippingCard.id,
});
assert.equal(shipped.discard.at(-1)?.effect, 'truthtrance');
const purchase = fixture(advanced);
const purchaseCard = hold(purchase, 'a', 'truthtrance');
purchase.phase = 3;
const lot = purchase.deck.shift()!;
purchase.auction = {
cards: [lot],
index: 0,
bid: 0,
bidder: null,
active: 'a',
passed: [],
opener: 0,
};
const bought = applyAction(purchase, 'a', {
type: 'card',
card: purchaseCard.id,
mode: 'purchase',
});
assert.ok(bought.players[0].hand.some((card) => card.id === lot.id));
assert.equal(bought.discard.at(-1)?.id, purchaseCard.id);
assert.equal(bought.discard.at(-1)?.effect, 'truthtrance');
}
});
void test('hidden Shrine, nonoccupants, advisors, and departed occupants cannot start a converted use', () => {
const cases: [string, (game: Game) => void][] = [
['hidden', (game: Game) => {
game.discoveries!.tokens.find((token) => token.face === 'shrine')!.revealedTurn =
null;
}],
['departed', (game: Game) => {
game.players[0].forces = {};
}],
['advisor', (game: Game) => {
game.players[0].faction = 'beneGesserit';
game.players[0].advisors = { shrine: {} };
game.players[1].forces['shrine:0'] = 1;
game.players[1].reserves--;
}],
];
for (const [label, alter] of cases) {
const game = fixture();
const card = hold(game, 'a', 'truthtrance');
alter(game);
game.players[1].ally = 'g';
game.players[2].ally = 'e';
game.response = {
kind: 'emperorGift',
owner: 'e',
recipient: 'g',
amount: 5,
passed: [],
};
assert.ok(
!viewGame(game, 'a').responseControls!.cancelCards.includes(card.id),
label,
);
rejectUnchanged(game, 'a', {
type: 'card',
card: card.id,
mode: 'cancel',
});
}
});
void test('a physical card reserved for a pending Richese gift cannot also enter the Truthtrance queue', () => {
let game = fixture();
const owner = game.players[0];
owner.faction = 'richese';
owner.ally = 'e';
game.players[1].ally = owner.id;
const karama = richeseCards().find((card) => card.effect === 'karama')!;
owner.hand = [karama];
hold(game, 'g', 'karama');
game = applyAction(game, owner.id, { type: 'richeseGift', card: karama.id });
assert.equal(game.pendingRicheseGift?.intent.cardId, karama.id);
rejectUnchanged(game, owner.id, {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
});
});
void test('a Shrine occupant can spend physical Truthtrance on an implemented special Karama power', () => {
const game = fixture();
const emperor = game.players[0];
emperor.faction = 'emperor';
emperor.hand = [];
emperor.tanks = 3;
emperor.reserves = 17;
const truthtrance = hold(game, emperor.id, 'truthtrance');
const markup = renderToStaticMarkup(
createElement(GameTable, {
game: viewGame(game, emperor.id),
send: async () => {},
onExit() {},
busy: false,
}),
);
assert.match(markup, /Use special Karama · forces/);
const done = applyAction(game, emperor.id, {
type: 'card',
mode: 'special',
card: truthtrance.id,
amount: 3,
elite: 0,
});
assert.equal(done.players[0].specialKaramaUsed, true);
assert.equal(done.players[0].tanks, 0);
assert.equal(done.players[0].reserves, 20);
assert.equal(done.discard.at(-1)?.id, truthtrance.id);
assert.equal(done.discard.at(-1)?.effect, 'truthtrance');
assert.ok(
done.log.some((entry) =>
entry.text.includes('physical Truthtrance as Karama'),
),
);
});
void test('Karama committed as Truthtrance keeps its Shrine receipt through JSON, departure, answer, and discard continuation', () => {
let game = fixture();
const karama = hold(game, 'a', 'karama');
hold(game, 'e', 'truthtrance');
game.players[1].ally = 'g';
game.players[2].ally = 'e';
const suspended = {
kind: 'emperorGift' as const,
owner: 'e',
recipient: 'g',
amount: 5,
passed: [] as string[],
};
game.response = suspended;
game = applyAction(game, 'a', {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
});
assert.deepEqual(game.truthtrance!.queue[0], {
player: 'a',
card: karama.id,
source: 'shrine',
});
const pending = reload(game);
assert.deepEqual(pending.response, suspended);
assert.deepEqual(normalizeAutomaticGame(reload(pending)), pending);
pending.players[0].forces = { 'arrakeen:10': 1 };
game = finishPriority(pending);
game = applyAction(game, 'a', {
type: 'truthAsk',
question: {
kind: 'fact',
target: 'e',
fact: { kind: 'hand', name: 'Truthtrance' },
},
});
assert.deepEqual(game.response, suspended);
assert.equal(viewGame(reload(game), 'e').truthAnswer, 'yes');
game = applyAction(reload(game), 'e', {
type: 'truthAnswer',
answer: 'yes',
});
assert.equal(game.truthtrance, null);
assert.equal(game.truthHistory!.at(-1)?.answer, 'yes');
assert.equal(game.discard.at(-1)?.id, karama.id);
assert.equal(game.discard.at(-1)?.effect, 'karama');
const forged = reload(pending);
delete forged.truthtrance!.queue[0].source;
assert.throws(() => viewGame(forged, 'a'), /committed physical card/);
});
void test('an unknown converted question may save the same Karama, but departure revokes a fresh conversion', () => {
let game = fixture();
const karama = hold(game, 'a', 'karama');
game = finishPriority(
applyAction(game, 'a', {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
}),
);
game = applyAction(game, 'a', {
type: 'truthAsk',
question: {
kind: 'freeform',
target: 'e',
text: 'Will the next battle be won by a traitor?',
scope: 'currentTurn',
},
});
game = applyAction(game, 'e', { type: 'truthAnswer', answer: 'unknown' });
game = applyAction(reload(game), 'a', { type: 'truthSave' });
assert.equal(game.truthtrance, null);
assert.equal(game.players[0].hand[0].id, karama.id);
game.players[0].forces = {};
rejectUnchanged(game, 'a', {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
});
});
void test('all four AI profiles use the converted Truthtrance control and complete the existing question path', () => {
for (const difficulty of DIFFICULTIES) {
let game = fixture();
const karama = hold(game, 'a', 'karama');
game.phase = 6;
game.battle = {
territory: 'arrakeen',
attacker: 'a',
defender: 'e',
prepared: true,
plans: {},
revealed: false,
traitorCalls: {},
};
for (const player of game.players) player.bot = difficulty;
const action = botActions(viewGame(game, 'a'))[0];
assert.deepEqual(action, {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
});
game = applyAction(game, 'a', action);
while (game.truthtrance?.stage === 'priority') {
const next = game.players.find(
(player) => !game.truthtrance!.passed.includes(player.id),
)!;
const pass = botActions(viewGame(game, next.id))[0];
assert.deepEqual(pass, { type: 'truthPass' });
game = applyAction(game, next.id, pass);
}
const question = botActions(viewGame(game, 'a'))[0];
assert.equal(question.type, 'truthAsk');
game = applyAction(game, 'a', question);
const target = game.truthtrance!.question!.target;
game.players.find((player) => player.id === target)!.bot = difficulty;
const answer = botActions(viewGame(game, target))[0];
assert.equal(answer.type, 'truthAnswer');
game = applyAction(game, target, answer);
assert.equal(game.truthtrance, null);
assert.equal(game.discard.at(-1)?.id, karama.id);
}
});
void test('Truthtrance priority renders the owner conversion without exposing another hand', () => {
let game = fixture();
const initiator = hold(game, 'a', 'truthtrance');
const karama = hold(game, 'e', 'karama');
const truthtrance = hold(game, 'e', 'truthtrance');
game.players[1].forces['shrine:0'] = 1;
game.players[1].reserves--;
game = applyAction(game, 'a', { type: 'card', card: initiator.id });
const view = viewGame(game, 'e');
const markup = renderToStaticMarkup(
createElement(Truthtrance, { game: view, busy: false, act() {} }),
);
assert.match(markup, /Use my Karama as Truthtrance/);
assert.match(markup, /Declare my Truthtrance/);
assert.match(markup, /Declare all 2 as Truthtrance/);
assert.deepEqual(
view.players[1].hand!.map((card) => card.id),
[karama.id, truthtrance.id],
);
assert.equal(view.players[0].hand, undefined);
});
+194
View File
@@ -0,0 +1,194 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { DatabaseSync } from 'node:sqlite';
import { baseDeck } from '../game/cards';
import { createDiscoveryState } from '../game/discoveries';
import { createGame, newPlayer, type Game } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
function savedShrineGame(code: string, ids: [string, string, string]): Game {
const game = createGame(code, newPlayer(ids[0], 'Atreides', 'atreides'));
game.players.push(
newPlayer(ids[1], 'Emperor', 'emperor'),
newPlayer(ids[2], 'Guild', 'guild'),
);
Object.assign(game, {
status: 'playing',
phase: 4,
turn: 2,
active: ids[0],
advanced: true,
discoveryEnabled: true,
discoveries: createDiscoveryState(() => 0),
order: ids,
deck: baseDeck(),
response: {
kind: 'emperorGift',
owner: ids[1],
recipient: ids[2],
amount: 5,
passed: [],
},
decision: null,
phaseOpening: null,
});
for (const player of game.players) {
player.hand = [];
player.forces = {};
player.spice = 10;
player.traitorChoices = [];
}
game.players[1].ally = ids[2];
game.players[2].ally = ids[1];
const shrine = game.discoveries!.tokens.find(
(token) => token.face === 'shrine',
)!;
Object.assign(shrine, {
status: 'placed',
territory: 'gara_kulon',
sector: 8,
revealedTurn: 1,
});
game.players[0].forces['shrine:0'] = 1;
game.players[0].reserves--;
const index = game.deck.findIndex((card) => card.effect === 'karama');
assert.ok(index >= 0);
game.players[0].hand.push(game.deck.splice(index, 1)[0]);
return game;
}
void test('SQLite restoration and competing Shrine declarations keep one physical Karama private and complete once', async () => {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
try {
const created = await store.rooms.createRoom(
'Shrine recovery',
'atreides',
false,
[],
);
const joined = await store.rooms.joinRoom(
created.view.code,
'Emperor',
'emperor',
);
const third = await store.rooms.joinRoom(
created.view.code,
'Guild',
'guild',
);
const auths = await Promise.all(
[created.token, joined.token!, third.token!].map((token) =>
store.rooms.authenticate(created.view.code, token),
),
);
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
const stored = await store.rooms.readRoom(created.view.code);
const game = savedShrineGame(created.view.code, ids);
game.version = stored.version;
const karama = game.players[0].hand[0];
sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(game), game.version, game.code);
const seats = sqlite
.prepare('SELECT * FROM seats ORDER BY player_id')
.all();
const action = {
type: 'card' as const,
card: karama.id,
shrineTruthtrance: [karama.id],
};
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
store.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
try {
const results = await Promise.allSettled(
[0, 1].map(() =>
store
.restart()
.act(game.code, auths[0], game.version, action, clock),
),
);
assert.equal(arrivals, 2);
assert.equal(
results.filter((result) => result.status === 'fulfilled').length,
1,
);
} finally {
clearTimeout(timer);
delete store.hooks.beforeWrite;
}
let saved = await store.restart().readRoom(game.code);
assert.equal(saved.version, game.version + 1);
assert.deepEqual(saved.truthtrance!.queue, [
{ player: ids[0], card: karama.id, source: 'shrine' },
]);
assert.equal(saved.players[0].hand[0].effect, 'karama');
const ownerView = await store.restart().readSeatView(game.code, auths[0]);
const otherView = await store.restart().readSeatView(game.code, auths[1]);
assert.equal(ownerView.players[0].hand![0].effect, 'karama');
assert.equal(otherView.players[0].hand, undefined);
for (let i = 1; i < auths.length; i++) {
await store
.restart()
.act(game.code, auths[i], saved.version, { type: 'truthPass' }, clock);
saved = await store.restart().readRoom(game.code);
}
assert.equal(saved.truthtrance!.stage, 'ask');
await store.restart().act(
game.code,
auths[0],
saved.version,
{
type: 'truthAsk',
question: {
kind: 'fact',
target: ids[1],
fact: { kind: 'spice', compare: 'eq', value: 10 },
},
},
clock,
);
saved = await store.restart().readRoom(game.code);
assert.deepEqual(saved.response, game.response);
await store.restart().act(
game.code,
auths[1],
saved.version,
{ type: 'truthAnswer', answer: 'yes' },
clock,
);
const done = await store.restart().readRoom(game.code);
assert.equal(done.truthtrance, null);
assert.equal(done.response, null);
assert.equal(done.players[1].spice, 5);
assert.equal(done.players[2].spice, 15);
assert.equal(
done.players[0].hand.some((card) => card.id === karama.id),
false,
);
assert.deepEqual(
done.discard.filter((card) => card.id === karama.id),
[{ ...karama, effect: 'karama' }],
);
assert.deepEqual(
sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
seats,
);
} finally {
sqlite.close();
}
});
+146
View File
@@ -0,0 +1,146 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { unitStore } from './fixture-nexus-room-store';
import {
discoveryStormFixture,
confirmDiscoveryStorm,
} from './fixture-discovery-storm';
import { discoveryStormActions } from '../game/discovery-storm-options';
import {
createStormSource,
discoveryStormSignature,
} from '../game/discovery-storm';
import { DiscoveryStormDecision } from '../components/discovery-storm';
import { normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('restored SQLite station choice keeps private controls and competing submissions adjust the storm only once', async () => {
const store = unitStore();
try {
const created = await store.rooms.createRoom(
'Station recovery',
'atreides',
false,
[],
);
const code = created.view.code,
sessions = [created.token];
for (const faction of ['guild', 'fremen'] as const)
sessions.push(
(await store.rooms.joinRoom(code, faction, faction)).token!,
);
const auths = await Promise.all(
sessions.map((token) => store.rooms.authenticate(code, token)),
);
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
const game = confirmDiscoveryStorm(discoveryStormFixture(false, ids));
game.code = code;
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(game), game.version, code);
const seats = store.sqlite
.prepare('SELECT * FROM seats ORDER BY player_id')
.all();
for (const [i, auth] of auths.entries()) {
const view = await store.restart().readSeatView(code, auth);
assert.deepEqual(view, viewGame(game, auth.playerId));
assert.equal(view.ecologicalStorm !== null, i === 0);
assert.equal(JSON.stringify(view).includes('stormMovementSource'), false);
}
const action = discoveryStormActions(viewGame(game, ids[0])).find(
(a) => a.delta === 1,
)!;
let arrivals = 0,
release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
store.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
try {
const results = await Promise.allSettled(
[0, 1].map(() =>
store.restart().act(code, auths[0], game.version, action, clock),
),
);
assert.equal(arrivals, 2);
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
} finally {
clearTimeout(timer);
delete store.hooks.beforeWrite;
}
const done = await store.restart().readRoom(code);
assert.equal(done.version, game.version + 1);
assert.equal(done.storm, 9);
assert.equal(done.ecologicalStorm!.stage, 'complete');
assert.equal(
done.log.filter((e) => e.text.includes('increased storm movement'))
.length,
1,
);
await assert.rejects(
store.restart().act(code, auths[0], done.version, action, clock),
);
assert.deepEqual(await store.restart().readRoom(code), done);
assert.deepEqual(
store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
seats,
);
} finally {
store.sqlite.close();
}
});
void test('station controls show all legal distances only to the owner and disable while busy or on autopilot', () => {
const g = confirmDiscoveryStorm(discoveryStormFixture());
const render = (id: string, busy = false) =>
renderToStaticMarkup(
createElement(DiscoveryStormDecision, {
game: viewGame(g, id),
busy,
act() {},
}),
);
const markup = render('a');
for (const label of [
'Decrease: 1 sector',
'Keep: 2 sectors',
'Increase: 3 sectors',
])
assert.ok(markup.includes(label));
assert.equal(render('g'), '');
assert.equal((render('a', true).match(/\sdisabled=""/g) ?? []).length, 3);
g.players[0].autopilot = 'Easy';
assert.equal((render('a').match(/\sdisabled=""/g) ?? []).length, 3);
});
void test('source turn, source phase and exact station token are bound across restored decisions', () => {
const source = confirmDiscoveryStorm(discoveryStormFixture());
for (const mutate of [
(g: Game) => {
g.stormMovementSource = createStormSource(g.turn - 1, 'dials', 2);
},
(g: Game) => {
g.phase = 1;
},
(g: Game) => {
g.ecologicalStorm!.token = g.discoveries!.tokens.find(
(t) => t.face !== 'ecological-testing-station',
)!.id;
g.ecologicalStorm!.signature = discoveryStormSignature(
g.ecologicalStorm!,
);
},
]) {
const g = structuredClone(source);
mutate(g);
const before = structuredClone(g);
assert.throws(() => viewGame(g, 'a'));
assert.throws(() => normalizeAutomaticGame(g));
assert.deepEqual(g, before);
}
});
+200
View File
@@ -0,0 +1,200 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { discoveryStormActions } from '../game/discovery-storm-options';
import {
createStormSource,
discoveryStormOffer,
discoveryStormSignature,
} from '../game/discovery-storm';
import {
discoveryStormFixture,
confirmDiscoveryStorm,
holdStormCard,
} from './fixture-discovery-storm';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function reject(g: Game, owner: string, action: Action) {
const before = reload(g);
assert.throws(() => applyAction(g, owner, action));
assert.deepEqual(g, before);
}
for (const advanced of [false, true])
for (const delta of [-1, 0, 1]) {
void test(`Ecological Testing Station changes genuine ${advanced ? 'Advanced card' : 'Basic dial'} movement by ${delta} after the storm window`, () => {
let g = discoveryStormFixture(advanced);
assert.equal(g.ecologicalStorm, undefined);
assert.equal(g.stormMovementSource?.kind, advanced ? 'card' : 'dials');
g = confirmDiscoveryStorm(g);
assert.equal(g.decision?.kind, 'ecologicalStorm');
assert.equal(g.storm, 6);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
const action = discoveryStormActions(viewGame(g, 'a')).find(
(a) => a.delta === delta,
)!;
const before = reload(g);
g = applyAction(reload(g), 'a', action);
assert.deepEqual(
before.players.map((p) => p.hand),
g.players.map((p) => p.hand),
);
assert.equal(g.storm, 8 + delta);
assert.equal(g.phase, 1);
assert.equal(g.ecologicalStorm?.stage, 'complete');
assert.equal(g.stormMovementSource, undefined);
assert.equal(g.stormResolution, null);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
reject(g, 'a', action);
});
}
void test('station choice is private to its current sole fighter occupant; wrong owner, stale event and injected distance are immutable rejections', () => {
const g = confirmDiscoveryStorm(discoveryStormFixture()),
before = reload(g);
assert.equal(viewGame(g, 'g').ecologicalStorm, null);
assert.deepEqual(discoveryStormActions(viewGame(g, 'g')), []);
const v = viewGame(g, 'a'),
action = discoveryStormActions(v)[0];
v.ecologicalStorm!.options[0].distance = 999;
assert.deepEqual(g, before);
reject(g, 'g', action);
reject(g, 'a', { ...action, event: 'stale' });
for (const delta of [-2, 2, 0.5, NaN]) reject(g, 'a', { ...action, delta });
reject(g, 'a', { ...action, distance: 10 });
reject(g, 'a', { type: 'ready' });
});
void test('all four AI levels select a legal owned station choice', () => {
const g = confirmDiscoveryStorm(discoveryStormFixture(true));
for (const level of DIFFICULTIES) {
const v = viewGame(g, 'a');
v.players[0].bot = level;
const actions = botActions(v);
assert.ok(actions.length);
for (const action of actions)
assert.doesNotThrow(() => applyAction(g, 'a', action));
}
});
void test('Weather Control replaces natural provenance and bypasses the station, including zero movement', () => {
for (const advanced of [false, true])
for (const amount of [0, 3]) {
let g = discoveryStormFixture(advanced);
const card = holdStormCard(g, 'a', 'weather');
g = applyAction(g, 'g', { type: 'ready' });
g = applyAction(g, 'a', { type: 'card', card: card.id, amount });
assert.equal(g.stormMovementSource?.kind, 'weather');
assert.deepEqual(g.ready, []);
assert.match(discoveryStormOffer(g)!.blocked!, /Weather Control/);
g = confirmDiscoveryStorm(reload(g));
assert.equal(g.ecologicalStorm, undefined);
assert.equal(g.storm, 6 + amount);
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
}
});
void test('unoccupied, contested and legacy unknown-source storms continue without inventing entitlement or changing distance', () => {
for (const mode of ['empty', 'contested', 'legacy']) {
let g = discoveryStormFixture();
if (mode === 'empty') {
g.players[0].reserves +=
g.players[0].forces['ecological-testing-station:0'];
delete g.players[0].forces['ecological-testing-station:0'];
}
if (mode === 'contested') {
g.players[1].reserves--;
g.players[1].forces['ecological-testing-station:0'] = 1;
}
if (mode === 'legacy') delete g.stormMovementSource;
g = confirmDiscoveryStorm(g);
assert.equal(g.ecologicalStorm, undefined);
assert.equal(g.storm, 8);
}
});
void test('station can reduce a one-sector natural storm to zero and never offers a negative distance', () => {
let g = discoveryStormFixture(true);
g.stormPending = 1;
g.stormMovementSource = createStormSource(g.turn, 'card', 1);
g = confirmDiscoveryStorm(g);
g = applyAction(g, 'a', {
type: 'decision',
event: g.ecologicalStorm!.event,
delta: -1,
});
assert.equal(g.storm, 6);
assert.equal(g.phase, 1);
const source = discoveryStormFixture();
source.stormPending = 0;
source.stormMovementSource = createStormSource(source.turn, 'dials', 0);
assert.deepEqual(
discoveryStormOffer(source)!.options.map((o) => o.delta),
[0, 1],
);
});
void test('orphaned station choices, rewritten Weather sources and mismatched traversal receipts fail closed on reads and normalization', () => {
const original = confirmDiscoveryStorm(discoveryStormFixture());
for (const mutate of [
(g: Game) => {
delete g.ecologicalStorm;
},
(g: Game) => {
g.decision = null;
},
(g: Game) => {
g.ecologicalStorm!.owner = 'g';
g.ecologicalStorm!.signature = discoveryStormSignature(
g.ecologicalStorm!,
);
},
(g: Game) => {
g.stormMovementSource = createStormSource(g.turn, 'weather', 2);
},
(g: Game) => {
g.stormPending = 3;
},
]) {
const g = reload(original);
mutate(g);
const before = reload(g);
assert.throws(() => viewGame(g, 'a'));
assert.throws(() => normalizeAutomaticGame(g));
assert.deepEqual(g, before);
}
});
void test('adjusted Advanced storm retains its receipt through Fremen protection and typed loss continuation', () => {
let g = discoveryStormFixture(true);
const f = g.players[2];
f.reserves -= 3;
f.forces['red_chasm:7'] = 3;
f.elites!.reserves--;
f.elites!.forces['red_chasm:7'] = 1;
holdStormCard(g, 'g', 'karama');
g = confirmDiscoveryStorm(g);
g = applyAction(g, 'a', {
type: 'decision',
event: g.ecologicalStorm!.event,
delta: 1,
});
assert.equal(g.response?.kind, 'stormProtection');
assert.equal(g.ecologicalStorm!.stage, 'traversal');
assert.equal(g.stormResolution!.discoveryStorm, g.ecologicalStorm!.signature);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
const corrupt = reload(g);
delete corrupt.stormResolution!.discoveryStorm;
assert.throws(() => viewGame(corrupt, 'f'));
for (let step = 0; g.response?.kind === 'stormProtection'; step++) {
assert.ok(step < 10);
const owner = g.players.find((p) => !g.response!.passed.includes(p.id))!.id;
g = applyAction(reload(g), owner, { type: 'passResponse' });
}
assert.equal(g.decision?.kind, 'stormLosses');
g = applyAction(reload(g), 'f', { type: 'decision', elite: 1 });
assert.equal(g.players[2].forces['red_chasm:7'], 1);
assert.equal(g.players[2].elites!.forces['red_chasm:7'] ?? 0, 0);
assert.equal(g.ecologicalStorm?.stage, 'complete');
assert.equal(g.storm, 9);
});
+97
View File
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { applyAction } from '../game/engine';
import { JACURUTU_SIETCH } from '../game/discoveries';
import { discoveryFixture, putDiscovery } from './fixture-discovery';
export function discoveryBattleBeforeFinalVote(
advanced = false,
ids: [string, string, string] = ['a', 'g', 'f'],
attackerTraitor = false,
) {
let game = discoveryFixture(advanced, ids);
const attacker = game.players[0];
const defender = game.players[1];
const token = putDiscovery(game, 'jacurutu-sietch');
token.revealedTurn = game.turn;
for (const player of game.players) {
player.forces = {};
player.reserves = 20;
player.tanks = 0;
player.spice = 20;
player.hand = [];
player.traitors = [];
for (const leader of player.leaders) leader.strength = 0;
}
attacker.forces[`${JACURUTU_SIETCH}:0`] = 5;
attacker.reserves = 15;
defender.forces[`${JACURUTU_SIETCH}:0`] = 5;
defender.reserves = 15;
attacker.leaders[0].strength = 5;
defender.leaders[0].strength = 1;
if (attackerTraitor) attacker.traitors = [defender.leaders[0].id];
Object.assign(game, {
phase: 6,
active: attacker.id,
ready: [],
response: null,
decision: null,
phaseOpening: null,
battle: null,
lastBattle: [],
lastBattleContext: undefined,
storm: 18,
});
game = applyAction(game, attacker.id, {
type: 'chooseBattle',
territory: JACURUTU_SIETCH,
target: defender.id,
});
for (const id of [attacker.id, defender.id])
if (game.battle?.preLeader && !game.battle.preLeader.ready.includes(id))
game = applyAction(game, id, {
type: 'battlePreparationReady',
event: game.battle.event,
});
for (
let step = 0;
step < 30 && (game.response || game.battle?.preparation);
step++
) {
if (game.response)
game = applyAction(
game,
game.players.find(
(player) => !game.response!.passed.includes(player.id),
)!.id,
{ type: 'passResponse' },
);
else
game = applyAction(game, game.battle!.preparation!.owner, {
type: 'declineBattlePower',
});
}
assert.equal(game.response, null);
assert.equal(game.battle?.preparation, undefined);
if (game.decision?.kind === 'fullPlanOffer')
game = applyAction(game, game.decision.player, {
type: 'decision',
decline: true,
});
game = applyAction(game, attacker.id, {
type: 'battlePlan',
dial: 1,
support: advanced ? 1 : 0,
leader: attacker.leaders[0].id,
});
game = applyAction(game, defender.id, {
type: 'battlePlan',
dial: 2,
support: advanced ? 2 : 0,
leader: defender.leaders[0].id,
});
game = applyAction(game, attacker.id, {
type: 'traitorCall',
call: attackerTraitor,
});
return { game, attacker: attacker.id, defender: defender.id };
}
+72
View File
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import {
discoveryFixture,
enterDiscoveryCollection,
} from './fixture-discovery';
/** Genuine module setup, physical reveal and next-turn entry; positions are
* arranged for a focused storm rather than an unmodified complete game. */
export function discoveryStormFixture(
advanced = false,
ids: [string, string, string] = ['a', 'g', 'f'],
) {
let g = discoveryFixture(advanced, ids);
const token = enterDiscoveryCollection(
g,
'ecological-testing-station',
ids[0],
);
if (viewGame(g, ids[0]).discoveries!.canInspect.includes(token.id))
g = applyAction(g, ids[0], {
type: 'discovery',
token: token.id,
reveal: false,
});
g = applyAction(g, ids[0], {
type: 'discovery',
token: token.id,
reveal: true,
});
Object.assign(g, {
phase: 8,
ready: [],
decision: null,
response: null,
phaseOpening: null,
stormCard: 2,
});
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'discoveryEntry');
const offer = viewGame(g, ids[0]).discoveryEntry!;
g = applyAction(g, ids[0], {
type: 'decision',
event: offer.event,
accept: true,
groups: offer.sources,
});
assert.equal(g.discoveryEntry, undefined);
g.storm = 6;
if (!advanced)
for (const id of g.stormDialers)
g = applyAction(g, id, { type: 'stormDial', amount: 1 });
assert.equal(g.stormPending, 2);
return g;
}
export function confirmDiscoveryStorm(g: Game) {
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
export function holdStormCard(g: Game, owner: string, effect: string) {
const card = [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
].find((c) => c.effect === effect)!;
assert.ok(card);
g.deck = g.deck.filter((c) => c.id !== card.id);
g.discard = g.discard.filter((c) => c.id !== card.id);
for (const p of g.players) p.hand = p.hand.filter((c) => c.id !== card.id);
g.players.find((p) => p.id === owner)!.hand.push(card);
return card;
}
+1 -1
View File
@@ -195,7 +195,7 @@ void test('reserved Black Market Truthtrance cannot hide in the additional queue
g,
'r',
{ type: 'card', card: main.id, cards: [offered.id] },
/reserved.*Truthtrance/,
/card offered for Black Market must remain available/,
);
assert.equal(g.truthtrance ?? null, null);
assert.deepEqual(