Add dice rolling: server-side engine, roll log panel, DiceButton on stats/attacks, advantage/disadvantage, real-time sync

This commit is contained in:
Aaron Wood 2026-04-09 01:25:29 -04:00
parent 46f5227fa9
commit 20a1778cbd
21 changed files with 2098 additions and 132 deletions

View file

@ -5,6 +5,7 @@ import type {
Talent, Talent,
GameItem, GameItem,
GameTalent, GameTalent,
RollResult,
} from "./types"; } from "./types";
const BASE = "/api"; const BASE = "/api";
@ -107,3 +108,7 @@ export const getGameItems = () => request<GameItem[]>("/game-items");
// Game Talents // Game Talents
export const getGameTalents = () => request<GameTalent[]>("/game-talents"); export const getGameTalents = () => request<GameTalent[]>("/game-talents");
// Rolls
export const getRolls = (campaignId: number) =>
request<RollResult[]>(`/campaigns/${campaignId}/rolls`);

View file

@ -1,11 +1,22 @@
import type { AttackLine } from "../types"; import type { AttackLine } from "../types";
import DiceButton from "./DiceButton";
import styles from "./AttackBlock.module.css"; import styles from "./AttackBlock.module.css";
interface AttackBlockProps { interface AttackBlockProps {
attacks: AttackLine[]; 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 weapons = attacks.filter((a) => !a.isTalent);
const talents = attacks.filter((a) => a.isTalent); const talents = attacks.filter((a) => a.isTalent);
@ -29,7 +40,20 @@ export default function AttackBlock({ attacks }: AttackBlockProps) {
{", "} {", "}
<span className={styles.damage}>{atk.damage}</span> <span className={styles.damage}>{atk.damage}</span>
</span> </span>
{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> <span className={styles.rollSpace}></span>
)}
</div> </div>
))} ))}
{talents.map((atk) => ( {talents.map((atk) => (

View file

@ -5,6 +5,7 @@ import styles from "./CharacterDetail.module.css";
interface CharacterDetailProps { interface CharacterDetailProps {
character: Character; character: Character;
campaignId: number;
onUpdate: (id: number, data: Partial<Character>) => void; onUpdate: (id: number, data: Partial<Character>) => void;
onStatChange: (characterId: number, statName: string, value: number) => void; onStatChange: (characterId: number, statName: string, value: number) => void;
onAddGearFromItem: (characterId: number, item: GameItem) => void; onAddGearFromItem: (characterId: number, item: GameItem) => void;
@ -29,6 +30,7 @@ interface CharacterDetailProps {
export default function CharacterDetail({ export default function CharacterDetail({
character, character,
campaignId,
onUpdate, onUpdate,
onStatChange, onStatChange,
onAddGearFromItem, onAddGearFromItem,
@ -59,6 +61,7 @@ export default function CharacterDetail({
<CharacterSheet <CharacterSheet
character={character} character={character}
mode={mode} mode={mode}
campaignId={campaignId}
onUpdate={onUpdate} onUpdate={onUpdate}
onStatChange={onStatChange} onStatChange={onStatChange}
onAddGearFromItem={onAddGearFromItem} onAddGearFromItem={onAddGearFromItem}

View file

@ -11,6 +11,7 @@ import styles from "./CharacterSheet.module.css";
interface CharacterSheetProps { interface CharacterSheetProps {
character: Character; character: Character;
mode: "view" | "edit"; mode: "view" | "edit";
campaignId: number;
onUpdate: (id: number, data: Partial<Character>) => void; onUpdate: (id: number, data: Partial<Character>) => void;
onStatChange: (characterId: number, statName: string, value: number) => void; onStatChange: (characterId: number, statName: string, value: number) => void;
onAddGearFromItem: (characterId: number, item: GameItem) => void; onAddGearFromItem: (characterId: number, item: GameItem) => void;
@ -35,6 +36,7 @@ interface CharacterSheetProps {
export default function CharacterSheet({ export default function CharacterSheet({
character, character,
mode, mode,
campaignId,
onUpdate, onUpdate,
onStatChange, onStatChange,
onAddGearFromItem, onAddGearFromItem,
@ -157,6 +159,7 @@ export default function CharacterSheet({
<StatsPanel <StatsPanel
character={character} character={character}
mode={mode} mode={mode}
campaignId={campaignId}
onStatChange={onStatChange} onStatChange={onStatChange}
/> />
<InfoPanel <InfoPanel

View file

@ -0,0 +1,27 @@
.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);
}

View file

@ -0,0 +1,63 @@
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 (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>
);
}

View file

@ -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;
}

View file

@ -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 (
<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>
);
}

View file

@ -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;
}

View file

@ -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<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>
);
}

View file

@ -1,5 +1,6 @@
import type { Stat } from "../types"; import type { Stat } from "../types";
import { getModifier, formatModifier } from "../utils/modifiers"; import { getModifier, formatModifier } from "../utils/modifiers";
import DiceButton from "./DiceButton";
import styles from "./StatBlock.module.css"; import styles from "./StatBlock.module.css";
const STAT_ORDER = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]; const STAT_ORDER = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
@ -8,12 +9,18 @@ interface StatBlockProps {
stats: Stat[]; stats: Stat[];
onStatChange: (statName: string, newValue: number) => void; onStatChange: (statName: string, newValue: number) => void;
mode?: "view" | "edit"; mode?: "view" | "edit";
campaignId?: number;
characterId?: number;
characterName?: string;
} }
export default function StatBlock({ export default function StatBlock({
stats, stats,
onStatChange, onStatChange,
mode = "view", mode = "view",
campaignId,
characterId,
characterName,
}: StatBlockProps) { }: StatBlockProps) {
const statMap = new Map(stats.map((s) => [s.stat_name, s.value])); const statMap = new Map(stats.map((s) => [s.stat_name, s.value]));
@ -35,6 +42,16 @@ export default function StatBlock({
</button> </button>
)} )}
<span className={styles.modifier}>{formatModifier(mod)}</span> <span className={styles.modifier}>{formatModifier(mod)}</span>
{mode === "view" && campaignId && (
<DiceButton
campaignId={campaignId}
characterId={characterId}
characterName={characterName}
type="ability-check"
dice={`1d20${mod >= 0 ? "+" + mod : String(mod)}`}
label={`${name} check`}
/>
)}
{mode === "edit" && ( {mode === "edit" && (
<button <button
className={styles.btn} className={styles.btn}

View file

@ -7,12 +7,14 @@ import styles from "./StatsPanel.module.css";
interface StatsPanelProps { interface StatsPanelProps {
character: Character; character: Character;
mode: "view" | "edit"; mode: "view" | "edit";
campaignId: number;
onStatChange: (characterId: number, statName: string, value: number) => void; onStatChange: (characterId: number, statName: string, value: number) => void;
} }
export default function StatsPanel({ export default function StatsPanel({
character, character,
mode, mode,
campaignId,
onStatChange, onStatChange,
}: StatsPanelProps) { }: StatsPanelProps) {
const attacks = generateAttacks(character); const attacks = generateAttacks(character);
@ -26,9 +28,18 @@ export default function StatsPanel({
onStatChange(character.id, statName, value) onStatChange(character.id, statName, value)
} }
mode={mode} mode={mode}
campaignId={campaignId}
characterId={character.id}
characterName={character.name}
/> />
<hr className={styles.separator} /> <hr className={styles.separator} />
<AttackBlock attacks={attacks} /> <AttackBlock
attacks={attacks}
campaignId={campaignId}
characterId={character.id}
characterName={character.name}
mode={mode}
/>
</div> </div>
); );
} }

View file

@ -1,3 +1,14 @@
.layout {
display: flex;
height: calc(100vh - 100px);
}
.main {
flex: 1;
overflow-y: auto;
padding-right: 0.5rem;
}
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View file

@ -11,10 +11,12 @@ import {
removeGear, removeGear,
addTalent, addTalent,
removeTalent, removeTalent,
getRolls,
} from "../api"; } from "../api";
import type { Character, Gear, Talent, GameItem } from "../types"; import type { Character, Gear, Talent, GameItem, RollResult } from "../types";
import CharacterCard from "../components/CharacterCard"; import CharacterCard from "../components/CharacterCard";
import CharacterDetail from "../components/CharacterDetail"; import CharacterDetail from "../components/CharacterDetail";
import RollLog from "../components/RollLog";
import styles from "./CampaignView.module.css"; import styles from "./CampaignView.module.css";
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"]; const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
@ -32,10 +34,13 @@ export default function CampaignView() {
ancestry: "Human", ancestry: "Human",
hp_max: 1, hp_max: 1,
}); });
const [rolls, setRolls] = useState<RollResult[]>([]);
const [freshIds, setFreshIds] = useState<Set<number>>(new Set());
// Fetch characters and join socket room // Fetch characters and join socket room
useEffect(() => { useEffect(() => {
getCharacters(campaignId).then(setCharacters); getCharacters(campaignId).then(setCharacters);
getRolls(campaignId).then(setRolls);
socket.emit("join-campaign", String(campaignId)); socket.emit("join-campaign", String(campaignId));
return () => { return () => {
socket.emit("leave-campaign", String(campaignId)); socket.emit("leave-campaign", String(campaignId));
@ -155,6 +160,18 @@ export default function CampaignView() {
); );
} }
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("character:created", onCharacterCreated); socket.on("character:created", onCharacterCreated);
socket.on("character:updated", onCharacterUpdated); socket.on("character:updated", onCharacterUpdated);
socket.on("character:deleted", onCharacterDeleted); socket.on("character:deleted", onCharacterDeleted);
@ -163,6 +180,7 @@ export default function CampaignView() {
socket.on("gear:removed", onGearRemoved); socket.on("gear:removed", onGearRemoved);
socket.on("talent:added", onTalentAdded); socket.on("talent:added", onTalentAdded);
socket.on("talent:removed", onTalentRemoved); socket.on("talent:removed", onTalentRemoved);
socket.on("roll:result", onRollResult);
return () => { return () => {
socket.off("character:created", onCharacterCreated); socket.off("character:created", onCharacterCreated);
@ -173,6 +191,7 @@ export default function CampaignView() {
socket.off("gear:removed", onGearRemoved); socket.off("gear:removed", onGearRemoved);
socket.off("talent:added", onTalentAdded); socket.off("talent:added", onTalentAdded);
socket.off("talent:removed", onTalentRemoved); socket.off("talent:removed", onTalentRemoved);
socket.off("roll:result", onRollResult);
}; };
}, []); }, []);
@ -250,7 +269,8 @@ export default function CampaignView() {
const selectedCharacter = characters.find((c) => c.id === selectedId) ?? null; const selectedCharacter = characters.find((c) => c.id === selectedId) ?? null;
return ( return (
<div> <div className={styles.layout}>
<div className={styles.main}>
<div className={styles.header}> <div className={styles.header}>
<Link to="/" className={styles.backLink}> <Link to="/" className={styles.backLink}>
Campaigns Campaigns
@ -281,6 +301,7 @@ export default function CampaignView() {
{selectedCharacter && ( {selectedCharacter && (
<CharacterDetail <CharacterDetail
character={selectedCharacter} character={selectedCharacter}
campaignId={campaignId}
onUpdate={handleUpdate} onUpdate={handleUpdate}
onStatChange={handleStatChange} onStatChange={handleStatChange}
onAddGearFromItem={handleAddGearFromItem} onAddGearFromItem={handleAddGearFromItem}
@ -379,5 +400,7 @@ export default function CampaignView() {
</div> </div>
)} )}
</div> </div>
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} />
</div>
); );
} }

View file

@ -84,3 +84,19 @@ export interface AttackLine {
isTalent: boolean; isTalent: boolean;
description?: string; 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;
}

File diff suppressed because it is too large Load diff

View file

@ -77,6 +77,22 @@ db.exec(`
description TEXT DEFAULT '', description TEXT DEFAULT '',
effect 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 --- // --- Migrations for v2 ---

77
server/src/dice.ts Normal file
View file

@ -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,
};
}

View file

@ -7,6 +7,7 @@ import campaignRoutes from "./routes/campaigns.js";
import characterRoutes from "./routes/characters.js"; import characterRoutes from "./routes/characters.js";
import gameItemRoutes from "./routes/game-items.js"; import gameItemRoutes from "./routes/game-items.js";
import gameTalentRoutes from "./routes/game-talents.js"; import gameTalentRoutes from "./routes/game-talents.js";
import rollRoutes from "./routes/rolls.js";
const app = express(); const app = express();
const httpServer = createServer(app); const httpServer = createServer(app);
@ -27,6 +28,7 @@ app.use("/api/campaigns/:campaignId/characters", characterRoutes);
app.use("/api/characters", characterRoutes); app.use("/api/characters", characterRoutes);
app.use("/api/game-items", gameItemRoutes); app.use("/api/game-items", gameItemRoutes);
app.use("/api/game-talents", gameTalentRoutes); app.use("/api/game-talents", gameTalentRoutes);
app.use("/api/campaigns/:campaignId/rolls", rollRoutes);
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
httpServer.listen(PORT, () => { httpServer.listen(PORT, () => {

View file

@ -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<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;

View file

@ -1,4 +1,6 @@
import { Server } from "socket.io"; import { Server } from "socket.io";
import db from "./db.js";
import { rollDice } from "./dice.js";
export function setupSocket(io: Server) { export function setupSocket(io: Server) {
io.on("connection", (socket) => { io.on("connection", (socket) => {
@ -10,6 +12,65 @@ export function setupSocket(io: Server) {
socket.leave(`campaign:${campaignId}`); 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", () => { socket.on("disconnect", () => {
// Rooms are cleaned up automatically by Socket.IO // Rooms are cleaned up automatically by Socket.IO
}); });
@ -20,7 +81,7 @@ export function broadcastToCampaign(
io: Server, io: Server,
campaignId: number, campaignId: number,
event: string, event: string,
data: unknown data: unknown,
) { ) {
io.to(`campaign:${campaignId}`).emit(event, data); io.to(`campaign:${campaignId}`).emit(event, data);
} }