Files
Dune/game/battle-cards.ts
T

290 lines
9.9 KiB
TypeScript

import { treacheryDeck, type Card } from './cards';
import { richeseCardDefinition } from './richese-cards';
import {
resolveBattleWeapons,
type EffectiveWeapon,
type BattleWeaponResolution,
} from './effective-weapons';
/** Keep persisted physical card shapes stable while validating their printed role. */
export function isPortableSnooper(card: Card | undefined): boolean {
return (
!!card && richeseCardDefinition(card)?.card.effect === 'portableSnooper'
);
}
export function isDefenseCard(card: Card): boolean {
return DEFENSE_KINDS.includes(card.kind) || isPortableSnooper(card);
}
export function isStoneBurner(card: Card | undefined): boolean {
return !!card && richeseCardDefinition(card)?.card.effect === 'stoneBurner';
}
export function isMirrorWeapon(card: Card | undefined): boolean {
return !!card && richeseCardDefinition(card)?.card.effect === 'mirrorWeapon';
}
export function isWeaponCard(card: Card): boolean {
return WEAPON_KINDS.includes(card.kind) || isStoneBurner(card) || isMirrorWeapon(card);
}
export const WEAPON_KINDS = [
'projectile',
'poison',
'lasgun',
'worthless',
'poisonBlade',
'poisonTooth',
'artillery',
'weirdingWay',
'chemistry',
];
export const DEFENSE_KINDS = [
'shield',
'snooper',
'worthless',
'shieldSnooper',
'weirdingWay',
'chemistry',
];
export const VOICE_KINDS = [
'stoneBurner',
'mirrorWeapon',
'portableSnooper',
'poison',
'projectile',
'lasgun',
'shield',
'snooper',
'worthless',
'hero',
'poisonBlade',
'poisonTooth',
'artillery',
'shieldSnooper',
'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.',
poisonTooth:
'After plans are revealed, choose whether to activate. If activated, attacks both leaders; Chemistry as a defense protects, while Snoopers do not. Discard if used; a winner may retain an unused tooth.',
poisonBlade:
'A projectile and poison weapon. Both defenses are needed to protect the opposing leader.',
shieldSnooper:
'Both projectile and poison defense. This is a shield and triggers a lasgun explosion.',
weirdingWay:
'A projectile weapon by default. With another weapon or Worthless card in the weapon slot, play it as a projectile defense. It is not a shield and does not trigger a lasgun explosion.',
chemistry:
'A poison defense by default. With another defense or Worthless card in the defense slot, play it as a poison weapon. It has only the role selected for this battle.',
};
/** Roles refer to the selected slot; alternate roles need the other slot occupied. */
export function validBattleCardPair(weapon?: Card, defense?: Card) {
return (
(!weapon || isWeaponCard(weapon)) &&
(!defense || isDefenseCard(defense)) &&
(!weapon || weapon.id !== defense?.id) &&
(weapon?.kind !== 'chemistry' || !!defense) &&
(defense?.kind !== 'weirdingWay' || !!weapon)
);
}
export function weaponTypes(card?: Card): string[] {
if (!card) return [];
if (isStoneBurner(card)) return ['stoneBurner'];
if (isMirrorWeapon(card)) return ['mirrorWeapon'];
if (card.kind === 'poisonBlade') return ['projectile', 'poison'];
if (card.kind === 'weirdingWay') return ['projectile'];
if (card.kind === 'chemistry' || card.kind === 'poisonTooth')
return ['poison'];
return [card.kind];
}
export function defenseTypes(card?: Card): string[] {
if (!card) return [];
if (isPortableSnooper(card)) return ['snooper'];
if (card.kind === 'shieldSnooper') return ['shield', 'snooper'];
if (card.kind === 'weirdingWay') return ['shield'];
if (card.kind === 'chemistry') return ['snooper'];
return [card.kind];
}
export function isShield(card?: Card) {
return card?.kind === 'shield' || card?.kind === 'shieldSnooper';
}
export function weaponKills(weapon?: Card, defense?: Card) {
return effectiveWeaponKills(
isStoneBurner(weapon) ? 'stoneBurner' : (weapon?.kind ?? null),
defense,
);
}
/** Resolve attack behavior without changing a physical card's identity or role. */
function effectiveWeaponKills(
kind: EffectiveWeapon['kind'],
defense?: Card,
extraSnooper = false,
) {
if (kind === 'artillery') return !isShield(defense);
if (kind === 'poisonTooth') return defense?.kind !== 'chemistry';
const attack =
kind === 'poisonBlade'
? ['projectile', 'poison']
: kind === 'weirdingWay'
? ['projectile']
: kind === 'chemistry'
? ['poison']
: [kind],
protect = defenseTypes(defense);
return (
attack.includes('lasgun') ||
(attack.includes('projectile') && !protect.includes('shield')) ||
(attack.includes('poison') && !protect.includes('snooper') && !extraSnooper)
);
}
/** 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 (
isPortableSnooper(card) ||
['shield', 'snooper', 'shieldSnooper', 'chemistry'].includes(card.kind)
? defenseTypes(card)
: weaponTypes(card)
).includes(kind);
}
export function playedVoiceMatch(
card: Card | undefined,
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 &&
(card.kind === kind ||
(slot === 'weapon'
? weaponTypes(card)
: slot === 'defense'
? defenseTypes(card)
: []
).includes(kind))
);
}
export function battleCardLabel(kind: string) {
const name = voiceNamedCard(kind);
if (name) return `${name} (specific card)`;
return (
(
{
poisonTooth: 'Poison Tooth',
artillery: 'Artillery Strike',
stoneBurner: 'Stone Burner',
mirrorWeapon: 'Mirror Weapon',
poisonBlade: 'Poison Blade',
shieldSnooper: 'Shield Snooper',
weirdingWay: 'Weirding Way',
chemistry: 'Chemistry',
hero: 'Cheap Hero',
shield: 'Projectile defense',
snooper: 'Poison defense',
portableSnooper: 'Portable Snooper',
projectile: 'Projectile weapon',
poison: 'Poison weapon',
lasgun: 'Lasgun',
worthless: 'Worthless card',
} as Record<string, string>
)[kind] ?? kind
);
}
/** Ordinary resolution only: traitors and lasgun explosions take precedence. */
export function battleCardEffects(
aw?: Card,
ad?: Card,
dw?: Card,
dd?: Card,
toothA = true,
toothD = true,
extraSnoopers: { attacker?: boolean; defender?: boolean } = {},
resolved?: BattleWeaponResolution,
) {
const weapons = resolved ?? resolveBattleWeapons({
attacker: { weapon: aw, defense: ad },
defender: { weapon: dw, defense: dd },
});
if (weapons.error) throw new Error(weapons.error);
const a =
weapons.attacker.kind === 'poisonTooth' && !toothA
? null
: weapons.attacker.kind;
const d =
weapons.defender.kind === 'poisonTooth' && !toothD
? null
: weapons.defender.kind;
const selfAttack = (kind: EffectiveWeapon['kind']) =>
kind === 'artillery' || kind === 'poisonTooth';
const artillery = a === 'artillery' || d === 'artillery';
return {
attackerDead:
effectiveWeaponKills(d, ad, extraSnoopers.attacker) ||
(selfAttack(a) && effectiveWeaponKills(a, ad, extraSnoopers.attacker)),
defenderDead:
effectiveWeaponKills(a, dd, extraSnoopers.defender) ||
(selfAttack(d) && effectiveWeaponKills(d, dd, extraSnoopers.defender)),
stunned: artillery,
noBounty: artillery,
};
}
/** Explosion and ordinary attacks consume the same revealed weapon roles. */
export function battleWeaponsExplode(
aw?: Card,
ad?: Card,
dw?: Card,
dd?: Card,
) {
const weapons = resolveBattleWeapons({
attacker: { weapon: aw, defense: ad },
defender: { weapon: dw, defense: dd },
});
if (weapons.error) throw new Error(weapons.error);
return (
(weapons.attacker.kind === 'lasgun' ||
weapons.defender.kind === 'lasgun') &&
(isShield(ad) || isShield(dd))
);
}