From ac16ec99e530fcc49551159a4d68e9c68ed45e1c Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Thu, 7 Nov 2024 12:23:32 -0600 Subject: [PATCH] add a Cypher System dice rolling library method not available to the bot, yet, but that's next Signed-off-by: Brian S. Stephan --- dice/lib.py | 41 ++++++++++++++++ pyproject.toml | 4 +- requirements/requirements-dev.txt | 4 ++ requirements/requirements.txt | 4 ++ tests/test_dice_lib.py | 77 +++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 dice/lib.py create mode 100644 tests/test_dice_lib.py diff --git a/dice/lib.py b/dice/lib.py new file mode 100644 index 0000000..993b018 --- /dev/null +++ b/dice/lib.py @@ -0,0 +1,41 @@ +"""Dice rolling operations (outside of the lex/yacc roller).""" +import random + +import numexpr + +rand = random.SystemRandom() + + +def cypher_roll(difficulty=None, mods=0): + """Make a Cypher System roll. + + Args: + difficulty: the original difficulty to beat; if provided, success or failure is indicated in the results + mods: eases(-) and hindrances(+) to apply to the check, as a string (e.g. '-3+1') + Returns: + tuple of: + - the result on the d20 + - the highest difficulty beaten + - if the difficulty is known, if the target was beat + - miscellaneous effects + """ + roll = rand.randint(1, 20) + if roll == 1: + return (roll, None, False if difficulty else None, 'a GM intrusion') + + effect = None + if roll == 17: + effect = '+1 damage' + elif roll == 18: + effect = '+2 damage' + elif roll == 19: + effect = 'a minor effect' + elif roll == 20: + effect = 'a MAJOR EFFECT' + + # if we know the difficulty, the mods would adjust the difficulty, but for the case where we don't, + # and maybe just in general, it's easier to modify the difficulty that the roll beats, so we flip the logic + # if incoming eases are a negative number, they should add to the difficulty the roll beats + beats = (roll // 3) - (numexpr.evaluate(mods).item() if mods else 0) + beats = 0 if beats < 0 else beats + return (roll, beats, difficulty <= beats if difficulty else None, effect) diff --git a/pyproject.toml b/pyproject.toml index 27acf31..7db73a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,8 +11,8 @@ authors = [ {name = "Brian S. Stephan", email = "bss@incorporeal.org"}, ] requires-python = ">=3.10,<3.12" -dependencies = ["Django<5.1", "django-bootstrap3", "django-extensions", "djangorestframework", "irc", "parsedatetime", "ply", - "python-dateutil", "python-mpd2", "pytz", "zalgo-text"] +dependencies = ["Django<5.1", "django-bootstrap3", "django-extensions", "djangorestframework", "irc", "numexpr", + "parsedatetime", "ply", "python-dateutil", "python-mpd2", "pytz", "zalgo-text"] dynamic = ["version"] classifiers = [ "Framework :: Django", diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index bbfecdd..46ba5b7 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -152,6 +152,10 @@ more-itertools==10.5.0 # jaraco-functools # jaraco-stream # jaraco-text +numexpr==2.10.1 + # via dr.botzo (pyproject.toml) +numpy==2.1.3 + # via numexpr packaging==24.1 # via # build diff --git a/requirements/requirements.txt b/requirements/requirements.txt index ed765a6..9aa9b67 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -49,6 +49,10 @@ more-itertools==10.5.0 # jaraco-functools # jaraco-stream # jaraco-text +numexpr==2.10.1 + # via dr.botzo (pyproject.toml) +numpy==2.1.3 + # via numexpr parsedatetime==2.6 # via dr.botzo (pyproject.toml) ply==3.11 diff --git a/tests/test_dice_lib.py b/tests/test_dice_lib.py new file mode 100644 index 0000000..51156c6 --- /dev/null +++ b/tests/test_dice_lib.py @@ -0,0 +1,77 @@ +"""Tests for dice operations.""" +from unittest import mock + +from django.test import TestCase + +import dice.lib + + +class DiceLibTestCase(TestCase): + """Test that a variety of dice rolls work as expected.""" + + def test_cypher_rolls(self): + """Roll a variety of Cypher System rolls.""" + # simple task, simple check + with mock.patch('random.SystemRandom.randint', return_value=5): + result = dice.lib.cypher_roll(difficulty=1) + self.assertEqual(result, (5, 1, True, None)) + + # simple failure + with mock.patch('random.SystemRandom.randint', return_value=2): + result = dice.lib.cypher_roll(difficulty=1) + self.assertEqual(result, (2, 0, False, None)) + + # rolled a 1 + with mock.patch('random.SystemRandom.randint', return_value=1): + result = dice.lib.cypher_roll(difficulty=1) + self.assertEqual(result, (1, None, False, 'a GM intrusion')) + + # rolled a 17 + with mock.patch('random.SystemRandom.randint', return_value=17): + result = dice.lib.cypher_roll(difficulty=1) + self.assertEqual(result, (17, 5, True, '+1 damage')) + + # rolled a 18 + with mock.patch('random.SystemRandom.randint', return_value=18): + result = dice.lib.cypher_roll(difficulty=1) + self.assertEqual(result, (18, 6, True, '+2 damage')) + + # rolled a 19 + with mock.patch('random.SystemRandom.randint', return_value=19): + result = dice.lib.cypher_roll(difficulty=1) + self.assertEqual(result, (19, 6, True, 'a minor effect')) + + # rolled a 20 + with mock.patch('random.SystemRandom.randint', return_value=20): + result = dice.lib.cypher_roll(difficulty=1) + self.assertEqual(result, (20, 6, True, 'a MAJOR EFFECT')) + + # mods affect the result of what the roll beats + with mock.patch('random.SystemRandom.randint', return_value=2): + result = dice.lib.cypher_roll(difficulty=1, mods='-5') + self.assertEqual(result, (2, 5, True, None)) + + # complex mods + with mock.patch('random.SystemRandom.randint', return_value=2): + result = dice.lib.cypher_roll(difficulty=3, mods='+1-4') + self.assertEqual(result, (2, 3, True, None)) + + # complex mods + with mock.patch('random.SystemRandom.randint', return_value=2): + result = dice.lib.cypher_roll(difficulty=3, mods='-4+1') + self.assertEqual(result, (2, 3, True, None)) + + # ...even without a difficulty known + with mock.patch('random.SystemRandom.randint', return_value=2): + result = dice.lib.cypher_roll(mods='-5') + self.assertEqual(result, (2, 5, None, None)) + + # general "don't know the difficulty" kind of check + with mock.patch('random.SystemRandom.randint', return_value=10): + result = dice.lib.cypher_roll(mods='-2') + self.assertEqual(result, (10, 5, None, None)) + + # general "don't know the difficulty" kind of check in the other direction + with mock.patch('random.SystemRandom.randint', return_value=10): + result = dice.lib.cypher_roll(mods='2') + self.assertEqual(result, (10, 1, None, None))