290 lines
9.9 KiB
TypeScript
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))
|
|
);
|
|
}
|