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

+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',