Initial commit: Shadowdark character sheet manager with item/talent databases, view/edit modes, real-time sync

This commit is contained in:
Aaron Wood 2026-04-09 01:03:40 -04:00
commit 2c73dd9ec4
80 changed files with 17911 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
server/data/
.superpowers/

74
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,74 @@
{
"workbench.colorCustomizations": {
"editor.selectionBackground": "#5b9e8d",
"editor.selectionHighlightBackground": "#845f96",
"terminal.selectionBackground": "#4444aa",
"textMateRules": [
{
"scope": "storage.type.class.jsdoc,entity.name.type.instance.jsdoc,variable.other.jsdoc",
"settings": {
"foreground": "#485c6c"
}
},
{
"scope": "storage.type.class.jsdoc punctuation.definition.block.tag.jsdoc",
"settings": {
"foreground": "#485c6c"
}
},
{
"scope": "comment.block.documentation.phpdoc.php",
"settings": {
"foreground": "#5c6370"
}
},
{
"scope": "keyword.other.phpdoc.php,comment.block.documentation.phpdoc.php",
"settings": {
"foreground": "#76687d"
}
},
{
"scope": "keyword.other.type.php,meta.other.type.phpdoc.php,comment.block.documentation.phpdoc.php",
"settings": {
"foreground": "#76687d"
}
},
{
"scope": "support.class.php",
"settings": {
"foreground": "#E5C07B"
}
},
{
"scope": "meta.other.type.phpdoc.php,comment.block.documentation.phpdoc.php",
"settings": {
"foreground": "#5c6370"
}
},
{
"scope": "meta.other.type.phpdoc.php support.class.php",
"settings": {
"foreground": "#5c6370"
}
},
{
"scope": "meta.other.type.phpdoc.php support.class.builtin.php",
"settings": {
"foreground": "#5c6370"
}
}
],
"activityBar.background": "#875000",
"titleBar.activeBackground": "#BD7000",
"titleBar.activeForeground": "#FFFAF2",
"titleBar.inactiveBackground": "#875000",
"titleBar.inactiveForeground": "#FFFAF2",
"statusBar.background": "#875000",
"statusBar.foreground": "#FFFAF2",
"statusBar.debuggingBackground": "#875000",
"statusBar.debuggingForeground": "#FFFAF2",
"statusBar.noFolderBackground": "#875000",
"statusBar.noFolderForeground": "#FFFAF2"
}
}

12
client/index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shadowdark Character Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1986
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
client/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "shadowdark-client",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.1",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}

32
client/src/App.module.css Normal file
View file

@ -0,0 +1,32 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
}
.app {
max-width: 1400px;
margin: 0 auto;
padding: 1rem;
}
.header {
text-align: center;
padding: 1rem 0;
border-bottom: 1px solid #333;
margin-bottom: 1.5rem;
}
.header h1 {
font-size: 1.8rem;
color: #c9a84c;
font-variant: small-caps;
letter-spacing: 0.05em;
}

20
client/src/App.tsx Normal file
View file

@ -0,0 +1,20 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import CampaignList from "./pages/CampaignList";
import CampaignView from "./pages/CampaignView";
import styles from "./App.module.css";
export default function App() {
return (
<BrowserRouter>
<div className={styles.app}>
<header className={styles.header}>
<h1>Shadowdark</h1>
</header>
<Routes>
<Route path="/" element={<CampaignList />} />
<Route path="/campaign/:id" element={<CampaignView />} />
</Routes>
</div>
</BrowserRouter>
);
}

109
client/src/api.ts Normal file
View file

