Compare commits

...

7 Commits

Author SHA1 Message Date
12adb2a205
plug the cypher system roller into the irc plugin
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-11-07 16:32:06 -06:00
8e22ccb2a3
start a TODO for myself
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-11-07 16:16:12 -06:00
02e5769ab5
irc 20.5.0 breaks recursion checks/bot.reply()
it seems like the Command object (the event used in basically
everything) expects an argument to __new__() that deepcopy can't
resolve, and deepcopy is critical to recursion currently, so until that
can be refactored, just pin our library

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-11-07 16:13:06 -06:00
ac16ec99e5
add a Cypher System dice rolling library method
not available to the bot, yet, but that's next

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-11-07 14:33:00 -06:00
994f9ef50b
Revert "replace dice sanity checks as asserts"
not sure why I would have done this, asserts are not great

This reverts commit b917f78ca5.

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-11-07 12:24:33 -06:00
f43b9e4de4
updates to the packages under test
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-10-31 13:41:25 -05:00
58fd0d0728
remove unused gitlab CI configuration
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-10-31 13:41:08 -05:00
11 changed files with 243 additions and 23 deletions

View File

@ -1,9 +0,0 @@
before_script:
- virtualenv --python=python3.4 env-py3 --clear
- source env-py3/bin/activate
- pip install -Ur requirements-dev.txt
lint:
script:
- echo "--zero-exit should be set until only a few messages remain"
- prospector --zero-exit

6
TODO.md Normal file
View File

@ -0,0 +1,6 @@
# TODO
Notes to my future self.
* Refactor recursion --- see 02e5769ab5b9406725235c4e37968f93afd0e978 --- to remove deepcopy
* maybe I can push/pop onto arguments or something instead of duplicating the whole event?

View File

@ -7,11 +7,15 @@ from django.conf import settings
from irc.client import NickMask
from dice.lib import cypher_roll
from dice.roller import DiceRoller
from ircbot.lib import Plugin
logger = logging.getLogger(__name__)
CYPHER_ROLL_REGEX = r'(T(?P<difficulty>\d+))?(?P<mods>(?:\s*(-|\+)\d+)*)\s*(?P<comment>.*)?'
CYPHER_COMMAND_REGEX = r'^!cypher\s+(' + CYPHER_ROLL_REGEX + ')'
class Dice(Plugin):
"""Roll simple or complex dice strings."""
@ -24,6 +28,8 @@ class Dice(Plugin):
def start(self):
"""Set up the handlers."""
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], CYPHER_COMMAND_REGEX,
self.handle_cypher_roll, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!roll\s+(.*)$',
self.handle_roll, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!random\s+(.*)$',
@ -38,6 +44,45 @@ class Dice(Plugin):
super(Dice, self).stop()
def handle_cypher_roll(self, connection, event, match):
"""Handle the !cypher roll."""
nick = NickMask(event.source).nick
task = match.group(1)
task_group = re.search(CYPHER_ROLL_REGEX, task)
difficulty = int(task_group.group('difficulty')) if task_group.group('difficulty') else None
mods = task_group.group('mods')
comment = task_group.group('comment')
result, beats, success, effect = cypher_roll(difficulty=difficulty, mods=mods)
if success is not None:
if success:
if effect:
result_str = f"9succeeded, with {effect}!"
else:
result_str = "9succeeded!"
else:
if effect:
result_str = f"4failed, with {effect}!"
else:
result_str = "4failed."
else:
if effect:
result_str = f"beats a difficulty {beats} task, with {effect}!"
else:
result_str = f"beats a difficulty {beats} task."
if success is not None:
# show the adjusted difficulty
detail_str = f"14(d20={result} vs. diff. {difficulty}{mods})"
else:
detail_str = f"14(d20={result} with {mods} levels)"
if comment:
return self.bot.reply(event, f"{nick}: {comment} {result_str} {detail_str}")
else:
return self.bot.reply(event, f"{nick}: your check {result_str} {detail_str}")
def handle_random(self, connection, event, match):
"""Handle the !random command which picks an item from a list."""
nick = NickMask(event.source).nick

41
dice/lib.py Normal file
View File

@ -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)

View File

@ -93,8 +93,12 @@ class DiceRoller(object):
size = m[1]
if keep > dice or keep == 0:
keep = dice
assert size >= 1, f"Die must have at least one side."
assert dice >= 1, f"At least one die must be rolled."
if size < 1:
output = "# of sides for die is incorrect: %d" % size
return output
if dice < 1:
output = "# of dice is incorrect: %d" % dice
return output
res = self.roll_dice(keep, dice, size)
curr_str += "%d%s" % (res[0], res[1])
res = res[0]

View File

