darkwatch/client/src/pages/CampaignView.tsx
Aaron Wood 6114aef307 fix: broadcast character:updated on rest to clear Dying; clamp HP to 0 minimum
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:44:53 -04:00

503 lines
16 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { useParams, Link } from "react-router-dom";
import socket from "../socket";
import {
getCampaigns,
getCharacters,
createCharacter,
updateCharacter,
deleteCharacter,
updateStat,
addGear,
removeGear,
addTalent,
removeTalent,
getRolls,
getMyCampaignRole,
generateInvite,
undoRoll,
} from "../api";
import { useAuth } from "../context/AuthContext";
import type { Character, Gear, Talent, GameItem, RollResult, CombatState } from "../types.js";
import InitiativeTracker from "../components/InitiativeTracker.js";
import CombatStartModal from "../components/CombatStartModal.js";
import CharacterCard from "../components/CharacterCard";
import CharacterDetail from "../components/CharacterDetail";
import RollLog from "../components/RollLog";
import DiceTray from "../components/DiceTray";
import FogOverlay from "../components/FogOverlay";
import AtmospherePanel from "../components/AtmospherePanel";
import ParticleOverlay from "../components/ParticleOverlay";
import ThreeFireOverlay from "../components/ThreeFireOverlay";
import type { AtmosphereState } from "../lib/atmosphereTypes";
import { defaultAtmosphere } from "../lib/atmosphereTypes";
import CharacterWizard from "../components/CharacterWizard";
import type { CreateCharacterData } from "../api";
import styles from "./CampaignView.module.css";
export default function CampaignView() {
const { id } = useParams<{ id: string }>();
const campaignId = Number(id);
const { user } = useAuth();
const [role, setRole] = useState<"dm" | "player" | null>(null);
const [characters, setCharacters] = useState<Character[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [campaignName, setCampaignName] = useState("");
const [rolls, setRolls] = useState<RollResult[]>([]);
const [freshIds, setFreshIds] = useState<Set<number>>(new Set());
const [critKeys, setCritKeys] = useState<Set<string>>(new Set());
const [diceRoll, setDiceRoll] = useState<{
expression: string;
rolls: number[];
characterColor?: string;
id: number;
} | null>(null);
const pendingRollRef = useRef<RollResult | null>(null);
const [atmosphere, setAtmosphere] = useState<AtmosphereState>(defaultAtmosphere);
const [focusSpells, setFocusSpells] = useState<Map<number, string>>(new Map());
const [combats, setCombats] = useState<CombatState[]>([]);
const [showCombatStart, setShowCombatStart] = useState(false);
function handleAtmosphereChange(next: AtmosphereState) {
setAtmosphere(next);
socket.emit("atmosphere:update", { campaignId, ...next });
}
// Fetch characters and join socket room
useEffect(() => {
getCharacters(campaignId).then(setCharacters);
getRolls(campaignId).then(setRolls);
getMyCampaignRole(campaignId).then((r) => setRole(r.role)).catch(() => {});
getCampaigns().then((camps) => {
const c = camps.find((x) => x.id === campaignId);
if (c) setCampaignName(c.name);
});
socket.emit("join-campaign", String(campaignId));
socket.emit("initiative:request-state", campaignId);
return () => {
socket.emit("leave-campaign", String(campaignId));
};
}, [campaignId]);
function showRollInLog(roll: RollResult) {
setRolls((prev) => [roll, ...prev].slice(0, 50));
setFreshIds((prev) => new Set(prev).add(roll.id));
setTimeout(() => {
setFreshIds((prev) => {
const next = new Set(prev);
next.delete(roll.id);
return next;
});
}, 2000);
// Track crits by character+weapon key
if (roll.nat20 && roll.character_id && roll.label.includes("attack")) {
const weaponName = roll.label.replace(" attack", "");
const key = `${roll.character_id}:${weaponName}`;
setCritKeys((prev) => new Set(prev).add(key));
}
if (roll.character_id && roll.label.includes("damage")) {
const weaponName = roll.label
.replace(" damage", "")
.replace(" (CRIT)", "");
const key = `${roll.character_id}:${weaponName}`;
setCritKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}
}
function handleDiceComplete() {
if (pendingRollRef.current) {
showRollInLog(pendingRollRef.current);
pendingRollRef.current = null;
}
}
// Socket event listeners
useEffect(() => {
function onCharacterCreated(char: Character) {
setCharacters((prev) => {
if (prev.some((c) => c.id === char.id)) return prev;
return [...prev, char];
});
}
function onCharacterUpdated(data: Partial<Character> & { id: number }) {
setCharacters((prev) =>
prev.map((c) => (c.id === data.id ? { ...c, ...data } : c)),
);
}
function onCharacterDeleted({ id }: { id: number }) {
setCharacters((prev) => prev.filter((c) => c.id !== id));
setSelectedId((prev) => (prev === id ? null : prev));
}
function onStatUpdated({
characterId,
statName,
value,
}: {
characterId: number;
statName: string;
value: number;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? {
...c,
stats: c.stats.map((s) =>
s.stat_name === statName ? { ...s, value } : s,
),
}
: c,
),
);
}
function onGearAdded({
characterId,
gear,
}: {
characterId: number;
gear: Gear;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? { ...c, gear: [...c.gear.filter((g) => g.id !== gear.id), gear] }
: c,
),
);
}
function onGearRemoved({
characterId,
gearId,
}: {
characterId: number;
gearId: number;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? { ...c, gear: c.gear.filter((g) => g.id !== gearId) }
: c,
),
);
}
function onTalentAdded({
characterId,
talent,
}: {
characterId: number;
talent: Talent;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? {
...c,
talents: [
...c.talents.filter((t) => t.id !== talent.id),
talent,
],
}
: c,
),
);
}
function onTalentRemoved({
characterId,
talentId,
}: {
characterId: number;
talentId: number;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? { ...c, talents: c.talents.filter((t) => t.id !== talentId) }
: c,
),
);
}
function onRollResult(roll: RollResult) {
// Check if this roll can trigger 3D dice
const hasDice = /\d*d\d+/i.test(roll.dice_expression);
if (hasDice) {
// Queue roll — it will appear in log when dice animation finishes
pendingRollRef.current = roll;
setDiceRoll({
expression: roll.dice_expression,
rolls: roll.rolls,
characterColor: roll.character_color,
id: roll.id,
});
} else {
// No 3D dice — show immediately
showRollInLog(roll);
}
}
function onAtmosphereUpdate(data: AtmosphereState) {
setAtmosphere(data);
}
function onRollUndone({ rollId }: { rollId: number }) {
setRolls((prev) => prev.map((r) => r.id === rollId ? { ...r, undone: true } : r));
}
function onCharacterRested({ characterId }: { characterId: number }) {
// SpellList local state already updated via handleRest; suppress unused warning
void characterId;
}
function onInitiativeState(data: CombatState[]) {
setCombats(data);
}
function onInitiativeUpdated(data: CombatState[]) {
setCombats(data);
}
socket.on("character:created", onCharacterCreated);
socket.on("character:updated", onCharacterUpdated);
socket.on("character:deleted", onCharacterDeleted);
socket.on("stat:updated", onStatUpdated);
socket.on("gear:added", onGearAdded);
socket.on("gear:removed", onGearRemoved);
socket.on("talent:added", onTalentAdded);
socket.on("talent:removed", onTalentRemoved);
socket.on("roll:result", onRollResult);
socket.on("atmosphere:update", onAtmosphereUpdate);
socket.on("roll:undone", onRollUndone);
socket.on("character:rested", onCharacterRested);
socket.on("initiative:state", onInitiativeState);
socket.on("initiative:updated", onInitiativeUpdated);
return () => {
socket.off("character:created", onCharacterCreated);
socket.off("character:updated", onCharacterUpdated);
socket.off("character:deleted", onCharacterDeleted);
socket.off("stat:updated", onStatUpdated);
socket.off("gear:added", onGearAdded);
socket.off("gear:removed", onGearRemoved);
socket.off("talent:added", onTalentAdded);
socket.off("talent:removed", onTalentRemoved);
socket.off("roll:result", onRollResult);
socket.off("atmosphere:update", onAtmosphereUpdate);
socket.off("roll:undone", onRollUndone);
socket.off("character:rested", onCharacterRested);
socket.off("initiative:state", onInitiativeState);
socket.off("initiative:updated", onInitiativeUpdated);
};
}, []);
async function handleCreate(data: CreateCharacterData) {
try {
await createCharacter(campaignId, data);
setShowCreate(false);
} catch (err) {
console.error("Failed to create character:", err);
}
}
async function handleHpChange(characterId: number, hp: number) {
const clampedHp = Math.max(0, hp);
await updateCharacter(characterId, { hp_current: clampedHp });
}
async function handleStatChange(
characterId: number,
statName: string,
value: number,
) {
await updateStat(characterId, statName, value);
}
async function handleUpdate(characterId: number, data: Partial<Character>) {
await updateCharacter(characterId, data);
}
async function handleDelete(characterId: number) {
await deleteCharacter(characterId);
setSelectedId(null);
}
async function handleAddGearFromItem(characterId: number, item: GameItem) {
await addGear(characterId, {
name: item.name,
type: item.type,
slot_count: item.slot_count,
properties: item.properties,
effects: item.effects,
game_item_id: item.id,
});
}
async function handleAddGearCustom(
characterId: number,
data: { name: string; type: string; slot_count: number },
) {
await addGear(characterId, data);
}
async function handleRemoveGear(characterId: number, gearId: number) {
await removeGear(characterId, gearId);
}
async function handleAddTalent(
characterId: number,
data: {
name: string;
description: string;
effect?: Record<string, unknown>;
game_talent_id?: number | null;
},
) {
await addTalent(characterId, data);
}
async function handleRemoveTalent(characterId: number, talentId: number) {
await removeTalent(characterId, talentId);
}
async function handleUndoRoll(rollId: number) {
await undoRoll(campaignId, rollId);
setRolls((prev) => prev.map((r) => r.id === rollId ? { ...r, undone: true } : r));
}
async function handleInvite() {
try {
const { url } = await generateInvite(campaignId);
await navigator.clipboard.writeText(url);
alert("Invite link copied to clipboard!");
} catch {
alert("Failed to generate invite link");
}
}
const selectedCharacter = characters.find((c) => c.id === selectedId) ?? null;
return (
<div className={styles.layout}>
<div className={styles.main}>
<div className={styles.header}>
<Link to="/" className={styles.backLink}>
Campaigns
</Link>
<span className={styles.campaignName}>
{campaignName || "Campaign"}
</span>
<div className={styles.headerBtns}>
{role === "dm" && (
<AtmospherePanel
atmosphere={atmosphere}
onAtmosphereChange={handleAtmosphereChange}
/>
)}
{role === "dm" && (
<button
className={`${styles.addBtn} ${combats.length > 0 ? styles.addBtnActive : ""}`}
onClick={() => setShowCombatStart(true)}
>
Combat
</button>
)}
{role === "dm" && (
<button className={styles.addBtn} onClick={handleInvite}>
Invite Player
</button>
)}
<button
className={styles.addBtn}
onClick={() => setShowCreate(true)}
>
+ Add Character
</button>
</div>
</div>
<div className={`${styles.content} ${combats.length > 0 ? styles.withCombat : ""}`}>
{combats.length > 0 && (
<div className={styles.combatSidebar}>
<InitiativeTracker
combat={combats[0]}
characters={characters}
isDM={role === "dm"}
currentUserId={user?.userId ?? null}
campaignId={campaignId}
/>
</div>
)}
<div className={styles.grid}>
{characters.length === 0 && (
<p className={styles.empty}>
No characters yet. Add one to get started!
</p>
)}
{characters.map((char) => (
<CharacterCard
key={char.id}
character={char}
onHpChange={handleHpChange}
onUpdate={handleUpdate}
onClick={setSelectedId}
canEdit={role === "dm" || char.user_id === user?.userId}
focusSpell={focusSpells.get(char.id)}
isDM={role === "dm"}
/>
))}
</div>
</div>
{selectedCharacter && (
<CharacterDetail
character={selectedCharacter}
campaignId={campaignId}
critKeys={critKeys}
onUpdate={handleUpdate}
onStatChange={handleStatChange}
onAddGearFromItem={handleAddGearFromItem}
onAddGearCustom={handleAddGearCustom}
onRemoveGear={handleRemoveGear}
onAddTalent={handleAddTalent}
onRemoveTalent={handleRemoveTalent}
onDelete={handleDelete}
onClose={() => setSelectedId(null)}
canEdit={role === "dm" || selectedCharacter.user_id === user?.userId}
/>
)}
{showCreate && (
<CharacterWizard
campaignId={campaignId}
onSubmit={handleCreate}
onClose={() => setShowCreate(false)}
/>
)}
{showCombatStart && (
<CombatStartModal
characters={characters}
campaignId={campaignId}
onClose={() => setShowCombatStart(false)}
/>
)}
</div>
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} onUndoRoll={handleUndoRoll} />
<DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} />
<FogOverlay active={atmosphere.fog.active} intensity={atmosphere.fog.intensity} />
<ThreeFireOverlay active={atmosphere.fire.active} intensity={atmosphere.fire.intensity} />
<ParticleOverlay atmosphere={atmosphere} />
</div>
);
}