diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6e99a53a..6fb64013 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,13 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Voice naming Portable Snooper — 27 October. Prototyped / Partial.** Voice +can name the canonical Portable separately from ordinary Snooper or the generic +poison-defense category. Native plan and late-defense checks share the same +physical-card matcher; existing controls and minimal bots remain connected. +Individual ordinary-card names, wider combinations and final/deployed +acceptance remain open. [Scope](PORTABLE_SNOOPER_ENGINE_AUDIT.md#named-voice-choice--27-october-2026). + **Semuta after a ready battle preparation — 27 October. Prototyped / Partial.** One prior pre-leader Ready no longer prevents a Semuta offer after a legal Residual Poison discard. Claim/pass preserves the actual victim and battle, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 39663850..53926df0 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,14 @@ # Dune implementation status +## 27 October 2026 — Voice naming Portable Snooper + +Voice can name the physical Portable Snooper independently of generic poison +defense. Ordinary plan requirements, prohibitions and the late-defense check +share the canonical matcher; normal Snooper stays distinct. Existing controls +and minimal bot plans use the shared choice. Individual ordinary-card names, +wider interactions and final/deployed acceptance remain open. +[Scope](PORTABLE_SNOOPER_ENGINE_AUDIT.md#named-voice-choice--27-october-2026). + ## 27 October 2026 — Semuta after a ready battle preparation The neutral Semuta opportunity now follows a legal Residual Poison discard diff --git a/docs/PORTABLE_SNOOPER_ENGINE_AUDIT.md b/docs/PORTABLE_SNOOPER_ENGINE_AUDIT.md index 9b498121..907820bd 100644 --- a/docs/PORTABLE_SNOOPER_ENGINE_AUDIT.md +++ b/docs/PORTABLE_SNOOPER_ENGINE_AUDIT.md @@ -1,5 +1,24 @@ # Portable Snooper integration audit +## Named Voice choice — 27 October 2026 + +**Prototyped / Partial.** Voice can name the canonical Portable Snooper +separately from the broader poison-defense category. A specific requirement +is satisfied by the actual card in the original defense plan when the player +can comply; a prohibition blocks it in both the original plan and its printed +late-defense window. Ordinary Snooper remains distinct. Generic poison-defense +Voice continues to include both cards. Existing native and borrowed Voice +controls use the shared choice and matcher; saved earlier commands retain their +meaning. + +The user-authorized revised rulebook physical p. 28 / printed p. 26 permits +Voice to specify a card or card type. The physical Portable face subjects its +late play to Voice as recorded below. The [runtime cases](../tests/voice-portable-snooper-runtime.test.ts) +use real battle actions with a documented conserved-card and alliance +supplement; [control cases](../tests/voice-portable-snooper-controls.test.tsx) +use a controlled projection. Naming individual ordinary cards and wider +combinations, browser and deployed acceptance remain open. + Audit: 2026-09-06. Scope: the canonical card's ordinary defense role, late play and existing battle continuations. The source contract below informed the bounded integration recorded at the end. The current-hook list is historical; the final checkpoint records completed implementation. Full expansion certification remains unverified. ## Primary evidence diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index a3923f9c..55f4d828 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,14 @@ # Rule decision index +## Voice naming Portable Snooper — 27 October 2026 + +The authorized Advanced source permits Voice to name a specific card; the +Portable Snooper face also subjects its late defense to Voice. The canonical +Portable is now a distinct command from the broader poison-defense category, +in both ordinary plans and late use. Individual ordinary-card names and wider +combinations remain separate. +[Scope](PORTABLE_SNOOPER_ENGINE_AUDIT.md#named-voice-choice--27-october-2026). + ## Semuta and an already-ready Poison battle — 27 October 2026 In a supported open pre-leader preparation, one prior ready decision does not diff --git a/game/battle-cards.ts b/game/battle-cards.ts index edc09b4a..580f7bed 100644 --- a/game/battle-cards.ts +++ b/game/battle-cards.ts @@ -47,6 +47,7 @@ export const DEFENSE_KINDS = [ export const VOICE_KINDS = [ 'stoneBurner', 'mirrorWeapon', + 'portableSnooper', 'poison', 'projectile', 'lasgun', @@ -138,6 +139,7 @@ function effectiveWeaponKills( } /** Compulsion considers the card's default role, never forces an alternate role. */ export function defaultVoiceMatch(card: Card, kind: string) { + if (kind === 'portableSnooper') return isPortableSnooper(card); if (card.kind === kind) return true; return ( isPortableSnooper(card) || @@ -151,6 +153,7 @@ export function playedVoiceMatch( slot: 'weapon' | 'defense' | 'leader', kind: string, ) { + if (kind === 'portableSnooper') return slot === 'defense' && isPortableSnooper(card); return ( !!card && (card.kind === kind || @@ -178,6 +181,7 @@ export function battleCardLabel(kind: string) { hero: 'Cheap Hero', shield: 'Projectile defense', snooper: 'Poison defense', + portableSnooper: 'Portable Snooper', projectile: 'Projectile weapon', poison: 'Poison weapon', lasgun: 'Lasgun', diff --git a/game/engine.ts b/game/engine.ts index fddc9cce..d026fbac 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -958,7 +958,7 @@ export type Battle = { field: PlanField; value?: string | number | null; }; - voice?: { target: string; kind: Card['kind'] | 'stoneBurner' | 'mirrorWeapon'; must: boolean }; + voice?: { target: string; kind: Card['kind'] | 'stoneBurner' | 'mirrorWeapon' | 'portableSnooper'; must: boolean }; }; export type Auction = { /** New ordinary lots use trusted online timing; old open lots stay unstamped. */ diff --git a/game/portable-snooper.ts b/game/portable-snooper.ts index 9eb60648..41f4280b 100644 --- a/game/portable-snooper.ts +++ b/game/portable-snooper.ts @@ -21,6 +21,8 @@ export function portableSnooperPlanBlock( if (weapon && defense) return 'Portable Snooper cannot follow a weapon and Worthless card or another two-card plan.'; if (voice && !voice.must && playedVoiceMatch(card, 'defense', voice.kind)) - return 'The Bene Gesserit Voice forbids this poison defense.'; + return voice.kind === 'portableSnooper' + ? 'The Bene Gesserit Voice forbids Portable Snooper.' + : 'The Bene Gesserit Voice forbids this poison defense.'; return null; } diff --git a/game/reference.ts b/game/reference.ts index 654d0ffb..fa3e2b33 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -828,7 +828,7 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ 'Portable Snooper is a poison defense. Any faction holding the canonical card may select it in the normal defense slot. It protects against ordinary poison and Chemistry used as a weapon. It is not a shield; it does not stop projectiles, Lasgun, Artillery Strike or an activated Poison Tooth. Poison Blade still kills through its projectile attack.', 'After both battle plans are publicly revealed, open Portable Snooper in your hand to add it as a late defense. Your original plan must have a leader or Cheap Hero and no actual defense. A lone Worthless card in either slot permits this extra defense; a weapon plus Worthless, two Worthless cards or another two-card plan does not. There must be room within the two battle-card slots.', - 'The Bene Gesserit Voice still applies. A prohibition on poison defense forbids Portable Snooper; a poison-weapon prohibition does not. When Voice compels a poison defense and you hold this card, satisfy that requirement in the original plan. You cannot omit it on the promise of adding it later.', + 'The Bene Gesserit Voice still applies. A prohibition on poison defense forbids Portable Snooper; a poison-weapon prohibition does not. Voice may name Portable Snooper specifically, leaving an ordinary Snooper distinct. When Voice compels this card or a poison defense and you can comply, satisfy the requirement in the original plan. You cannot omit it on the promise of adding it later.', 'Play the late defense before submitting your own traitor decision, after any current Truthtrance, response or Poison Tooth choice finishes. This application procedure uses the existing decision opportunity, regardless of either hand, and does not add a holder-only public window. Your submitted traitor decision closes your late opportunity; another combatant submitting first does not close it.', 'The original revealed plan remains unchanged. The public table shows Portable Snooper separately as added after reveal, with its own inspector. Prescience and structured Truthtrance claims about the original defense slot still refer to that original slot. Broader freeform promises remain broader than the structured slot model.', 'The played card stays reserved with your battle cards until normal cleanup. A losing player discards it; the general official FAQ permits a winner to retain it. Moritani alliance retention can apply when its ordinary conditions allow. Successful traitors and actual Lasgun–shield explosions keep their existing precedence. Portable adds no spice payment, bounty or automatic traitor call.', diff --git a/tests/portable-snooper-bots.test.ts b/tests/portable-snooper-bots.test.ts index 143a2532..476a972c 100644 --- a/tests/portable-snooper-bots.test.ts +++ b/tests/portable-snooper-bots.test.ts @@ -225,12 +225,19 @@ void test('late defense uses public revealed threats without opponent hand infer ), ); } - const traitor = view(g, difficulty); - traitor.players[0].traitors = ['guild-0']; - assert.deepEqual(botActions(traitor)[0], { + // Build the authoritative traitor before revealing, so the server's + // projected call choices agree with the bot's actual held card. + const traitorGame = fixture(); + traitorGame.players[0].traitors = ['guild-0']; + const revealedTraitor = reveal(traitorGame); + const traitor = view(revealedTraitor, difficulty); + assert.ok(traitor.battle!.traitorCallChoices?.includes(true)); + const traitorAction = botActions(traitor)[0]; + assert.deepEqual(traitorAction, { type: 'traitorCall', call: true, }); + assert.doesNotThrow(() => applyAction(revealedTraitor, 'p', traitorAction)); } }); diff --git a/tests/portable-snooper-review.test.ts b/tests/portable-snooper-review.test.ts index 08209fe4..83dbe4f8 100644 --- a/tests/portable-snooper-review.test.ts +++ b/tests/portable-snooper-review.test.ts @@ -93,6 +93,15 @@ function finish(g: Game) { for (const id of ['r', 'g']) if (g.battle?.traitorCalls[id] === undefined) g = send(g, id, { type: 'traitorCall', call: false }); + // Fresh Richese battles retain their shared final Sapho opportunity after + // traitor declarations. Finish that actual native window before asserting + // outcomes or winner/loser card-cleanup decisions. + while (g.battle?.outcomeWindow && !g.battle.outcomeWindow.closed) { + const window = g.battle.outcomeWindow; + const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id)); + assert.ok(actor, 'The final battle window retains an unfinished combatant'); + g = send(g, actor, { type: 'battleOutcomeReady', event: window.event }); + } return g; } function cleanup(g: Game) { diff --git a/tests/portable-snooper.test.ts b/tests/portable-snooper.test.ts index 2e42b4ba..ea4ffa1e 100644 --- a/tests/portable-snooper.test.ts +++ b/tests/portable-snooper.test.ts @@ -42,6 +42,11 @@ void test('only the canonical persisted Portable identity gains the poison-defen assert.ok(defaultVoiceMatch(portable, 'snooper')); assert.ok(playedVoiceMatch(portable, 'defense', 'snooper')); assert.equal(playedVoiceMatch(portable, 'weapon', 'snooper'), false); + assert.ok(defaultVoiceMatch(portable, 'portableSnooper')); + assert.ok(playedVoiceMatch(portable, 'defense', 'portableSnooper')); + assert.equal(playedVoiceMatch(portable, 'weapon', 'portableSnooper'), false); + assert.equal(defaultVoiceMatch(get('snooper'), 'portableSnooper'), false); + assert.equal(defaultVoiceMatch({ ...portable, id: 'forged' }, 'portableSnooper'), false); }); void test('Portable stops ordinary poison and weapon Chemistry but no Tooth, Lasgun, projectile or Artillery', () => { for (const kind of ['poison', 'chemistry'] as const) diff --git a/tests/voice-portable-snooper-controls.test.tsx b/tests/voice-portable-snooper-controls.test.tsx new file mode 100644 index 00000000..e429f658 --- /dev/null +++ b/tests/voice-portable-snooper-controls.test.tsx @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import * as jsxRuntime from 'react/jsx-runtime'; +import ts from 'typescript'; +import { BattlePreparation } from '../components/battle-preparation'; +import { createGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import * as leaders from '../game/leader-control'; +import * as cards from '../game/cards'; +import * as battleCards from '../game/battle-cards'; +import * as battleSlots from '../game/battle-card-slots'; +import * as skills from '../game/leader-skill-combat'; +import * as mirror from '../game/mirror-weapon-mode'; + +type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>; +function elements(node: ReactNode): Element[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return []; + return [node, ...elements(node.props.children)]; +} + +/** Controlled public view for the actual Voice component. The engine tests + * exercise the real battle and authority; this is not a browser/hydration test. */ +function projection(borrowed = false): GameView { + const game = createGame('VOICEPORTABLEUI', newPlayer('bg', 'Bene Gesserit', 'beneGesserit'), true, ['choam']); + game.players.push(newPlayer('e', 'Emperor', 'emperor')); + Object.assign(game, { status: 'playing', phase: 6, turn: 2, active: 'bg', storm: 18, order: ['bg', 'e'] }); + for (const seat of game.players) { + seat.forces = { 'arrakeen:10': 2 }; seat.reserves = 18; seat.hand = []; seat.traitors = []; + } + game.battle = { event: 'voice-ui-battle', territory: 'arrakeen', attacker: 'bg', defender: 'e', + prepared: true, plans: {}, revealed: false, traitorCalls: {}, + preparation: { kind: 'voice', owner: 'bg', beneficiary: 'bg' } }; + const view = viewGame(game, 'bg'); + if (borrowed) { + view.battle!.preparation = { kind: 'nexusVoice', owner: 'bg', beneficiary: 'bg' }; + view.nexusBorrowedVoice = { event: 'borrowed-voice-ui', owner: 'bg', target: 'e', canUse: true, blocked: null, + choices: battleCards.VOICE_KINDS.flatMap(kind => [{ kind, must: false }, { kind, must: true }]) } as NonNullable; + } + return view; +} + +/** Re-render with persistent hooks so callbacks come from the real component. */ +function controller(game: GameView) { + const states: unknown[] = [], sent: Action[] = []; + let cursor = 0; + const exports: { BattlePreparation?: typeof BattlePreparation } = {}; + runInNewContext(ts.transpileModule(readFileSync(new URL('../components/battle-preparation.tsx', import.meta.url), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }, + }).outputText, { + exports, + require(name: string) { + if (name === 'react/jsx-runtime') return jsxRuntime; + if (name === 'react') return { useState(initial: unknown) { + const index = cursor++; if (!(index in states)) states[index] = initial; + return [states[index], (next: unknown) => { states[index] = next; }]; + } }; + if (name === '@/components/ui/button') return { Button: 'button' }; + if (name === '@/components/ui/input') return { Input: 'input' }; + if (name === '@/game/leader-control') return leaders; + if (name === '@/game/cards') return cards; + if (name === '@/game/battle-cards') return battleCards; + if (name === '@/game/battle-card-slots') return battleSlots; + if (name === '@/game/leader-skill-combat') return skills; + if (name === '@/game/mirror-weapon-mode') return mirror; + throw new Error(`Unexpected import ${name}`); + }, + }); + assert.ok(exports.BattlePreparation); + const render = () => { cursor = 0; return elements(exports.BattlePreparation!({ game, busy: false, + send: async action => { sent.push(action); } })); }; + const select = (id: string, value: string) => { + const node = render().find(element => element.props.id === id); assert.ok(node); + assert.ok(elements(node).some(option => option.type === 'option' && option.props.value === value)); + (node.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } }); + }; + const requireCard = () => { + const input = render().find(element => element.type === 'input' && element.props.type === 'checkbox'); assert.ok(input); + (input.props.onChange as (event: { target: { checked: boolean } }) => void)({ target: { checked: true } }); + }; + const submit = (label: string) => { + const button = render().find(element => element.type === 'button' && element.props.children === label); assert.ok(button); + assert.equal(button.props.disabled, false); (button.props.onClick as () => void)(); + }; + return { render, sent, select, requireCard, submit }; +} + +void test('ordinary Voice exposes distinct Portable Snooper and poison-defense choices and sends the chosen command', () => { + const game = projection(), html = renderToStaticMarkup(createElement(BattlePreparation, { game, busy: false, send: async () => {} })); + assert.match(html, /