@randsum/notation
Version:
Dice notation parser and types for the @randsum ecosystem
151 lines (134 loc) • 4.76 kB
text/typescript
import { validateNotation } from './validateNotation'
export type TokenType =
| 'core'
| 'dropLowest' // L, LN
| 'dropHighest' // H, HN
| 'dropCondition' // D{...}
| 'keepHighest' // K, KN
| 'keepLowest' // kl, klN
| 'reroll' // R{...}
| 'explode' // !
| 'compound' // !!
| 'penetrate' // !p
| 'cap' // C{...}
| 'replace' // V{...}
| 'unique' // U, U{...}
| 'countSuccesses' // S{...}
| 'plus' // +N
| 'minus' // -N
| 'multiply' // *N
| 'multiplyTotal' // **N
| 'unknown'
export interface Token {
readonly text: string
readonly type: TokenType
readonly start: number
readonly end: number
readonly description: string
}
interface ModifierEntry {
readonly type: Exclude<TokenType, 'core' | 'unknown'>
readonly pattern: RegExp
}
function describeCoreToken(text: string): string {
const stripped = text.replace(/^[+-]/, '')
const result = validateNotation(stripped)
if (result.valid) return result.description[0]?.[0] ?? text
const match = /^(\d+)[Dd](\d+)/.exec(stripped)
if (!match) return text
const qty = parseInt(match[1] ?? '1', 10)
const sides = match[2] ?? '?'
return `Roll ${qty} ${sides}-sided ${qty === 1 ? 'die' : 'dice'}`
}
function describeModifierToken(tokenText: string): string {
const result = validateNotation(`1d6${tokenText}`)
if (!result.valid) return tokenText
const descriptions = result.description[0] ?? []
const modifierDescriptions = descriptions.slice(1) // skip "Roll 1 6-sided die"
return modifierDescriptions.join(', ') || tokenText
}
// Order matters — more specific patterns must come before ambiguous ones
const MODIFIERS: readonly ModifierEntry[] = [
// ** before * to avoid partial match
{ type: 'multiplyTotal', pattern: /^\*\*\d+/ },
{ type: 'multiply', pattern: /^\*\d+/ },
// !! and !p before ! to avoid partial match
{ type: 'compound', pattern: /^!!\d*/ },
{ type: 'penetrate', pattern: /^!p\d*/i },
{ type: 'explode', pattern: /^!/ },
// Drop variants
{ type: 'dropHighest', pattern: /^[Hh]\d*/ },
{ type: 'dropLowest', pattern: /^[Ll]\d*/ },
{ type: 'dropCondition', pattern: /^[Dd]\{[^}]+\}/ },
// Keep: kl before K to avoid K matching first char of kl
{ type: 'keepLowest', pattern: /^[Kk][Ll]\d*/ },
{ type: 'keepHighest', pattern: /^[Kk]\d*/ },
// Brace-based modifiers — closing } required; partial input stays unknown
{ type: 'reroll', pattern: /^[Rr]\{[^}]+\}\d*/ },
{ type: 'cap', pattern: /^[Cc]\{[^}]+\}/ },
{ type: 'replace', pattern: /^[Vv]\{[^}]+\}/ },
{ type: 'unique', pattern: /^[Uu](?:\{[^}]+\})?/ },
{ type: 'countSuccesses', pattern: /^[Ss]\{\d+(?:,\d+)?\}/ },
// Arithmetic — only meaningful after a core token
{ type: 'plus', pattern: /^\+\d+/ },
{ type: 'minus', pattern: /^-\d+/ }
]
function appendUnknown(tokens: Token[], char: string, cursor: number): void {
const last = tokens[tokens.length - 1]
if (last?.type === 'unknown') {
tokens[tokens.length - 1] = { ...last, text: last.text + char, end: cursor + 1 }
} else {
tokens.push({ text: char, type: 'unknown', start: cursor, end: cursor + 1, description: '' })
}
}
function parseFrom(notation: string, cursor: number, tokens: Token[]): readonly Token[] {
if (cursor >= notation.length) return tokens
const remaining = notation.slice(cursor)
// A second dice pool (e.g. +1d20 in "1d6+1d20") must be detected before
// the plus/minus modifier patterns would consume the leading +/- sign.
const newPoolMatch = /^[+-]\d+[Dd][1-9]\d*/.exec(remaining)
if (newPoolMatch) {
const text = newPoolMatch[0]
tokens.push({
text,
type: 'core',
start: cursor,
end: cursor + text.length,
description: describeCoreToken(text)
})
return parseFrom(notation, cursor + text.length, tokens)
}
for (const entry of MODIFIERS) {
const m = remaining.match(entry.pattern)
if (m) {
const text = m[0]
tokens.push({
text,
type: entry.type,
start: cursor,
end: cursor + text.length,
description: describeModifierToken(text)
})
return parseFrom(notation, cursor + text.length, tokens)
}
}
appendUnknown(tokens, notation[cursor] ?? '', cursor)
return parseFrom(notation, cursor + 1, tokens)
}
export function tokenize(notation: string): readonly Token[] {
if (notation.length === 0) return []
const tokens: Token[] = []
const coreMatch = /^[+-]?\d+[Dd]\d+/.exec(notation)
if (coreMatch) {
const text = coreMatch[0]
tokens.push({
text,
type: 'core',
start: 0,
end: text.length,
description: describeCoreToken(text)
})
return parseFrom(notation, text.length, tokens)
}
return parseFrom(notation, 0, tokens)
}