dice.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. 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
  2. const rollRegex = () => /\b(roll|flip)\b/i
  3. const diceRegex = () => /\b(\d*)[dD](\d+)\b(?:\s?([+-])\s?(\d+))?/g
  4. const rerollRegex = () => /\b(reroll|re-roll)\s+((?:(?:\S*|\d+s|\d+'s|\d+)\s*)+)/
  5. 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
  6. const advRegex = () => /\b(disadvantage|advantage)\b/i
  7. const percentRegex = () => /\b(percentile|percentiles|percent)\b/i
  8. const dicebotRegex = () => /\b(dicebot|dice bot)\b/i
  9. const multiplierRegex = () => /\bx(-?\d+)/i
  10. const helpRegex = () => /(dicebot|roll) help/i
  11. const unicoder = require('./unicoder')
  12. const d6 = {
  13. stringify: n => unicoder.codesets.dice[n],
  14. total: (ns, bonus) => ns.reduce((a, b) => a + b, bonus).toString()
  15. }
  16. const coin = {
  17. 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') : ''}`,
  18. total: ns => {
  19. const heads = ns.filter(x => x === 1).length
  20. const tails = ns.filter(x => x === 2).length
  21. if (heads === 1 && tails === 0) return 'Heads'
  22. if (heads === 0 && tails === 1) return 'Tails'
  23. if (heads === 0 && tails > 0) return `${tails} Tails and no Heads`
  24. if (heads > 0 && tails === 0) return `${heads} Heads and no Tails`
  25. if (heads > 0 && tails > 0) return `${heads} Heads and ${tails} Tails`
  26. return 'The coin disappears'
  27. }
  28. }
  29. const d10 = {
  30. stringify: (n, i, total, { percent = false } = {}) => {
  31. if (total === 2 && percent) {
  32. if (i === 0) {
  33. return n === 10 ? '00' : `${n}0`
  34. } else {
  35. return n === 10 ? '0' : n.toString()
  36. }
  37. } else {
  38. return unicoder.codesets.negRound[n === 10 ? '0' : n.toString()]
  39. }
  40. },
  41. total: (ns, bonus, { percent = false } = {}) => {
  42. if (ns.length === 2 & percent) {
  43. if (ns[0] === 0 && ns[1] === 0) return '100'
  44. return ((ns[0] === 10 ? 0 : ns[0]) * 10 + (ns[1] % 10)).toString()
  45. }
  46. return ns.reduce((a, b) => a + b, bonus).toString()
  47. },
  48. bonusText: (x) => {
  49. const {percent = false, values = []} = x || {}
  50. if (values.length === 2 && !percent) return '_Did you mean "roll percentile"?_'
  51. }
  52. }
  53. const d20 = {
  54. stringify: n => unicoder.codesets.negRound[n],
  55. total: (ns, bonus) => ns.reduce((a, b) => a + b, bonus).toString()
  56. }
  57. const ascii = {
  58. stringify: n => n.toString(),
  59. total: (ns, bonus) => ns.reduce((a, b) => a + b, bonus).toString()
  60. }
  61. class Roll {
  62. constructor(roll) {
  63. if (roll) Object.assign(this, roll)
  64. }
  65. value() {
  66. return this.values.reduce((a, b) => a + b, this.bonus || 0)
  67. }
  68. toString() {
  69. let str = this.values.map((val, i) => this.formatter && this.formatter.stringify ? this.formatter.stringify(val, i, this.values.length, this) : `[${val}]`).join(' + ')
  70. if (this.bonus < 0) {
  71. str += ` - ${Math.abs(this.bonus)}`
  72. } else if (this.bonus > 0) {
  73. str += ` + ${this.bonus}`
  74. }
  75. if (this.rerolls === 1) {
  76. str += ` with 1 reroll`
  77. } else if (this.rerolls > 1) {
  78. str += ` with ${this.rerolls} rerolls`
  79. }
  80. if (this.dropped && this.dropped.length) {
  81. str += ` dropping ${this.dropped.map(v => this.formatter && this.formatter.stringify ? this.formatter.stringify(v) : `[${v}]`).join(', ')}`
  82. }
  83. if (this.values.length > 1 || this.bonus) {
  84. str += ` = ${this.formatter && this.formatter.total ? this.formatter.total(this.values, this.bonus || 0, this) : this.value()}`
  85. }
  86. return str
  87. }
  88. }
  89. class BadRoll extends Roll {
  90. constructor(text) {
  91. super({text})
  92. }
  93. toString() {
  94. return this.text
  95. }
  96. }
  97. const random = (rng, n) => Math.floor(rng() * n) + 1
  98. class Dice {
  99. constructor(dice) {
  100. if (dice) Object.assign(this, dice)
  101. if (!this.formatter) {
  102. if (this.sides === 2) this.formatter = coin
  103. else if (this.sides === 6) this.formatter = d6
  104. else if (this.sides === 10) this.formatter = d10
  105. else if (this.sides <= 20) this.formatter = d20
  106. else this.formatter = ascii
  107. }
  108. if (!this.rng) this.rng = () => Math.random()
  109. }
  110. roll() {
  111. if (this.multiplier === 0) return [ new BadRoll(`That was easy rolling ${this}`)]
  112. if (this.multiplier > 32) return [new BadRoll(`I can't keep track of ${this}`)]
  113. if (this.multiplier < 0) return [new BadRoll(`I finished before I started rolling ${this}`)]
  114. return Array(this.multiplier).fill().map(() => {
  115. if (this.count > 100) return new BadRoll(`I lost count rolling ${this}`)
  116. if (this.sides > 256) return new BadRoll(`The faces are too small to read on ${this}`)
  117. if (this.count === 0) return new BadRoll(`I finished before I started rolling ${this}`)
  118. if (this.sides === 0) return new BadRoll(`I lost my grip on ${this}`)
  119. if (this.sides === 1) return new BadRoll(`The mobius ${this} never stopped rolling`)
  120. if (this.bonusSign === '-' && this.bonus > this.sides) return new BadRoll(`It doesn't seem fair to roll ${this}`)
  121. let values = []
  122. for (let i = 0; i < this.count; i++) {
  123. if (this.modifier === 'advantage') {
  124. values.push(Math.max(random(this.rng, this.sides), random(this.rng, this.sides)))
  125. } else if (this.modifier === 'disadvantage') {
  126. values.push(Math.min(random(this.rng, this.sides), random(this.rng, this.sides)))
  127. } else {
  128. values.push(random(this.rng, this.sides))
  129. }
  130. }
  131. let bonus = 0
  132. switch (this.bonusSign) {
  133. case '-': bonus = -this.bonus; break
  134. case '+': bonus = this.bonus; break
  135. }
  136. let rerolls = 0
  137. for (let i = 0; i < 10; i++) {
  138. let foundAny = false
  139. for (let d = 0; d < values.length; d++) {
  140. const v = values[d]
  141. if (this.rerollNumbers && this.rerollNumbers.includes(v)) {
  142. values[d] = random(this.rng, this.sides)
  143. foundAny = true
  144. rerolls++
  145. }
  146. }
  147. if (!foundAny) break
  148. }
  149. let dropped = []
  150. if (this.takeOrDrop) {
  151. let c = this.takeOrDrop.count
  152. if (this.takeOrDrop.takeDrop === 'drop') {
  153. c = Math.max(0, values.length - c)
  154. }
  155. if (this.takeOrDrop.highLow === 'highest') {
  156. values.sort((a,b) => b - a)
  157. } else {
  158. values.sort((a,b) => a - b)
  159. }
  160. dropped = values.splice(c)
  161. }
  162. return new Roll({
  163. values,
  164. dropped,
  165. bonus,
  166. rerolls,
  167. percent: this.percent,
  168. formatter: this.formatter
  169. })
  170. })
  171. }
  172. toString() {
  173. let str =
  174. this.sides === 2
  175. ? (
  176. this.count === 1 ? 'a coin' : `${this.count} coins`
  177. )
  178. : (this.count === 2 && this.percent)
  179. ? 'percentile dice'
  180. :`${this.count}d${this.sides}`
  181. if (this.bonusSign) {
  182. str += this.bonusSign + this.bonus
  183. }
  184. if (this.modifier) {
  185. str += ` with ${this.modifier}`
  186. }
  187. if (this.rerollNumbers && this.rerollNumbers.length) {
  188. if (this.rerollNumbers.length === 1) {
  189. str += ` and reroll ${this.rerollNumbers[0]}'s`
  190. } else {
  191. str += ` and reroll ${this.rerollNumbers.slice(0, this.rerollNumbers.length - 1).join(`'s, `)}'s, and ${this.rerollNumbers[this.rerollNumbers.length - 1]}'s`
  192. }
  193. }
  194. if (this.takeOrDrop) {
  195. str += ` and ${this.takeOrDrop.takeDrop} the ${this.takeOrDrop.highLow} ${this.takeOrDrop.count}`
  196. }
  197. if (this.multiplier !== 1) {
  198. str += ` x${this.multiplier}`
  199. }
  200. return str
  201. }
  202. }
  203. Dice.parse = (str, opts) => {
  204. str = str.toLowerCase()
  205. const rRegex = rollRegex()
  206. const roll = rRegex.exec(str)
  207. const rrRegex = rerollRegex()
  208. const rerolls = []
  209. const reroll = rrRegex.exec(str)
  210. const rerollNumbers = []
  211. const rtake = takeRegex()
  212. const take = rtake.exec(str)
  213. const mult = multiplierRegex().exec(str)
  214. let multiplier = 1
  215. if (mult) {
  216. multiplier = parseFloat(mult[1])
  217. }
  218. let todTakeDrop = ''
  219. let todHighLow = ''
  220. let todCount = 0
  221. if (take) {
  222. switch (take[1]) {
  223. case 'take':
  224. case 'keep':
  225. todTakeDrop = 'take'
  226. break
  227. case 'drop':
  228. case 'lose':
  229. todTakeDrop = 'drop'
  230. break
  231. }
  232. switch (take[2]) {
  233. case 'top':
  234. case 'highest':
  235. case 'high':
  236. case 'best':
  237. todHighLow = 'highest'
  238. break
  239. case 'bottom':
  240. case 'lowest':
  241. case 'low':
  242. case 'worst':
  243. todHighLow = 'lowest'
  244. break
  245. }
  246. switch (take[3]) {
  247. case 'one':
  248. todCount = 1
  249. break
  250. case 'two':
  251. todCount = 2
  252. break
  253. case 'three':
  254. todCount = 3
  255. break
  256. case 'four':
  257. todCount = 4
  258. break
  259. case 'five':
  260. todCount = 5
  261. break
  262. case 'six':
  263. todCount = 6
  264. break
  265. case 'seven':
  266. todCount = 7
  267. break
  268. case 'eight':
  269. todCount = 8
  270. break
  271. case 'nine':
  272. todCount = 9
  273. break
  274. case 'ten':
  275. todCount = 10
  276. break
  277. default:
  278. todCount = parseInt(take[3])
  279. }
  280. }
  281. if (reroll) {
  282. const nRegex = numberRegex()
  283. const strRerollNumbers = reroll[2]
  284. let broken = false
  285. while (!broken && (number = nRegex.exec(strRerollNumbers))) {
  286. switch (number[1]) {
  287. case 'drop':
  288. case 'keep':
  289. case 'take':
  290. case 'lose':
  291. broken = true
  292. break;
  293. case 'ones':
  294. case 'one':
  295. rerollNumbers.push(1)
  296. break
  297. case 'twos':
  298. case 'two':
  299. rerollNumbers.push(2)
  300. break
  301. case 'threes':
  302. case 'three':
  303. rerollNumbers.push(3)
  304. break
  305. case 'fours':
  306. case 'four':
  307. rerollNumbers.push(4)
  308. break
  309. case 'fives':
  310. case 'five':
  311. rerollNumbers.push(5)
  312. break
  313. case 'sixes':
  314. case 'six':
  315. rerollNumbers.push(6)
  316. break
  317. case 'sevens':
  318. case 'seven':
  319. rerollNumbers.push(7)
  320. break
  321. case 'eights':
  322. case 'eight':
  323. rerollNumbers.push(8)
  324. break
  325. case 'nines':
  326. case 'nine':
  327. rerollNumbers.push(9)
  328. break
  329. case 'tens':
  330. case 'ten':
  331. rerollNumbers.push(10)
  332. break
  333. default:
  334. const n = parseFloat(number)
  335. if (!isNaN(n)) {
  336. rerollNumbers.push(n)
  337. }
  338. }
  339. }
  340. }
  341. const aRegex = advRegex()
  342. const ad = aRegex.exec(str)
  343. const dicebot = dicebotRegex().test(str)
  344. const modifier = ad ? (
  345. ad[1].toLowerCase()[0] === 'a' ? 'advantage' : 'disadvantage'
  346. ) : null
  347. const percent = percentRegex().test(str)
  348. const regex = diceRegex()
  349. const ret = []
  350. let dice
  351. if (percent) {
  352. ret.push(new Dice({
  353. enabled: !!roll,
  354. count: 2,
  355. sides: 10,
  356. percent: true,
  357. multiplier,
  358. rng: opts && opts.rng
  359. }))
  360. }
  361. while (dice = regex.exec(str)) {
  362. ret.push(new Dice({
  363. enabled: !!roll,
  364. match: dice,
  365. count: dice[1] === '' ? 1 : parseFloat(dice[1]),
  366. sides: parseFloat(dice[2]),
  367. bonusSign: dice[3] || null,
  368. bonus: dice[4] === undefined ? null : parseFloat(dice[4]),
  369. rerollNumbers,
  370. modifier,
  371. percent,
  372. multiplier,
  373. takeOrDrop: (todTakeDrop && todHighLow && todCount)
  374. ? {
  375. takeDrop: todTakeDrop,
  376. highLow: todHighLow,
  377. count: todCount
  378. }
  379. : null,
  380. dicebot,
  381. rng: opts && opts.rng
  382. }))
  383. }
  384. return ret
  385. }
  386. Dice.chat = (chat, opts) => {
  387. if (helpRegex().test(chat)) {
  388. 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.`
  389. } else {
  390. const dice = Dice.parse(chat, opts).filter(x => x.enabled)
  391. if (dice.length) {
  392. const diceStrings = dice.map(x => x.toString())
  393. if (diceStrings.length > 1) {
  394. diceStrings[diceStrings.length - 1] = `and ${diceStrings[diceStrings.length - 1]}`
  395. }
  396. const isCoin = dice.length === 1 && dice[0].sides === 2
  397. const rollingString = `${isCoin ? 'Flipping' : 'Rolling'} ${diceStrings.join(', ')}...`
  398. const rolls = dice.flatMap(die => die.roll())
  399. const rollsSplits = rolls.map(roll => roll.toString().split(' = '))
  400. const rollsStrings = rollsSplits.map(x => x[0])
  401. const rollsResults = rollsSplits.map(x => x[1]).filter(x => x)
  402. const results = rollsResults.length
  403. ? ' = ' + rollsResults.join(', ')
  404. : ''
  405. const bonusString = rolls
  406. .filter(x => x.formatter && x.formatter.bonusText)
  407. .map(x => x.formatter.bonusText(x))
  408. .filter(x => x)
  409. .map(x => '\n' + x)
  410. .join('')
  411. let rollSummary = rollsStrings.join(', ')
  412. if (rollSummary.length > 1000) rollSummary = '... '
  413. return `${rollingString} ${rollSummary}${results}.${bonusString}`
  414. }
  415. }
  416. }
  417. module.exports = {
  418. Dice,
  419. Roll,
  420. diceRegex,
  421. rerollRegex,
  422. advRegex
  423. }