server.js (5008B)
1 const crypto = require("crypto"); 2 const fs = require("fs"); 3 const path = require("path"); 4 5 const HISTORY_MAX = Number(process.env.DICE_HISTORY_MAX || 120); 6 const MAX_DICE = Number(process.env.DICE_MAX_DICE || 100); 7 const MAX_SIDES = Number(process.env.DICE_MAX_SIDES || 1000); 8 const MAX_MOD_ABS = Number(process.env.DICE_MAX_MOD_ABS || 10000); 9 10 function safeJsonParse(str) { 11 try { 12 return JSON.parse(str); 13 } catch { 14 return null; 15 } 16 } 17 18 function readJsonOrNull(filePath) { 19 try { 20 const raw = fs.readFileSync(filePath, "utf8"); 21 return safeJsonParse(raw); 22 } catch { 23 return null; 24 } 25 } 26 27 function writeFileAtomic(filePath, content) { 28 const dir = path.dirname(filePath); 29 fs.mkdirSync(dir, { recursive: true }); 30 const tmp = `${filePath}.tmp.${crypto.randomBytes(6).toString("hex")}`; 31 fs.writeFileSync(tmp, content, "utf8"); 32 fs.renameSync(tmp, filePath); 33 } 34 35 function nowMs() { 36 return Date.now(); 37 } 38 39 function toId(bytes = 6) { 40 return crypto.randomBytes(Math.max(3, Math.min(16, bytes))).toString("hex"); 41 } 42 43 function normUser(u) { 44 return String(u || "").trim().toLowerCase(); 45 } 46 47 function parseExpr(raw) { 48 const s = String(raw || "").trim(); 49 if (!s) return { ok: false, error: "Empty roll." }; 50 const m = s.match(/^\s*(\d*)\s*d\s*(\d+)\s*([+-]\s*\d+)?\s*$/i); 51 if (!m) return { ok: false, error: "Invalid format. Use XdY+Z (example: 2d6+1)." }; 52 const countRaw = m[1]; 53 const sidesRaw = m[2]; 54 const modRaw = m[3] || ""; 55 const count = countRaw ? Number(countRaw) : 1; 56 const sides = Number(sidesRaw); 57 const mod = modRaw ? Number(String(modRaw).replace(/\s+/g, "")) : 0; 58 if (!Number.isFinite(count) || !Number.isFinite(sides) || !Number.isFinite(mod)) { 59 return { ok: false, error: "Invalid numbers in roll." }; 60 } 61 const dice = Math.floor(count); 62 const sds = Math.floor(sides); 63 const md = Math.trunc(mod); 64 if (dice <= 0) return { ok: false, error: "Dice count must be >= 1." }; 65 if (dice > MAX_DICE) return { ok: false, error: `Too many dice (max ${MAX_DICE}).` }; 66 if (sds < 2) return { ok: false, error: "Sides must be >= 2." }; 67 if (sds > MAX_SIDES) return { ok: false, error: `Too many sides (max ${MAX_SIDES}).` }; 68 if (Math.abs(md) > MAX_MOD_ABS) return { ok: false, error: `Modifier too large (max ±${MAX_MOD_ABS}).` }; 69 return { ok: true, dice, sides: sds, mod: md, expr: `${dice}d${sds}${md === 0 ? "" : md > 0 ? `+${md}` : `${md}`}` }; 70 } 71 72 function rollOnce(sides) { 73 // inclusive 1..sides 74 return crypto.randomInt(1, sides + 1); 75 } 76 77 module.exports = function init(api) { 78 const dataFile = path.join(__dirname, "dice.json"); 79 let history = []; 80 81 function loadHistory() { 82 const parsed = readJsonOrNull(dataFile); 83 const arr = Array.isArray(parsed?.history) ? parsed.history : []; 84 history = arr 85 .map((r) => { 86 const id = String(r?.id || "").trim().toLowerCase(); 87 const user = normUser(r?.user); 88 const expr = String(r?.expr || "").trim().slice(0, 40); 89 const dice = Number(r?.dice || 0) || 0; 90 const sides = Number(r?.sides || 0) || 0; 91 const mod = Number(r?.mod || 0) || 0; 92 const rolls = Array.isArray(r?.rolls) ? r.rolls.map((n) => Math.max(1, Math.floor(Number(n || 0) || 0))) : []; 93 const total = Number(r?.total || 0) || 0; 94 const createdAt = Number(r?.createdAt || 0) || 0; 95 if (!id || !user || !expr || !createdAt) return null; 96 return { id, user, expr, dice, sides, mod, rolls: rolls.slice(0, MAX_DICE), total, createdAt }; 97 }) 98 .filter(Boolean) 99 .slice(-HISTORY_MAX); 100 } 101 102 function saveHistory() { 103 writeFileAtomic(dataFile, JSON.stringify({ version: 1, savedAt: nowMs(), history }, null, 2) + "\n"); 104 } 105 106 function broadcastRoll(entry) { 107 api.broadcast({ type: "plugin:dice:rolled", roll: entry }); 108 } 109 110 function send(ws, msg) { 111 try { 112 ws.send(JSON.stringify(msg)); 113 return true; 114 } catch { 115 return false; 116 } 117 } 118 119 function sendError(ws, message) { 120 send(ws, { type: "plugin:dice:error", message: String(message || "Error.") }); 121 } 122 123 loadHistory(); 124 125 api.registerWs("stateReq", (ws) => { 126 send(ws, { type: "plugin:dice:history", history }); 127 }); 128 129 api.registerWs("roll", (ws, msg) => { 130 const user = normUser(ws?.user?.username); 131 if (!user) { 132 sendError(ws, "Sign in required to roll dice."); 133 return; 134 } 135 const parsed = parseExpr(msg?.expr); 136 if (!parsed.ok) { 137 sendError(ws, parsed.error); 138 return; 139 } 140 const rolls = []; 141 let sum = 0; 142 for (let i = 0; i < parsed.dice; i++) { 143 const r = rollOnce(parsed.sides); 144 rolls.push(r); 145 sum += r; 146 } 147 const total = sum + parsed.mod; 148 const entry = { 149 id: toId(6), 150 user, 151 expr: parsed.expr, 152 dice: parsed.dice, 153 sides: parsed.sides, 154 mod: parsed.mod, 155 rolls, 156 total, 157 createdAt: nowMs() 158 }; 159 history = [...history, entry].slice(-HISTORY_MAX); 160 saveHistory(); 161 broadcastRoll(entry); 162 }); 163 }; 164