Fixes batch 3: autosave, campaign-agnostic PCs, live push, builder
- Autosave: while signed in, a debounced full backup pushes whenever durable data
changes (useCloudAutosave watches a fingerprint of the main tables; high-churn
tables excluded). No-op signed out.
- Campaign-agnostic characters: PCs are now global — the Characters roster shows
every player character regardless of active campaign, and any PC can be added to
a fight. NPCs stay per-campaign. (charactersRepo.listAllPcs / useAllPcs; difficulty
fallback stays campaign-scoped.)
- Live "bring your own character": a joining player can push one of their own local
characters; the GM's grant inserts it into the campaign and seats them.
- Character builder: a system picker (5e / PF2e, defaults to the campaign) so creation
no longer assumes 5e; PF2e skill wording ("Trained" vs "proficient"); spell step now
explains how many to pick + that they're known spells prepared later.
230 unit + 34 e2e + 2 realtime green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import { SessionControl } from '@/features/play/SessionControl';
|
||||
import { HandoutControl } from '@/features/play/HandoutControl';
|
||||
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
|
||||
import { usePlayerConnection } from '@/features/play/usePlayerConnection';
|
||||
import { useCloudAutosave } from '@/features/cloud/useCloudAutosave';
|
||||
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
|
||||
import { SessionSidebar } from '@/features/play/SessionSidebar';
|
||||
import { useSessionStore } from '@/stores/sessionStore';
|
||||
@@ -87,6 +88,7 @@ export function RootLayout() {
|
||||
// While the GM is hosting, mirror state to players (inert unless hosting).
|
||||
useSessionBroadcaster(activeCampaign ?? null);
|
||||
usePlayerConnection();
|
||||
useCloudAutosave();
|
||||
|
||||
// Global Ctrl/Cmd+K opens the command palette.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Campaign, Character } from '@/lib/schemas';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
|
||||
import { pickTextFile } from '@/lib/io/file';
|
||||
import { useCharacters } from './hooks';
|
||||
import { useCharacters, useAllPcs } from './hooks';
|
||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Avatar, Badge } from '@/components/ui/Codex';
|
||||
@@ -18,11 +18,11 @@ export function CharactersPage() {
|
||||
}
|
||||
|
||||
function CharactersList({ campaign }: { campaign: Campaign }) {
|
||||
const characters = useCharacters(campaign.id);
|
||||
// PCs are campaign-agnostic — show every player character; NPCs stay per-campaign.
|
||||
const pcs = useAllPcs();
|
||||
const npcs = useCharacters(campaign.id).filter((c) => c.kind === 'npc');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const pcs = characters.filter((c) => c.kind === 'pc');
|
||||
const npcs = characters.filter((c) => c.kind === 'npc');
|
||||
|
||||
const importCharacter = async () => {
|
||||
setImportError(null);
|
||||
@@ -60,7 +60,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{characters.length === 0 ? (
|
||||
{pcs.length === 0 && npcs.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No characters yet"
|
||||
hint="Add player characters and NPCs, or import a character file."
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useNavigate } from '@tanstack/react-router';
|
||||
import type { Campaign, Character, SpellEntry } from '@/lib/schemas';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import {
|
||||
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter,
|
||||
type AbilityKey, type AbilityScores, type ProficiencyRank,
|
||||
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, SYSTEM_OPTIONS,
|
||||
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
|
||||
} from '@/lib/rules';
|
||||
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
|
||||
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
|
||||
@@ -48,7 +48,10 @@ const TEMPLATES: Record<string, { label: string; hint: string; className: string
|
||||
|
||||
export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const sys = getSystem(campaign.system);
|
||||
// Characters are campaign-agnostic: the wizard asks which system to build for,
|
||||
// defaulting to the campaign's. Changing it resets all dependent selections.
|
||||
const [system, setSystem] = useState<SystemId>(campaign.system);
|
||||
const sys = getSystem(system);
|
||||
const allSkillKeys = sys.skills.map((s) => s.key);
|
||||
const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key;
|
||||
|
||||
@@ -58,10 +61,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
const [backgrounds, setBackgrounds] = useState<Origin[]>([]);
|
||||
const [allSpells, setAllSpells] = useState<SpellOpt[]>([]);
|
||||
|
||||
useEffect(() => { let on = true; void loadClasses(campaign.system).then((c) => on && setClasses([...c].sort((a, b) => a.name.localeCompare(b.name)))); return () => { on = false; }; }, [campaign.system]);
|
||||
useEffect(() => { let on = true; void loadClasses(system).then((c) => on && setClasses([...c].sort((a, b) => a.name.localeCompare(b.name)))); return () => { on = false; }; }, [system]);
|
||||
useEffect(() => {
|
||||
let on = true;
|
||||
if (campaign.system === '5e') {
|
||||
if (system === '5e') {
|
||||
void loadRaces5e().then((rs) => on && setOrigins(rs.map((r) => ({ name: r.name, desc: r.desc, meta: r.asi || r.speed }))));
|
||||
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills }))));
|
||||
} else {
|
||||
@@ -69,7 +72,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(bs.map((b) => ({ name: String(b.name), desc: String((b.description ?? b.text) ?? '') }))));
|
||||
}
|
||||
return () => { on = false; };
|
||||
}, [campaign.system]);
|
||||
}, [system]);
|
||||
|
||||
// ---- selections ----
|
||||
const [step, setStep] = useState(0);
|
||||
@@ -108,7 +111,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
const [skills, setSkills] = useState<string[]>([]);
|
||||
const intMod = abilityModifier(abilities.int);
|
||||
const skillCount = selectedClass
|
||||
? selectedClass.skillCount + (campaign.system === 'pf2e' ? Math.max(0, intMod) : 0)
|
||||
? selectedClass.skillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0)
|
||||
: 2;
|
||||
const skillOptions = useMemo(() => {
|
||||
if (!selectedClass || selectedClass.skillChoices.length === 0) return allSkillKeys;
|
||||
@@ -127,10 +130,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
useEffect(() => {
|
||||
if (!isCaster || allSpells.length) return;
|
||||
let on = true;
|
||||
if (campaign.system === '5e') void loadSpells().then((ss) => on && setAllSpells((ss as unknown as { name: string; level_int: number; school: string }[]).map((s) => ({ name: s.name, level: s.level_int ?? 0, meta: s.school ?? '' }))));
|
||||
if (system === '5e') void loadSpells().then((ss) => on && setAllSpells((ss as unknown as { name: string; level_int: number; school: string }[]).map((s) => ({ name: s.name, level: s.level_int ?? 0, meta: s.school ?? '' }))));
|
||||
else void loadPf2e('spells').then((ss) => on && setAllSpells(ss.map((s) => ({ name: String(s.name), level: Number(s.level) || 0, meta: Array.isArray(s.trait) ? (s.trait as string[]).slice(0, 2).join(', ') : '' }))));
|
||||
return () => { on = false; };
|
||||
}, [isCaster, campaign.system, allSpells.length]);
|
||||
}, [isCaster, system, allSpells.length]);
|
||||
const spellResults = useMemo(() => {
|
||||
const q = spellQuery.trim().toLowerCase();
|
||||
const maxLevel = Math.min(9, Math.ceil(level / 2));
|
||||
@@ -140,15 +143,36 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
setSpellPicks((prev) => (prev.some((x) => x.name === s.name) ? prev.filter((x) => x.name !== s.name) : [...prev, s]));
|
||||
|
||||
// ---- derived build ----
|
||||
const built = useMemo(() => buildCharacter(campaign.system, {
|
||||
const built = useMemo(() => buildCharacter(system, {
|
||||
className: selectedClass?.name ?? '', level, abilities, skillChoices: skills,
|
||||
...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}),
|
||||
...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}),
|
||||
}), [campaign.system, selectedClass, level, abilities, skills, selectedOrigin]);
|
||||
}), [system, selectedClass, level, abilities, skills, selectedOrigin]);
|
||||
|
||||
// Rough "how many should I pick" suggestion for the Spells step. Not enforced —
|
||||
// just guidance: a few cantrips plus a handful of starting leveled spells.
|
||||
const leveledSlots = useMemo(() => built.spellcasting.slots.reduce((n, s) => n + (s.level > 0 ? s.max : 0), 0), [built]);
|
||||
const suggestedSpells = Math.max(4, leveledSlots + 2);
|
||||
|
||||
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Review'], [isCaster]);
|
||||
const stepName = STEPS[Math.min(step, STEPS.length - 1)]!;
|
||||
|
||||
const changeSystem = (next: SystemId) => {
|
||||
if (next === system) return;
|
||||
setSystem(next);
|
||||
// Reset everything keyed on the system so stale picks don't leak across systems.
|
||||
// The class/origin/background/spell lists reload from the loaders above.
|
||||
setClassSlug('');
|
||||
setSubclass('');
|
||||
setSkills([]);
|
||||
setSpellPicks([]);
|
||||
setSpellQuery('');
|
||||
setAllSpells([]); // force the spell loader (guarded on length) to refetch for the new system
|
||||
setAncestry('');
|
||||
setBackground('');
|
||||
setStep(0);
|
||||
};
|
||||
|
||||
const applyTemplate = (t: { label: string; className: string; ability: AbilityScores }) => {
|
||||
const c = classes.find((x) => x.name === t.className);
|
||||
if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); }
|
||||
@@ -169,7 +193,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
const finish = async () => {
|
||||
if (!selectedClass) return;
|
||||
const created = await charactersRepo.create(campaign.id, {
|
||||
system: campaign.system, name: name.trim() || selectedClass.name, kind,
|
||||
system, name: name.trim() || selectedClass.name, kind,
|
||||
ancestry: ancestry.trim(), className: selectedClass.name, level,
|
||||
});
|
||||
// For classes outside the curated tables (esp. PF2e), fill saves from data.
|
||||
@@ -222,10 +246,22 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
<Field label="Level"><NumberField value={level} min={1} max={20} onChange={setLevel} aria-label="Level" /></Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Game system</div>
|
||||
<div className="flex gap-1" role="group" aria-label="Game system">
|
||||
{SYSTEM_OPTIONS.map((opt) => (
|
||||
<button key={opt.id} type="button" onClick={() => changeSystem(opt.id)} aria-pressed={system === opt.id}
|
||||
className={cn('rounded-md px-3 py-1.5 text-sm', system === opt.id ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-accent/30 bg-accent/5 p-2">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">New here? Start from a ready-made hero</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(TEMPLATES[campaign.system] ?? []).map((t) => (
|
||||
{(TEMPLATES[system] ?? []).map((t) => (
|
||||
<button key={t.label} onClick={() => applyTemplate(t)} title={t.hint} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent">{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -241,7 +277,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
<span className="font-display font-semibold text-ink">{c.name}</span>
|
||||
<span className="rounded-full bg-elevated px-1.5 py-0.5 text-[10px] uppercase text-muted">{playstyle(c)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted">{campaign.system === 'pf2e' ? `${c.hitDie} HP/lvl` : `d${c.hitDie}`}{c.keyAbilities.length ? ` · ${c.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join('/')}` : ''}{c.caster !== 'none' ? ' · caster' : ''}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted">{system === 'pf2e' ? `${c.hitDie} HP/lvl` : `d${c.hitDie}`}{c.keyAbilities.length ? ` · ${c.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join('/')}` : ''}{c.caster !== 'none' ? ' · caster' : ''}</div>
|
||||
{c.description && <p className="mt-1 line-clamp-2 text-xs text-muted">{c.description}</p>}
|
||||
</button>
|
||||
))}
|
||||
@@ -250,7 +286,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
</div>
|
||||
|
||||
{selectedClass && selectedClass.subclasses.length > 0 && (
|
||||
<Field label={campaign.system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
|
||||
<Field label={system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
|
||||
<Select value={subclass} onChange={(e) => setSubclass(e.target.value)}>
|
||||
<option value="">— decide later —</option>
|
||||
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
|
||||
@@ -262,7 +298,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
|
||||
{stepName === 'Origin' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<OriginPicker title={campaign.system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
|
||||
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
|
||||
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
|
||||
</div>
|
||||
)}
|
||||
@@ -302,7 +338,8 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
|
||||
{stepName === 'Skills' && (
|
||||
<div>
|
||||
<p className="mb-3 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> trained {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
|
||||
<p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
|
||||
<p className="mb-3 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{skillOptions.map((key) => {
|
||||
const checked = skills.includes(key);
|
||||
@@ -320,7 +357,11 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
|
||||
{stepName === 'Spells' && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted">Pick starting spells (search by name). You can add more on the sheet later. {spellPicks.length} selected.</p>
|
||||
<p className="mb-1 text-sm text-muted">Pick your cantrips and a few starting spells — about <span className="font-semibold text-ink">{suggestedSpells}</span> is a good start at level {level}. Search by name; you can always adjust on the sheet later.</p>
|
||||
<p className="mb-1 text-xs text-muted">These are the spells you <span className="text-ink">{system === 'pf2e' ? 'know (your repertoire)' : 'know (the spells you’ve learned)'}</span>. You prepare or cast them from slots on the character sheet.</p>
|
||||
<p className={cn('mb-2 text-xs', spellPicks.length > suggestedSpells + 4 ? 'text-warning' : 'text-muted')}>
|
||||
{spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — that’s quite a few; you can trim some later if you like.' : ''}
|
||||
</p>
|
||||
<Input value={spellQuery} onChange={(e) => setSpellQuery(e.target.value)} placeholder="Search spells…" className="mb-2" aria-label="Search spells" />
|
||||
<div className="grid max-h-64 grid-cols-1 gap-1 overflow-y-auto pr-1 sm:grid-cols-2">
|
||||
{spellResults.map((s) => {
|
||||
|
||||
@@ -6,6 +6,11 @@ export function useCharacters(campaignId: string): Character[] {
|
||||
return useLiveQuery(() => charactersRepo.listByCampaign(campaignId), [campaignId], []);
|
||||
}
|
||||
|
||||
/** All player characters across every campaign — PCs are campaign-agnostic. */
|
||||
export function useAllPcs(): Character[] {
|
||||
return useLiveQuery(() => charactersRepo.listAllPcs(), [], []);
|
||||
}
|
||||
|
||||
export function useCharacter(id: string): Character | undefined {
|
||||
return useLiveQuery(() => charactersRepo.get(id), [id], undefined);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db } from '@/lib/db/db';
|
||||
import { cloudUsername, pushBackup } from '@/lib/cloud/client';
|
||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||
|
||||
/**
|
||||
* Autosave to the cloud while signed in: whenever durable data changes, debounce
|
||||
* a full backup push. A fingerprint of (id:updatedAt) over the durable tables is
|
||||
* watched via liveQuery, so it reacts to edits, adds, and deletes alike. High-churn
|
||||
* tables (dice rolls, the session recap) are excluded so a roll doesn't trigger a
|
||||
* save. No-op when signed out. Mounted once in the app shell.
|
||||
*/
|
||||
export function useCloudAutosave(): void {
|
||||
const fingerprint = useLiveQuery(async () => {
|
||||
const stamp = async (rows: Promise<Array<{ id?: string; campaignId?: string; updatedAt?: string }>>) =>
|
||||
(await rows).map((r) => `${r.id ?? r.campaignId ?? ''}:${r.updatedAt ?? ''}`).join(',');
|
||||
const [c, ch, e, n, np, q, m, h, cal] = await Promise.all([
|
||||
stamp(db.campaigns.toArray()),
|
||||
stamp(db.characters.toArray()),
|
||||
stamp(db.encounters.toArray()),
|
||||
stamp(db.notes.toArray()),
|
||||
stamp(db.npcs.toArray()),
|
||||
stamp(db.quests.toArray()),
|
||||
stamp(db.maps.toArray()),
|
||||
stamp(db.homebrew.toArray()),
|
||||
stamp(db.calendars.toArray()),
|
||||
]);
|
||||
return [c, ch, e, n, np, q, m, h, cal].join('|');
|
||||
}, [], undefined);
|
||||
|
||||
const save = useDebouncedCallback(() => {
|
||||
if (cloudUsername()) void pushBackup().catch(() => { /* offline / transient — next change retries */ });
|
||||
}, 8000);
|
||||
|
||||
// Skip the initial load; only push once data actually changes after mount.
|
||||
const prev = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (fingerprint === undefined) return;
|
||||
if (prev.current === undefined) { prev.current = fingerprint; return; }
|
||||
if (prev.current !== fingerprint) { prev.current = fingerprint; save(); }
|
||||
}, [fingerprint, save]);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { createRng } from '@/lib/rng';
|
||||
import { rollDice } from '@/lib/dice/notation';
|
||||
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
|
||||
import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
|
||||
import { useConditionGlossary } from './useConditionGlossary';
|
||||
import {
|
||||
addCombatant,
|
||||
@@ -48,6 +48,9 @@ import { EncounterTipCard } from '@/features/assistant/EncounterTipCard';
|
||||
|
||||
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
|
||||
const characters = useCharacters(campaign.id);
|
||||
// PCs are campaign-agnostic, so any PC can join a fight; NPCs stay campaign-scoped.
|
||||
const allPcs = useAllPcs();
|
||||
const addableChars = [...allPcs, ...characters.filter((c) => c.kind === 'npc')];
|
||||
const glossary = useConditionGlossary(campaign.system);
|
||||
|
||||
const undoStack = useRef<Encounter[]>([]);
|
||||
@@ -162,7 +165,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
|
||||
<EncounterTipCard campaign={campaign} encounter={encounter} />
|
||||
|
||||
<AddCombatantBar encounter={encounter} characters={characters} onAdd={(c) => mutate((e) => addCombatant(e, c))} />
|
||||
<AddCombatantBar encounter={encounter} characters={addableChars} onAdd={(c) => mutate((e) => addCombatant(e, c))} />
|
||||
|
||||
{/* Combatant list */}
|
||||
{encounter.combatants.length === 0 ? (
|
||||
|
||||
@@ -74,16 +74,22 @@ export function SessionControl() {
|
||||
}
|
||||
|
||||
function SeatRequestRow({ req }: { req: SeatRequest }) {
|
||||
const campaign = useActiveCampaign();
|
||||
const character = useLiveQuery(() => charactersRepo.get(req.characterId), [req.characterId]);
|
||||
const removeSeatRequest = useSessionStore((s) => s.removeSeatRequest);
|
||||
const name = character?.name ?? req.offlineSnapshot?.name ?? 'Unknown character';
|
||||
|
||||
const grant = async (applyOffline: boolean) => {
|
||||
if (applyOffline && req.offlineSnapshot) {
|
||||
let current = await charactersRepo.get(req.characterId);
|
||||
if (!current && req.offlineSnapshot && campaign) {
|
||||
// A character the player made and pushed — add it to this campaign, then seat them.
|
||||
await charactersRepo.insert({ ...req.offlineSnapshot, campaignId: campaign.id });
|
||||
current = await charactersRepo.get(req.characterId);
|
||||
} else if (applyOffline && req.offlineSnapshot && current) {
|
||||
const { id: _id, campaignId: _c, createdAt: _cr, updatedAt: _u, ...rest } = req.offlineSnapshot;
|
||||
await charactersRepo.update(req.characterId, rest);
|
||||
current = await charactersRepo.get(req.characterId);
|
||||
}
|
||||
const current = await charactersRepo.get(req.characterId);
|
||||
if (current) grantSeat(req.playerId, current);
|
||||
else removeSeatRequest(req.playerId);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { charactersRepo } from '@/lib/db/repositories';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { useSessionStore } from '@/stores/sessionStore';
|
||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
|
||||
import { useEncounters } from '@/features/combat/hooks';
|
||||
import { useCalendar, useMaps } from '@/features/world/hooks';
|
||||
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
@@ -88,10 +88,12 @@ function NetworkedPlayerView() {
|
||||
const seatStatus = usePlayerSessionStore((s) => s.seatStatus);
|
||||
const [needPw, setNeedPw] = useState(false);
|
||||
|
||||
// Locally-developed copies of party characters (for offline-edit handoff).
|
||||
// Locally-developed copies of party characters (for offline-edit handoff) PLUS
|
||||
// the player's own PCs, so they can push one the GM hasn't added yet.
|
||||
const partyIdsKey = snapshot ? snapshot.party.map((p) => p.id).join(',') : '';
|
||||
const localList = useLiveQuery(() => charactersRepo.getMany(partyIdsKey ? partyIdsKey.split(',') : []), [partyIdsKey], [] as Character[]);
|
||||
const localChars = new Map((localList ?? []).map((c) => [c.id, c]));
|
||||
const myPcs = useAllPcs();
|
||||
const localChars = new Map([...(localList ?? []), ...myPcs].map((c) => [c.id, c]));
|
||||
|
||||
useEffect(() => {
|
||||
if (error?.toLowerCase().includes('password') && !needPw) {
|
||||
@@ -122,7 +124,7 @@ function NetworkedPlayerView() {
|
||||
{myCharacter && seatStatus === 'granted' ? (
|
||||
<MyCharacterPanel character={myCharacter} onPatch={handlePatch} />
|
||||
) : (
|
||||
<SeatClaimScreen snapshot={snapshot} localChars={localChars} pending={seatStatus === 'pending'} onClaim={handleClaim} />
|
||||
<SeatClaimScreen snapshot={snapshot} localChars={localChars} ownCharacters={myPcs} pending={seatStatus === 'pending'} onClaim={handleClaim} />
|
||||
)}
|
||||
<PlayerBoards snapshot={snapshot} images={images} {...(image ? { image } : {})} />
|
||||
<RollFeed />
|
||||
|
||||
@@ -10,14 +10,19 @@ import { Avatar, Badge } from '@/components/ui/Codex';
|
||||
* party. If a locally-developed copy exists, its offline edits ride along for
|
||||
* the GM to review.
|
||||
*/
|
||||
export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {
|
||||
export function SeatClaimScreen({ snapshot, localChars, ownCharacters = [], pending, onClaim }: {
|
||||
snapshot: Snapshot;
|
||||
localChars: Map<string, Character>;
|
||||
/** the player's own local characters — they can push one the GM hasn't added yet */
|
||||
ownCharacters?: Character[];
|
||||
pending: boolean;
|
||||
onClaim: (characterId: string) => void;
|
||||
}) {
|
||||
if (snapshot.party.length === 0) {
|
||||
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters to this campaign." />;
|
||||
const partyIds = new Set(snapshot.party.map((p) => p.id));
|
||||
const pushable = ownCharacters.filter((c) => c.kind === 'pc' && !partyIds.has(c.id));
|
||||
|
||||
if (snapshot.party.length === 0 && pushable.length === 0) {
|
||||
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters — make one of your own and it'll appear here to bring to the table." />;
|
||||
}
|
||||
return (
|
||||
<section className="paper-grain mb-6 rounded-xl border border-line bg-panel p-4">
|
||||
@@ -25,22 +30,45 @@ export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {
|
||||
<h2 className="font-display text-xl font-semibold text-ink">Which character is yours?</h2>
|
||||
<p className="mt-1 text-sm text-muted">Pick your character to manage its HP, spells, and rolls live. The GM approves the request.</p>
|
||||
<hr className="gilt-rule my-3" />
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{snapshot.party.map((c) => {
|
||||
const hasOffline = localChars.has(c.id);
|
||||
return (
|
||||
<div key={c.id} data-testid="seat-option" className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 p-3">
|
||||
<Avatar name={c.name} size={40} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-display text-lg font-semibold text-ink">{c.name}</div>
|
||||
<div className="font-mono text-xs text-muted">Lv {c.level} · AC {c.ac} · {c.hp.current}/{c.hp.max} HP</div>
|
||||
{hasOffline && <Badge tone="arcane" className="mt-1"><UserPlus size={11} aria-hidden /> offline edits ready</Badge>}
|
||||
|
||||
{snapshot.party.length > 0 && (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{snapshot.party.map((c) => {
|
||||
const hasOffline = localChars.has(c.id);
|
||||
return (
|
||||
<div key={c.id} data-testid="seat-option" className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 p-3">
|
||||
<Avatar name={c.name} size={40} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-display text-lg font-semibold text-ink">{c.name}</div>
|
||||
<div className="font-mono text-xs text-muted">Lv {c.level} · AC {c.ac} · {c.hp.current}/{c.hp.max} HP</div>
|
||||
{hasOffline && <Badge tone="arcane" className="mt-1"><UserPlus size={11} aria-hidden /> offline edits ready</Badge>}
|
||||
</div>
|
||||
<Button size="sm" variant="primary" disabled={pending} onClick={() => onClaim(c.id)}>This is me</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="primary" disabled={pending} onClick={() => onClaim(c.id)}>This is me</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pushable.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="smallcaps mb-1.5 text-muted">Bring your own character</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{pushable.map((c) => (
|
||||
<div key={c.id} data-testid="seat-option" className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 p-3">
|
||||
<Avatar name={c.name} size={40} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-display text-lg font-semibold text-ink">{c.name}</div>
|
||||
<div className="font-mono text-xs text-muted">Lv {c.level}{c.className ? ` · ${c.className}` : ''}</div>
|
||||
<Badge tone="gold" className="mt-1"><UserPlus size={11} aria-hidden /> send to the GM</Badge>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" disabled={pending} onClick={() => onClaim(c.id)}>Use this one</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pending && <p className="mt-3 text-sm text-accent">Waiting for the GM to approve…</p>}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -88,6 +88,11 @@ export const charactersRepo = {
|
||||
return db.characters.where('campaignId').equals(campaignId).toArray();
|
||||
},
|
||||
|
||||
/** All player characters across every campaign (PCs are campaign-agnostic). */
|
||||
listAllPcs(): Promise<Character[]> {
|
||||
return db.characters.where('kind').equals('pc').toArray();
|
||||
},
|
||||
|
||||
get(id: string): Promise<Character | undefined> {
|
||||
return db.characters.get(id);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user