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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.26.0",
|
||||
"version": "1.27.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user