Support named ordinary cards in Advanced Voice

This commit is contained in:
Quintine committed 2026-10-28 00:08:16 +11:00
1 parent b00d720e40
commit a244aeb5f8
20 files changed
+514 -41

No files matched your search

+7 -7
View File
@@ -7,7 +7,7 @@ import {
isStoneBurner,
isMirrorWeapon,
isDefenseCard,
VOICE_KINDS,
voiceChoices,
} from '@/game/battle-cards';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
@@ -150,15 +150,15 @@ export function BattlePreparation({
<p className="muted">Both combatants continue in attacker order before Prescience or sealed plans. Only an unallied holder may spend the card.</p>
{offer.blocked && <p className="notice">{offer.blocked}</p>}
{offer.canUse && <>
<label htmlFor="borrowed-voice-kind">Card type
<label htmlFor="borrowed-voice-kind">Card or type
<select id="borrowed-voice-kind" value={kind} onChange={e => setKind(e.target.value)}>
{VOICE_KINDS.filter(k => offer.choices.some(choice => choice.kind === k))
{voiceChoices(game.advanced).filter(k => offer.choices.some(choice => choice.kind === k))
.map(k => <option key={k} value={k}>{battleCardLabel(k)}</option>)}
</select>
</label>
<label className="decision-checkbox">
<input type="checkbox" checked={must} onChange={e => setMust(e.target.checked)} />
Require this type (unchecked: forbid)
Require this card or type (unchecked: forbid)
</label>
<p className="notice">Provisional policy: Karama may cancel the Voice. The Nexus card stays spent.</p>
<Button className="game-action" disabled={busy || !!offer.blocked || !selected}
@@ -185,13 +185,13 @@ export function BattlePreparation({
</p>
{preparation.blocked && <p className="notice">{preparation.blocked}</p>}
<label htmlFor="voice-kind">
Card type
Card or type
<select
id="voice-kind"
value={kind}
onChange={(e) => setKind(e.target.value)}
>
{VOICE_KINDS.filter((name) => name !== 'mirrorWeapon' || !mirrorWeaponModeBlock(game)).map((k) => (
{voiceChoices(game.advanced).filter((name) => name !== 'mirrorWeapon' || !mirrorWeaponModeBlock(game)).map((k) => (
<option key={k} value={k}>
{battleCardLabel(k)}
</option>
@@ -204,7 +204,7 @@ export function BattlePreparation({
checked={must}
onChange={(e) => setMust(e.target.checked)}
/>
Require this type (unchecked: forbid)
Require this card or type (unchecked: forbid)
</label>
<Button
className="game-action"
+5 -5
View File
@@ -6,7 +6,7 @@ import type { GameView } from '@/game/engine';
import type { MixedActionExpression } from '@/game/mixed-action-question';
import { parseBattleActionClaim } from '@/game/mixed-battle-question';
import { battlePowerOf, nativeBattlePowerOpportunity, type BattlePowerClaim } from '@/game/battle-power-question';
import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards';
import { battleCardLabel, voiceChoices } from '@/game/battle-cards';
import { nativeStormDialOpportunity } from '@/game/storm-dial-question';
import { karamaBattleCancelOpportunity, WINDOW_CARD_EFFECTS, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, type TurnActionLeaf } from '@/game/turn-action-promises';
@@ -328,13 +328,13 @@ const config: MixedActionConfig<TurnActionLeaf> = {
</select>
</> : value.claim.kind === 'voiceCommand' ? <>
<label htmlFor={`${id}-power-card-type`}>Voice card type</label>
<select id={`${id}-power-card-type`} value={VOICE_KINDS.includes(value.claim.cardType) ? value.claim.cardType : ''}
disabled={disabled} aria-invalid={!VOICE_KINDS.includes(value.claim.cardType)} onChange={event => {
if (value.claim.kind === 'voiceCommand' && (event.currentTarget.value === '' || VOICE_KINDS.includes(event.currentTarget.value)))
<select id={`${id}-power-card-type`} value={voiceChoices(game.advanced).includes(value.claim.cardType) ? value.claim.cardType : ''}
disabled={disabled} aria-invalid={!voiceChoices(game.advanced).includes(value.claim.cardType)} onChange={event => {
if (value.claim.kind === 'voiceCommand' && (event.currentTarget.value === '' || voiceChoices(game.advanced).includes(event.currentTarget.value)))
onChange({ ...value, claim: { ...value.claim, cardType: event.currentTarget.value } });
}}>
<option value="">Choose a native Voice card type</option>
{VOICE_KINDS.map(kind => <option key={kind} value={kind}>{battleCardLabel(kind)}</option>)}
{voiceChoices(game.advanced).map(kind => <option key={kind} value={kind}>{battleCardLabel(kind)}</option>)}
</select>
<label htmlFor={`${id}-power-must`}>Exact Voice command</label>
<select id={`${id}-power-must`} value={value.claim.must ? 'must' : 'cannot'} disabled={disabled}
+2 -2
View File
@@ -10,7 +10,7 @@ import { ordinaryCardAvailability } from '@/game/card-availability';
import { gholaForcePreparations } from '@/game/ghola-force-preparation';
import { richeseCardDefinition } from '@/game/richese-cards';
import { nativeBattlePowerOpportunity, type NativeBattlePower } from '@/game/battle-power-question';
import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards';
import { battleCardLabel, voiceChoices } from '@/game/battle-cards';
import { nativeStormDialOpportunity } from '@/game/storm-dial-question';
import { karamaBattleCancelOpportunity, WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
import type { TurnActionLeaf } from '@/game/turn-action-promises';
@@ -409,7 +409,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
JSON.stringify(publicBattleContext(current)) === battleSignature;
const powerPacket = Object.keys(action).every(key => ['type', 'turnActionPromiseEvent', 'turnActionPromisePhase',
...(action.type === 'voice' ? ['kind', 'must'] : action.type === 'prescience' ? ['field'] : [])].includes(key)) &&
(action.type === 'voice' ? typeof action.kind === 'string' && VOICE_KINDS.includes(action.kind) && typeof action.must === 'boolean'
(action.type === 'voice' ? typeof action.kind === 'string' && voiceChoices(current.advanced).includes(action.kind) && typeof action.must === 'boolean'
: action.type === 'prescience' ? ['leader', 'weapon', 'defense', 'dial'].includes(String(action.field))
: action.type === 'declineBattlePower');
const ownedPower = currentBattle && !!power && !!powerSource && powerSource.ready && powerSource.owner === actor &&
+10
View File
@@ -16,6 +16,16 @@ open mode gates.
## Current checkpoint and work
**Advanced Voice names for ordinary cards — 28 October. Prototyped / Partial.**
Native and borrowed Voice can require or forbid an exact ordinary battle-card
face in Advanced. Generic Voice and Basic choices remain; matching covers
weapons, defenses, Worthless and Cheap Heroes, including multiple physical
copies. The ordinary-name interpretation of the supplied source is provisional.
Twelve related files pass 168 cases, including real Advanced battle actions,
borrowed Voice, controls and all four existing legal bot profiles. The Basic
gate uses a controlled mode change; wider combinations, browser and deployed
acceptance remain open. [Scope](PORTABLE_SNOOPER_ENGINE_AUDIT.md#advanced-voice-names-for-ordinary-cards--28-october-2026).
**AI opponent in the four-faction setup — 28 October. Prototyped / Partial.**
The host can add two AI faction seats under one AI controller. Existing lobby
configuration applies separately; removing either bot keeps or promotes the
+11
View File
@@ -1,5 +1,16 @@
# Dune implementation status
## 28 October 2026 — Advanced Voice names for ordinary cards
Advanced Voice can provisionally require or forbid a specific ordinary
battle-card face while Basic keeps its existing categories. Native and borrowed
Voice, battle plans, hero availability, structured power controls and legal bot
responses use the same matcher. Twelve related files pass 168 cases, including
real Advanced battle actions with conserved-card/alliance staging and borrowed
Voice; the Basic rejection uses a controlled mode change. Named playable
Specials, wider combinations, browser and deployed acceptance remain open.
[Scope](PORTABLE_SNOOPER_ENGINE_AUDIT.md#advanced-voice-names-for-ordinary-cards--28-october-2026).
## 28 October 2026 — AI opponent with two factions
An AI controller can have two distinct bot faction seats in the optional
+24 -2
View File
@@ -1,5 +1,26 @@
# Portable Snooper integration audit
## Advanced Voice names for ordinary cards — 28 October 2026
**Prototyped / Partial.** The user-authorized revised source, physical p. 28
(printed p. 26), permits Voice to name a specific card or type. Its parenthetic
examples and the separate publisher FAQ do not independently settle naming
every ordinary face. The Advanced preview provisionally permits exact named
ordinary projectile, poison, Lasgun, shield, snooper, Worthless and Cheap
Hero/Heroine cards. Basic retains its prior category choices. Identical physical
copies share the printed name; another card in the same category does not
match. Named hero prohibitions account for any other available hero before
allowing a leaderless plan. Special-card categories retain their previous
handling; named playable Specials, wider combinations and final acceptance
remain open.
Native and borrowed Voice, the saved response, plan matcher, structured power
controls and the existing four legal bot policies share the same command. Real
Advanced battle actions use a documented conserved-card/alliance supplement;
the Basic gate uses a controlled mode change at the Voice boundary. Twelve
related files pass 168 cases. No human browser or deployed battle has been
verified for this slice.
## Named Voice choice — 27 October 2026
**Prototyped / Partial.** Voice can name the canonical Portable Snooper
@@ -16,8 +37,9 @@ 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.
use a controlled projection. The later Advanced ordinary-name prototype above
supersedes the earlier absence; 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.
+12 -2
View File
@@ -1,5 +1,15 @@
# Rule decision index
## Advanced Voice and ordinary card names — 28 October 2026
The supplied revised Advanced rules, physical p. 28 (printed p. 26), permit a
specific card or type. Its examples and the publisher's different Basic FAQ
do not clearly settle every ordinary face. In Advanced only, the prototype
interprets that language to permit naming an ordinary weapon, defense,
Worthless card or Cheap Hero face; every copy of that face matches. Basic
retains its existing categories. This is a provisional Advanced interpretation,
not a publisher ruling. [Scope](PORTABLE_SNOOPER_ENGINE_AUDIT.md#advanced-voice-names-for-ordinary-cards--28-october-2026).
## Truthtrance and Atreides ordinary auction knowledge — 27 October 2026
The publisher FAQ permits game-related yes/no and compound questions. A named
@@ -119,8 +129,8 @@ earlier claims below that a free Ghola can raise Low Salusa to High.
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.
in both ordinary plans and late use. The later Advanced ordinary-name prototype
above supersedes the earlier absence; 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
+40 -1
View File
@@ -1,4 +1,4 @@
import type { Card } from './cards';
import { treacheryDeck, type Card } from './cards';
import { richeseCardDefinition } from './richese-cards';
import {
resolveBattleWeapons,
@@ -62,6 +62,37 @@ export const VOICE_KINDS = [
'weirdingWay',
'chemistry',
];
/** Stable, public identifiers for individual ordinary battle card faces.
* A name denotes all physical copies of that face, not an opponent's card ID.
* Special weapons and defenses retain their existing separate Voice kinds. */
const namedVoiceKinds = new Set<Card['kind']>([
'projectile', 'poison', 'lasgun', 'shield', 'snooper', 'worthless', 'hero',
]);
const namedVoiceFaces = treacheryDeck(['ix']).filter(card => namedVoiceKinds.has(card.kind));
export const VOICE_NAMED_CARDS: readonly string[] = Object.freeze(
[...new Set(namedVoiceFaces.map(card => card.name))].sort(),
);
const namedVoiceCommands = Object.freeze(VOICE_NAMED_CARDS.map(name => `named:${name}`));
/** The supplied Advanced rules permit a command naming a specific face.
* Basic retains its established category choices and saved command values. */
export function voiceChoices(advanced: boolean): readonly string[] {
return advanced ? [...VOICE_KINDS, ...namedVoiceCommands] : VOICE_KINDS;
}
/** Return the canonical public face for a valid exact-name command. */
export function voiceNamedCard(kind: string): string | null {
if (!kind.startsWith('named:')) return null;
const name = kind.slice('named:'.length);
return VOICE_NAMED_CARDS.includes(name) ? name : null;
}
function namedVoiceMatch(card: Card | undefined, kind: string): boolean {
const name = voiceNamedCard(kind);
return !!name && !!card && card.name === name &&
namedVoiceFaces.some(face => face.name === name && face.kind === card.kind);
}
export const BATTLE_CARD_HELP: Partial<Record<Card['kind'], string>> = {
artillery:
'Kills both leaders unless each is protected by a shield. Surviving leaders add no battle strength, and no leader bounty is paid. Discard after use.',
@@ -139,6 +170,7 @@ function effectiveWeaponKills(
}
/** Compulsion considers the card's default role, never forces an alternate role. */
export function defaultVoiceMatch(card: Card, kind: string) {
if (voiceNamedCard(kind)) return namedVoiceMatch(card, kind);
if (kind === 'portableSnooper') return isPortableSnooper(card);
if (card.kind === kind) return true;
return (
@@ -153,6 +185,11 @@ export function playedVoiceMatch(
slot: 'weapon' | 'defense' | 'leader',
kind: string,
) {
if (voiceNamedCard(kind)) {
if (!card || !namedVoiceMatch(card, kind)) return false;
return slot === 'leader' ? card.kind === 'hero'
: slot === 'weapon' ? isWeaponCard(card) : isDefenseCard(card);
}
if (kind === 'portableSnooper') return slot === 'defense' && isPortableSnooper(card);
return (
!!card &&
@@ -167,6 +204,8 @@ export function playedVoiceMatch(
}
export function battleCardLabel(kind: string) {
const name = voiceNamedCard(kind);
if (name) return `${name} (specific card)`;
return (
(
{
+2 -2
View File
@@ -1,4 +1,4 @@
import { VOICE_KINDS } from './battle-cards';
import { voiceChoices } from './battle-cards';
export type NativeBattlePower = 'voice' | 'prescience';
export type BattlePowerClaim =
@@ -51,7 +51,7 @@ const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const keys = (value: Record<string, unknown>, expected: string): boolean =>
Object.keys(value).sort().join(',') === expected;
const cardType = (value: unknown): value is string => typeof value === 'string' && VOICE_KINDS.includes(value);
const cardType = (value: unknown): value is string => typeof value === 'string' && voiceChoices(true).includes(value);
const field = (value: unknown): value is 'leader' | 'weapon' | 'defense' | 'dial' =>
value === 'leader' || value === 'weapon' || value === 'defense' || value === 'dial';
+1 -1
View File
@@ -790,7 +790,7 @@ function plans(g: GameView): Action[] {
const voice = b.voice?.target === me.id ? b.voice : null;
const mayOmitLeader =
!availableLeaders.length &&
(!heroes.length || (voice?.kind === 'hero' && !voice.must));
!heroes.some(hero => !(voice && !voice.must && playedVoiceMatch(hero, 'leader', voice.kind)));
const leaders = [
...(mayOmitLeader ? [null] : []),
...availableLeaders.map((l) => l.id),
+2 -2
View File
@@ -1,5 +1,5 @@
import { FACTIONS, type FactionId } from './catalog';
import { VOICE_KINDS } from './battle-cards';
import { voiceChoices } from './battle-cards';
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
import { validateNexusBorrowedVoice, type NexusBorrowedVoiceFrame } from './nexus-borrowed-voice';
@@ -332,7 +332,7 @@ function calculate(
record(b.voice) &&
b.voice.target === opponent &&
typeof b.voice.kind === 'string' &&
VOICE_KINDS.includes(b.voice.kind) &&
voiceChoices(input.advanced).includes(b.voice.kind) &&
typeof b.voice.must === 'boolean',
'The declared Voice response is malformed or targets the wrong opponent.',
);
+14 -14
View File
@@ -698,7 +698,8 @@ import {
isStoneBurner,
isMirrorWeapon,
isPortableSnooper,
VOICE_KINDS,
voiceChoices,
voiceNamedCard,
defaultVoiceMatch,
playedVoiceMatch,
} from './battle-cards';
@@ -965,7 +966,7 @@ export type Battle = {
field: PlanField;
value?: string | number | null;
};
voice?: { target: string; kind: Card['kind'] | 'stoneBurner' | 'mirrorWeapon' | 'portableSnooper'; must: boolean };
voice?: { target: string; kind: Card['kind'] | 'stoneBurner' | 'mirrorWeapon' | 'portableSnooper' | `named:${string}`; must: boolean };
};
export type Auction = {
/** New ordinary lots use trusted online timing; old open lots stay unstamped. */
@@ -3170,7 +3171,8 @@ function playNexusFremenBetrayal(g: Game, p: Player, action: Action) {
function quoteVoiceCommand(g: Game, target: string, kind: unknown, must: unknown): NonNullable<Battle['voice']> {
const occupation = homeworldRule(() => quoteOccupiedHomeworldVoice(g, target));
requireRule(occupation.status === 'allowed', occupation.blocked ?? 'This target cannot receive the Voice.');
requireRule(typeof kind === 'string' && VOICE_KINDS.includes(kind), 'Choose a card type.');
requireRule(typeof kind === 'string' && voiceChoices(true).includes(kind), 'Choose a card or card type.');
requireRule(g.advanced || !voiceNamedCard(kind), 'Naming an individual card requires Advanced rules.');
if (kind === 'mirrorWeapon') {
const blocked = mirrorWeaponModeBlock(g);
requireRule(!blocked, blocked ?? 'Mirror Weapon is unavailable in this battle configuration.');
@@ -3185,6 +3187,8 @@ function quoteVoiceCommand(g: Game, target: string, kind: unknown, must: unknown
function nexusBorrowedVoiceIntegrity(g: Game) {
const b = g.battle, frame = b?.nexusBorrowedVoice;
if (!b || !frame) return;
requireRule(!frame.voice || voiceChoices(g.advanced).includes(frame.voice.kind),
'Borrowed Voice cannot name an individual card in Basic rules.');
requireRule(b.event && g.nexusCards?.cards && !byFaction(g, 'beneGesserit'),
'Borrowed Voice has lost its absent-faction Nexus module.');
nexusRule(() => validateNexusBorrowedVoice({
@@ -3221,7 +3225,7 @@ function nexusBorrowedVoiceOffer(g: Game, id: string) {
g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy
? 'Finish the current response before continuing borrowed Voice.' : null;
const choices: { kind: NonNullable<Battle['voice']>['kind']; must: boolean }[] = [];
if (canUse && occupation.status === 'allowed') for (const kind of VOICE_KINDS) {
if (canUse && occupation.status === 'allowed') for (const kind of voiceChoices(g.advanced)) {
if (kind === 'mirrorWeapon' && mirrorWeaponModeBlock(g)) continue;
choices.push({ kind: kind as NonNullable<Battle['voice']>['kind'], must: false });
if (!['stoneBurner', 'mirrorWeapon'].includes(kind) || !stoneCompulsionBlock(g, getPlayer(g, target)))
@@ -3283,7 +3287,7 @@ function nexusBgBetrayalIntegrity(g: Game) {
g.players.some(p => p.id === record.owner) &&
g.players.some(p => p.id === record.target && p.faction === 'beneGesserit') &&
record.voice && typeof record.voice.must === 'boolean' &&
VOICE_KINDS.includes(record.voice.kind) &&
voiceChoices(g.advanced).includes(record.voice.kind) &&
g.players.some(p => p.id === record.voice.target) && !events.has(record.event),
'Bene Gesserit Betrayal has a malformed or repeated Voice receipt.');
events.add(record.event);
@@ -11210,9 +11214,8 @@ function finishMandatorySkillVisibility(g: Game) {
const available = controlledLeaders(g, p).filter(
(l) => !l.dead && (!l.usedAt || l.usedAt === b.territory),
);
const heroAvailable =
p.hand.some((c) => c.kind === 'hero') &&
!(b.voice?.target === owner && b.voice.kind === 'hero' && !b.voice.must);
const heroAvailable = p.hand.some((c) => c.kind === 'hero' &&
!(b.voice?.target === owner && !b.voice.must && playedVoiceMatch(c, 'leader', b.voice.kind)));
if (
available.length === 1 &&
available[0].id === assignment.leader &&
@@ -20775,7 +20778,7 @@ function turnActionPowerCompletion(g: Game, actor: string, requirements: readonl
{ declaration: { power, use: false }, action: { type: 'declineBattlePower' } },
];
if (power === 'voice') {
for (const kind of ['poison', ...VOICE_KINDS.filter(kind => kind !== 'poison')]) for (const must of [false, true]) {
for (const kind of ['poison', ...voiceChoices(g.advanced).filter(kind => kind !== 'poison')]) for (const must of [false, true]) {
try {
quoteVoiceCommand(g, source.target, kind, must);
choices.push({ declaration: { power, use: true, cardType: kind, must }, action: { type: 'voice', kind, must } });
@@ -29854,11 +29857,8 @@ function validatePlan(
);
requireRule(
plan.leader ||
(!available.length &&
(!p.hand.some((c) => c.kind === 'hero') ||
(b.voice?.target === p.id &&
!b.voice.must &&
b.voice.kind === 'hero'))),
(!available.length && !p.hand.some((c) => c.kind === 'hero' &&
!(b.voice?.target === p.id && !b.voice.must && playedVoiceMatch(c, 'leader', b.voice.kind)))),
'You must use an available leader or cheap hero.',
);
const w = cardOf(p, plan.weapon),
+2 -2
View File
@@ -1,4 +1,4 @@
import { VOICE_KINDS } from './battle-cards';
import { voiceChoices } from './battle-cards';
import type { Battle } from './engine';
export type NexusBorrowedVoiceFrame = {
@@ -71,7 +71,7 @@ function validateFrame(frame: NexusBorrowedVoiceFrame): void {
frame.voice && typeof frame.voice === 'object' && !Array.isArray(frame.voice) &&
Object.keys(frame.voice).sort().join(',') === 'kind,must,target' &&
frame.voice.target === frame.order[1 - frame.passed.length] &&
typeof frame.voice.kind === 'string' && VOICE_KINDS.includes(frame.voice.kind) &&
typeof frame.voice.kind === 'string' && voiceChoices(true).includes(frame.voice.kind) &&
typeof frame.voice.must === 'boolean',
'Borrowed Voice needs one ordered owner and a canonical opposing command.',
);
+21
View File
@@ -3240,6 +3240,27 @@ export const RULE_TOPICS: RuleTopic[] = [
},
],
},
{
id: 'advanced-voice-named-cards',
title: 'Advanced Voice: naming a battle card',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Advanced Voice may require or forbid a particular ordinary battle-card face.',
steps: [
'In Advanced Voice, choose a category or one named ordinary weapon, defense, Worthless card or Cheap Hero face. Named commands distinguish different cards of the same category; all physical copies of the chosen face match.',
'The target must play the named card in a legal slot if required and able, or avoid it if forbidden. If no eligible matching card is held, a requirement does not force another card. Existing special-card commands keep their own behavior.',
'Basic retains its existing category choices. Borrowed Voice uses the same Advanced choices; the exact ordinary-name interpretation and wider interactions remain provisional.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Native and borrowed Voice share a canonical face matcher, role-aware plan validation and Advanced-only command gate; ordinary hero availability and generic commands remain intact.', evidence: ['game/battle-cards.ts', 'game/engine.ts', 'game/nexus-borrowed-voice.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Native and borrowed Voice and structured declaration controls offer named faces in Advanced.', evidence: ['components/battle-preparation.tsx', 'components/turn-action-fields.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use the shared plan matcher; broader strategy and calibration remain unfinished.', evidence: ['game/bots.ts', 'tests/voice-named-card-runtime.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The supplied Advanced source and provisional interpretation are recorded.', evidence: ['docs/PORTABLE_SNOOPER_ENGINE_AUDIT.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused matching, real battle/borrowed Voice, controls and minimal bot cases cover the prototype; complete combinations and deployed acceptance remain open.', evidence: ['tests/voice-named-card.test.ts', 'tests/voice-named-card-runtime.test.ts', 'tests/voice-named-card-controls.test.tsx', 'tests/nexus-borrowed-voice-runtime.test.ts'] },
],
related: ['card-truthtrance', 'truthtrance-turn-actions'],
},
{
id: 'truthtrance-knowledge',
title: 'Truthtrance: recorded predictions and private forecasts',
+3
View File
@@ -48,6 +48,9 @@ void test('actual Voice use, exact command and explicit Decline match independen
assert.equal(battlePowerMatches({ kind: 'voice', use: false }, decline), true);
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'projectile', must: true }, decline), false);
assert.equal(battlePowerMatches({ kind: 'prescience', use: false }, decline), false);
const named: BattlePowerDeclaration = { power: 'voice', use: true, cardType: 'named:Maula Pistol', must: true };
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'named:Maula Pistol', must: true }, named), true);
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'projectile', must: true }, named), false);
});
void test('Prescience predicates refer to use or requested field, never the inspected answer', () => {
@@ -85,6 +85,22 @@ function fixture(advanced: boolean): NexusBorrowedVoiceFixture {
return built[key]!;
}
void test('Advanced borrowed Voice preserves a specifically named ordinary card through its original response', () => {
const f = fixture(true);
const first = applyAction(reload(f.game), f.atreides,
{ type: 'nexusBorrowedVoice', event: offer(f.game, f.atreides).event, use: false });
const option = offer(first, f.holder);
assert.ok(option.choices.some(choice => choice.kind === 'named:Maula Pistol' && choice.must));
const declared = applyAction(first, f.holder, {
type: 'nexusBorrowedVoice', event: option.event, use: true, kind: 'named:Maula Pistol', must: true,
});
assert.deepEqual(declared.battle!.voice, { target: f.atreides, kind: 'named:Maula Pistol', must: true });
const settled = settledWindow(declared);
assert.equal(frame(settled).stage, 'active');
assert.equal(settled.battle!.voice?.kind, 'named:Maula Pistol');
nexusBorrowedVoiceInventory(settled);
});
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced' : 'Basic';
+1 -1
View File
@@ -199,7 +199,7 @@ void test('declarations reject missing/noncanonical commands, nonbooleans and wr
const frame = createNexusBorrowedVoice(context);
const invalid: unknown[] = [
null, undefined, {}, { ...command, target: 'a' }, { ...command, target: 'foreign' },
{ ...command, kind: 'karama' }, { ...command, kind: 'portableSnooper' },
{ ...command, kind: 'karama' }, { ...command, kind: 'named:Imaginary Card' },
{ ...command, kind: 'Poison' }, { ...command, kind: null },
{ target: 'd', must: true }, { target: 'd', kind: 'poison' },
{ ...command, must: 'false' }, { ...command, must: 0 },
+103
View File
@@ -0,0 +1,103 @@
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 battle projection; engine tests exercise actual combat. */
function projection(advanced: boolean, borrowed = false): GameView {
const game = createGame('VOICENAMEUI', newPlayer('bg', 'Bene Gesserit', 'beneGesserit'), advanced);
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.voiceChoices(advanced).flatMap(kind => [{ kind, must: false }, { kind, must: true }]) } as NonNullable<GameView['nexusBorrowedVoice']>;
}
return view;
}
/** Execute real event handlers with persistent hooks; no browser is implied. */
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); } })); };
return { render, sent };
}
void test('named ordinary battle cards appear only in Advanced Voice choices', () => {
const render = (advanced: boolean) => renderToStaticMarkup(createElement(BattlePreparation, {
game: projection(advanced), busy: false, send: async () => {},
}));
assert.match(render(true), /value="named:Maula Pistol"/);
assert.match(render(true), />Maula Pistol \(specific card\)</);
assert.match(render(true), /value="projectile"/);
assert.doesNotMatch(render(false), /value="named:Maula Pistol"/);
assert.match(render(false), /value="projectile"/);
});
void test('native and borrowed Voice send the chosen exact card name and honor offered choices', () => {
for (const borrowed of [false, true]) {
const ui = controller(projection(true, borrowed));
const select = ui.render().find(element => element.props.id === (borrowed ? 'borrowed-voice-kind' : 'voice-kind'))!;
assert.ok(elements(select).some(option => option.type === 'option' && option.props.value === 'named:Maula Pistol'));
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'named:Maula Pistol' } });
const checkbox = ui.render().find(element => element.type === 'input' && element.props.type === 'checkbox')!;
(checkbox.props.onChange as (event: { target: { checked: boolean } }) => void)({ target: { checked: true } });
const button = ui.render().find(element => element.type === 'button' && element.props.children === (borrowed ? 'Spend Nexus · Use the Voice' : 'Use the Voice'))!;
assert.equal(button.props.disabled, false);
(button.props.onClick as () => void)();
const expected = borrowed ? { type: 'nexusBorrowedVoice', event: 'borrowed-voice-ui', use: true, kind: 'named:Maula Pistol', must: true }
: { type: 'voice', kind: 'named:Maula Pistol', must: true };
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent)), [expected]);
}
});
+164
View File
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
advanceSemutaAdvisorBattle,
createSemutaAdvisorBattleFixture,
semutaAdvisorBattleInventory,
semutaAdvisorBattlePlayer,
} from './fixture-semuta-advisor-battle';
/** Original Advanced setup, shipments and battle choice. The explicit test
* supplement forms a BG/Harkonnen alliance and moves existing physical cards
* between zones; it does not claim these acquisitions occurred naturally. */
function source(names: string[]) {
const fixture = createSemutaAdvisorBattleFixture();
let game = structuredClone(fixture.beforeBattle);
const bg = semutaAdvisorBattlePlayer(game, fixture.bg);
const ally = semutaAdvisorBattlePlayer(game, fixture.harkonnen);
const target = semutaAdvisorBattlePlayer(game, fixture.atreides);
assert.equal(bg.ally, null);
assert.equal(ally.ally, null);
bg.ally = ally.id;
ally.ally = bg.id;
const original = semutaAdvisorBattleInventory(game);
// Return the target's actual cards before staging the requested original
// physical cards, so the named-absence cases do not depend on a shuffled deal.
game.deck.push(...target.hand.splice(0));
const pools = [game.richeseCache!, game.richeseRemoved ?? [], game.deck, game.discard,
...game.players.map(player => player.hand)];
const ids = names.map(name => {
const pool = pools.find(cards => cards.some(card => card.name === name && !target.hand.some(held => held.id === card.id)));
assert.ok(pool, `An original physical ${name} card must exist`);
const index = pool.findIndex(card => card.name === name && !target.hand.some(held => held.id === card.id));
const card = pool.splice(index, 1)[0];
target.hand.push(card);
return card.id;
});
assert.deepEqual(semutaAdvisorBattleInventory(game), original);
game = applyAction(game, fixture.choice.chooser, fixture.battleAction);
game = advanceSemutaAdvisorBattle(game, state => state.battle?.preparation?.kind === 'voice' &&
!state.response && !state.decision && !state.phaseOpening);
assert.equal(game.battle!.preparation!.owner, fixture.bg);
assert.equal(game.battle!.preparation!.beneficiary, fixture.harkonnen);
return { game, target: fixture.atreides, ally: fixture.harkonnen, bg: fixture.bg, ids, original };
}
function command(game: Game, actor: string, kind: string, must: boolean) {
game = applyAction(game, actor, { type: 'voice', kind, must });
return advanceSemutaAdvisorBattle(game, state => !!state.battle &&
!state.response && !state.decision && !state.phaseOpening &&
!state.battle.preparation && state.battle.preLeader?.closed !== false);
}
function plan(game: Game, actor: string, fields: Partial<{ leader: string | null; weapon: string | null; defense: string | null }> = {}): Action {
const player = semutaAdvisorBattlePlayer(game, actor);
const leader = player.leaders.find(candidate => !candidate.dead && !candidate.capturedBy);
return { type: 'battlePlan', dial: 0, support: 0, leader: leader?.id ?? null,
weapon: null, defense: null, ...fields };
}
function reject(game: Game, actor: string, action: Action, reason?: RegExp) {
const before = structuredClone(game);
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
else assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('Advanced Voice requires or forbids one named projectile without conflating another', () => {
const { game: initial, bg, target, ids: [maula, crysknife], original } = source(['Maula Pistol', 'Crysknife']);
const required = command(initial, bg, 'named:Maula Pistol', true);
assert.deepEqual(required.battle!.voice, { target, kind: 'named:Maula Pistol', must: true });
reject(required, target, plan(required, target));
reject(required, target, plan(required, target, { weapon: crysknife }));
const complied = applyAction(required, target, plan(required, target, { weapon: maula }));
assert.equal(complied.battle!.plans[target].weapon, maula);
assert.deepEqual(semutaAdvisorBattleInventory(complied), original);
const forbidden = command(initial, bg, 'named:Maula Pistol', false);
reject(forbidden, target, plan(forbidden, target, { weapon: maula }));
const other = applyAction(forbidden, target, plan(forbidden, target, { weapon: crysknife }));
assert.equal(other.battle!.plans[target].weapon, crysknife);
assert.deepEqual(semutaAdvisorBattleInventory(other), original);
});
void test('missing a named card permits another projectile, while the old generic command still matches both', () => {
const { game: initial, bg, target, ids: [crysknife], original } = source(['Crysknife']);
const required = command(initial, bg, 'named:Maula Pistol', true);
const accepted = applyAction(required, target, plan(required, target, { weapon: crysknife }));
assert.equal(accepted.battle!.plans[target].weapon, crysknife);
assert.deepEqual(semutaAdvisorBattleInventory(accepted), original);
const category = command(initial, bg, 'projectile', false);
reject(category, target, plan(category, target, { weapon: crysknife }));
assert.doesNotThrow(() => applyAction(category, target, plan(category, target)));
});
void test('a named Worthless command accepts its physical card in either legal battle slot', () => {
const { game: initial, bg, target, ids: [baliset, kulon] } = source(['Baliset', 'Kulon']);
const required = command(initial, bg, 'named:Baliset', true);
reject(required, target, plan(required, target, { weapon: kulon }));
const weapon = applyAction(required, target, plan(required, target, { weapon: baliset }));
assert.equal(weapon.battle!.plans[target].weapon, baliset);
const defense = applyAction(required, target, plan(required, target, { defense: baliset }));
assert.equal(defense.battle!.plans[target].defense, baliset);
const forbidden = command(initial, bg, 'named:Baliset', false);
reject(forbidden, target, plan(forbidden, target, { defense: baliset }));
assert.doesNotThrow(() => applyAction(forbidden, target, plan(forbidden, target, { defense: kulon })));
});
void test('a named Cheap Hero prohibition preserves a different hero and permits no leader when none is legal', () => {
const pair = source(['Cheap Hero', 'Cheap Heroine']);
const forbidden = command(pair.game, pair.bg, 'named:Cheap Hero', false);
const controlled = structuredClone(forbidden);
for (const leader of semutaAdvisorBattlePlayer(controlled, pair.target).leaders) {
leader.dead = true;
leader.deaths = Math.max(leader.deaths, 1);
}
reject(controlled, pair.target, plan(controlled, pair.target, { leader: pair.ids[0] }));
reject(controlled, pair.target, plan(controlled, pair.target, { leader: null }));
const heroine = applyAction(controlled, pair.target, plan(controlled, pair.target, { leader: pair.ids[1] }));
assert.equal(heroine.battle!.plans[pair.target].leader, pair.ids[1]);
const one = source(['Cheap Hero']);
const lastHero = command(one.game, one.bg, 'named:Cheap Hero', false);
for (const leader of semutaAdvisorBattlePlayer(lastHero, one.target).leaders) {
leader.dead = true;
leader.deaths = Math.max(leader.deaths, 1);
}
const noLeader = applyAction(lastHero, one.target, plan(lastHero, one.target, { leader: null }));
assert.equal(noLeader.battle!.plans[one.target].leader, null);
});
void test('named Shield means the printed name, so either physical copy satisfies the requirement', () => {
const { game: initial, bg, target, ids: [first, second] } = source(['Shield', 'Shield']);
assert.notEqual(first, second);
const required = command(initial, bg, 'named:Shield', true);
reject(required, target, plan(required, target));
for (const defense of [first, second]) {
const next = applyAction(required, target, plan(required, target, { defense }));
assert.equal(next.battle!.plans[target].defense, defense);
}
});
void test('Basic rejects a named card command before committing Voice', () => {
// Controlled mode downgrade at the genuine Voice boundary isolates the new
// mode gate; it does not claim this Advanced fixture is a native Basic game.
const { game, bg } = source(['Maula Pistol']);
game.advanced = false;
reject(game, bg, { type: 'voice', kind: 'named:Maula Pistol', must: true }, /Naming an individual card requires Advanced rules/);
});
void test('all bot profiles can make legal plans under a named projectile command', () => {
const { game: initial, bg, target, ids: [maula] } = source(['Maula Pistol', 'Crysknife']);
for (const must of [true, false]) {
const voiced = command(initial, bg, 'named:Maula Pistol', must);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(voiced, target);
view.players.find(player => player.id === target)!.bot = difficulty;
const actions = botActions(view).filter(action => action.type === 'battlePlan');
assert.ok(actions.length, `${difficulty} can make a plan under named Voice`);
if (must) assert.ok(actions.every(action => action.weapon === maula));
else assert.ok(actions.every(action => action.weapon !== maula));
for (const action of actions) assert.doesNotThrow(() => applyAction(voiced, target, action));
}
}
});
+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck, ixBattleCards, ixStandardCards, type Card } from '../game/cards';
import {
VOICE_KINDS, VOICE_NAMED_CARDS, voiceChoices, voiceNamedCard,
defaultVoiceMatch, playedVoiceMatch, battleCardLabel,
} from '../game/battle-cards';
const face = (name: string): Card => {
const card = [...baseDeck(), ...ixStandardCards(), ...ixBattleCards()].find(item => item.name === name);
assert.ok(card, `Expected original ${name} card`);
return card;
};
void test('specific-name commands are canonical public Advanced choices, not Basic categories or arbitrary card IDs', () => {
assert.deepEqual(voiceChoices(false), VOICE_KINDS);
for (const name of VOICE_NAMED_CARDS) {
assert.ok(voiceChoices(true).includes(`named:${name}`));
assert.equal(voiceNamedCard(`named:${name}`), name);
}
assert.equal(voiceNamedCard('named:Truthtrance'), null);
assert.equal(voiceNamedCard('named:Poison Tooth'), null);
assert.equal(voiceNamedCard('named:treachery-1'), null);
assert.equal(voiceNamedCard('poison'), null);
assert.equal(battleCardLabel('named:Maula Pistol'), 'Maula Pistol (specific card)');
assert.equal(battleCardLabel('poison'), 'Poison weapon');
});
void test('specific-name Voice distinguishes different ordinary faces with the same role', () => {
const pistol = face('Maula Pistol');
const knife = face('Crysknife');
assert.equal(defaultVoiceMatch(pistol, 'named:Maula Pistol'), true);
assert.equal(defaultVoiceMatch(knife, 'named:Maula Pistol'), false);
assert.equal(playedVoiceMatch(pistol, 'weapon', 'named:Maula Pistol'), true);
assert.equal(playedVoiceMatch(knife, 'weapon', 'named:Maula Pistol'), false);
assert.equal(playedVoiceMatch(pistol, 'defense', 'named:Maula Pistol'), false);
assert.equal(defaultVoiceMatch(pistol, 'projectile'), true);
assert.equal(defaultVoiceMatch(knife, 'projectile'), true);
// Matching the text alone cannot turn a card of another printed role into
// the named physical face.
assert.equal(defaultVoiceMatch({ ...pistol, kind: 'poison' }, 'named:Maula Pistol'), false);
assert.equal(playedVoiceMatch({ ...pistol, kind: 'poison' }, 'weapon', 'named:Maula Pistol'), false);
});
void test('named defense, Worthless and Cheap Hero commands honor legal battle slots and copies', () => {
const shields = [...baseDeck(), ...ixStandardCards()].filter(card => card.name === 'Shield');
assert.ok(shields.length >= 2);
for (const shield of shields) {
assert.equal(defaultVoiceMatch(shield, 'named:Shield'), true);
assert.equal(playedVoiceMatch(shield, 'defense', 'named:Shield'), true);
assert.equal(playedVoiceMatch(shield, 'weapon', 'named:Shield'), false);
}
const baliset = face('Baliset');
assert.equal(playedVoiceMatch(baliset, 'weapon', 'named:Baliset'), true);
assert.equal(playedVoiceMatch(baliset, 'defense', 'named:Baliset'), true);
assert.equal(playedVoiceMatch(baliset, 'leader', 'named:Baliset'), false);
assert.equal(defaultVoiceMatch(face('Kulon'), 'named:Baliset'), false);
const hero = face('Cheap Hero'), heroine = face('Cheap Heroine');
assert.equal(playedVoiceMatch(hero, 'leader', 'named:Cheap Hero'), true);
assert.equal(playedVoiceMatch(heroine, 'leader', 'named:Cheap Hero'), false);
assert.equal(playedVoiceMatch(hero, 'weapon', 'named:Cheap Hero'), false);
assert.equal(defaultVoiceMatch(heroine, 'hero'), true);
});
void test('generic special-card default and alternate Voice matching remain intact', () => {
const chemistry = face('Chemistry'), weirding = face('Weirding Way');
assert.equal(defaultVoiceMatch(chemistry, 'snooper'), true);
assert.equal(defaultVoiceMatch(chemistry, 'poison'), false);
assert.equal(playedVoiceMatch(chemistry, 'weapon', 'poison'), true);
assert.equal(defaultVoiceMatch(weirding, 'projectile'), true);
assert.equal(defaultVoiceMatch(weirding, 'shield'), false);
assert.equal(playedVoiceMatch(weirding, 'defense', 'shield'), true);
assert.equal(voiceNamedCard('named:Chemistry'), null);
});