@ -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<20.5.0", "numexpr",
"parsedatetime", "ply", "python-dateutil", "python-mpd2", "pytz", "zalgo-text"]
dynamic = ["version"]
classifiers = [
"Framework :: Django",

View File

@ -105,11 +105,9 @@ flake8-pyproject==1.2.3
# via dr.botzo (pyproject.toml)
idna==3.10
# via requests
importlib-resources==6.4.5
# via irc
iniconfig==2.0.0
# via pytest
irc==20.5.0
irc==20.4.3
# via dr.botzo (pyproject.toml)
isort==5.13.2
# via flake8-isort
@ -152,6 +150,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

View File

@ -22,9 +22,7 @@ django-extensions==3.2.3
# via dr.botzo (pyproject.toml)
djangorestframework==3.15.2
# via dr.botzo (pyproject.toml)
importlib-resources==6.4.5
# via irc
irc==20.5.0
irc==20.4.3
# via dr.botzo (pyproject.toml)
jaraco-collections==5.1.0
# via irc
@ -49,6 +47,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

View File

@ -0,0 +1,48 @@
"""Test IRC behavior of the dice plugin."""
import re
from unittest import mock
from django.test import TestCase
import dice.ircplugin
from ircbot.models import IrcServer
class MarkovTestCase(TestCase):
"""Test the markov plugin."""
fixtures = ['tests/fixtures/irc_server_fixture.json']
def setUp(self):
"""Create common objects."""
self.mock_bot = mock.MagicMock()
self.mock_connection = mock.MagicMock()
self.mock_connection.get_nickname.return_value = 'test_bot'
self.mock_connection.server_config = IrcServer.objects.get(pk=1)
self.plugin = dice.ircplugin.Dice(self.mock_bot, self.mock_connection, mock.MagicMock())
def test_cypher_roll_strings(self):
"""Simulate incoming Cypher System requests."""
mock_event = mock.MagicMock()
mock_event.arguments = ['!cypher T3']
mock_event.source = 'test!test@test'
mock_event.target = '#test'
mock_event.recursing = False
match = re.search(dice.ircplugin.CYPHER_COMMAND_REGEX, '!cypher T3')
with mock.patch('random.SystemRandom.randint', return_value=17):
self.plugin.handle_cypher_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: your check 9succeeded, with +1 damage! 14(d20=17 vs. diff. 3)'
)
match = re.search(dice.ircplugin.CYPHER_COMMAND_REGEX, '!cypher +1')
with mock.patch('random.SystemRandom.randint', return_value=17):
self.plugin.handle_cypher_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: your check beats a difficulty 4 task, with +1 damage! 14(d20=17 with +1 levels)'
)

77
tests/test_dice_lib.py Normal file
View File

@ -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))

12
tox.ini
View File

@ -31,7 +31,7 @@ commands =
--cov={envsitepackagesdir}/dispatch/ \
--cov={envsitepackagesdir}/dr_botzo/ \
--cov={envsitepackagesdir}/facts/ \
--cov={envsitepackagesdir}/gitlab_bot/ \
--cov={envsitepackagesdir}/history/ \
--cov={envsitepackagesdir}/ircbot/ \
--cov={envsitepackagesdir}/karma/ \
--cov={envsitepackagesdir}/markov/ \
@ -40,6 +40,7 @@ commands =
--cov={envsitepackagesdir}/races/ \
--cov={envsitepackagesdir}/seen/ \
--cov={envsitepackagesdir}/storycraft/ \
--cov={envsitepackagesdir}/text_manip/ \
--cov={envsitepackagesdir}/transform/ \
--cov={envsitepackagesdir}/weather/
@ -53,7 +54,7 @@ commands =
--cov={envsitepackagesdir}/dispatch/ \
--cov={envsitepackagesdir}/dr_botzo/ \
--cov={envsitepackagesdir}/facts/ \
--cov={envsitepackagesdir}/gitlab_bot/ \
--cov={envsitepackagesdir}/history/ \
--cov={envsitepackagesdir}/ircbot/ \
--cov={envsitepackagesdir}/karma/ \
--cov={envsitepackagesdir}/markov/ \
@ -62,6 +63,7 @@ commands =
--cov={envsitepackagesdir}/races/ \
--cov={envsitepackagesdir}/seen/ \
--cov={envsitepackagesdir}/storycraft/ \
--cov={envsitepackagesdir}/text_manip/ \
--cov={envsitepackagesdir}/transform/ \
--cov={envsitepackagesdir}/weather/
@ -84,7 +86,7 @@ commands =
{envsitepackagesdir}/dispatch/ \
{envsitepackagesdir}/dr_botzo/ \
{envsitepackagesdir}/facts/ \
{envsitepackagesdir}/gitlab_bot/ \
{envsitepackagesdir}/history/ \
{envsitepackagesdir}/ircbot/ \
{envsitepackagesdir}/karma/ \
{envsitepackagesdir}/markov/ \
@ -93,6 +95,7 @@ commands =
{envsitepackagesdir}/races/ \
{envsitepackagesdir}/seen/ \
{envsitepackagesdir}/storycraft/ \
{envsitepackagesdir}/text_manip/ \
{envsitepackagesdir}/transform/ \
{envsitepackagesdir}/weather/ \
-r
@ -122,7 +125,7 @@ include =
{envsitepackagesdir}/dispatch/
{envsitepackagesdir}/dr_botzo/
{envsitepackagesdir}/facts/
{envsitepackagesdir}/gitlab_bot/
{envsitepackagesdir}/history/
{envsitepackagesdir}/ircbot/
{envsitepackagesdir}/karma/
{envsitepackagesdir}/markov/
@ -131,6 +134,7 @@ include =
{envsitepackagesdir}/races/
{envsitepackagesdir}/seen/
{envsitepackagesdir}/storycraft/
{envsitepackagesdir}/text_manip/
{envsitepackagesdir}/transform/
{envsitepackagesdir}/weather/