Initial commit: Shadowdark character sheet manager with item/talent databases, view/edit modes, real-time sync
This commit is contained in:
commit
2c73dd9ec4
80 changed files with 17911 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
server/data/
|
||||
.superpowers/
|
||||
74
.vscode/settings.json
vendored
Normal file
74
.vscode/settings.json
vendored
Normal 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
12
client/index.html
Normal 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
1986
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
client/package.json
Normal file
22
client/package.json
Normal 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
32
client/src/App.module.css
Normal 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
20
client/src/App.tsx
Normal 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
109
client/src/api.ts
Normal 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");
|
||||
67
client/src/components/AcDisplay.module.css
Normal file
67
client/src/components/AcDisplay.module.css
Normal 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;
|
||||
}
|
||||
92
client/src/components/AcDisplay.tsx
Normal file
92
client/src/components/AcDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
client/src/components/AttackBlock.module.css
Normal file
73
client/src/components/AttackBlock.module.css
Normal 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;
|
||||
}
|
||||
43
client/src/components/AttackBlock.tsx
Normal file
43
client/src/components/AttackBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
client/src/components/CharacterCard.module.css
Normal file
78
client/src/components/CharacterCard.module.css
Normal 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;
|
||||
}
|
||||
63
client/src/components/CharacterCard.tsx
Normal file
63
client/src/components/CharacterCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
client/src/components/CharacterDetail.module.css
Normal file
82
client/src/components/CharacterDetail.module.css
Normal 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;
|
||||
}
|
||||
74
client/src/components/CharacterDetail.tsx
Normal file
74
client/src/components/CharacterDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
client/src/components/CharacterSheet.module.css
Normal file
157
client/src/components/CharacterSheet.module.css
Normal 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);
|
||||
}
|
||||
212
client/src/components/CharacterSheet.tsx
Normal file
212
client/src/components/CharacterSheet.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
client/src/components/CurrencyRow.module.css
Normal file
70
client/src/components/CurrencyRow.module.css
Normal 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;
|
||||
}
|
||||
80
client/src/components/CurrencyRow.tsx
Normal file
80
client/src/components/CurrencyRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
client/src/components/GearList.module.css
Normal file
153
client/src/components/GearList.module.css
Normal 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;
|
||||
}
|
||||
184
client/src/components/GearList.tsx
Normal file
184
client/src/components/GearList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
client/src/components/GearPanel.module.css
Normal file
6
client/src/components/GearPanel.module.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.panel {
|
||||
background: #16213e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
50
client/src/components/GearPanel.tsx
Normal file
50
client/src/components/GearPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
client/src/components/HpBar.module.css
Normal file
60
client/src/components/HpBar.module.css
Normal 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;
|
||||
}
|
||||
30
client/src/components/HpBar.tsx
Normal file
30
client/src/components/HpBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
client/src/components/HpDisplay.module.css
Normal file
143
client/src/components/HpDisplay.module.css
Normal 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;
|
||||
}
|
||||
84
client/src/components/HpDisplay.tsx
Normal file
84
client/src/components/HpDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
client/src/components/InfoPanel.module.css
Normal file
112
client/src/components/InfoPanel.module.css
Normal 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;
|
||||
}
|
||||
196
client/src/components/InfoPanel.tsx
Normal file
196
client/src/components/InfoPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
client/src/components/InlineNumber.module.css
Normal file
25
client/src/components/InlineNumber.module.css
Normal 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;
|
||||
}
|
||||
66
client/src/components/InlineNumber.tsx
Normal file
66
client/src/components/InlineNumber.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
client/src/components/ItemPicker.module.css
Normal file
81
client/src/components/ItemPicker.module.css
Normal 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;
|
||||
}
|
||||
83
client/src/components/ItemPicker.tsx
Normal file
83
client/src/components/ItemPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
client/src/components/StatBlock.module.css
Normal file
75
client/src/components/StatBlock.module.css
Normal 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;
|
||||
}
|
||||
53
client/src/components/StatBlock.tsx
Normal file
53
client/src/components/StatBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
client/src/components/StatsPanel.module.css
Normal file
21
client/src/components/StatsPanel.module.css
Normal 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;
|
||||
}
|
||||
34
client/src/components/StatsPanel.tsx
Normal file
34
client/src/components/StatsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
client/src/components/TalentList.module.css
Normal file
103
client/src/components/TalentList.module.css
Normal 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;
|
||||
}
|
||||
131
client/src/components/TalentList.tsx
Normal file
131
client/src/components/TalentList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
client/src/components/TalentPicker.module.css
Normal file
82
client/src/components/TalentPicker.module.css
Normal 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;
|
||||
}
|
||||
84
client/src/components/TalentPicker.tsx
Normal file
84
client/src/components/TalentPicker.tsx
Normal 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
9
client/src/main.tsx
Normal 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>
|
||||
);
|
||||
94
client/src/pages/CampaignList.module.css
Normal file
94
client/src/pages/CampaignList.module.css
Normal 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;
|
||||
}
|
||||
81
client/src/pages/CampaignList.tsx
Normal file
81
client/src/pages/CampaignList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
client/src/pages/CampaignView.module.css
Normal file
163
client/src/pages/CampaignView.module.css
Normal 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;
|
||||
}
|
||||
383
client/src/pages/CampaignView.tsx
Normal file
383
client/src/pages/CampaignView.tsx
Normal 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
8
client/src/socket.ts
Normal 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
86
client/src/types.ts
Normal 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;
|
||||
}
|
||||
47
client/src/utils/derived-ac.ts
Normal file
47
client/src/utils/derived-ac.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
48
client/src/utils/derived-attacks.ts
Normal file
48
client/src/utils/derived-attacks.ts
Normal 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;
|
||||
}
|
||||
22
client/src/utils/modifiers.ts
Normal file
22
client/src/utils/modifiers.ts
Normal 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
1
client/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
13
client/tsconfig.json
Normal file
13
client/tsconfig.json
Normal 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
16
client/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
3214
docs/plans/2026-04-08-shadowdark-character-manager.md
Normal file
3214
docs/plans/2026-04-08-shadowdark-character-manager.md
Normal file
File diff suppressed because it is too large
Load diff
2528
docs/plans/2026-04-08-v2-item-database-derived-stats.md
Normal file
2528
docs/plans/2026-04-08-v2-item-database-derived-stats.md
Normal file
File diff suppressed because it is too large
Load diff
1412
docs/plans/2026-04-09-view-edit-mode.md
Normal file
1412
docs/plans/2026-04-09-view-edit-mode.md
Normal file
File diff suppressed because it is too large
Load diff
164
docs/specs/2026-04-08-shadowdark-character-manager-design.md
Normal file
164
docs/specs/2026-04-08-shadowdark-character-manager-design.md
Normal 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
|
||||
235
docs/specs/2026-04-08-v2-item-database-derived-stats-design.md
Normal file
235
docs/specs/2026-04-08-v2-item-database-derived-stats-design.md
Normal 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
|
||||
161
docs/specs/2026-04-09-view-edit-mode-design.md
Normal file
161
docs/specs/2026-04-09-view-edit-mode-design.md
Normal 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
327
package-lock.json
generated
Normal 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
12
package.json
Normal 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
58
server/dist/db.js
vendored
Normal 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
25
server/dist/index.js
vendored
Normal 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
48
server/dist/routes/campaigns.js
vendored
Normal 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
238
server/dist/routes/characters.js
vendored
Normal 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
16
server/dist/socket.js
vendored
Normal 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
2170
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
server/package.json
Normal file
23
server/package.json
Normal 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
137
server/src/db.ts
Normal 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
36
server/src/index.ts
Normal 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 };
|
||||
54
server/src/routes/campaigns.ts
Normal file
54
server/src/routes/campaigns.ts
Normal 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;
|
||||
380
server/src/routes/characters.ts
Normal file
380
server/src/routes/characters.ts
Normal 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;
|
||||
18
server/src/routes/game-items.ts
Normal file
18
server/src/routes/game-items.ts
Normal 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;
|
||||
17
server/src/routes/game-talents.ts
Normal file
17
server/src/routes/game-talents.ts
Normal 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
268
server/src/seed-items.ts
Normal 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
154
server/src/seed-talents.ts
Normal 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
26
server/src/socket.ts
Normal 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
14
server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue