-
+
{[
- { value: 'normal', label: modeNormal },
- { value: 'advantage', label: modeAdvantage },
- { value: 'disadvantage',label: modeDisadvantage },
+ { value: 'normal', label: modeNormal },
+ { value: 'advantage', label: modeAdvantage },
+ { value: 'disadvantage', label: modeDisadvantage },
].map(({ value, label }, i) => (
-
diff --git a/src/data/generators.ts b/src/data/generators.ts
index 79e4841..6d60749 100644
--- a/src/data/generators.ts
+++ b/src/data/generators.ts
@@ -32,12 +32,12 @@ export const generators: Generator[] = [
description: 'Roll one or more dice with any number of sides.',
icon: 'dice-6',
status: 'live',
- seoTitle: 'Dice Roller — d4 d6 d8 d10 d12 d20 | D&D Dice | Randify',
- seoDescription: 'Free online dice roller for D&D and tabletop RPGs. Supports any XdY+Z notation, advantage/disadvantage, roll history, and quick presets for attacks, damage, and stat rolls.',
+ seoTitle: 'Dice Roller — d4 d6 d20 + Full Notation | D&D Dice | Randify',
+ seoDescription: 'Free online dice roller with full XdY+Z notation, keep/drop (4d6dl1), exploding dice (3d6!), reroll, advantage/disadvantage, and roll history. Perfect for D&D, Pathfinder, and Savage Worlds.',
ruTitle: 'Кубик',
ruDescription: 'Бросьте один или несколько кубиков с любым числом граней.',
ruSeoTitle: 'Бросить кубик онлайн — d4 d6 d8 d10 d12 d20 | D&D | Randify',
- ruSeoDescription: 'Бесплатный онлайн-бросок кубиков для D&D и настольных RPG. Поддерживает нотацию XdY+Z, преимущество/помеху, историю бросков и быстрые пресеты.',
+ ruSeoDescription: 'Бесплатный онлайн-бросок кубиков с полной нотацией XdY+Z, keep/drop (4d6dl1), взрывающимися кубиками (3d6!), перебросом, преимуществом/помехой и историей. Для D&D, Pathfinder и Savage Worlds.',
},
{
slug: 'wheel',
diff --git a/src/lib/dice-engine.ts b/src/lib/dice-engine.ts
new file mode 100644
index 0000000..ade5f30
--- /dev/null
+++ b/src/lib/dice-engine.ts
@@ -0,0 +1,267 @@
+// 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
;
+ 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]?)(\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);
+}
diff --git a/src/pages/generators/dice.astro b/src/pages/generators/dice.astro
index cf99023..19a90c3 100644
--- a/src/pages/generators/dice.astro
+++ b/src/pages/generators/dice.astro
@@ -70,7 +70,9 @@ const generator = generators.find((g) => g.slug === 'dice')!;
'Rolling dice for D&D, Pathfinder, or any tabletop RPG.',
'Using dice notation shortcuts like 2d6+3 or 1d20 for fast rolling.',
'Rolling with advantage or disadvantage for D&D 5e mechanics.',
- 'Generating character stats with the 4d6 drop-lowest method.',
+ 'Generating character stats with 4d6 drop-lowest method.',
+ 'Rolling exploding dice for Savage Worlds or house rules (e.g., 3d6!).',
+ 'Using keep/drop modifiers like 4d6kh3 to keep highest rolls.',
]}
/>
diff --git a/src/pages/ru/generators/dice.astro b/src/pages/ru/generators/dice.astro
index 8224b43..9787670 100644
--- a/src/pages/ru/generators/dice.astro
+++ b/src/pages/ru/generators/dice.astro
@@ -30,12 +30,14 @@ const generator = generators.find((g) => g.slug === 'dice')!;
'Введите нотацию, например 2d6+3 или 1d20 — или настройте кубики вручную.',
'Выберите режим: Обычный, Преимущество или Помеха (только для одного кубика).',
'Нажмите «Бросить» — результаты отобразятся мгновенно.',
- 'Нажмите «Копировать», чтобы сохранить результат.',
+ 'Максимумы подсвечены фиолетовым, единицы — красным.',
]} whenTo={[
'Броски в настольных ролевых играх (D&D и другие).',
'Быстрые броски через нотацию: 2d6+3, 1d20 и любые другие.',
'Преимущество и помеха для механик D&D 5e.',
- 'Бросок характеристик: 4d6, отбросить наименьший.',
+ 'Бросок характеристик: 4d6, отбросить наименьший (4d6dl1).',
+ 'Взрывающиеся кубики для Savage Worlds или хаус-рулов (например, 3d6!).',
+ 'Использование keep/drop модификаторов типа 4d6kh3 (оставить три лучших).',
]} />