const takeRegex = () => /\b(take|keep|drop|lose)\s+(?:the\s+)?(top|highest|high|best|bottom|lowest|low|worst)\s+(one|two|three|four|five|six|seven|eight|nine|ten|\d+)/i const rollRegex = () => /\b(roll|flip)\b/i const diceRegex = () => /\b(\d*)[dD](\d+)\b(?:\s?([+-])\s?(\d+))?/g const rerollRegex = () => /\b(reroll|re-roll)\s+((?:(?:\S*|\d+s|\d+'s|\d+)\s*)+)/ const numberRegex = () => /\b(take|keep|drop|lose|one|two|three|four|five|six|seven|eight|nine|ten|ones|twos|threes|fours|fives|sixes|sevens|eights|nines|tens|one's|two's|three's|four's|five's|seven's|eight's|nine's|ten's|\d+)\b/gi const advRegex = () => /\b(disadvantage|advantage)\b/i const percentRegex = () => /\b(percentile|percentiles|percent)\b/i const dicebotRegex = () => /\b(dicebot|dice bot)\b/i const multiplierRegex = () => /\bx(-?\d+)/i const helpRegex = () => /(dicebot|roll) help/i const unicoder = require('./unicoder') const d6 = { stringify: n => unicoder.codesets.dice[n], total: (ns, bonus) => ns.reduce((a, b) => a + b, bonus).toString() } const coin = { stringify: (n, i, total) => `${n === 1 ? unicoder.codesets.circles.red : n === 2 ? unicoder.codesets.circles.blue : unicoder.codesets.circles.black}${total === 1 ? (n === 1 ? ' Heads' : n === 2 ? ' Tails' : ' Sides') : ''}`, total: ns => { const heads = ns.filter(x => x === 1).length const tails = ns.filter(x => x === 2).length if (heads === 1 && tails === 0) return 'Heads' if (heads === 0 && tails === 1) return 'Tails' if (heads === 0 && tails > 0) return `${tails} Tails and no Heads` if (heads > 0 && tails === 0) return `${heads} Heads and no Tails` if (heads > 0 && tails > 0) return `${heads} Heads and ${tails} Tails` return 'The coin disappears' } } const d10 = { stringify: (n, i, total, { percent = false } = {}) => { if (total === 2 && percent) { if (i === 0) { return n === 10 ? '00' : `${n}0` } else { return n === 10 ? '0' : n.toString() } } else { return unicoder.codesets.negRound[n === 10 ? '0' : n.toString()] } }, total: (ns, bonus, { percent = false } = {}) => { if (ns.length === 2 & percent) { if (ns[0] === 0 && ns[1] === 0) return '100' return ((ns[0] === 10 ? 0 : ns[0]) * 10 + (ns[1] % 10)).toString() } return ns.reduce((a, b) => a + b, bonus).toString() }, bonusText: (x) => { const {percent = false, values = []} = x || {} if (values.length === 2 && !percent) return '_Did you mean "roll percentile"?_' } } const d20 = { stringify: n => unicoder.codesets.negRound[n], total: (ns, bonus) => ns.reduce((a, b) => a + b, bonus).toString() } const ascii = { stringify: n => n.toString(), total: (ns, bonus) => ns.reduce((a, b) => a + b, bonus).toString() } class Roll { constructor(roll) { if (roll) Object.assign(this, roll) } value() { return this.values.reduce((a, b) => a + b, this.bonus || 0) } toString() { let str = this.values.map((val, i) => this.formatter && this.formatter.stringify ? this.formatter.stringify(val, i, this.values.length, this) : `[${val}]`).join(' + ') if (this.bonus < 0) { str += ` - ${Math.abs(this.bonus)}` } else if (this.bonus > 0) { str += ` + ${this.bonus}` } if (this.rerolls === 1) { str += ` with 1 reroll` } else if (this.rerolls > 1) { str += ` with ${this.rerolls} rerolls` } if (this.dropped && this.dropped.length) { str += ` dropping ${this.dropped.map(v => this.formatter && this.formatter.stringify ? this.formatter.stringify(v) : `[${v}]`).join(', ')}` } if (this.values.length > 1 || this.bonus) { str += ` = ${this.formatter && this.formatter.total ? this.formatter.total(this.values, this.bonus || 0, this) : this.value()}` } return str } } class BadRoll extends Roll { constructor(text) { super({text}) } toString() { return this.text } } const random = (rng, n) => Math.floor(rng() * n) + 1 class Dice { constructor(dice) { if (dice) Object.assign(this, dice) if (!this.formatter) { if (this.sides === 2) this.formatter = coin else if (this.sides === 6) this.formatter = d6 else if (this.sides === 10) this.formatter = d10 else if (this.sides <= 20) this.formatter = d20 else this.formatter = ascii } if (!this.rng) this.rng = () => Math.random() } roll() { if (this.multiplier === 0) return [ new BadRoll(`That was easy rolling ${this}`)] if (this.multiplier > 32) return [new BadRoll(`I can't keep track of ${this}`)] if (this.multiplier < 0) return [new BadRoll(`I finished before I started rolling ${this}`)] return Array(this.multiplier).fill().map(() => { if (this.count > 100) return new BadRoll(`I lost count rolling ${this}`) if (this.sides > 256) return new BadRoll(`The faces are too small to read on ${this}`) if (this.count === 0) return new BadRoll(`I finished before I started rolling ${this}`) if (this.sides === 0) return new BadRoll(`I lost my grip on ${this}`) if (this.sides === 1) return new BadRoll(`The mobius ${this} never stopped rolling`) if (this.bonusSign === '-' && this.bonus > this.sides) return new BadRoll(`It doesn't seem fair to roll ${this}`) let values = [] for (let i = 0; i < this.count; i++) { if (this.modifier === 'advantage') { values.push(Math.max(random(this.rng, this.sides), random(this.rng, this.sides))) } else if (this.modifier === 'disadvantage') { values.push(Math.min(random(this.rng, this.sides), random(this.rng, this.sides))) } else { values.push(random(this.rng, this.sides)) } } let bonus = 0 switch (this.bonusSign) { case '-': bonus = -this.bonus; break case '+': bonus = this.bonus; break } let rerolls = 0 for (let i = 0; i < 10; i++) { let foundAny = false for (let d = 0; d < values.length; d++) { const v = values[d] if (this.rerollNumbers && this.rerollNumbers.includes(v)) { values[d] = random(this.rng, this.sides) foundAny = true rerolls++ } } if (!foundAny) break } let dropped = [] if (this.takeOrDrop) { let c = this.takeOrDrop.count if (this.takeOrDrop.takeDrop === 'drop') { c = Math.max(0, values.length - c) } if (this.takeOrDrop.highLow === 'highest') { values.sort((a,b) => b - a) } else { values.sort((a,b) => a - b) } dropped = values.splice(c) } return new Roll({ values, dropped, bonus, rerolls, percent: this.percent, formatter: this.formatter }) }) } toString() { let str = this.sides === 2 ? ( this.count === 1 ? 'a coin' : `${this.count} coins` ) : (this.count === 2 && this.percent) ? 'percentile dice' :`${this.count}d${this.sides}` if (this.bonusSign) { str += this.bonusSign + this.bonus } if (this.modifier) { str += ` with ${this.modifier}` } if (this.rerollNumbers && this.rerollNumbers.length) { if (this.rerollNumbers.length === 1) { str += ` and reroll ${this.rerollNumbers[0]}'s` } else { str += ` and reroll ${this.rerollNumbers.slice(0, this.rerollNumbers.length - 1).join(`'s, `)}'s, and ${this.rerollNumbers[this.rerollNumbers.length - 1]}'s` } } if (this.takeOrDrop) { str += ` and ${this.takeOrDrop.takeDrop} the ${this.takeOrDrop.highLow} ${this.takeOrDrop.count}` } if (this.multiplier !== 1) { str += ` x${this.multiplier}` } return str } } Dice.parse = (str, opts) => { str = str.toLowerCase() const rRegex = rollRegex() const roll = rRegex.exec(str) const rrRegex = rerollRegex() const rerolls = [] const reroll = rrRegex.exec(str) const rerollNumbers = [] const rtake = takeRegex() const take = rtake.exec(str) const mult = multiplierRegex().exec(str) let multiplier = 1 if (mult) { multiplier = parseFloat(mult[1]) } let todTakeDrop = '' let todHighLow = '' let todCount = 0 if (take) { switch (take[1]) { case 'take': case 'keep': todTakeDrop = 'take' break case 'drop': case 'lose': todTakeDrop = 'drop' break } switch (take[2]) { case 'top': case 'highest': case 'high': case 'best': todHighLow = 'highest' break case 'bottom': case 'lowest': case 'low': case 'worst': todHighLow = 'lowest' break } switch (take[3]) { case 'one': todCount = 1 break case 'two': todCount = 2 break case 'three': todCount = 3 break case 'four': todCount = 4 break case 'five': todCount = 5 break case 'six': todCount = 6 break case 'seven': todCount = 7 break case 'eight': todCount = 8 break case 'nine': todCount = 9 break case 'ten': todCount = 10 break default: todCount = parseInt(take[3]) } } if (reroll) { const nRegex = numberRegex() const strRerollNumbers = reroll[2] let broken = false while (!broken && (number = nRegex.exec(strRerollNumbers))) { switch (number[1]) { case 'drop': case 'keep': case 'take': case 'lose': broken = true break; case 'ones': case 'one': rerollNumbers.push(1) break case 'twos': case 'two': rerollNumbers.push(2) break case 'threes': case 'three': rerollNumbers.push(3) break case 'fours': case 'four': rerollNumbers.push(4) break case 'fives': case 'five': rerollNumbers.push(5) break case 'sixes': case 'six': rerollNumbers.push(6) break case 'sevens': case 'seven': rerollNumbers.push(7) break case 'eights': case 'eight': rerollNumbers.push(8) break case 'nines': case 'nine': rerollNumbers.push(9) break case 'tens': case 'ten': rerollNumbers.push(10) break default: const n = parseFloat(number) if (!isNaN(n)) { rerollNumbers.push(n) } } } } const aRegex = advRegex() const ad = aRegex.exec(str) const dicebot = dicebotRegex().test(str) const modifier = ad ? ( ad[1].toLowerCase()[0] === 'a' ? 'advantage' : 'disadvantage' ) : null const percent = percentRegex().test(str) const regex = diceRegex() const ret = [] let dice if (percent) { ret.push(new Dice({ enabled: !!roll, count: 2, sides: 10, percent: true, multiplier, rng: opts && opts.rng })) } while (dice = regex.exec(str)) { ret.push(new Dice({ enabled: !!roll, match: dice, count: dice[1] === '' ? 1 : parseFloat(dice[1]), sides: parseFloat(dice[2]), bonusSign: dice[3] || null, bonus: dice[4] === undefined ? null : parseFloat(dice[4]), rerollNumbers, modifier, percent, multiplier, takeOrDrop: (todTakeDrop && todHighLow && todCount) ? { takeDrop: todTakeDrop, highLow: todHighLow, count: todCount } : null, dicebot, rng: opts && opts.rng })) } return ret } Dice.chat = (chat, opts) => { if (helpRegex().test(chat)) { return `Tell me to roll dice by saying "Roll" followed by dice notation. For example, \`Roll a D20\`, or \`Roll 4d4+2\`. You can also \`Roll percentile\` to roll two D10's as 1-100, or \`Roll a D100\` (It's just as random). Also try \`Roll a D20 with advantage\`, \`Roll a D20 at disadvantage\`, or \`Roll 4d6 and reroll ones\`. You can roll a nice stat block with \`Roll 4d6, reroll ones, take the top 3 x6\`. If you mess up, you can edit your message without tainting the outcome.` } else { const dice = Dice.parse(chat, opts).filter(x => x.enabled) if (dice.length) { const diceStrings = dice.map(x => x.toString()) if (diceStrings.length > 1) { diceStrings[diceStrings.length - 1] = `and ${diceStrings[diceStrings.length - 1]}` } const isCoin = dice.length === 1 && dice[0].sides === 2 const rollingString = `${isCoin ? 'Flipping' : 'Rolling'} ${diceStrings.join(', ')}...` const rolls = dice.flatMap(die => die.roll()) const rollsSplits = rolls.map(roll => roll.toString().split(' = ')) const rollsStrings = rollsSplits.map(x => x[0]) const rollsResults = rollsSplits.map(x => x[1]).filter(x => x) const results = rollsResults.length ? ' = ' + rollsResults.join(', ') : '' const bonusString = rolls .filter(x => x.formatter && x.formatter.bonusText) .map(x => x.formatter.bonusText(x)) .filter(x => x) .map(x => '\n' + x) .join('') let rollSummary = rollsStrings.join(', ') if (rollSummary.length > 1000) rollSummary = '... ' return `${rollingString} ${rollSummary}${results}.${bonusString}` } } } module.exports = { Dice, Roll, diceRegex, rerollRegex, advRegex }