Files
ttrpg_manager/src/features/characters/sheet/AttacksSection.tsx
T
NilsBriggen 9ecd817bc6 Phase 1: player character depth
- Inventory + currency + encumbrance vs carrying capacity
- Class resources with correct short/long/daily rest recovery (applyRest)
- Spellcasting: slots (+pact), known/prepared spells, derived DC + attack
- Defenses: 5e death saves/inspiration/exhaustion, pf2e dying/wounded/hero points
- Derived weapon attacks + passive perception via extended RulesSystem
- Dexie v2 migration backfills new fields; debounced save flushes on pagehide
- 9 new unit tests, new character-depth e2e; all green

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 00:30:34 +02:00

82 lines
4.5 KiB
TypeScript

import { useState } from 'react';
import { newId } from '@/lib/ids';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
import type { Attack } from '@/lib/schemas';
import { formatModifier } from '@/lib/format';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { SheetSection, type SectionProps } from './common';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
export function AttacksSection({ c, update }: SectionProps) {
const [name, setName] = useState('');
const sys = getSystem(c.system);
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
const rulesInput: CharacterRulesInput = { level: c.level, abilities: c.abilities };
const add = () => {
if (name.trim() === '') return;
const atk: Attack = {
id: newId(), name: name.trim(), ability: 'str', rank: 'trained',
damageDice: '1d8', damageType: '', itemBonus: 0, addAbilityToDamage: true,
};
update({ attacks: [...c.attacks, atk] });
setName('');
};
const patch = (id: string, p: Partial<Attack>) =>
update({ attacks: c.attacks.map((a) => (a.id === id ? { ...a, ...p } : a)) });
const remove = (id: string) => update({ attacks: c.attacks.filter((a) => a.id !== id) });
return (
<SheetSection title="Attacks">
<p className="mb-2 text-sm text-muted">
Passive Perception <span className="font-display text-lg font-semibold text-accent">{sys.passivePerception({ ...rulesInput, perceptionRank: c.perceptionRank })}</span>
</p>
<div className="mb-3 flex items-end gap-2">
<label className="flex-1 min-w-40 text-xs text-muted">
New attack
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Longsword, Shortbow…" />
</label>
<Button variant="primary" onClick={add}>Add</Button>
</div>
{c.attacks.length === 0 ? (
<p className="text-sm text-muted">No attacks defined.</p>
) : (
<ul className="space-y-2">
{c.attacks.map((a) => {
const result = sys.weaponAttack(rulesInput, {
ability: a.ability, rank: a.rank, itemBonus: a.itemBonus,
damageDice: a.damageDice, addAbilityToDamage: a.addAbilityToDamage,
});
return (
<li key={a.id} className="flex flex-wrap items-center gap-2 rounded-md border border-line bg-panel px-3 py-2 text-sm">
<Input className="h-8 min-w-28 flex-1" value={a.name} onChange={(e) => patch(a.id, { name: e.target.value })} aria-label="Attack name" />
<Select className="w-auto py-1" value={a.ability} onChange={(e) => patch(a.id, { ability: e.target.value as AbilityKey })} aria-label="Attack ability">
{ABILITIES.map((ab) => <option key={ab} value={ab}>{ABILITY_ABBR[ab]}</option>)}
</Select>
<Select className="w-auto py-1" value={a.rank} onChange={(e) => patch(a.id, { rank: e.target.value as ProficiencyRank })} aria-label="Attack proficiency">
{ranks.map((r) => <option key={r} value={r}>{r}</option>)}
</Select>
<label className="text-xs text-muted">dice<Input className="ml-1 inline-block h-8 w-20" value={a.damageDice} onChange={(e) => patch(a.id, { damageDice: e.target.value })} aria-label="Damage dice" /></label>
<label className="text-xs text-muted">+item<NumberField className="ml-1 w-14" value={a.itemBonus} onChange={(v) => patch(a.id, { itemBonus: v })} aria-label="Item bonus" /></label>
<span className="ml-auto rounded bg-elevated px-2 py-1 text-sm">
<span className="text-muted">to hit </span><span className="font-display font-semibold text-accent">{formatModifier(result.toHit)}</span>
<span className="text-muted"> · dmg </span><span className="font-mono text-ink">{result.damage}{a.damageType ? ` ${a.damageType}` : ''}</span>
</span>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}></Button>
</li>
);
})}
</ul>
)}
</SheetSection>
);
}