- Inline dice engine: XdY±Z, keep/drop (4d6dl1), exploding (3d6!), penetrating (!p), reroll (r1, ro1, r<3) - Notation input with real-time validation and bidirectional sync with controls - Advantage/Disadvantage toggle with smart disable when count>1 or keep/drop present - 6 presets: Attack, Damage, Fireball, Sneak Attack, Stat Roll (4d6dl1), Savage Strike (2d6!) - Roll history with dropped strikethrough, explosion chains, reroll arrows - Updated SEO title/description and When-to-use blocks (EN+RU)
268 lines
8.3 KiB
TypeScript
268 lines
8.3 KiB
TypeScript
// Dice notation engine for Randify.pro
|
|
// Supports: XdY±Z, kh/kl/dh/dl, ! exploding, r reroll, advantage/disadvantage
|
|
// Caps: explode chain ≤100, reroll recursion ≤1000
|
|
|
|
type KeepDrop = { type: 'kh' | 'kl' | 'dh' | 'dl'; count: number } | null;
|
|
|
|
type Explode = {
|
|
active: boolean;
|
|
threshold: number | null; // null = max value
|
|
penetrating: boolean;
|
|
};
|
|
|
|
type Reroll = {
|
|
active: boolean;
|
|
values: Set<number>;
|
|
once: boolean; // "ro" = once only; "r" = recursive
|
|
operator: 'eq' | 'lt' | 'gt';
|
|
};
|
|
|
|
export interface Parsed {
|
|
count: number;
|
|
sides: number;
|
|
modifier: number;
|
|
keepDrop: KeepDrop;
|
|
explode: Explode;
|
|
reroll: Reroll;
|
|
advantage: boolean;
|
|
disadvantage: boolean;
|
|
}
|
|
|
|
export interface DieResult {
|
|
value: number;
|
|
original: number;
|
|
exploded: boolean;
|
|
explosions: number[]; // chained explosion rolls
|
|
rerolledFrom: number | null;
|
|
dropped: boolean;
|
|
}
|
|
|
|
export interface RollResult {
|
|
dice: DieResult[];
|
|
kept: DieResult[];
|
|
dropped: DieResult[];
|
|
modifier: number;
|
|
total: number;
|
|
notation: string;
|
|
advantageRolls: [number, number] | null; // for adv/disadv display
|
|
}
|
|
|
|
const EXPLODE_CAP = 100;
|
|
const REROLL_CAP = 1000;
|
|
|
|
/** Remove all whitespace and lowercase for parsing */
|
|
function normalize(raw: string): string {
|
|
return raw.replace(/\s+/g, '').toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Parse dice notation string into structured object.
|
|
* Returns null if syntax is invalid.
|
|
*/
|
|
export function parseDiceNotation(raw: string): Parsed | null {
|
|
const s = normalize(raw);
|
|
if (!s) return null;
|
|
|
|
// Main pattern: count d sides [modifiers+operators]
|
|
const main = s.match(/^(\d*)d(\d+)(.*)$/);
|
|
if (!main) return null;
|
|
|
|
const count = main[1] === '' ? 1 : parseInt(main[1], 10);
|
|
const sides = parseInt(main[2], 10);
|
|
const rest = main[3];
|
|
|
|
if (count < 1 || count > 20 || sides < 1 || sides > 9999) return null;
|
|
|
|
let remaining = rest;
|
|
|
|
// Keep/Drop: khN, klN, dhN, dlN
|
|
let keepDrop: KeepDrop = null;
|
|
const kdMatch = remaining.match(/^(kh|kl|dh|dl)(\d+)/);
|
|
if (kdMatch) {
|
|
const countKD = parseInt(kdMatch[2], 10);
|
|
if (countKD < 1 || countKD >= count) return null;
|
|
keepDrop = { type: kdMatch[1] as 'kh' | 'kl' | 'dh' | 'dl', count: countKD };
|
|
remaining = remaining.slice(kdMatch[0].length);
|
|
}
|
|
|
|
// Exploding: ! or !>N or !p or !p>N
|
|
let explode: Explode = { active: false, threshold: null, penetrating: false };
|
|
const expMatch = remaining.match(/^(!p|!)(>?)(\d*)/);
|
|
if (expMatch) {
|
|
explode.active = true;
|
|
explode.penetrating = expMatch[1] === '!p';
|
|
if (expMatch[2] === '>' && expMatch[3]) {
|
|
explode.threshold = parseInt(expMatch[3], 10);
|
|
if (explode.threshold < 2 || explode.threshold > sides) return null;
|
|
} else {
|
|
explode.threshold = sides; // default: explode on max
|
|
}
|
|
remaining = remaining.slice(expMatch[0].length);
|
|
}
|
|
|
|
// Reroll: rN, roN, r<N, ro<N
|
|
let reroll: Reroll = { active: false, values: new Set(), once: false, operator: 'eq' };
|
|
const rrMatch = remaining.match(/^(ro|r)([<>]?)(\d+)/);
|
|
if (rrMatch) {
|
|
reroll.active = true;
|
|
reroll.once = rrMatch[1] === 'ro';
|
|
const op = rrMatch[2] as '' | '<' | '>';
|
|
const val = parseInt(rrMatch[3], 10);
|
|
if (op === '<') {
|
|
reroll.operator = 'lt';
|
|
for (let i = 1; i < val && i < sides; i++) reroll.values.add(i);
|
|
} else if (op === '>') {
|
|
reroll.operator = 'gt';
|
|
for (let i = val + 1; i <= sides; i++) reroll.values.add(i);
|
|
} else {
|
|
reroll.operator = 'eq';
|
|
reroll.values.add(val);
|
|
}
|
|
remaining = remaining.slice(rrMatch[0].length);
|
|
}
|
|
|
|
// Flat modifier at the end: +N or -N
|
|
let modifier = 0;
|
|
const modMatch = remaining.match(/^([+-]\d+)$/);
|
|
if (modMatch) {
|
|
modifier = parseInt(modMatch[1], 10);
|
|
if (Math.abs(modifier) > 999) return null;
|
|
remaining = remaining.slice(modMatch[0].length);
|
|
}
|
|
|
|
// If anything remains unparsed, it's invalid
|
|
if (remaining.length > 0) return null;
|
|
|
|
return { count, sides, modifier, keepDrop, explode, reroll, advantage: false, disadvantage: false };
|
|
}
|
|
|
|
/** Roll a single die with optional reroll and explode logic */
|
|
function rollDie(sides: number, explode: Explode, reroll: Reroll): DieResult {
|
|
let value = Math.floor(Math.random() * sides) + 1;
|
|
const original = value;
|
|
let rerolledFrom: number | null = null;
|
|
let explosions: number[] = [];
|
|
let exploded = false;
|
|
|
|
// Handle reroll
|
|
if (reroll.active && reroll.values.has(value)) {
|
|
rerolledFrom = value;
|
|
let rerollCount = 0;
|
|
while (reroll.values.has(value) && rerollCount < REROLL_CAP) {
|
|
value = Math.floor(Math.random() * sides) + 1;
|
|
rerollCount++;
|
|
if (reroll.once) break;
|
|
}
|
|
}
|
|
|
|
// Handle exploding
|
|
if (explode.active && value >= (explode.threshold ?? sides)) {
|
|
exploded = true;
|
|
let chain = 0;
|
|
while (chain < EXPLODE_CAP) {
|
|
let next = Math.floor(Math.random() * sides) + 1;
|
|
if (explode.penetrating) {
|
|
next = Math.max(1, next - 1); // penetrating subtracts 1
|
|
}
|
|
explosions.push(next);
|
|
if (next < (explode.threshold ?? sides)) break;
|
|
chain++;
|
|
}
|
|
}
|
|
|
|
return {
|
|
value,
|
|
original,
|
|
exploded,
|
|
explosions,
|
|
rerolledFrom,
|
|
dropped: false,
|
|
};
|
|
}
|
|
|
|
/** Execute a roll from parsed notation */
|
|
export function rollDice(parsed: Parsed): RollResult {
|
|
const dice: DieResult[] = [];
|
|
|
|
for (let i = 0; i < parsed.count; i++) {
|
|
dice.push(rollDie(parsed.sides, parsed.explode, parsed.reroll));
|
|
}
|
|
|
|
// Apply keep/drop
|
|
let kept = [...dice];
|
|
let dropped: DieResult[] = [];
|
|
|
|
if (parsed.keepDrop) {
|
|
const sorted = [...dice].map((d, i) => ({ die: d, idx: i }));
|
|
if (parsed.keepDrop.type === 'kh') {
|
|
sorted.sort((a, b) => b.die.value - a.die.value);
|
|
const keepIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx));
|
|
kept = dice.filter((_, i) => keepIndices.has(i));
|
|
dropped = dice.filter((_, i) => !keepIndices.has(i));
|
|
} else if (parsed.keepDrop.type === 'kl') {
|
|
sorted.sort((a, b) => a.die.value - b.die.value);
|
|
const keepIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx));
|
|
kept = dice.filter((_, i) => keepIndices.has(i));
|
|
dropped = dice.filter((_, i) => !keepIndices.has(i));
|
|
} else if (parsed.keepDrop.type === 'dh') {
|
|
sorted.sort((a, b) => b.die.value - a.die.value);
|
|
const dropIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx));
|
|
kept = dice.filter((_, i) => !dropIndices.has(i));
|
|
dropped = dice.filter((_, i) => dropIndices.has(i));
|
|
} else if (parsed.keepDrop.type === 'dl') {
|
|
sorted.sort((a, b) => a.die.value - b.die.value);
|
|
const dropIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx));
|
|
kept = dice.filter((_, i) => !dropIndices.has(i));
|
|
dropped = dice.filter((_, i) => dropIndices.has(i));
|
|
}
|
|
}
|
|
|
|
dropped.forEach(d => { d.dropped = true; });
|
|
|
|
// Calculate total
|
|
let total = kept.reduce((sum, d) => sum + d.value, 0);
|
|
total += kept.reduce((sum, d) => sum + d.explosions.reduce((s, v) => s + v, 0), 0);
|
|
total += parsed.modifier;
|
|
|
|
return {
|
|
dice,
|
|
kept,
|
|
dropped,
|
|
modifier: parsed.modifier,
|
|
total,
|
|
notation: '', // filled by caller
|
|
advantageRolls: null,
|
|
};
|
|
}
|
|
|
|
/** Roll with advantage or disadvantage (2dX keep highest/lowest) */
|
|
export function rollAdvantage(sides: number, modifier: number, advantage: boolean): RollResult {
|
|
const r1 = Math.floor(Math.random() * sides) + 1;
|
|
const r2 = Math.floor(Math.random() * sides) + 1;
|
|
const keptVal = advantage ? Math.max(r1, r2) : Math.min(r1, r2);
|
|
|
|
return {
|
|
dice: [{ value: keptVal, original: keptVal, exploded: false, explosions: [], rerolledFrom: null, dropped: false }],
|
|
kept: [{ value: keptVal, original: keptVal, exploded: false, explosions: [], rerolledFrom: null, dropped: false }],
|
|
dropped: [],
|
|
modifier,
|
|
total: keptVal + modifier,
|
|
notation: '',
|
|
advantageRolls: [r1, r2],
|
|
};
|
|
}
|
|
|
|
/** Build notation string from parsed object (basic only, for simple sync) */
|
|
export function buildNotation(count: number, sides: number, modifier: number): string {
|
|
let s = `${count}d${sides}`;
|
|
if (modifier > 0) s += `+${modifier}`;
|
|
else if (modifier < 0) s += modifier;
|
|
return s;
|
|
}
|
|
|
|
/** Check if notation contains advanced features beyond basic XdY±Z */
|
|
export function isAdvancedNotation(raw: string): boolean {
|
|
const s = normalize(raw);
|
|
return /(kh|kl|dh|dl|!|[<>]?r\d)/.test(s);
|
|
}
|