bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

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