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
@@ -7,7 +7,7 @@ import {
|
|||||||
isStoneBurner,
|
isStoneBurner,
|
||||||
isMirrorWeapon,
|
isMirrorWeapon,
|
||||||
isDefenseCard,
|
isDefenseCard,
|
||||||
VOICE_KINDS,
|
voiceChoices,
|
||||||
} from '@/game/battle-cards';
|
} from '@/game/battle-cards';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
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>
|
<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.blocked && <p className="notice">{offer.blocked}</p>}
|
||||||
{offer.canUse && <>
|
{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)}>
|
<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>)}
|
.map(k => <option key={k} value={k}>{battleCardLabel(k)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="decision-checkbox">
|
<label className="decision-checkbox">
|
||||||
<input type="checkbox" checked={must} onChange={e => setMust(e.target.checked)} />
|
<input type="checkbox" checked={must} onChange={e => setMust(e.target.checked)} />
|
||||||
Require this type (unchecked: forbid)
|
Require this card or type (unchecked: forbid)
|
||||||
</label>
|
</label>
|
||||||
<p className="notice">Provisional policy: Karama may cancel the Voice. The Nexus card stays spent.</p>
|
<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}
|
<Button className="game-action" disabled={busy || !!offer.blocked || !selected}
|
||||||
@@ -185,13 +185,13 @@ export function BattlePreparation({
|
|||||||
</p>
|
</p>
|
||||||
{preparation.blocked && <p className="notice">{preparation.blocked}</p>}
|
{preparation.blocked && <p className="notice">{preparation.blocked}</p>}
|
||||||
<label htmlFor="voice-kind">
|
<label htmlFor="voice-kind">
|
||||||
Card type
|
Card or type
|
||||||
<select
|
<select
|
||||||
id="voice-kind"
|
id="voice-kind"
|
||||||
value={kind}
|
value={kind}
|
||||||
onChange={(e) => setKind(e.target.value)}
|
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}>
|
<option key={k} value={k}>
|
||||||
{battleCardLabel(k)}
|
{battleCardLabel(k)}
|
||||||
</option>
|
</option>
|
||||||
@@ -204,7 +204,7 @@ export function BattlePreparation({
|
|||||||
checked={must}
|
checked={must}
|
||||||
onChange={(e) => setMust(e.target.checked)}
|
onChange={(e) => setMust(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
Require this type (unchecked: forbid)
|
Require this card or type (unchecked: forbid)
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
className="game-action"
|
className="game-action"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { GameView } from '@/game/engine';
|
|||||||
import type { MixedActionExpression } from '@/game/mixed-action-question';
|
import type { MixedActionExpression } from '@/game/mixed-action-question';
|
||||||
import { parseBattleActionClaim } from '@/game/mixed-battle-question';
|
import { parseBattleActionClaim } from '@/game/mixed-battle-question';
|
||||||
import { battlePowerOf, nativeBattlePowerOpportunity, type BattlePowerClaim } from '@/game/battle-power-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 { nativeStormDialOpportunity } from '@/game/storm-dial-question';
|
||||||
import { karamaBattleCancelOpportunity, WINDOW_CARD_EFFECTS, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
|
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';
|
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>
|
</select>
|
||||||
</> : value.claim.kind === 'voiceCommand' ? <>
|
</> : value.claim.kind === 'voiceCommand' ? <>
|
||||||
<label htmlFor={`${id}-power-card-type`}>Voice card type</label>
|
<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 : ''}
|
<select id={`${id}-power-card-type`} value={voiceChoices(game.advanced).includes(value.claim.cardType) ? value.claim.cardType : ''}
|
||||||
disabled={disabled} aria-invalid={!VOICE_KINDS.includes(value.claim.cardType)} onChange={event => {
|
disabled={disabled} aria-invalid={!voiceChoices(game.advanced).includes(value.claim.cardType)} onChange={event => {
|
||||||
if (value.claim.kind === 'voiceCommand' && (event.currentTarget.value === '' || VOICE_KINDS.includes(event.currentTarget.value)))
|
if (value.claim.kind === 'voiceCommand' && (event.currentTarget.value === '' || voiceChoices(game.advanced).includes(event.currentTarget.value)))
|
||||||
onChange({ ...value, claim: { ...value.claim, cardType: event.currentTarget.value } });
|
onChange({ ...value, claim: { ...value.claim, cardType: event.currentTarget.value } });
|
||||||
}}>
|
}}>
|
||||||
<option value="">Choose a native Voice card type</option>
|
<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>
|
</select>
|
||||||
<label htmlFor={`${id}-power-must`}>Exact Voice command</label>
|
<label htmlFor={`${id}-power-must`}>Exact Voice command</label>
|
||||||
<select id={`${id}-power-must`} value={value.claim.must ? 'must' : 'cannot'} disabled={disabled}
|
<select id={`${id}-power-must`} value={value.claim.must ? 'must' : 'cannot'} disabled={disabled}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ordinaryCardAvailability } from '@/game/card-availability';
|
|||||||
import { gholaForcePreparations } from '@/game/ghola-force-preparation';
|
import { gholaForcePreparations } from '@/game/ghola-force-preparation';
|
||||||
import { richeseCardDefinition } from '@/game/richese-cards';
|
import { richeseCardDefinition } from '@/game/richese-cards';
|
||||||
import { nativeBattlePowerOpportunity, type NativeBattlePower } from '@/game/battle-power-question';
|
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 { nativeStormDialOpportunity } from '@/game/storm-dial-question';
|
||||||
import { karamaBattleCancelOpportunity, WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
|
import { karamaBattleCancelOpportunity, WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
|
||||||
import type { TurnActionLeaf } from '@/game/turn-action-promises';
|
import type { TurnActionLeaf } from '@/game/turn-action-promises';
|
||||||
@@ -409,7 +409,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
|||||||
JSON.stringify(publicBattleContext(current)) === battleSignature;
|
JSON.stringify(publicBattleContext(current)) === battleSignature;
|
||||||
const powerPacket = Object.keys(action).every(key => ['type', 'turnActionPromiseEvent', 'turnActionPromisePhase',
|
const powerPacket = Object.keys(action).every(key => ['type', 'turnActionPromiseEvent', 'turnActionPromisePhase',
|
||||||
...(action.type === 'voice' ? ['kind', 'must'] : action.type === 'prescience' ? ['field'] : [])].includes(key)) &&
|
...(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 === 'prescience' ? ['leader', 'weapon', 'defense', 'dial'].includes(String(action.field))
|
||||||
: action.type === 'declineBattlePower');
|
: action.type === 'declineBattlePower');
|
||||||
const ownedPower = currentBattle && !!power && !!powerSource && powerSource.ready && powerSource.owner === actor &&
|
const ownedPower = currentBattle && !!power && !!powerSource && powerSource.ready && powerSource.owner === actor &&
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ open mode gates.
|
|||||||
|
|
||||||
|
|
||||||
## Current checkpoint and work
|
## 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.**
|
**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
|
The host can add two AI faction seats under one AI controller. Existing lobby
|
||||||
configuration applies separately; removing either bot keeps or promotes the
|
configuration applies separately; removing either bot keeps or promotes the
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# Dune implementation status
|
# 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
|
## 28 October 2026 — AI opponent with two factions
|
||||||
|
|
||||||
An AI controller can have two distinct bot faction seats in the optional
|
An AI controller can have two distinct bot faction seats in the optional
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
# Portable Snooper integration audit
|
# 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
|
## Named Voice choice — 27 October 2026
|
||||||
|
|
||||||
**Prototyped / Partial.** Voice can name the canonical Portable Snooper
|
**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)
|
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
|
use real battle actions with a documented conserved-card and alliance
|
||||||
supplement; [control cases](../tests/voice-portable-snooper-controls.test.tsx)
|
supplement; [control cases](../tests/voice-portable-snooper-controls.test.tsx)
|
||||||
use a controlled projection. Naming individual ordinary cards and wider
|
use a controlled projection. The later Advanced ordinary-name prototype above
|
||||||
combinations, browser and deployed acceptance remain open.
|
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.
|
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
@@ -1,5 +1,15 @@
|
|||||||
# Rule decision index
|
# 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
|
## Truthtrance and Atreides ordinary auction knowledge — 27 October 2026
|
||||||
|
|
||||||
The publisher FAQ permits game-related yes/no and compound questions. A named
|
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
|
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 Snooper face also subjects its late defense to Voice. The canonical
|
||||||
Portable is now a distinct command from the broader poison-defense category,
|
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
|
in both ordinary plans and late use. The later Advanced ordinary-name prototype
|
||||||
combinations remain separate.
|
above supersedes the earlier absence; wider combinations remain separate.
|
||||||
[Scope](PORTABLE_SNOOPER_ENGINE_AUDIT.md#named-voice-choice--27-october-2026).
|
[Scope](PORTABLE_SNOOPER_ENGINE_AUDIT.md#named-voice-choice--27-october-2026).
|
||||||
|
|
||||||
## Semuta and an already-ready Poison battle — 27 October 2026
|
## Semuta and an already-ready Poison battle — 27 October 2026
|
||||||
|
|||||||
+40
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Card } from './cards';
|
import { treacheryDeck, type Card } from './cards';
|
||||||
import { richeseCardDefinition } from './richese-cards';
|
import { richeseCardDefinition } from './richese-cards';
|
||||||
import {
|
import {
|
||||||
resolveBattleWeapons,
|
resolveBattleWeapons,
|
||||||
@@ -62,6 +62,37 @@ export const VOICE_KINDS = [
|
|||||||
'weirdingWay',
|
'weirdingWay',
|
||||||
'chemistry',
|
'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>> = {
|
export const BATTLE_CARD_HELP: Partial<Record<Card['kind'], string>> = {
|
||||||
artillery:
|
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.',
|
'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. */
|
/** Compulsion considers the card's default role, never forces an alternate role. */
|
||||||
export function defaultVoiceMatch(card: Card, kind: string) {
|
export function defaultVoiceMatch(card: Card, kind: string) {
|
||||||
|
if (voiceNamedCard(kind)) return namedVoiceMatch(card, kind);
|
||||||
if (kind === 'portableSnooper') return isPortableSnooper(card);
|
if (kind === 'portableSnooper') return isPortableSnooper(card);
|
||||||
if (card.kind === kind) return true;
|
if (card.kind === kind) return true;
|
||||||
return (
|
return (
|
||||||
@@ -153,6 +185,11 @@ export function playedVoiceMatch(
|
|||||||
slot: 'weapon' | 'defense' | 'leader',
|
slot: 'weapon' | 'defense' | 'leader',
|
||||||
kind: string,
|
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);
|
if (kind === 'portableSnooper') return slot === 'defense' && isPortableSnooper(card);
|
||||||
return (
|
return (
|
||||||
!!card &&
|
!!card &&
|
||||||
@@ -167,6 +204,8 @@ export function playedVoiceMatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function battleCardLabel(kind: string) {
|
export function battleCardLabel(kind: string) {
|
||||||
|
const name = voiceNamedCard(kind);
|
||||||
|
if (name) return `${name} (specific card)`;
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { VOICE_KINDS } from './battle-cards';
|
import { voiceChoices } from './battle-cards';
|
||||||
|
|
||||||
export type NativeBattlePower = 'voice' | 'prescience';
|
export type NativeBattlePower = 'voice' | 'prescience';
|
||||||
export type BattlePowerClaim =
|
export type BattlePowerClaim =
|
||||||
@@ -51,7 +51,7 @@ const identifier = (value: unknown): value is string =>
|
|||||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||||
const keys = (value: Record<string, unknown>, expected: string): boolean =>
|
const keys = (value: Record<string, unknown>, expected: string): boolean =>
|
||||||
Object.keys(value).sort().join(',') === expected;
|
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' =>
|
const field = (value: unknown): value is 'leader' | 'weapon' | 'defense' | 'dial' =>
|
||||||
value === 'leader' || value === 'weapon' || value === 'defense' || value === '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 voice = b.voice?.target === me.id ? b.voice : null;
|
||||||
const mayOmitLeader =
|
const mayOmitLeader =
|
||||||
!availableLeaders.length &&
|
!availableLeaders.length &&
|
||||||
(!heroes.length || (voice?.kind === 'hero' && !voice.must));
|
!heroes.some(hero => !(voice && !voice.must && playedVoiceMatch(hero, 'leader', voice.kind)));
|
||||||
const leaders = [
|
const leaders = [
|
||||||
...(mayOmitLeader ? [null] : []),
|
...(mayOmitLeader ? [null] : []),
|
||||||
...availableLeaders.map((l) => l.id),
|
...availableLeaders.map((l) => l.id),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FACTIONS, type FactionId } from './catalog';
|
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 { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
|
||||||
import { validateNexusBorrowedVoice, type NexusBorrowedVoiceFrame } from './nexus-borrowed-voice';
|
import { validateNexusBorrowedVoice, type NexusBorrowedVoiceFrame } from './nexus-borrowed-voice';
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ function calculate(
|
|||||||
record(b.voice) &&
|
record(b.voice) &&
|
||||||
b.voice.target === opponent &&
|
b.voice.target === opponent &&
|
||||||
typeof b.voice.kind === 'string' &&
|
typeof b.voice.kind === 'string' &&
|
||||||
VOICE_KINDS.includes(b.voice.kind) &&
|
voiceChoices(input.advanced).includes(b.voice.kind) &&
|
||||||
typeof b.voice.must === 'boolean',
|
typeof b.voice.must === 'boolean',
|
||||||
'The declared Voice response is malformed or targets the wrong opponent.',
|
'The declared Voice response is malformed or targets the wrong opponent.',
|
||||||
);
|
);
|
||||||
|
|||||||
+14
-14
@@ -698,7 +698,8 @@ import {
|
|||||||
isStoneBurner,
|
isStoneBurner,
|
||||||
isMirrorWeapon,
|
isMirrorWeapon,
|
||||||
isPortableSnooper,
|
isPortableSnooper,
|
||||||
VOICE_KINDS,
|
voiceChoices,
|
||||||
|
voiceNamedCard,
|
||||||
defaultVoiceMatch,
|
defaultVoiceMatch,
|
||||||
playedVoiceMatch,
|
playedVoiceMatch,
|
||||||
} from './battle-cards';
|
} from './battle-cards';
|
||||||
@@ -965,7 +966,7 @@ export type Battle = {
|
|||||||
field: PlanField;
|
field: PlanField;
|
||||||
value?: string | number | null;
|
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 = {
|
export type Auction = {
|
||||||
/** New ordinary lots use trusted online timing; old open lots stay unstamped. */
|
/** 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']> {
|
function quoteVoiceCommand(g: Game, target: string, kind: unknown, must: unknown): NonNullable<Battle['voice']> {
|
||||||
const occupation = homeworldRule(() => quoteOccupiedHomeworldVoice(g, target));
|
const occupation = homeworldRule(() => quoteOccupiedHomeworldVoice(g, target));
|
||||||
requireRule(occupation.status === 'allowed', occupation.blocked ?? 'This target cannot receive the Voice.');
|
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') {
|
if (kind === 'mirrorWeapon') {
|
||||||
const blocked = mirrorWeaponModeBlock(g);
|
const blocked = mirrorWeaponModeBlock(g);
|
||||||
requireRule(!blocked, blocked ?? 'Mirror Weapon is unavailable in this battle configuration.');
|
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) {
|
function nexusBorrowedVoiceIntegrity(g: Game) {
|
||||||
const b = g.battle, frame = b?.nexusBorrowedVoice;
|
const b = g.battle, frame = b?.nexusBorrowedVoice;
|
||||||
if (!b || !frame) return;
|
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'),
|
requireRule(b.event && g.nexusCards?.cards && !byFaction(g, 'beneGesserit'),
|
||||||
'Borrowed Voice has lost its absent-faction Nexus module.');
|
'Borrowed Voice has lost its absent-faction Nexus module.');
|
||||||
nexusRule(() => validateNexusBorrowedVoice({
|
nexusRule(() => validateNexusBorrowedVoice({
|
||||||
@@ -3221,7 +3225,7 @@ function nexusBorrowedVoiceOffer(g: Game, id: string) {
|
|||||||
g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy
|
g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy
|
||||||
? 'Finish the current response before continuing borrowed Voice.' : null;
|
? 'Finish the current response before continuing borrowed Voice.' : null;
|
||||||
const choices: { kind: NonNullable<Battle['voice']>['kind']; must: boolean }[] = [];
|
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;
|
if (kind === 'mirrorWeapon' && mirrorWeaponModeBlock(g)) continue;
|
||||||
choices.push({ kind: kind as NonNullable<Battle['voice']>['kind'], must: false });
|
choices.push({ kind: kind as NonNullable<Battle['voice']>['kind'], must: false });
|
||||||
if (!['stoneBurner', 'mirrorWeapon'].includes(kind) || !stoneCompulsionBlock(g, getPlayer(g, target)))
|
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.owner) &&
|
||||||
g.players.some(p => p.id === record.target && p.faction === 'beneGesserit') &&
|
g.players.some(p => p.id === record.target && p.faction === 'beneGesserit') &&
|
||||||
record.voice && typeof record.voice.must === 'boolean' &&
|
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),
|
g.players.some(p => p.id === record.voice.target) && !events.has(record.event),
|
||||||
'Bene Gesserit Betrayal has a malformed or repeated Voice receipt.');
|
'Bene Gesserit Betrayal has a malformed or repeated Voice receipt.');
|
||||||
events.add(record.event);
|
events.add(record.event);
|
||||||
@@ -11210,9 +11214,8 @@ function finishMandatorySkillVisibility(g: Game) {
|
|||||||
const available = controlledLeaders(g, p).filter(
|
const available = controlledLeaders(g, p).filter(
|
||||||
(l) => !l.dead && (!l.usedAt || l.usedAt === b.territory),
|
(l) => !l.dead && (!l.usedAt || l.usedAt === b.territory),
|
||||||
);
|
);
|
||||||
const heroAvailable =
|
const heroAvailable = p.hand.some((c) => c.kind === 'hero' &&
|
||||||
p.hand.some((c) => c.kind === 'hero') &&
|
!(b.voice?.target === owner && !b.voice.must && playedVoiceMatch(c, 'leader', b.voice.kind)));
|
||||||
!(b.voice?.target === owner && b.voice.kind === 'hero' && !b.voice.must);
|
|
||||||
if (
|
if (
|
||||||
available.length === 1 &&
|
available.length === 1 &&
|
||||||
available[0].id === assignment.leader &&
|
available[0].id === assignment.leader &&
|
||||||
@@ -20775,7 +20778,7 @@ function turnActionPowerCompletion(g: Game, actor: string, requirements: readonl
|
|||||||
{ declaration: { power, use: false }, action: { type: 'declineBattlePower' } },
|
{ declaration: { power, use: false }, action: { type: 'declineBattlePower' } },
|
||||||
];
|
];
|
||||||
if (power === 'voice') {
|
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 {
|
try {
|
||||||
quoteVoiceCommand(g, source.target, kind, must);
|
quoteVoiceCommand(g, source.target, kind, must);
|
||||||
choices.push({ declaration: { power, use: true, cardType: kind, must }, action: { type: 'voice', kind, must } });
|
choices.push({ declaration: { power, use: true, cardType: kind, must }, action: { type: 'voice', kind, must } });
|
||||||
@@ -29854,11 +29857,8 @@ function validatePlan(
|
|||||||
);
|
);
|
||||||
requireRule(
|
requireRule(
|
||||||
plan.leader ||
|
plan.leader ||
|
||||||
(!available.length &&
|
(!available.length && !p.hand.some((c) => c.kind === 'hero' &&
|
||||||
(!p.hand.some((c) => c.kind === 'hero') ||
|
!(b.voice?.target === p.id && !b.voice.must && playedVoiceMatch(c, 'leader', b.voice.kind)))),
|
||||||
(b.voice?.target === p.id &&
|
|
||||||
!b.voice.must &&
|
|
||||||
b.voice.kind === 'hero'))),
|
|
||||||
'You must use an available leader or cheap hero.',
|
'You must use an available leader or cheap hero.',
|
||||||
);
|
);
|
||||||
const w = cardOf(p, plan.weapon),
|
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';
|
import type { Battle } from './engine';
|
||||||
|
|
||||||
export type NexusBorrowedVoiceFrame = {
|
export type NexusBorrowedVoiceFrame = {
|
||||||
@@ -71,7 +71,7 @@ function validateFrame(frame: NexusBorrowedVoiceFrame): void {
|
|||||||
frame.voice && typeof frame.voice === 'object' && !Array.isArray(frame.voice) &&
|
frame.voice && typeof frame.voice === 'object' && !Array.isArray(frame.voice) &&
|
||||||
Object.keys(frame.voice).sort().join(',') === 'kind,must,target' &&
|
Object.keys(frame.voice).sort().join(',') === 'kind,must,target' &&
|
||||||
frame.voice.target === frame.order[1 - frame.passed.length] &&
|
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',
|
typeof frame.voice.must === 'boolean',
|
||||||
'Borrowed Voice needs one ordered owner and a canonical opposing command.',
|
'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',
|
id: 'truthtrance-knowledge',
|
||||||
title: 'Truthtrance: recorded predictions and private forecasts',
|
title: 'Truthtrance: recorded predictions and private forecasts',
|
||||||
|
|||||||
@@ -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: 'voice', use: false }, decline), true);
|
||||||
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'projectile', must: true }, decline), false);
|
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'projectile', must: true }, decline), false);
|
||||||
assert.equal(battlePowerMatches({ kind: 'prescience', use: false }, 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', () => {
|
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]!;
|
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]) {
|
for (const advanced of [false, true]) {
|
||||||
const band = advanced ? 'Advanced' : 'Basic';
|
const band = advanced ? 'Advanced' : 'Basic';
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ void test('declarations reject missing/noncanonical commands, nonbooleans and wr
|
|||||||
const frame = createNexusBorrowedVoice(context);
|
const frame = createNexusBorrowedVoice(context);
|
||||||
const invalid: unknown[] = [
|
const invalid: unknown[] = [
|
||||||
null, undefined, {}, { ...command, target: 'a' }, { ...command, target: 'foreign' },
|
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 },
|
{ ...command, kind: 'Poison' }, { ...command, kind: null },
|
||||||
{ target: 'd', must: true }, { target: 'd', kind: 'poison' },
|
{ target: 'd', must: true }, { target: 'd', kind: 'poison' },
|
||||||
{ ...command, must: 'false' }, { ...command, must: 0 },
|
{ ...command, must: 'false' }, { ...command, must: 0 },
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user