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