- Spell slots. Every class that casts has a slot tracker, worked out from class and level: the full-caster table for bards, clerics, druids, sorcerers and wizards, the half-caster table for paladins and rangers (from level 2), and a warlock's few slots of one level. It is in the Spellcasting card on the sheet and in the character panel, and the party overview has a column for it. A player presses Use to spend a slot; only the DM gives one back. - Rests. The DM's "Rest…" button, beside the character list and on the party overview, rests any of the party at once. A short rest shows which Hit Dice each character can roll and heals from what they rolled (typed in, or rolled by the site), and gives a warlock their slots. A long rest puts back HP, every spell slot and half the level in Hit Dice, and clears temp HP. - Hit Dice are counted: one per level, shown on the sheet, the panel and the party overview. - Level Up. "Level Up…" on a sheet takes a character up one level with the whole checklist: the new max HP and a full heal, the class's new special abilities for levels 2 to 10, a Boon where the level grants one (Toughened raises Extra AC, Hardy gives temp HP now and after each long rest), fresh spell slots and new spells, and the free upgrade of one standard weapon. - A level typed into the Level field still writes down what the class gains on the way up, and leaves a line where a Boon is owed. - The docs cover all of it, with a generated list of what each class gains at each level. The Hollowcrown seed's note no longer says to track spell slots on paper. - New migration: spent spell slots and Hit Dice on a character. - All packages at version 1.14.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
10 KiB
TypeScript
235 lines
10 KiB
TypeScript
import { useState } from 'react';
|
||
import { useRest } from '../api/queries';
|
||
import { CLASS_LABELS, extractErrorMessage, fmtMod, slotsSummary } from '../lib/format';
|
||
import { Modal } from './Modal';
|
||
import { NumberInput } from './NumberInput';
|
||
import { ErrorBanner } from './Spinner';
|
||
import { LifeBadge } from './DeathSaves';
|
||
import type { CharacterSummary, RestResult, RestType } from '../api/types';
|
||
|
||
const REST_LABELS: Record<RestType, string> = { SHORT: 'Short rest', LONG: 'Long rest' };
|
||
|
||
// What a rest did for one character, in a line or two.
|
||
function ResultRow({ result, type }: { result: RestResult; type: RestType }) {
|
||
if (result.skipped) {
|
||
return (
|
||
<div className="reward-row">
|
||
<strong>{result.name}</strong>
|
||
<div className="combatant-sub">{result.skipped}</div>
|
||
</div>
|
||
);
|
||
}
|
||
const { hitDice } = result;
|
||
const lines: string[] = [];
|
||
if (hitDice.spent > 0) {
|
||
const dice = `${hitDice.spent}d${hitDice.size}`;
|
||
const rolled = hitDice.rolls ? `the site rolled ${hitDice.rolls.join(' + ')}` : `rolled ${hitDice.total}`;
|
||
const con = hitDice.conBonus === 0 ? '' : ` ${hitDice.conBonus > 0 ? '+' : '−'} ${Math.abs(hitDice.conBonus)} Constitution`;
|
||
lines.push(`Spent ${dice}: ${rolled}${con}, healing ${hitDice.healed}.`);
|
||
}
|
||
if (hitDice.regained > 0) lines.push(`${hitDice.regained} Hit ${hitDice.regained === 1 ? 'Die' : 'Dice'} back.`);
|
||
lines.push(`Hit Dice left: ${hitDice.left} of ${hitDice.of}.`);
|
||
if (result.slotsRestored) lines.push('All spell slots back.');
|
||
if (type === 'SHORT' && hitDice.spent === 0 && !result.slotsRestored) lines.unshift('Rested, with no Hit Dice spent.');
|
||
|
||
return (
|
||
<div className="reward-row">
|
||
<div className="flex-between">
|
||
<strong>{result.name}</strong>
|
||
<span className="mono">
|
||
HP {result.hpBefore === result.hpAfter ? result.hpAfter : `${result.hpBefore} → ${result.hpAfter}`}/{result.maxHp}
|
||
{result.tempHp > 0 && ` +${result.tempHp}`}
|
||
</span>
|
||
</div>
|
||
<div className="combatant-sub">{lines.join(' ')}</div>
|
||
{result.notes.map((note) => (
|
||
<div key={note} className="combatant-sub">
|
||
✦ {note}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// The DM's rest button: pick who rests and for how long. A long rest is
|
||
// worked out by the site. A short rest says which Hit Dice each character can
|
||
// roll; the DM types how many they spend and what they rolled (or leaves the
|
||
// roll to the site), and the healing is added up from that.
|
||
export function RestModal({ campaignId, characters, onClose }: { campaignId: string; characters: CharacterSummary[]; onClose: () => void }) {
|
||
const rest = useRest(campaignId);
|
||
const party = characters.filter((c) => c.buildStage === 'COMPLETE');
|
||
const [type, setType] = useState<RestType>('SHORT');
|
||
// The dead are left out to begin with: a rest does nothing for them.
|
||
const [picked, setPicked] = useState<Set<string>>(() => new Set(party.filter((c) => c.deathSaves.state !== 'DEAD').map((c) => c.id)));
|
||
const [dice, setDice] = useState<Record<string, string>>({});
|
||
const [rolled, setRolled] = useState<Record<string, string>>({});
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [done, setDone] = useState<{ type: RestType; results: RestResult[] } | null>(null);
|
||
|
||
function toggle(id: string) {
|
||
setPicked((current) => {
|
||
const next = new Set(current);
|
||
if (!next.delete(id)) next.add(id);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
async function handleRest() {
|
||
setError(null);
|
||
try {
|
||
const resting = party.filter((c) => picked.has(c.id));
|
||
setDone(
|
||
await rest.mutateAsync({
|
||
type,
|
||
characters: resting.map((c) => {
|
||
const spent = type === 'SHORT' ? Number(dice[c.id]) || 0 : 0;
|
||
return { characterId: c.id, hitDice: spent, rolled: spent > 0 && rolled[c.id] ? Number(rolled[c.id]) : null };
|
||
}),
|
||
})
|
||
);
|
||
} catch (err) {
|
||
setError(extractErrorMessage(err));
|
||
}
|
||
}
|
||
|
||
if (done) {
|
||
return (
|
||
<Modal title={`${REST_LABELS[done.type]} taken`} subtitle="What it did for each character" onClose={onClose}>
|
||
{done.results.map((result) => (
|
||
<ResultRow key={result.characterId} result={result} type={done.type} />
|
||
))}
|
||
{done.type === 'SHORT' && (
|
||
<p className="combatant-sub mt-md" style={{ marginBottom: 0 }}>
|
||
Also back on a short rest, for the players to note: a fighter's Second Wind and Action Surge, a monk's ki, a cleric's Channel
|
||
Divinity, a druid's Wild Shape.
|
||
</p>
|
||
)}
|
||
{done.type === 'LONG' && (
|
||
<p className="combatant-sub mt-md" style={{ marginBottom: 0 }}>
|
||
"Per day" abilities are back too. One long rest per 24 hours.
|
||
</p>
|
||
)}
|
||
<div className="modal-foot">
|
||
<button className="btn btn-primary" onClick={onClose}>
|
||
Done
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Modal title="Rest" subtitle="Pick who rests, and for how long" wide onClose={onClose}>
|
||
<div className="flex-row flex-wrap" role="radiogroup" aria-label="Rest type">
|
||
{(['SHORT', 'LONG'] as const).map((option) => (
|
||
<button
|
||
key={option}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={type === option}
|
||
className={`btn ${type === option ? 'btn-primary' : ''}`}
|
||
onClick={() => setType(option)}
|
||
>
|
||
{option === 'SHORT' ? '☕ Short rest (1 hour)' : '🛏 Long rest (8 hours)'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="subtitle">
|
||
{type === 'SHORT'
|
||
? 'Each character can spend Hit Dice to heal: they roll each die and add their Constitution modifier. Type how many dice they spend and what the dice added up to, or leave the total empty and the site rolls them. A warlock gets every spell slot back.'
|
||
: 'Everyone resting goes to full HP, gets every spell slot back, and regains spent Hit Dice up to half their level (at least one). Temp HP is gone, except what the Hardy Boon gives.'}
|
||
</p>
|
||
|
||
{error && <ErrorBanner message={error} />}
|
||
|
||
<div className="flex-between">
|
||
<h3 className="section-label" style={{ margin: 0 }}>
|
||
Who rests ({party.filter((c) => picked.has(c.id)).length} of {party.length})
|
||
</h3>
|
||
<span className="flex-row gap-sm">
|
||
<button className="btn btn-ghost" onClick={() => setPicked(new Set(party.map((c) => c.id)))}>
|
||
All
|
||
</button>
|
||
<button className="btn btn-ghost" onClick={() => setPicked(new Set())}>
|
||
None
|
||
</button>
|
||
</span>
|
||
</div>
|
||
{party.length === 0 && <p className="combatant-sub">No finished characters in this campaign yet.</p>}
|
||
{party.map((c) => {
|
||
const isPicked = picked.has(c.id);
|
||
const hitDice = c.hitDice;
|
||
const spent = Math.min(Number(dice[c.id]) || 0, hitDice?.remaining ?? 0);
|
||
const slots = slotsSummary(c.spellSlots);
|
||
return (
|
||
<div key={c.id} className={`reward-row${isPicked ? '' : ' is-awarded'}`}>
|
||
<label className="checkbox-row" style={{ margin: 0 }}>
|
||
<input type="checkbox" checked={isPicked} onChange={() => toggle(c.id)} />
|
||
<span style={{ flex: 1, minWidth: 0 }}>
|
||
<strong>{c.name}</strong>{' '}
|
||
<span className="combatant-sub">
|
||
{c.characterClass && CLASS_LABELS[c.characterClass]} · Level {c.level}
|
||
</span>{' '}
|
||
<LifeBadge deathSaves={c.deathSaves} />
|
||
<span className="combatant-sub" style={{ display: 'block' }}>
|
||
HP {c.currentHp}/{c.maxHp}
|
||
{c.tempHp > 0 && ` +${c.tempHp}`}
|
||
{hitDice && ` · Hit Dice ${hitDice.remaining} of ${hitDice.count} (d${hitDice.size}) left`}
|
||
{slots && ` · Slots ${slots}`}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
{type === 'SHORT' && isPicked && hitDice && (
|
||
<div className="flex-row flex-wrap gap-sm mt-sm rest-dice">
|
||
{hitDice.remaining === 0 ? (
|
||
<span className="combatant-sub">No Hit Dice left to spend until a long rest.</span>
|
||
) : (
|
||
<>
|
||
<label className="flex-row gap-sm">
|
||
Spend
|
||
<NumberInput
|
||
min={0}
|
||
max={hitDice.remaining}
|
||
placeholder="0"
|
||
aria-label={`Hit Dice ${c.name} spends`}
|
||
value={dice[c.id] ?? ''}
|
||
onChange={(e) => setDice((d) => ({ ...d, [c.id]: e.target.value }))}
|
||
style={{ width: 64 }}
|
||
/>
|
||
of {hitDice.remaining} d{hitDice.size}
|
||
</label>
|
||
{spent > 0 && (
|
||
<label className="flex-row gap-sm">
|
||
Roll {spent}d{hitDice.size}:
|
||
<NumberInput
|
||
min={spent}
|
||
max={spent * hitDice.size}
|
||
placeholder="site rolls"
|
||
aria-label={`What ${c.name}'s Hit Dice added up to`}
|
||
title={`What the ${spent}d${hitDice.size} added up to (${spent} to ${spent * hitDice.size}). Leave empty and the site rolls.`}
|
||
value={rolled[c.id] ?? ''}
|
||
onChange={(e) => setRolled((r) => ({ ...r, [c.id]: e.target.value }))}
|
||
style={{ width: 96 }}
|
||
/>
|
||
<span className="combatant-sub">
|
||
{fmtMod(spent * hitDice.conModifier)} Constitution ({fmtMod(hitDice.conModifier)} a die)
|
||
</span>
|
||
</label>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<div className="modal-foot">
|
||
<button className="btn btn-primary" onClick={handleRest} disabled={picked.size === 0 || rest.isPending}>
|
||
{rest.isPending ? 'Resting…' : `Take the ${REST_LABELS[type].toLowerCase()}`}
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|