@ -0,0 +1,109 @@
import type {
Campaign,
Character,
Gear,
Talent,
GameItem,
GameTalent,
} from "./types";
const BASE = "/api";
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
}
// Campaigns
export const getCampaigns = () => request<Campaign[]>("/campaigns");
export const createCampaign = (name: string) =>
request<Campaign>("/campaigns", {
method: "POST",
body: JSON.stringify({ name }),
});
export const deleteCampaign = (id: number) =>
request<void>(`/campaigns/${id}`, { method: "DELETE" });
// Characters
export const getCharacters = (campaignId: number) =>
request<Character[]>(`/campaigns/${campaignId}/characters`);
export const createCharacter = (
campaignId: number,
data: { name: string; class?: string; ancestry?: string; hp_max?: number },
) =>
request<Character>(`/campaigns/${campaignId}/characters`, {
method: "POST",
body: JSON.stringify(data),
});
export const updateCharacter = (id: number, data: Partial<Character>) =>
request<Character>(`/characters/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
export const deleteCharacter = (id: number) =>
request<void>(`/characters/${id}`, { method: "DELETE" });
// Stats
export const updateStat = (
characterId: number,
statName: string,
value: number,
) =>
request<{ characterId: number; statName: string; value: number }>(
`/characters/${characterId}/stats/${statName}`,
{ method: "PATCH", body: JSON.stringify({ value }) },
);
// Gear
export const addGear = (
characterId: number,
data: {
name: string;
type?: string;
slot_count?: number;
properties?: Record<string, unknown>;
effects?: Record<string, unknown>;
game_item_id?: number | null;
},
) =>
request<Gear>(`/characters/${characterId}/gear`, {
method: "POST",
body: JSON.stringify(data),
});
export const removeGear = (characterId: number, gearId: number) =>
request<void>(`/characters/${characterId}/gear/${gearId}`, {
method: "DELETE",
});
// Talents
export const addTalent = (
characterId: number,
data: {
name: string;
description?: string;
effect?: Record<string, unknown>;
game_talent_id?: number | null;
},
) =>
request<Talent>(`/characters/${characterId}/talents`, {
method: "POST",
body: JSON.stringify(data),
});
export const removeTalent = (characterId: number, talentId: number) =>
request<void>(`/characters/${characterId}/talents/${talentId}`, {
method: "DELETE",
});
// Game Items
export const getGameItems = () => request<GameItem[]>("/game-items");
// Game Talents
export const getGameTalents = () => request<GameTalent[]>("/game-talents");

View file

@ -0,0 +1,67 @@
.container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.label {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
font-weight: 600;
}
.value {
font-size: 1.4rem;
font-weight: 700;
color: #5dade2;
cursor: pointer;
min-width: 2rem;
text-align: center;
}
.value.overridden {
color: #c9a84c;
}
.source {
font-size: 0.7rem;
color: #666;
}
.override {
display: flex;
align-items: center;
gap: 0.3rem;
}
.overrideIndicator {
font-size: 0.65rem;
color: #c9a84c;
cursor: pointer;
background: rgba(201, 168, 76, 0.15);
border: none;
border-radius: 3px;
padding: 0.1rem 0.3rem;
}
.overrideIndicator:hover {
background: rgba(201, 168, 76, 0.3);
}
.calculatedHint {
font-size: 0.65rem;
color: #555;
}
.editInput {
width: 3rem;
padding: 0.2rem 0.3rem;
background: #0f1a30;
border: 1px solid #c9a84c;
border-radius: 4px;
color: #e0e0e0;
font-size: 1.2rem;
font-weight: 700;
text-align: center;
}

View file

@ -0,0 +1,92 @@
import { useState } from "react";
import type { AcBreakdown } from "../utils/derived-ac";
import styles from "./AcDisplay.module.css";
interface AcDisplayProps {
breakdown: AcBreakdown;
onOverride: (value: number | null) => void;
mode?: "view" | "edit";
}
export default function AcDisplay({
breakdown,
onOverride,
mode = "view",
}: AcDisplayProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const isOverridden = breakdown.override !== null;
function startEdit() {
setEditValue(String(breakdown.effective));
setEditing(true);
}
function commitEdit() {
setEditing(false);
const num = parseInt(editValue, 10);
if (!isNaN(num) && num !== breakdown.calculated) {
onOverride(num);
} else if (num === breakdown.calculated) {
onOverride(null);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditing(false);
}
return (
<div className={styles.container}>
<span className={styles.label}>AC</span>
{mode === "edit" ? (
editing ? (
<input
className={styles.editInput}
type="number"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
autoFocus
/>
) : (
<span
className={`${styles.value} ${isOverridden ? styles.overridden : ""}`}
onClick={startEdit}
title="Click to override"
>
{breakdown.effective}
</span>
)
) : (
<span
className={`${styles.value} ${isOverridden ? styles.overridden : ""}`}
>
{breakdown.effective}
</span>
)}
{mode === "edit" && (
<div>
<div className={styles.source}>{breakdown.source}</div>
{isOverridden && (
<div className={styles.override}>
<span className={styles.calculatedHint}>
auto: {breakdown.calculated}
</span>
<button
className={styles.overrideIndicator}
onClick={() => onOverride(null)}
title="Revert to auto-calculated"
>
revert
</button>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,73 @@
.section {
margin-top: 1rem;
}
.title {
font-size: 0.9rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.list {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.line {
display: flex;
justify-content: space-between;
align-items: center;
background: #0f1a30;
border-radius: 6px;
padding: 0.35rem 0.6rem;
font-size: 0.85rem;
}
.weaponName {
font-weight: 700;
text-transform: uppercase;
color: #e0e0e0;
}
.stats {
color: #888;
}
.modifier {
color: #c9a84c;
font-weight: 600;
}
.damage {
color: #e0e0e0;
}
.tags {
font-size: 0.7rem;
color: #666;
margin-left: 0.3rem;
}
.talentLine {
font-style: italic;
color: #888;
font-size: 0.8rem;
padding: 0.25rem 0.6rem;
}
.rollSpace {
width: 2.5rem;
text-align: center;
color: #444;
font-size: 0.75rem;
}
.empty {
font-size: 0.8rem;
color: #555;
font-style: italic;
}

View file

@ -0,0 +1,43 @@
import type { AttackLine } from "../types";
import styles from "./AttackBlock.module.css";
interface AttackBlockProps {
attacks: AttackLine[];
}
export default function AttackBlock({ attacks }: AttackBlockProps) {
const weapons = attacks.filter((a) => !a.isTalent);
const talents = attacks.filter((a) => a.isTalent);
return (
<div className={styles.section}>
<div className={styles.title}>Attacks</div>
<div className={styles.list}>
{weapons.length === 0 && talents.length === 0 && (
<span className={styles.empty}>No weapons equipped</span>
)}
{weapons.map((atk) => (
<div key={atk.name} className={styles.line}>
<span>
<span className={styles.weaponName}>{atk.name}</span>
{atk.tags.length > 0 && (
<span className={styles.tags}>({atk.tags.join(", ")})</span>
)}
</span>
<span className={styles.stats}>
<span className={styles.modifier}>{atk.modifierStr}</span>
{", "}
<span className={styles.damage}>{atk.damage}</span>
</span>
<span className={styles.rollSpace}></span>
</div>
))}
{talents.map((atk) => (
<div key={atk.name} className={styles.talentLine}>
<strong>{atk.name}:</strong> {atk.description}
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
.card {
background: #16213e;
border: 1px solid #333;
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition:
border-color 0.15s,
transform 0.1s;
}
.card:hover {
border-color: #c9a84c;
transform: translateY(-2px);
}
.cardHeader {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.name {
font-size: 1.1rem;
font-weight: 700;
color: #e0e0e0;
}
.level {
font-size: 0.8rem;
color: #888;
}
.meta {
font-size: 0.8rem;
color: #666;
margin-bottom: 0.75rem;
}
.hpAcRow {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.ac {
display: flex;
align-items: center;
gap: 0.3rem;
font-weight: 700;
}
.acLabel {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
font-weight: 600;
}
.acValue {
font-size: 1.1rem;
color: #5dade2;
}
.gearSummary {
font-size: 0.75rem;
color: #666;
text-align: right;
margin-top: 0.5rem;
}
.xp {
font-size: 0.75rem;
color: #888;
text-align: right;
}

View file

@ -0,0 +1,63 @@
import type { Character } from "../types";
import HpBar from "./HpBar";
import StatBlock from "./StatBlock";
import styles from "./CharacterCard.module.css";
import { calculateAC } from "../utils/derived-ac";
interface CharacterCardProps {
character: Character;
onHpChange: (characterId: number, hp: number) => void;
onStatChange: (characterId: number, statName: string, value: number) => void;
onClick: (characterId: number) => void;
}
export default function CharacterCard({
character,
onHpChange,
onStatChange,
onClick,
}: CharacterCardProps) {
const totalSlots = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
return (
<div className={styles.card} onClick={() => onClick(character.id)}>
<div className={styles.cardHeader}>
<span className={styles.name}>
{character.name}
{character.title ? ` ${character.title}` : ""}
</span>
<span className={styles.level}>Lvl {character.level}</span>
</div>
<div className={styles.meta}>
{character.ancestry} {character.class}
</div>
<div className={styles.hpAcRow} onClick={(e) => e.stopPropagation()}>
<HpBar
current={character.hp_current}
max={character.hp_max}
onChange={(hp) => onHpChange(character.id, hp)}
/>
<div className={styles.ac}>
<span className={styles.acLabel}>AC</span>
<span className={styles.acValue}>
{calculateAC(character).effective}
</span>
</div>
</div>
<div onClick={(e) => e.stopPropagation()}>
<StatBlock
stats={character.stats}
onStatChange={(statName, value) =>
onStatChange(character.id, statName, value)
}
/>
</div>
<div className={styles.gearSummary}>
{totalSlots} gear slot{totalSlots !== 1 ? "s" : ""} used
</div>
<div className={styles.xp}>
XP: {character.xp} / {character.level * 10}
</div>
</div>
);
}

View file

@ -0,0 +1,82 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.modal {
background: #1a1a2e;
border: 1px solid #333;
border-radius: 12px;
width: 100%;
max-width: 1100px;
max-height: 95vh;
overflow-y: auto;
overflow-x: hidden;
padding: 1.5rem;
scrollbar-width: thin;
scrollbar-color: #333 transparent;
}
.modal::-webkit-scrollbar {
width: 6px;
}
.modal::-webkit-scrollbar-track {
background: transparent;
}
.modal::-webkit-scrollbar-thumb {
background: #333;
border-radius: 3px;
}
.modal::-webkit-scrollbar-thumb:hover {
background: #555;
}
.topBar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.editBtn {
padding: 0.35rem 0.75rem;
background: transparent;
border: 1px solid #c9a84c;
border-radius: 5px;
color: #c9a84c;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
}
.editBtn:hover {
background: rgba(201, 168, 76, 0.15);
}
.editBtn.active {
background: #c9a84c;
color: #1a1a2e;
}
.closeBtn {
background: none;
border: none;
color: #888;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.closeBtn:hover {
color: #e0e0e0;
}

View file

@ -0,0 +1,74 @@
import { useState } from "react";
import type { Character, GameItem } from "../types";
import CharacterSheet from "./CharacterSheet";
import styles from "./CharacterDetail.module.css";
interface CharacterDetailProps {
character: Character;
onUpdate: (id: number, data: Partial<Character>) => void;
onStatChange: (characterId: number, statName: string, value: number) => void;
onAddGearFromItem: (characterId: number, item: GameItem) => void;
onAddGearCustom: (
characterId: number,
data: { name: string; type: string; slot_count: number },
) => void;
onRemoveGear: (characterId: number, gearId: number) => void;
onAddTalent: (
characterId: number,
data: {
name: string;
description: string;
effect?: Record<string, unknown>;
game_talent_id?: number | null;
},
) => void;
onRemoveTalent: (characterId: number, talentId: number) => void;
onDelete: (id: number) => void;
onClose: () => void;
}
export default function CharacterDetail({
character,
onUpdate,
onStatChange,
onAddGearFromItem,
onAddGearCustom,
onRemoveGear,
onAddTalent,
onRemoveTalent,
onDelete,
onClose,
}: CharacterDetailProps) {
const [mode, setMode] = useState<"view" | "edit">("view");
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.topBar}>
<button
className={`${styles.editBtn} ${mode === "edit" ? styles.active : ""}`}
onClick={() => setMode(mode === "view" ? "edit" : "view")}
>
{mode === "view" ? "Edit" : "Done"}
</button>
<button className={styles.closeBtn} onClick={onClose}>
</button>
</div>
<CharacterSheet
character={character}
mode={mode}
onUpdate={onUpdate}
onStatChange={onStatChange}
onAddGearFromItem={onAddGearFromItem}
onAddGearCustom={onAddGearCustom}
onRemoveGear={onRemoveGear}
onAddTalent={onAddTalent}
onRemoveTalent={onRemoveTalent}
onDelete={onDelete}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,157 @@
.banner {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, #16213e, #0f1a30);
border: 1px solid #333;
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.identity {
flex: 1;
}
.name {
font-size: 1.4rem;
font-weight: 700;
color: #c9a84c;
}
.title {
color: #888;
font-size: 0.9rem;
}
.subtitle {
color: #666;
font-size: 0.8rem;
margin-top: 0.15rem;
}
.vitals {
display: flex;
gap: 1.25rem;
align-items: center;
}
.vital {
text-align: center;
}
.vitalValue {
font-size: 1.3rem;
font-weight: 700;
}
.hp {
color: #4caf50;
}
.ac {
color: #5dade2;
}
.vitalLabel {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
font-weight: 600;
}
.hpValues {
display: flex;
align-items: center;
gap: 0.3rem;
}
.hpSlash {
color: #666;
font-size: 0.9rem;
}
.hpMax {
color: #888;
font-weight: 600;
}
.xpThreshold {
font-size: 0.75rem;
color: #666;
}
.xpCurrent {
color: #c9a84c;
font-weight: 600;
}
.nameInput {
font-size: 1.3rem;
font-weight: 700;
color: #c9a84c;
background: #0f1a30;
border: 1px solid #333;
border-radius: 5px;
padding: 0.2rem 0.4rem;
width: 10rem;
}
.nameInput:focus {
outline: none;
border-color: #c9a84c;
}
.titleInput {
font-size: 0.85rem;
color: #888;
background: #0f1a30;
border: 1px solid #333;
border-radius: 5px;
padding: 0.15rem 0.4rem;
width: 8rem;
margin-left: 0.3rem;
}
.titleInput:focus {
outline: none;
border-color: #c9a84c;
}
.panels {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem;
}
@media (max-width: 1100px) {
.panels {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 768px) {
.panels {
grid-template-columns: 1fr;
}
}
.deleteSection {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid #333;
}
.deleteBtn {
padding: 0.4rem 0.75rem;
background: transparent;
border: 1px solid #e74c3c;
border-radius: 5px;
color: #e74c3c;
cursor: pointer;
font-size: 0.8rem;
}
.deleteBtn:hover {
background: rgba(231, 76, 60, 0.1);
}

View file

@ -0,0 +1,212 @@
import { useState, useRef, useEffect } from "react";
import type { Character, GameItem } from "../types";
import { calculateAC } from "../utils/derived-ac";
import AcDisplay from "./AcDisplay";
import InlineNumber from "./InlineNumber";
import StatsPanel from "./StatsPanel";
import InfoPanel from "./InfoPanel";
import GearPanel from "./GearPanel";
import styles from "./CharacterSheet.module.css";
interface CharacterSheetProps {
character: Character;
mode: "view" | "edit";
onUpdate: (id: number, data: Partial<Character>) => void;
onStatChange: (characterId: number, statName: string, value: number) => void;
onAddGearFromItem: (characterId: number, item: GameItem) => void;
onAddGearCustom: (
characterId: number,
data: { name: string; type: string; slot_count: number },
) => void;
onRemoveGear: (characterId: number, gearId: number) => void;
onAddTalent: (
characterId: number,
data: {
name: string;
description: string;
effect?: Record<string, unknown>;
game_talent_id?: number | null;
},
) => void;
onRemoveTalent: (characterId: number, talentId: number) => void;
onDelete: (id: number) => void;
}
export default function CharacterSheet({
character,
mode,
onUpdate,
onStatChange,
onAddGearFromItem,
onAddGearCustom,
onRemoveGear,
onAddTalent,
onRemoveTalent,
onDelete,
}: CharacterSheetProps) {
const [confirmDelete, setConfirmDelete] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const acBreakdown = calculateAC(character);
const xpThreshold = character.level * 10;
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
function handleNameField(field: string, value: string) {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdate(character.id, { [field]: value });
}, 400);
}
function handleAcOverride(value: number | null) {
const overrides = { ...(character.overrides || {}) };
if (value === null) {
delete overrides.ac;
} else {
overrides.ac = value;
}
onUpdate(character.id, { overrides } as Partial<Character>);
}
return (
<>
{/* HEADER BANNER */}
<div className={styles.banner}>
<div className={styles.identity}>
{mode === "edit" ? (
<div>
<input
className={styles.nameInput}
defaultValue={character.name}
onChange={(e) => handleNameField("name", e.target.value)}
/>
<input
className={styles.titleInput}
defaultValue={character.title}
placeholder="title..."
onChange={(e) => handleNameField("title", e.target.value)}
/>
</div>
) : (
<div className={styles.name}>
{character.name}
{character.title && (
<span className={styles.title}> {character.title}</span>
)}
</div>
)}
<div className={styles.subtitle}>
Level {character.level} {character.ancestry} {character.class}
</div>
</div>
<div className={styles.vitals}>
{/* HP — click to edit */}
<div className={styles.vital}>
<div className={styles.hpValues}>
<span className={styles.vitalLabel}>HP</span>
<InlineNumber
value={character.hp_current}
onChange={(hp) => onUpdate(character.id, { hp_current: hp })}
className={`${styles.vitalValue} ${styles.hp}`}
/>
<span className={styles.hpSlash}>/</span>
{mode === "edit" ? (
<InlineNumber
value={character.hp_max}
onChange={(hp) => onUpdate(character.id, { hp_max: hp })}
className={styles.hpMax}
min={0}
/>
) : (
<span className={styles.hpMax}>{character.hp_max}</span>
)}
</div>
</div>
{/* AC — display only in view, override in edit */}
<div className={styles.vital}>
<AcDisplay
breakdown={acBreakdown}
onOverride={handleAcOverride}
mode={mode}
/>
</div>
{/* XP — click to edit */}
<div className={styles.vital}>
<div className={styles.hpValues}>
<span className={styles.vitalLabel}>XP</span>
<InlineNumber
value={character.xp}
onChange={(xp) => onUpdate(character.id, { xp })}
className={`${styles.vitalValue} ${styles.xpCurrent}`}
min={0}
/>
<span className={styles.xpThreshold}>/ {xpThreshold}</span>
</div>
</div>
</div>
</div>
{/* THREE PANELS */}
<div className={styles.panels}>
<StatsPanel
character={character}
mode={mode}
onStatChange={onStatChange}
/>
<InfoPanel
character={character}
mode={mode}
onUpdate={onUpdate}
onAddTalent={onAddTalent}
onRemoveTalent={onRemoveTalent}
/>
<GearPanel
character={character}
mode={mode}
onAddGearFromItem={onAddGearFromItem}
onAddGearCustom={onAddGearCustom}
onRemoveGear={onRemoveGear}
onCurrencyChange={(charId, field, value) =>
onUpdate(charId, { [field]: value })
}
/>
</div>
{/* DELETE — edit mode only */}
{mode === "edit" && (
<div className={styles.deleteSection}>
{confirmDelete ? (
<div>
<span>Delete {character.name}? </span>
<button
className={styles.deleteBtn}
onClick={() => onDelete(character.id)}
>
Yes, delete
</button>{" "}
<button
className={styles.deleteBtn}
onClick={() => setConfirmDelete(false)}
>
Cancel
</button>
</div>
) : (
<button
className={styles.deleteBtn}
onClick={() => setConfirmDelete(true)}
>
Delete Character
</button>
)}
</div>
)}
</>
);
}

View file

@ -0,0 +1,70 @@
.row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0;
}
.coin {
display: flex;
align-items: center;
gap: 0.3rem;
}
.coinLabel {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.gp {
color: #c9a84c;
}
.sp {
color: #a0a0a0;
}
.cp {
color: #b87333;
}
.coinInput {
width: 3.5rem;
padding: 0.25rem 0.4rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 4px;
color: #e0e0e0;
font-size: 0.85rem;
text-align: center;
}
.coinInput:focus {
outline: none;
border-color: #c9a84c;
}
.coinBtn {
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid #444;
background: #16213e;
color: #e0e0e0;
cursor: pointer;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.coinBtn:hover {
border-color: #c9a84c;
color: #c9a84c;
}
.coinValue {
min-width: 1.5rem;
text-align: center;
font-size: 0.85rem;
font-weight: 600;
}

View file

@ -0,0 +1,80 @@
import InlineNumber from "./InlineNumber";
import styles from "./CurrencyRow.module.css";
interface CurrencyRowProps {
gp: number;
sp: number;
cp: number;
onChange: (field: "gp" | "sp" | "cp", value: number) => void;
mode?: "view" | "edit";
}
export default function CurrencyRow({
gp,
sp,
cp,
onChange,
mode = "view",
}: CurrencyRowProps) {
return (
<div className={styles.row}>
<div className={styles.coin}>
<span className={`${styles.coinLabel} ${styles.gp}`}>GP</span>
{mode === "edit" ? (
<input
className={styles.coinInput}
type="number"
min={0}
value={gp}
onChange={(e) => onChange("gp", Number(e.target.value))}
/>
) : (
<InlineNumber
value={gp}
onChange={(v) => onChange("gp", v)}
className={styles.coinValue}
min={0}
/>
)}
</div>
<div className={styles.coin}>
<span className={`${styles.coinLabel} ${styles.sp}`}>SP</span>
{mode === "edit" ? (
<input
className={styles.coinInput}
type="number"
min={0}
value={sp}
onChange={(e) => onChange("sp", Number(e.target.value))}
/>
) : (
<InlineNumber
value={sp}
onChange={(v) => onChange("sp", v)}
className={styles.coinValue}
min={0}
/>
)}
</div>
<div className={styles.coin}>
<span className={`${styles.coinLabel} ${styles.cp}`}>CP</span>
{mode === "edit" ? (
<input
className={styles.coinInput}
type="number"
min={0}
value={cp}
onChange={(e) => onChange("cp", Number(e.target.value))}
/>
) : (
<InlineNumber
value={cp}
onChange={(v) => onChange("cp", v)}
className={styles.coinValue}
min={0}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,153 @@
.section {
margin-top: 0.75rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.title {
font-size: 0.9rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.slotCounter {
font-size: 0.8rem;
font-weight: 600;
}
.slotCounter.normal {
color: #4caf50;
}
.slotCounter.warning {
color: #ff9800;
}
.slotCounter.over {
color: #e74c3c;
}
.table {
width: 100%;
border-collapse: collapse;
}
.tableHeader {
font-size: 0.7rem;
color: #666;
text-transform: uppercase;
font-weight: 600;
text-align: left;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid #333;
}
.right {
text-align: right;
}
.center {
text-align: center;
}
.row {
border-bottom: 1px solid #222;
}
.row:hover {
background: rgba(201, 168, 76, 0.05);
}
.cell {
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
vertical-align: middle;
}
.itemName {
font-weight: 600;
}
.removeBtn {
background: none;
border: none;
color: #555;
cursor: pointer;
font-size: 0.9rem;
padding: 0.1rem 0.3rem;
}
.removeBtn:hover {
color: #e74c3c;
}
.addArea {
margin-top: 0.5rem;
}
.addBtn {
padding: 0.4rem 0.75rem;
background: #c9a84c;
color: #1a1a2e;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 0.8rem;
}
.addBtn:hover {
background: #d4b65a;
}
.cancelBtn {
padding: 0.4rem 0.75rem;
background: #333;
color: #888;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 0.8rem;
}
.customForm {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
.customInput {
flex: 1;
padding: 0.4rem 0.6rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.85rem;
}
.customInput:focus {
outline: none;
border-color: #c9a84c;
}
.customSelect {
padding: 0.4rem 0.6rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.85rem;
}
.empty {
font-size: 0.8rem;
color: #555;
font-style: italic;
padding: 0.5rem;
}

View file

@ -0,0 +1,184 @@
import { useState } from "react";
import type { Gear, GameItem } from "../types";
import ItemPicker from "./ItemPicker";
import CurrencyRow from "./CurrencyRow";
import styles from "./GearList.module.css";
interface GearListProps {
gear: Gear[];
gp: number;
sp: number;
cp: number;
slotsUsed: number;
slotsMax: number;
onAddFromItem: (item: GameItem) => void;
onAddCustom: (data: {
name: string;
type: string;
slot_count: number;
}) => void;
onRemove: (gearId: number) => void;
onCurrencyChange: (field: "gp" | "sp" | "cp", value: number) => void;
mode?: "view" | "edit";
}
function gearIcon(type: string): string {
switch (type) {
case "weapon":
return "⚔";
case "armor":
return "🛡";
case "spell":
return "✨";
default:
return "⚙";
}
}
export default function GearList({
gear,
gp,
sp,
cp,
slotsUsed,
slotsMax,
onAddFromItem,
onAddCustom,
onRemove,
onCurrencyChange,
mode = "view",
}: GearListProps) {
const [showPicker, setShowPicker] = useState(false);
const [showCustom, setShowCustom] = useState(false);
const [customName, setCustomName] = useState("");
const [customType, setCustomType] = useState("gear");
function handleCustomAdd(e: React.FormEvent) {
e.preventDefault();
if (!customName.trim()) return;
onAddCustom({ name: customName.trim(), type: customType, slot_count: 1 });
setCustomName("");
setShowCustom(false);
}
function handleItemSelect(item: GameItem) {
onAddFromItem(item);
setShowPicker(false);
}
const slotClass =
slotsUsed >= slotsMax
? styles.over
: slotsUsed >= slotsMax - 2
? styles.warning
: styles.normal;
return (
<div className={styles.section}>
<div className={styles.header}>
<span className={styles.title}>Gear & Inventory</span>
<span className={`${styles.slotCounter} ${slotClass}`}>
Slots: {slotsUsed} / {slotsMax}
</span>
</div>
{gear.length === 0 ? (
<p className={styles.empty}>No gear yet</p>
) : (
<table className={styles.table}>
<thead>
<tr>
<th className={styles.tableHeader}>Item</th>
<th className={`${styles.tableHeader} ${styles.center}`}>Type</th>
<th className={`${styles.tableHeader} ${styles.center}`}>
Slots
</th>
<th className={`${styles.tableHeader} ${styles.right}`}></th>
</tr>
</thead>
<tbody>
{gear.map((item) => (
<tr key={item.id} className={styles.row}>
<td className={styles.cell}>
<span className={styles.itemName}>{item.name}</span>
</td>
<td className={`${styles.cell} ${styles.center}`}>
<span title={item.type}>{gearIcon(item.type)}</span>
</td>
<td className={`${styles.cell} ${styles.center}`}>
{item.slot_count > 0 ? item.slot_count : "—"}
</td>
<td className={`${styles.cell} ${styles.right}`}>
{mode === "edit" && (
<button
className={styles.removeBtn}
onClick={() => onRemove(item.id)}
title="Remove"
>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
<CurrencyRow gp={gp} sp={sp} cp={cp} onChange={onCurrencyChange} />
{mode === "edit" && (
<div className={styles.addArea}>
{showPicker ? (
<ItemPicker
onSelect={handleItemSelect}
onCustom={() => {
setShowPicker(false);
setShowCustom(true);
}}
onClose={() => setShowPicker(false)}
/>
) : showCustom ? (
<form className={styles.customForm} onSubmit={handleCustomAdd}>
<input
className={styles.customInput}
type="text"
placeholder="Item name..."
value={customName}
onChange={(e) => setCustomName(e.target.value)}
autoFocus
/>
<select
className={styles.customSelect}
value={customType}
onChange={(e) => setCustomType(e.target.value)}
>
<option value="weapon">Weapon</option>
<option value="armor">Armor</option>
<option value="gear">Gear</option>
<option value="spell">Spell</option>
</select>
<button className={styles.addBtn} type="submit">
Add
</button>
<button
className={styles.cancelBtn}
type="button"
onClick={() => setShowCustom(false)}
>
Cancel
</button>
</form>
) : (
<button
className={styles.addBtn}
onClick={() => setShowPicker(true)}
>
+ Add Gear
</button>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,6 @@
.panel {
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 0.75rem;
}

View file

@ -0,0 +1,50 @@
import type { Character, GameItem } from "../types";
import GearList from "./GearList";
import styles from "./GearPanel.module.css";
interface GearPanelProps {
character: Character;
mode: "view" | "edit";
onAddGearFromItem: (characterId: number, item: GameItem) => void;
onAddGearCustom: (
characterId: number,
data: { name: string; type: string; slot_count: number },
) => void;
onRemoveGear: (characterId: number, gearId: number) => void;
onCurrencyChange: (
characterId: number,
field: "gp" | "sp" | "cp",
value: number,
) => void;
}
export default function GearPanel({
character,
mode,
onAddGearFromItem,
onAddGearCustom,
onRemoveGear,
onCurrencyChange,
}: GearPanelProps) {
const slotsUsed = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
return (
<div className={styles.panel}>
<GearList
gear={character.gear}
gp={character.gp}
sp={character.sp}
cp={character.cp}
slotsUsed={slotsUsed}
slotsMax={character.gear_slots_max}
onAddFromItem={(item) => onAddGearFromItem(character.id, item)}
onAddCustom={(data) => onAddGearCustom(character.id, data)}
onRemove={(gearId) => onRemoveGear(character.id, gearId)}
onCurrencyChange={(field, value) =>
onCurrencyChange(character.id, field, value)
}
mode={mode}
/>
</div>
);
}

View file

@ -0,0 +1,60 @@
.hpBar {
display: flex;
align-items: center;
gap: 0.5rem;
}
.label {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
font-weight: 600;
}
.values {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 1.1rem;
font-weight: 700;
}
.current {
color: #4caf50;
}
.current.hurt {
color: #ff9800;
}
.current.critical {
color: #e74c3c;
}
.slash {
color: #666;
}
.max {
color: #888;
}
.btn {
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid #444;
background: #16213e;
color: #e0e0e0;
cursor: pointer;
font-size: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.btn:hover {
border-color: #c9a84c;
color: #c9a84c;
}

View file

@ -0,0 +1,30 @@
import styles from "./HpBar.module.css";
interface HpBarProps {
current: number;
max: number;
onChange: (current: number) => void;
}
export default function HpBar({ current, max, onChange }: HpBarProps) {
const ratio = max > 0 ? current / max : 1;
const colorClass =
ratio <= 0.25 ? styles.critical : ratio <= 0.5 ? styles.hurt : "";
return (
<div className={styles.hpBar}>
<span className={styles.label}>HP</span>
<button className={styles.btn} onClick={() => onChange(current - 1)}>
</button>
<span className={styles.values}>
<span className={`${styles.current} ${colorClass}`}>{current}</span>
<span className={styles.slash}>/</span>
<span className={styles.max}>{max}</span>
</span>
<button className={styles.btn} onClick={() => onChange(current + 1)}>
+
</button>
</div>
);
}

View file

@ -0,0 +1,143 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
.values {
display: flex;
align-items: baseline;
gap: 0.2rem;
}
.current {
font-size: 1.4rem;
font-weight: 700;
color: #4caf50;
}
.current.hurt {
color: #ff9800;
}
.current.critical {
color: #e74c3c;
}
.slash {
color: #666;
font-size: 0.9rem;
}
.max {
color: #888;
font-weight: 600;
font-size: 1rem;
}
.label {
font-size: 0.65rem;
color: #888;
text-transform: uppercase;
font-weight: 600;
}
.buttons {
display: flex;
gap: 0.3rem;
}
.healBtn {
padding: 0.15rem 0.5rem;
background: rgba(76, 175, 80, 0.15);
border: 1px solid #4caf50;
border-radius: 4px;
color: #4caf50;
cursor: pointer;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
}
.healBtn:hover {
background: rgba(76, 175, 80, 0.3);
}
.dmgBtn {
padding: 0.15rem 0.5rem;
background: rgba(231, 76, 60, 0.15);
border: 1px solid #e74c3c;
border-radius: 4px;
color: #e74c3c;
cursor: pointer;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
}
.dmgBtn:hover {
background: rgba(231, 76, 60, 0.3);
}
.inputRow {
display: flex;
align-items: center;
gap: 0.25rem;
}
.amountInput {
width: 2.5rem;
padding: 0.2rem 0.3rem;
background: #0f1a30;
border: 1px solid #444;
border-radius: 4px;
color: #e0e0e0;
font-size: 0.85rem;
text-align: center;
font-weight: 600;
}
.amountInput:focus {
outline: none;
}
.amountInput.healing {
border-color: #4caf50;
}
.amountInput.damage {
border-color: #e74c3c;
}
.applyBtn {
padding: 0.2rem 0.4rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.7rem;
font-weight: 600;
}
.applyBtn.healing {
background: #4caf50;
color: #1a1a2e;
}
.applyBtn.damage {
background: #e74c3c;
color: #fff;
}
.cancelBtn {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 0.85rem;
padding: 0 0.2rem;
}
.cancelBtn:hover {
color: #e0e0e0;
}

View file

@ -0,0 +1,84 @@
import { useState } from "react";
import styles from "./HpDisplay.module.css";
interface HpDisplayProps {
current: number;
max: number;
onChange: (newCurrent: number) => void;
}
export default function HpDisplay({ current, max, onChange }: HpDisplayProps) {
const [mode, setMode] = useState<"idle" | "heal" | "damage">("idle");
const [amount, setAmount] = useState("");
const ratio = max > 0 ? current / max : 1;
const colorClass =
ratio <= 0.25 ? styles.critical : ratio <= 0.5 ? styles.hurt : "";
function apply() {
const num = parseInt(amount, 10);
if (isNaN(num) || num <= 0) {
reset();
return;
}
if (mode === "heal") {
onChange(Math.min(current + num, max));
} else if (mode === "damage") {
onChange(current - num);
}
reset();
}
function reset() {
setMode("idle");
setAmount("");
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") apply();
if (e.key === "Escape") reset();
}
return (
<div className={styles.container}>
<div className={styles.values}>
<span className={`${styles.current} ${colorClass}`}>{current}</span>
<span className={styles.slash}>/</span>
<span className={styles.max}>{max}</span>
</div>
<div className={styles.label}>HP</div>
{mode === "idle" ? (
<div className={styles.buttons}>
<button className={styles.healBtn} onClick={() => setMode("heal")}>
Heal
</button>
<button className={styles.dmgBtn} onClick={() => setMode("damage")}>
Dmg
</button>
</div>
) : (
<div className={styles.inputRow}>
<input
className={`${styles.amountInput} ${mode === "heal" ? styles.healing : styles.damage}`}
type="number"
min={1}
value={amount}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="#"
autoFocus
/>
<button
className={`${styles.applyBtn} ${mode === "heal" ? styles.healing : styles.damage}`}
onClick={apply}
>
{mode === "heal" ? "+" : ""}
</button>
<button className={styles.cancelBtn} onClick={reset}>
</button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,112 @@
.panel {
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 0.75rem;
}
.sectionTitle {
font-size: 0.8rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.infoGrid {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0.75rem;
}
.infoRow {
display: flex;
gap: 0.3rem;
font-size: 0.85rem;
}
.infoLabel {
color: #666;
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 600;
min-width: 5rem;
}
.infoValue {
color: #e0e0e0;
}
.notes {
font-size: 0.85rem;
color: #aaa;
white-space: pre-wrap;
margin-top: 0.5rem;
}
.editField {
padding: 0.3rem 0.5rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 5px;
color: #e0e0e0;
font-size: 0.85rem;
width: 100%;
}
.editField:focus {
outline: none;
border-color: #c9a84c;
}
.editSelect {
padding: 0.3rem 0.5rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 5px;
color: #e0e0e0;
font-size: 0.85rem;
}
.editRow {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.editRow > * {
flex: 1;
}
.field {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.fieldLabel {
font-size: 0.65rem;
color: #666;
text-transform: uppercase;
font-weight: 600;
}
.notesEdit {
width: 100%;
min-height: 50px;
padding: 0.4rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 5px;
color: #e0e0e0;
font-size: 0.85rem;
font-family: inherit;
resize: vertical;
}
.notesEdit:focus {
outline: none;
border-color: #c9a84c;
}

View file

@ -0,0 +1,196 @@
import { useRef, useEffect } from "react";
import type { Character } from "../types";
import TalentList from "./TalentList";
import styles from "./InfoPanel.module.css";
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"];
const ALIGNMENTS = ["Lawful", "Neutral", "Chaotic"];
interface InfoPanelProps {
character: Character;
mode: "view" | "edit";
onUpdate: (id: number, data: Partial<Character>) => void;
onAddTalent: (
characterId: number,
data: {
name: string;
description: string;
effect?: Record<string, unknown>;
game_talent_id?: number | null;
},
) => void;
onRemoveTalent: (characterId: number, talentId: number) => void;
}
export default function InfoPanel({
character,
mode,
onUpdate,
onAddTalent,
onRemoveTalent,
}: InfoPanelProps) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
function handleField(field: string, value: string | number) {
if (typeof value === "string") {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdate(character.id, { [field]: value });
}, 400);
} else {
onUpdate(character.id, { [field]: value });
}
}
return (
<div className={styles.panel}>
<TalentList
talents={character.talents}
onAdd={(data) => onAddTalent(character.id, data)}
onRemove={(id) => onRemoveTalent(character.id, id)}
mode={mode}
/>
<div className={styles.sectionTitle} style={{ marginTop: "0.75rem" }}>
Info
</div>
{mode === "view" ? (
<div className={styles.infoGrid}>
{character.background && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Background</span>
<span className={styles.infoValue}>{character.background}</span>
</div>
)}
{character.deity && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Deity</span>
<span className={styles.infoValue}>{character.deity}</span>
</div>
)}
{character.languages && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Languages</span>
<span className={styles.infoValue}>{character.languages}</span>
</div>
)}
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Alignment</span>
<span className={styles.infoValue}>{character.alignment}</span>
</div>
{character.notes && (
<>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Notes</span>
</div>
<div className={styles.notes}>{character.notes}</div>
</>
)}
</div>
) : (
<div className={styles.infoGrid}>
<div className={styles.editRow}>
<div className={styles.field}>
<label className={styles.fieldLabel}>Class</label>
<select
className={styles.editSelect}
value={character.class}
onChange={(e) => handleField("class", e.target.value)}
>
{CLASSES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Ancestry</label>
<select
className={styles.editSelect}
value={character.ancestry}
onChange={(e) => handleField("ancestry", e.target.value)}
>
{ANCESTRIES.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
</div>
<div className={styles.editRow}>
<div className={styles.field}>
<label className={styles.fieldLabel}>Level</label>
<input
className={styles.editField}
type="number"
min={0}
value={character.level}
onChange={(e) => handleField("level", Number(e.target.value))}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Alignment</label>
<select
className={styles.editSelect}
value={character.alignment}
onChange={(e) => handleField("alignment", e.target.value)}
>
{ALIGNMENTS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Background</label>
<input
className={styles.editField}
value={character.background}
placeholder="Urchin..."
onChange={(e) => handleField("background", e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Deity</label>
<input
className={styles.editField}
value={character.deity}
placeholder="None..."
onChange={(e) => handleField("deity", e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Languages</label>
<input
className={styles.editField}
value={character.languages}
placeholder="Common, Elvish..."
onChange={(e) => handleField("languages", e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Notes</label>
<textarea
className={styles.notesEdit}
value={character.notes}
onChange={(e) => handleField("notes", e.target.value)}
placeholder="Freeform notes..."
/>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,25 @@
.value {
cursor: pointer;
border-bottom: 1px dashed transparent;
transition: border-color 0.15s;
}
.value:hover {
border-bottom-color: #c9a84c;
}
.input {
width: 3rem;
padding: 0.1rem 0.2rem;
background: #0f1a30;
border: 1px solid #c9a84c;
border-radius: 4px;
color: #e0e0e0;
font-size: inherit;
font-weight: inherit;
text-align: center;
}
.input:focus {
outline: none;
}

View file

@ -0,0 +1,66 @@
import { useState } from "react";
import styles from "./InlineNumber.module.css";
interface InlineNumberProps {
value: number;
onChange: (value: number) => void;
className?: string;
min?: number;
max?: number;
}
export default function InlineNumber({
value,
onChange,
className,
min,
max,
}: InlineNumberProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState("");
function startEdit() {
setEditValue(String(value));
setEditing(true);
}
function commit() {
setEditing(false);
const num = parseInt(editValue, 10);
if (isNaN(num)) return;
const clamped = max !== undefined ? Math.min(num, max) : num;
const final = min !== undefined ? Math.max(clamped, min) : clamped;
if (final !== value) onChange(final);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}
if (editing) {
return (
<input
className={styles.input}
type="number"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commit}
onKeyDown={handleKeyDown}
min={min}
max={max}
autoFocus
/>
);
}
return (
<span
className={`${styles.value} ${className || ""}`}
onClick={startEdit}
title="Click to edit"
>
{value}
</span>
);
}

View file

@ -0,0 +1,81 @@
.container {
position: relative;
}
.searchInput {
width: 100%;
padding: 0.5rem 0.75rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.85rem;
}
.searchInput:focus {
outline: none;
border-color: #c9a84c;
}
.dropdown {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
max-height: 220px;
overflow-y: auto;
background: #16213e;
border: 1px solid #444;
border-radius: 6px;
margin-bottom: 0.25rem;
z-index: 200;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
}
.group {
padding: 0.25rem 0;
}
.groupLabel {
font-size: 0.7rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
padding: 0.25rem 0.75rem;
letter-spacing: 0.05em;
}
.item {
display: flex;
justify-content: space-between;
padding: 0.35rem 0.75rem;
cursor: pointer;
font-size: 0.85rem;
}
.item:hover {
background: rgba(201, 168, 76, 0.15);
}
.itemName {
color: #e0e0e0;
}
.itemMeta {
color: #666;
font-size: 0.75rem;
}
.customOption {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.85rem;
color: #888;
font-style: italic;
border-top: 1px solid #333;
}
.customOption:hover {
background: rgba(201, 168, 76, 0.15);
color: #c9a84c;
}

View file

@ -0,0 +1,83 @@
import { useState, useEffect, useRef } from "react";
import { getGameItems } from "../api";
import type { GameItem } from "../types";
import styles from "./ItemPicker.module.css";
interface ItemPickerProps {
onSelect: (item: GameItem) => void;
onCustom: () => void;
onClose: () => void;
}
export default function ItemPicker({
onSelect,
onCustom,
onClose,
}: ItemPickerProps) {
const [items, setItems] = useState<GameItem[]>([]);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
getGameItems().then(setItems);
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const filtered = search
? items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase()))
: items;
const groups: Record<string, GameItem[]> = {};
for (const item of filtered) {
const key = item.type.charAt(0).toUpperCase() + item.type.slice(1) + "s";
if (!groups[key]) groups[key] = [];
groups[key].push(item);
}
return (
<div className={styles.container} ref={containerRef}>
<input
className={styles.searchInput}
type="text"
placeholder="Search items or type to filter..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
<div className={styles.dropdown}>
{Object.entries(groups).map(([groupName, groupItems]) => (
<div key={groupName} className={styles.group}>
<div className={styles.groupLabel}>{groupName}</div>
{groupItems.map((item) => (
<div
key={item.id}
className={styles.item}
onClick={() => onSelect(item)}
>
<span className={styles.itemName}>{item.name}</span>
<span className={styles.itemMeta}>
{item.slot_count > 0 ? `${item.slot_count} slot` : "—"}
</span>
</div>
))}
</div>
))}
<div className={styles.customOption} onClick={onCustom}>
Custom item...
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
.statGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
background: #0f1a30;
border: 1px solid #2a2a4a;
border-radius: 8px;
padding: 0.5rem 0.3rem;
position: relative;
}
.statName {
font-size: 0.7rem;
color: #c9a84c;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.15rem;
}
.statRow {
display: flex;
align-items: center;
gap: 0.3rem;
}
.modifier {
font-size: 1.3rem;
font-weight: 700;
color: #e0e0e0;
min-width: 2.2rem;
text-align: center;
}
.score {
font-size: 0.75rem;
color: #666;
background: #1a1a2e;
border-radius: 3px;
padding: 0 0.3rem;
margin-top: 0.15rem;
}
.btn {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid #444;
background: #16213e;
color: #e0e0e0;
cursor: pointer;
font-size: 0.8rem;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition:
border-color 0.15s,
color 0.15s;
}
.btn:hover {
border-color: #c9a84c;
color: #c9a84c;
}
.rollSpace {
width: 2.5rem;
}

View file

@ -0,0 +1,53 @@
import type { Stat } from "../types";
import { getModifier, formatModifier } from "../utils/modifiers";
import styles from "./StatBlock.module.css";
const STAT_ORDER = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
interface StatBlockProps {
stats: Stat[];
onStatChange: (statName: string, newValue: number) => void;
mode?: "view" | "edit";
}
export default function StatBlock({
stats,
onStatChange,
mode = "view",
}: StatBlockProps) {
const statMap = new Map(stats.map((s) => [s.stat_name, s.value]));
return (
<div className={styles.statGrid}>
{STAT_ORDER.map((name) => {
const value = statMap.get(name) ?? 10;
const mod = getModifier(value);
return (
<div key={name} className={styles.stat}>
<span className={styles.statName}>{name}</span>
<div className={styles.statRow}>
{mode === "edit" && (
<button
className={styles.btn}
onClick={() => onStatChange(name, value - 1)}
>
</button>
)}
<span className={styles.modifier}>{formatModifier(mod)}</span>
{mode === "edit" && (
<button
className={styles.btn}
onClick={() => onStatChange(name, value + 1)}
>
+
</button>
)}
</div>
<span className={styles.score}>{value}</span>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,21 @@
.panel {
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 0.75rem;
}
.sectionTitle {
font-size: 0.8rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.separator {
border: none;
border-top: 1px solid #2a2a4a;
margin: 0.75rem 0;
}

View file

@ -0,0 +1,34 @@
import type { Character } from "../types";
import { generateAttacks } from "../utils/derived-attacks";
import StatBlock from "./StatBlock";
import AttackBlock from "./AttackBlock";
import styles from "./StatsPanel.module.css";
interface StatsPanelProps {
character: Character;
mode: "view" | "edit";
onStatChange: (characterId: number, statName: string, value: number) => void;
}
export default function StatsPanel({
character,
mode,
onStatChange,
}: StatsPanelProps) {
const attacks = generateAttacks(character);
return (
<div className={styles.panel}>
<div className={styles.sectionTitle}>Ability Scores</div>
<StatBlock
stats={character.stats}
onStatChange={(statName, value) =>
onStatChange(character.id, statName, value)
}
mode={mode}
/>
<hr className={styles.separator} />
<AttackBlock attacks={attacks} />
</div>
);
}

View file

@ -0,0 +1,103 @@
.section {
margin-bottom: 1rem;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.sectionTitle {
font-size: 0.9rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.item {
display: flex;
justify-content: space-between;
align-items: flex-start;
background: #0f1a30;
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.itemInfo {
flex: 1;
}
.itemName {
font-weight: 600;
font-size: 0.9rem;
}
.itemDesc {
font-size: 0.75rem;
color: #888;
}
.removeBtn {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 1rem;
padding: 0.25rem;
}
.removeBtn:hover {
color: #e74c3c;
}
.addForm {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
.addInput {
flex: 1;
padding: 0.4rem 0.6rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.85rem;
}
.addInput:focus {
outline: none;
border-color: #c9a84c;
}
.addBtn {
padding: 0.4rem 0.75rem;
background: #c9a84c;
color: #1a1a2e;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
}
.addBtn:hover {
background: #d4b65a;
}
.empty {
font-size: 0.8rem;
color: #555;
font-style: italic;
padding: 0.5rem 0;
}

View file

@ -0,0 +1,131 @@
import { useState } from "react";
import type { Talent, GameTalent } from "../types";
import TalentPicker from "./TalentPicker";
import styles from "./TalentList.module.css";
interface TalentListProps {
talents: Talent[];
onAdd: (data: {
name: string;
description: string;
effect?: Record<string, unknown>;
game_talent_id?: number | null;
}) => void;
onRemove: (talentId: number) => void;
mode?: "view" | "edit";
}
export default function TalentList({
talents,
onAdd,
onRemove,
mode = "view",
}: TalentListProps) {
const [showPicker, setShowPicker] = useState(false);
const [showCustom, setShowCustom] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
function handleSelectPredefined(talent: GameTalent) {
onAdd({
name: talent.name,
description: talent.description,
effect: talent.effect,
game_talent_id: talent.id,
});
setShowPicker(false);
}
function handleCustomSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
onAdd({ name: name.trim(), description: description.trim() });
setName("");
setDescription("");
setShowCustom(false);
}
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Talents</span>
</div>
<div className={styles.list}>
{talents.length === 0 && <p className={styles.empty}>No talents yet</p>}
{talents.map((t) => (
<div key={t.id} className={styles.item}>
<div className={styles.itemInfo}>
<div className={styles.itemName}>{t.name}</div>
{t.description && (
<div className={styles.itemDesc}>{t.description}</div>
)}
</div>
{mode === "edit" && (
<button
className={styles.removeBtn}
onClick={() => onRemove(t.id)}
title="Remove"
>
</button>
)}
</div>
))}
</div>
{mode === "edit" && (
<div className={styles.addForm}>
{showPicker && (
<TalentPicker
onSelect={handleSelectPredefined}
onCustom={() => {
setShowPicker(false);
setShowCustom(true);
}}
onClose={() => setShowPicker(false)}
/>
)}
{showCustom ? (
<form onSubmit={handleCustomSubmit}>
<input
className={styles.addInput}
type="text"
placeholder="Talent name..."
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
<input
className={styles.addInput}
type="text"
placeholder="Description (optional)..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div>
<button className={styles.addBtn} type="submit">
+ Add
</button>
<button
className={styles.addBtn}
type="button"
onClick={() => setShowCustom(false)}
>
Cancel
</button>
</div>
</form>
) : (
!showPicker && (
<button
className={styles.addBtn}
onClick={() => setShowPicker(true)}
>
+ Add Talent
</button>
)
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,82 @@
.container {
position: relative;
}
.searchInput {
width: 100%;
padding: 0.5rem 0.75rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.85rem;
}
.searchInput:focus {
outline: none;
border-color: #c9a84c;
}
.dropdown {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
max-height: 220px;
overflow-y: auto;
background: #16213e;
border: 1px solid #444;
border-radius: 6px;
margin-bottom: 0.25rem;
z-index: 200;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
}
.group {
padding: 0.25rem 0;
}
.groupLabel {
font-size: 0.7rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
padding: 0.25rem 0.75rem;
letter-spacing: 0.05em;
}
.item {
display: flex;
flex-direction: column;
padding: 0.35rem 0.75rem;
cursor: pointer;
font-size: 0.85rem;
}
.item:hover {
background: rgba(201, 168, 76, 0.15);
}
.itemName {
color: #e0e0e0;
font-weight: 600;
}
.itemDesc {
color: #888;
font-size: 0.75rem;
}
.customOption {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.85rem;
color: #888;
font-style: italic;
border-top: 1px solid #333;
}
.customOption:hover {
background: rgba(201, 168, 76, 0.15);
color: #c9a84c;
}

View file

@ -0,0 +1,84 @@
import { useState, useEffect, useRef } from "react";
import { getGameTalents } from "../api";
import type { GameTalent } from "../types";
import styles from "./TalentPicker.module.css";
interface TalentPickerProps {
onSelect: (talent: GameTalent) => void;
onCustom: () => void;
onClose: () => void;
}
export default function TalentPicker({
onSelect,
onCustom,
onClose,
}: TalentPickerProps) {
const [talents, setTalents] = useState<GameTalent[]>([]);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
getGameTalents().then(setTalents);
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const filtered = search
? talents.filter(
(t) =>
t.name.toLowerCase().includes(search.toLowerCase()) ||
t.source.toLowerCase().includes(search.toLowerCase()),
)
: talents;
const groups: Record<string, GameTalent[]> = {};
for (const talent of filtered) {
if (!groups[talent.source]) groups[talent.source] = [];
groups[talent.source].push(talent);
}
return (
<div className={styles.container} ref={containerRef}>
<input
className={styles.searchInput}
type="text"
placeholder="Search talents..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
<div className={styles.dropdown}>
{Object.entries(groups).map(([source, groupTalents]) => (
<div key={source} className={styles.group}>
<div className={styles.groupLabel}>{source}</div>
{groupTalents.map((talent) => (
<div
key={talent.id}
className={styles.item}
onClick={() => onSelect(talent)}
>
<span className={styles.itemName}>{talent.name}</span>
<span className={styles.itemDesc}>{talent.description}</span>
</div>
))}
</div>
))}
<div className={styles.customOption} onClick={onCustom}>
Custom talent...
</div>
</div>
</div>
);
}

9
client/src/main.tsx Normal file
View file

@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

View file

@ -0,0 +1,94 @@
.container {
max-width: 600px;
margin: 0 auto;
}
.campaignGrid {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.campaignCard {
display: flex;
align-items: center;
justify-content: space-between;
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 1rem 1.25rem;
cursor: pointer;
transition: border-color 0.15s;
}
.campaignCard:hover {
border-color: #c9a84c;
}
.campaignName {
font-size: 1.1rem;
font-weight: 600;
}
.campaignDate {
font-size: 0.8rem;
color: #888;
}
.deleteBtn {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 1.2rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.deleteBtn:hover {
color: #e74c3c;
background: rgba(231, 76, 60, 0.1);
}
.createForm {
display: flex;
gap: 0.5rem;
}
.createInput {
flex: 1;
padding: 0.6rem 1rem;
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
color: #e0e0e0;
font-size: 1rem;
}
.createInput:focus {
outline: none;
border-color: #c9a84c;
}
.createBtn {
padding: 0.6rem 1.25rem;
background: #c9a84c;
color: #1a1a2e;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 1rem;
}
.createBtn:hover {
background: #d4b65a;
}
.empty {
text-align: center;
color: #666;
padding: 3rem 0;
font-style: italic;
}

View file

@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getCampaigns, createCampaign, deleteCampaign } from "../api";
import type { Campaign } from "../types";
import styles from "./CampaignList.module.css";
export default function CampaignList() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [newName, setNewName] = useState("");
const navigate = useNavigate();
useEffect(() => {
getCampaigns().then(setCampaigns);
}, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
try {
const campaign = await createCampaign(newName.trim());
setCampaigns((prev) => [campaign, ...prev]);
setNewName("");
} catch (err) {
console.error("Failed to create campaign:", err);
}
}
async function handleDelete(e: React.MouseEvent, id: number) {
e.stopPropagation();
try {
await deleteCampaign(id);
setCampaigns((prev) => prev.filter((c) => c.id !== id));
} catch (err) {
console.error("Failed to delete campaign:", err);
}
}
return (
<div className={styles.container}>
<form className={styles.createForm} onSubmit={handleCreate}>
<input
className={styles.createInput}
type="text"
placeholder="New campaign name..."
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<button className={styles.createBtn} type="submit">
+ Create
</button>
</form>
<div className={styles.campaignGrid}>
{campaigns.length === 0 && (
<p className={styles.empty}>No campaigns yet. Create one above!</p>
)}
{campaigns.map((c) => (
<div
key={c.id}
className={styles.campaignCard}
onClick={() => navigate(`/campaign/${c.id}`)}
>
<div>
<div className={styles.campaignName}>{c.name}</div>
<div className={styles.campaignDate}>
{new Date(c.created_at).toLocaleDateString()}
</div>
</div>
<button
className={styles.deleteBtn}
onClick={(e) => handleDelete(e, c.id)}
title="Delete campaign"
>
</button>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,163 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.backLink {
color: #888;
text-decoration: none;
font-size: 0.9rem;
}
.backLink:hover {
color: #c9a84c;
}
.campaignName {
font-size: 1.5rem;
font-weight: 700;
color: #c9a84c;
}
.addBtn {
padding: 0.5rem 1rem;
background: #c9a84c;
color: #1a1a2e;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
}
.addBtn:hover {
background: #d4b65a;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
@media (max-width: 1100px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.grid {
grid-template-columns: 1fr;
}
}
.empty {
text-align: center;
color: #666;
padding: 3rem 0;
font-style: italic;
grid-column: 1 / -1;
}
.createModal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.createForm {
background: #1a1a2e;
border: 1px solid #333;
border-radius: 12px;
padding: 1.5rem;
width: 100%;
max-width: 400px;
}
.createTitle {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 1rem;
color: #c9a84c;
}
.formField {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.formLabel {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
font-weight: 600;
}
.formInput {
padding: 0.5rem 0.75rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.9rem;
}
.formInput:focus {
outline: none;
border-color: #c9a84c;
}
.formSelect {
padding: 0.5rem 0.75rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.9rem;
}
.formActions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
}
.formBtn {
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
}
.formBtnPrimary {
background: #c9a84c;
color: #1a1a2e;
border: none;
}
.formBtnPrimary:hover {
background: #d4b65a;
}
.formBtnSecondary {
background: transparent;
color: #888;
border: 1px solid #333;
}
.formBtnSecondary:hover {
border-color: #888;
color: #e0e0e0;
}

View file

@ -0,0 +1,383 @@
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import socket from "../socket";
import {
getCharacters,
createCharacter,
updateCharacter,
deleteCharacter,
updateStat,
addGear,
removeGear,
addTalent,
removeTalent,
} from "../api";
import type { Character, Gear, Talent, GameItem } from "../types";
import CharacterCard from "../components/CharacterCard";
import CharacterDetail from "../components/CharacterDetail";
import styles from "./CampaignView.module.css";
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"];
export default function CampaignView() {
const { id } = useParams<{ id: string }>();
const campaignId = Number(id);
const [characters, setCharacters] = useState<Character[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [newChar, setNewChar] = useState({
name: "",
class: "Fighter",
ancestry: "Human",
hp_max: 1,
});
// Fetch characters and join socket room
useEffect(() => {
getCharacters(campaignId).then(setCharacters);
socket.emit("join-campaign", String(campaignId));
return () => {
socket.emit("leave-campaign", String(campaignId));
};
}, [campaignId]);
// 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,
),
);
}
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);
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);
};
}, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newChar.name.trim()) return;
try {
await createCharacter(campaignId, newChar);
setNewChar({ name: "", class: "Fighter", ancestry: "Human", hp_max: 1 });
setShowCreate(false);
} catch (err) {
console.error("Failed to create character:", err);
}
}
async function handleHpChange(characterId: number, hp: number) {
await updateCharacter(characterId, { hp_current: hp });
}
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);
}
const selectedCharacter = characters.find((c) => c.id === selectedId) ?? null;
return (
<div>
<div className={styles.header}>
<Link to="/" className={styles.backLink}>
Campaigns
</Link>
<span className={styles.campaignName}>Campaign</span>
<button className={styles.addBtn} onClick={() => setShowCreate(true)}>
+ Add Character
</button>
</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}
onStatChange={handleStatChange}
onClick={setSelectedId}
/>
))}
</div>
{selectedCharacter && (
<CharacterDetail
character={selectedCharacter}
onUpdate={handleUpdate}
onStatChange={handleStatChange}
onAddGearFromItem={handleAddGearFromItem}
onAddGearCustom={handleAddGearCustom}
onRemoveGear={handleRemoveGear}
onAddTalent={handleAddTalent}
onRemoveTalent={handleRemoveTalent}
onDelete={handleDelete}
onClose={() => setSelectedId(null)}
/>
)}
{showCreate && (
<div
className={styles.createModal}
onClick={() => setShowCreate(false)}
>
<form
className={styles.createForm}
onClick={(e) => e.stopPropagation()}
onSubmit={handleCreate}
>
<div className={styles.createTitle}>New Character</div>
<div className={styles.formField}>
<label className={styles.formLabel}>Name</label>
<input
className={styles.formInput}
type="text"
value={newChar.name}
onChange={(e) =>
setNewChar({ ...newChar, name: e.target.value })
}
autoFocus
/>
</div>
<div className={styles.formField}>
<label className={styles.formLabel}>Class</label>
<select
className={styles.formSelect}
value={newChar.class}
onChange={(e) =>
setNewChar({ ...newChar, class: e.target.value })
}
>
{CLASSES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className={styles.formField}>
<label className={styles.formLabel}>Ancestry</label>
<select
className={styles.formSelect}
value={newChar.ancestry}
onChange={(e) =>
setNewChar({ ...newChar, ancestry: e.target.value })
}
>
{ANCESTRIES.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<div className={styles.formField}>
<label className={styles.formLabel}>Max HP</label>
<input
className={styles.formInput}
type="number"
min={1}
value={newChar.hp_max}
onChange={(e) =>
setNewChar({ ...newChar, hp_max: Number(e.target.value) })
}
/>
</div>
<div className={styles.formActions}>
<button
type="button"
className={`${styles.formBtn} ${styles.formBtnSecondary}`}
onClick={() => setShowCreate(false)}
>
Cancel
</button>
<button
type="submit"
className={`${styles.formBtn} ${styles.formBtnPrimary}`}
>
Create
</button>
</div>
</form>
</div>
)}
</div>
);
}

8
client/src/socket.ts Normal file
View file

@ -0,0 +1,8 @@
import { io } from "socket.io-client";
const socket = io("/", {
autoConnect: true,
reconnection: true,
});
export default socket;

86
client/src/types.ts Normal file
View file

@ -0,0 +1,86 @@
export interface Campaign {
id: number;
name: string;
created_by: string;
created_at: string;
}
export interface Stat {
stat_name: string;
value: number;
}
export interface Gear {
id: number;
character_id: number;
name: string;
type: "weapon" | "armor" | "gear" | "spell";
slot_count: number;
properties: Record<string, unknown>;
effects: Record<string, unknown>;
game_item_id: number | null;
}
export interface Talent {
id: number;
character_id: number;
name: string;
description: string;
effect: Record<string, unknown>;
game_talent_id: number | null;
}
export interface Character {
id: number;
campaign_id: number;
created_by: string;
name: string;
class: string;
ancestry: string;
level: number;
xp: number;
hp_current: number;
hp_max: number;
ac: number;
alignment: string;
title: string;
notes: string;
background: string;
deity: string;
languages: string;
gp: number;
sp: number;
cp: number;
gear_slots_max: number;
overrides: Record<string, unknown>;
stats: Stat[];
gear: Gear[];
talents: Talent[];
}
export interface GameItem {
id: number;
name: string;
type: "weapon" | "armor" | "gear";
slot_count: number;
effects: Record<string, unknown>;
properties: Record<string, unknown>;
}
export interface GameTalent {
id: number;
name: string;
source: string;
description: string;
effect: Record<string, unknown>;
}
export interface AttackLine {
name: string;
modifier: number;
modifierStr: string;
damage: string;
tags: string[];
isTalent: boolean;
description?: string;
}

View file

@ -0,0 +1,47 @@
import type { Character } from "../types";
import { getModifier } from "./modifiers";
export interface AcBreakdown {
calculated: number;
override: number | null;
effective: number;
source: string;
}
export function calculateAC(character: Character): AcBreakdown {
const dexMod = getModifier(
character.stats.find((s) => s.stat_name === "DEX")?.value ?? 10,
);
let base = 10 + dexMod;
let source = "Unarmored";
// Find equipped armor (not shields)
const armor = character.gear.find(
(g) => g.type === "armor" && g.effects.ac_base !== undefined,
);
if (armor) {
const acBase = armor.effects.ac_base as number;
const acDex = armor.effects.ac_dex as boolean;
base = acDex ? acBase + dexMod : acBase;
source = armor.name;
}
// Find shield
const shield = character.gear.find(
(g) => g.type === "armor" && g.effects.ac_bonus !== undefined,
);
if (shield) {
base += shield.effects.ac_bonus as number;
source += " + " + shield.name;
}
const override = (character.overrides?.ac as number | undefined) ?? null;
return {
calculated: base,
override: override,
effective: override ?? base,
source,
};
}

View file

@ -0,0 +1,48 @@
import type { Character, AttackLine } from "../types";
import { getModifier, formatModifier } from "./modifiers";
export function generateAttacks(character: Character): AttackLine[] {
const strMod = getModifier(
character.stats.find((s) => s.stat_name === "STR")?.value ?? 10,
);
const dexMod = getModifier(
character.stats.find((s) => s.stat_name === "DEX")?.value ?? 10,
);
const attacks: AttackLine[] = [];
for (const gear of character.gear) {
if (gear.type !== "weapon") continue;
const effects = gear.effects;
const damage = (effects.damage as string) || "1d4";
const tags: string[] = [];
let mod: number;
if (effects.finesse) {
mod = Math.max(strMod, dexMod);
tags.push("F");
} else if (effects.ranged) {
mod = dexMod;
} else {
mod = strMod;
}
const bonus = (effects.bonus as number) || 0;
mod += bonus;
if (effects.two_handed) tags.push("2H");
if (effects.thrown) tags.push("T");
attacks.push({
name: gear.name.toUpperCase(),
modifier: mod,
modifierStr: formatModifier(mod),
damage,
tags,
isTalent: false,
});
}
return attacks;
}

View file

@ -0,0 +1,22 @@
const MODIFIER_TABLE: [number, number][] = [
[3, -4],
[5, -3],
[7, -2],
[9, -1],
[11, 0],
[13, 1],
[15, 2],
[17, 3],
[18, 4],
];
export function getModifier(score: number): number {
for (const [max, mod] of MODIFIER_TABLE) {
if (score <= max) return mod;
}
return 4;
}
export function formatModifier(mod: number): string {
return mod >= 0 ? `+${mod}` : `${mod}`;
}

1
client/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
client/tsconfig.json Normal file
View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src"]
}

16
client/vite.config.ts Normal file
View file

@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://localhost:3000",
"/socket.io": {
target: "http://localhost:3000",
ws: true,
},
},
},
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,164 @@
# Shadowdark Character Sheet Manager — Design Spec
## Overview
A web app for managing Shadowdark RPG character sheets in real-time. Players in a campaign can view and edit characters simultaneously, with changes syncing live across all connected clients. Hosted locally and exposed via ngrok for group access.
## Tech Stack
- **Frontend:** React + Vite (TypeScript)
- **Backend:** Node.js + Express + Socket.IO
- **Database:** SQLite (single file, zero config)
- **Project location:** `/Users/aaron.wood/workspace/shadowdark/`
- **Structure:** `client/` and `server/` directories
## Data Model
### campaigns
| Column | Type | Notes |
| ---------- | ------- | -------------------------- |
| id | INTEGER | Primary key, autoincrement |
| name | TEXT | Campaign name |
| created_by | TEXT | For future auth |
| created_at | TEXT | ISO timestamp |
### characters
| Column | Type | Notes |
| ----------- | ------- | -------------------------------------------------- |
| id | INTEGER | Primary key, autoincrement |
| campaign_id | INTEGER | FK to campaigns |
| created_by | TEXT | For future auth |
| name | TEXT | Character name |
| class | TEXT | Fighter / Priest / Thief / Wizard |
| ancestry | TEXT | Human / Elf / Dwarf / Halfling / Goblin / Half-Orc |
| level | INTEGER | Default 1 |
| xp | INTEGER | Default 0 |
| hp_current | INTEGER | |
| hp_max | INTEGER | |
| ac | INTEGER | |
| alignment | TEXT | Lawful / Neutral / Chaotic |
| title | TEXT | Optional, e.g. "the Brave" |
| notes | TEXT | Freeform notes |
### character_stats
| Column | Type | Notes |
| ------------ | ------- | --------------------------------- |
| character_id | INTEGER | FK to characters |
| stat_name | TEXT | STR / DEX / CON / INT / WIS / CHA |
| value | INTEGER | Raw score (3-18 typically) |
One row per stat per character. Ability modifiers are derived, not stored:
| Score | Modifier |
| ----- | -------- |
| 1-3 | -4 |
| 4-5 | -3 |
| 6-7 | -2 |
| 8-9 | -1 |
| 10-11 | +0 |
| 12-13 | +1 |
| 14-15 | +2 |
| 16-17 | +3 |
| 18 | +4 |
### character_gear
| Column | Type | Notes |
| ------------ | ------- | ----------------------------------------------------- |
| id | INTEGER | Primary key, autoincrement |
| character_id | INTEGER | FK to characters |
| name | TEXT | Item name |
| type | TEXT | weapon / armor / gear / spell |
| slot_count | INTEGER | Gear slots used (Shadowdark inventory system) |
| properties | TEXT | JSON — e.g. `{"damage":"1d6","melee":true,"bonus":1}` |
Spells are stored as gear entries with `type: "spell"` and a `tier` property in the JSON.
### character_talents
| Column | Type | Notes |
| ------------ | ------- | ----------------------------------------- |
| id | INTEGER | Primary key, autoincrement |
| character_id | INTEGER | FK to characters |
| name | TEXT | Talent name |
| description | TEXT | Flavor/rules text |
| effect | TEXT | JSON — mechanical bonuses for dice roller |
## UI Layout
### Campaign List (home page)
- Grid/list of existing campaigns
- "+ New Campaign" button
- Click a campaign to enter it
### Campaign View (main screen)
- Campaign name header
- Responsive character card grid:
- Desktop: 4 cards across
- Tablet: 2 cards across
- Mobile: 1 card, stacked
- Cards flow naturally — 6 characters = 4 + 2 rows on desktop, just scroll
- "+ Add Character" button
- Future: dice log panel (right side on desktop, bottom drawer on mobile)
### Character Card (in the grid)
- Name, class, ancestry, level header
- HP bar with +/- buttons (current / max)
- AC display
- Six stats in compact 3x2 layout: score, modifier, +/- buttons
- Gear summary (slots used / total)
- Click to expand into full detail view
### Character Detail (expanded / modal)
- Full stat editing with +/- buttons
- Gear/inventory management: add/remove items with type and properties
- Talents list: add/remove with name, description, effect
- Spell list (for Priests/Wizards — gear items with type "spell")
- Notes freeform text field
- Future: dice roll buttons next to weapons/spells
## Real-time Sync
- Client joins a Socket.IO room per campaign (room ID = campaign ID)
- Mutation flow: client -> server API -> save to SQLite -> broadcast to room
- Other clients receive update and patch local state (no full reload)
- Optimistic UI: editing client updates immediately, server confirms
- On reconnect, client fetches latest full state from server
## Auth (v1: none, future-ready)
- v1 is open free-for-all: anyone with the link can create/edit anything
- `created_by` fields exist in the schema for future use
- Migration path to light auth (name/pin per character) or GM controls requires adding middleware, not restructuring
## Future: Dice Rolling (not in v1)
Design accommodations for future dice rolling:
- **Shared dice log panel** — layout reserves space for it
- **Inline roll buttons** — next to weapons/spells and stats
- **Server-side rolls** — server calculates d20 + ability modifier + gear/talent bonuses, broadcasts result
- **Log entries** — show who rolled, what for, full breakdown (e.g. "Kira attacks with Longsword +1: d20(14) + STR(+2) + weapon(+1) = 17"), and timestamp
- **Data model supports it**`character_gear.properties` and `character_talents.effect` JSON fields carry the mechanical data the roller needs
## Deployment
- Run `server/` on localhost with a configured port
- `client/` built and served by Express (or Vite dev server during development)
- Expose via ngrok for friends to access
- SQLite file lives in `server/data/shadowdark.db`
## Out of Scope for v1
- Authentication / permissions
- Dice rolling
- Character import/export
- Multiple themes / dark mode
- Mobile native app

View file

@ -0,0 +1,235 @@
# V2: Item Database + Derived Stats — Design Spec
## Overview
Add a predefined item database from the Shadowdark core rules, auto-calculated derived stats (AC, attacks), manual override system, missing character fields, a multi-column detail layout, and gear slot visualization. Builds on the existing v1 app.
## 1. Predefined Item Database
### game_items table
| Column | Type | Notes |
| ---------- | ------- | -------------------------------------------- |
| id | INTEGER | Primary key, autoincrement |
| name | TEXT | Item name |
| type | TEXT | weapon / armor / gear |
| slot_count | INTEGER | Gear slots used (0 for coins, backpack, etc) |
| effects | TEXT | JSON — mechanical effects (see below) |
| properties | TEXT | JSON — display metadata (tags, notes) |
Seeded on first run from a static data file. Not editable by users — it's a reference list.
### Effects JSON examples
**Weapons:**
```json
{ "damage": "1d8", "melee": true, "stat": "STR" }
{ "damage": "1d4", "ranged": true, "stat": "DEX", "range": "near" }
{ "damage": "1d6", "melee": true, "stat": "STR", "finesse": true }
{ "damage": "1d10", "melee": true, "stat": "STR", "two_handed": true }
```
**Armor:**
```json
{ "ac_base": 11, "ac_dex": true }
{ "ac_base": 15, "ac_dex": false }
{ "ac_bonus": 2 }
```
**Gear:** (no mechanical effects)
```json
{}
```
### Properties JSON examples
```json
{ "tags": ["two-handed"], "note": "Disadvantage on stealth" }
{ "tags": ["finesse", "thrown"], "range": "close" }
```
### Seed Data (~35 items)
**Weapons (~15):**
Bastard sword, Club, Crossbow, Dagger, Greataxe, Greatsword, Javelin, Longbow, Longsword, Mace, Shortbow, Shortsword, Spear, Staff, Warhammer
**Armor (~5):**
Leather armor, Chainmail, Plate mail, Shield, Mithral chainmail
**Gear (~15):**
Arrows/bolts (20), Backpack, Caltrops, Climbing gear, Crowbar, Flask/bottle, Flint and steel, Grappling hook, Iron spikes (10), Lantern, Mirror, Oil flask, Rations, Rope (60ft), Thieves' tools, Torch
### UI for Adding Gear
When clicking "+ Add" in the gear section:
1. A searchable dropdown appears with all predefined items grouped by type (Weapons | Armor | Gear)
2. Selecting an item auto-fills name, type, slot_count, and properties/effects
3. A "Custom..." option at the bottom opens the existing manual form
4. For predefined items, user can still edit the name (e.g. "Longsword" → "Longsword +1") and adjust properties before confirming
## 2. Auto-Calculated Derived Stats
### AC Calculation
Priority chain:
1. **Unarmored:** 10 + DEX modifier
2. **Armor equipped:** armor's `ac_base` + DEX modifier (if `ac_dex: true`) or just `ac_base`
3. **Shield equipped:** adds `ac_bonus` on top of armor/unarmored
4. Only one armor item allowed — if a second armor is added, a confirmation dialog asks "Replace {current armor} with {new armor}?" Yes replaces (removes old from gear), No cancels
Calculated client-side from character stats + gear effects. Not stored in DB — derived on render.
### Attack Lines
Auto-generated for each weapon in the character's gear:
- **Melee weapons:** `LONGSWORD: +{STR mod + bonus}, {damage die}`
- **Ranged weapons:** `SHORTBOW: +{DEX mod + bonus}, {damage die}`
- **Finesse weapons:** use higher of STR/DEX modifier
- **Tags:** `(2H)` for two-handed, `(F)` for finesse
- Format matches paper sheet: `SHORTSWORD: +0, 1d6`
Talents with attack-related effects (like Backstab) appear as additional lines below weapon attacks, showing their description.
Calculated client-side. Not stored.
### Future Dice Rolling Accommodation
Each attack line reserves space on the right side for a roll button. Not implemented now — just layout space. The attacks section heading will also have room for a general "roll" area.
## 3. Override System
### character_overrides column
Add a `overrides` TEXT column (JSON) to the `characters` table. Default: `'{}'`.
Structure:
```json
{
"ac": 18
}
```
When a field has an override:
- The override value is used instead of the auto-calculated value
- A small indicator icon appears next to the field
- Hovering/clicking the indicator shows the auto-calculated value
- Clicking "revert" removes the override and returns to auto-calculated
For v2, only AC is overridable. The system is extensible — more fields can be added later.
## 4. Missing Character Fields
### Schema additions to characters table
| Column | Type | Notes |
| -------------- | ------- | ---------------------------- |
| background | TEXT | Default '' |
| deity | TEXT | Default '' |
| languages | TEXT | Comma-separated, default '' |
| gp | INTEGER | Gold pieces, default 0 |
| sp | INTEGER | Silver pieces, default 0 |
| cp | INTEGER | Copper pieces, default 0 |
| gear_slots_max | INTEGER | Default 10 |
| overrides | TEXT | JSON overrides, default '{}' |
### XP Threshold Display
Shadowdark XP thresholds: 10 XP per level (level 2 = 10, level 3 = 20, etc.). Derived from `level * 10`. Display as "XP: 6 / 10" on the card and detail view. Not a new DB field.
## 5. Multi-Column Detail Layout
Replace the current 700px single-column modal with a wider (max-width: 1100px) multi-column layout.
### Desktop (1100px+) — 3 columns
**Left column — Identity & Vitals:**
- Name, title
- Class, ancestry, level
- Background, deity
- Alignment
- HP bar with +/- (current / max)
- AC display (with override indicator)
- XP (current / threshold)
**Center column — Combat & Stats:**
- Ability scores (3x2 StatBlock grid)
- Attacks section (auto-generated weapon lines + relevant talents)
- (Future: dice roll area)
**Right column — Abilities & Notes:**
- Talents/Spells list
- Languages
- Notes textarea
**Full-width below columns:**
- Gear/Inventory section — table with slot count, type, currency row
- Gear slot counter: "Slots: 7 / 10"
- "+ Add Gear" with predefined item dropdown
### Tablet (768-1100px) — 2 columns
Left: identity + stats + attacks. Right: talents + notes. Gear full-width below.
### Mobile (<768px)
Single column, stacked vertically (similar to current).
### Still a Modal
Overlay modal, but wider. Click outside or X to close. All the same real-time sync behavior.
## 6. Gear Slot Visualization
### Slot Counter
- Header of gear section: "Slots: 7 / 10" with visual indicator
- Color coding: green (normal), yellow (8-9 of max), red (at or over max)
- `gear_slots_max` stored on character, default 10, editable in detail view
### Gear Table
Each gear row shows:
- Item name
- Type badge (weapon/armor/gear/spell)
- Slot count (or "—" for 0-slot items)
- Remove button
Currency displayed as a compact "GP / SP / CP" row with +/- or direct input, separate from gear slots.
## 7. Data Flow
- Predefined items are read-only reference data in `game_items` table
- When a predefined item is added to a character, a copy is made in `character_gear` with the item's data. The copy can be modified (renamed, bonus added, etc.) without affecting the reference.
- `game_item_id` nullable FK on `character_gear` tracks which predefined item it came from (if any). Custom items have null.
- AC and attacks are calculated client-side from character state (stats + gear + talents + overrides). They are not stored in the database.
- The server broadcasts gear/stat changes; other clients recalculate derived values locally.
## 8. Schema Changes Summary
**New table:** `game_items` (id, name, type, slot_count, effects, properties)
**Alter `characters`:** add columns — background, deity, languages, gp, sp, cp, gear_slots_max, overrides
**Alter `character_gear`:** add column — game_item_id (nullable INTEGER, FK to game_items)
## Out of Scope
- Dice rolling (layout accommodates it)
- Spell attacks / spell system
- Visual theme overhaul
- Authentication
- Drag-and-drop inventory management
- Item editing in the game_items reference table

View file

@ -0,0 +1,161 @@
# View/Edit Mode Redesign — Design Spec
## Overview
Redesign the CharacterDetail modal with a view/edit mode split. View mode is a clean, read-only character sheet for gameplay. Edit mode enables full editing for character building and level-ups. The layout follows the "Modern Card Panels" design: header banner with vitals, three content panels below.
## 1. View Mode (Default)
### Header Banner
Full-width banner at top of modal:
- **Left side:** Character name + title, class/ancestry/level
- **Right side:** Three vital displays:
- **HP:** large number with +/- buttons (live-editable)
- **AC:** large number, display only (derived from gear)
- **XP:** number with +/- buttons (live-editable), shows threshold (e.g. "6 / 10")
### Three Panels
**Left Panel — Ability Scores + Attacks**
- Ability scores as a compact 2-column list: label + modifier (e.g. "STR +0"). Score value shown smaller. Space reserved on the right of each stat for a future dice roll button.
- Visual separator (line or spacing) between stats and attacks.
- Attacks section: auto-generated weapon lines (e.g. "SHORTSWORD: +0, 1d6"). Talent-based attack modifiers shown below as italic text. Space reserved on the right of each attack for a future dice roll button.
**Center Panel — Talents + Info**
- Talents list with name and description for each. Read-only (no add/remove buttons).
- Info section below talents:
- Background
- Deity
- Languages
- Alignment
- Notes (displayed as plain text, not a textarea)
**Right Panel — Gear + Currency**
- Gear list as a compact table. Each row shows:
- Item name
- Small type icon: sword (weapon), shield (armor), backpack (gear), sparkle (spell) — using unicode or simple CSS icons, not an icon library
- Slot count
- No delete buttons in view mode
- Slot counter in header: "Slots: 4 / 10" with color coding (green/yellow/red)
- Currency row with +/- buttons for GP, SP, CP (live-editable)
### View Mode Principles
- No input fields, dropdowns, or form elements (except HP/XP/currency quick controls)
- Everything is plain display text
- Clean, readable, looks like a proper character sheet
- Real-time sync still works — changes from other clients update the display
## 2. Edit Mode
### Toggle
- "Edit" button in the header bar, next to the close (X) button
- When in edit mode, button text changes to "Done"
- Clicking "Done" returns to view mode
### What Changes in Edit Mode
**Header:**
- Name and title become editable input fields
**Left Panel — Ability Scores + Attacks:**
- Ability scores get +/- buttons for incrementing/decrementing
- Attacks remain auto-generated (read-only) since they derive from gear + stats
**Center Panel — Talents + Info:**
- Talents get add (name + description form) and remove (X button) controls
- Class/ancestry/level/alignment fields appear as editable controls (these are displayed as read-only text in the header in view mode, and editable here in the Info panel in edit mode)
- Background, deity, languages become editable text inputs
- Alignment becomes a dropdown (Lawful/Neutral/Chaotic)
- Class becomes a dropdown (Fighter/Priest/Thief/Wizard)
- Ancestry becomes a dropdown (Human/Elf/Dwarf/Halfling/Goblin/Half-Orc)
- Level becomes a number input
- Notes becomes an editable textarea
**Right Panel — Gear:**
- "+ Add Gear" button appears with ItemPicker (searchable predefined item dropdown)
- Delete (X) buttons appear on each gear row
- Gear slots max becomes editable
- Currency fields become direct number inputs (replacing +/- buttons)
**Additional:**
- AC click-to-override becomes available
- "Delete Character" button appears at the bottom of the modal
### What Stays the Same in Both Modes
- HP +/- buttons (always available — core gameplay)
- XP +/- buttons (always available — awarded during play)
- Currency +/- buttons (always available — spending gold during play)
- Overall layout structure (header + 3 panels)
- Real-time sync (changes broadcast immediately in both modes)
- Modal behavior (click outside or X to close)
## 3. Component Architecture
Split the current monolithic CharacterDetail.tsx (~345 lines) into focused components:
- **CharacterDetail.tsx** — modal shell (overlay, close behavior), mode state (`'view' | 'edit'`), passes mode + handlers to CharacterSheet
- **CharacterSheet.tsx** — header banner + 3-panel grid layout, receives `mode` and all handler props, distributes to panels
- **StatsPanel.tsx** — left panel: ability scores + attacks. Shows +/- on stats only in edit mode. AttackBlock always shown. Reserves roll button space.
- **InfoPanel.tsx** — center panel: talents + character info fields. TalentList add/remove only in edit mode. Info fields editable only in edit mode.
- **GearPanel.tsx** — right panel: gear list + currency. Add/remove gear only in edit mode. Currency +/- always shown. Slot counter always shown.
Existing components reused:
- StatBlock — already has +/- buttons, just conditionally render them
- AttackBlock — read-only in both modes
- TalentList — conditionally show add/remove
- GearList — conditionally show add/remove
- AcDisplay — override only in edit mode
- CurrencyRow — always shown with +/- in view, direct input in edit
- ItemPicker — only rendered in edit mode
Each panel file stays under ~150 lines.
## 4. Gear Type Icons
Replace the colored text badges (WEAPON, ARMOR, GEAR, SPELL) with small unicode/CSS icons:
- Weapon: crossed swords or dagger unicode (e.g. `⚔` or `🗡`)
- Armor: shield unicode (e.g. `🛡`)
- Gear: backpack/bag (e.g. `🎒` or `⚙`)
- Spell: sparkle/star (e.g. `✨` or `⚡`)
Kept as plain unicode characters — no icon library dependency. Shown as small inline icons next to the item name in the gear list.
## 5. Future Dice Rolling Accommodation
Layout reserves space but does not implement rolling:
- Each ability score row has empty space on the right (~2.5rem) for a future "roll" button
- Each attack line has empty space on the right (~2.5rem) for a future "roll" button
- The attacks section header has room for a general roll area
## 6. Data Flow
No database or API changes. This is purely a frontend restructuring:
- Mode state (`'view' | 'edit'`) is local component state in CharacterDetail
- All existing mutation handlers (onUpdate, onStatChange, onAddGear, etc.) still work the same
- The only change is whether the UI renders input controls or display text
- Debounced text field updates still work in edit mode (same as current)
- HP/XP/currency handlers work in both modes
## Out of Scope
- Dice rolling (layout accommodates it)
- Predefined talent database
- New character fields or schema changes
- Authentication or permissions (edit mode is available to everyone)

327
package-lock.json generated Normal file
View file

@ -0,0 +1,327 @@
{
"name": "shadowdark",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shadowdark",
"devDependencies": {
"concurrently": "^9.1.2"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

12
package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "shadowdark",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "cd server && npm run dev",
"dev:client": "cd client && npm run dev"
},
"devDependencies": {
"concurrently": "^9.1.2"
}
}

58
server/dist/db.js vendored Normal file
View file

@ -0,0 +1,58 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
const DATA_DIR = path.join(import.meta.dirname, "..", "data");
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new Database(path.join(DATA_DIR, "shadowdark.db"));
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_by TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS characters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
created_by TEXT DEFAULT '',
name TEXT NOT NULL,
class TEXT NOT NULL DEFAULT 'Fighter',
ancestry TEXT NOT NULL DEFAULT 'Human',
level INTEGER NOT NULL DEFAULT 1,
xp INTEGER NOT NULL DEFAULT 0,
hp_current INTEGER NOT NULL DEFAULT 0,
hp_max INTEGER NOT NULL DEFAULT 0,
ac INTEGER NOT NULL DEFAULT 10,
alignment TEXT NOT NULL DEFAULT 'Neutral',
title TEXT DEFAULT '',
notes TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS character_stats (
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
stat_name TEXT NOT NULL,
value INTEGER NOT NULL DEFAULT 10,
PRIMARY KEY (character_id, stat_name)
);
CREATE TABLE IF NOT EXISTS character_gear (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'gear',
slot_count INTEGER NOT NULL DEFAULT 1,
properties TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS character_talents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT DEFAULT '',
effect TEXT DEFAULT '{}'
);
`);
export default db;

25
server/dist/index.js vendored Normal file
View file

@ -0,0 +1,25 @@
import express from "express";
import cors from "cors";
import { createServer } from "http";
import { Server } from "socket.io";
import { setupSocket } from "./socket.js";
import campaignRoutes from "./routes/campaigns.js";
import characterRoutes from "./routes/characters.js";
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: { origin: "*" },
});
app.use(cors());
app.use(express.json());
// Make io accessible to route handlers
app.set("io", io);
setupSocket(io);
app.use("/api/campaigns", campaignRoutes);
app.use("/api/campaigns/:campaignId/characters", characterRoutes);
app.use("/api/characters", characterRoutes);
const PORT = process.env.PORT || 3000;
httpServer.listen(PORT, () => {
console.log(`Shadowdark server running on http://localhost:${PORT}`);
});
export { io };

48
server/dist/routes/campaigns.js vendored Normal file
View file

@ -0,0 +1,48 @@
import { Router } from "express";
import db from "../db.js";
const router = Router();
// GET /api/campaigns — list all campaigns
router.get("/", (_req, res) => {
const campaigns = db
.prepare("SELECT * FROM campaigns ORDER BY created_at DESC")
.all();
res.json(campaigns);
});
// POST /api/campaigns — create a campaign
router.post("/", (req, res) => {
const { name } = req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Campaign name is required" });
return;
}
const result = db
.prepare("INSERT INTO campaigns (name) VALUES (?)")
.run(name.trim());
const campaign = db
.prepare("SELECT * FROM campaigns WHERE id = ?")
.get(result.lastInsertRowid);
res.status(201).json(campaign);
});
// GET /api/campaigns/:id — get a single campaign
router.get("/:id", (req, res) => {
const campaign = db
.prepare("SELECT * FROM campaigns WHERE id = ?")
.get(req.params.id);
if (!campaign) {
res.status(404).json({ error: "Campaign not found" });
return;
}
res.json(campaign);
});
// DELETE /api/campaigns/:id — delete a campaign (cascades to characters)
router.delete("/:id", (req, res) => {
const result = db
.prepare("DELETE FROM campaigns WHERE id = ?")
.run(req.params.id);
if (result.changes === 0) {
res.status(404).json({ error: "Campaign not found" });
return;
}
res.status(204).end();
});
export default router;

