V1.3.0: Hollowcrown seed, player visibility rules, initiative rolls, number limits, temp HP

- Hollowcrown: The Rift Between Worlds as its own seed (npm run
  seed:hollowcrown), built from the campaign book: pools, 58 monsters,
  57 NPCs, 28 locations, 72 encounters with rewards, and DM notes. Shared
  seeding logic moved to prisma/seedCampaign.js.
- What players can see: only encounters their own character is in; only
  the pool entries their character holds (plus starting options while
  building); only locations their character has been to; no invite code;
  no gold, inventory or money log of other players' characters. Socket
  events carry ids only and campaign rooms are members-only.
- Encounters have a location; while one is in progress its characters are
  there, and visits are remembered.
- Campaign setting to show players a health bar only for monsters and NPCs.
- Initiative is entered as the d20 roll (1-20) and the bonus is added;
  players can enter their own character's roll.
- Number fields enforce their limits when typing, and the API enforces
  the same limits.
- Temp HP shows on HP bars and absorbs damage before real HP.
- All packages at version 1.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:53:48 +02:00
co-authored by Claude Opus 5.5
parent 2637a510df
commit 932e755895
38 changed files with 5841 additions and 409 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.0.0",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "vite",
+13 -7
View File
@@ -373,10 +373,11 @@ export function useRemoveCharacterSpell(characterId: string) {
// Money tracker
// --------------------------------------------------------------------------
export function useGoldLedger(characterId: string | undefined) {
// Only the character's own player and the DM may read it.
export function useGoldLedger(characterId: string | undefined, enabled = true) {
return useQuery({
queryKey: ['character', characterId, 'gold'],
enabled: Boolean(characterId),
enabled: Boolean(characterId) && enabled,
queryFn: async () =>
(await apiClient.get<{ gold: number; transactions: GoldTransaction[] }>(`/characters/${characterId}/gold`)).data
.transactions,
@@ -514,10 +515,10 @@ export function useDeleteMonster(campaignId: string) {
// Default content library — search/browse, "add to campaign"
// --------------------------------------------------------------------------
export function useCampaignLibrary(campaignId: string | undefined) {
export function useCampaignLibrary(campaignId: string | undefined, enabled = true) {
return useQuery({
queryKey: ['campaign', campaignId, 'library'],
enabled: Boolean(campaignId),
enabled: Boolean(campaignId) && enabled, // DM only
staleTime: Infinity, // static reference data — never changes under a campaign
queryFn: async () => (await apiClient.get<CampaignLibrary>(`/campaigns/${campaignId}/library`)).data,
});
@@ -660,7 +661,7 @@ export function useEncounter(encounterId: string | undefined) {
export function useAddParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiative?: number }) =>
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiativeRoll?: number }) =>
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
@@ -701,11 +702,16 @@ export function usePatchEncounter(encounterId: string) {
name?: string;
description?: string | null;
dmNotes?: string | null;
locationId?: string | null;
}) => (await apiClient.patch(`/encounters/${encounterId}`, data)).data,
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
// The campaign page lists encounters by name, mode and status.
if (variables.status || variables.mode || variables.name) qc.invalidateQueries({ queryKey: ['campaign'] });
// The campaign page lists encounters by name, mode and status, and an
// encounter in progress carries its characters to its location.
if (variables.status || variables.mode || variables.name || variables.locationId !== undefined) {
qc.invalidateQueries({ queryKey: ['campaign'] });
qc.invalidateQueries({ queryKey: ['character'] });
}
},
});
}
+20 -6
View File
@@ -87,11 +87,12 @@ export interface Campaign {
id: string;
name: string;
description: string | null;
inviteCode: string;
inviteCode?: string; // DM only
startingGold: number;
startingItemCount: number;
startingSpellCount: number;
skipMotivationRoll: boolean;
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
ownerId: string;
archivedAt: string | null; // set while the campaign is archived
role: 'DM' | 'PLAYER';
@@ -117,7 +118,7 @@ export interface CharacterSummary {
buildStage: BuildStage;
currentHp: number;
tempHp: number;
gold: number;
gold: number | null; // null for another player's character
maxHp: number | null;
armorClass: number | null;
currentLocationId: string | null;
@@ -276,7 +277,10 @@ export interface CharacterDetail {
diceMode: DiceMode;
currentLocationId: string | null;
currentLocation: Location | null;
gold: number;
gold: number | null;
// True when this is another player's character: no gold, inventory or
// location is sent, only what the party can see across the table.
restricted: boolean;
buildStage: BuildStage;
buildState: Record<string, unknown> | null;
skillProficiencies: SkillProficiency[];
@@ -390,14 +394,21 @@ export interface EncounterParticipant {
characterId: string | null;
monsterId: string | null;
displayName: string;
// The d20 rolled for initiative (1–20), null until rolled. `initiative` is
// that roll plus the combatant's bonus, and is what the order sorts on.
initiativeRoll: number | null;
initiative: number;
currentHp: number;
maxHp: number;
initiativeBonus?: number; // a monster's or NPC's is sent to the DM only
// Null for a player when the campaign hides monster hit points; hpPercent
// (0–100) is sent in their place to draw the health bar.
currentHp: number | null;
maxHp: number | null;
hpPercent?: number | null;
conditions: string[];
isMonster: boolean;
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
npcId: string | null;
character?: { id: string; name: string } | null;
character?: { id: string; name: string; userId: string | null } | null;
// The DM gets the whole stat block; players only the id and name.
monster?: ({ id: string; name: string } & Partial<Monster>) | null;
npc?: (NpcPublic & Partial<Npc>) | null;
@@ -414,6 +425,9 @@ export interface Encounter {
activeParticipantId: string | null;
description: string | null;
dmNotes?: string | null; // DM only
// Where the scene is. A player is only told once their character has been there.
locationId: string | null;
location?: Pick<Location, 'id' | 'name' | 'description'> | null;
archivedAt: string | null; // archived encounters only reach the DM's list
sortOrder: number; // place in the campaign's own order, lowest first
createdAt: string;
+24
View File
@@ -0,0 +1,24 @@
import { hpBarClass } from '../lib/format';
// A health bar with temporary hit points on it. Temp HP sits on top of real
// HP, so it is drawn as its own blue segment after the HP fill, and the bar
// stretches to fit when the two together come to more than the maximum (full
// health plus a buffer). The fill's colour still reflects real HP only: temp
// HP doesn't make a bloodied character look healthy.
export function HpBar({ current, max, temp = 0, height, className = 'mt-sm' }: {
current: number;
max: number;
temp?: number;
height?: number;
className?: string;
}) {
const hp = Math.max(0, Math.min(current, max));
const buffer = Math.max(0, temp);
const scale = Math.max(max, hp + buffer, 1);
return (
<div className={`hp-bar-track ${className}`} style={height ? { height } : undefined}>
<div className={hpBarClass(hp, max)} style={{ width: `${(hp / scale) * 100}%` }} />
{buffer > 0 && <div className="hp-bar-fill hp-temp" style={{ width: `${(buffer / scale) * 100}%` }} title="Temporary HP" />}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { ChangeEvent, FocusEvent, InputHTMLAttributes } from 'react';
type NumberInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'min' | 'max' | 'step'> & {
min?: number;
max?: number;
// For a field that always holds a number (a level, a bonus): if it is left
// empty, it goes back to its starting value instead of being saved blank.
keepValue?: boolean;
};
// A whole-number field that cannot hold a value outside its limits. A plain
// <input type="number" max={20}> only limits the arrow buttons; typing 500
// into it works. This one corrects the text as it is typed: anything above
// `max` becomes `max`, anything below `min` becomes `min`, decimals are
// dropped. Works the same whether the field is controlled (`value`) or not
// (`defaultValue`): the handlers passed in see the corrected value.
export function NumberInput({ min, max, keepValue, onChange, onBlur, ...rest }: NumberInputProps) {
function limit(input: HTMLInputElement, leaving: boolean) {
const raw = input.value;
if (raw === '' || raw === '-') {
// Nothing typed yet, or the start of a negative number.
if (leaving) input.value = keepValue ? String(rest.defaultValue ?? rest.value ?? min ?? 0) : '';
return;
}
let value = Math.trunc(Number(raw));
if (Number.isNaN(value)) {
input.value = '';
return;
}
if (max !== undefined && value > max) value = max;
// While typing, a value below a minimum above 1 may just be unfinished
// ("1" on the way to "15"), so that case waits until the field is left.
if (min !== undefined && value < min && (leaving || min <= 1)) value = min;
if (String(value) !== raw) input.value = String(value);
}
function handleChange(e: ChangeEvent<HTMLInputElement>) {
limit(e.target, false);
onChange?.(e);
}
function handleBlur(e: FocusEvent<HTMLInputElement>) {
limit(e.target, true);
onBlur?.(e);
}
return <input {...rest} type="number" inputMode="numeric" step={1} min={min} max={max} onChange={handleChange} onBlur={handleBlur} />;
}
+3 -4
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useQuickStats } from '../api/queries';
import { Spinner, ErrorBanner } from './Spinner';
import { fmtMod, hpBarClass } from '../lib/format';
import { fmtMod } from '../lib/format';
import { HpBar } from './HpBar';
export function QuickStatsFab({ characterId }: { characterId: string }) {
const [open, setOpen] = useState(false);
@@ -50,9 +51,7 @@ export function QuickStatsDrawer({ characterId, onClose }: { characterId: string
<span className="stat-value">{fmtMod(data.initiative)}</span>
</div>
</div>
<div className="hp-bar-track mt-sm">
<div className={hpBarClass(data.currentHp, data.maxHp)} style={{ width: `${Math.max(0, Math.min(100, (data.currentHp / data.maxHp) * 100))}%` }} />
</div>
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
{data.spellcasting && (
<div className="card mt-md">
+4
View File
@@ -29,12 +29,16 @@ export function useCampaignSocket(campaignId: string | undefined) {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet HP
// A character arriving somewhere new adds it to the places their player can see.
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'locations'] });
};
const onCharacterCreated = () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
};
const onParticipantUpdated = () => {
qc.invalidateQueries({ queryKey: ['encounter'] });
// A player's list only holds the encounters their characters are in.
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
};
const onRoundAdvanced = () => {
qc.invalidateQueries({ queryKey: ['encounter'] });
+5
View File
@@ -450,6 +450,7 @@ input[type='radio'] {
}
.hp-bar-track {
display: flex;
width: 100%;
height: 10px;
background: var(--bg-inset);
@@ -470,6 +471,10 @@ input[type='radio'] {
.hp-bar-fill.hp-critical {
background: var(--danger-strong);
}
/* Temporary HP: a separate segment after the real HP. */
.hp-bar-fill.hp-temp {
background: var(--arcane-strong);
}
.divider {
border: none;
+32 -11
View File
@@ -40,6 +40,7 @@ import {
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { usePagination, Pagination } from '../components/Paginated';
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, spellLevelLabel, spellRequirementLabel } from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
@@ -293,7 +294,7 @@ export function CampaignHomePage() {
const { data: npcs } = useCampaignNpcs(campaignId, data?.campaign.role === 'DM');
const { data: notes } = useCampaignNotes(campaignId);
const { data: locations } = useCampaignLocations(campaignId);
const { data: library } = useCampaignLibrary(campaignId);
const { data: library } = useCampaignLibrary(campaignId, data?.campaign.role === 'DM');
const createCharacter = useCreateCharacter(campaignId);
const deleteCharacter = useDeleteCharacter(campaignId);
@@ -486,27 +487,32 @@ export function CampaignHomePage() {
startingItemCount: Number(fd.get('startingItemCount')),
startingSpellCount: Number(fd.get('startingSpellCount')),
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
showMonsterHp: fd.get('showMonsterHp') === 'on',
});
}}
>
<div className="grid grid-cols-3">
<div className="field">
<label>Starting gold</label>
<input name="startingGold" type="number" min={0} defaultValue={campaign.startingGold} />
<NumberInput name="startingGold" min={0} max={1000000} keepValue defaultValue={campaign.startingGold} />
</div>
<div className="field">
<label>Starting items (weapons + items)</label>
<input name="startingItemCount" type="number" min={0} defaultValue={campaign.startingItemCount} />
<NumberInput name="startingItemCount" min={0} max={50} keepValue defaultValue={campaign.startingItemCount} />
</div>
<div className="field">
<label>Starting spells</label>
<input name="startingSpellCount" type="number" min={0} defaultValue={campaign.startingSpellCount} />
<NumberInput name="startingSpellCount" min={0} max={50} keepValue defaultValue={campaign.startingSpellCount} />
</div>
</div>
<label className="checkbox-row">
<input type="checkbox" name="skipMotivationRoll" defaultChecked={campaign.skipMotivationRoll} />
Skip the motivation step when building a character
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
</label>
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
Save Settings
</button>
@@ -750,6 +756,18 @@ export function CampaignHomePage() {
)}
</div>
{!isDM && locations && locations.length > 0 && (
<div className="mt-lg card">
<div className="card-title">Places You've Been</div>
{locations.map((loc) => (
<div key={loc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<strong>📍 {loc.name}</strong>
{loc.description && <p className="subtitle" style={{ margin: '0.2rem 0 0', whiteSpace: 'pre-wrap' }}>{loc.description}</p>}
</div>
))}
</div>
)}
{isDM && (
<div className="mt-lg card">
<div className="card-title">
@@ -800,9 +818,9 @@ export function CampaignHomePage() {
<textarea name="description" rows={2} className="textarea-block mt-sm" placeholder="What the party sees and knows (shown to players)" />
<textarea name="dmNotes" rows={2} className="textarea-block mt-sm" placeholder="Secrets and motives (only you see this)" />
<div className="grid grid-cols-3 mt-sm">
<input name="ac" type="number" min={0} placeholder="AC (optional)" />
<input name="maxHp" type="number" min={1} placeholder="HP (optional)" />
<input name="attackBonus" type="number" placeholder="Attack bonus (optional)" />
<NumberInput name="ac" min={0} max={50} placeholder="AC (optional)" />
<NumberInput name="maxHp" min={1} max={100000} placeholder="HP (optional)" />
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus (optional)" />
<input name="damageDice" placeholder="Damage (optional, e.g. 1d6)" maxLength={20} />
<button type="submit" className="btn btn-primary" disabled={createNpc.isPending}>Add NPC</button>
</div>
@@ -838,7 +856,10 @@ export function CampaignHomePage() {
<div className="card-title">
<span>Locations</span>
</div>
<p className="subtitle" style={{ marginTop: 0 }}>Track where each character currently is.</p>
<p className="subtitle" style={{ marginTop: 0 }}>
Track where each character currently is. Give an encounter a location and its characters move there when it
begins. Players only see the places their own characters have been.
</p>
<form onSubmit={handleCreateLocation} className="flex-row flex-wrap">
<input
placeholder="Location name (e.g. The Rusty Tankard)"
@@ -1200,9 +1221,9 @@ export function CampaignHomePage() {
className="grid grid-cols-3"
>
<input name="name" placeholder="Name" required />
<input name="ac" type="number" placeholder="AC" required />
<input name="maxHp" type="number" placeholder="Max HP" required />
<input name="attackBonus" type="number" placeholder="Attack bonus" />
<NumberInput name="ac" min={0} max={50} placeholder="AC" required />
<NumberInput name="maxHp" min={1} max={100000} placeholder="Max HP" required />
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus" />
<input name="damageDice" placeholder="Damage dice" />
<button type="submit" className="btn btn-primary">Add</button>
</form>
+3 -4
View File
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useBuildBack, useBuildChoose, useBuildFinalize, useBuildOptions, useBuildRoll, useCampaign, useCampaignItems, useCampaignSpells, useCampaignWeapons, useCharacter, usePatchDiceMode } from '../api/queries';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { InfoTip } from '../components/InfoTip';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
import { SPECIES_ABILITY_BONUSES, abilityModifier, speciesBonusFor } from '../lib/speciesData';
@@ -898,8 +899,7 @@ function RollControl({
<>
<p>{label} Roll it at the table, then type the result in below.</p>
<div className="flex-row" style={{ justifyContent: 'center' }}>
<input
type="number"
<NumberInput
min={1}
max={sides}
placeholder={`1–${sides}`}
@@ -1091,9 +1091,8 @@ function PhysicalAbilityRollForm({ onRoll, rolling }: { onRoll: (body?: { abilit
<div key={i} className="flex-row flex-wrap mt-sm">
<span className="badge">Set {i + 1}</span>
{set.map((v, j) => (
<input
<NumberInput
key={j}
type="number"
min={1}
max={6}
value={v}
+38 -20
View File
@@ -30,10 +30,12 @@ import {
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
import { QuickStatsFab } from '../components/QuickStatsDrawer';
import { InfoTip } from '../components/InfoTip';
import { usePagination, Pagination } from '../components/Paginated';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, fmtMod, hpBarClass, extractErrorMessage, spellLevelLabel } from '../lib/format';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, fmtMod, extractErrorMessage, spellLevelLabel } from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import { CLASS_HIT_DIE } from '../lib/classData';
@@ -91,10 +93,11 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
const [kind, id] = what.split(':');
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
const heldItem = character.inventoryItems.find((i) => i.itemId === id);
const max = kind === 'gold' ? character.gold : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
const gold = character.gold ?? 0;
const max = kind === 'gold' ? gold : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
const count = Math.floor(Number(amount));
const countOk = kind === 'spell' || (count >= 1 && count <= max);
const nothingToGive = character.gold === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
const nothingToGive = gold === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
function handleGive() {
const gift: Gift | null =
@@ -142,7 +145,7 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
style={{ flex: 2, minWidth: 160 }}
>
<option value="">What to give…</option>
{character.gold > 0 && <option value="gold:">Gold ({character.gold} gp)</option>}
{gold > 0 && <option value="gold:">Gold ({gold} gp)</option>}
{character.weapons.length > 0 && (
<optgroup label="Weapons">
{character.weapons.map((w) => (
@@ -172,8 +175,7 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
)}
</select>
{what && kind !== 'spell' && (
<input
type="number"
<NumberInput
min={1}
max={max}
aria-label={kind === 'gold' ? 'How much gold' : 'How many'}
@@ -212,7 +214,7 @@ export function CharacterSheetPage() {
const { data: campaignWeapons } = useCampaignWeapons(character?.campaignId);
const { data: campaignSpells } = useCampaignSpells(character?.campaignId);
const { data: campaignItems } = useCampaignItems(character?.campaignId);
const { data: goldLedger } = useGoldLedger(characterId);
const { data: goldLedger } = useGoldLedger(characterId, Boolean(character) && !character?.restricted);
const { data: locations } = useCampaignLocations(character?.campaignId);
const { data: notes } = useCharacterNotes(characterId);
@@ -412,15 +414,15 @@ export function CharacterSheetPage() {
</div>
</div>
<div className="hp-bar-track mt-sm">
<div className={hpBarClass(character.currentHp, sheet.hp.max)} style={{ width: `${Math.max(0, Math.min(100, (character.currentHp / sheet.hp.max) * 100))}%` }} />
</div>
<HpBar current={character.currentHp} max={sheet.hp.max} temp={character.tempHp} />
{canEdit && (
<div className="flex-row mt-sm flex-wrap">
<input
type="number"
<NumberInput
min={1}
max={9999}
placeholder="Amount"
aria-label="HP change"
value={hpDelta}
onChange={(e) => setHpDelta(e.target.value)}
style={{ width: 100 }}
@@ -433,9 +435,11 @@ export function CharacterSheetPage() {
</button>
<label className="flex-row gap-sm" style={{ marginLeft: '1rem' }}>
Temp HP <InfoTip term="TEMP_HP" />
<input
type="number"
<NumberInput
key={`temp:${character.tempHp}`}
min={0}
max={9999}
keepValue
defaultValue={character.tempHp}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ tempHp: Number(e.target.value) })}
@@ -443,9 +447,11 @@ export function CharacterSheetPage() {
</label>
<label className="flex-row gap-sm">
Armor Bonus <InfoTip term="ARMOR_BONUS" />
<input
type="number"
<NumberInput
key={`armor:${character.armorBonus}`}
min={0}
max={30}
keepValue
defaultValue={character.armorBonus}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ armorBonus: Number(e.target.value) })}
@@ -458,10 +464,11 @@ export function CharacterSheetPage() {
<div className="flex-row mt-sm flex-wrap">
<label className="flex-row gap-sm">
Level
<input
type="number"
<NumberInput
key={`level:${character.level}`}
min={1}
max={20}
keepValue
defaultValue={character.level}
style={{ width: 60 }}
onBlur={(e) => patchCharacter.mutate({ level: Number(e.target.value) })}
@@ -671,6 +678,15 @@ export function CharacterSheetPage() {
</div>
)}
{character.restricted && (
<p className="subtitle mt-md">
This is another player's character. You can see what the party sees across the table; what they carry and how
much gold they have is theirs to share.
</p>
)}
{!character.restricted && (
<>
<div className="card mt-md">
<div className="card-title">Inventory</div>
{itemsPage.pageItems.map((held) => (
@@ -758,9 +774,9 @@ export function CharacterSheetPage() {
</div>
{canEdit && (
<div className="flex-row flex-wrap">
<input
type="number"
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="Gold amount"
value={goldAmount}
@@ -801,6 +817,8 @@ export function CharacterSheetPage() {
</div>
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} />
</div>
</>
)}
{/* Composed by the character builder from the player's picks. Players
read them; only the DM can rewrite them (new abilities on level-up,
+68 -30
View File
@@ -8,6 +8,7 @@ import {
useCampaignNpcs,
useCampaignWeapons,
useCampaignItems,
useCampaignLocations,
useAddParticipant,
usePatchParticipant,
useRemoveParticipant,
@@ -22,8 +23,11 @@ import {
type NewReward,
} from '../api/queries';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { useAuth } from '../auth/AuthContext';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { ABILITY_LABELS, CLASS_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, hpBarClass, spellLevelLabel } from '../lib/format';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
import { ABILITY_LABELS, CLASS_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type {
Ability,
@@ -93,6 +97,7 @@ function CombatantCard({
mode,
isActive,
onPatch,
canRollInitiative,
onRemove,
}: {
participant: EncounterParticipant;
@@ -100,6 +105,8 @@ function CombatantCard({
mode: EncounterMode;
isActive: boolean;
onPatch: (data: Partial<EncounterParticipant>) => void;
// The DM for anyone; a player for their own character.
canRollInitiative: boolean;
onRemove: () => void;
}) {
const [hpDelta, setHpDelta] = useState('');
@@ -112,9 +119,13 @@ function CombatantCard({
const info = p.characterCombatInfo;
const monster = p.monster;
const npc = p.npc;
const tracksHp = p.maxHp > 0;
const isDown = tracksHp && p.currentHp <= 0;
const pct = tracksHp ? Math.max(0, Math.min(100, (p.currentHp / p.maxHp) * 100)) : 0;
// A player may get a percentage in place of a monster's or NPC's numbers.
const currentHp = p.currentHp ?? 0;
const maxHp = p.maxHp ?? 0;
const barOnly = p.maxHp == null && p.hpPercent != null;
const tracksHp = barOnly || maxHp > 0;
const pct = barOnly ? (p.hpPercent ?? 0) : tracksHp ? Math.max(0, Math.min(100, (currentHp / maxHp) * 100)) : 0;
const isDown = tracksHp && pct <= 0;
// Players get a monster's name and health bar only; the stat block is the DM's.
const monsterStats = monster && monster.ac !== undefined ? monster : null;
const hasDetails = Boolean(info || monsterStats || npc?.dmNotes);
@@ -122,7 +133,7 @@ function CombatantCard({
function applyDelta(sign: 1 | -1) {
const amount = Number(hpDelta);
if (!amount) return;
onPatch({ currentHp: p.currentHp + sign * amount });
onPatch({ currentHp: currentHp + sign * amount });
setHpDelta('');
}
@@ -152,26 +163,36 @@ function CombatantCard({
<div className="combatant-sub">{subline(p)}</div>
</div>
{mode === 'BATTLE' &&
(isDM ? (
<label className="flex-row gap-sm">
Initiative
<input
key={`${p.id}:${p.initiative}`}
type="number"
defaultValue={p.initiative}
(canRollInitiative ? (
<label className="flex-row gap-sm" title="Type what the d20 showed (1 to 20). The initiative bonus is added for you.">
Initiative roll
<NumberInput
key={`${p.id}:${p.initiativeRoll}`}
min={1}
max={20}
placeholder="d20"
aria-label={`Initiative roll for ${p.displayName}`}
defaultValue={p.initiativeRoll ?? ''}
style={{ width: 64 }}
onBlur={(e) => Number(e.target.value) !== p.initiative && onPatch({ initiative: Number(e.target.value) })}
onBlur={(e) => {
const roll = e.target.value === '' ? null : Number(e.target.value);
if (roll !== p.initiativeRoll) onPatch({ initiativeRoll: roll });
}}
/>
<span className="mono">
{p.initiativeBonus !== undefined && fmtMod(p.initiativeBonus)}
{p.initiativeRoll != null && ` = ${p.initiative}`}
</span>
</label>
) : (
<StatChip label="Initiative">{p.initiative}</StatChip>
<StatChip label="Initiative">{p.initiativeRoll == null ? '—' : p.initiative}</StatChip>
))}
</div>
<div className="stat-chips mt-sm">
{tracksHp && (
{tracksHp && !barOnly && (
<StatChip label="HP">
{p.currentHp}/{p.maxHp}
{currentHp}/{maxHp}
{info && info.tempHp > 0 && ` +${info.tempHp}`}
</StatChip>
)}
@@ -203,14 +224,12 @@ function CombatantCard({
{tracksHp && (
<>
<div className="hp-bar-track mt-sm">
<div className={hpBarClass(p.currentHp, p.maxHp)} style={{ width: `${pct}%` }} />
</div>
<HpBar current={pct} max={100} temp={info && maxHp > 0 ? (info.tempHp / maxHp) * 100 : 0} />
{isDM && (
<div className="flex-row gap-sm mt-sm">
<input
type="number"
<NumberInput
min={1}
max={99999}
placeholder="Amount"
aria-label={`HP change for ${p.displayName}`}
value={hpDelta}
@@ -505,9 +524,9 @@ function RewardsPanel({
<option value="ITEM">Item</option>
</select>
{type === 'GOLD' ? (
<input
type="number"
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="Gold amount"
value={gold}
@@ -542,9 +561,9 @@ function RewardsPanel({
</option>
))}
</select>
<input
type="number"
<NumberInput
min={1}
max={999}
aria-label="Quantity"
title="Quantity"
value={quantity}
@@ -577,6 +596,7 @@ function RewardsPanel({
export function EncounterPage() {
const { encounterId = '' } = useParams();
const { user } = useAuth();
const { data: encounter, isLoading, error } = useEncounter(encounterId);
useCampaignSocket(encounter?.campaignId);
@@ -587,6 +607,7 @@ export function EncounterPage() {
const { data: npcs } = useCampaignNpcs(encounter?.campaignId, isDM);
const { data: weapons } = useCampaignWeapons(encounter?.campaignId);
const { data: items } = useCampaignItems(encounter?.campaignId);
const { data: locations } = useCampaignLocations(encounter?.campaignId);
const addParticipant = useAddParticipant(encounterId);
const patchParticipant = usePatchParticipant(encounterId);
@@ -624,7 +645,7 @@ export function EncounterPage() {
if (!pickId) return;
setFormError(null);
addParticipant.mutate(
{ [`${pickType}Id`]: pickId, initiative: Number(initiative) || 0 },
{ [`${pickType}Id`]: pickId, initiativeRoll: initiative ? Number(initiative) : undefined },
{ onError, onSuccess: () => setPickId('') }
);
}
@@ -637,6 +658,7 @@ export function EncounterPage() {
mode={encounter.mode}
isActive={p.id === active?.id}
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
canRollInitiative={isDM || (p.character != null && p.character.userId === user?.id)}
onRemove={() => removeParticipant.mutate(p.id, fail)}
/>
);
@@ -661,6 +683,7 @@ export function EncounterPage() {
<span className="badge badge-dm">{MODE_LABELS[encounter.mode]}</span>
<span className={`badge ${encounter.status === 'ACTIVE' ? 'badge-live' : ''}`}>{STATUS_LABELS[encounter.status]}</span>
{isBattle && <span className="badge">Round {encounter.currentRound}</span>}
{encounter.location && <span className="badge">📍 {encounter.location.name}</span>}
{encounter.archivedAt && <span className="badge">Archived</span>}
{active && <span className="turn-marker">▶ {active.displayName}'s turn</span>}
</div>
@@ -679,6 +702,19 @@ export function EncounterPage() {
<option value="BATTLE">Battle</option>
<option value="QUEST">NPC quest</option>
</select>
<select
aria-label="Location"
title="Where this scene takes place. Its characters move here when it begins."
value={encounter.locationId ?? ''}
onChange={(e) => patchEncounter.mutate({ locationId: e.target.value || null }, fail)}
>
<option value="">No location</option>
{locations?.map((loc) => (
<option key={loc.id} value={loc.id}>
📍 {loc.name}
</option>
))}
</select>
{encounter.status === 'PLANNING' && (
<button className="btn btn-primary" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' }, fail)}>
{isBattle ? 'Begin Battle' : 'Begin Quest'}
@@ -804,10 +840,12 @@ export function EncounterPage() {
</div>
<div className="flex-row gap-sm mt-sm">
{isBattle && (
<input
type="number"
placeholder="Initiative"
aria-label="Initiative"
<NumberInput
min={1}
max={20}
placeholder="Initiative roll (d20)"
title="What the d20 showed, 1 to 20. Leave empty to roll later; the bonus is added for you."
aria-label="Initiative roll"
value={initiative}
onChange={(e) => setInitiative(e.target.value)}
style={{ flex: 1, minWidth: 0 }}
+8 -9
View File
@@ -5,7 +5,8 @@ import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { usePagination, Pagination } from '../components/Paginated';
import { InfoTip } from '../components/InfoTip';
import { CLASS_LABELS, SPECIES_LABELS, hpBarClass } from '../lib/format';
import { CLASS_LABELS, SPECIES_LABELS } from '../lib/format';
import { HpBar } from '../components/HpBar';
import { speciesIcon } from '../lib/speciesIcons';
// A DM's one-screen view of every character's stats at a glance; a player
@@ -77,20 +78,18 @@ export function PartyOverviewPage() {
<td>
{c.maxHp != null ? (
<div style={{ minWidth: 90 }}>
<span className="mono">{c.currentHp}/{c.maxHp}</span>
<div className="hp-bar-track mt-sm" style={{ height: 6 }}>
<div
className={hpBarClass(c.currentHp, c.maxHp)}
style={{ width: `${Math.max(0, Math.min(100, (c.currentHp / c.maxHp) * 100))}%` }}
/>
</div>
<span className="mono">
{c.currentHp}/{c.maxHp}
{c.tempHp > 0 && ` +${c.tempHp}`}
</span>
<HpBar current={c.currentHp} max={c.maxHp} temp={c.tempHp} height={6} />
</div>
) : (
'—'
)}
</td>
<td>{c.armorClass ?? '—'}</td>
<td className="mono">{c.gold}</td>
<td className="mono">{c.gold ?? '—'}</td>
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
</tr>
);