diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..9995036
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,27 @@
+# Darkwatch — Claude Code Instructions
+
+## Permissions
+
+All tool uses are pre-approved for this project. No confirmation needed for:
+- Reading, writing, or editing any file in this project
+- Running bash commands (npm, git, docker, curl, lsof, kill, pkill, etc.)
+- Spawning subagents
+- Searching files (Glob, Grep)
+
+## Project Overview
+
+This is **Darkwatch**, a real-time session manager for the Shadowdark TTRPG.
+
+- **Client:** `client/` — React + Vite + TypeScript + CSS Modules
+- **Server:** `server/` — Express + Socket.IO + MariaDB (mysql2/promise)
+- **DB:** MariaDB 11 in Docker (`darkwatch-maria` container, port 3307)
+- **Worktrees:** `.worktrees/` (gitignored)
+- **Roadmap:** `docs/ROADMAP.md`
+
+## Important Rules
+
+- NEVER touch the `mysql` Docker container — only `darkwatch-*` containers
+- DB port is 3307 (not 3306) to avoid conflicts
+- Local git repo only — commits are fine, NEVER push
+- All imports use `.js` extensions (ES modules)
+- Dev seed accounts: `dm@darkwatch.test` / `player@darkwatch.test` (password: `password`)
diff --git a/client/src/components/DiceTray.module.css b/client/src/components/DiceTray.module.css
index c7ec80c..7408754 100644
--- a/client/src/components/DiceTray.module.css
+++ b/client/src/components/DiceTray.module.css
@@ -8,7 +8,6 @@
}
.tray.active {
- pointer-events: auto;
opacity: 1;
}
@@ -17,7 +16,3 @@
height: 100% !important;
pointer-events: none !important;
}
-
-.tray.active :global(canvas) {
- pointer-events: auto !important;
-}
diff --git a/client/src/components/FogOverlay.tsx b/client/src/components/FogOverlay.tsx
index 16d7962..6d2b696 100644
--- a/client/src/components/FogOverlay.tsx
+++ b/client/src/components/FogOverlay.tsx
@@ -2,13 +2,17 @@ import styles from "./FogOverlay.module.css";
interface FogOverlayProps {
active: boolean;
+ intensity: number; // 0–100
}
-export default function FogOverlay({ active }: FogOverlayProps) {
+export default function FogOverlay({ active, intensity }: FogOverlayProps) {
if (!active) return null;
+ // Map 0–100 to 0.15–1.0 so fog is always at least faintly visible when active
+ const opacity = 0.15 + (intensity / 100) * 0.85;
+
return (
-
+
diff --git a/client/src/components/InfoPanel.tsx b/client/src/components/InfoPanel.tsx
index a91624e..47e4676 100644
--- a/client/src/components/InfoPanel.tsx
+++ b/client/src/components/InfoPanel.tsx
@@ -2,6 +2,7 @@ import { useRef, useEffect } from "react";
import type { Character } from "../types";
import TalentList from "./TalentList";
import SelectDropdown from "./SelectDropdown";
+import { getShadowdarkTitle } from "../utils/shadowdark-titles.js";
import styles from "./InfoPanel.module.css";
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
@@ -40,13 +41,24 @@ export default function InfoPanel({
}, []);
function handleField(field: string, value: string | number) {
- if (typeof value === "string") {
+ const update: Partial
= { [field]: value };
+
+ // Auto-update title when class, alignment, or level changes
+ if (field === "class" || field === "alignment" || field === "level") {
+ const newClass = field === "class" ? String(value) : character.class;
+ const newAlignment = field === "alignment" ? String(value) : character.alignment;
+ const newLevel = field === "level" ? Number(value) : character.level;
+ const derived = getShadowdarkTitle(newClass, newAlignment, newLevel);
+ if (derived) update.title = derived;
+ }
+
+ if (typeof value === "string" && field !== "class" && field !== "alignment") {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
- onUpdate(character.id, { [field]: value });
+ onUpdate(character.id, update);
}, 400);
} else {
- onUpdate(character.id, { [field]: value });
+ onUpdate(character.id, update);
}
}
diff --git a/client/src/pages/CampaignView.tsx b/client/src/pages/CampaignView.tsx
index 2a8fb8a..a6695c7 100644
--- a/client/src/pages/CampaignView.tsx
+++ b/client/src/pages/CampaignView.tsx
@@ -509,7 +509,7 @@ export default function CampaignView() {
-
+
diff --git a/client/src/utils/shadowdark-titles.ts b/client/src/utils/shadowdark-titles.ts
new file mode 100644
index 0000000..e1dd858
--- /dev/null
+++ b/client/src/utils/shadowdark-titles.ts
@@ -0,0 +1,59 @@
+type Alignment = "Lawful" | "Neutral" | "Chaotic";
+type CharClass = "Fighter" | "Priest" | "Thief" | "Wizard";
+
+// Title table from the Shadowdark Player Quickstart
+// [level range min, level range max, lawful, chaotic, neutral]
+const TITLES: Record = {
+ Fighter: [
+ [1, 2, "Squire", "Knave", "Warrior"],
+ [3, 4, "Cavalier", "Bandit", "Barbarian"],
+ [5, 6, "Knight", "Slayer", "Battlerager"],
+ [7, 8, "Thane", "Reaver", "Warchief"],
+ [9, 10, "Lord", "Warlord", "Chieftain"],
+ ],
+ Priest: [
+ [1, 2, "Acolyte", "Initiate", "Seeker"],
+ [3, 4, "Crusader", "Zealot", "Invoker"],
+ [5, 6, "Templar", "Cultist", "Haruspex"],
+ [7, 8, "Champion", "Scourge", "Mystic"],
+ [9, 10, "Paladin", "Chaos Knight", "Oracle"],
+ ],
+ Thief: [
+ [1, 2, "Footpad", "Thug", "Robber"],
+ [3, 4, "Burglar", "Cutthroat", "Outlaw"],
+ [5, 6, "Rook", "Shadow", "Rogue"],
+ [7, 8, "Underboss", "Assassin", "Renegade"],
+ [9, 10, "Boss", "Wraith", "Bandit King"],
+ ],
+ Wizard: [
+ [1, 2, "Apprentice", "Adept", "Shaman"],
+ [3, 4, "Conjurer", "Channeler", "Seer"],
+ [5, 6, "Arcanist", "Witch", "Warden"],
+ [7, 8, "Mage", "Diabolist", "Sage"],
+ [9, 10, "Archmage", "Sorcerer", "Druid"],
+ ],
+};
+
+const ALIGNMENT_INDEX: Record = {
+ Lawful: 0,
+ Chaotic: 1,
+ Neutral: 2,
+};
+
+export function getShadowdarkTitle(
+ charClass: string,
+ alignment: string,
+ level: number
+): string | null {
+ const classTable = TITLES[charClass as CharClass];
+ if (!classTable) return null;
+
+ const alignIndex = ALIGNMENT_INDEX[alignment as Alignment];
+ if (alignIndex === undefined) return null;
+
+ const clampedLevel = Math.min(Math.max(level, 1), 10);
+ const row = classTable.find(([min, max]) => clampedLevel >= min && clampedLevel <= max);
+ if (!row) return null;
+
+ return row[2 + alignIndex] as string;
+}