238
server/dist/routes/characters.js vendored Normal file
View file

@ -0,0 +1,238 @@
import { Router } from "express";
import db from "../db.js";
import { broadcastToCampaign } from "../socket.js";
const router = Router({ mergeParams: true });
const DEFAULT_STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
// GET /api/campaigns/:campaignId/characters — list characters in a campaign
router.get("/", (req, res) => {
const { campaignId } = req.params;
const characters = db
.prepare("SELECT * FROM characters WHERE campaign_id = ? ORDER BY name")
.all(campaignId);
const stmtStats = db.prepare("SELECT stat_name, value FROM character_stats WHERE character_id = ?");
const stmtGear = db.prepare("SELECT * FROM character_gear WHERE character_id = ?");
const stmtTalents = db.prepare("SELECT * FROM character_talents WHERE character_id = ?");
const enriched = characters.map((char) => ({
...char,
stats: stmtStats.all(char.id),
gear: stmtGear.all(char.id),
talents: stmtTalents.all(char.id),
}));
res.json(enriched);
});
// POST /api/campaigns/:campaignId/characters — create a character
router.post("/", (req, res) => {
const { campaignId } = req.params;
const { name, class: charClass, ancestry, hp_max } = req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Character name is required" });
return;
}
const insertChar = db.prepare(`
INSERT INTO characters (campaign_id, name, class, ancestry, hp_current, hp_max)
VALUES (?, ?, ?, ?, ?, ?)
`);
const insertStat = db.prepare("INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, 10)");
const result = insertChar.run(campaignId, name.trim(), charClass || "Fighter", ancestry || "Human", hp_max || 0, hp_max || 0);
const characterId = result.lastInsertRowid;
for (const stat of DEFAULT_STATS) {
insertStat.run(characterId, stat);
}
const character = db
.prepare("SELECT * FROM characters WHERE id = ?")
.get(characterId);
const stats = db
.prepare("SELECT stat_name, value FROM character_stats WHERE character_id = ?")
.all(characterId);
const enriched = {
...character,
stats,
gear: [],
talents: [],
};
const io = req.app.get("io");
broadcastToCampaign(io, Number(campaignId), "character:created", enriched);
res.status(201).json(enriched);
});
// PATCH /api/characters/:id — update character fields
router.patch("/:id", (req, res) => {
const { id } = req.params;
const allowedFields = [
"name",
"class",
"ancestry",
"level",
"xp",
"hp_current",
"hp_max",
"ac",
"alignment",
"title",
"notes",
];
const updates = [];
const values = [];
for (const field of allowedFields) {
if (req.body[field] !== undefined) {
updates.push(`${field} = ?`);
values.push(req.body[field]);
}
}
if (updates.length === 0) {
res.status(400).json({ error: "No valid fields to update" });
return;
}
values.push(id);
db.prepare(`UPDATE characters SET ${updates.join(", ")} WHERE id = ?`).run(...values);
const character = db
.prepare("SELECT * FROM characters WHERE id = ?")
.get(id);
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
const io = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "character:updated", {
id: Number(id),
...req.body,
});
res.json(character);
});
// DELETE /api/characters/:id — delete a character
router.delete("/:id", (req, res) => {
const character = db
.prepare("SELECT * FROM characters WHERE id = ?")
.get(req.params.id);
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
db.prepare("DELETE FROM characters WHERE id = ?").run(req.params.id);
const io = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "character:deleted", {
id: Number(req.params.id),
});
res.status(204).end();
});
// PATCH /api/characters/:id/stats/:statName — update a single stat
router.patch("/:id/stats/:statName", (req, res) => {
const { id, statName } = req.params;
const { value } = req.body;
const upper = statName.toUpperCase();
if (!DEFAULT_STATS.includes(upper)) {
res.status(400).json({ error: "Invalid stat name" });
return;
}
db.prepare("UPDATE character_stats SET value = ? WHERE character_id = ? AND stat_name = ?").run(value, id, upper);
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id);
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
const io = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "stat:updated", {
characterId: Number(id),
statName: upper,
value,
});
res.json({ characterId: Number(id), statName: upper, value });
});
// POST /api/characters/:id/gear — add gear
router.post("/:id/gear", (req, res) => {
const { id } = req.params;
const { name, type, slot_count, properties } = req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Gear name is required" });
return;
}
const result = db
.prepare("INSERT INTO character_gear (character_id, name, type, slot_count, properties) VALUES (?, ?, ?, ?, ?)")
.run(id, name.trim(), type || "gear", slot_count ?? 1, JSON.stringify(properties || {}));
const gear = db
.prepare("SELECT * FROM character_gear WHERE id = ?")
.get(result.lastInsertRowid);
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id);
const io = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "gear:added", {
characterId: Number(id),
gear,
});
res.status(201).json(gear);
});
// DELETE /api/characters/:id/gear/:gearId — remove gear
router.delete("/:id/gear/:gearId", (req, res) => {
const { id, gearId } = req.params;
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id);
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
const result = db
.prepare("DELETE FROM character_gear WHERE id = ? AND character_id = ?")
.run(gearId, id);
if (result.changes === 0) {
res.status(404).json({ error: "Gear not found" });
return;
}
const io = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "gear:removed", {
characterId: Number(id),
gearId: Number(gearId),
});
res.status(204).end();
});
// POST /api/characters/:id/talents — add talent
router.post("/:id/talents", (req, res) => {
const { id } = req.params;
const { name, description, effect } = req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Talent name is required" });
return;
}
const result = db
.prepare("INSERT INTO character_talents (character_id, name, description, effect) VALUES (?, ?, ?, ?)")
.run(id, name.trim(), description || "", JSON.stringify(effect || {}));
const talent = db
.prepare("SELECT * FROM character_talents WHERE id = ?")
.get(result.lastInsertRowid);
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id);
const io = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "talent:added", {
characterId: Number(id),
talent,
});
res.status(201).json(talent);
});
// DELETE /api/characters/:id/talents/:talentId — remove talent
router.delete("/:id/talents/:talentId", (req, res) => {
const { id, talentId } = req.params;
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id);
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
const result = db
.prepare("DELETE FROM character_talents WHERE id = ? AND character_id = ?")
.run(talentId, id);
if (result.changes === 0) {
res.status(404).json({ error: "Talent not found" });
return;
}
const io = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "talent:removed", {
characterId: Number(id),
talentId: Number(talentId),
});
res.status(204).end();
});
export default router;

