From ef2aeb5345c2e34443f10e1a2c6d001d07c28ae9 Mon Sep 17 00:00:00 2001 From: Emil Shanaty Date: Sun, 10 May 2026 01:23:50 +0300 Subject: [PATCH] feat(dice): full notation engine with kh/kl/dh/dl, exploding dice, reroll, presets, roll history, adv/disadv toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/components/generators/DiceGenerator.astro | 928 ++++++++++-------- src/data/generators.ts | 6 +- src/lib/dice-engine.ts | 267 +++++ src/pages/generators/dice.astro | 4 +- src/pages/ru/generators/dice.astro | 6 +- 5 files changed, 821 insertions(+), 390 deletions(-) create mode 100644 src/lib/dice-engine.ts diff --git a/src/components/generators/DiceGenerator.astro b/src/components/generators/DiceGenerator.astro index 26ef6eb..83fcd79 100644 --- a/src/components/generators/DiceGenerator.astro +++ b/src/components/generators/DiceGenerator.astro @@ -3,44 +3,54 @@ import { useT } from '../../i18n/translations'; const isRu = Astro.url.pathname.startsWith('/ru'); const T = useT(isRu ? 'ru' : 'en'); -const notationLabel = isRu ? 'Нотация' : 'Notation'; -const notationPlaceholder = isRu ? 'напр. 2d6+3, 1d20, 4d4-1' : 'e.g. 2d6+3, 1d20, 4d4-1'; -const modeLabel = isRu ? 'Режим' : 'Mode'; -const modeNormal = isRu ? 'Обычный' : 'Normal'; -const modeAdvantage = isRu ? 'Преимущество' : 'Advantage'; -const modeDisadvantage = isRu ? 'Помеха' : 'Disadvantage'; +const notationPlaceholder = isRu ? 'напр. 2d6+3, 1d20, 4d6dl1' : 'e.g. 2d6+3, 1d20, 4d6dl1'; +const modeNormal = isRu ? 'Обычный' : 'Normal'; +const modeAdvantage = isRu ? 'Преимущество' : 'Advantage'; +const modeDisadvantage = isRu ? 'Помеха' : 'Disadvantage'; const advOnly = isRu ? 'Преимущество работает только с одним кубиком' : 'Advantage only works with a single die'; -const historyTitle = isRu ? 'История бросков' : 'Roll history'; -const clearHistory = isRu ? 'Очистить историю' : 'Clear history'; +const historyTitle = isRu ? 'История бросков' : 'Roll history'; +const clearHistory = isRu ? 'Очистить историю' : 'Clear history'; +const presetsLabel = isRu ? 'Пресеты' : 'Presets'; const presets = [ - { label: isRu ? 'Атака' : 'Attack', notation: '1d20', stat: false }, - { label: isRu ? 'Урон (1d8)' : 'Damage (1d8)', notation: '1d8+3', stat: false }, - { label: isRu ? 'Огненный шар' : 'Fireball', notation: '8d6', stat: false }, - { label: isRu ? 'Скрытая атака' : 'Sneak Attack', notation: '3d6', stat: false }, - { label: isRu ? 'Характеристика' : 'Stat Roll', notation: '4d6', stat: true }, + { label: isRu ? 'Атака' : 'Attack', notation: '1d20' }, + { label: isRu ? 'Урон' : 'Damage', notation: '1d8+3' }, + { label: isRu ? 'Огненный шар' : 'Fireball', notation: '8d6' }, + { label: isRu ? 'Скрытая атака' : 'Sneak Attack', notation: '3d6' }, + { label: isRu ? 'Характеристика' : 'Stat Roll', notation: '4d6dl1' }, + { label: isRu ? 'Яростный удар' : 'Savage Strike', notation: '2d6!' }, ]; ---
- -
- {presets.map((p) => ( - - ))} + +
+ +
-
- +
{[ - { 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 (оставить три лучших).', ]} />