Skip to main content
Glama
rng.ts9.58 kB
import seedrandom from 'seedrandom'; /** * Comprehensive Combat RNG system supporting multiple RPG dice mechanics. * Deterministic and seeded for reproducibility. * * Supports: * - D&D 5e: Advantage, Disadvantage, Keep/Drop, Reroll, Minimum * - Savage Worlds: Exploding dice * - Hackmaster: Penetrating dice * - Shadowrun/WoD: Dice pool success counting * - Pathfinder 2e: Degree-of-success mechanics (in CombatEngine) */ export class CombatRNG { private rng: seedrandom.PRNG; constructor(seed: string) { this.rng = seedrandom(seed); } /** * Roll a single die with N sides */ private rollDie(sides: number): number { return Math.floor(this.rng() * sides) + 1; } /** * Parse and execute standard dice notation (NdS+M or NdS-M) * Examples: "1d20", "2d6+3", "1d8-1" */ roll(notation: string): number { const match = notation.match(/^(\d+)d(\d+)(([+\-])(\d+))?$/i); if (!match) { throw new Error(`Invalid dice notation: ${notation}`); } const count = parseInt(match[1], 10); const sides = parseInt(match[2], 10); const modifier = match[3] ? parseInt(match[4] + match[5], 10) : 0; let total = 0; for (let i = 0; i < count; i++) { total += this.rollDie(sides); } return total + modifier; } /** * D&D 5e: Roll with Advantage (2d20, keep highest) */ rollWithAdvantage(modifier: number = 0): number { const roll1 = this.rollDie(20); const roll2 = this.rollDie(20); return Math.max(roll1, roll2) + modifier; } /** * D&D 5e: Roll with Disadvantage (2d20, keep lowest) */ rollWithDisadvantage(modifier: number = 0): number { const roll1 = this.rollDie(20); const roll2 = this.rollDie(20); return Math.min(roll1, roll2) + modifier; } /** * General Keep/Drop mechanic * Roll N dice of S sides, keep the highest/lowest K dice */ rollKeepDrop( count: number, sides: number, keep: number, type: 'highest' | 'lowest' ): number { if (keep > count) { throw new Error(`Cannot keep ${keep} dice when only rolling ${count}`); } const rolls: number[] = []; for (let i = 0; i < count; i++) { rolls.push(this.rollDie(sides)); } rolls.sort((a, b) => type === 'highest' ? b - a : a - b); let total = 0; for (let i = 0; i < keep; i++) { total += rolls[i]; } return total; } /** * D&D 5e: Reroll specific values once (e.g., Great Weapon Fighting) * rerollOn: array of values to reroll (e.g., [1, 2]) */ rollWithReroll(count: number, sides: number, rerollOn: number[]): number { let total = 0; for (let i = 0; i < count; i++) { let roll = this.rollDie(sides); // Reroll once if value is in rerollOn array if (rerollOn.includes(roll)) { roll = this.rollDie(sides); } total += roll; } return total; } /** * D&D 5e: Roll with minimum value (e.g., Reliable Talent) * Any roll below min is treated as min */ rollWithMin(count: number, sides: number, min: number): number { let total = 0; for (let i = 0; i < count; i++) { const roll = this.rollDie(sides); total += Math.max(roll, min); } return total; } /** * Savage Worlds/L5R: Exploding dice * When max value is rolled, roll again and add (can chain indefinitely) */ rollExploding(count: number, sides: number): number { let total = 0; for (let i = 0; i < count; i++) { let roll = this.rollDie(sides); total += roll; // Keep exploding while rolling max while (roll === sides) { roll = this.rollDie(sides); total += roll; } } return total; } /** * Hackmaster: Penetrating dice * Like exploding, but subtract 1 from each reroll after the first */ rollPenetrating(count: number, sides: number): number { let total = 0; for (let i = 0; i < count; i++) { let roll = this.rollDie(sides); total += roll; // Keep penetrating while rolling max while (roll === sides) { roll = this.rollDie(sides) - 1; // Subtract 1 from penetration total += roll; } } return total; } /** * Shadowrun/World of Darkness: Dice pool success counting * Roll poolSize dice of diceSize, count how many meet/exceed threshold * * @param poolSize Number of dice to roll * @param diceSize Size of each die (typically d6 or d10) * @param threshold Minimum value to count as success * @returns Number of successes */ rollPool(poolSize: number, diceSize: number, threshold: number): number { let successes = 0; for (let i = 0; i < poolSize; i++) { const roll = this.rollDie(diceSize); if (roll >= threshold) { successes++; } } return successes; } /** * Convenience method for d20 checks */ d20(modifier: number = 0): number { return this.rollDie(20) + modifier; } /** * Make a check against a Difficulty Class * Returns true if roll + modifier meets or exceeds DC */ check(modifier: number, dc: number): boolean { return this.d20(modifier) >= dc; } /** * Pathfinder 2e: Determine degree of success * Returns: 'critical-failure' | 'failure' | 'success' | 'critical-success' */ checkDegree( modifier: number, dc: number ): 'critical-failure' | 'failure' | 'success' | 'critical-success' { const result = this.checkDegreeDetailed(modifier, dc); return result.degree; } /** * Detailed check result with full dice mechanics exposed * This is the TRANSPARENT version - shows exactly what was rolled */ checkDegreeDetailed( modifier: number, dc: number ): CheckResult { const roll = this.rollDie(20); const total = roll + modifier; const margin = total - dc; // Natural 20/1 adjust degree by one step let degree: 'critical-failure' | 'failure' | 'success' | 'critical-success'; if (margin >= 10) { degree = 'critical-success'; } else if (margin >= 0) { degree = 'success'; } else if (margin >= -10) { degree = 'failure'; } else { degree = 'critical-failure'; } const isNat20 = roll === 20; const isNat1 = roll === 1; // Adjust for natural 20 (improve by one step) if (isNat20) { if (degree === 'failure') degree = 'success'; else if (degree === 'success') degree = 'critical-success'; } // Adjust for natural 1 (worsen by one step) if (isNat1) { if (degree === 'success') degree = 'failure'; else if (degree === 'critical-success') degree = 'success'; } return { roll, modifier, total, dc, margin, degree, isNat20, isNat1, isHit: degree === 'success' || degree === 'critical-success', isCrit: degree === 'critical-success' }; } /** * Roll damage dice with detailed breakdown */ rollDamageDetailed(notation: string): DamageResult { const match = notation.match(/^(\d+)d(\d+)(([+\-])(\d+))?$/i); if (!match) { throw new Error(`Invalid dice notation: ${notation}`); } const count = parseInt(match[1], 10); const sides = parseInt(match[2], 10); const modifierSign = match[4] || '+'; const modifierValue = match[5] ? parseInt(match[5], 10) : 0; const modifier = modifierSign === '-' ? -modifierValue : modifierValue; const rolls: number[] = []; for (let i = 0; i < count; i++) { rolls.push(this.rollDie(sides)); } const diceTotal = rolls.reduce((sum, r) => sum + r, 0); const total = diceTotal + modifier; return { notation, rolls, diceTotal, modifier, total }; } } /** * Detailed result of a d20 check */ export interface CheckResult { roll: number; // The raw d20 roll (1-20) modifier: number; // The modifier applied total: number; // roll + modifier dc: number; // The DC to beat margin: number; // total - dc (positive = success) degree: 'critical-failure' | 'failure' | 'success' | 'critical-success'; isNat20: boolean; isNat1: boolean; isHit: boolean; // success or critical-success isCrit: boolean; // critical-success } /** * Detailed result of a damage roll */ export interface DamageResult { notation: string; // Original notation (e.g., "2d6+3") rolls: number[]; // Individual die results diceTotal: number; // Sum of dice only modifier: number; // Flat modifier total: number; // Final damage total }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Mnehmos/rpg-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server