V1.27.0: battles that run themselves: track, targets, hit or miss, saves, auto-rolls

- Turn-order track: names coloured by PC, NPC or monster, this round, a flag
  where it ends, and the next round
- Target by clicking a name on the track, the card, or keys 1-9; whoever is
  on turn and every target show what they have in hand
- Attacks and spells are declared, rolled and applied: type or roll the d20
  and the app says hit, miss or critical; a save spell asks each target for
  its save against the caster's DC; the DM overrules anything and applies
- Critical damage by the campaign's rule; a spell spends its slot; a
  concentration save is called for
- Auto-rolled monster and NPC attacks: aimed (weighted), rolled, and offered
  to the DM to accept, edit or discard; hidden from players until accepted
- Monsters can have named attacks and multiattacks; spells say how they land
- A "What now" line tells the DM (and each player) what the scene waits on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:14:04 +02:00
co-authored by Claude Opus 5.5
parent 8811f14732
commit 9b3637576d
29 changed files with 2693 additions and 38 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.26.0",
"version": "1.27.0",
"type": "module",
"scripts": {
"dev": "vite",
+35
View File
@@ -1,6 +1,8 @@
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
import { apiClient } from './client';
import type {
ActionPatch,
ActionSource,
BuildOptions,
Campaign,
CharacterClass,
@@ -984,6 +986,39 @@ export function useSetTargets(encounterId: string) {
});
}
// Declares an attack or a spell: by whoever is on turn unless an actor is
// named, at the targets marked unless some are given.
export function useDeclareAction(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { source: ActionSource; actorParticipantId?: string; targetIds?: string[]; hidden?: boolean }) =>
(await apiClient.post(`/encounters/${encounterId}/actions`, data)).data,
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
}
// Puts a roll, a ruling or the damage into an action still being resolved.
export function usePatchAction(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ actionId, ...data }: ActionPatch & { actionId: string }) => (await apiClient.patch(`/encounters/${encounterId}/actions/${actionId}`, data)).data,
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
}
// The DM lets an action land. Answers with a save to call for anyone hurt while concentrating.
export function useApplyAction(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (actionId: string) =>
(await apiClient.post<{ concentration: { name: string; damage: number; dc: number }[]; slotNote: string | null }>(`/encounters/${encounterId}/actions/${actionId}/apply`)).data,
onSuccess: (data) => {
keepEncounter(qc, encounterId, data);
qc.invalidateQueries({ queryKey: ['character'] }); // HP, and a spell slot spent
},
});
}
// The character on turn trying to bring a dead ally back; the server decides
// whether they have the means.
export function useReviveParticipant(encounterId: string) {
+92
View File
@@ -433,6 +433,11 @@ export interface Spell {
damageDice: string | null;
isStartingOption: boolean;
revives: boolean; // brings back the dead: lets its caster revive a dead ally in a battle
// How it lands in a battle: a spell attack against AC, or a save by each
// target against the caster's DC (half damage on a success when halfOnSave).
attackRoll: boolean;
saveAbility: Ability | null;
halfOnSave: boolean;
}
export interface CharacterSpell {
@@ -566,6 +571,16 @@ export interface Monster {
challengeRating: string | null;
specialTrait: string | null;
notes: string | null;
// Named attacks, for more than the one above; `count` makes it a multiattack.
attacks: MonsterAttack[];
}
export interface MonsterAttack {
name: string;
attackBonus: number;
damageDice?: string | null;
damageType?: string | null;
count?: number;
}
export type EncounterStatus = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
@@ -657,6 +672,79 @@ export interface CombatSpell {
description: string | null;
damageDice: string | null;
prepared: boolean;
attackRoll: boolean;
saveAbility: Ability | null;
halfOnSave: boolean;
}
export type ActionOutcome = 'HIT' | 'MISS' | 'CRIT' | 'FUMBLE' | 'SAVED' | 'FAILED';
// One target of an action, as the dice (or the DM) have it.
export interface ActionTarget {
participantId: string;
name: string;
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
ac: number | null; // the AC an attack is against; a player is not sent a monster's
penalty: number; // what an area attack adds to it
saveBonus: number | null; // what they add to the save, for a save
d20: number | null;
total: number | null; // the d20 plus the bonus
rolled: ActionOutcome | null; // what the dice say
outcome: ActionOutcome | null; // what stands: the dice, or the DM's ruling
overruled: boolean;
damage: number | null; // set by the DM for this target alone
finalDamage: number | null; // what they take; null until it can be known
}
// An attack or a spell in a battle, from being declared to its damage landing.
export interface EncounterAction {
id: string;
round: number;
actorParticipantId: string | null;
actorName: string;
kind: 'ATTACK' | 'SAVE' | 'AUTO'; // a d20 against AC | targets save against a DC | it just lands
name: string;
detail: {
attackBonus?: number;
saveAbility?: Ability;
saveDC?: number;
halfOnSave?: boolean;
damageDice?: string | null;
damageBonus?: number;
damageType?: string | null;
slotLevel?: number | null;
};
targets: ActionTarget[];
damage: { total: number; dice?: RolledDie[]; modifier?: number; entered?: boolean; crit?: { total: number; dice: RolledDie[]; modifier: number } | null } | null;
status: 'PENDING' | 'APPLIED' | 'DISCARDED';
auto: boolean; // declared and rolled by the server, for a monster or NPC
hidden: boolean; // the DM's alone until applied
needsRolls: number; // targets still waiting on a d20
needsDamage: boolean;
needsCrit: boolean;
ready: boolean;
createdAt: string;
appliedAt: string | null;
}
// What an action is made with (POST /encounters/:id/actions).
export type ActionSource =
| { type: 'weapon'; characterWeaponId: string }
| { type: 'spell'; characterSpellId: string; slotLevel?: number }
| { type: 'basic'; index?: number }
| { type: 'custom'; name: string; kind: 'ATTACK' | 'SAVE' | 'AUTO'; attackBonus?: number; saveAbility?: Ability; saveDC?: number; halfOnSave?: boolean; damageDice?: string | null };
// What can be changed on an action still being resolved (PATCH).
export interface ActionPatch {
rolls?: { participantId: string; d20: number | null }[];
rollRemaining?: boolean;
outcomes?: { participantId: string; outcome: 'HIT' | 'MISS' | 'CRIT' | 'SAVED' | 'FAILED' | null }[];
damage?: number | null;
rollDamage?: boolean;
targetDamage?: { participantId: string; damage: number | null }[];
targetIds?: string[];
hidden?: boolean;
discard?: boolean;
}
export interface CharacterCombatInfo {
@@ -746,6 +834,10 @@ export interface Encounter {
// The dead characters in the scene. Players do not get their cards, only
// this, so that an ally can try to revive them.
fallen?: { participantId: string; characterId: string; name: string }[];
// Attacks and spells still being resolved (a player does not get the DM's
// hidden ones), and the last few that landed.
actions?: EncounterAction[];
recentActions?: EncounterAction[];
rewards?: EncounterReward[]; // players only receive the awarded ones
wares?: SaleOffer[]; // for sale at the scene itself, not by an NPC in it
}
@@ -7,7 +7,8 @@ import { EffectsEditor } from '../EffectsEditor';
import { WaresEditor } from '../Wares';
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
import { abilityLine, rateLabel, type EntryKind, type EntryRef } from '../../lib/campaignEntries';
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, StartLevelSelect } from './parts';
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, SpellLandingSelect, StartLevelSelect } from './parts';
import { landingValue } from '../../lib/campaignEntries';
import type { GearEffect, Item, Monster, Npc, Spell, Weapon } from '../../api/types';
const KIND_NOUNS: Record<EntryKind, string> = { weapon: 'weapon', item: 'item', spell: 'spell', monster: 'monster', npc: 'NPC' };
@@ -140,6 +141,9 @@ function SpellDetail({ campaignId, spell, added, onClose }: { added: ReactNode;
<Field label="Damage">
{(id) => <SpellDamageSelect id={id} spellName={spell.name} value={spell.damageDice} onChange={(damageDice) => patch.mutate({ id: spell.id, damageDice }, { onError })} />}
</Field>
<Field label="In a battle">
{(id) => <SpellLandingSelect id={id} value={landingValue(spell)} onChange={(landing) => patch.mutate({ id: spell.id, ...landing }, { onError })} />}
</Field>
</div>
<label className="checkbox-row mt-md">
<input type="checkbox" checked={spell.isStartingOption} onChange={(e) => patch.mutate({ id: spell.id, isStartingOption: e.target.checked }, { onError })} />
@@ -191,6 +195,12 @@ function MonsterDetail({ campaignId, monster, added, onClose }: { added: ReactNo
<Chip label="Speed">{monster.speed}</Chip>
<Chip label="Attack">{fmtMod(monster.attackBonus)}</Chip>
<Chip label="Damage">{monster.damageDice || '—'}</Chip>
{(monster.attacks ?? []).map((a, i) => (
<Chip key={i} label={`${a.name}${(a.count ?? 1) > 1 ? ` ×${a.count}` : ''}`}>
{fmtMod(a.attackBonus)} · {a.damageDice || '—'}
{a.damageType ? ` ${a.damageType}` : ''}
</Chip>
))}
</div>
<p className="mono mt-sm" style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{abilityLine(monster)}</p>
{monster.specialTrait && (
@@ -18,7 +18,8 @@ import { effectsSummary } from '../../lib/gear';
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
import { MAX_GEAR_LEVEL } from '../../lib/previewCalc';
import { abilityLine, rateLabel, type EntryRef } from '../../lib/campaignEntries';
import { EntryList, Field, LibraryPicker, MonsterForm, ScalingSelect, SpellDamageSelect, StartLevelSelect } from './parts';
import { EntryList, Field, LibraryPicker, MonsterForm, ScalingSelect, SpellDamageSelect, SpellLandingSelect, StartLevelSelect } from './parts';
import { landingFrom } from '../../lib/campaignEntries';
import type { CampaignLibrary, Item, LibraryItem, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Spell, Weapon } from '../../api/types';
interface SectionProps<T> {
@@ -347,6 +348,7 @@ export function SpellsSection({ campaignId, entries, onOpen, formRequested, onFo
damageDice: String(fd.get('damageDice') || '') || null,
isStartingOption: fd.get('isStartingOption') === 'on',
revives: fd.get('revives') === 'on',
...landingFrom(String(fd.get('landing') || '')),
});
close();
onOpen({ kind: 'spell', id: spell.id, isNew: true });
@@ -412,6 +414,7 @@ export function SpellsSection({ campaignId, entries, onOpen, formRequested, onFo
</Field>
<Field label="School (optional)">{(id) => <input id={id} name="school" />}</Field>
<Field label="Damage">{(id) => <SpellDamageSelect id={id} name="damageDice" />}</Field>
<Field label="In a battle" style={{ gridColumn: '1 / -1' }}>{(id) => <SpellLandingSelect id={id} name="landing" />}</Field>
<Field label="What it does (one sentence)" style={{ gridColumn: '1 / -1' }}>
{(id) => <input id={id} name="description" maxLength={4000} required />}
</Field>
+72 -2
View File
@@ -5,8 +5,8 @@ import { Pagination } from '../Paginated';
import { usePagination } from '../../hooks/usePagination';
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS } from '../../lib/format';
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../../lib/previewCalc';
import { matchesSearch, scoreField, type MonsterInput } from '../../lib/campaignEntries';
import type { Monster } from '../../api/types';
import { landingFrom, matchesSearch, scoreField, type MonsterInput, type SpellLanding } from '../../lib/campaignEntries';
import type { Ability, Monster, MonsterAttack } from '../../api/types';
// A labelled field in a pop-out form. The child is handed the id its label points at.
export function Field({ label, style, children }: { label: string; style?: CSSProperties; children: (id: string) => ReactNode }) {
@@ -19,6 +19,30 @@ export function Field({ label, style, children }: { label: string; style?: CSSPr
);
}
const LANDING_ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
const ABILITY_NAMES: Record<Ability, string> = { STR: 'Strength', DEX: 'Dexterity', CON: 'Constitution', INT: 'Intelligence', WIS: 'Wisdom', CHA: 'Charisma' };
// How a spell lands in a battle, as one choice (lib/campaignEntries.ts, SpellLanding).
export function SpellLandingSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: string; onChange?: (landing: SpellLanding) => void }) {
return (
<select
id={id}
name={name}
title="In a battle: whether the caster rolls to hit, each target makes a saving throw, or it simply happens"
{...(onChange ? { value, onChange: (e) => onChange(landingFrom(e.target.value)) } : { defaultValue: '' })}
>
<option value="">It just takes effect (no roll)</option>
<option value="ATTACK">The caster rolls a spell attack</option>
{LANDING_ABILITIES.map((a) => (
<optgroup key={a} label={`${ABILITY_NAMES[a]} save`}>
<option value={`${a}:HALF`}>{ABILITY_NAMES[a]} save, half damage on a success</option>
<option value={`${a}:NONE`}>{ABILITY_NAMES[a]} save, nothing on a success</option>
</optgroup>
))}
</select>
);
}
// How fast a weapon's or item's dice grow as the DM levels a held copy up.
export function ScalingSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: number; onChange?: (scaling: number) => void }) {
return (
@@ -119,6 +143,17 @@ export function MonsterForm({
onSave: (data: MonsterInput) => Promise<void>;
onCancel?: () => void;
}) {
// Named attacks, as typed: kept as text until saved.
const [attacks, setAttacks] = useState<{ name: string; attackBonus: string; damageDice: string; damageType: string; count: string }[]>(() =>
(monster?.attacks ?? []).map((a) => ({
name: a.name,
attackBonus: String(a.attackBonus),
damageDice: a.damageDice ?? '',
damageType: a.damageType ?? '',
count: String(a.count ?? 1),
}))
);
const setAttack = (index: number, change: Partial<(typeof attacks)[number]>) => setAttacks((rows) => rows.map((row, i) => (i === index ? { ...row, ...change } : row)));
const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => (
<div className="field" style={{ marginBottom: 0, ...style }}>
<label htmlFor={`monster-${name}`}>{label}</label>
@@ -164,6 +199,17 @@ export function MonsterForm({
chaScore: num('chaScore'),
specialTrait: text('specialTrait'),
notes: text('notes'),
attacks: attacks
.filter((a) => a.name.trim())
.map(
(a): MonsterAttack => ({
name: a.name.trim(),
attackBonus: Math.trunc(Number(a.attackBonus)) || 0,
damageDice: a.damageDice.trim() || null,
damageType: a.damageType.trim() || null,
count: Math.min(10, Math.max(1, Math.trunc(Number(a.count)) || 1)),
})
),
});
} catch {
// Whoever opened the form shows the error; what was typed stays.
@@ -190,6 +236,30 @@ export function MonsterForm({
<div className="grid mt-sm" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))' }}>
{ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))}
</div>
<div className="field" style={{ marginTop: '0.5rem', marginBottom: 0 }}>
<label>Named attacks (optional)</label>
<p className="combatant-sub" style={{ margin: '0 0 0.3rem' }}>
For a creature with more than the one attack above: each with its own bonus and damage, and how many times it is made each turn (a multiattack).
With any listed here, these are what it attacks with.
</p>
{attacks.map((a, i) => (
<div key={i} className="flex-row flex-wrap gap-sm" style={{ marginBottom: '0.3rem' }}>
<input aria-label="Attack name" placeholder="Claw" value={a.name} maxLength={40} onChange={(e) => setAttack(i, { name: e.target.value })} style={{ flex: '2 1 110px' }} />
<NumberInput aria-label="Attack bonus" placeholder="+hit" min={-20} max={50} value={a.attackBonus} onChange={(e) => setAttack(i, { attackBonus: e.target.value })} style={{ width: 70 }} />
<input aria-label="Damage dice" placeholder="2d6+3" value={a.damageDice} maxLength={60} onChange={(e) => setAttack(i, { damageDice: e.target.value })} style={{ flex: '1 1 80px' }} />
<input aria-label="Damage type" placeholder="slashing" value={a.damageType} maxLength={30} onChange={(e) => setAttack(i, { damageType: e.target.value })} style={{ flex: '1 1 80px' }} />
<NumberInput aria-label="Times per turn" title="How many times it is made each turn" min={1} max={10} value={a.count} onChange={(e) => setAttack(i, { count: e.target.value })} style={{ width: 60 }} />
<button type="button" className="btn btn-ghost" aria-label={`Remove attack ${a.name || i + 1}`} onClick={() => setAttacks((rows) => rows.filter((_, at) => at !== i))}>
✕
</button>
</div>
))}
{attacks.length < 8 && (
<button type="button" className="btn btn-sm" onClick={() => setAttacks((rows) => [...rows, { name: '', attackBonus: String(monster?.attackBonus ?? 0), damageDice: '', damageType: '', count: '1' }])}>
+ Add an attack
</button>
)}
</div>
{field(
'specialTrait',
'Special trait',
@@ -0,0 +1,485 @@
import { useState } from 'react';
import { useApplyAction, useDeclareAction, usePatchAction } from '../../api/queries';
import { ABILITY_LABELS, fmtMod, slotLevelLabel, spellLevelLabel } from '../../lib/format';
import { NumberInput } from '../NumberInput';
import { DiceRow } from '../dice/Die';
import type { Ability, ActionOutcome, ActionSource, ActionTarget, CampaignDiceMode, EncounterAction, EncounterParticipant, MonsterAttack } from '../../api/types';
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
const OUTCOME_LABELS: Record<ActionOutcome, string> = { HIT: 'HIT', MISS: 'MISS', CRIT: 'CRITICAL HIT', FUMBLE: 'NATURAL 1: MISS', SAVED: 'SAVED', FAILED: 'FAILED' };
const OUTCOME_CLASS: Record<ActionOutcome, string> = { HIT: 'is-hit', CRIT: 'is-crit', MISS: 'is-miss', FUMBLE: 'is-miss', SAVED: 'is-saved', FAILED: 'is-hit' };
// What an action rolls with, in a few words: "+5 to hit", "DC 14 Dexterity save, half on a success".
function rollLine(action: EncounterAction): string {
const { detail } = action;
if (action.kind === 'ATTACK') return `${fmtMod(detail.attackBonus ?? 0)} to hit`;
if (action.kind === 'SAVE') {
return `DC ${detail.saveDC} ${detail.saveAbility ? ABILITY_LABELS[detail.saveAbility] : ''} save, ${detail.halfOnSave ? 'half damage' : 'nothing'} on a success`;
}
return 'no roll: it just lands';
}
function damageLine(action: EncounterAction): string | null {
const { damageDice, damageBonus, damageType } = action.detail;
if (!damageDice) return null;
return `${damageDice}${damageBonus ? ` ${fmtMod(damageBonus)}` : ''}${damageType ? ` ${damageType}` : ''}`;
}
// One attack or spell being resolved: the d20 for each target (typed in or
// rolled), whether it hits or they save, the damage, and, for the DM, the
// means to overrule any of it and let it land. A monster's auto-rolled attack
// arrives here with everything already rolled, to accept, change or discard.
export function ActionCard({
action,
participants,
isDM,
userId,
diceMode,
encounterId,
onNotice,
onError,
}: {
action: EncounterAction;
participants: EncounterParticipant[];
isDM: boolean;
userId: string | undefined;
diceMode: CampaignDiceMode;
encounterId: string;
// Lines for the DM once it has landed: a concentration save to call for, a slot that was not there.
onNotice: (...lines: string[]) => void;
onError: (err: unknown) => void;
}) {
const patch = usePatchAction(encounterId);
const apply = useApplyAction(encounterId);
const [damageTyped, setDamageTyped] = useState('');
const fail = { onError };
const send = (data: Parameters<typeof patch.mutate>[0]) => patch.mutate(data, fail);
const ownerOf = (participantId: string | null) => participants.find((p) => p.id === participantId)?.character?.userId;
const mine = Boolean(userId) && ownerOf(action.actorParticipantId) === userId;
const actsForActor = isDM || mine;
// Who may put in the d20 for a target: the attacker for an attack; the target's own player for a save.
const mayRollFor = (t: ActionTarget) => isDM || (action.kind === 'ATTACK' ? mine : Boolean(userId) && ownerOf(t.participantId) === userId);
// A player follows the campaign's dice; the DM types or rolls as they like.
const mayType = isDM || diceMode !== 'DIGITAL';
const mayAsk = isDM || diceMode !== 'PHYSICAL';
const waitingOnMe = action.targets.filter((t) => t.d20 == null && mayRollFor(t));
const busy = patch.isPending || apply.isPending;
const outcomes: ('HIT' | 'MISS' | 'CRIT' | 'SAVED' | 'FAILED')[] = action.kind === 'SAVE' ? ['SAVED', 'FAILED'] : ['HIT', 'MISS', 'CRIT'];
const damage = damageLine(action);
const total = action.targets.reduce((sum, t) => sum + (t.finalDamage ?? 0), 0);
function land() {
apply.mutate(action.id, {
onError,
onSuccess: (data) =>
onNotice(
...data.concentration.map((c) => `${c.name} is concentrating and took ${c.damage} damage: Constitution save, DC ${c.dc}. On a failure the spell ends: remove Concentrating.`),
...(data.slotNote ? [data.slotNote] : [])
),
});
}
return (
<div className={`card action-card${action.auto ? ' is-auto' : ''}${action.ready ? ' is-ready' : ''}`}>
<div className="flex-between flex-wrap">
<div className="action-title">
{action.auto ? '🤖' : action.kind === 'SAVE' ? '✨' : '⚔'} {action.actorName}: <strong>{action.name}</strong>
</div>
<div className="flex-row flex-wrap gap-sm">
{action.auto && <span className="badge badge-dm" title="Aimed and rolled by the app as this turn began">auto-rolled</span>}
{action.hidden && <span className="badge" title="The players do not see this until you accept it">🙈 hidden from players</span>}
{action.detail.slotLevel != null && <span className="badge">{slotLevelLabel(action.detail.slotLevel)}-level slot</span>}
</div>
</div>
<div className="combatant-sub">
{rollLine(action)}
{damage && ` · ${damage}`}
</div>
{action.targets.map((t) => {
const canEnter = mayRollFor(t) && mayType && action.kind !== 'AUTO';
const bonus = action.kind === 'SAVE' ? (t.saveBonus ?? 0) : (action.detail.attackBonus ?? 0);
const against = action.kind === 'ATTACK' ? (t.ac != null ? `AC ${t.ac + t.penalty}${t.penalty > 0 ? ` (${t.ac} +${t.penalty} area)` : ''}` : 'their AC') : action.kind === 'SAVE' ? `DC ${action.detail.saveDC}` : '';
return (
<div key={t.participantId} className="action-target">
<span className="action-target-name">
{action.kind === 'SAVE' ? '🛡' : '🎯'} {t.name}
</span>
{action.kind !== 'AUTO' && (
<span className="action-roll">
{canEnter ? (
<NumberInput
// Remounted when the roll changes elsewhere (the server rolled it, or someone else typed it).
key={`${t.participantId}:${t.d20}`}
min={1}
max={20}
placeholder="d20"
aria-label={action.kind === 'SAVE' ? `${t.name}'s saving throw (d20)` : `Attack roll against ${t.name} (d20)`}
defaultValue={t.d20 ?? ''}
autoFocus={t.d20 == null && waitingOnMe[0]?.participantId === t.participantId}
style={{ width: 64 }}
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
onBlur={(e) => {
const d20 = e.target.value === '' ? null : Number(e.target.value);
if (d20 !== t.d20) send({ actionId: action.id, rolls: [{ participantId: t.participantId, d20 }] });
}}
/>
) : (
<span className="mono">{t.d20 ?? '—'}</span>
)}
<span className="mono combatant-sub">
{fmtMod(bonus)}
{t.total != null && ` = ${t.total}`}
{against && ` vs ${against}`}
</span>
</span>
)}
{t.outcome ? (
<span className={`action-outcome ${OUTCOME_CLASS[t.outcome]}`}>
{OUTCOME_LABELS[t.outcome]}
{t.overruled && <span title={`The dice said ${t.rolled ? OUTCOME_LABELS[t.rolled] : 'nothing yet'}; you ruled otherwise`}> ✎</span>}
</span>
) : (
<span className="action-outcome is-waiting">{action.kind === 'SAVE' ? 'waiting for their save' : 'waiting for the d20'}</span>
)}
{t.finalDamage != null && t.finalDamage > 0 && <span className="action-damage">−{t.finalDamage} HP</span>}
{isDM && (
<span className="action-overrule">
{outcomes.map((o) => (
<button
key={o}
type="button"
className={`btn btn-sm${t.outcome === o || (o === 'MISS' && t.outcome === 'FUMBLE') ? ' is-on' : ''}`}
title={t.overruled && t.outcome === o ? 'Go back to what the dice say' : `Rule it ${OUTCOME_LABELS[o].toLowerCase()}, whatever the dice say`}
disabled={busy}
onClick={() => send({ actionId: action.id, outcomes: [{ participantId: t.participantId, outcome: t.overruled && t.outcome === o ? null : o }] })}
>
{o === 'CRIT' ? 'Crit' : o.charAt(0) + o.slice(1).toLowerCase()}
</button>
))}
<NumberInput
key={`dmg:${t.participantId}:${t.damage}`}
min={0}
max={99999}
placeholder="dmg"
title="Set what this target takes by hand. Empty: what the action deals."
aria-label={`Damage ${t.name} takes, by hand`}
defaultValue={t.damage ?? ''}
style={{ width: 64 }}
onBlur={(e) => {
const amount = e.target.value === '' ? null : Number(e.target.value);
if (amount !== t.damage) send({ actionId: action.id, targetDamage: [{ participantId: t.participantId, damage: amount }] });
}}
/>
</span>
)}
</div>
);
})}
{/* The DM aims a creature's own attack elsewhere: the d20 already rolled stays. */}
{isDM && action.targets.length === 1 && action.actorParticipantId && (
<label className="flex-row flex-wrap gap-sm mt-sm combatant-sub">
Aim it at
<select value={action.targets[0].participantId} disabled={busy} onChange={(e) => send({ actionId: action.id, targetIds: [e.target.value] })} style={{ width: 'auto' }}>
{participants
.filter((p) => !p.hidden && p.id !== action.actorParticipantId)
.map((p) => (
<option key={p.id} value={p.id}>
{p.displayName}
</option>
))}
</select>
<span>(the d20 already rolled stays)</span>
</label>
)}
{action.damage && (
<div className="action-damage-roll">
<span className="dice-label">Damage</span>
{action.damage.dice && <DiceRow dice={action.damage.dice} size={30} />}
<strong className="mono">{action.damage.total}</strong>
{action.damage.entered && <span className="combatant-sub">entered by hand</span>}
{action.damage.crit && (
<>
<span className="dice-label">Critical</span>
<DiceRow dice={action.damage.crit.dice} size={30} />
<strong className="mono">{action.damage.crit.total}</strong>
</>
)}
</div>
)}
{action.targets.some((t) => t.outcome === 'CRIT') && action.damage?.entered && (
<p className="combatant-sub" style={{ margin: '0.3rem 0 0' }}>
A critical hit with damage entered by hand: roll the damage dice twice and put the total in that target’s “dmg” box.
</p>
)}
<div className="flex-row flex-wrap gap-sm mt-sm">
{waitingOnMe.length > 0 && mayAsk && action.kind !== 'AUTO' && (
<button className="btn" disabled={busy} onClick={() => send({ actionId: action.id, rollRemaining: true })}>
🎲 Roll {waitingOnMe.length === 1 ? 'the d20' : `${waitingOnMe.length} d20s`}
</button>
)}
{actsForActor && damage && (action.needsDamage || action.damage) && (
<>
{mayAsk && (
<button className={`btn${action.needsDamage ? ' btn-primary' : ''}`} disabled={busy} onClick={() => send({ actionId: action.id, rollDamage: true })}>
🎲 {action.damage ? 'Reroll' : 'Roll'} damage
</button>
)}
{mayType && (
<span className="flex-row gap-sm">
<NumberInput
min={0}
max={99999}
placeholder="damage"
aria-label="Damage rolled at the table"
value={damageTyped}
onChange={(e) => setDamageTyped(e.target.value)}
style={{ width: 90 }}
onKeyDown={(e) => {
if (e.key === 'Enter' && damageTyped !== '') {
send({ actionId: action.id, damage: Number(damageTyped) });
setDamageTyped('');
}
}}
/>
<button
className="btn"
disabled={busy || damageTyped === ''}
onClick={() => {
send({ actionId: action.id, damage: Number(damageTyped) });
setDamageTyped('');
}}
>
Enter damage
</button>
</span>
)}
</>
)}
<span style={{ flex: 1 }} />
{isDM ? (
<>
<button
className="btn btn-primary"
disabled={busy || !action.ready}
title={
action.ready
? 'Let it land: the damage is applied and everyone sees what happened. Shortcut: A'
: action.needsRolls > 0
? `Waiting on ${action.needsRolls} d20${action.needsRolls === 1 ? '' : 's'}`
: 'Waiting on the damage'
}
onClick={land}
>
✔ {action.auto ? 'Accept' : 'Apply'}
{action.ready && total > 0 ? ` (${total} damage)` : ''}
</button>
<button className="btn btn-ghost" disabled={busy} onClick={() => send({ actionId: action.id, discard: true })}>
✕ Discard
</button>
</>
) : (
mine && (
<>
<span className="combatant-sub">{action.ready ? 'Ready: your DM lets it land.' : ''}</span>
<button className="btn btn-ghost" disabled={busy} onClick={() => send({ actionId: action.id, discard: true })}>
✕ Call it off
</button>
</>
)
)}
</div>
</div>
);
}
// What a monster or NPC attacks with, as the server works it out
// (game/combat.js, attacksOf): its named attacks, each as often as it makes
// it, or its single attack. Empty for a player (who is not sent the stats).
function creatureAttacks(p: EncounterParticipant): (MonsterAttack & { index: number })[] {
const named = (p.monster?.attacks ?? []).filter((a) => a.name);
if (named.length > 0) {
return named.flatMap((a) => Array.from({ length: Math.max(1, a.count ?? 1) }, () => a)).map((a, index) => ({ ...a, index }));
}
const stats = p.monster ?? p.npc;
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
return [{ name: 'Attack', attackBonus: stats.attackBonus ?? 0, damageDice: stats.damageDice, index: 0 }];
}
// What whoever is acting can attack with, one button each: a character's
// equipped weapons and their spells (each saying how it lands), or a
// creature's own attacks. Pressing one declares it against the targets
// marked. The DM can also spell out something else (a trap, a breath weapon).
export function ActionComposer({
actor,
targetCount,
isDM,
encounterId,
onError,
}: {
actor: EncounterParticipant | undefined;
targetCount: number;
isDM: boolean;
encounterId: string;
onError: (err: unknown) => void;
}) {
const declare = useDeclareAction(encounterId);
const [custom, setCustom] = useState(false);
const [kind, setKind] = useState<'ATTACK' | 'SAVE' | 'AUTO'>('ATTACK');
const info = actor?.characterCombatInfo;
const attacks = actor && !info ? creatureAttacks(actor) : [];
const noTarget = targetCount === 0;
const go = (source: ActionSource) => declare.mutate({ source, actorParticipantId: actor?.id }, { onError });
const slotsLeft = (level: number) => info?.spellSlots?.levels.find((l) => l.level === level)?.remaining ?? 0;
return (
<div className="card action-composer">
<div className="flex-between flex-wrap">
<div className="action-title">{actor ? `${actor.displayName} attacks with…` : 'An attack from nobody in particular'}</div>
{noTarget && <span className="badge badge-hostile">Pick a target first: click a name on the track, or 🎯 on a card</span>}
</div>
<div className="action-options">
{info?.weapons.map((w) => (
<button
key={w.characterWeaponId}
className="btn action-option is-equipped"
disabled={noTarget || declare.isPending}
title={`${w.name}: equipped. Roll a d20 ${fmtMod(w.attackBonus)} against the target's AC.`}
onClick={() => go({ type: 'weapon', characterWeaponId: w.characterWeaponId })}
>
<span className="action-option-name">⚔ {w.name}</span>
<span className="combatant-sub mono">
{fmtMod(w.attackBonus)} to hit · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
</span>
</button>
))}
{info && info.weapons.length === 0 && <span className="combatant-sub">No weapon equipped: equip one on the sheet to attack with it.</span>}
{info?.spells.map((s) => {
const cantrip = s.level === 0;
const spent = !cantrip && Boolean(info.spellSlots) && slotsLeft(s.level) === 0;
const how = s.attackRoll
? `${fmtMod(info.spellcasting?.attackBonus ?? 0)} to hit`
: s.saveAbility
? `DC ${info.spellcasting?.saveDC ?? '?'} ${s.saveAbility} save`
: 'no roll';
return (
<button
key={s.characterSpellId}
className="btn action-option"
disabled={noTarget || declare.isPending}
title={[s.description, spent ? `No ${slotLevelLabel(s.level)}-level slot left: it can still be declared, but no slot will be spent.` : null].filter(Boolean).join(' — ')}
onClick={() => go({ type: 'spell', characterSpellId: s.characterSpellId })}
>
<span className="action-option-name">
✨ {s.name} {spent && <span className="badge badge-hostile">no slot</span>}
</span>
<span className="combatant-sub mono">
{spellLevelLabel(s.level)} · {how}
{s.damageDice ? ` · ${s.damageDice}` : ''}
</span>
</button>
);
})}
{attacks.map((a) => (
<button
key={a.index}
className="btn action-option is-equipped"
disabled={noTarget || declare.isPending}
onClick={() => go({ type: 'basic', index: a.index })}
>
<span className="action-option-name">⚔ {a.name}</span>
<span className="combatant-sub mono">
{fmtMod(a.attackBonus)} to hit{a.damageDice ? ` · ${a.damageDice}` : ''}
{a.damageType ? ` ${a.damageType}` : ''}
</span>
</button>
))}
{isDM && (
<button className={`btn action-option${custom ? ' is-on' : ''}`} onClick={() => setCustom((c) => !c)} title="Spell out an attack, a save or an effect of your own: a trap, a breath weapon, a monster's spell">
<span className="action-option-name">✎ Something else…</span>
<span className="combatant-sub">a trap, a breath weapon, a monster’s spell</span>
</button>
)}
</div>
{isDM && custom && (
<form
className="flex-row flex-wrap gap-sm mt-sm"
onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
go({
type: 'custom',
name: String(fd.get('name') || '').trim() || 'Attack',
kind,
attackBonus: Number(fd.get('attackBonus')) || 0,
saveAbility: (fd.get('saveAbility') as Ability) || 'DEX',
saveDC: Number(fd.get('saveDC')) || 10,
halfOnSave: fd.get('halfOnSave') === 'on',
damageDice: String(fd.get('damageDice') || '').trim() || null,
});
}}
>
<input name="name" placeholder="What it is (Fire breath)" maxLength={60} aria-label="What it is" style={{ flex: '2 1 140px' }} />
<select value={kind} onChange={(e) => setKind(e.target.value as typeof kind)} aria-label="How it lands" style={{ width: 'auto' }}>
<option value="ATTACK">Attack roll vs AC</option>
<option value="SAVE">Targets make a save</option>
<option value="AUTO">It just lands</option>
</select>
{kind === 'ATTACK' && <NumberInput name="attackBonus" min={-20} max={50} placeholder="+ to hit" aria-label="Attack bonus" style={{ width: 90 }} />}
{kind === 'SAVE' && (
<>
<NumberInput name="saveDC" min={1} max={40} placeholder="DC" aria-label="Save DC" style={{ width: 70 }} />
<select name="saveAbility" defaultValue="DEX" aria-label="Save ability" style={{ width: 'auto' }}>
{ABILITIES.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
<label className="checkbox-row">
<input type="checkbox" name="halfOnSave" defaultChecked /> half on a success
</label>
</>
)}
<input name="damageDice" placeholder="Damage (6d6)" maxLength={60} aria-label="Damage dice" style={{ flex: '1 1 100px' }} />
<button type="submit" className="btn btn-primary" disabled={noTarget || declare.isPending}>
Declare
</button>
</form>
)}
</div>
);
}
// The last few attacks and spells that landed, newest first, one line each.
export function RecentActions({ actions }: { actions: EncounterAction[] }) {
if (actions.length === 0) return <p className="subtitle">Nothing has landed yet.</p>;
return (
<div className="roll-feed">
{actions.map((action) => (
<div key={action.id} className="roll-line">
<div className="roll-line-head">
<span>
<strong>{action.actorName}</strong> · {action.name}
</span>
<span className="combatant-sub">Round {action.round}</span>
</div>
{action.targets.map((t) => (
<div key={t.participantId} className="combatant-sub">
→ {t.name}: {t.outcome ? OUTCOME_LABELS[t.outcome].toLowerCase() : '—'}
{t.d20 != null && ` (d20 ${t.d20})`}
{t.finalDamage ? `, ${t.finalDamage} damage` : ''}
</div>
))}
</div>
))}
</div>
);
}
@@ -0,0 +1,83 @@
import type { EncounterParticipant } from '../../api/types';
const KIND_CLASS = { CHARACTER: 'track-pc', NPC: 'track-npc', MONSTER: 'track-monster' } as const;
const KIND_WORD = { CHARACTER: 'player character', NPC: 'NPC', MONSTER: 'monster' } as const;
// Who acts when, as one line of names: whoever is on turn first, then
// everyone still to act this round, a flag where the round ends, and the
// whole order again for the round after. Names only, coloured by what they
// are (player character, NPC, monster), so the DM can see what is coming.
//
// Clicking a name marks them as a target when the viewer may pick targets
// (the DM, or a player on their own turn); otherwise it goes to their card.
export function InitiativeTrack({
participants,
activeId,
round,
targetIds,
userId,
canTarget,
onTarget,
onJump,
}: {
participants: EncounterParticipant[]; // those still standing, in initiative order
activeId: string | undefined;
round: number;
targetIds: string[];
userId: string | undefined;
canTarget: boolean;
onTarget: (participantId: string) => void;
onJump: (participantId: string) => void;
}) {
const activeIndex = participants.findIndex((p) => p.id === activeId);
// This round: from whoever is on turn to the end of the order.
const thisRound = activeIndex >= 0 ? participants.slice(activeIndex) : [];
const chip = (p: EncounterParticipant, key: string, upcoming: boolean) => {
const isActive = !upcoming && p.id === activeId;
const isTargeted = targetIds.includes(p.id);
const isMine = p.character != null && p.character.userId === userId;
const targetable = canTarget && p.id !== activeId;
const dying = p.characterCombatInfo?.deathSaves.state === 'DYING';
return (
<button
key={key}
type="button"
className={`track-chip ${KIND_CLASS[p.kind]}${isActive ? ' is-active' : ''}${isTargeted ? ' is-targeted' : ''}${upcoming ? ' is-upcoming' : ''}${dying ? ' is-dying' : ''}`}
aria-current={isActive ? 'true' : undefined}
aria-pressed={targetable ? isTargeted : undefined}
title={[
`${p.displayName} (${KIND_WORD[p.kind]})`,
isActive && 'on turn',
dying && 'dying',
isTargeted && 'targeted',
targetable ? (isTargeted ? 'click to stop targeting' : 'click to target') : 'click to go to their card',
]
.filter(Boolean)
.join(' · ')}
onClick={() => (targetable ? onTarget(p.id) : onJump(p.id))}
>
{isActive && <span aria-hidden="true">▶ </span>}
{isMine && <span aria-hidden="true">★ </span>}
{isTargeted && <span aria-hidden="true">🎯 </span>}
{p.displayName}
</button>
);
};
return (
<nav className="initiative-track" aria-label="Turn order: this round, then the next">
<span className="track-round">Round {round}</span>
{thisRound.map((p) => chip(p, `now-${p.id}`, false))}
<span className="track-flag" title={`The round ends here. Round ${round + 1} starts from the top of the order.`}>
🏁 Round {round + 1}
</span>
{participants.map((p) => chip(p, `next-${p.id}`, true))}
<span className="track-legend" aria-hidden="true">
<span className="track-dot track-pc" /> PC
<span className="track-dot track-npc" /> NPC
<span className="track-dot track-monster" /> Monster
</span>
</nav>
);
}
+269 -4
View File
@@ -875,6 +875,273 @@ input[type='radio'] {
}
}
/* ---------------------------------------------------------------------- */
/* The battle: the turn-order track, the DM's cue, and attacks being */
/* resolved (components/encounter/*.tsx). */
/* ---------------------------------------------------------------------- */
/* Who acts when, as one line of names: this round, a flag, the next round. */
.initiative-track {
position: sticky;
top: 3.6rem;
z-index: 25;
display: flex;
align-items: center;
gap: 0.35rem;
overflow-x: auto;
margin: 0 -0.25rem 0.75rem;
padding: 0.5rem 0.25rem;
background: var(--bg);
border-bottom: 1px solid var(--border-soft);
scrollbar-width: thin;
}
.track-round,
.track-flag {
flex: 0 0 auto;
font-family: var(--font-display);
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-dim);
white-space: nowrap;
}
.track-flag {
padding: 0.2rem 0.6rem;
margin: 0 0.25rem;
border-left: 2px dashed var(--accent);
border-right: 2px dashed var(--accent);
color: var(--accent-strong);
}
.track-chip {
flex: 0 0 auto;
padding: 0.3rem 0.7rem;
border-radius: 999px;
border: 1px solid transparent;
border-left-width: 5px;
background: var(--bg-inset);
color: var(--text);
font-family: var(--font-body);
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.track-chip:hover {
transform: translateY(-1px);
}
/* What they are, by colour: a player character, an NPC, a monster. */
.track-pc {
border-color: var(--arcane-strong);
background: rgba(92, 127, 176, 0.16);
}
.track-npc {
border-color: var(--success-strong);
background: rgba(95, 138, 79, 0.16);
}
.track-monster {
border-color: var(--danger-strong);
background: rgba(178, 58, 58, 0.16);
}
.track-chip.is-active {
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 700;
padding: 0.4rem 0.9rem;
box-shadow: 0 0 0 2px var(--accent-strong), 0 0 14px rgba(201, 162, 39, 0.35);
}
.track-chip.is-targeted {
box-shadow: 0 0 0 2px var(--danger-strong);
}
.track-chip.is-upcoming {
opacity: 0.6;
}
.track-chip.is-dying {
text-decoration: line-through;
text-decoration-color: var(--danger-strong);
}
.track-legend {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.3rem;
margin-left: 0.75rem;
font-size: 0.72rem;
color: var(--text-dim);
white-space: nowrap;
}
.track-dot {
display: inline-block;
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
border: 2px solid;
margin-left: 0.4rem;
}
/* What the scene is waiting on, in a sentence. */
.cue-card {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.6rem;
padding: 0.55rem 0.9rem;
border-left: 4px solid var(--accent);
}
.cue-card.is-urgent {
border-color: var(--accent-strong);
box-shadow: 0 0 14px rgba(201, 162, 39, 0.2);
}
.cue-label {
font-family: var(--font-display);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-strong);
}
.cue-keys {
margin-left: auto;
font-size: 0.75rem;
}
/* An attack or a spell being resolved. */
.action-card {
border-color: var(--accent);
border-left-width: 4px;
}
.action-card.is-auto {
border-color: var(--danger-strong);
}
.action-card.is-ready {
box-shadow: 0 0 14px rgba(122, 178, 102, 0.25);
}
.action-title {
font-family: var(--font-display);
font-size: 1.05rem;
}
.action-target {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-soft);
}
.action-target-name {
font-family: var(--font-display);
min-width: 7rem;
}
.action-roll {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.action-outcome {
font-family: var(--font-display);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.05em;
padding: 0.15rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--border);
}
.action-outcome.is-hit {
color: var(--danger-strong);
border-color: var(--danger-strong);
background: var(--danger-soft);
}
.action-outcome.is-crit {
color: #1a140a;
border-color: var(--accent-strong);
background: var(--accent-strong);
}
.action-outcome.is-miss,
.action-outcome.is-saved {
color: var(--text-muted);
background: var(--bg-inset);
}
.action-outcome.is-waiting {
color: var(--text-dim);
border-style: dashed;
font-weight: 400;
}
.action-damage {
font-family: var(--font-display);
font-weight: 700;
color: var(--danger-strong);
}
.action-overrule {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
}
.action-overrule .btn.is-on,
.action-option.is-on {
border-color: var(--accent-strong);
color: var(--accent-strong);
background: var(--accent-soft);
}
.action-damage-roll {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.action-damage-roll strong {
font-size: 1.3rem;
color: var(--accent-strong);
}
.action-options {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 0.4rem;
margin-top: 0.5rem;
}
.action-option {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
text-align: left;
padding: 0.4rem 0.6rem;
}
.action-option-name {
font-family: var(--font-display);
}
/* The equipped weapon stands out among what can be attacked with. */
.action-option.is-equipped {
border-color: var(--accent-strong);
box-shadow: inset 3px 0 0 var(--accent-strong);
}
/* What whoever is on turn (and anyone targeted) is fighting with. */
.in-hand {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--accent-strong);
border-radius: 6px;
background: var(--accent-soft);
}
.in-hand-label {
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-strong);
}
.in-hand-weapon {
font-family: var(--font-display);
font-size: 0.92rem;
}
.in-hand-weapon .mono {
font-size: 0.8rem;
color: var(--text-muted);
}
/* Quick stats drawer */
.drawer-backdrop {
position: fixed;
@@ -1175,11 +1442,9 @@ table.data-table tbody tr:hover {
border-color: var(--danger);
}
/* The DM's attack bar stays in reach while scrolling the initiative order. */
/* The DM's quick damage bar. (Attacks being resolved sit above it, so it no
longer follows the scroll.) */
.attack-bar {
position: sticky;
top: 4.5rem;
z-index: 20;
border-color: var(--danger-strong);
}
.attack-target {
+23 -1
View File
@@ -1,6 +1,6 @@
import { ABILITY_ORDER } from './previewCalc';
import { formatMoney } from './format';
import type { Monster, Npc } from '../api/types';
import type { Ability, Monster, Npc } from '../api/types';
// One thing in a campaign's lists that opens in a pop-out.
export type EntryKind = 'weapon' | 'item' | 'spell' | 'monster' | 'npc';
@@ -33,3 +33,25 @@ export function matchesSearch(query: string, ...fields: (string | number | null
const text = fields.filter((f) => f !== null && f !== undefined).join(' ').toLowerCase();
return words.every((word) => text.includes(word));
}
// How a spell lands in a battle, as one choice: it just takes effect, the
// caster rolls a spell attack, or each target makes a save with an ability
// (taking half damage on a success, or none). `landingValue` and
// `landingFrom` turn it into the value of a select and back.
export interface SpellLanding {
attackRoll: boolean;
saveAbility: Ability | null;
halfOnSave: boolean;
}
export function landingValue(spell: SpellLanding): string {
if (spell.attackRoll) return 'ATTACK';
return spell.saveAbility ? `${spell.saveAbility}:${spell.halfOnSave ? 'HALF' : 'NONE'}` : '';
}
export function landingFrom(value: string): SpellLanding {
if (value === 'ATTACK') return { attackRoll: true, saveAbility: null, halfOnSave: false };
if (!value) return { attackRoll: false, saveAbility: null, halfOnSave: false };
const [ability, half] = value.split(':');
return { attackRoll: false, saveAbility: ability as Ability, halfOnSave: half === 'HALF' };
}
+197 -5
View File
@@ -25,11 +25,14 @@ import {
useDeleteReward,
useAwardReward,
useRolls,
useApplyAction,
type NewReward,
} from '../api/queries';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { useAuth } from '../auth/useAuth';
import { RollFeed } from '../components/RollFeed';
import { InitiativeTrack } from '../components/encounter/InitiativeTrack';
import { ActionCard, ActionComposer, RecentActions } from '../components/encounter/ActionPanel';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
@@ -51,6 +54,7 @@ import type {
CharacterSummary,
Currency,
Encounter,
EncounterAction,
EncounterMode,
EncounterParticipant,
EncounterReward,
@@ -298,6 +302,50 @@ function HeadlineChips({ participant: p }: { participant: EncounterParticipant }
);
}
// What a combatant is fighting with right now, set apart so it catches the
// eye: a character's equipped weapons, or a creature's attacks. Shown on the
// card of whoever is on turn and of anyone targeted. A player is not sent a
// monster's attacks, so for them a monster shows nothing here.
function InHand({ participant: p }: { participant: EncounterParticipant }) {
const info = p.characterCombatInfo;
if (info) {
return (
<div className="in-hand" title="Equipped: what they fight with">
<span className="in-hand-label">In hand</span>
{info.weapons.length === 0 && <span className="combatant-sub">nothing equipped</span>}
{info.weapons.map((w) => (
<span key={w.characterWeaponId} className="in-hand-weapon">
⚔ {w.name}{' '}
<span className="mono">
{fmtMod(w.attackBonus)} · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
</span>
</span>
))}
</div>
);
}
const named = (p.monster?.attacks ?? []).filter((a) => a.name);
const stats = p.monster ?? p.npc;
const single = stats && (stats.attackBonus != null || stats.damageDice) ? [{ name: 'Attack', attackBonus: stats.attackBonus ?? 0, damageDice: stats.damageDice, count: 1 }] : [];
const attacks = named.length > 0 ? named : single;
if (attacks.length === 0) return null;
return (
<div className="in-hand" title="What it attacks with">
<span className="in-hand-label">Attacks</span>
{attacks.map((a, i) => (
<span key={i} className="in-hand-weapon">
⚔ {a.name}
{(a.count ?? 1) > 1 ? ` ×${a.count}` : ''}{' '}
<span className="mono">
{fmtMod(a.attackBonus)}
{a.damageDice ? ` · ${a.damageDice}` : ''}
</span>
</span>
))}
</div>
);
}
// "2 rd" on a condition that wears off by itself.
function roundsNote(p: EncounterParticipant, condition: string): string | undefined {
const left = p.conditionRounds?.[condition];
@@ -601,6 +649,7 @@ function CombatantCard({
</div>
<HeadlineChips participant={p} />
{(isActive || isTargeted) && <InHand participant={p} />}
{/* An NPC's description is what players are here for, so it is never tucked away. */}
{npc?.description && <p style={{ margin: '0.5rem 0 0', whiteSpace: 'pre-wrap' }}>{npc.description}</p>}
@@ -864,6 +913,68 @@ function AttackBar({
);
}
// The one thing the scene is waiting on, in a sentence: for the DM, what to
// do next; for a player, what is theirs to do. `urgent` when it is a roll or a
// ruling that holds the fight up. Null when there is nothing to say.
function whatNow({
encounter,
isDM,
active,
targets,
actions,
myTurn,
unrolled,
userId,
}: {
encounter: Encounter;
isDM: boolean;
active: EncounterParticipant | undefined;
targets: EncounterParticipant[];
actions: EncounterAction[];
myTurn: boolean;
unrolled: number;
userId: string | undefined;
}): { text: string; urgent: boolean } | null {
if (encounter.mode !== 'BATTLE') return null;
const names = (list: { name: string }[]) => list.map((t) => t.name).join(', ');
if (!isDM) {
const mine = (encounter.participants ?? []).filter((p) => p.character?.userId === userId).map((p) => p.id);
const save = actions.find((a) => a.kind === 'SAVE' && a.targets.some((t) => t.d20 == null && mine.includes(t.participantId)));
if (save) return { text: `${save.actorName}'s ${save.name}: make a ${save.detail.saveAbility} saving throw and enter your d20.`, urgent: true };
const own = actions.find((a) => a.actorParticipantId != null && mine.includes(a.actorParticipantId));
if (own?.needsRolls) return { text: `Roll your d20 for ${own.name} against ${names(own.targets.filter((t) => t.d20 == null))}.`, urgent: true };
if (own?.needsDamage) return { text: `It hits! Roll the damage for ${own.name}.`, urgent: true };
if (own?.ready) return { text: `${own.name} is ready: your DM lets it land.`, urgent: false };
if (myTurn) return targets.length === 0 ? { text: 'Your turn! Pick a target: click a name on the track above.', urgent: true } : { text: `Your turn: choose what you attack ${names(targets.map((t) => ({ name: t.displayName })))} with.`, urgent: true };
return null;
}
if (encounter.status === 'PLANNING') {
return { text: unrolled > 0 ? `Setting up: ${unrolled} still to roll initiative. Then press Begin Battle.` : 'Everyone has rolled initiative. Press Begin Battle.', urgent: false };
}
if (encounter.status !== 'ACTIVE') return null;
const pending = actions[0];
if (pending) {
const who = `${pending.actorName}'s ${pending.name}`;
const waiting = pending.targets.filter((t) => !t.outcome);
if (pending.needsRolls > 0) {
return pending.kind === 'SAVE'
? { text: `${who}: ${waiting.length} saving throw${waiting.length === 1 ? '' : 's'} to enter (${pending.detail.saveAbility}, DC ${pending.detail.saveDC}) for ${names(waiting)}.`, urgent: true }
: { text: `${who}: enter the d20 against ${names(waiting)}${waiting[0]?.ac != null ? ` (AC ${waiting[0].ac + waiting[0].penalty})` : ''}.`, urgent: true };
}
if (pending.needsDamage) return { text: `${who} hits: roll or enter the damage.`, urgent: true };
const result = pending.targets.map((t) => `${t.name}: ${t.outcome?.toLowerCase() ?? '—'}${t.finalDamage ? ` for ${t.finalDamage}` : ''}`).join('; ');
return { text: `${who} is ready (${result}). ${pending.auto ? 'Accept, change or discard it.' : 'Apply it, or overrule anything first.'}`, urgent: true };
}
if (unrolled > 0) return { text: `${unrolled} combatant${unrolled === 1 ? ' has' : 's have'} no initiative roll yet.`, urgent: false };
if (!active) return null;
if (targets.length === 0) {
return active.character
? { text: `${active.displayName}'s turn: waiting for them to pick a target (or pick one for them: click a name).`, urgent: false }
: { text: `${active.displayName}'s turn: click a name to pick its target, or press Next Turn.`, urgent: false };
}
return { text: `${active.displayName} → ${names(targets.map((t) => ({ name: t.displayName })))}: choose the attack below, or apply damage by hand.`, urgent: false };
}
const RATING_LABELS = { TRIVIAL: 'Trivial', EASY: 'Easy', MEDIUM: 'Medium', HARD: 'Hard', DEADLY: 'Deadly' } as const;
const RATING_BADGES = { TRIVIAL: '', EASY: 'badge-friendly', MEDIUM: 'badge-dm', HARD: 'badge-hostile', DEADLY: 'badge-hostile' } as const;
@@ -1202,6 +1313,7 @@ export function EncounterPage() {
const nextTurn = useNextTurn(encounterId);
const setTargets = useSetTargets(encounterId);
const revive = useReviveParticipant(encounterId);
const applyAction = useApplyAction(encounterId);
const patchEncounter = usePatchEncounter(encounterId);
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
@@ -1228,12 +1340,40 @@ export function EncounterPage() {
// The combatant whose stats are kept in view in place of whoever is on turn.
const [pinnedId, setPinnedId] = useState<string | null>(null);
// The DM's keys: N passes the turn, S opens the sheet of whoever is on turn.
// The DM's keys: N passes the turn, S opens the sheet of whoever is on turn,
// A accepts the first attack that is ready. For whoever may pick targets
// (the DM, or a player on their own turn), 1 to 9 mark the combatants in
// turn order, leaving out whoever is on turn.
const onTurn = encounter?.participants?.find((p) => p.id === encounter.activeParticipantId);
const dmRunning = isDM && encounter?.mode === 'BATTLE' && encounter.status === 'ACTIVE';
const running = encounter?.mode === 'BATTLE' && encounter.status === 'ACTIVE';
const dmRunning = isDM && running;
const marked = encounter?.targetParticipantIds ?? [];
const mayMark = isDM || (running && onTurn?.character != null && onTurn.character.userId === user?.id);
const byKey = (encounter?.participants ?? []).filter((p) => !p.hidden && !(running && p.id === encounter?.activeParticipantId)).slice(0, 9);
const readyAction = (encounter?.actions ?? []).find((a) => a.ready);
useHotkeys({
n: dmRunning && !nextTurn.isPending ? () => nextTurn.mutate(undefined, turnPassed) : undefined,
s: dmRunning && onTurn ? () => (onTurn.character ? openCharacterPanel(onTurn.character.id) : setPinnedId(onTurn.id)) : undefined,
a:
isDM && readyAction && !applyAction.isPending
? () =>
applyAction.mutate(readyAction.id, {
onError: (err: unknown) => setFormError(extractErrorMessage(err)),
onSuccess: (data) =>
notify(
...data.concentration.map((c) => `${c.name} is concentrating and took ${c.damage} damage: Constitution save, DC ${c.dc}. On a failure the spell ends: remove Concentrating.`),
...(data.slotNote ? [data.slotNote] : [])
),
})
: undefined,
...Object.fromEntries(
byKey.map((p, i) => [
String(i + 1),
mayMark
? () => setTargets.mutate(marked.includes(p.id) ? marked.filter((id) => id !== p.id) : [...marked, p.id], { onError: (err: unknown) => setFormError(extractErrorMessage(err)) })
: undefined,
])
),
});
if (isLoading) return <Spinner label="Setting the scene…" />;
@@ -1261,6 +1401,14 @@ export function EncounterPage() {
const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? [];
const onError = (err: unknown) => setFormError(extractErrorMessage(err));
const fail = { onError };
const toggleTarget = (participantId: string) =>
setTargets.mutate(targetIds.includes(participantId) ? targetIds.filter((id) => id !== participantId) : [...targetIds, participantId], fail);
// Attacks and spells being resolved, and whether it is this viewer's own character's turn.
const actions = encounter.actions ?? [];
const myTurn = active?.character != null && active.character.userId === user?.id;
const diceMode = campaignData?.campaign.diceMode ?? 'BOTH';
// What the DM is waiting on right now, in one line; for a player, what is theirs to do.
const cue = whatNow({ encounter, isDM, active, targets, actions, myTurn, unrolled: participants.filter((p) => p.initiativeRoll == null && !p.hidden).length, userId: user?.id });
const pickOptions: { id: string; name: string }[] =
pickType === 'character'
@@ -1318,7 +1466,7 @@ export function EncounterPage() {
onTarget={
// Nobody targets themselves: the combatant on turn has no button.
canTarget && p.id !== active?.id
? () => setTargets.mutate(targetIds.includes(p.id) ? targetIds.filter((id) => id !== p.id) : [...targetIds, p.id], fail)
? () => toggleTarget(p.id)
: undefined
}
isPinned={p.id === pinnedId}
@@ -1482,8 +1630,26 @@ export function EncounterPage() {
</div>
)}
{standing.length > 1 && (
<TurnStrip participants={standing} activeId={active?.id} targetIds={targetIds} userId={user?.id} hasActed={hasActed} />
{active ? (
<InitiativeTrack
participants={standing}
activeId={active.id}
round={encounter.currentRound}
targetIds={targetIds}
userId={user?.id}
canTarget={canTarget}
onTarget={toggleTarget}
onJump={jumpToCard}
/>
) : (
standing.length > 1 && <TurnStrip participants={standing} activeId={undefined} targetIds={targetIds} userId={user?.id} hasActed={hasActed} />
)}
{cue && (
<div className={`card cue-card${cue.urgent ? ' is-urgent' : ''}`} role="status">
<span className="cue-label">{isDM ? 'What now' : 'Your move'}</span>
<span>{cue.text}</span>
{isDM && running && <span className="combatant-sub cue-keys">Keys: 1–9 target · A accept · N next turn · S sheet</span>}
</div>
)}
{(isDM || encounter.description) && (
@@ -1514,6 +1680,22 @@ export function EncounterPage() {
<div className="encounter-layout mt-md">
<div>
{actions.map((action) => (
<ActionCard
key={action.id}
action={action}
participants={participants}
isDM={isDM}
userId={user?.id}
diceMode={diceMode}
encounterId={encounterId}
onNotice={notify}
onError={onError}
/>
))}
{encounter.status === 'ACTIVE' && isBattle && (isDM || myTurn) && (active || isDM) && (
<ActionComposer actor={active} targetCount={targets.length} isDM={isDM} encounterId={encounterId} onError={onError} />
)}
{isDM && targets.length > 0 && (
<AttackBar
attacker={active}
@@ -1658,6 +1840,16 @@ export function EncounterPage() {
</div>
)}
{isBattle && (encounter.recentActions ?? []).length > 0 && (
<details className="card" open>
<summary className="card-title" style={{ cursor: 'pointer', marginBottom: 0 }}>
⚔ What just happened
</summary>
<div className="mt-sm">
<RecentActions actions={encounter.recentActions ?? []} />
</div>
</details>
)}
{isDM && (
<details className="card" open={(rolls ?? []).length > 0}>
<summary className="card-title" style={{ cursor: 'pointer', marginBottom: 0 }}>