roll.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. #!/usr/bin/env python
  2. import attr
  3. import logging
  4. import re
  5. import sys
  6. import readline
  7. import operator
  8. from numbers import Number
  9. from random import randint
  10. from pyparsing import Regex, oneOf, Optional, Group, Combine, Literal, CaselessLiteral, ZeroOrMore, StringStart, StringEnd, opAssoc, infixNotation, ParseException, Empty, pyparsing_common, ParseResults
  11. logFormatter = logging.Formatter('%(asctime)s %(levelname)s: %(message)s')
  12. logger = logging.getLogger(__name__)
  13. logger.setLevel(logging.INFO)
  14. logger.handlers = []
  15. logger.addHandler(logging.StreamHandler())
  16. for handler in logger.handlers:
  17. handler.setFormatter(logFormatter)
  18. @attr.s
  19. class IntegerValidator(object):
  20. min_val = attr.ib(default='-inf', convert=float)
  21. max_val = attr.ib(default='+inf', convert=float)
  22. handle_float = attr.ib(default='exception')
  23. @handle_float.validator
  24. def validate_handle_float(self, attribute, value):
  25. assert value in ('exception', 'truncate', 'round')
  26. def __call__(self, value):
  27. try:
  28. xf = float(value)
  29. except ValueError:
  30. raise ValueError('{} does not look like a number'.format(value))
  31. if not xf.is_integer():
  32. if self.handle_float == 'exception':
  33. raise ValueError('{} is not an integer'.format(value))
  34. elif self.handle_float == 'truncate':
  35. x = int(xf)
  36. else:
  37. x = round(xf)
  38. else:
  39. x = int(xf)
  40. if self.min_val is not None:
  41. assert x >= self.min_val
  42. if self.max_val is not None:
  43. assert x <= self.max_val
  44. return x
  45. def normalize_die_type(x):
  46. if x == 'F':
  47. return x
  48. elif x == '%':
  49. return 100
  50. else:
  51. try:
  52. return IntegerValidator(min_val=2, handle_float='exception')(x)
  53. except Exception:
  54. raise ValueError('Invalid die type: d{}'.format(x))
  55. def normalize_dice_count(x):
  56. xf = float(x)
  57. x = int(x)
  58. if not xf.is_integer():
  59. raise ValueError('dice count must be an integer, not {}'.format(xf))
  60. if x < 1:
  61. raise ValueError("dice count must be positive; {} is invalid".format(x))
  62. return x
  63. def ImplicitToken(x):
  64. '''Like pyparsing.Empty, but yields one or more tokens instead of nothing.'''
  65. return Empty().setParseAction(lambda toks: x)
  66. # TODO: Look at http://infohost.nmt.edu/tcc/help/pubs/pyparsing/web/classes.html#class-ParserElement
  67. # Implementing the syntax described here: https://www.critdice.com/roll-advanced-dice
  68. # https://stackoverflow.com/a/23956778/125921
  69. # https://stackoverflow.com/a/46583691/125921
  70. var_name = pyparsing_common.identifier.copy()
  71. real_num = pyparsing_common.fnumber.copy()
  72. positive_int = pyparsing_common.integer.copy().setParseAction(lambda toks: [ IntegerValidator(min_val=1)(toks[0]) ])
  73. drop_type = oneOf('K k X x -H -L')
  74. drop_spec = Group(drop_type.setResultsName('type') +
  75. (positive_int | ImplicitToken(1)).setResultsName('count')
  76. ).setResultsName('drop')
  77. pos_int_implicit_one = (positive_int | ImplicitToken(1))
  78. comparator_type = oneOf('<= < >= > ≤ ≥ =')
  79. reroll_type = Combine(oneOf('R r') | ( oneOf('! !!') + Optional('p')))
  80. reroll_spec = Group(
  81. reroll_type.setResultsName('type') +
  82. Optional(
  83. (comparator_type | ImplicitToken('=')).setResultsName('operator') + \
  84. positive_int.setResultsName('value')
  85. )
  86. ).setResultsName('reroll')
  87. count_spec = Group(
  88. Group(
  89. comparator_type.setResultsName('operator') + \
  90. positive_int.setResultsName('value')
  91. ).setResultsName('success_condition') +
  92. Optional(
  93. Literal('f') +
  94. Group(
  95. comparator_type.setResultsName('operator') + \
  96. positive_int.setResultsName('value')
  97. ).setResultsName('failure_condition')
  98. )
  99. ).setResultsName('count_successes')
  100. roll_spec = Group(
  101. (positive_int | ImplicitToken(1)).setResultsName('dice_count') +
  102. CaselessLiteral('d') +
  103. (positive_int | oneOf('% F')).setResultsName('die_type') +
  104. Optional(reroll_spec | drop_spec) +
  105. Optional(count_spec)
  106. ).setResultsName('roll')
  107. expr_parser = (StringStart() + infixNotation(
  108. baseExpr=(roll_spec | positive_int | real_num | var_name),
  109. opList=[
  110. (oneOf('**').setResultsName('operator', True), 2, opAssoc.RIGHT),
  111. (oneOf('* / × ÷').setResultsName('operator', True), 2, opAssoc.LEFT),
  112. (oneOf('+ -').setResultsName('operator', True), 2, opAssoc.LEFT),
  113. ]
  114. ) + StringEnd())
  115. def roll_die(sides=6):
  116. '''Roll a single die.
  117. Supports any valid integer number of sides as well as 'F' for a fate
  118. die, which can return -1, 0, or 1 with equal probability.
  119. '''
  120. if sides == 'F':
  121. # Fate die = 1d3-2
  122. return roll_die(3) - 2
  123. else:
  124. return randint(1, int(sides))
  125. class DieRolled(int):
  126. '''Subclass of int that allows a string suffix.
  127. This is meant for recording the result of rolling a die. The
  128. suffix is purely cosmetic, for the purposes of string conversion.
  129. It can be used to indicate a die roll that has been re-rolled or
  130. exploded, or to indicate a critical hit/miss.
  131. '''
  132. def __new__(cls, value, formatter='{}'):
  133. newval = super(DieRolled, cls).__new__(cls, value)
  134. newval.formatter = formatter
  135. return newval
  136. def __str__(self):
  137. return self.formatter.format(super().__str__())
  138. def __repr__(self):
  139. if self.formatter != '{}':
  140. return 'DieRolled(value={value!r}, formatter={formatter!r})'.format(
  141. value=int(self),
  142. formatter=self.formatter,
  143. )
  144. else:
  145. return 'DieRolled({value!r})'.format(value=int(self))
  146. def validate_dice_roll_list(instance, attribute, value):
  147. for x in value:
  148. # Not using positive_int here because 0 is a valid roll for
  149. # penetrating dice
  150. pyparsing_common.integer.parseString(str(x))
  151. def format_dice_roll_list(rolls, always_list=False):
  152. if len(rolls) == 0:
  153. raise ValueError('Need at least one die rolled')
  154. elif len(rolls) == 1 and not always_list:
  155. return str(rolls[0])
  156. else:
  157. return '[' + ",".join(map(str, rolls)) + ']'
  158. @attr.s
  159. class DiceRolled(object):
  160. '''Class representing the result of rolling one or more similar dice.'''
  161. dice_results = attr.ib(convert=list, validator = validate_dice_roll_list)
  162. dropped_results = attr.ib(default=attr.Factory(list), convert=list,
  163. validator = validate_dice_roll_list)
  164. roll_desc = attr.ib(default='', convert=str)
  165. success_count = attr.ib(default=None)
  166. @success_count.validator
  167. def validate_success_count(self, attribute, value):
  168. if value is not None:
  169. self.success_count = int(value)
  170. def __attrs_post_init__(self):
  171. if len(self.dice_results) < 1:
  172. raise ValueError('Need at least one non-dropped roll')
  173. def total(self):
  174. if self.success_count is not None:
  175. return int(self.success_count)
  176. else:
  177. return sum(self.dice_results)
  178. def __str__(self):
  179. if self.roll_desc:
  180. prefix = '{roll} rolled'.format(roll=self.roll_desc)
  181. else:
  182. prefix = 'Rolled'
  183. if self.dropped_results:
  184. drop = ' (dropped {dropped})'.format(dropped = format_dice_roll_list(self.dropped_results))
  185. else:
  186. drop = ''
  187. if self.success_count is not None:
  188. tot = ', Total successes: ' + str(self.total())
  189. elif len(self.dice_results) > 1:
  190. tot = ', Total: ' + str(self.total())
  191. else:
  192. tot = ''
  193. return '{prefix}: {results}{drop}{tot}'.format(
  194. prefix=prefix,
  195. results=format_dice_roll_list(self.dice_results),
  196. drop=drop,
  197. tot=tot,
  198. )
  199. def __int__(self):
  200. return self.total()
  201. def validate_by_parser(parser):
  202. def private_validator(instance, attribute, value):
  203. parser.parseString(str(value), True)
  204. return private_validator
  205. @attr.s
  206. class Comparator(object):
  207. cmp_dict = {
  208. '<=': operator.le,
  209. '<': operator.lt,
  210. '>=': operator.ge,
  211. '>': operator.gt,
  212. '≤': operator.le,
  213. '≥': operator.ge,
  214. '=': operator.eq,
  215. }
  216. operator = attr.ib(convert=str, validator=validate_by_parser(comparator_type))
  217. value = attr.ib(convert=int, validator=validate_by_parser(positive_int))
  218. def __str__(self):
  219. return '{op}{val}'.format(op=self.operator, val=self.value)
  220. def compare(self, x):
  221. '''Return True if x satisfies the comparator.
  222. In other words, x is placed on the left-hand side of the
  223. comparison, the Comparator's value is placed on the right hand
  224. side, and the truth value of the resulting test is returned.
  225. '''
  226. return self.cmp_dict[self.operator](x, self.value)
  227. @attr.s
  228. class RerollSpec(object):
  229. type = attr.ib(convert=str, validator=validate_by_parser(reroll_type))
  230. operator = attr.ib(default=None)
  231. value = attr.ib(default=None)
  232. comparator = attr.ib(default=None, repr=False)
  233. def __attrs_post_init__(self):
  234. if self.comparator is None:
  235. self.comparator = Comparator(self.operator, self.value)
  236. else:
  237. if self.operator is not None or self.value is not None:
  238. raise ValueError('Do not provide opeartor or value if providing a pre-build comparator')
  239. self.operator = self.comparator.operator
  240. self.value = self.comparator.value
  241. def __str__(self):
  242. return '{typ}{cmp}'.format(typ=self.type, cmp=self.comparator)
  243. def compare(self, x):
  244. return self.comparator.compare(x)
  245. def roll_die(self, sides):
  246. '''Roll a single die, following specified re-rolling rules.
  247. Returns a list of rolls, since some types of re-rolling
  248. collect the result of multiple die rolls.
  249. '''
  250. if sides == 'F':
  251. raise ValueError("Re-rolling/exploding is incompatible with Fate")
  252. if self.type == 'r':
  253. # Single reroll
  254. roll = roll_die(sides)
  255. if self.compare(roll):
  256. roll = DieRolled(roll_die(sides), '{}' + self.type)
  257. return [ roll ]
  258. elif self.type == 'R':
  259. # Indefinite reroll
  260. roll = roll_die(sides)
  261. while self.compare(roll):
  262. roll = DieRolled(roll_die(sides), '{}' + self.type)
  263. return [ roll ]
  264. elif self.type in ['!', '!!', '!p', '!!p']:
  265. # Explode/penetrate/compound
  266. all_rolls = [ roll_die(sides) ]
  267. while self.compare(all_rolls[-1]):
  268. all_rolls.append(roll_die(sides))
  269. # If we never re-rolled, no need to do anything special
  270. if len(all_rolls) == 1:
  271. return all_rolls
  272. # For penetration, subtract 1 from all rolls except the first
  273. if self.type.endswith('p'):
  274. for i in range(1, len(all_rolls)):
  275. all_rolls[i] -= 1
  276. # For compounding, return the sum
  277. if self.type.startswith('!!'):
  278. total = sum(all_rolls)
  279. return [ DieRolled(total, '{}' + self.type) ]
  280. else:
  281. for i in range(0, len(all_rolls)-1):
  282. all_rolls[i] = DieRolled(all_rolls[i], '{}' + self.type)
  283. return all_rolls
  284. @attr.s
  285. class DropSpec(object):
  286. type = attr.ib(convert=str, validator=validate_by_parser(drop_type))
  287. count = attr.ib(default=1, convert=int, validator=validate_by_parser(positive_int))
  288. def __str__(self):
  289. return self.type + str(self.count)
  290. def drop_rolls(self, rolls):
  291. '''Drop the appripriate rolls from a list of rolls.
  292. Returns a 2-tuple of roll lists. The first list is the kept
  293. rolls, and the second list is the dropped rolls.
  294. The order of the rolls is not preserved.
  295. '''
  296. if not isinstance(rolls, list):
  297. rolls = list(rolls)
  298. keeping = self.type in ('K', 'k')
  299. # if keeping:
  300. # num_to_keep = self.count
  301. # else:
  302. # num_to_keep = len(rolls) - self.count
  303. # if num_to_keep == 0:
  304. # raise ValueError('Not enough rolls: would drop all rolls')
  305. # elif num_to_keep == len(rolls):
  306. # raise ValueError('Keeping too many rolls: would not drop any rolls')
  307. rolls.sort()
  308. if self.type in ('K', 'X', '-H'):
  309. rolls.reverse()
  310. (head, tail) = rolls[:self.count], rolls[self.count:]
  311. if keeping:
  312. (kept, dropped) = (head, tail)
  313. else:
  314. (kept, dropped) = (tail, head)
  315. return (kept, dropped)
  316. @attr.s
  317. class DiceRoller(object):
  318. die_type = attr.ib(convert = normalize_die_type)
  319. dice_count = attr.ib(default=1, convert=normalize_dice_count)
  320. reroll_spec = attr.ib(default=None)
  321. @reroll_spec.validator
  322. def validate_reroll_spec(self, attribute, value):
  323. if value is not None:
  324. assert isinstance(value, RerollSpec)
  325. drop_spec = attr.ib(default=None)
  326. @drop_spec.validator
  327. def validate_drop_spec(self, attribute, value):
  328. if value is not None:
  329. assert isinstance(value, DropSpec)
  330. success_comparator = attr.ib(default=None)
  331. failure_comparator = attr.ib(default=None)
  332. @success_comparator.validator
  333. @failure_comparator.validator
  334. def validate_comparator(self, attribute, value):
  335. if value is not None:
  336. assert isinstance(value, Comparator)
  337. def __attrs_post_init__(self):
  338. if self.reroll_spec is not None and self.drop_spec is not None:
  339. raise ValueError('Reroll and drop specs are mutually exclusive')
  340. if self.success_comparator is None and self.failure_comparator is not None:
  341. raise ValueError('Cannot use a failure condition without a success condition')
  342. def __str__(self):
  343. return '{count}d{type}{reroll}{drop}{success}{fail}'.format(
  344. count = self.dice_count,
  345. type = self.die_type,
  346. reroll = self.reroll_spec or '',
  347. drop = self.drop_spec or '',
  348. success = self.success_comparator or '',
  349. fail = 'f' + str(self.success_comparator) if self.success_comparator else '',
  350. )
  351. def roll(self):
  352. '''Roll dice according to specifications. Returns a DiceRolled object.'''
  353. all_rolls = []
  354. if self.reroll_spec:
  355. for i in range(self.dice_count):
  356. all_rolls.extend(self.reroll_spec.roll_die(self.die_type))
  357. else:
  358. for i in range(self.dice_count):
  359. all_rolls.append(roll_die(self.die_type))
  360. if self.drop_spec:
  361. (dice_results, dropped_results) = self.drop_spec.drop_rolls(all_rolls)
  362. else:
  363. (dice_results, dropped_results) = (all_rolls, [])
  364. if self.success_comparator is not None:
  365. success_count = 0
  366. for roll in dice_results:
  367. if self.success_comparator.compare(roll):
  368. success_count += 1
  369. if self.failure_comparator is not None:
  370. for roll in dice_results:
  371. if self.failure_comparator.compare(roll):
  372. success_count -= 1
  373. else:
  374. success_count = None
  375. return DiceRolled(
  376. dice_results=dice_results,
  377. dropped_results=dropped_results,
  378. roll_desc=str(self),
  379. success_count=success_count,
  380. )
  381. def make_dice_roller(expr):
  382. if isinstance(expr, str):
  383. expr = roll_spec.parseString(expr, True)[0]
  384. assert expr.getName() == 'roll'
  385. expr = expr.asDict()
  386. dtype = normalize_die_type(expr['die_type'])
  387. dcount = normalize_dice_count(expr['dice_count'])
  388. constructor_args = {
  389. 'die_type': dtype,
  390. 'dice_count': dcount,
  391. 'reroll_spec': None,
  392. 'drop_spec': None,
  393. 'success_comparator': None,
  394. 'failure_comparator': None,
  395. }
  396. rrdict = None
  397. if 'reroll' in expr:
  398. rrdict = expr['reroll']
  399. if 'value' not in rrdict:
  400. rrdict['operator'] = '='
  401. # Default value is 1 for rerollers, dtype for exploders
  402. if rrdict['type'] in ('R', 'r'):
  403. rrdict['value'] = 1
  404. else:
  405. rrdict['value'] = dtype
  406. constructor_args['reroll_spec'] = RerollSpec(**rrdict)
  407. if 'drop' in expr:
  408. ddict = expr['drop']
  409. constructor_args['drop_spec'] = DropSpec(**ddict)
  410. if 'count_successes' in expr:
  411. csdict = expr['count_successes']
  412. constructor_args['success_comparator'] = Comparator(**csdict['success_condition'])
  413. if 'failure_condition' in csdict:
  414. constructor_args['failure_comparator'] = Comparator(**csdict['failure_condition'])
  415. return DiceRoller(**constructor_args)
  416. # examples = [
  417. # '1+1',
  418. # '1 + 1 + x',
  419. # '3d8',
  420. # '2e3 * 4d6 + 2',
  421. # '2d20k',
  422. # '3d20x2',
  423. # '4d4rK3',
  424. # '4d4R4',
  425. # '4d4R>=3',
  426. # '4d4r>=3',
  427. # '4d4!1',
  428. # '4d4!<3',
  429. # '4d4!p',
  430. # '2D20K+10',
  431. # '2D20k+10',
  432. # '10d6X4',
  433. # '4d8r + 6',
  434. # '20d6R≤2',
  435. # '6d10!≥8+6',
  436. # '10d4!p',
  437. # '20d6≥6',
  438. # '8d12≥10f≤2',
  439. # # '4d20R<=2!>=19Xx21>=20f<=5*2+3', # Pretty much every possible feature
  440. # ]
  441. # example_results = {}
  442. # for x in examples:
  443. # try:
  444. # example_results[x] = parse_roll(x)
  445. # except ParseException as ex:
  446. # example_results[x] = ex
  447. # example_results
  448. # rs = RerollSpec('!!p', '=', 6)
  449. # rs.roll_die(6)
  450. # ds = DropSpec('K', 2)
  451. # ds.drop_rolls([1,2,3,4,5])
  452. # ds = DropSpec('x', 2)
  453. # ds.drop_rolls([1,2,3,4,5])
  454. # parse_roll = lambda x: expr_parser.parseString(x)[0]
  455. # exprstring = 'x + 1 + (2 + (3 + 4))'
  456. # expr = parse_roll(exprstring)
  457. # r = parse_roll('x + 1 - 2 * y * 4d4 + 2d20K1>=20f<=5')[0]
  458. op_dict = {
  459. '+': operator.add,
  460. '-': operator.sub,
  461. '*': operator.mul,
  462. '×': operator.mul,
  463. '/': operator.truediv,
  464. '÷': operator.truediv,
  465. '**': operator.pow,
  466. }
  467. def eval_expr(expr, env={}, print_rolls=True):
  468. if isinstance(expr, str):
  469. expr = expr_parser.parseString(expr)[0]
  470. if isinstance(expr, Number):
  471. # Numeric literal
  472. return expr
  473. elif isinstance(expr, str):
  474. # variable name
  475. raise NotImplementedError("Variables are not implemented yet")
  476. if 'operator' in expr:
  477. # Compound expression
  478. operands = expr[::2]
  479. operators = expr[1::2]
  480. assert len(operands) == len(operators) + 1
  481. values = [ eval_expr(x) for x in operands ]
  482. result = values[0]
  483. for (op, nextval) in zip(operators, values[1:]):
  484. opfun = op_dict[op]
  485. result = opfun(result, nextval)
  486. return result
  487. else:
  488. # roll specification
  489. roller = make_dice_roller(expr)
  490. result = roller.roll()
  491. if print_rolls:
  492. print(result)
  493. return int(result)
  494. def read_roll(handle=sys.stdin):
  495. return input("Enter roll> ")
  496. if __name__ == '__main__':
  497. expr = " ".join(sys.argv[1:])
  498. if re.search("\\S", expr):
  499. try:
  500. result = roll(expr)
  501. logger.info("Total roll: %s", result)
  502. except Exception as exc:
  503. logger.error("Error while rolling: %s", repr(exc))
  504. sys.exit(1)
  505. else:
  506. try:
  507. while True:
  508. try:
  509. expr = read_roll()
  510. if expr in ('exit', 'quit', 'q'):
  511. break
  512. if re.search("\\S", expr):
  513. try:
  514. result = eval_expr(expr)
  515. logger.info("Total roll: %s", result)
  516. print('', file=sys.stderr)
  517. except Exception as exc:
  518. logger.error("Error while rolling: %s", repr(exc))
  519. except KeyboardInterrupt:
  520. print('')
  521. except EOFError:
  522. # Print a newline before exiting
  523. print('')