Support named ordinary cards in Advanced Voice
This commit is contained in:
1 parent
b00d720e40
commit
a244aeb5f8
20 files changed
+514
-41
No files matched your search
+40
-1
@@ -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 (
|
||||
(
|
||||
{
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user