diff --git a/client/src/api.ts b/client/src/api.ts index 7aed0f2..8ed0d99 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -5,6 +5,7 @@ import type { Talent, GameItem, GameTalent, + RollResult, } from "./types"; const BASE = "/api"; @@ -107,3 +108,7 @@ export const getGameItems = () => request("/game-items"); // Game Talents export const getGameTalents = () => request("/game-talents"); + +// Rolls +export const getRolls = (campaignId: number) => + request(`/campaigns/${campaignId}/rolls`); diff --git a/client/src/components/AttackBlock.tsx b/client/src/components/AttackBlock.tsx index 501147d..d16e5af 100644 --- a/client/src/components/AttackBlock.tsx +++ b/client/src/components/AttackBlock.tsx @@ -1,11 +1,22 @@ import type { AttackLine } from "../types"; +import DiceButton from "./DiceButton"; import styles from "./AttackBlock.module.css"; interface AttackBlockProps { attacks: AttackLine[]; + campaignId?: number; + characterId?: number; + characterName?: string; + mode?: "view" | "edit"; } -export default function AttackBlock({ attacks }: AttackBlockProps) { +export default function AttackBlock({ + attacks, + campaignId, + characterId, + characterName, + mode, +}: AttackBlockProps) { const weapons = attacks.filter((a) => !a.isTalent); const talents = attacks.filter((a) => a.isTalent); @@ -29,7 +40,20 @@ export default function AttackBlock({ attacks }: AttackBlockProps) { {", "} {atk.damage} - + {mode === "view" && campaignId ? ( + = 0 ? "+" + atk.modifier : String(atk.modifier)}`} + label={`${atk.name} attack`} + damageDice={atk.damage} + damageLabel={`${atk.name} damage`} + /> + ) : ( + + )} ))} {talents.map((atk) => ( diff --git a/client/src/components/CharacterDetail.tsx b/client/src/components/CharacterDetail.tsx index c1ba6e3..f178c73 100644 --- a/client/src/components/CharacterDetail.tsx +++ b/client/src/components/CharacterDetail.tsx @@ -5,6 +5,7 @@ import styles from "./CharacterDetail.module.css"; interface CharacterDetailProps { character: Character; + campaignId: number; onUpdate: (id: number, data: Partial) => void; onStatChange: (characterId: number, statName: string, value: number) => void; onAddGearFromItem: (characterId: number, item: GameItem) => void; @@ -29,6 +30,7 @@ interface CharacterDetailProps { export default function CharacterDetail({ character, + campaignId, onUpdate, onStatChange, onAddGearFromItem, @@ -59,6 +61,7 @@ export default function CharacterDetail({ ) => void; onStatChange: (characterId: number, statName: string, value: number) => void; onAddGearFromItem: (characterId: number, item: GameItem) => void; @@ -35,6 +36,7 @@ interface CharacterSheetProps { export default function CharacterSheet({ character, mode, + campaignId, onUpdate, onStatChange, onAddGearFromItem, @@ -157,6 +159,7 @@ export default function CharacterSheet({ { + socket.emit("roll:request", { + campaignId, + characterId, + characterName, + type: "attack", + dice: damageDice, + label: damageLabel, + }); + }, 100); + } + } + + return ( + + ); +} diff --git a/client/src/components/RollEntry.module.css b/client/src/components/RollEntry.module.css new file mode 100644 index 0000000..cbff5e1 --- /dev/null +++ b/client/src/components/RollEntry.module.css @@ -0,0 +1,106 @@ +.card { + background: #0f1a30; + border: 1px solid #2a2a4a; + border-radius: 6px; + padding: 0.5rem 0.6rem; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.card.fresh { + border-color: #c9a84c; + animation: + slideIn 0.3s ease-out, + glow 1s ease-out; +} + +@keyframes glow { + 0% { + box-shadow: 0 0 8px rgba(201, 168, 76, 0.4); + } + 100% { + box-shadow: none; + } +} + +.topLine { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.2rem; +} + +.charName { + font-weight: 700; + font-size: 0.8rem; + color: #c9a84c; +} + +.timestamp { + font-size: 0.65rem; + color: #555; +} + +.label { + font-size: 0.75rem; + color: #888; + margin-bottom: 0.2rem; +} + +.breakdown { + font-size: 0.75rem; + color: #666; + margin-bottom: 0.15rem; +} + +.dieResult { + color: #e0e0e0; + font-weight: 600; +} + +.dieChosen { + color: #c9a84c; + font-weight: 700; +} + +.dieDiscarded { + color: #555; + text-decoration: line-through; +} + +.modLine { + font-size: 0.7rem; + color: #666; +} + +.total { + font-size: 1.3rem; + font-weight: 700; + color: #e0e0e0; + text-align: center; + margin-top: 0.2rem; +} + +.advantage { + color: #4caf50; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; +} + +.disadvantage { + color: #e74c3c; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; +} diff --git a/client/src/components/RollEntry.tsx b/client/src/components/RollEntry.tsx new file mode 100644 index 0000000..19063fa --- /dev/null +++ b/client/src/components/RollEntry.tsx @@ -0,0 +1,72 @@ +import type { RollResult } from "../types"; +import styles from "./RollEntry.module.css"; + +interface RollEntryProps { + roll: RollResult; + fresh?: boolean; +} + +function timeAgo(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr + "Z").getTime(); + const seconds = Math.floor((now - then) / 1000); + if (seconds < 10) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} + +export default function RollEntry({ roll, fresh }: RollEntryProps) { + const { rolls, advantage, disadvantage, dice_expression } = roll; + + const isAdvantage = advantage && rolls.length === 2; + const isDisadvantage = disadvantage && rolls.length === 2; + const chosen = isAdvantage + ? Math.max(...rolls) + : isDisadvantage + ? Math.min(...rolls) + : null; + + return ( +
+
+ {roll.character_name} + {timeAgo(roll.created_at)} +
+
+ {roll.label} + {isAdvantage && ADV} + {isDisadvantage && DIS} +
+
+ {dice_expression}: [ + {rolls.map((r, i) => ( + + {i > 0 && ", "} + + {r} + + + ))} + ]{chosen !== null && ` โ†’ ${chosen}`} +
+ {roll.modifier !== 0 && ( +
+ {roll.modifier > 0 ? "+" : ""} + {roll.modifier} +
+ )} +
{roll.total}
+
+ ); +} diff --git a/client/src/components/RollLog.module.css b/client/src/components/RollLog.module.css new file mode 100644 index 0000000..bd085ec --- /dev/null +++ b/client/src/components/RollLog.module.css @@ -0,0 +1,120 @@ +.panel { + width: 300px; + height: 100%; + background: #1a1a2e; + border-left: 1px solid #333; + display: flex; + flex-direction: column; + transition: width 0.2s; + flex-shrink: 0; +} + +.panel.collapsed { + width: 40px; + cursor: pointer; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #333; +} + +.title { + font-size: 0.85rem; + font-weight: 700; + color: #c9a84c; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.collapseBtn { + background: none; + border: none; + color: #888; + cursor: pointer; + font-size: 1rem; + padding: 0.2rem; +} + +.collapseBtn:hover { + color: #c9a84c; +} + +.collapsedContent { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 0.5rem; + gap: 0.5rem; +} + +.collapsedIcon { + font-size: 1.2rem; + cursor: pointer; +} + +.collapsedLast { + writing-mode: vertical-rl; + font-size: 0.7rem; + color: #888; + max-height: 100px; + overflow: hidden; +} + +.inputArea { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #333; +} + +.input { + width: 100%; + padding: 0.4rem 0.6rem; + background: #0f1a30; + border: 1px solid #333; + border-radius: 6px; + color: #e0e0e0; + font-size: 0.85rem; +} + +.input:focus { + outline: none; + border-color: #c9a84c; +} + +.hint { + font-size: 0.6rem; + color: #555; + margin-top: 0.25rem; + text-align: center; +} + +.entries { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + scrollbar-width: thin; + scrollbar-color: #333 transparent; +} + +.entries::-webkit-scrollbar { + width: 4px; +} + +.entries::-webkit-scrollbar-thumb { + background: #333; + border-radius: 2px; +} + +.empty { + text-align: center; + color: #555; + font-size: 0.8rem; + font-style: italic; + padding: 2rem 0; +} diff --git a/client/src/components/RollLog.tsx b/client/src/components/RollLog.tsx new file mode 100644 index 0000000..8d3a818 --- /dev/null +++ b/client/src/components/RollLog.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import type { RollResult } from "../types"; +import socket from "../socket"; +import RollEntry from "./RollEntry"; +import styles from "./RollLog.module.css"; + +interface RollLogProps { + campaignId: number; + rolls: RollResult[]; + freshIds: Set; +} + +export default function RollLog({ campaignId, rolls, freshIds }: RollLogProps) { + const [collapsed, setCollapsed] = useState(false); + const [input, setInput] = useState(""); + + function handleRoll(e: React.FormEvent) { + e.preventDefault(); + if (!input.trim()) return; + socket.emit("roll:request", { + campaignId, + type: "custom", + dice: input.trim(), + label: input.trim(), + }); + setInput(""); + } + + if (collapsed) { + return ( +
setCollapsed(false)} + > +
+ ๐ŸŽฒ + {rolls.length > 0 && ( + {rolls[0].total} + )} +
+
+ ); + } + + return ( +
+
+ Roll Log + +
+
+
+ setInput(e.target.value)} + placeholder="Roll dice... (e.g. 2d6+1)" + /> +
+
Shift: advantage ยท Ctrl: disadvantage
+
+
+ {rolls.length === 0 &&

No rolls yet

} + {rolls.map((roll) => ( + + ))} +
+
+ ); +} diff --git a/client/src/components/StatBlock.tsx b/client/src/components/StatBlock.tsx index f8f2db6..be2b675 100644 --- a/client/src/components/StatBlock.tsx +++ b/client/src/components/StatBlock.tsx @@ -1,5 +1,6 @@ import type { Stat } from "../types"; import { getModifier, formatModifier } from "../utils/modifiers"; +import DiceButton from "./DiceButton"; import styles from "./StatBlock.module.css"; const STAT_ORDER = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]; @@ -8,12 +9,18 @@ interface StatBlockProps { stats: Stat[]; onStatChange: (statName: string, newValue: number) => void; mode?: "view" | "edit"; + campaignId?: number; + characterId?: number; + characterName?: string; } export default function StatBlock({ stats, onStatChange, mode = "view", + campaignId, + characterId, + characterName, }: StatBlockProps) { const statMap = new Map(stats.map((s) => [s.stat_name, s.value])); @@ -35,6 +42,16 @@ export default function StatBlock({ )} {formatModifier(mod)} + {mode === "view" && campaignId && ( + = 0 ? "+" + mod : String(mod)}`} + label={`${name} check`} + /> + )} {mode === "edit" && ( - - -
- {characters.length === 0 && ( -

- No characters yet. Add one to get started! -

- )} - {characters.map((char) => ( - - ))} -
- - {selectedCharacter && ( - setSelectedId(null)} - /> - )} - - {showCreate && ( -
setShowCreate(false)} - > -
e.stopPropagation()} - onSubmit={handleCreate} - > -
New Character
-
- - - setNewChar({ ...newChar, name: e.target.value }) - } - autoFocus - /> -
-
- - -
-
- - -
-
- - - setNewChar({ ...newChar, hp_max: Number(e.target.value) }) - } - /> -
-
- - -
-
+
+
+
+ + โ† Campaigns + + Campaign +
- )} + +
+ {characters.length === 0 && ( +

+ No characters yet. Add one to get started! +

+ )} + {characters.map((char) => ( + + ))} +
+ + {selectedCharacter && ( + setSelectedId(null)} + /> + )} + + {showCreate && ( +
setShowCreate(false)} + > +
e.stopPropagation()} + onSubmit={handleCreate} + > +
New Character
+
+ + + setNewChar({ ...newChar, name: e.target.value }) + } + autoFocus + /> +
+
+ + +
+
+ + +
+
+ + + setNewChar({ ...newChar, hp_max: Number(e.target.value) }) + } + /> +
+
+ + +
+
+
+ )} +
+
); } diff --git a/client/src/types.ts b/client/src/types.ts index 9a87acf..78faf39 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -84,3 +84,19 @@ export interface AttackLine { isTalent: boolean; description?: string; } + +export interface RollResult { + id: number; + campaign_id: number; + character_id: number | null; + character_name: string; + type: "attack" | "ability-check" | "custom"; + label: string; + dice_expression: string; + rolls: number[]; + modifier: number; + total: number; + advantage: boolean; + disadvantage: boolean; + created_at: string; +} diff --git a/docs/plans/2026-04-09-dice-rolling.md b/docs/plans/2026-04-09-dice-rolling.md new file mode 100644 index 0000000..b37ee95 --- /dev/null +++ b/docs/plans/2026-04-09-dice-rolling.md @@ -0,0 +1,1210 @@ +# Dice Rolling โ€” Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a complete dice rolling system with server-side roll engine, shared real-time roll log panel, character sheet roll buttons, and advantage/disadvantage support. + +**Architecture:** Server-side dice parser and roller handles all roll logic. Rolls flow through Socket.IO: client emits `roll:request`, server generates results, saves to `roll_log` table, broadcasts `roll:result` to the campaign room. A collapsible side panel on the campaign view shows the shared roll history. Roll buttons appear on stat rows and attack lines in view mode. + +**Tech Stack:** Same as existing โ€” React 18, TypeScript, CSS Modules, Node/Express, Socket.IO, better-sqlite3 + +--- + +## File Structure + +``` +server/src/ +โ”œโ”€โ”€ dice.ts # CREATE: dice expression parser + roller +โ”œโ”€โ”€ routes/rolls.ts # CREATE: GET /api/campaigns/:id/rolls +โ”œโ”€โ”€ db.ts # MODIFY: add roll_log table +โ”œโ”€โ”€ socket.ts # MODIFY: handle roll:request events +โ”œโ”€โ”€ index.ts # MODIFY: register rolls route + +client/src/ +โ”œโ”€โ”€ types.ts # MODIFY: add RollResult type +โ”œโ”€โ”€ api.ts # MODIFY: add getRolls() +โ”œโ”€โ”€ components/ +โ”‚ โ”œโ”€โ”€ RollLog.tsx # CREATE: collapsible side panel +โ”‚ โ”œโ”€โ”€ RollLog.module.css # CREATE: panel styling +โ”‚ โ”œโ”€โ”€ RollEntry.tsx # CREATE: individual roll result card +โ”‚ โ”œโ”€โ”€ RollEntry.module.css # CREATE: card styling + animation +โ”‚ โ”œโ”€โ”€ DiceButton.tsx # CREATE: small reusable dice button +โ”‚ โ”œโ”€โ”€ DiceButton.module.css # CREATE: button styling +โ”‚ โ”œโ”€โ”€ StatsPanel.tsx # MODIFY: add DiceButton to stat rows +โ”‚ โ””โ”€โ”€ AttackBlock.tsx # MODIFY: add DiceButton to attack lines +โ”œโ”€โ”€ pages/ +โ”‚ โ”œโ”€โ”€ CampaignView.tsx # MODIFY: add RollLog panel, socket events +โ”‚ โ””โ”€โ”€ CampaignView.module.css # MODIFY: layout with side panel +``` + +--- + +### Task 1: Dice Parser + Roll Engine (Server) + +**Files:** + +- Create: `server/src/dice.ts` + +- [ ] **Step 1: Create server/src/dice.ts** + +```ts +export interface ParsedDice { + count: number; + sides: number; + modifier: number; + raw: string; +} + +export interface RollResult { + rolls: number[]; + modifier: number; + total: number; + expression: string; + error?: string; +} + +export function parseDice(expression: string): ParsedDice | null { + const cleaned = expression.trim().toLowerCase().replace(/\s/g, ""); + const match = cleaned.match(/^(\d*)d(\d+)([+-]\d+)?$/); + if (!match) return null; + + const count = match[1] ? parseInt(match[1], 10) : 1; + const sides = parseInt(match[2], 10); + const modifier = match[3] ? parseInt(match[3], 10) : 0; + + if (count < 1 || count > 100 || sides < 1 || sides > 100) return null; + + return { count, sides, modifier, raw: expression.trim() }; +} + +export function rollDice( + expression: string, + options?: { advantage?: boolean; disadvantage?: boolean }, +): RollResult { + const parsed = parseDice(expression); + if (!parsed) { + return { + rolls: [], + modifier: 0, + total: 0, + expression, + error: `Couldn't parse: ${expression}`, + }; + } + + const { count, sides, modifier } = parsed; + + // Advantage/disadvantage: roll the die twice, pick higher/lower + if ( + (options?.advantage || options?.disadvantage) && + count === 1 && + sides === 20 + ) { + const roll1 = Math.floor(Math.random() * sides) + 1; + const roll2 = Math.floor(Math.random() * sides) + 1; + const chosen = options.advantage + ? Math.max(roll1, roll2) + : Math.min(roll1, roll2); + return { + rolls: [roll1, roll2], + modifier, + total: chosen + modifier, + expression, + }; + } + + // Normal roll + const rolls: number[] = []; + for (let i = 0; i < count; i++) { + rolls.push(Math.floor(Math.random() * sides) + 1); + } + const sum = rolls.reduce((a, b) => a + b, 0); + + return { + rolls, + modifier, + total: sum + modifier, + expression, + }; +} +``` + +- [ ] **Step 2: Verify dice module loads** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx -e " +import { parseDice, rollDice } from './src/dice.js'; +console.log('parse 2d6+3:', parseDice('2d6+3')); +console.log('parse d20:', parseDice('d20')); +console.log('parse invalid:', parseDice('abc')); +console.log('roll 1d20:', rollDice('1d20')); +console.log('roll 2d6+1:', rollDice('2d6+1')); +console.log('roll adv:', rollDice('1d20', { advantage: true })); +console.log('roll disadv:', rollDice('1d20', { disadvantage: true })); +" +``` + +Expected: Parsed objects for valid expressions, null for invalid, roll results with arrays. + +--- + +### Task 2: Roll Log Table + Rolls API Endpoint + +**Files:** + +- Modify: `server/src/db.ts` +- Create: `server/src/routes/rolls.ts` +- Modify: `server/src/index.ts` + +- [ ] **Step 1: Add roll_log table to server/src/db.ts** + +Read the file first. Add inside the `db.exec()` block, after the game_items table: + +```sql + CREATE TABLE IF NOT EXISTS roll_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + character_id INTEGER, + character_name TEXT NOT NULL DEFAULT 'Roll', + type TEXT NOT NULL DEFAULT 'custom', + label TEXT NOT NULL, + dice_expression TEXT NOT NULL, + rolls TEXT NOT NULL DEFAULT '[]', + modifier INTEGER NOT NULL DEFAULT 0, + total INTEGER NOT NULL DEFAULT 0, + advantage INTEGER NOT NULL DEFAULT 0, + disadvantage INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); +``` + +- [ ] **Step 2: Create server/src/routes/rolls.ts** + +```ts +import { Router } from "express"; +import db from "../db.js"; + +const router = Router({ mergeParams: true }); + +// GET /api/campaigns/:campaignId/rolls โ€” last 50 rolls +router.get("/", (req, res) => { + const { campaignId } = req.params; + const rolls = db + .prepare( + "SELECT * FROM roll_log WHERE campaign_id = ? ORDER BY created_at DESC LIMIT 50", + ) + .all(campaignId) as Array>; + + const parsed = rolls.map((r) => ({ + ...r, + rolls: JSON.parse(r.rolls as string), + advantage: r.advantage === 1, + disadvantage: r.disadvantage === 1, + })); + + res.json(parsed); +}); + +export default router; +``` + +- [ ] **Step 3: Register rolls route in server/src/index.ts** + +Read the file first. Add after existing route registrations: + +```ts +import rollRoutes from "./routes/rolls.js"; + +app.use("/api/campaigns/:campaignId/rolls", rollRoutes); +``` + +- [ ] **Step 4: Verify** + +```bash +rm -f /Users/aaron.wood/workspace/shadowdark/server/data/shadowdark.db +cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx -e " +import db from './src/db.js'; +const cols = db.prepare('PRAGMA table_info(roll_log)').all(); +console.log('roll_log cols:', cols.map((c: any) => c.name).join(', ')); +" +``` + +Expected: All roll_log columns listed. + +--- + +### Task 3: Socket.IO Roll Handler + +**Files:** + +- Modify: `server/src/socket.ts` + +- [ ] **Step 1: Update server/src/socket.ts to handle roll requests** + +Read the file first. Replace the entire file: + +```ts +import { Server } from "socket.io"; +import db from "./db.js"; +import { rollDice } from "./dice.js"; + +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( + "roll:request", + (data: { + campaignId: number; + characterId?: number; + characterName?: string; + type: string; + dice: string; + label: string; + modifier?: number; + advantage?: boolean; + disadvantage?: boolean; + }) => { + const result = rollDice(data.dice, { + advantage: data.advantage, + disadvantage: data.disadvantage, + }); + + if (result.error) { + socket.emit("roll:error", { error: result.error }); + return; + } + + const row = db + .prepare( + ` + INSERT INTO roll_log (campaign_id, character_id, character_name, type, label, dice_expression, rolls, modifier, total, advantage, disadvantage) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + data.campaignId, + data.characterId ?? null, + data.characterName || "Roll", + data.type || "custom", + data.label, + data.dice, + JSON.stringify(result.rolls), + result.modifier, + result.total, + data.advantage ? 1 : 0, + data.disadvantage ? 1 : 0, + ); + + const saved = db + .prepare("SELECT * FROM roll_log WHERE id = ?") + .get(row.lastInsertRowid) as Record; + + const broadcast = { + ...saved, + rolls: result.rolls, + advantage: data.advantage || false, + disadvantage: data.disadvantage || false, + }; + + io.to(`campaign:${data.campaignId}`).emit("roll:result", broadcast); + }, + ); + + 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); +} +``` + +- [ ] **Step 2: Verify server starts and roll endpoint works** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/index.ts & +sleep 2 +curl -s http://localhost:3000/api/campaigns/1/rolls | python3 -m json.tool +``` + +Expected: Empty array `[]` (no rolls yet). Kill server after. + +--- + +### Task 4: Client Types + API + +**Files:** + +- Modify: `client/src/types.ts` +- Modify: `client/src/api.ts` + +- [ ] **Step 1: Add RollResult type to client/src/types.ts** + +Read the file first. Add after AttackLine: + +```ts +export interface RollResult { + id: number; + campaign_id: number; + character_id: number | null; + character_name: string; + type: "attack" | "ability-check" | "custom"; + label: string; + dice_expression: string; + rolls: number[]; + modifier: number; + total: number; + advantage: boolean; + disadvantage: boolean; + created_at: string; +} +``` + +- [ ] **Step 2: Add getRolls to client/src/api.ts** + +Read the file first. Add the import and function: + +```ts +import type { + Campaign, + Character, + Gear, + Talent, + GameItem, + GameTalent, + RollResult, +} from "./types"; +``` + +```ts +// Rolls +export const getRolls = (campaignId: number) => + request(`/campaigns/${campaignId}/rolls`); +``` + +--- + +### Task 5: DiceButton + RollEntry Components + +**Files:** + +- Create: `client/src/components/DiceButton.tsx` +- Create: `client/src/components/DiceButton.module.css` +- Create: `client/src/components/RollEntry.tsx` +- Create: `client/src/components/RollEntry.module.css` + +- [ ] **Step 1: Create DiceButton.module.css** + +```css +.btn { + width: 24px; + height: 24px; + border-radius: 4px; + border: 1px solid #444; + background: #16213e; + color: #888; + cursor: pointer; + font-size: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + transition: + border-color 0.15s, + color 0.15s, + background 0.15s; +} + +.btn:hover { + border-color: #c9a84c; + color: #c9a84c; + background: rgba(201, 168, 76, 0.1); +} + +.btn:active { + background: rgba(201, 168, 76, 0.25); +} +``` + +- [ ] **Step 2: Create DiceButton.tsx** + +```tsx +import socket from "../socket"; +import styles from "./DiceButton.module.css"; + +interface DiceButtonProps { + campaignId: number; + characterId?: number; + characterName?: string; + type: "attack" | "ability-check" | "custom"; + dice: string; + label: string; + damageDice?: string; + damageLabel?: string; +} + +export default function DiceButton({ + campaignId, + characterId, + characterName, + type, + dice, + label, + damageDice, + damageLabel, +}: DiceButtonProps) { + function handleClick(e: React.MouseEvent) { + const advantage = e.shiftKey; + const disadvantage = e.ctrlKey || e.metaKey; + + socket.emit("roll:request", { + campaignId, + characterId, + characterName, + type, + dice, + label, + advantage: advantage && !disadvantage, + disadvantage: disadvantage && !advantage, + }); + + // If this is an attack with a damage die, roll damage too + if (damageDice && damageLabel) { + setTimeout(() => { + socket.emit("roll:request", { + campaignId, + characterId, + characterName, + type: "attack", + dice: damageDice, + label: damageLabel, + }); + }, 100); + } + } + + return ( + + ); +} +``` + +- [ ] **Step 3: Create RollEntry.module.css** + +```css +.card { + background: #0f1a30; + border: 1px solid #2a2a4a; + border-radius: 6px; + padding: 0.5rem 0.6rem; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.card.fresh { + border-color: #c9a84c; + animation: + slideIn 0.3s ease-out, + glow 1s ease-out; +} + +@keyframes glow { + 0% { + box-shadow: 0 0 8px rgba(201, 168, 76, 0.4); + } + 100% { + box-shadow: none; + } +} + +.topLine { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.2rem; +} + +.charName { + font-weight: 700; + font-size: 0.8rem; + color: #c9a84c; +} + +.timestamp { + font-size: 0.65rem; + color: #555; +} + +.label { + font-size: 0.75rem; + color: #888; + margin-bottom: 0.2rem; +} + +.breakdown { + font-size: 0.75rem; + color: #666; + margin-bottom: 0.15rem; +} + +.dieResult { + color: #e0e0e0; + font-weight: 600; +} + +.dieChosen { + color: #c9a84c; + font-weight: 700; +} + +.dieDiscarded { + color: #555; + text-decoration: line-through; +} + +.modLine { + font-size: 0.7rem; + color: #666; +} + +.total { + font-size: 1.3rem; + font-weight: 700; + color: #e0e0e0; + text-align: center; + margin-top: 0.2rem; +} + +.advantage { + color: #4caf50; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; +} + +.disadvantage { + color: #e74c3c; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; +} + +.error { + color: #e74c3c; + font-size: 0.8rem; + font-style: italic; +} +``` + +- [ ] **Step 4: Create RollEntry.tsx** + +```tsx +import type { RollResult } from "../types"; +import styles from "./RollEntry.module.css"; + +interface RollEntryProps { + roll: RollResult; + fresh?: boolean; +} + +function timeAgo(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr + "Z").getTime(); + const seconds = Math.floor((now - then) / 1000); + if (seconds < 10) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} + +function formatBreakdown(roll: RollResult): string { + const { rolls, advantage, disadvantage, dice_expression } = roll; + + if ((advantage || disadvantage) && rolls.length === 2) { + const chosen = advantage ? Math.max(...rolls) : Math.min(...rolls); + const parts = rolls.map((r) => (r === chosen ? `**${r}**` : `~~${r}~~`)); + return `d20: [${parts.join(", ")}] โ†’ ${chosen}`; + } + + return `${dice_expression}: [${rolls.join(", ")}]`; +} + +export default function RollEntry({ roll, fresh }: RollEntryProps) { + const { rolls, advantage, disadvantage, dice_expression } = roll; + + const isAdvantage = advantage && rolls.length === 2; + const isDisadvantage = disadvantage && rolls.length === 2; + const chosen = isAdvantage + ? Math.max(...rolls) + : isDisadvantage + ? Math.min(...rolls) + : null; + + return ( +
+
+ {roll.character_name} + {timeAgo(roll.created_at)} +
+
+ {roll.label} + {isAdvantage && ADV} + {isDisadvantage && DIS} +
+
+ {dice_expression}: [ + {rolls.map((r, i) => ( + + {i > 0 && ", "} + + {r} + + + ))} + ]{chosen !== null && ` โ†’ ${chosen}`} +
+ {roll.modifier !== 0 && ( +
+ {roll.modifier > 0 ? "+" : ""} + {roll.modifier} +
+ )} +
{roll.total}
+
+ ); +} +``` + +--- + +### Task 6: RollLog Side Panel + +**Files:** + +- Create: `client/src/components/RollLog.tsx` +- Create: `client/src/components/RollLog.module.css` + +- [ ] **Step 1: Create RollLog.module.css** + +```css +.panel { + width: 300px; + height: 100%; + background: #1a1a2e; + border-left: 1px solid #333; + display: flex; + flex-direction: column; + transition: width 0.2s; +} + +.panel.collapsed { + width: 40px; + cursor: pointer; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #333; +} + +.title { + font-size: 0.85rem; + font-weight: 700; + color: #c9a84c; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.collapseBtn { + background: none; + border: none; + color: #888; + cursor: pointer; + font-size: 1rem; + padding: 0.2rem; +} + +.collapseBtn:hover { + color: #c9a84c; +} + +.collapsedContent { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 0.5rem; + gap: 0.5rem; +} + +.collapsedIcon { + font-size: 1.2rem; + cursor: pointer; +} + +.collapsedLast { + writing-mode: vertical-rl; + font-size: 0.7rem; + color: #888; + max-height: 100px; + overflow: hidden; +} + +.inputArea { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #333; +} + +.input { + width: 100%; + padding: 0.4rem 0.6rem; + background: #0f1a30; + border: 1px solid #333; + border-radius: 6px; + color: #e0e0e0; + font-size: 0.85rem; +} + +.input:focus { + outline: none; + border-color: #c9a84c; +} + +.hint { + font-size: 0.6rem; + color: #555; + margin-top: 0.25rem; + text-align: center; +} + +.entries { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + scrollbar-width: thin; + scrollbar-color: #333 transparent; +} + +.entries::-webkit-scrollbar { + width: 4px; +} + +.entries::-webkit-scrollbar-thumb { + background: #333; + border-radius: 2px; +} + +.empty { + text-align: center; + color: #555; + font-size: 0.8rem; + font-style: italic; + padding: 2rem 0; +} +``` + +- [ ] **Step 2: Create RollLog.tsx** + +```tsx +import { useState } from "react"; +import type { RollResult } from "../types"; +import socket from "../socket"; +import RollEntry from "./RollEntry"; +import styles from "./RollLog.module.css"; + +interface RollLogProps { + campaignId: number; + rolls: RollResult[]; + freshIds: Set; +} + +export default function RollLog({ campaignId, rolls, freshIds }: RollLogProps) { + const [collapsed, setCollapsed] = useState(false); + const [input, setInput] = useState(""); + + function handleRoll(e: React.FormEvent) { + e.preventDefault(); + if (!input.trim()) return; + socket.emit("roll:request", { + campaignId, + type: "custom", + dice: input.trim(), + label: input.trim(), + }); + setInput(""); + } + + if (collapsed) { + return ( +
setCollapsed(false)} + > +
+ ๐ŸŽฒ + {rolls.length > 0 && ( + {rolls[0].total} + )} +
+
+ ); + } + + return ( +
+
+ Roll Log + +
+
+
+ setInput(e.target.value)} + placeholder="Roll dice... (e.g. 2d6+1)" + /> +
+
Shift: advantage ยท Ctrl: disadvantage
+
+
+ {rolls.length === 0 &&

No rolls yet

} + {rolls.map((roll) => ( + + ))} +
+
+ ); +} +``` + +--- + +### Task 7: Add DiceButton to StatsPanel and AttackBlock + +**Files:** + +- Modify: `client/src/components/StatsPanel.tsx` +- Modify: `client/src/components/StatBlock.tsx` +- Modify: `client/src/components/StatBlock.module.css` +- Modify: `client/src/components/AttackBlock.tsx` + +- [ ] **Step 1: Add campaignId prop to StatsPanel and pass to StatBlock** + +Read StatsPanel.tsx. Add `campaignId` to the props interface: + +```tsx +interface StatsPanelProps { + character: Character; + mode: "view" | "edit"; + campaignId: number; + onStatChange: (characterId: number, statName: string, value: number) => void; +} +``` + +Pass `campaignId` and character info to StatBlock: + +```tsx + + onStatChange(character.id, statName, value) + } + mode={mode} + campaignId={campaignId} + characterId={character.id} + characterName={character.name} +/> +``` + +Also pass campaignId and character to AttackBlock: + +```tsx + +``` + +- [ ] **Step 2: Update StatBlock to show DiceButton in view mode** + +Read StatBlock.tsx. Add `campaignId`, `characterId`, `characterName` to props: + +```tsx +interface StatBlockProps { + stats: Stat[]; + onStatChange: (statName: string, newValue: number) => void; + mode?: "view" | "edit"; + campaignId?: number; + characterId?: number; + characterName?: string; +} +``` + +Import DiceButton and getModifier/formatModifier. In view mode, after the modifier span, render a DiceButton: + +```tsx +{ + mode === "view" && campaignId && ( + = 0 ? "+" + mod : String(mod)}`} + label={`${name} check`} + /> + ); +} +``` + +Import at top: + +```tsx +import DiceButton from "./DiceButton"; +``` + +Remove the `.rollSpace` CSS class usage since DiceButton replaces it. + +- [ ] **Step 3: Update AttackBlock to show DiceButton in view mode** + +Read AttackBlock.tsx. Add `campaignId`, `characterId`, `characterName`, `mode` to props: + +```tsx +interface AttackBlockProps { + attacks: AttackLine[]; + campaignId?: number; + characterId?: number; + characterName?: string; + mode?: "view" | "edit"; +} +``` + +Import DiceButton. Replace the rollSpace span in each weapon line with a DiceButton: + +```tsx +{ + mode === "view" && campaignId ? ( + = 0 ? "+" + atk.modifier : String(atk.modifier)}`} + label={`${atk.name} attack`} + damageDice={atk.damage} + damageLabel={`${atk.name} damage`} + /> + ) : ( + + ); +} +``` + +--- + +### Task 8: Wire RollLog into CampaignView + +**Files:** + +- Modify: `client/src/pages/CampaignView.tsx` +- Modify: `client/src/pages/CampaignView.module.css` +- Modify: `client/src/components/CharacterSheet.tsx` + +- [ ] **Step 1: Update CampaignView layout for side panel** + +Read CampaignView.module.css. Wrap the existing content in a flex container with the roll log on the right. Add: + +```css +.layout { + display: flex; + height: calc(100vh - 100px); +} + +.main { + flex: 1; + overflow-y: auto; + padding-right: 0.5rem; +} +``` + +- [ ] **Step 2: Update CampaignView.tsx** + +Read the file. Add these changes: + +a) Import RollLog and getRolls: + +```tsx +import RollLog from "../components/RollLog"; +import { getRolls } from "../api"; +import type { Character, Gear, Talent, GameItem, RollResult } from "../types"; +``` + +b) Add state for rolls and fresh IDs: + +```tsx +const [rolls, setRolls] = useState([]); +const [freshIds, setFreshIds] = useState>(new Set()); +``` + +c) In the useEffect that fetches characters and joins the socket room, also fetch rolls: + +```tsx +getRolls(campaignId).then(setRolls); +``` + +d) Add socket listener for `roll:result` in the socket useEffect: + +```tsx +function onRollResult(roll: RollResult) { + setRolls((prev) => [roll, ...prev].slice(0, 50)); + setFreshIds((prev) => new Set(prev).add(roll.id)); + setTimeout(() => { + setFreshIds((prev) => { + const next = new Set(prev); + next.delete(roll.id); + return next; + }); + }, 2000); +} + +socket.on("roll:result", onRollResult); +``` + +And in the cleanup: + +```tsx +socket.off("roll:result", onRollResult); +``` + +e) Wrap the JSX in the layout flex container and add RollLog: + +```tsx +return ( +
+
{/* existing header, grid, modals */}
+ +
+); +``` + +f) Pass `campaignId` to CharacterDetail so it can reach StatsPanel/AttackBlock. Add `campaignId` prop to CharacterDetail, CharacterSheet: + +In CharacterDetail.tsx, add `campaignId: number` to props and pass through to CharacterSheet. +In CharacterSheet.tsx, add `campaignId: number` to props and pass to StatsPanel: + +```tsx + +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit +``` + +Expected: No errors. + +--- + +### Task 9: End-to-End Smoke Test + +**Files:** None (testing only) + +- [ ] **Step 1: Reset DB and start full stack** + +```bash +rm -f /Users/aaron.wood/workspace/shadowdark/server/data/shadowdark.db +lsof -ti:3000 | xargs kill 2>/dev/null; lsof -ti:5173 | xargs kill 2>/dev/null +cd /Users/aaron.wood/workspace/shadowdark && npm run dev +``` + +- [ ] **Step 2: Test in browser** + +Open http://localhost:5173, go into a campaign: + +1. **Roll log panel** visible on the right side +2. Type "1d20" in the general roller input, press Enter โ€” roll result appears in the log +3. Type "2d6+3" โ€” result shows two dice and modifier +4. Collapse the panel โ€” should show thin strip with last result +5. Expand โ€” full panel returns + +- [ ] **Step 3: Test character sheet roll buttons** + +1. Open a character sheet (view mode) +2. Click ๐ŸŽฒ next to a stat โ€” roll appears in log with character name and "STR check" etc. +3. Shift+click โ€” roll shows ADV with two d20s +4. Ctrl+click โ€” roll shows DIS with two d20s +5. Click ๐ŸŽฒ on an attack line โ€” two rolls appear: attack d20 then damage die + +- [ ] **Step 4: Test real-time sync** + +Open second tab: + +1. Roll in tab 1 โ€” appears in tab 2's roll log +2. Roll in tab 2 โ€” appears in tab 1's roll log +3. Both see the pop-in animation + +- [ ] **Step 5: Test persistence** + +1. Make several rolls +2. Refresh the page +3. Navigate back to the campaign +4. Roll log should show previous rolls (loaded from DB) diff --git a/server/src/db.ts b/server/src/db.ts index 13d62a7..14c5327 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -77,6 +77,22 @@ db.exec(` description TEXT DEFAULT '', effect TEXT DEFAULT '{}' ); + + CREATE TABLE IF NOT EXISTS roll_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + character_id INTEGER, + character_name TEXT NOT NULL DEFAULT 'Roll', + type TEXT NOT NULL DEFAULT 'custom', + label TEXT NOT NULL, + dice_expression TEXT NOT NULL, + rolls TEXT NOT NULL DEFAULT '[]', + modifier INTEGER NOT NULL DEFAULT 0, + total INTEGER NOT NULL DEFAULT 0, + advantage INTEGER NOT NULL DEFAULT 0, + disadvantage INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); `); // --- Migrations for v2 --- diff --git a/server/src/dice.ts b/server/src/dice.ts new file mode 100644 index 0000000..ee487b6 --- /dev/null +++ b/server/src/dice.ts @@ -0,0 +1,77 @@ +export interface ParsedDice { + count: number; + sides: number; + modifier: number; + raw: string; +} + +export interface RollResult { + rolls: number[]; + modifier: number; + total: number; + expression: string; + error?: string; +} + +export function parseDice(expression: string): ParsedDice | null { + const cleaned = expression.trim().toLowerCase().replace(/\s/g, ""); + const match = cleaned.match(/^(\d*)d(\d+)([+-]\d+)?$/); + if (!match) return null; + + const count = match[1] ? parseInt(match[1], 10) : 1; + const sides = parseInt(match[2], 10); + const modifier = match[3] ? parseInt(match[3], 10) : 0; + + if (count < 1 || count > 100 || sides < 1 || sides > 100) return null; + + return { count, sides, modifier, raw: expression.trim() }; +} + +export function rollDice( + expression: string, + options?: { advantage?: boolean; disadvantage?: boolean }, +): RollResult { + const parsed = parseDice(expression); + if (!parsed) { + return { + rolls: [], + modifier: 0, + total: 0, + expression, + error: `Couldn't parse: ${expression}`, + }; + } + + const { count, sides, modifier } = parsed; + + if ( + (options?.advantage || options?.disadvantage) && + count === 1 && + sides === 20 + ) { + const roll1 = Math.floor(Math.random() * sides) + 1; + const roll2 = Math.floor(Math.random() * sides) + 1; + const chosen = options.advantage + ? Math.max(roll1, roll2) + : Math.min(roll1, roll2); + return { + rolls: [roll1, roll2], + modifier, + total: chosen + modifier, + expression, + }; + } + + const rolls: number[] = []; + for (let i = 0; i < count; i++) { + rolls.push(Math.floor(Math.random() * sides) + 1); + } + const sum = rolls.reduce((a, b) => a + b, 0); + + return { + rolls, + modifier, + total: sum + modifier, + expression, + }; +} diff --git a/server/src/index.ts b/server/src/index.ts index 02d491f..8f6087a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,6 +7,7 @@ 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"; +import rollRoutes from "./routes/rolls.js"; const app = express(); const httpServer = createServer(app); @@ -27,6 +28,7 @@ app.use("/api/campaigns/:campaignId/characters", characterRoutes); app.use("/api/characters", characterRoutes); app.use("/api/game-items", gameItemRoutes); app.use("/api/game-talents", gameTalentRoutes); +app.use("/api/campaigns/:campaignId/rolls", rollRoutes); const PORT = process.env.PORT || 3000; httpServer.listen(PORT, () => { diff --git a/server/src/routes/rolls.ts b/server/src/routes/rolls.ts new file mode 100644 index 0000000..b52f7c7 --- /dev/null +++ b/server/src/routes/rolls.ts @@ -0,0 +1,24 @@ +import { Router } from "express"; +import db from "../db.js"; + +const router = Router({ mergeParams: true }); + +router.get("/", (req, res) => { + const { campaignId } = req.params; + const rolls = db + .prepare( + "SELECT * FROM roll_log WHERE campaign_id = ? ORDER BY created_at DESC LIMIT 50", + ) + .all(campaignId) as Array>; + + const parsed = rolls.map((r) => ({ + ...r, + rolls: JSON.parse(r.rolls as string), + advantage: r.advantage === 1, + disadvantage: r.disadvantage === 1, + })); + + res.json(parsed); +}); + +export default router; diff --git a/server/src/socket.ts b/server/src/socket.ts index bd0eddf..a9c1c9a 100644 --- a/server/src/socket.ts +++ b/server/src/socket.ts @@ -1,4 +1,6 @@ import { Server } from "socket.io"; +import db from "./db.js"; +import { rollDice } from "./dice.js"; export function setupSocket(io: Server) { io.on("connection", (socket) => { @@ -10,6 +12,65 @@ export function setupSocket(io: Server) { socket.leave(`campaign:${campaignId}`); }); + socket.on( + "roll:request", + (data: { + campaignId: number; + characterId?: number; + characterName?: string; + type: string; + dice: string; + label: string; + modifier?: number; + advantage?: boolean; + disadvantage?: boolean; + }) => { + const result = rollDice(data.dice, { + advantage: data.advantage, + disadvantage: data.disadvantage, + }); + + if (result.error) { + socket.emit("roll:error", { error: result.error }); + return; + } + + const row = db + .prepare( + ` + INSERT INTO roll_log (campaign_id, character_id, character_name, type, label, dice_expression, rolls, modifier, total, advantage, disadvantage) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + data.campaignId, + data.characterId ?? null, + data.characterName || "Roll", + data.type || "custom", + data.label, + data.dice, + JSON.stringify(result.rolls), + result.modifier, + result.total, + data.advantage ? 1 : 0, + data.disadvantage ? 1 : 0, + ); + + const saved = db + .prepare("SELECT * FROM roll_log WHERE id = ?") + .get(row.lastInsertRowid) as Record; + + const broadcast = { + ...saved, + rolls: result.rolls, + advantage: data.advantage || false, + disadvantage: data.disadvantage || false, + }; + + io.to(`campaign:${data.campaignId}`).emit("roll:result", broadcast); + }, + ); + socket.on("disconnect", () => { // Rooms are cleaned up automatically by Socket.IO }); @@ -20,7 +81,7 @@ export function broadcastToCampaign( io: Server, campaignId: number, event: string, - data: unknown + data: unknown, ) { io.to(`campaign:${campaignId}`).emit(event, data); }