16
server/dist/socket.js vendored Normal file
View file

@ -0,0 +1,16 @@
export function setupSocket(io) {
io.on("connection", (socket) => {
socket.on("join-campaign", (campaignId) => {
socket.join(`campaign:${campaignId}`);
});
socket.on("leave-campaign", (campaignId) => {
socket.leave(`campaign:${campaignId}`);
});
socket.on("disconnect", () => {
// Rooms are cleaned up automatically by Socket.IO
});
});
}
export function broadcastToCampaign(io, campaignId, event, data) {
io.to(`campaign:${campaignId}`).emit(event, data);
}

2170
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
server/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "shadowdark-server",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"express": "^4.21.2",
"socket.io": "^4.8.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

137
server/src/db.ts Normal file
View file

@ -0,0 +1,137 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import { SEED_ITEMS } from "./seed-items.js";
import { SEED_TALENTS } from "./seed-talents.js";
const DATA_DIR = path.join(import.meta.dirname, "..", "data");
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new Database(path.join(DATA_DIR, "shadowdark.db"));
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_by TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS characters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
created_by TEXT DEFAULT '',
name TEXT NOT NULL,
class TEXT NOT NULL DEFAULT 'Fighter',
ancestry TEXT NOT NULL DEFAULT 'Human',
level INTEGER NOT NULL DEFAULT 1,
xp INTEGER NOT NULL DEFAULT 0,
hp_current INTEGER NOT NULL DEFAULT 0,
hp_max INTEGER NOT NULL DEFAULT 0,
ac INTEGER NOT NULL DEFAULT 10,
alignment TEXT NOT NULL DEFAULT 'Neutral',
title TEXT DEFAULT '',
notes TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS character_stats (
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
stat_name TEXT NOT NULL,
value INTEGER NOT NULL DEFAULT 10,
PRIMARY KEY (character_id, stat_name)
);
CREATE TABLE IF NOT EXISTS character_gear (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'gear',
slot_count INTEGER NOT NULL DEFAULT 1,
properties TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS character_talents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT DEFAULT '',
effect TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS game_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
slot_count INTEGER NOT NULL DEFAULT 1,
effects TEXT DEFAULT '{}',
properties TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS game_talents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
source TEXT NOT NULL,
description TEXT DEFAULT '',
effect TEXT DEFAULT '{}'
);
`);
// --- Migrations for v2 ---
const v2Columns: Array<[string, string, string]> = [
["characters", "background", "TEXT DEFAULT ''"],
["characters", "deity", "TEXT DEFAULT ''"],
["characters", "languages", "TEXT DEFAULT ''"],
["characters", "gp", "INTEGER DEFAULT 0"],
["characters", "sp", "INTEGER DEFAULT 0"],
["characters", "cp", "INTEGER DEFAULT 0"],
["characters", "gear_slots_max", "INTEGER DEFAULT 10"],
["characters", "overrides", "TEXT DEFAULT '{}'"],
["character_gear", "game_item_id", "INTEGER"],
["character_gear", "effects", "TEXT DEFAULT '{}'"],
["character_talents", "game_talent_id", "INTEGER"],
];
for (const [table, column, definition] of v2Columns) {
try {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
} catch {
// Column already exists
}
}
// Seed game_items if empty
const count = (
db.prepare("SELECT COUNT(*) as c FROM game_items").get() as { c: number }
).c;
if (count === 0) {
const insert = db.prepare(
"INSERT INTO game_items (name, type, slot_count, effects, properties) VALUES (?, ?, ?, ?, ?)",
);
for (const item of SEED_ITEMS) {
insert.run(
item.name,
item.type,
item.slot_count,
JSON.stringify(item.effects),
JSON.stringify(item.properties),
);
}
}
// Seed game_talents if empty
const talentCount = (
db.prepare("SELECT COUNT(*) as c FROM game_talents").get() as { c: number }
).c;
if (talentCount === 0) {
const insertTalent = db.prepare(
"INSERT INTO game_talents (name, source, description, effect) VALUES (?, ?, ?, ?)",
);
for (const t of SEED_TALENTS) {
insertTalent.run(t.name, t.source, t.description, JSON.stringify(t.effect));
}
}
export default db;

36
server/src/index.ts Normal file
View file

@ -0,0 +1,36 @@
import express from "express";
import cors from "cors";
import { createServer } from "http";
import { Server } from "socket.io";
import { setupSocket } from "./socket.js";
import campaignRoutes from "./routes/campaigns.js";
import characterRoutes from "./routes/characters.js";
import gameItemRoutes from "./routes/game-items.js";
import gameTalentRoutes from "./routes/game-talents.js";
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: { origin: "*" },
});
app.use(cors());
app.use(express.json());
// Make io accessible to route handlers
app.set("io", io);
setupSocket(io);
app.use("/api/campaigns", campaignRoutes);
app.use("/api/campaigns/:campaignId/characters", characterRoutes);
app.use("/api/characters", characterRoutes);
app.use("/api/game-items", gameItemRoutes);
app.use("/api/game-talents", gameTalentRoutes);
const PORT = process.env.PORT || 3000;
httpServer.listen(PORT, () => {
console.log(`Shadowdark server running on http://localhost:${PORT}`);
});
export { io };

View file

@ -0,0 +1,54 @@
import { Router } from "express";
import db from "../db.js";
const router = Router();
// GET /api/campaigns — list all campaigns
router.get("/", (_req, res) => {
const campaigns = db
.prepare("SELECT * FROM campaigns ORDER BY created_at DESC")
.all();
res.json(campaigns);
});
// POST /api/campaigns — create a campaign
router.post("/", (req, res) => {
const { name } = req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Campaign name is required" });
return;
}
const result = db
.prepare("INSERT INTO campaigns (name) VALUES (?)")
.run(name.trim());
const campaign = db
.prepare("SELECT * FROM campaigns WHERE id = ?")
.get(result.lastInsertRowid);
res.status(201).json(campaign);
});
// GET /api/campaigns/:id — get a single campaign
router.get("/:id", (req, res) => {
const campaign = db
.prepare("SELECT * FROM campaigns WHERE id = ?")
.get(req.params.id);
if (!campaign) {
res.status(404).json({ error: "Campaign not found" });
return;
}
res.json(campaign);
});
// DELETE /api/campaigns/:id — delete a campaign (cascades to characters)
router.delete("/:id", (req, res) => {
const result = db
.prepare("DELETE FROM campaigns WHERE id = ?")
.run(req.params.id);
if (result.changes === 0) {
res.status(404).json({ error: "Campaign not found" });
return;
}
res.status(204).end();
});
export default router;

View file

@ -0,0 +1,380 @@
import { Router } from "express";
import type { ParamsDictionary } from "express-serve-static-core";
import type { Server } from "socket.io";
import db from "../db.js";
import { broadcastToCampaign } from "../socket.js";
type CampaignParams = ParamsDictionary & { campaignId: string };
const router = Router({ mergeParams: true });
const DEFAULT_STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
function parseJson(val: unknown): Record<string, unknown> {
if (typeof val === "string") {
try {
return JSON.parse(val);
} catch {
return {};
}
}
return (val as Record<string, unknown>) ?? {};
}
function parseGear(rows: Array<Record<string, unknown>>) {
return rows.map((r) => ({
...r,
properties: parseJson(r.properties),
effects: parseJson(r.effects),
}));
}
function parseTalents(rows: Array<Record<string, unknown>>) {
return rows.map((r) => ({ ...r, effect: parseJson(r.effect) }));
}
// GET /api/campaigns/:campaignId/characters — list characters in a campaign
router.get<CampaignParams>("/", (req, res) => {
const { campaignId } = req.params;
const characters = db
.prepare("SELECT * FROM characters WHERE campaign_id = ? ORDER BY name")
.all(campaignId) as Array<Record<string, unknown>>;
const stmtStats = db.prepare(
"SELECT stat_name, value FROM character_stats WHERE character_id = ?",
);
const stmtGear = db.prepare(
"SELECT * FROM character_gear WHERE character_id = ?",
);
const stmtTalents = db.prepare(
"SELECT * FROM character_talents WHERE character_id = ?",
);
const enriched = characters.map((char) => ({
...char,
overrides: parseJson(char.overrides),
stats: stmtStats.all(char.id),
gear: parseGear(stmtGear.all(char.id) as Array<Record<string, unknown>>),
talents: parseTalents(
stmtTalents.all(char.id) as Array<Record<string, unknown>>,
),
}));
res.json(enriched);
});
// POST /api/campaigns/:campaignId/characters — create a character
router.post<CampaignParams>("/", (req, res) => {
const { campaignId } = req.params;
const { name, class: charClass, ancestry, hp_max } = req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Character name is required" });
return;
}
const insertChar = db.prepare(`
INSERT INTO characters (campaign_id, name, class, ancestry, hp_current, hp_max)
VALUES (?, ?, ?, ?, ?, ?)
`);
const insertStat = db.prepare(
"INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, 10)",
);
const result = insertChar.run(
campaignId,
name.trim(),
charClass || "Fighter",
ancestry || "Human",
hp_max || 0,
hp_max || 0,
);
const characterId = result.lastInsertRowid;
for (const stat of DEFAULT_STATS) {
insertStat.run(characterId, stat);
}
const character = db
.prepare("SELECT * FROM characters WHERE id = ?")
.get(characterId);
const stats = db
.prepare(
"SELECT stat_name, value FROM character_stats WHERE character_id = ?",
)
.all(characterId);
const enriched = {
...(character as Record<string, unknown>),
overrides: {},
stats,
gear: [],
talents: [],
};
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(campaignId), "character:created", enriched);
res.status(201).json(enriched);
});
// PATCH /api/characters/:id — update character fields
router.patch("/:id", (req, res) => {
const { id } = req.params;
const allowedFields = [
"name",
"class",
"ancestry",
"level",
"xp",
"hp_current",
"hp_max",
"ac",
"alignment",
"title",
"notes",
"background",
"deity",
"languages",
"gp",
"sp",
"cp",
"gear_slots_max",
"overrides",
];
const updates: string[] = [];
const values: unknown[] = [];
for (const field of allowedFields) {
if (req.body[field] !== undefined) {
updates.push(`${field} = ?`);
values.push(req.body[field]);
}
}
if (updates.length === 0) {
res.status(400).json({ error: "No valid fields to update" });
return;
}
values.push(id);
db.prepare(`UPDATE characters SET ${updates.join(", ")} WHERE id = ?`).run(
...values,
);
const character = db
.prepare("SELECT * FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "character:updated", {
id: Number(id),
...req.body,
});
res.json(character);
});
// DELETE /api/characters/:id — delete a character
router.delete("/:id", (req, res) => {
const character = db
.prepare("SELECT * FROM characters WHERE id = ?")
.get(req.params.id) as Record<string, unknown> | undefined;
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
db.prepare("DELETE FROM characters WHERE id = ?").run(req.params.id);
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "character:deleted", {
id: Number(req.params.id),
});
res.status(204).end();
});
// PATCH /api/characters/:id/stats/:statName — update a single stat
router.patch("/:id/stats/:statName", (req, res) => {
const { id, statName } = req.params;
const { value } = req.body;
const upper = statName.toUpperCase();
if (!DEFAULT_STATS.includes(upper)) {
res.status(400).json({ error: "Invalid stat name" });
return;
}
db.prepare(
"UPDATE character_stats SET value = ? WHERE character_id = ? AND stat_name = ?",
).run(value, id, upper);
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "stat:updated", {
characterId: Number(id),
statName: upper,
value,
});
res.json({ characterId: Number(id), statName: upper, value });
});
// POST /api/characters/:id/gear — add gear
router.post("/:id/gear", (req, res) => {
const { id } = req.params;
const { name, type, slot_count, properties, effects, game_item_id } =
req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Gear name is required" });
return;
}
const result = db
.prepare(
"INSERT INTO character_gear (character_id, name, type, slot_count, properties, effects, game_item_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(
id,
name.trim(),
type || "gear",
slot_count ?? 1,
JSON.stringify(properties || {}),
JSON.stringify(effects || {}),
game_item_id ?? null,
);
const gearRow = db
.prepare("SELECT * FROM character_gear WHERE id = ?")
.get(result.lastInsertRowid) as Record<string, unknown>;
const gear = {
...gearRow,
properties: parseJson(gearRow.properties),
effects: parseJson(gearRow.effects),
};
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "gear:added", {
characterId: Number(id),
gear,
});
res.status(201).json(gear);
});
// DELETE /api/characters/:id/gear/:gearId — remove gear
router.delete("/:id/gear/:gearId", (req, res) => {
const { id, gearId } = req.params;
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
const result = db
.prepare("DELETE FROM character_gear WHERE id = ? AND character_id = ?")
.run(gearId, id);
if (result.changes === 0) {
res.status(404).json({ error: "Gear not found" });
return;
}
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "gear:removed", {
characterId: Number(id),
gearId: Number(gearId),
});
res.status(204).end();
});
// POST /api/characters/:id/talents — add talent
router.post("/:id/talents", (req, res) => {
const { id } = req.params;
const { name, description, effect, game_talent_id } = req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Talent name is required" });
return;
}
const result = db
.prepare(
"INSERT INTO character_talents (character_id, name, description, effect, game_talent_id) VALUES (?, ?, ?, ?, ?)",
)
.run(
id,
name.trim(),
description || "",
JSON.stringify(effect || {}),
game_talent_id ?? null,
);
const talentRow = db
.prepare("SELECT * FROM character_talents WHERE id = ?")
.get(result.lastInsertRowid) as Record<string, unknown>;
const talent = { ...talentRow, effect: parseJson(talentRow.effect) };
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "talent:added", {
characterId: Number(id),
talent,
});
res.status(201).json(talent);
});
// DELETE /api/characters/:id/talents/:talentId — remove talent
router.delete("/:id/talents/:talentId", (req, res) => {
const { id, talentId } = req.params;
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
const result = db
.prepare("DELETE FROM character_talents WHERE id = ? AND character_id = ?")
.run(talentId, id);
if (result.changes === 0) {
res.status(404).json({ error: "Talent not found" });
return;
}
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "talent:removed", {
characterId: Number(id),
talentId: Number(talentId),
});
res.status(204).end();
});
export default router;

