1210 lines
28 KiB
Markdown
1210 lines
28 KiB
Markdown
# 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<Record<string, unknown>>;
|
|
|
|
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<string, unknown>;
|
|
|
|
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<RollResult[]>(`/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 (
|
|
<button
|
|
className={styles.btn}
|
|
onClick={handleClick}
|
|
title={`Roll ${dice} (Shift: advantage, Ctrl: disadvantage)`}
|
|
>
|
|
🎲
|
|
</button>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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 (
|
|
<div className={`${styles.card} ${fresh ? styles.fresh : ""}`}>
|
|
<div className={styles.topLine}>
|
|
<span className={styles.charName}>{roll.character_name}</span>
|
|
<span className={styles.timestamp}>{timeAgo(roll.created_at)}</span>
|
|
</div>
|
|
<div className={styles.label}>
|
|
{roll.label}
|
|
{isAdvantage && <span className={styles.advantage}> ADV</span>}
|
|
{isDisadvantage && <span className={styles.disadvantage}> DIS</span>}
|
|
</div>
|
|
<div className={styles.breakdown}>
|
|
{dice_expression}: [
|
|
{rolls.map((r, i) => (
|
|
<span key={i}>
|
|
{i > 0 && ", "}
|
|
<span
|
|
className={
|
|
chosen === null
|
|
? styles.dieResult
|
|
: r === chosen
|
|
? styles.dieChosen
|
|
: styles.dieDiscarded
|
|
}
|
|
>
|
|
{r}
|
|
</span>
|
|
</span>
|
|
))}
|
|
]{chosen !== null && ` → ${chosen}`}
|
|
</div>
|
|
{roll.modifier !== 0 && (
|
|
<div className={styles.modLine}>
|
|
{roll.modifier > 0 ? "+" : ""}
|
|
{roll.modifier}
|
|
</div>
|
|
)}
|
|
<div className={styles.total}>{roll.total}</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 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<number>;
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className={`${styles.panel} ${styles.collapsed}`}
|
|
onClick={() => setCollapsed(false)}
|
|
>
|
|
<div className={styles.collapsedContent}>
|
|
<span className={styles.collapsedIcon}>🎲</span>
|
|
{rolls.length > 0 && (
|
|
<span className={styles.collapsedLast}>{rolls[0].total}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.panel}>
|
|
<div className={styles.header}>
|
|
<span className={styles.title}>Roll Log</span>
|
|
<button
|
|
className={styles.collapseBtn}
|
|
onClick={() => setCollapsed(true)}
|
|
>
|
|
▸
|
|
</button>
|
|
</div>
|
|
<div className={styles.inputArea}>
|
|
<form onSubmit={handleRoll}>
|
|
<input
|
|
className={styles.input}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
placeholder="Roll dice... (e.g. 2d6+1)"
|
|
/>
|
|
</form>
|
|
<div className={styles.hint}>Shift: advantage · Ctrl: disadvantage</div>
|
|
</div>
|
|
<div className={styles.entries}>
|
|
{rolls.length === 0 && <p className={styles.empty}>No rolls yet</p>}
|
|
{rolls.map((roll) => (
|
|
<RollEntry key={roll.id} roll={roll} fresh={freshIds.has(roll.id)} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 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
|
|
<StatBlock
|
|
stats={character.stats}
|
|
onStatChange={(statName, value) =>
|
|
onStatChange(character.id, statName, value)
|
|
}
|
|
mode={mode}
|
|
campaignId={campaignId}
|
|
characterId={character.id}
|
|
characterName={character.name}
|
|
/>
|
|
```
|
|
|
|
Also pass campaignId and character to AttackBlock:
|
|
|
|
```tsx
|
|
<AttackBlock
|
|
attacks={attacks}
|
|
campaignId={campaignId}
|
|
characterId={character.id}
|
|
characterName={character.name}
|
|
mode={mode}
|
|
/>
|
|
```
|
|
|
|
- [ ] **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 && (
|
|
<DiceButton
|
|
campaignId={campaignId}
|
|
characterId={characterId}
|
|
characterName={characterName}
|
|
type="ability-check"
|
|
dice={`1d20${mod >= 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 ? (
|
|
<DiceButton
|
|
campaignId={campaignId}
|
|
characterId={characterId}
|
|
characterName={characterName}
|
|
type="attack"
|
|
dice={`1d20${atk.modifier >= 0 ? "+" + atk.modifier : String(atk.modifier)}`}
|
|
label={`${atk.name} attack`}
|
|
damageDice={atk.damage}
|
|
damageLabel={`${atk.name} damage`}
|
|
/>
|
|
) : (
|
|
<span className={styles.rollSpace}></span>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 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<RollResult[]>([]);
|
|
const [freshIds, setFreshIds] = useState<Set<number>>(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 (
|
|
<div className={styles.layout}>
|
|
<div className={styles.main}>{/* existing header, grid, modals */}</div>
|
|
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} />
|
|
</div>
|
|
);
|
|
```
|
|
|
|
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
|
|
<StatsPanel
|
|
character={character}
|
|
mode={mode}
|
|
campaignId={campaignId}
|
|
onStatChange={onStatChange}
|
|
/>
|
|
```
|
|
|
|
- [ ] **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)
|