Prototype private Mentat questions behind explicit preview opt-in

Connect the optional named-weapon question before skill posture and faction powers. Every accepted question waits for the same target-owned private card-show action, preserving held/absent privacy; matching cards are compulsory, otherwise the owner selects a fallback. Keep cards in hand and retain historical observations only for the questioner and target, without binding later plans. Support actual controls, inspectors, all four AI profiles and saved JSON/SQLite continuation.

Normal activation remains disabled pending the user's uniform-response UX exception. The private flag is neither projected nor writable through actions. Legacy battles remain unchanged. Empty hands skip with an explicit unavailable receipt and no invented answer. Source and unresolved boundaries are documented, with a compact current-status dashboard preserving all goal, Git, subagent, usage and saved-game requirements.

Independent review passed privacy, custody, sequencing and preview gating. Twenty-two focused tests cover canonical card roles, exact-match/fallback symmetry, target-only controls, captured lower-only behavior, stale/duplicate requests and concurrent SQLite writes. Typecheck, lint, 4605 offline tests, production build and 43 HTTP tests passed with stable source. The first broad run retained one outdated Mentat card-copy assertion; update it to verify private disclosure, custody, optional question and subsequent concealment. Gameplay source was unchanged by that test-only correction.

Two genuine-setup sample games deliberately deal Mentat via a controlled initial skill shuffle. Basic: 100 actions, 3 JSON restores, turn-one Harkonnen win. Advanced: 507 actions, 14 restores, turn-five Harkonnen win. Each has one private observation, subsequent Atreides Prescience and no rejected candidate. These bounded force/skill custody samples do not establish general card-custody coverage, calibration or full module acceptance.

After the reported outage all 665 opening rooms and original seat/recovery/receipt records were preserved (699 after integration). A fresh 666-room backup preceded checks; database integrity and foreign keys pass. The existing localhost server was reused; its live lock prevented a duplicate start. Existing Ecaz and Ixian browser seats restored their same hands and pending choices. A separate conserved staged Mentat preview restored its private history and posture, then legally concealed its leader before subsequent faction powers. Human/browser paths are distinguished from genuine-setup simulations. No database reset, recurring automation, public activation or publication.

Verified source tree: 297ae06e0764d7cad525a802fe383a84971c3e9c5d6362c8cd0b79c5c3977caf
Final source tree: 177c98415098ea42174a860d8b1fac4f7c7dd6c3e67c93b18330d1d0fc4edf0e
The only post-verification edit removed one surplus newline at the end of the new developer document; its exact before/after content and fingerprints are retained privately. Staged diff checking caught this whitespace issue before commit. No application or test code changed.
This commit is contained in:
Quintine committed 2026-09-13 23:55:45 +10:00
1 parent 903d861170
commit 9f1f783be4
21 files changed
+1717 -203

No files matched your search

+6
View File
@@ -52,6 +52,7 @@ import { JuiceOfSapho } from './juice-of-sapho';
import { LeaderSkillsPanel } from './leader-skills';
import { LeaderSkillBattleGuide } from './leader-skill-battle-guide';
import { RihaniChoice, RihaniHistory } from './rihani-decipherer';
import { MentatQuestion, MentatHistory } from './mentat-question';
import { SukGraduatePanel } from './suk-graduate';
import { NullentropyBox, NullentropySearch } from './nullentropy-box';
import { OrnithopterMovement } from './ornithopter-movement';
@@ -1523,6 +1524,7 @@ export function GameTable({
<DiscoveryPanel game={g} act={act} busy={transportBusy || !!me.autopilot} />
<LeaderSkillsPanel skills={g.leaderSkills} leaders={g.allLeaders} players={g.players} act={act} busy={transportBusy || !!me.autopilot} />
<RihaniHistory game={g} />
<MentatHistory game={g} />
<NexusChoamTrade game={g} act={act} busy={transportBusy || !!me.autopilot} />
<NexusTleilaxu game={g} act={act} busy={transportBusy || !!me.autopilot} />
<NexusSuboids game={g} act={act} busy={transportBusy || !!me.autopilot} />
@@ -2142,6 +2144,8 @@ export function GameTable({
? 'Grumman Collection'
: g.decision.kind === 'leaderSkillVisibility'
? 'Position your skilled leader'
: g.decision.kind === 'mentatQuestion'
? g.decision.stage === 'name' ? 'Mentat · name a weapon' : 'Mentat · privately show a card'
: g.decision.kind === 'leaderSkillRevival'
? 'Choose a skill for the revived leader'
: g.decision.kind === 'rihani'
@@ -2433,6 +2437,8 @@ export function GameTable({
act={act}
busy={busy}
/>
) : g.decision.kind === 'mentatQuestion' ? (
<MentatQuestion key={`${g.decision.event}-${g.decision.stage}-${g.me}`} game={g} act={act} busy={busy} />
) : g.decision.kind === 'leaderSkillVisibility' || g.decision.kind === 'leaderSkillRevival' ? (
<p className="muted">Use the Leader Skills controls above to make your choice.</p>
) : g.decision.kind === 'homeworldRevivalDeployment' ? (
+2 -2
View File
@@ -112,8 +112,8 @@ export function LeaderSkillsPanel({
This development-only module currently connects the card-role strength
modifiers for Warmaster, Master of Assassins, Swordmaster of Ginaz,
Killer Medic and Prana-Bindu Adept, plus Planetologist movement and
Special-card battle use, Suk Graduate force rescue, Mentat and
Bureaucrat battle scores, Sandmaster ground-route collection and
Special-card battle use, Suk Graduate force rescue, Mentat’s private
weapon-question preview and battle bonus, Bureaucrat battle scores, Sandmaster ground-route collection and
victory spice, Rihani inspections and exchanges, optional Smuggler
reserve shipping, Spice Banker battle spending, and Diplomat copying
an opposing base defense with a committed Worthless card after plans
+154
View File
@@ -0,0 +1,154 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { territory } from '@/game/board';
import { useState } from 'react';
import { Button } from './ui/button';
import { CardInspector } from './card-inspector';
function playerName(game: GameView, id: string) {
return game.players.find((player) => player.id === id)?.name ?? id;
}
export function MentatQuestion({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const [weapon, setWeapon] = useState('');
const pending = game.mentat?.pending;
const decision = game.decision;
if (
!pending ||
decision?.kind !== 'mentatQuestion' ||
decision.player !== game.me ||
decision.event !== pending.event ||
pending.player !== game.me ||
(pending.stage !== 'name' && pending.stage !== 'reveal')
)
return null;
const owner = playerName(game, pending.owner);
const target = playerName(game, pending.target);
if (pending.stage === 'name')
return (
<section className="min-w-0 space-y-3" aria-label="Mentat question">
<h3 className="font-serif text-xl">Ask the Mentat question</h3>
<p className="text-base leading-7">
Name one specific weapon. {target} will privately show that card if
it is held, or choose another card from their hand.
</p>
{pending.blocked && <p className="notice">{pending.blocked}</p>}
{pending.weapons.length > 0 && (
<label className="block space-y-2">
<span className="font-semibold">Weapon to name</span>
<select
aria-label="Weapon to name for Mentat"
className="min-h-11 w-full rounded border border-[#65644b] bg-[#171b17] px-3 py-2"
defaultValue=""
disabled={busy || !!pending.blocked}
onChange={(event) => setWeapon(event.currentTarget.value)}
>
<option value="">Choose a weapon</option>
{pending.weapons.map((weapon) => (
<option key={weapon} value={weapon}>
{weapon}
</option>
))}
</select>
</label>
)}
<Button
className="min-h-11 whitespace-normal"
disabled={busy || !weapon || !!pending.blocked}
onClick={() =>
!busy && weapon && !pending.blocked &&
act({ type: 'decision', event: pending.event, weapon })
}
>
Ask about {weapon || 'a weapon'}
</Button>
<Button
variant="outline"
className="min-h-11 whitespace-normal"
disabled={busy}
onClick={() =>
!busy && act({ type: 'decision', event: pending.event, decline: true })
}
>
Decline Mentat question
</Button>
</section>
);
const namedCards = pending.cards.filter((card) => card.name === pending.weapon);
const hasNamedCard = namedCards.length > 0;
return (
<section className="min-w-0 space-y-3" aria-label="Mentat card choice">
<h3 className="font-serif text-xl">Mentat card shown privately</h3>
<p className="text-base leading-7">
{owner} named <strong>{pending.weapon}</strong>.{' '}
{hasNamedCard
? 'Show the named card privately.'
: 'Choose one card from your hand to show privately.'}{' '}
The card stays in your hand.
</p>
{pending.blocked && <p className="notice">{pending.blocked}</p>}
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
{pending.cards.map((card) => (
<article
key={card.id}
className="min-w-0 space-y-3 rounded-lg border border-[#485542] p-4"
>
<h4 className="font-serif text-xl break-words">{card.name}</h4>
<CardInspector card={card} />
<Button
className="min-h-11 w-full whitespace-normal"
disabled={busy || !!pending.blocked}
onClick={() =>
!busy &&
!pending.blocked &&
act({ type: 'decision', event: pending.event, card: card.id })
}
>
Show {card.name} privately
</Button>
</article>
))}
</div>
</section>
);
}
export function MentatHistory({ game }: { game: GameView }) {
const history = game.mentat?.history ?? [];
if (!history.length) return null;
return (
<section className="min-w-0 space-y-3" aria-label="Mentat history">
<h3 className="font-serif text-xl">Your Mentat observations</h3>
<p className="fine">
These private observations were true when each question resolved. Later
hand changes can make an observation stale.
</p>
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
{history.map((entry) => (
<article
key={entry.event}
className="min-w-0 space-y-2 rounded-lg border border-[#485542] p-4"
>
<h4 className="font-serif text-lg break-words">{entry.card.name}</h4>
<p className="m-0 text-sm leading-6">
Turn {entry.turn} · {territory(entry.territory).name} · {playerName(game, entry.owner)}{' '}
asked {playerName(game, entry.target)} for <strong>{entry.weapon}</strong>.
</p>
<CardInspector card={entry.card} />
</article>
))}
</div>
</section>
);
}
+79 -187
View File
@@ -1,206 +1,98 @@
# Current development status
Updated 13 September 2026. This is the short navigation dashboard; detailed evidence remains in the linked records. **The complete twelve-faction goal is unfinished. Basic is playable; Advanced and unfinished expansion starts remain gated.** No completion or publication gate is opened by this document.
Updated 13 September 2026. **The full twelve-faction goal is unfinished. Basic
is playable; public Advanced and unfinished expansion/module starts remain
gated.** Prototypes do not open completion or publication gates.
## Evidence and source of truth
## Current checkpoint and work
- [Deferred movement arrival safety](DEFERRED_MOVEMENT_ARRIVAL.md) repairs the
combined Advanced sample's saved CHOAM decline deadlock. New unsupported moves
reject before prevention windows or card commitment; an old saved decline
returns the uncommitted move while preserving any existing Ornithopter escrow.
Simultaneous Ambassador/Terror priority remains a pending source question.
Focused rules and saved concurrency checks retain supported arrivals and
Karama's validation before payment. The reusable offline faction harness records
private traces, incomplete snapshots and source-bound reports. All six fresh
samples now finish; combined Advanced uses 1,303 actions and 35 JSON restores.
The exact earlier failed save also finishes after its recovered decline.
Guarded candidates still occur; broad verification is recorded in the checkpoint. Both existing
browser QA seats restored their hands and decisions after refresh. The server
was healthy and all 632 opening games and original seat records were preserved.
Latest verified checkpoint: `903d861`, deferred movement arrival safety.
Types, lint, **4,583 offline tests**, build and **43 HTTP tests** passed.
All **632 opening games** and original seat/recovery records were preserved
(665 after HTTP checks). The healthy server was reused; both existing Ecaz and
Ixian browser QA seats restored their private hands and pending decisions.
- [Expansion-faction development games](EXPANSION_FACTIONS_PROTOTYPE.md) connect
genuine setup for all seven expansion selections, using existing faction
controls, all four AI profiles and saved continuation. Ecaz now places its six
starting forces in Imperial Basin and keeps fourteen in reserve. The optional
three-card Ecaz variant remains separate and unfinished. Advanced Ixians have
an explicit saved per-lot decline for Richese offers; the actual exchange still
awaits its replacement-custody ruling. Complete-game samples
finish five of six games; combined Advanced reaches turn three, then stops
on the then-existing CHOAM movement/Ambassador arrival guard. The deferred
movement follow-up above repairs that saved deadlock while retaining the
unresolved simultaneous-arrival gate. Setup evidence does not open public starts or
establish full faction compliance. Saved-game checks passed after a necessary
restart for a stale server module cache.
[Deferred movement](DEFERRED_MOVEMENT_ARRIVAL.md) now rejects unsupported arrivals
before prevention windows or new card commitment. A legacy CHOAM decline returns
an uncommitted move for another choice while preserving reserved Ornithopter
custody. All six genuine faction sample games finish; combined Advanced completes
1,303 actions and 35 JSON restorations. Guarded arrival candidates still occur.
The exact prior failed save also finishes. Simultaneous Ambassador/Terror priority
remains unresolved, so these paths do not prove complete combined rules support.
- [Diplomat defense copying](DIPLOMAT_DEFENSE.md) connects one committed Worthless
card to an opposing base Shield or Snooper, with optional controls, all four AI
profiles, native trained-leader self-protection and mandatory discard. Saved
choices and concurrent resolution have focused recovery evidence. Lower retreat
and combined modules remain unfinished; no public gate opens. After the latest
outage, the existing server was healthy and all 563 opening games and original
seat records matched the backup. Final checks are recorded in the checkpoint.
The [Mentat question preview](MENTAT_QUESTION.md) now connects private naming and
card disclosure before leader posture and faction powers, with human controls,
all four AI profiles and saved continuation. Independent review and 22 focused
tests pass; two controlled-shuffle genuine-setup samples finish. Ordinary games
remain unactivated while the uniform private response UX question is pending.
Final broad results and Git delivery are recorded with the verified checkpoint.
- [Smuggler with Richese No-Field](SMUGGLER_NO_FIELD.md) connects the officially
permitted free physical companion, unchanged marker price, private controls,
all four AI profiles and saved cancellation/interception. Genuine CHOAM &
Richese Leader Skills setup now includes its two replacement/additional battle
cards. After the latest outage the server was healthy and all 529 opening games
and seat records matched the backup. Other combinations and public starts stay
gated; final checks and preservation totals are recorded in the checkpoint.
After the latest power failure, all 665 opening rooms and original seat/recovery
records matched the backup; the new Mentat QA room brings the total to 666 before
HTTP checks. Database integrity and foreign keys passed. The existing server
responds at localhost:3000 and is reused; its lock prevented a duplicate start.
The Mentat browser seat restored its private history and pending leader choice.
- [Sandmaster movement collection](SANDMASTER_MOVEMENT.md) connects explicit
ground routes, optional board-spice collection, controls, legal AI and saved
Fremen cancellation. Special relocation and combined modules remain missing;
multiple-pile collection stays guarded. No mode gate is opened.
## Authoritative navigation
- [Spice Banker battle spending](SPICE_BANKER_RUNTIME.md) connects sealed own
spice, separate support funding, survivor strength, traitor payment precedence,
private controls, all four AI profiles and saved resolution. Normal income
remains pending the recorded custody question. Broad results and browser
evidence are recorded in the checkpoint; no mode gate is opened.
- [Rules checklist](../game/reference.ts), topic `implementation-checklist` and
each feature's entries: track implementation, controls, AI, documentation and
verification, plus Missing, Prototyped, Integrated, Verified and Polished stages.
- [Rule decision index](RULE_DECISIONS.md): settled contracts, source precedence
and pending material interpretations. Consult it before reopening research.
- [Development guide](DEVELOPMENT.md) and [verification tools](VERIFICATION_WORKFLOW.md):
architecture, focused checks, source-bound reports, reusable faction samples,
private backup and saved-seat restoration.
- [Implementation history](IMPLEMENTATION_STATUS.md): newest first. Historical
evidence and outage records stay there and in feature documents; read bounded
relevant sections and verify old absence claims against current code/tests.
- [Efficiency pilot](EFFICIENCY_PILOT.md): the first three milestones are pushed;
measurement limits remain explicit. Reuse tools and checks, without promising
an unmeasured savings percentage.
- [Smuggler reserve shipment](SMUGGLER_SHIPMENT.md) connects the optional free
companion, total physical count, Guild rounding, controls, all four AI profiles
and saved interception. Independent review added missing-decision rejection.
Browser opt-out, actual shipment and refresh are exercised in a new room.
Battle collection and combined routes remain missing. After the latest outage,
the server was healthy and all 427 opening rooms remained unchanged; a fresh
backup captured 428. Broad results and final preservation are in the commit.
## Connected development capabilities
- [Leader battle effects](LEADER_BATTLE_EFFECTS.md) now connect both Rihani bands
and lower Mentat, Bureaucrat and Sandmaster effects. Private controls, all four
AI profiles, JSON/SQLite continuation and concurrent exchanges are covered.
A new browser room exercised separate inspection, draw, public return and refresh.
Three normal bands and Spice Banker, Diplomat and Smuggler remain missing.
The server responded after the outage; a fresh backup captured 393 rooms.
Broad check results and final preservation are recorded in the checkpoint commit.
These links define bounded working behavior, not complete module certification.
- [Suk Graduate](SUK_GRADUATE_RULES.md) now connects both casualty-rescue bands,
ordinary/elite counter routing, player controls, all four AI profiles and saved
continuation. Targeted browser rescue and refresh preserved two board forces,
seventeen reserves, one Tank and the private hand. Two Basic/Advanced samples
finished but did not encounter an eligible rescue; targeted tests cover that
effect. Advanced Atreides selection remains unavailable pending the recorded
Kwisatz Haderach question. After the latest reported outage, the server was
healthy and all 360 rooms were backed up; the 359 opening rooms and their seat
records were unchanged. Checkpoint-wide results are in the commit.
- [Planetologist](PLANETOLOGIST_RULES.md) now has bounded movement and Special-card
battle prototypes. Independent review and **84 focused tests** pass. Two genuine
four-profile Basic/Advanced samples finished with saved continuations and no
rejected actions; the Basic sample used both movement alternatives. Browser
gather, Special substitution and refresh were exercised in a new isolated room.
All 325 opening games remain preserved. The existing server was healthy after
the outage report, so it was reused. Broad results are in the checkpoint commit.
- [Juice of Sapho](JUICE_OF_SAPHO_RUNTIME.md#13-september-first-at-a-later-clean-movement-boundary)
now connects first among remaining unstarted movement turns after earlier
turns finish, including after Advanced Guild completes its turn. Existing
private controls, all four AI profiles and saved continuation use the same
server option. Other Sapho timing questions remain pending. Independent
review and focused checks pass; broad results are in the checkpoint commit.
- The [first Leader Skills prototype](LEADER_SKILLS_RUNTIME.md) connects all
fourteen physical cards, private setup, five battle disciplines, skilled capture,
death and own revival through controls, legal AI and saved choices. The [Planetologist follow-up](PLANETOLOGIST_RULES.md) adds movement and
Special-card battle controls, and Suk Graduate adds casualty rescue. The
battle-effects follow-up adds Rihani and three lower bands; remaining work is
listed in its contract. The user-requested [capture search](LEADER_SKILLS_CAPTURE.md)
resolves publicity through the known physical card; captured replacement and
broader combinations remain pending. No mode gate is opened.
- Following the reported power outage, the existing server responded normally,
the database integrity check passed with 291 rooms, and all 218 rooms in the
preceding handover baseline were unchanged. The saved QA seat restored its
same private hand and pending Storm decision after refresh. A new private
backup protects all 291 rooms. No server restart or database reset was needed.
- A second reported outage later stopped the server. All 292 rooms survived
integrity and preservation checks. A fresh backup preceded the necessary
server start; the Leader Skills QA seat restored its same private hand,
assignments and pending Storm choice. No database reset was performed.
- 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
`f0256c1` passed 4,264 offline tests, build and 40 HTTP tests; two seeded games
finished and the Fremen browser view restored its private token face. All 125
opening saved rooms remained unchanged. That checkpoint predates the current
free-entry, Cistern and carried-Ornithopter additions.
- Ix prototype checkpoint `5a76ae0`: types, lint, **4,211 offline tests**, build and **40 HTTP tests** passed. Genuine Basic/Advanced setup and two seeded complete simulations were exercised; a short browser game finished with private hand restoration. All 64 opening saved rooms were preserved; 31 QA rooms were added. This is bounded prototype evidence, not complete Ix compliance.
- [Rules implementation checklist](../game/reference.ts): `RULE_TOPICS`, topic `implementation-checklist`, and each feature's implementation, controls, AI, documentation and verification entries. Coverage is per stated boundary, not an implicit whole-game certificate.
- [Latest verified gameplay checkpoint](IMPLEMENTATION_STATUS.md): Truthtrance hand inventory, 13 September 2026 (`7454456`): types, lint, **4,200 offline tests**, production build and **40 HTTP tests** passed, with a phone/browser refresh journey and preserved saved games. These are recorded results, not checks rerun for this dashboard.
- Latest verification checkpoint `4a0b1ee`: genuine Advanced full-plan interaction tests; types, lint and **4,205 offline tests** passed. Private inspection restored after restart, with desktop/phone checks and all 64 saved games unchanged. No runtime change; prior build/HTTP evidence applies.
- [Development guide](DEVELOPMENT.md) defines focused verification; [rule decisions](RULE_DECISIONS.md) locates settled contracts and pending interpretations. Read the newest checkpoint and relevant feature document before consulting older log entries.
The user authorized a one-time local reset on 13 September: the 3,644 historical
rooms were archived privately and cleared. [Fresh checkpoint and preservation
policy](VERIFICATION_WORKFLOW.md#authorized-reset-13-september-2026) now apply to
new games; historical room IDs are no longer active local fixtures.
The first three efficiency milestones are pushed: tooling `6e26ba5`, typed
Advanced shipment commitments `32791ed`, and [hand inventory facts](TRUTHTRANCE_HAND_INVENTORY.md)
`7454456`. See [pilot measurements](EFFICIENCY_PILOT.md).
The current [Atreides full-plan audit](ATREIDES_FULL_PLAN_TIMING.md) verifies
Truthtrance/Voice/Prescience/Ghola interactions from genuine Advanced setup,
replacing the old Basic-then-flip test fixture. These existing paths pass without
a runtime change. Exact special-offer timing and target-first ordering remain
source interpretations; they are not settled by test results.
The development server is reused until a change or observed condition warrants
a restart. The user removed hourly restarts; preserve saved games and verify
restoration after necessary restarts. No recurring automation is installed.
| Area | Current connected work and evidence |
| --- | --- |
| Base/Advanced setup | [Genuine setup](ADVANCED_SETUP_TEST_SEAM.md), [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md), [Fremen cancellation](FREMEN_MOVEMENT_KARAMA_RULES.md), [Atreides full-plan audit](ATREIDES_FULL_PLAN_TIMING.md). Earlier Basic-then-flip fixtures do not establish current setup coverage. |
| Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement, separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. |
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md) and [No-Field](SMUGGLER_NO_FIELD.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md). Remaining bands and combinations are explicit in those contracts. |
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Jacurutu income, Testing Station and Shrine. Orgiz and contested/mixed cases remain pending. |
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) is connected; its after-victory inspection remains pending. A source audit or helper alone is not a completed effect. |
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. Richese card contracts and the checklist identify the other connected effects and explicit gaps. |
| Multiplayer | Saved rooms/seats, [recovery](../README.md#saved-seats-and-reconnecting), uncertain-request retry, [own-seat AI](AUTOPILOT.md), and [voluntary seat handover](SEAT_HANDOVER.md) have connected controls and recovery evidence. |
## Remaining readiness
| Area | Established evidence | Remaining gate |
| --- | --- | --- |
| 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 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, voluntary own-seat AI control and a [voluntary seat handover prototype](SEAT_HANDOVER.md). | Broader handover/disconnection acceptance, 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. |
| Area | Remaining gate |
| --- | --- |
| Basic core | Ordinary Guild repricing/transport settlement, broader truthful commitments, unresolved special timing and integrated rules/human/AI acceptance. |
| Six-faction Advanced | Base gaps, provisional special-Karama outcomes, remaining timing audits and authentic multiplayer/browser acceptance. Setup matrices and complete samples do not open public starts. |
| Twelve factions and all optional modules | Every missing effect, remaining skill bands, Ecaz card variant, Kull Wahad, unfinished Richese cards, Terror effects, occupation entitlement, Nexus interactions, contested Discoveries and complete games across valid combinations. Detailed boundaries remain in the checklist/decision index. |
| AI | Legal continuation for every supported function and distinct Easy/Medium/Hard/Brutal strategy; consistent upper-level strength ordering remains unproven. Defer broad calibration until functional coverage warrants it. |
| Multiplayer | Broader disconnected/abandoned-seat recovery, unattended continuation and network-failure acceptance, while preserving authoritative versioning, custody, privacy and saves. |
| Components and experience | Full component inventory/text verification, all readable inspectors, original artwork, animation/sound/accessibility/mobile polish, complete internal guidance and end-to-end human play. |
The [base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md) includes historical missing-feature rows. Its follow-ups supersede the Fremen cancellation and BG setup findings; [genuine setup](ADVANCED_SETUP_TEST_SEAM.md) and the [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md) supersede the old Basic-then-flip testing concern. They do not settle the remaining rules.
[AI calibration](AI_CALIBRATION.md), [multiplayer audit](MULTIPLAYER_AUDIT.md),
[component inventory](COMPONENT_INVENTORY.md) and [visual playtest](VISUAL_PLAYTEST.md)
record their measured scope. [README](../README.md) retains publication gates.
Likewise, the [Truthtrance readiness audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) predates [spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card-count facts](TRUTHTRANCE_CARD_COUNT.md) and [Basic shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md). Current `TruthFact` already includes these facts; do not rebuild them. Typed preparation and BG conversion now support the active reserve-shipment scope in base Advanced without Guild or optional modules. Broader facts, earlier-phase promises, remaining Advanced/expansion commitment scope and freeform interpretation remain incomplete.
## Working sequence and preservation
The voluntary [seat handover prototype](SEAT_HANDOVER.md) connects private one-time offers, recipient controls, exact saved retry proof and active-token custody fencing. It preserves existing game state and revokes previous owner recovery authority. Full disconnected-seat acceptance remains open. CHOAM Nexus after-victory inspection awaits the [private-window decision](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit); development continues independently.
Prototype every remaining function in dependency order, using existing controls,
legal AI and the saved-state model. Continue independent functions while material
rulings are pending. Then run integrated games, repair failures, deepen combination
and recovery coverage, calibrate AI and polish. Keep focused checks for crashes,
deadlocks, legality, custody and privacy; complete required broad checkpoint checks.
Use selective subagents and independent review for complex rules/privacy/persistence.
Commit and push verified checkpoints under **“Push all from now on”**, verify push
success, and stop pursuit at **80% weekly usage consumed** with the goal unfinished.
## Next milestone: rapid functional coverage
The first connected batch is the [Ixians & Tleilaxu prototype](IX_PROTOTYPE.md):
genuine setup, both expansion factions, full Ix deck and Sandtrout, shared human/AI
controls and saved continuation. The checklist now distinguishes its Prototyped
development stage from Partial rules coverage. The next connected action is the [CHOAM Nexus Collection trade](NEXUS_CHOAM_SECRET_ALLY.md),
with private controls, all four AI profiles and a saved payment/discard continuation.
Its after-victory inspection remains missing. The [first Discovery batch](DISCOVERY_PROTOTYPE.md)
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. 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
of all remaining functions into the game, then use integration and play to find
issues and refine them. Complete core certification no longer precedes independent
expansion prototyping. Final rules compliance and release requirements still apply.
1. Reconcile the existing checklist with current helpers, controls, AI and saved-state paths. Mark remaining work as missing, prototyped, integrated, verified or polished; retain explicit blockers and reuse completed features.
2. Implement the next connected batch across Basic, Advanced, factions or modules according to dependencies. Include real actions, usable controls, a legal AI path and saved continuation. Keep fast checks for crashes, deadlocks, legality, custody, privacy and save integrity; avoid exhaustive refinement of each small feature before broad coverage exists.
3. Continue independent functions while [material rulings](RULE_DECISIONS.md) are pending. Ordinary Guild repricing/settlement and special Karama interpretations remain unresolved; do not invent their outcomes to fill the inventory.
4. Integrate prototypes and play complete games to discover issues, then deepen combination coverage, multiplayer recovery, AI calibration and visual polish. Required checkpoint checks, reviewed Git pushes, selective subagents and the 80%-used weekly stop remain in force. Keep unfinished modes and publication gated until acceptance.
Consult [AI calibration](AI_CALIBRATION.md), [multiplayer audit](MULTIPLAYER_AUDIT.md), [autopilot](AUTOPILOT.md), [component inventory](COMPONENT_INVENTORY.md) and [visual playtest](VISUAL_PLAYTEST.md) for their measured scope and follow-ups. Use current code and later checkpoints to interpret historical results. Publication still requires the completion and integrated verification gates in [README](../README.md).
The user-authorized 13 September reset happened once; preserve all games created
since it. Keep `.wrangler/state`, credentials and private backup artifacts out of
Git. Reuse a healthy server; restart only for a relevant change or observed problem,
with a fresh backup, safe human-play boundary and verified restoration. No hourly
restart or recurring maintenance automation is installed; do not recreate one.
+27
View File
@@ -1,5 +1,32 @@
# Dune implementation status
## 13 September 2026 — Private Mentat question preview and outage recovery
[Mentat](MENTAT_QUESTION.md) has a connected private pre-plan question preview:
canonical named weapons, a uniform target-owned card-show step, owner/target-only
historical observations and subsequent leader concealment before faction powers.
The shown card stays in hand and does not bind a plan. The private opt-in is absent
from player views and cannot be enabled through actions. Ordinary Leader Skills
games remain unchanged pending the user's forced-response UX exception decision;
empty hands skip with an explicit unavailable receipt and no fabricated answer.
Independent review and 22 focused tests cover legality, custody, sequencing,
private controls, all four AI profiles, JSON/SQLite restoration and stale/competing
writes. Two complete genuine-setup samples deliberately deal Mentat through a
controlled initial skill shuffle: Basic 100 actions/3 JSON restores, Advanced
507/14, each with one private observation and no rejected candidate. These are
bounded preview paths, not complete skill or expansion acceptance. The separate
browser QA uses a staged conserved battle and verifies private disclosure, a
readable inspector, refresh recovery and the right to conceal after asking.
After another reported power failure, all 665 opening rooms and original seat,
recovery and receipt rows matched the backup. A fresh backup captured 666 rooms,
including the isolated preview; database integrity and foreign keys passed. The
existing server responded at localhost:3000, and its lock prevented a duplicate
start. The saved Mentat browser seat restored its hand, resources, observation and
pending posture. No reset, recurring automation or publication was introduced.
The verified Git checkpoint records final broad results and preservation counts.
## 13 September 2026 — Deferred movement arrival safety and reusable samples
[Deferred movement](DEFERRED_MOVEMENT_ARRIVAL.md) fixes the combined Advanced
+6 -3
View File
@@ -52,8 +52,11 @@ publisher instruction to announce a name.
## Missing and guarded work
[Battle-effects follow-up](LEADER_BATTLE_EFFECTS.md) connects both Rihani bands
and the lower Mentat, Bureaucrat and Sandmaster effects. Their normal questioning,
payment redirection and Spice Banker income remain missing. [Banker battle spending](SPICE_BANKER_RUNTIME.md)
and the lower Mentat, Bureaucrat and Sandmaster effects. A separate
[Mentat question preview](MENTAT_QUESTION.md) connects private naming/disclosure,
posture and saved history under an explicit private opt-in. The uniform mandatory
response step awaits the user's UX decision; ordinary activation remains disabled.
Bureaucrat payment redirection and Spice Banker income remain missing. [Banker battle spending](SPICE_BANKER_RUNTIME.md)
now connects its lower band with separate sealed funds and survivor strength. [Smuggler shipping](SMUGGLER_SHIPMENT.md)
connects its optional normal reserve bonus and the [owned No-Field companion](SMUGGLER_NO_FIELD.md).
[Diplomat defense](DIPLOMAT_DEFENSE.md) connects its normal band for a physical
@@ -72,7 +75,7 @@ Before enabling them, add response-driven automatic revival continuation
coverage; the current before/after revival observer does not certify deferred
Tleilaxu response chains. The other source-contract timing questions also remain
open. Planetologist now has bounded movement and battle controls as described below.
Remaining normal-band payments and questions, Smuggler battle collection,
Remaining normal-band payments, ordinary Mentat activation, Smuggler battle collection,
Diplomat retreat and the other guarded skill combinations still need connected
effects.
+101
View File
@@ -0,0 +1,101 @@
# Mentat before Battle Plans
13 September 2026. This developer preview adds the normal Mentat question to
the existing lower-band strength bonus. It requires an explicit private preview
opt-in while the user decides whether to accept the uniform card-show step.
Ordinary Leader Skills starts do not activate it. Leader Skills and public mode
acceptance remain partial. [Common skill rules](LEADER_SKILLS_RULES.md) govern assignment,
capture, death and the right to conceal a skilled leader.
## Source contract
The physical card permits asking the battle opponent about one **specific named
weapon**, such as Crysknife or Poison Tooth. If held, the opponent must show that
card; otherwise they choose another Treachery Card to show. The card says “show
you”, which makes the answer private to the questioner, not a public reveal.
The [archived physical face](https://cdn.anyfinder.eu/assets/NQOmfvkJitFsQncGJ73kLzv7aC1w7Jp1kQAyy5CJ0pOoj8RlSEN3veAO6wBnw8wV?height=768)
and [designer Jack Reda's walkthrough, 17:37–18:16](https://www.youtube.com/watch?v=XT_azRVLq_0&t=1057s)
agree. The persistent source archive and provenance are listed in the common
skill contract; no inaccessible image text was reconstructed.
The question is optional, and an accepted question requires a truthful answer.
The shown card stays in the opponent's hand and need not appear in their plan.
The owner may ask first, then conceal the same Mentat for possible selection.
Skills precede faction powers: the printed example specifically puts Mentat
before Atreides Prescience. [GF9 CHOAM & Richese, pp. 9 and 12](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf)
A named weapon uses its held/default category, not every role it could take in a
future Battle Plan. Weirding Way defaults to a weapon; Chemistry defaults to a
defense. Heroes, Worthless cards and unrelated Specials are not named weapons.
If the weapon is absent, the fallback may be any held Treachery Card.
[GF9 November FAQ, p. 5](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=5)
## Boundaries and continuation
Offer the question for a living, native face-up Mentat against its actual battle
opponent before skill posture and faction powers. A captured Mentat has only its
lower +2 bonus. Resolve the selected question and its compulsory disclosure as one
saved sequence, then restore the ordinary posture choice. Do not reopen older
battles that have already passed this boundary.
Use canonical weapon names without consulting the opponent's hidden hand to
construct the questioner's choices. After naming, show only the required matching
card, or let the opponent choose a fallback from their own hand. The public record
may identify the question and named weapon; it must never disclose the answer's
physical identity, fallback choices or an extra private hand snapshot.
The observation records what was shown at that time. Later play, transfers or a
leader's death do not turn it into current hand knowledge or require that card in
a plan. Retaining already learned information after death is a persistence
necessity, not a new skill benefit or an additional publisher ruling.
The sources give no fallback when the named weapon is absent and the opponent
has an empty hand. The prototype leaves asking unavailable with an explicit reason
and automatically skips that unavailable opportunity so battle can proceed.
It records no answer. This is an unfinished case, not a
printed ban. No new Karama counter to the skill or public disclosure is inferred.
The pending naming/disclosure sequence must retain exact decision ownership and
battle identity across JSON/SQLite restoration. Invalid, stale or competing
requests must not repeat an observation, change card custody, bypass posture or
start faction powers early. Optional hand-changing actions wait for this short
sequence to complete; that serialization is an explicit implementation boundary.
Every accepted question uses the same target-owned private card-show step,
including an exact match or a sole fallback. Automatically completing only some
answers would reveal held/absent information through different public stages.
Target controls alone receive the eligible physical cards; public decision shape,
logs and number of response steps stay the same across equal-size hands.
That uniform step is an exception to the user's preference to omit forced
confirmations. The user has been asked to approve it or leave the ability disabled
pending another design. No answer has been applied. Private QA can exercise the
reviewable preview; normal activation and any claim of approved UX remain pending.
## Preview evidence and remaining acceptance
Independent review and 22 focused tests cover the common question/answer sequence,
all four AI profiles, private projected controls, exact-match versus fallback
privacy, canonical weapon roles, captured lower-only behavior, saved ownership,
stale/duplicate rejection and competing SQLite writes. Ordinary Basic/Advanced
base and CHOAM skill games stay unactivated; actions cannot set the private flag.
Two complete base-faction samples use genuine Basic/Advanced skill setup, with a
controlled initial skill shuffle to include Mentat. They do not stage hands,
forces or phases. Basic completed 100 actions with three JSON restorations at
turn one; Advanced completed 507 actions with fourteen restorations at turn five.
Each produced one private observation and then reached Atreides Prescience;
neither rejected a candidate. All four profiles participated. These are targeted
preview paths with force/skill custody checks, not general card-custody coverage,
AI calibration or complete module certification.
An isolated browser QA room uses genuine skill setup followed by an explicitly
staged, conserved battle. Naming Lasgun led to the same target-owned private step,
then a Crysknife observation and the ordinary leader posture choice. The owning
seat restored its hand, resources, observation and pending posture after the
reported power failure. The observed card inspector was readable, and the leader
could move behind the shield after asking. Production SQLite tests separately
verify that the opponent and questioner alone retain the observation.
The checkpoint's source-bound broad results are recorded with its Git commit.
Normal activation still awaits the uniform-response UX answer; empty hands and
other module combinations remain unfinished. No mode or publication gate opens.
+2 -1
View File
@@ -12,6 +12,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi
| Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. The officially permitted owned No-Field plus a separate free force is now prototyped at the unchanged one-marker price. Legacy unstamped ordinary declarations keep their quoted full price. | [Ordinary contract](SMUGGLER_SHIPMENT.md), [No-Field combination](SMUGGLER_NO_FIELD.md) |
| Sandmaster movement | One optional board spice per distinct entered territory on an explicit legal route; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing; multiple-pile collection remains guarded. | [Source and connected prototype](SANDMASTER_MOVEMENT.md) |
| Diplomat defense | With no actual defense, one physical Worthless card in either Battle Plan slot may copy the opponent's canonical base Shield or Snooper. The same native trained disc may protect itself. The public after-reveal/before-traitor choice and mandatory discard after traitor preemption are documented implementation inferences. Lower retreat and modified or multirole defenses remain guarded. | [Source and connected prototype](DIPLOMAT_DEFENSE.md) |
| Mentat question preview | The physical card says to show the card to the questioner, privately; it stays in hand and does not bind a plan. Ask before posture/faction powers. A uniform target response protects held/absent privacy but awaits approval as an exception to skipping forced confirmations; standard activation stays disabled. Empty hands skip with no fabricated answer. | [Source and preview](MENTAT_QUESTION.md) |
| Leader battle effects | Rihani normal peek and reshuffle precede a separate committed optional draw; ordering is an explicit printed-order inference. Unused excludes actual calls, not mere disclosure. Mentat/Bureaucrat change scores; Sandmaster adds board spice. Multiple piles and Face Dancer composition stay guarded. | [Source contract and prototype](LEADER_BATTLE_EFFECTS.md) |
| Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) |
| Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) |
@@ -39,7 +40,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and remaining battle timing stay guarded. Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) |
| Mirror Weapon | Whether copied Tooth/Artillery mandatory-discard rules attach to the physical Mirror remains unanswered; arithmetic helpers do not admit the full battle card. | [Engine audit](MIRROR_WEAPON_ENGINE_AUDIT.md) |
| Semuta Drug | Full-hand take-before-discard and the privacy-neutral immediate reaction protocol remain unresolved. Typed discard continuations already exist; they are not activation. | [Source update](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) |
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody (user question sent), Smuggler survival settlement, Mentat empty-hand/death disclosure, Diplomat lower retreat ordering, modified-defense inheritance and remaining simultaneous optional timing need resolution before those cases are enabled. These are recorded source gaps, not answered user rulings. | [Material boundaries](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources), [Diplomat boundary](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody (user question sent), Smuggler survival settlement, Mentat empty-hand fallback (private historical observations persist after death), Diplomat lower retreat ordering, modified-defense inheritance and remaining simultaneous optional timing need resolution before those cases are enabled. These are recorded source gaps, not answered user rulings. | [Material boundaries](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources), [Diplomat boundary](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
| Suk Graduate / Atreides | Whether rescued dialed casualties count toward the seven-loss Kwisatz Haderach threshold remains unresolved. The user question has been sent; Advanced Atreides rescue stays guarded. | [Source and runtime boundary](SUK_GRADUATE_RULES.md) |
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) |
+19 -1
View File
@@ -1438,7 +1438,7 @@ function policyActions(g: GameView): Action[] {
if (g.decision) {
if (g.decision.player !== me.id) return [];
const d = g.decision;
if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival') return [];
if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival' || d.kind === 'mentatQuestion') return [];
if (d.kind === 'choamAudit')
return [{ type: 'decision', event: d.event, audit: true }];
if (d.kind === 'choamAuditPayment') {
@@ -3659,6 +3659,24 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
/** Obligations apply across policy branches, including choosing to move first. */
export function botActions(g: GameView): Action[] {
const mentat = g.mentat?.pending;
if (mentat) {
if (mentat.player !== g.me || g.decision?.kind !== 'mentatQuestion' ||
g.decision.player !== g.me || g.decision.event !== mentat.event ||
g.truthtrance || g.response || g.automaticContinuationPending) return [];
if (mentat.stage === 'name') {
if (mentat.blocked || !mentat.weapons.length)
return [{ type: 'decision', event: mentat.event, decline: true }];
const preferred = rank(g) === 0 ? mentat.weapons :
['Poison Tooth', 'Lasgun', 'Crysknife', 'Chaumas', ...mentat.weapons];
const weapon = preferred.find((name) => mentat.weapons.includes(name))!;
return [{ type: 'decision', event: mentat.event, weapon }];
}
const card = [...mentat.cards].sort((a, b) =>
technologyCardValue(g, a) - technologyCardValue(g, b) || a.id.localeCompare(b.id),
)[0];
return card ? [{ type: 'decision', event: mentat.event, card: card.id }] : [];
}
const skill = g.leaderSkills;
if (skill && !g.truthtrance && !g.response && !g.phaseOpening) {
if (skill.battleChoice)
+167
View File
@@ -1,5 +1,6 @@
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
import { leaderSkillCard, type LeaderSkillId } from './leader-skill-cards';
import { MENTAT_EMPTY_HAND, MentatQuestionError, mentatQuestionModeSupported, mentatWeaponNames, mentatHand, quoteMentatReveal, mentatSignature, type MentatQuestionReceipt, type MentatObservation, type MentatView } from './mentat-question';
import {
canUsePlanetologistBattleSpecial,
isPlanetologistBattleSpecialCard,
@@ -593,6 +594,10 @@ export type DiplomatDefenseReceipt = DiplomatDefenseQuote & {
signature: string;
};
export type Battle = {
/** Only newly created supported battles enter the pre-plan Mentat question. */
mentatQuestionVersion?: 1;
mentatQuestionEvent?: string;
mentatQuestion?: MentatQuestionReceipt;
/** New battles opt into the revealed Diplomat step; legacy battles are not reopened. */
diplomatDefenseVersion?: 1;
diplomatDefenseEvent?: string;
@@ -741,6 +746,7 @@ export type Decision =
| { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[] }
| { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' }
| { kind: 'fullPlanOffer'; player: string }
| { kind: 'mentatQuestion'; player: string; event: string; owner: string; target: string; stage: 'name' | 'reveal'; weapon: string | null }
| { kind: 'fullPlanRead'; player: string; target: string }
| { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string }
| {
@@ -1461,6 +1467,10 @@ export type Game = {
/** Absent on legacy rooms whose starting cards were already dealt. */
setupStage?: 'prediction' | 'skillTreachery' | 'leaderSkills' | 'traitors' | 'forces';
leaderSkills?: LeaderSkillsState;
/** Private development opt-in pending the uniform Mentat response UX decision. */
mentatQuestionPreview?: true;
mentatHistory?: MentatObservation[];
mentatHistoryEvents?: string[];
advanced: boolean;
expansions: string[];
players: Player[];
@@ -4967,6 +4977,7 @@ function leaderSkillAssignmentUnavailable(
: null;
}
function leaderSkillsIntegrity(g: Game) {
mentatQuestionIntegrity(g);
diplomatDefenseIntegrity(g);
const noFieldShipment = leaderSkillNoFieldIntegrity(g);
sandmasterIntegrity(g);
@@ -6906,8 +6917,155 @@ function strongholdCopyChoices(g: Game, player: string): StrongholdId[] {
(card) => card.id !== MOBILE_STRONGHOLD && controllers[card.id] === player,
).map((card) => card.id);
}
function mentatRule<T>(quote: () => T): T {
try { return quote(); } catch (error) {
if (error instanceof MentatQuestionError) throw new RuleError(error.message);
throw error;
}
}
function createMentatQuestion(g: Game) {
const b = g.battle!;
if (b.mentatQuestionVersion !== 1) return;
const owner = [b.attacker,b.defender].find(id => nativeLeaderSkill(g,id)?.skill === 'mentat');
if (!owner) return;
const trained = nativeLeaderSkill(g,owner)!, target = b.attacker === owner ? b.defender : b.attacker;
const event = crypto.randomUUID();
const receipt: MentatQuestionReceipt = {event,battle:b.event!,turn:g.turn,territory:b.territory,
owner,target,leader:trained.leader,stage:'name',weapon:null,card:null,
hand:mentatRule(() => mentatHand(getPlayer(g,target).hand,g.expansions)),signature:''};
if (!receipt.hand.length) {receipt.stage = 'unavailable';log(g,MENTAT_EMPTY_HAND,{faction:getPlayer(g,owner).faction,name:'Mentat'});}
receipt.signature = mentatSignature(receipt);
b.mentatQuestion = receipt;
b.mentatQuestionEvent = event;
}
function mentatQuestionIntegrity(g: Game) {
requireRule(g.mentatQuestionPreview === undefined || g.mentatQuestionPreview === true,
'The private Mentat preview flag is invalid.');
const history = g.mentatHistory, events = g.mentatHistoryEvents;
requireRule(history === undefined && events === undefined || Array.isArray(history) && Array.isArray(events) &&
history.length === events.length && new Set(events).size === events.length && new Set(history.map(observation => observation?.event)).size === history.length,
'The saved Mentat observations lost their independent event history.');
for (const observation of history ?? []) {
requireRule(observation && typeof observation === 'object' && typeof observation.event === 'string' && observation.event.length > 0 &&
events!.filter(event => event === observation.event).length === 1 &&
typeof observation.battle === 'string' && observation.battle.length > 0 &&
Number.isSafeInteger(observation.turn) && observation.turn >= 1 && observation.turn <= g.turn &&
typeof observation.territory === 'string' && observation.territory.length > 0 &&
observation.owner !== observation.target && g.players.some(p => p.id === observation.target) &&
g.players.some(p => p.id === observation.owner && p.leaders.some(l => l.id === observation.leader)) &&
mentatWeaponNames(g.expansions).includes(observation.weapon) && observation.signature === mentatSignature(observation),
'The saved Mentat observation changed its question, audience or event.');
mentatRule(() => mentatHand([observation.card],g.expansions));
}
const b = g.battle, receipt = b?.mentatQuestion;
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'mentatQuestion');
if (!receipt) {
requireRule(receipt === undefined && !b?.mentatQuestionEvent && !decisions.length,
'The saved Mentat question lost its original receipt.');
requireRule(b?.mentatQuestionVersion === undefined || b.mentatQuestionVersion === 1 && g.mentatQuestionPreview === true && !!g.leaderSkills,
'The saved Mentat question has an invalid battle version.');
return;
}
requireRule(b && b.mentatQuestionVersion === 1 && g.mentatQuestionPreview === true && g.leaderSkills && mentatQuestionModeSupported(g) &&
g.status === 'playing' && g.phase === 6 && receipt.battle === b.event && receipt.turn === g.turn && receipt.territory === b.territory &&
typeof receipt.event === 'string' && receipt.event.length > 0 && receipt.event === b.mentatQuestionEvent &&
[b.attacker,b.defender].includes(receipt.owner) && [b.attacker,b.defender].includes(receipt.target) && receipt.owner !== receipt.target &&
receipt.signature === mentatSignature(receipt),
'The saved Mentat question changed its battle, audience or original event.');
mentatRule(() => mentatHand(receipt.hand,g.expansions));
requireRule(['name','reveal','answered','declined','unavailable'].includes(receipt.stage), 'The saved Mentat question has an invalid stage.');
requireRule(receipt.stage === 'unavailable' ? receipt.hand.length === 0 : receipt.hand.length > 0,
'The saved Mentat opportunity changed its empty-hand boundary.');
if (receipt.stage === 'name' || receipt.stage === 'declined' || receipt.stage === 'unavailable')
requireRule(receipt.weapon === null && receipt.card === null, 'The unasked Mentat question cannot contain an answer.');
else {
requireRule(typeof receipt.weapon === 'string', 'The saved Mentat weapon question is missing.');
const quote = mentatRule(() => quoteMentatReveal(receipt.hand,receipt.weapon!,g.expansions));
requireRule(receipt.stage === 'reveal' ? receipt.card === null :
receipt.card && quote.cards.some(card => JSON.stringify(card) === JSON.stringify(receipt.card)),
'The saved Mentat disclosure does not answer its exact original question.');
}
const pending = receipt.stage === 'name' || receipt.stage === 'reveal';
requireRule(decisions.length === (pending ? 1 : 0), 'The saved Mentat question lost or reopened its unique decision.');
if (pending) {
const assignment = nativeLeaderSkill(g,receipt.owner);
requireRule(!b.revealed && !Object.keys(b.plans).length && !Object.keys(b.leaderSkillHidden ?? {}).length &&
!b.preparation && !g.response && assignment?.skill === 'mentat' && assignment.leader === receipt.leader &&
getPlayer(g,receipt.owner).leaders.some(l => l.id === receipt.leader && !l.dead && !l.capturedBy && !l.gholaBy) &&
JSON.stringify(getPlayer(g,receipt.target).hand) === JSON.stringify(receipt.hand),
'The pending Mentat question changed its trainer, timing or reserved private hand.');
for (const card of receipt.hand) requireRule(physicalTreacheryCards(g).filter(c => c.id === card.id).length === 1,
'The pending Mentat response must retain unique physical card custody.');
const decision = decisions[0];
requireRule(decision === g.decision && decision.event === receipt.event && decision.owner === receipt.owner && decision.target === receipt.target &&
decision.player === (receipt.stage === 'name' ? receipt.owner : receipt.target) && decision.stage === receipt.stage && decision.weapon === receipt.weapon,
'The Mentat decision no longer matches its atomic question and answering player.');
}
const observations = (history ?? []).filter(observation => observation.event === receipt.event);
requireRule(observations.length === (receipt.stage === 'answered' ? 1 : 0),
'The completed Mentat question lost or duplicated its private observation.');
if (observations.length) {
const {stage: _stage,hand: _hand,signature: _signature,...answer} = receipt;
const {signature: _observationSignature,...observation} = observations[0];
requireRule(JSON.stringify(answer) === JSON.stringify(observation), 'The Mentat historical answer changed after disclosure.');
}
}
function projectedMentat(g: Game, viewer: string): MentatView {
const receipt = g.battle?.mentatQuestion;
const pending = receipt && (receipt.stage === 'name' || receipt.stage === 'reveal') ? receipt : null;
return {pending: pending ? {event:pending.event,owner:pending.owner,target:pending.target,
player:pending.stage === 'name' ? pending.owner : pending.target,stage:pending.stage as 'name'|'reveal',weapon:pending.weapon,
weapons:pending.stage === 'name' && viewer === pending.owner && pending.hand.length ? mentatWeaponNames(g.expansions) : [],
cards:pending.stage === 'reveal' && viewer === pending.target ?
mentatRule(() => quoteMentatReveal(pending.hand,pending.weapon!,g.expansions)).cards : [],
blocked:pending.stage === 'name' && !pending.hand.length ? MENTAT_EMPTY_HAND : null} : null,
history:(g.mentatHistory ?? []).filter(observation => [observation.owner,observation.target].includes(viewer))
.map(({signature: _signature,...observation}) => structuredClone(observation))};
}
function actMentatQuestion(g: Game, decision: Extract<Decision,{kind:'mentatQuestion'}>, action: Action) {
const receipt = g.battle!.mentatQuestion!;
requireRule(action.event === receipt.event, 'Choose the current Mentat question event.');
if (decision.stage === 'name') {
if (action.decline === true) {
requireRule(Object.keys(action).every(key => ['type','event','decline'].includes(key)), 'Decline only this Mentat question.');
receipt.stage = 'declined';
log(g,`${getPlayer(g,receipt.owner).name} declined the optional Mentat question.`);
} else {
requireRule(Object.keys(action).every(key => ['type','event','weapon'].includes(key)), 'Name only one specific Mentat weapon.');
const weapon = stringField(action.weapon);
mentatRule(() => quoteMentatReveal(receipt.hand,weapon,g.expansions));
receipt.weapon = weapon;
log(g,`${getPlayer(g,receipt.owner).name} asked ${getPlayer(g,receipt.target).name} about ${weapon} with Mentat. The card shown is private and need not be played.`);
// Every question uses the same target-owned response, so public timing
// cannot reveal whether the named weapon was actually held.
receipt.stage = 'reveal';
}
} else {
requireRule(Object.keys(action).every(key => ['type','event','card'].includes(key)), 'Show exactly one held Treachery Card for Mentat.');
const quote = mentatRule(() => quoteMentatReveal(receipt.hand,receipt.weapon!,g.expansions));
const card = quote.cards.find(card => card.id === action.card);
requireRule(card, 'Choose a held card that answers this Mentat question.');
receipt.card = {...card}; receipt.stage = 'answered';
}
if (receipt.stage === 'answered') {
const {stage: _stage,hand: _hand,signature: _signature,...answer} = receipt;
const observation = {...answer,weapon:receipt.weapon!,card:receipt.card!,signature:''};
observation.signature = mentatSignature(observation);
(g.mentatHistory ??= []).push(observation);
(g.mentatHistoryEvents ??= []).push(observation.event);
log(g,`${getPlayer(g,receipt.target).name} privately showed one Treachery Card to ${getPlayer(g,receipt.owner).name}.`);
}
receipt.signature = mentatSignature(receipt);
beginBattlePowers(g);
}
function beginBattlePowers(g: Game) {
const b = g.battle!;
if (b.mentatQuestion?.stage === 'name' || b.mentatQuestion?.stage === 'reveal') {
const receipt = b.mentatQuestion;
g.decision = {kind:'mentatQuestion',player:receipt.stage === 'name' ? receipt.owner : receipt.target,
event:receipt.event,owner:receipt.owner,target:receipt.target,stage:receipt.stage as 'name'|'reveal',weapon:receipt.weapon};
return;
}
if (g.leaderSkills) {
b.leaderSkillHidden ??= {};
const pending = [b.attacker, b.defender].find((id) =>
@@ -19634,6 +19792,8 @@ function applyActionInner(
requireRule(g.status !== 'finished', 'This game has ended.');
const t = action.type;
if (t === 'advanceBots') return g;
requireRule(!(g.battle?.mentatQuestion?.stage === 'name' || g.battle?.mentatQuestion?.stage === 'reveal') || t === 'decision',
'Resolve the current Mentat question before another action.');
if (t === 'nexusCardChoice') { decideNexusCard(g, p, action); return g; }
requireRule(g.nexusCards?.phase?.stage !== 'drawing', 'Finish the closing Nexus card choices first.');
if (t === 'nexusTraitorDraw') { playNexusTraitorDraw(g, p, action); return g; }
@@ -19949,6 +20109,10 @@ function applyActionInner(
);
g.decision = null;
requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.');
if (decision.kind === 'mentatQuestion') {
actMentatQuestion(g, decision, action);
return g;
}
if (decision.kind === 'ixRicheseTechnology') {
const pending = g.pendingIxRicheseTechnology!;
requireRule(action.event === pending.event && action.decline === true &&
@@ -22628,6 +22792,7 @@ function applyActionInner(
g.battle = {
...choice,
event: battleEvent,
...(g.mentatQuestionPreview === true && g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 1 as const } : {}),
...(byFaction(g, 'richese')
? { preLeader: { event: battleEvent, ready: [], closed: false } }
@@ -22638,6 +22803,7 @@ function applyActionInner(
revealed: false,
traitorCalls: {},
};
createMentatQuestion(g);
beginStrongholdBattle(g);
log(
g,
@@ -23096,6 +23262,7 @@ export function viewGame(state: Game, id: string) {
setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null,
setupPending: setupPending(g),
leaderSkills: projectedLeaderSkills(g, id),
mentat: projectedMentat(g, id),
rihani: projectedRihani(g, id),
advanced: g.advanced,
dukeVidal: g.dukeVidal
+2 -2
View File
@@ -37,8 +37,8 @@ export const LEADER_SKILL_CARDS = [
id: 'mentat',
name: 'Mentat',
normal: [
'Before Battle Plans, name a specific weapon and ask whether your opponent holds it. They reveal that card if held, or a different Treachery Card otherwise.',
'Revealing the named weapon does not require your opponent to use it.',
'Before Battle Plans, you may name a specific weapon and ask whether your opponent holds it. They privately show you that card if held, or choose another Treachery Card to show you otherwise.',
'The shown card stays in their hand and need not be used in battle. You may then move your Mentat behind the shield for possible use.',
],
battle: ['Add 2 to this leader’s strength.'],
},
+120
View File
@@ -0,0 +1,120 @@
import { treacheryDeck, type Card } from './cards';
import { richeseCards } from './richese-cards';
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
export class MentatQuestionError extends Error {}
export const MENTAT_EMPTY_HAND =
'Mentat cannot ask while the opponent has no Treachery Card to show; the empty-hand ruling remains unresolved. This question was skipped without a disclosure.';
export type MentatQuestionReceipt = {
event: string;
battle: string;
turn: number;
territory: string;
owner: string;
target: string;
leader: string;
stage: 'name' | 'reveal' | 'answered' | 'declined' | 'unavailable';
weapon: string | null;
card: Card | null;
/** Private snapshot for this atomic question; never a later hand obligation. */
hand: Card[];
signature: string;
};
export type MentatObservation = Omit<
MentatQuestionReceipt,
'stage' | 'hand' | 'weapon' | 'card'
> & { weapon: string; card: Card };
export type MentatView = {
pending: null | {
event: string;
owner: string;
target: string;
player: string;
stage: 'name' | 'reveal';
weapon: string | null;
weapons: string[];
cards: Card[];
blocked: string | null;
};
history: Omit<MentatObservation, 'signature'>[];
};
export function mentatQuestionModeSupported(game: {
expansions: readonly string[];
homeworlds?: unknown;
nexusCards?: unknown;
discoveries?: unknown;
discoveryEnabled?: unknown;
strongholdCards?: unknown;
techTokens?: unknown;
}): boolean {
return (
(!game.expansions.length ||
(game.expansions.length === 1 && game.expansions[0] === 'choam')) &&
!game.homeworlds &&
!game.nexusCards &&
!game.discoveries &&
!game.discoveryEnabled &&
!game.strongholdCards &&
!game.techTokens
);
}
function inventory(expansions: readonly string[]): Card[] {
return [
...treacheryDeck(expansions),
...(expansions.includes('choam') ? richeseCards() : []),
];
}
/** Printed/default held-card roles, shared with Tupile; never battle-slot legality. */
export function mentatWeaponNames(expansions: readonly string[]): string[] {
return [
...new Set(
inventory(expansions)
.filter(
(card) =>
quoteTupileIntelligenceAnswer([card], 0, 'weapons').count === 1,
)
.map((card) => card.name),
),
];
}
export function mentatHand(
hand: readonly Card[],
expansions: readonly string[],
): Card[] {
const canonical = new Map(
inventory(expansions).map((card) => [card.id, card]),
);
if (
!Array.isArray(hand) ||
hand.some(
(card) =>
!card ||
!canonical.has(card.id) ||
JSON.stringify(card) !== JSON.stringify(canonical.get(card.id)),
) ||
new Set(hand.map((card) => card.id)).size !== hand.length
)
throw new MentatQuestionError(
'Mentat needs a valid uniquely held physical Treachery hand.',
);
return hand.map((card) => ({ ...card }));
}
export function quoteMentatReveal(
hand: readonly Card[],
weapon: string,
expansions: readonly string[],
): { cards: Card[] } {
if (!mentatWeaponNames(expansions).includes(weapon))
throw new MentatQuestionError('Name one specific canonical weapon.');
const held = mentatHand(hand, expansions);
if (!held.length) throw new MentatQuestionError(MENTAT_EMPTY_HAND);
const matching = held.filter((card) => card.name === weapon);
return {
cards: matching.length ? matching : held,
};
}
export function mentatSignature(
receipt: MentatQuestionReceipt | MentatObservation,
): string {
return JSON.stringify({ ...receipt, signature: undefined });
}
+6 -5
View File
@@ -3579,16 +3579,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income remains unfinished.',
'Diplomat may use one committed Worthless card as the opponent’s base Shield or Snooper if you played no defense. After both plans reveal, choose the card or decline before traitor decisions. A native Diplomat may protect its own selected trained leader this way; a captive grants no upper effect. The chosen Worthless must be discarded afterward, even if you win. Lower retreat and modified defenses remain unfinished.',
'Normal Mentat questioning, Bureaucrat payments, special Sandmaster relocation and Smuggler battle collection, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
'Mentat has a private developer preview for naming a specific weapon before posture and faction powers. The target privately shows the named held card or chooses another held card. It stays in hand and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.',
'Bureaucrat payments, special Sandmaster relocation and Smuggler battle collection, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
],
related: ['setup','choam-modules','implementation-checklist'],
checklist: [
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion and optional Sandmaster ground-route collection are connected. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; normal income, other remaining bands, Diplomat retreat and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/diplomat-defense.ts','game/engine.ts'] },
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. Diplomat offers a named committed Worthless choice or decline after public reveal. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion and optional Sandmaster ground-route collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; normal income, other remaining bands, Diplomat retreat and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/diplomat-defense.ts','game/engine.ts'] },
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. Diplomat offers a named committed Worthless choice or decline after public reveal. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments, Sandmaster route collection funded Banker commitments and optional Diplomat copying from public revealed defenses. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/DIPLOMAT_DEFENSE.md'] },
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] },
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/DIPLOMAT_DEFENSE.md'] },
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/mentat-question.test.ts','tests/mentat-question-bots.test.ts','tests/mentat-question-controls.test.tsx','tests/mentat-question-recovery.test.ts','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] },
],
},
{
+1
View File
@@ -6,6 +6,7 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
const DECISIONS = {
leaderSkillVisibility: true,
leaderSkillRevival: true,
mentatQuestion: true,
sukRescue: true,
rihani: true,
diplomatDefense: true,
+5 -1
View File
@@ -80,8 +80,12 @@ export function leaderSkillBattle({ skill = 'suk-graduate' as LeaderSkillId, uns
}
Object.assign(game, { phase: 6, storm: 18, order: ['a', 'd'], active: 'a', ready: [], decision: null, response: null, phaseOpening: null });
game = applyAction(game, 'a', { type: 'chooseBattle', territory, target: 'd' });
while (game.decision?.kind === 'leaderSkillVisibility') {
while (game.decision?.kind === 'leaderSkillVisibility' || game.decision?.kind === 'mentatQuestion') {
const decision = game.decision;
if (decision.kind === 'mentatQuestion') {
game = applyAction(game, decision.player, { type: 'decision', event: decision.event, decline: true });
continue;
}
game = applyAction(game, decision.player, { type: 'leaderSkillVisibility', event: decision.event,
hide: decision.player === 'a' ? hide : false });
}
+5 -1
View File
@@ -83,7 +83,11 @@ void test('named economic, custody and information effects retain their source n
);
assert.match(
leaderSkillCard('mentat').normal.join(' '),
/specific weapon.*different Treachery Card.*does not require/,
/may name a specific weapon.*privately show.*if held.*choose another Treachery Card/,
);
assert.match(
leaderSkillCard('mentat').normal.join(' '),
/stays in their hand.*need not be used in battle.*then move.*behind the shield/,
);
});
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { applyAction, viewGame, type Game } from '../game/engine';
import { mentatQuestionGame, mentatReload } from './mentat-question-fixture';
function botView(game: Game, id: string, difficulty: (typeof DIFFICULTIES)[number]) {
const view = viewGame(game, id);
view.players.find(player => player.id === id)!.bot = difficulty;
return view;
}
void test('all profiles name a permitted weapon and show only a permitted physical card using their own private views', () => {
for (const difficulty of DIFFICULTIES) {
const game = mentatQuestionGame();
const named = botActions(botView(game, 'a', difficulty))[0];
assert.ok(viewGame(game, 'a').mentat!.pending!.weapons.includes(named.weapon as string));
const question = applyAction(mentatReload(game), 'a', named);
assert.equal(question.decision?.kind, 'mentatQuestion');
assert.equal(question.decision?.player, 'd');
assert.equal(viewGame(question, 'a').mentat!.history.length, 0);
const response = botActions(botView(question, 'd', difficulty))[0];
assert.ok(viewGame(question, 'd').mentat!.pending!.cards.some(card => card.id === response.card));
const shown = applyAction(mentatReload(question), 'd', response);
assert.equal(shown.decision?.kind, 'leaderSkillVisibility');
assert.equal(viewGame(shown, 'a').mentat!.history.length, 1);
assert.deepEqual(shown.players.map(player => player.hand), game.players.map(player => player.hand));
assert.deepEqual(viewGame(shown, 'o').mentat!.history, []);
}
});
void test('the naming policy does not depend on hidden opponent cards and nonowners cannot act during either question stage', () => {
for (const difficulty of DIFFICULTIES) {
const held = mentatQuestionGame({cards:['Crysknife','Snooper']});
const absent = mentatQuestionGame({cards:['Chaumas','Shield']});
const left = botActions(botView(held,'a',difficulty))[0];
const right = botActions(botView(absent,'a',difficulty))[0];
assert.equal(left.weapon,right.weapon);
assert.deepEqual(botActions(botView(held,'d',difficulty)),[]);
assert.deepEqual(botActions(botView(held,'o',difficulty)),[]);
assert.ok(held.decision?.kind === 'mentatQuestion');
const pending = applyAction(held,'a',{type:'decision',event:held.decision.event,weapon:'Crysknife'});
assert.deepEqual(botActions(botView(pending,'a',difficulty)),[]);
assert.deepEqual(botActions(botView(pending,'o',difficulty)),[]);
const response = botActions(botView(pending,'d',difficulty))[0];
assert.equal(response.card,held.players.find(player => player.id === 'd')!.hand.find(card => card.name === 'Crysknife')!.id);
}
});
+154
View File
@@ -0,0 +1,154 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MentatHistory, MentatQuestion } from '../components/mentat-question';
import type { GameView } from '../game/engine';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
context,
);
},
});
const card = { id: 'p1', name: 'Projectile', kind: 'projectile' as const };
const otherCard = { id: 'w1', name: 'Worthless', kind: 'worthless' as const };
function view(overrides: Record<string, unknown> = {}) {
return {
me: 'a',
players: [
{ id: 'a', name: 'House A' },
{ id: 'b', name: 'House B' },
],
decision: {
kind: 'mentatQuestion',
player: 'a',
event: 'mentat-1',
owner: 'a',
target: 'b',
stage: 'name',
weapon: null,
},
mentat: {
pending: {
event: 'mentat-1',
owner: 'a',
target: 'b',
player: 'a',
stage: 'name',
weapon: null,
weapons: ['Projectile', 'Poison'],
cards: [],
blocked: null,
},
history: [],
},
...overrides,
} as unknown as GameView;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(
createElement(MentatQuestion, { game, act() {}, busy }),
);
}
void test('Mentat owner sees naming controls and blocked empty-hand decline', () => {
const html = markup(view());
assert.match(html, /aria-label="Weapon to name for Mentat"/);
assert.match(html, /Decline Mentat question/);
const blocked = markup(
view({
mentat: {
pending: {
event: 'mentat-1', owner: 'a', target: 'b', player: 'a', stage: 'name',
weapon: null, weapons: [], cards: [], blocked: 'Opponent has no card to show.',
}, history: [],
},
}),
);
assert.match(blocked, /Opponent has no card to show/);
assert.doesNotMatch(blocked, /Choose a weapon/);
});
void test('fallback cards are visible only to entitled target and inspectable', () => {
const target = view({
me: 'b',
decision: {
kind: 'mentatQuestion', player: 'b', event: 'mentat-1', owner: 'a', target: 'b',
stage: 'reveal', weapon: 'Projectile',
},
mentat: {
pending: {
event: 'mentat-1', owner: 'a', target: 'b', player: 'b', stage: 'reveal',
weapon: 'Projectile', weapons: [], cards: [card, otherCard], blocked: null,
}, history: [],
},
});
const html = markup(target);
assert.match(html, /Show Projectile privately/);
assert.match(html, /Show Worthless privately/);
assert.match(html, /Inspect card/);
const spectator = view({
decision: {
kind: 'mentatQuestion', player: 'b', event: 'mentat-1', owner: 'a', target: 'b',
stage: 'reveal', weapon: 'Projectile',
},
mentat: {
pending: {
event: 'mentat-1', owner: 'a', target: 'b', player: 'b', stage: 'reveal',
weapon: 'Projectile', weapons: [], cards: [card, otherCard], blocked: null,
}, history: [],
},
});
assert.equal(markup(spectator), '');
});
void test('exact-match reveal keeps the private stage and offers the named card once', () => {
const html = markup(view({
me: 'b',
decision: {
kind: 'mentatQuestion', player: 'b', event: 'mentat-1', owner: 'a', target: 'b',
stage: 'reveal', weapon: 'Projectile',
},
mentat: {
pending: {
event: 'mentat-1', owner: 'a', target: 'b', player: 'b', stage: 'reveal',
weapon: 'Projectile', weapons: [], cards: [card], blocked: null,
}, history: [],
},
}));
assert.match(html, /Mentat card shown privately/);
assert.match(html, /Show the named card privately/);
assert.equal((html.match(/Show Projectile privately/g) ?? []).length, 1);
assert.doesNotMatch(html, /disabled=""/);
});
void test('history is read-only and busy controls are disabled', () => {
const pending = markup(view(), true);
assert.match(pending, /disabled=""/);
const history = renderToStaticMarkup(
createElement(MentatHistory, {
game: view({
mentat: {
pending: null,
history: [{
event: 'old', battle: 'b', turn: 4, territory: 'arrakeen', owner: 'a',
target: 'b', leader: 'l', weapon: 'Projectile', card,
}],
},
}),
}),
);
assert.match(history, /Your Mentat observations/);
assert.match(history, /Later hand changes can make an observation stale/);
assert.match(history, /Inspect card/);
assert.doesNotMatch(history, /Show .* privately/);
});
aliases.deregister();
+145
View File
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { botActions } from '../game/bots';
export const mentatReload = (game: Game): Game =>
JSON.parse(JSON.stringify(game)) as Game;
export const mentatPlayer = (game: Game, id: string) =>
game.players.find((player) => player.id === id)!;
export function takeMentatCard(game: Game, player: string, name: string) {
const pool =
name === 'Stone Burner' || name === 'Mirror Weapon'
? game.richeseCache!
: game.deck;
const index = pool.findIndex((card) => card.name === name);
assert.ok(index >= 0, `Missing fixture card ${name}`);
const [card] = pool.splice(index, 1);
mentatPlayer(game, player).hand.push(card);
return card;
}
/** Genuine public skill assignment and setup, followed by a conserved battle position. */
export function mentatQuestionGame({
advanced = false,
choam = false,
cards = ['Crysknife', 'Snooper'],
begin = true,
captured = false,
preview = true,
}: {
advanced?: boolean;
choam?: boolean;
cards?: string[];
begin?: boolean;
captured?: boolean;
preview?: boolean;
} = {}): Game {
const native = choam ? 'richese' : 'emperor';
let game = createGame(
'MENTATQA',
newPlayer('a', 'Mentat owner', native),
advanced,
choam ? ['choam'] : [],
);
joinGame(
game,
newPlayer('d', 'Questioned opponent', captured ? 'harkonnen' : 'atreides'),
);
joinGame(game, newPlayer('o', 'Observer', 'guild'));
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
// Server-only review fixture opt-in; ordinary Leader Skills starts remain unchanged.
if (preview) game.mentatQuestionPreview = true;
const index = LEADER_SKILL_CARDS.findIndex((card) => card.id === 'mentat');
let cursor = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = cursor-- === index ? 0 : 0xffffffff;
return array;
});
try {
game = initializeLeaderSkillsGameForAudit(game);
} finally {
mock.restoreAll();
}
assert.ok(game.leaderSkills!.offers.a.cards.includes('mentat'));
for (let step = 0; game.status === 'setup' && step < 80; step++) {
let progress = false;
for (const player of game.players) {
const offer = game.leaderSkills!.offers[player.id];
const actions =
game.setupStage === 'leaderSkills' && offer
? [
{
type: 'leaderSkill',
event: offer.event,
skill: player.id === 'a' ? 'mentat' : offer.cards[0],
leader: `${player.faction}-0`,
},
]
: game.setupStage === 'traitors' && player.traitorChoices.length
? [{ type: 'traitor', leader: player.traitorChoices[0] }]
: botActions(viewGame(game, player.id));
for (const action of actions)
try {
game = applyAction(game, player.id, action);
progress = true;
break;
} catch {}
if (progress) break;
}
assert.ok(progress, `Setup stalled at ${game.setupStage}`);
}
assert.equal(game.status, 'playing');
for (const player of game.players) {
game.deck.push(...player.hand);
Object.assign(player, {
hand: [],
forces: player.id === 'o' ? {} : { 'arrakeen:10': 5 },
reserves: player.id === 'o' ? 20 : 15,
tanks: 0,
spice: 20,
});
}
takeMentatCard(game, 'a', 'Baliset');
takeMentatCard(game, 'o', 'Karama');
for (const name of cards) takeMentatCard(game, 'd', name);
if (captured) mentatPlayer(game, 'a').leaders[0].capturedBy = 'd';
Object.assign(game, {
phase: 6,
turn: 2,
storm: 18,
active: 'a',
order: ['a', 'd', 'o'],
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
return begin
? applyAction(game, 'a', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'd',
})
: game;
}
export function finishMentatPosture(game: Game, hide = true): Game {
while (game.decision?.kind === 'leaderSkillVisibility') {
const decision = game.decision;
game = applyAction(game, decision.player, {
type: 'leaderSkillVisibility',
event: decision.event,
hide: decision.player === 'a' ? hide : false,
});
}
return game;
}
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import type { DatabaseSync } from 'node:sqlite';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import type * as Rooms from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { mentatQuestionGame } from './mentat-question-fixture';
const clock: Rooms.RoomsClock = { now: () => 57_000, sleep: async () => {} };
const hash = (value: string) => createHash('sha256').update(value).digest('hex');
function snapshot(db: DatabaseSync) {
return { rooms: db.prepare('SELECT * FROM rooms ORDER BY code').all(), seats: db.prepare('SELECT * FROM seats ORDER BY player_id').all() };
}
function resources(game: Game) {
return { players: game.players, deck: game.deck, discard: game.discard, skills: game.leaderSkills };
}
async function fixture(t: test.TestContext, advanced = false) {
const store = unitStore();
t.after(() => store.sqlite.close());
const game = mentatQuestionGame({advanced});
game.version = 51;
const code = game.code;
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run(code, JSON.stringify(game), game.version, 56_000);
const tokens = Object.fromEntries(game.players.map(player => [player.id, hash(`mentat-test:${player.id}`)]));
const auths: Record<string, Rooms.SeatAuth> = {};
for (const player of game.players) {
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)').run(hash(tokens[player.id]),code,player.id);
auths[player.id] = await store.rooms.authenticate(code,tokens[player.id]);
}
assert.ok(game.decision?.kind === 'mentatQuestion');
return {...store, code, tokens, auths, initial:game, event:game.decision.event};
}
async function views(f: Awaited<ReturnType<typeof fixture>>, game: Game) {
const room = f.restart(), before = snapshot(f.sqlite);
for (const id of ['a','d','o']) {
const auth = await room.authenticate(f.code,f.tokens[id]);
const view = await room.readSeatView(f.code,auth);
assert.deepEqual(view,viewGame(game,id));
for (const rival of view.players.filter(player => player.id !== id)) {
assert.equal(rival.hand,undefined);
assert.equal(rival.traitors,undefined);
}
if (id === 'o') {
assert.deepEqual(view.mentat!.history,[]);
assert.deepEqual(view.mentat!.pending?.cards ?? [],[]);
assert.deepEqual(view.mentat!.pending?.weapons ?? [],[]);
}
}
assert.deepEqual(snapshot(f.sqlite),before);
}
void test('saved Mentat naming and mandatory private disclosure restore without moving cards or starting faction powers early', async t => {
for (const advanced of [false,true]) {
const f = await fixture(t,advanced), before = snapshot(f.sqlite), held = structuredClone(resources(f.initial));
const name = {type:'decision',event:f.event,weapon:'Lasgun'};
await views(f,f.initial);
await assert.rejects(f.rooms.act(f.code,f.auths.d,51,name,clock));
await assert.rejects(f.rooms.act(f.code,f.auths.a,50,name,clock),/table changed/);
await assert.rejects(f.rooms.act(f.code,f.auths.a,51,{...name,event:'old-event'},clock));
assert.deepEqual(snapshot(f.sqlite),before);
assert.equal(f.writes.length,0);
await f.restart().act(f.code,f.auths.a,51,name,clock);
const pending = await f.restart().readRoom(f.code);
assert.equal(pending.version,52);
assert.ok(pending.decision?.kind === 'mentatQuestion' && pending.decision.stage === 'reveal');
assert.equal(pending.decision.player,'d');
assert.equal(pending.battle!.preparation,undefined);
assert.deepEqual(resources(pending),held);
assert.equal(viewGame(pending,'a').mentat!.history.length,0);
await views(f,pending);
const eligible = viewGame(pending,'d').mentat!.pending!.cards;
assert.equal(eligible.length,2);
const card = eligible.find(candidate => candidate.name === 'Snooper')!;
const show = {type:'decision',event:f.event,card:card.id};
const reserved = snapshot(f.sqlite);
await assert.rejects(f.rooms.act(f.code,f.auths.a,52,show,clock));
await assert.rejects(f.rooms.act(f.code,f.auths.d,52,{...show,card:'not-held'},clock));
assert.deepEqual(snapshot(f.sqlite),reserved);
await f.restart().act(f.code,f.auths.d,52,show,clock);
const answered = await f.restart().readRoom(f.code);
assert.equal(answered.version,53);
assert.equal(answered.decision?.kind,'leaderSkillVisibility');
assert.deepEqual(resources(answered),held);
assert.equal(viewGame(answered,'a').mentat!.history[0].card.id,card.id);
assert.equal(viewGame(answered,'d').mentat!.history[0].card.id,card.id);
assert.deepEqual(viewGame(answered,'o').mentat!.history,[]);
await views(f,answered);
const saved = snapshot(f.sqlite);
await assert.rejects(f.rooms.act(f.code,f.auths.d,52,show,clock),/table changed/);
await assert.rejects(f.rooms.act(f.code,f.auths.d,53,show,clock));
assert.deepEqual(snapshot(f.sqlite),saved);
assert.deepEqual(f.writes.map(write => write.changes),[1,1]);
}
});
void test('competing private fallback submissions commit one historical observation and retain the same physical hand', async t => {
const f = await fixture(t), held = structuredClone(resources(f.initial));
await f.rooms.act(f.code,f.auths.a,51,{type:'decision',event:f.event,weapon:'Lasgun'},clock);
const pending = await f.rooms.readRoom(f.code);
const cards = viewGame(pending,'d').mentat!.pending!.cards;
assert.equal(cards.length,2);
let release!: () => void;
const wait = new Promise<void>(resolve => { release = resolve; });
let arrivals = 0;
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await wait; };
const outcomes = await Promise.allSettled(cards.map(card => f.restart().act(f.code,f.auths.d,52,{type:'decision',event:f.event,card:card.id},clock)));
delete f.hooks.beforeWrite;
assert.equal(outcomes.filter(outcome => outcome.status === 'fulfilled').length,1);
assert.equal(outcomes.filter(outcome => outcome.status === 'rejected').length,1);
assert.deepEqual(f.writes.slice(1).map(write => write.changes).sort((a,b) => a-b),[0,1]);
const answered = await f.restart().readRoom(f.code);
assert.equal(answered.version,53);
assert.deepEqual(resources(answered),held);
assert.equal(answered.mentatHistory!.length,1);
assert.equal(answered.mentatHistoryEvents!.length,1);
assert.ok(cards.some(card => card.id === viewGame(answered,'a').mentat!.history[0].card.id));
await views(f,answered);
});
+548
View File
@@ -0,0 +1,548 @@
import assert from 'node:assert/strict';
import test, { mock } from 'node:test';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import {
mentatHand,
mentatWeaponNames,
quoteMentatReveal,
} from '../game/mentat-question';
import { baseDeck, ixDeck } from '../game/cards';
import {
finishMentatPosture,
mentatPlayer,
mentatQuestionGame,
mentatReload,
} from './mentat-question-fixture';
function reject(game: Game, owner: string, action: Action) {
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, owner, action));
assert.equal(JSON.stringify(game), before);
}
function resources(game: Game) {
return structuredClone({
players: game.players,
deck: game.deck,
discard: game.discard,
cache: game.richeseCache,
skills: game.leaderSkills,
});
}
function nameWeapon(game: Game, weapon = 'Crysknife') {
return applyAction(game, 'a', {
type: 'decision',
event: game.battle!.mentatQuestionEvent,
weapon,
});
}
function ask(game: Game, weapon = 'Crysknife') {
const pending = nameWeapon(game, weapon),
choice = viewGame(pending, 'd').mentat.pending!;
return applyAction(pending, 'd', {
type: 'decision',
event: choice.event,
card: choice.cards[0].id,
});
}
function finishPowers(game: Game) {
game = finishMentatPosture(game);
for (
let i = 0;
i < 12 &&
(game.battle?.preparation || game.decision?.kind === 'fullPlanOffer');
i++
) {
game =
game.decision?.kind === 'fullPlanOffer'
? applyAction(game, game.decision.player, {
type: 'decision',
decline: true,
})
: applyAction(game, game.battle!.preparation!.owner, {
type: 'declineBattlePower',
});
}
return game;
}
void test('Mentat names exact printed/default weapons and validates physical hands without permissive slot substitutes', () => {
assert.equal(mentatWeaponNames([]).length, 9);
const choam = mentatWeaponNames(['choam']);
for (const name of [
'Poison Tooth',
'Artillery Strike',
'Stone Burner',
'Mirror Weapon',
])
assert.ok(choam.includes(name));
const ix = mentatWeaponNames(['ix']);
for (const name of [
'Poison Blade',
'Hunter Seeker',
'Basilia Weapon',
'Weirding Way',
])
assert.ok(ix.includes(name));
for (const name of [
'Chemistry',
'Baliset',
'Snooper',
'Cheap Hero',
'Residual Poison',
'poison',
'projectile',
]) {
assert.ok(!choam.includes(name));
assert.ok(!ix.includes(name));
assert.throws(() => quoteMentatReveal(baseDeck().slice(0, 1), name, []));
}
assert.throws(() => mentatHand([baseDeck()[0], baseDeck()[0]], []));
assert.throws(() => mentatHand([{ ...baseDeck()[0], name: 'Lasgun' }], []));
assert.throws(() => mentatHand(ixDeck().slice(0, 1), []));
});
void test('Mentat requires a private preview opt-in and never exposes or accepts that flag through a player view or action', () => {
for (const advanced of [false, true])
for (const choam of [false, true]) {
const ordinary = mentatQuestionGame({ advanced, choam, preview: false });
assert.equal(ordinary.mentatQuestionPreview, undefined);
assert.equal(ordinary.battle!.mentatQuestionVersion, undefined);
assert.equal(ordinary.battle!.mentatQuestion, undefined);
assert.equal(ordinary.decision?.kind, 'leaderSkillVisibility');
assert.equal(viewGame(ordinary, 'a').mentat.pending, null);
assert.equal(
applyAction(ordinary, 'a', {
type: 'advanceBots',
mentatQuestionPreview: true,
}).mentatQuestionPreview,
undefined,
);
}
const preview = mentatReload(mentatQuestionGame());
assert.equal(preview.mentatQuestionPreview, true);
assert.equal(
normalizeAutomaticGame(preview).decision?.kind,
'mentatQuestion',
);
for (const id of ['a', 'd', 'o'])
assert.equal('mentatQuestionPreview' in viewGame(preview, id), false);
delete preview.mentatQuestionPreview;
for (const operation of [
() => viewGame(preview, 'a'),
() => normalizeAutomaticGame(preview),
() => applyAction(preview, 'a', { type: 'advanceBots' }),
])
assert.throws(operation);
});
for (const advanced of [false, true])
for (const choam of [false, true])
void test(`genuine ${choam ? 'CHOAM' : 'base'} ${advanced ? 'Advanced' : 'Basic'} Mentat asks privately before posture and faction powers`, () => {
const game = mentatQuestionGame({
advanced,
choam,
cards: [choam ? 'Poison Tooth' : 'Crysknife', 'Snooper'],
});
const before = resources(game),
event = game.battle!.mentatQuestionEvent;
assert.equal(game.decision?.kind, 'mentatQuestion');
assert.equal(game.battle!.preparation, undefined);
assert.equal(game.battle!.leaderSkillHidden, undefined);
for (const owner of ['d', 'o'])
assert.deepEqual(viewGame(game, owner).mentat.pending!.weapons, []);
mock.method(globalThis.crypto, 'getRandomValues', () => {
throw new Error('Unexpected disclosure RNG');
});
mock.method(globalThis.crypto, 'randomUUID', () => {
throw new Error('Unexpected new disclosure event');
});
let done: Game;
try {
done = ask(mentatReload(game), choam ? 'Poison Tooth' : 'Crysknife');
} finally {
mock.restoreAll();
}
assert.deepEqual(resources(done), before);
assert.equal(done.decision?.kind, 'leaderSkillVisibility');
assert.equal(done.battle!.preparation, undefined);
const observed = viewGame(done, 'a').mentat.history[0];
assert.equal(observed.event, event);
assert.equal(observed.card.id, mentatPlayer(game, 'd').hand[0].id);
assert.deepEqual(viewGame(done, 'o').mentat.history, []);
assert.ok(
!JSON.stringify(viewGame(done, 'o')).includes(observed.card.id),
);
assert.ok(!JSON.stringify(done.log).includes(observed.card.id));
assert.deepEqual(normalizeAutomaticGame(mentatReload(done)), done);
done = finishMentatPosture(done);
assert.equal(done.battle!.leaderSkillHidden!.a, true);
assert.ok(
done.battle!.preparation || done.decision?.kind === 'fullPlanOffer',
);
reject(done, 'a', { type: 'decision', event, weapon: 'Lasgun' });
});
void test('absent weapon requires the opponent’s private choice of any other card, with no transfer, reservation or plan promise afterward', () => {
let game = mentatQuestionGame();
const before = resources(game),
event = game.battle!.mentatQuestionEvent;
game = nameWeapon(game, 'Lasgun');
assert.equal(game.decision?.player, 'd');
assert.equal(game.decision?.kind, 'mentatQuestion');
for (const owner of ['a', 'o'])
assert.deepEqual(viewGame(game, owner).mentat.pending!.cards, []);
assert.deepEqual(
viewGame(game, 'd').mentat.pending!.cards,
mentatPlayer(game, 'd').hand,
);
reject(game, 'a', {
type: 'decision',
event,
card: mentatPlayer(game, 'd').hand[1].id,
});
reject(game, 'd', {
type: 'decision',
event: 'stale',
card: mentatPlayer(game, 'd').hand[1].id,
});
reject(game, 'd', { type: 'decision', event, decline: true });
reject(game, 'd', { type: 'decision', event, card: 'missing' });
reject(game, 'o', {
type: 'card',
card: mentatPlayer(game, 'o').hand[0].id,
mode: 'special',
});
game = applyAction(mentatReload(game), 'd', {
type: 'decision',
event,
card: mentatPlayer(game, 'd').hand[1].id,
});
assert.equal(viewGame(game, 'a').mentat.history[0].card.name, 'Snooper');
assert.deepEqual(resources(game), before);
game = finishPowers(game);
game = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-1',
});
assert.equal(game.battle!.plans.d.defense, null);
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
game = applyAction(game, 'd', { type: 'traitorCall', call: false });
assert.equal(viewGame(game, 'a').mentat.history[0].card.name, 'Snooper');
assert.ok(
mentatPlayer(game, 'd').hand.some((card) => card.name === 'Snooper'),
);
assert.deepEqual(viewGame(mentatReload(game), 'o').mentat.history, []);
});
void test('exact match and single-card fallback use the same private response; decline is unspent and empty hands skip without disclosure', () => {
const single = mentatQuestionGame({ cards: ['Snooper'] });
assert.equal(nameWeapon(single).decision?.player, 'd');
assert.equal(nameWeapon(mentatQuestionGame()).decision?.player, 'd');
assert.equal(ask(single).battle!.mentatQuestion!.stage, 'answered');
const named = mentatQuestionGame(),
before = resources(named);
const declined = applyAction(named, 'a', {
type: 'decision',
event: named.battle!.mentatQuestionEvent,
decline: true,
});
assert.equal(declined.battle!.mentatQuestion!.stage, 'declined');
assert.deepEqual(resources(declined), before);
assert.deepEqual(viewGame(declined, 'a').mentat.history, []);
const empty = mentatQuestionGame({ cards: [] });
assert.equal(empty.battle!.mentatQuestion!.stage, 'unavailable');
assert.equal(empty.decision?.kind, 'leaderSkillVisibility');
assert.equal(viewGame(empty, 'a').mentat.pending, null);
assert.deepEqual(viewGame(empty, 'a').mentat.history, []);
assert.ok(
empty.log.some((entry) =>
entry.text.includes('empty-hand ruling remains unresolved'),
),
);
reject(empty, 'a', {
type: 'decision',
event: empty.battle!.mentatQuestionEvent,
weapon: 'Crysknife',
});
});
void test('a shown weapon need not be used; knowledge survives the selected native Mentat’s death and skill return', () => {
let game = finishPowers(ask(mentatQuestionGame()));
const observation = viewGame(game, 'a').mentat.history[0];
game = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-1',
weapon: observation.card.id,
});
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
game = applyAction(game, 'd', { type: 'traitorCall', call: false });
assert.ok(
mentatPlayer(game, 'a').leaders.find((leader) => leader.id === 'emperor-0')!
.dead,
);
assert.ok(
!game.leaderSkills!.assignments.some(
(assignment) => assignment.skill === 'mentat',
),
);
assert.deepEqual(viewGame(mentatReload(game), 'a').mentat.history, [
observation,
]);
const noWeapon = finishPowers(ask(mentatQuestionGame()));
const first = applyAction(noWeapon, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
});
assert.equal(
applyAction(first, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-1',
}).battle!.plans.d.weapon,
null,
);
});
void test('saved Mentat decisions and observations reject missing, changed, duplicate or nested context before any view, action or normalization', () => {
const name = mentatQuestionGame(),
reveal = nameWeapon(name, 'Lasgun'),
answered = ask(name);
const cases: [Game, (game: Game) => void][] = [
[
name,
(g) => {
g.decision = null;
},
],
[
name,
(g) => {
delete g.battle!.mentatQuestion;
},
],
[
name,
(g) => {
delete g.battle!.mentatQuestionEvent;
},
],
[
name,
(g) => {
delete g.battle!.mentatQuestionVersion;
},
],
[
name,
(g) => {
g.battle!.mentatQuestion!.event = 'forged';
},
],
[
name,
(g) => {
mentatPlayer(g, 'd').hand.reverse();
},
],
[
name,
(g) => {
g.deck.push({ ...mentatPlayer(g, 'd').hand[0] });
},
],
[
reveal,
(g) => {
if (g.decision?.kind === 'mentatQuestion') g.decision.player = 'a';
},
],
[
reveal,
(g) => {
g.pendingExchange = { decision: g.decision, response: null };
g.decision = null;
},
],
[
answered,
(g) => {
g.mentatHistory![0].card.name = 'Lasgun';
},
],
[
answered,
(g) => {
g.mentatHistoryEvents = [];
},
],
[
answered,
(g) => {
g.decision = name.decision;
},
],
];
for (const [source, mutate] of cases) {
const game = mentatReload(source);
mutate(game);
const before = JSON.stringify(game);
for (const operation of [
() => viewGame(game, 'a'),
() => normalizeAutomaticGame(game),
() => applyAction(game, 'a', { type: 'advanceBots' }),
]) {
assert.throws(operation);
assert.equal(JSON.stringify(game), before);
}
}
});
void test('legacy unmarked battles are not reopened and later new battles receive a fresh question event', () => {
const legacy = mentatQuestionGame();
delete legacy.battle!.mentatQuestion;
delete legacy.battle!.mentatQuestionEvent;
delete legacy.battle!.mentatQuestionVersion;
legacy.decision = {
kind: 'leaderSkillVisibility',
player: 'a',
event: legacy.battle!.event!,
};
assert.equal(
viewGame(normalizeAutomaticGame(mentatReload(legacy)), 'a').mentat.pending,
null,
);
let game = finishPowers(ask(mentatQuestionGame()));
const original = game.battle!.mentatQuestionEvent;
game = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-1',
});
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
game = applyAction(game, 'd', { type: 'traitorCall', call: false });
// Conserved next-turn board staging exercises the real new-battle creation hook.
for (const id of ['a', 'd']) {
const p = mentatPlayer(game, id);
p.forces = { 'arrakeen:10': 1 };
p.reserves = 19 - p.tanks;
for (const leader of p.leaders) delete leader.usedAt;
}
Object.assign(game, {
turn: 3,
phase: 6,
active: 'a',
battle: null,
decision: null,
response: null,
phaseOpening: null,
});
game = applyAction(game, 'a', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'd',
});
assert.notEqual(game.battle!.mentatQuestionEvent, original);
assert.equal(game.decision?.kind, 'mentatQuestion');
assert.equal(viewGame(game, 'a').mentat.history.length, 1);
reject(game, 'a', { type: 'decision', event: original, weapon: 'Crysknife' });
});
void test('canonical Richese weapon disclosure uses the same private path and card conservation', () => {
for (const name of ['Stone Burner', 'Mirror Weapon']) {
const game = mentatQuestionGame({ choam: true, cards: [name, 'Snooper'] }),
before = resources(game);
const done = ask(game, name);
assert.equal(viewGame(done, 'a').mentat.history[0].card.name, name);
assert.deepEqual(resources(done), before);
assert.deepEqual(viewGame(done, 'o').mentat.history, []);
}
});
void test('captured Mentat grants no question and retains its selected captured-leader lower bonus', () => {
let game = mentatQuestionGame({ captured: true });
assert.equal(game.battle!.mentatQuestion, undefined);
assert.equal(viewGame(game, 'd').mentat.pending, null);
game = finishPowers(game);
game = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-1',
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
});
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
game = applyAction(game, 'd', { type: 'traitorCall', call: false });
assert.ok(
game.log.some((entry) =>
entry.text.includes('gained 2 battle strength from Mentat'),
),
);
assert.deepEqual(viewGame(game, 'd').mentat.history, []);
});
void test('exact match and absent weapon produce indistinguishable public question stages and hide response eligibility', () => {
const held = mentatQuestionGame(),
absent = mentatQuestionGame({ cards: ['Maula Pistol', 'Snooper'] });
const namedHeld = nameWeapon(held),
namedAbsent = nameWeapon(absent);
const publicPending = (game: Game, viewer: string) => {
const view = viewGame(game, viewer);
const { event: _event, ...pending } = view.mentat.pending!;
assert.equal(view.decision!.kind, 'mentatQuestion');
const { event: _decisionEvent, ...decision } = view.decision as Extract<
NonNullable<typeof view.decision>,
{ kind: 'mentatQuestion' }
>;
return {
pending,
decision,
history: view.mentat.history,
log: game.log.at(-1)!.text,
};
};
for (const viewer of ['a', 'o'])
assert.deepEqual(
publicPending(namedHeld, viewer),
publicPending(namedAbsent, viewer),
);
assert.equal(namedHeld.log.length - held.log.length, 1);
assert.equal(namedAbsent.log.length - absent.log.length, 1);
const legal = viewGame(namedHeld, 'd').mentat.pending!.cards;
assert.equal(legal.length, 1);
assert.equal(legal[0].name, 'Crysknife');
reject(namedHeld, 'd', {
type: 'decision',
event: namedHeld.battle!.mentatQuestionEvent,
card: mentatPlayer(namedHeld, 'd').hand[1].id,
});
assert.equal(viewGame(namedAbsent, 'd').mentat.pending!.cards.length, 2);
assert.deepEqual(viewGame(namedHeld, 'a').mentat.pending!.cards, []);
assert.deepEqual(viewGame(namedAbsent, 'a').mentat.pending!.cards, []);
});