View file

@ -0,0 +1,18 @@
import { Router } from "express";
import db from "../db.js";
const router = Router();
router.get("/", (_req, res) => {
const items = db
.prepare("SELECT * FROM game_items ORDER BY type, name")
.all() as Array<Record<string, unknown>>;
const parsed = items.map((item) => ({
...item,
effects: JSON.parse(item.effects as string),
properties: JSON.parse(item.properties as string),
}));
res.json(parsed);
});
export default router;

View file

@ -0,0 +1,17 @@
import { Router } from "express";
import db from "../db.js";
const router = Router();
router.get("/", (_req, res) => {
const talents = db
.prepare("SELECT * FROM game_talents ORDER BY source, name")
.all() as Array<Record<string, unknown>>;
const parsed = talents.map((t) => ({
...t,
effect: JSON.parse(t.effect as string),
}));
res.json(parsed);
});
export default router;

268
server/src/seed-items.ts Normal file
View file

@ -0,0 +1,268 @@
export interface SeedItem {
name: string;
type: "weapon" | "armor" | "gear";
slot_count: number;
effects: Record<string, unknown>;
properties: Record<string, unknown>;
}
export const SEED_ITEMS: SeedItem[] = [
// --- Weapons ---
{
name: "Bastard sword",
type: "weapon",
slot_count: 1,
effects: { damage: "1d8", melee: true, stat: "STR", versatile: "1d10" },
properties: { tags: ["versatile"] },
},
{
name: "Club",
type: "weapon",
slot_count: 1,
effects: { damage: "1d4", melee: true, stat: "STR" },
properties: {},
},
{
name: "Crossbow",
type: "weapon",
slot_count: 1,
effects: { damage: "1d6", ranged: true, stat: "DEX", range: "far" },
properties: { tags: ["loading"] },
},
{
name: "Dagger",
type: "weapon",
slot_count: 1,
effects: {
damage: "1d4",
melee: true,
stat: "STR",
finesse: true,
thrown: true,
range: "close",
},
properties: { tags: ["finesse", "thrown"] },
},
{
name: "Greataxe",
type: "weapon",
slot_count: 1,
effects: { damage: "1d10", melee: true, stat: "STR", two_handed: true },
properties: { tags: ["two-handed"] },
},
{
name: "Greatsword",
type: "weapon",
slot_count: 1,
effects: { damage: "2d6", melee: true, stat: "STR", two_handed: true },
properties: { tags: ["two-handed"] },
},
{
name: "Javelin",
type: "weapon",
slot_count: 1,
effects: {
damage: "1d4",
melee: true,
stat: "STR",
thrown: true,
range: "far",
},
properties: { tags: ["thrown"] },
},
{
name: "Longbow",
type: "weapon",
slot_count: 1,
effects: {
damage: "1d8",
ranged: true,
stat: "DEX",
range: "far",
two_handed: true,
},
properties: { tags: ["two-handed"] },
},
{
name: "Longsword",
type: "weapon",
slot_count: 1,
effects: { damage: "1d8", melee: true, stat: "STR" },
properties: {},
},
{
name: "Mace",
type: "weapon",
slot_count: 1,
effects: { damage: "1d6", melee: true, stat: "STR" },
properties: {},
},
{
name: "Shortbow",
type: "weapon",
slot_count: 1,
effects: {
damage: "1d4",
ranged: true,
stat: "DEX",
range: "far",
two_handed: true,
},
properties: { tags: ["two-handed"] },
},
{
name: "Shortsword",
type: "weapon",
slot_count: 1,
effects: { damage: "1d6", melee: true, stat: "STR" },
properties: {},
},
{
name: "Spear",
type: "weapon",
slot_count: 1,
effects: {
damage: "1d6",
melee: true,
stat: "STR",
thrown: true,
range: "close",
},
properties: { tags: ["thrown"] },
},
{
name: "Staff",
type: "weapon",
slot_count: 1,
effects: { damage: "1d4", melee: true, stat: "STR", two_handed: true },
properties: { tags: ["two-handed"] },
},
{
name: "Warhammer",
type: "weapon",
slot_count: 1,
effects: { damage: "1d10", melee: true, stat: "STR", two_handed: true },
properties: { tags: ["two-handed"] },
},
// --- Armor ---
{
name: "Leather armor",
type: "armor",
slot_count: 1,
effects: { ac_base: 11, ac_dex: true },
properties: {},
},
{
name: "Chainmail",
type: "armor",
slot_count: 1,
effects: { ac_base: 13, ac_dex: true },
properties: { note: "Disadvantage on stealth and swimming" },
},
{
name: "Plate mail",
type: "armor",
slot_count: 1,
effects: { ac_base: 15, ac_dex: false },
properties: { note: "Disadvantage on stealth, swimming, and climbing" },
},
{
name: "Shield",
type: "armor",
slot_count: 1,
effects: { ac_bonus: 2 },
properties: {},
},
{
name: "Mithral chainmail",
type: "armor",
slot_count: 1,
effects: { ac_base: 13, ac_dex: true },
properties: { note: "No disadvantage" },
},
// --- Gear ---
{
name: "Arrows/bolts (20)",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{
name: "Backpack",
type: "gear",
slot_count: 0,
effects: {},
properties: {},
},
{
name: "Caltrops",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{
name: "Climbing gear",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{ name: "Crowbar", type: "gear", slot_count: 1, effects: {}, properties: {} },
{
name: "Flask/bottle",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{
name: "Flint and steel",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{
name: "Grappling hook",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{
name: "Iron spikes (10)",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{ name: "Lantern", type: "gear", slot_count: 1, effects: {}, properties: {} },
{ name: "Mirror", type: "gear", slot_count: 1, effects: {}, properties: {} },
{
name: "Oil flask",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{ name: "Rations", type: "gear", slot_count: 1, effects: {}, properties: {} },
{
name: "Rope (60ft)",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{
name: "Thieves' tools",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{ name: "Torch", type: "gear", slot_count: 1, effects: {}, properties: {} },
];

154
server/src/seed-talents.ts Normal file
View file

@ -0,0 +1,154 @@
export interface SeedTalent {
name: string;
source: string; // 'Fighter', 'Priest', 'Thief', 'Wizard', 'Human', 'Elf', 'Dwarf', 'Halfling', 'Goblin', 'Half-Orc', 'General'
description: string;
effect: Record<string, unknown>;
}
export const SEED_TALENTS: SeedTalent[] = [
// --- Fighter ---
{
name: "Hauler",
source: "Fighter",
description: "Gain +2 gear slots",
effect: { gear_slots_bonus: 2 },
},
{
name: "Weapon Mastery",
source: "Fighter",
description: "+1 to attack and damage with one weapon type",
effect: { attack_bonus: 1, damage_bonus: 1 },
},
{
name: "Grit",
source: "Fighter",
description: "Gain +2 HP and +1 HP each level",
effect: { hp_bonus: 2 },
},
// --- Priest ---
{
name: "Turn Undead",
source: "Priest",
description: "Force nearby undead to flee",
effect: {},
},
{
name: "Healing Touch",
source: "Priest",
description: "Heal 1d4+level HP once per rest",
effect: {},
},
{
name: "Shield of Faith",
source: "Priest",
description: "+1 AC when holding a holy symbol",
effect: { ac_bonus: 1 },
},
// --- Thief ---
{
name: "Backstab",
source: "Thief",
description:
"Extra 1 + half level (round down) weapon dice of damage with surprise attacks",
effect: { damage_bonus_surprise: true },
},
{
name: "Thievery",
source: "Thief",
description:
"Trained in climbing, sneaking, hiding, disguise, finding & disabling traps, delicate tasks",
effect: {},
},
{
name: "Keen Senses",
source: "Goblin",
description: "Can't be surprised",
effect: { immune_surprise: true },
},
// --- Wizard ---
{
name: "Magic Missile",
source: "Wizard",
description: "Auto-hit spell dealing 1d4 damage",
effect: {},
},
{
name: "Arcane Focus",
source: "Wizard",
description: "+1 to spellcasting checks",
effect: { spellcast_bonus: 1 },
},
// --- Ancestry ---
{
name: "Brave",
source: "Halfling",
description: "Advantage on rolls to resist fear",
effect: { advantage: "fear" },
},
{
name: "Stout",
source: "Dwarf",
description: "Advantage on Constitution checks vs poison",
effect: { advantage: "poison" },
},
{
name: "Farsight",
source: "Elf",
description: "Can see in dim light as if bright light",
effect: {},
},
{
name: "Mighty",
source: "Half-Orc",
description: "Advantage on Strength checks",
effect: { advantage: "strength" },
},
{
name: "Ambitious",
source: "Human",
description: "Gain one extra talent at 1st level",
effect: {},
},
// --- General (level-up talents anyone can pick) ---
{
name: "Stat Bonus: +2 Strength",
source: "General",
description: "+2 to Strength",
effect: { stat_bonus: "STR", stat_bonus_value: 2 },
},
{
name: "Stat Bonus: +2 Dexterity",
source: "General",
description: "+2 to Dexterity",
effect: { stat_bonus: "DEX", stat_bonus_value: 2 },
},
{
name: "Stat Bonus: +2 Constitution",
source: "General",
description: "+2 to Constitution",
effect: { stat_bonus: "CON", stat_bonus_value: 2 },
},
{
name: "Stat Bonus: +2 Intelligence",
source: "General",
description: "+2 to Intelligence",
effect: { stat_bonus: "INT", stat_bonus_value: 2 },
},
{
name: "Stat Bonus: +2 Wisdom",
source: "General",
description: "+2 to Wisdom",
effect: { stat_bonus: "WIS", stat_bonus_value: 2 },
},
{
name: "Stat Bonus: +2 Charisma",
source: "General",
description: "+2 to Charisma",
effect: { stat_bonus: "CHA", stat_bonus_value: 2 },
},
];

26
server/src/socket.ts Normal file
View file

@ -0,0 +1,26 @@
import { Server } from "socket.io";
export function setupSocket(io: Server) {
io.on("connection", (socket) => {
socket.on("join-campaign", (campaignId: string) => {
socket.join(`campaign:${campaignId}`);
});
socket.on("leave-campaign", (campaignId: string) => {
socket.leave(`campaign:${campaignId}`);
});
socket.on("disconnect", () => {
// Rooms are cleaned up automatically by Socket.IO
});
});
}
export function broadcastToCampaign(
io: Server,
campaignId: number,
event: string,
data: unknown
) {
io.to(`campaign:${campaignId}`).emit(event, data);
}

14
server/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src"]
}