Add dice rolling: server-side engine, roll log panel, DiceButton on stats/attacks, advantage/disadvantage, real-time sync
This commit is contained in:
parent
46f5227fa9
commit
20a1778cbd
21 changed files with 2098 additions and 132 deletions
|
|
@ -5,6 +5,7 @@ import type {
|
|||
Talent,
|
||||
GameItem,
|
||||
GameTalent,
|
||||
RollResult,
|
||||
} from "./types";
|
||||
|
||||
const BASE = "/api";
|
||||
|
|
@ -107,3 +108,7 @@ export const getGameItems = () => request<GameItem[]>("/game-items");
|
|||
|
||||
// Game Talents
|
||||
export const getGameTalents = () => request<GameTalent[]>("/game-talents");
|
||||
|
||||
// Rolls
|
||||
export const getRolls = (campaignId: number) =>
|
||||
request<RollResult[]>(`/campaigns/${campaignId}/rolls`);
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
{", "}
|
||||
<span className={styles.damage}>{atk.damage}</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>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{talents.map((atk) => (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import styles from "./CharacterDetail.module.css";
|
|||
|
||||
interface CharacterDetailProps {
|
||||
character: Character;
|
||||
campaignId: number;
|
||||
onUpdate: (id: number, data: Partial<Character>) => 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({
|
|||
<CharacterSheet
|
||||
character={character}
|
||||
mode={mode}
|
||||
campaignId={campaignId}
|
||||
onUpdate={onUpdate}
|
||||
onStatChange={onStatChange}
|
||||
onAddGearFromItem={onAddGearFromItem}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import styles from "./CharacterSheet.module.css";
|
|||
interface CharacterSheetProps {
|
||||
character: Character;
|
||||
mode: "view" | "edit";
|
||||
campaignId: number;
|
||||
onUpdate: (id: number, data: Partial<Character>) => 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({
|
|||
<StatsPanel
|
||||
character={character}
|
||||
mode={mode}
|
||||
campaignId={campaignId}
|
||||
onStatChange={onStatChange}
|
||||
/>
|
||||
<InfoPanel
|
||||
|
|
|
|||
27
client/src/components/DiceButton.module.css
Normal file
27
client/src/components/DiceButton.module.css
Normal 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);
|
||||
}
|
||||
63
client/src/components/DiceButton.tsx
Normal file
63
client/src/components/DiceButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
client/src/components/RollEntry.module.css
Normal file
106
client/src/components/RollEntry.module.css
Normal 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;
|
||||
}
|
||||
72
client/src/components/RollEntry.tsx
Normal file
72
client/src/components/RollEntry.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
client/src/components/RollLog.module.css
Normal file
120
client/src/components/RollLog.module.css
Normal 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;
|
||||
}
|
||||
75
client/src/components/RollLog.tsx
Normal file
75
client/src/components/RollLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
|||
</button>
|
||||
)}
|
||||
<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" && (
|
||||
<button
|
||||
className={styles.btn}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ import styles from "./StatsPanel.module.css";
|
|||
interface StatsPanelProps {
|
||||
character: Character;
|
||||
mode: "view" | "edit";
|
||||
campaignId: number;
|
||||
onStatChange: (characterId: number, statName: string, value: number) => void;
|
||||
}
|
||||
|
||||
export default function StatsPanel({
|
||||
character,
|
||||
mode,
|
||||
campaignId,
|
||||
onStatChange,
|
||||
}: StatsPanelProps) {
|
||||
const attacks = generateAttacks(character);
|
||||
|
|
@ -26,9 +28,18 @@ export default function StatsPanel({
|
|||
onStatChange(character.id, statName, value)
|
||||
}
|
||||
mode={mode}
|
||||
campaignId={campaignId}
|
||||
characterId={character.id}
|
||||
characterName={character.name}
|
||||
/>
|
||||
<hr className={styles.separator} />
|
||||
<AttackBlock attacks={attacks} />
|
||||
<AttackBlock
|
||||
attacks={attacks}
|
||||
campaignId={campaignId}
|
||||
characterId={character.id}
|
||||
characterName={character.name}
|
||||
mode={mode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
.layout {
|
||||
display: flex;
|
||||
height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ import {
|
|||
removeGear,
|
||||
addTalent,
|
||||
removeTalent,
|
||||
getRolls,
|
||||
} 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 CharacterDetail from "../components/CharacterDetail";
|
||||
import RollLog from "../components/RollLog";
|
||||
import styles from "./CampaignView.module.css";
|
||||
|
||||
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
|
||||
|
|
@ -32,10 +34,13 @@ export default function CampaignView() {
|
|||
ancestry: "Human",
|
||||
hp_max: 1,
|
||||
});
|
||||
const [rolls, setRolls] = useState<RollResult[]>([]);
|
||||
const [freshIds, setFreshIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Fetch characters and join socket room
|
||||
useEffect(() => {
|
||||
getCharacters(campaignId).then(setCharacters);
|
||||
getRolls(campaignId).then(setRolls);
|
||||
socket.emit("join-campaign", String(campaignId));
|
||||
return () => {
|
||||
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:updated", onCharacterUpdated);
|
||||
socket.on("character:deleted", onCharacterDeleted);
|
||||
|
|
@ -163,6 +180,7 @@ export default function CampaignView() {
|
|||
socket.on("gear:removed", onGearRemoved);
|
||||
socket.on("talent:added", onTalentAdded);
|
||||
socket.on("talent:removed", onTalentRemoved);
|
||||
socket.on("roll:result", onRollResult);
|
||||
|
||||
return () => {
|
||||
socket.off("character:created", onCharacterCreated);
|
||||
|
|
@ -173,6 +191,7 @@ export default function CampaignView() {
|
|||
socket.off("gear:removed", onGearRemoved);
|
||||
socket.off("talent:added", onTalentAdded);
|
||||
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;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.layout}>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.header}>
|
||||
<Link to="/" className={styles.backLink}>
|
||||
← Campaigns
|
||||
|
|
@ -281,6 +301,7 @@ export default function CampaignView() {
|
|||
{selectedCharacter && (
|
||||
<CharacterDetail
|
||||
character={selectedCharacter}
|
||||
campaignId={campaignId}
|
||||
onUpdate={handleUpdate}
|
||||
onStatChange={handleStatChange}
|
||||
onAddGearFromItem={handleAddGearFromItem}
|
||||
|
|
@ -379,5 +400,7 @@ export default function CampaignView() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
1210
docs/plans/2026-04-09-dice-rolling.md
Normal file
1210
docs/plans/2026-04-09-dice-rolling.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 ---
|
||||
|
|
|
|||
77
server/src/dice.ts
Normal file
77
server/src/dice.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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, () => {
|
||||
|
|
|
|||
24
server/src/routes/rolls.ts
Normal file
24
server/src/routes/rolls.ts
Normal 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;
|
||||
|
|
@ -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<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
|
||||
});
|
||||
|
|
@ -20,7 +81,7 @@ export function broadcastToCampaign(
|
|||
io: Server,
|
||||
campaignId: number,
|
||||
event: string,
|
||||
data: unknown
|
||||
data: unknown,
|
||||
) {
|
||||
io.to(`campaign:${campaignId}`).emit(event, data);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue