54 Commits

Author SHA1 Message Date
bss 49f3d35f2e add a simple oracle to the dice roller
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-07-25 09:03:19 -05:00
bss 2cdb6feb44 allow roll strings on IRC to have an 'a' natural language prefix
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-07-25 07:27:20 -05:00
bss 59d462fcd0 dice: cypher GM intrusions should not be auto-fails
this also has some code reorg around IRC styles

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-07-25 07:15:09 -05:00
bss e0ae5335f4 dice: more tests of the cypher roll string
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-07-24 10:41:19 -05:00
bss 533a74db6d dice: more fork syncing, this time changing some output
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-07-24 10:02:57 -05:00
bss 3c953ba822 dice: more syncing from the fork
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-07-24 09:45:04 -05:00
bss 76b669be07 dice: more syncs from the fork, returning more about a result
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-07-24 09:30:44 -05:00
bss 915afbf5ec dice: more test method granularity
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-07-24 09:26:10 -05:00
bss 6fec81917e cypher dice: some cleanups from my fork that I'm merging back
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-07-24 07:57:14 -05:00
bss eb325f98b0 tweak cypher result output string
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-06-01 11:02:47 -05:00
bss ba076b846a handle empty string comments in cypher results
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-06-01 10:49:15 -05:00
bss 9c0a459540 construct the lex/yacc dice roller in the package
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-05-17 12:48:36 -05:00
bss c14afe478a move the generic roll result code into a common place
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-05-17 12:38:13 -05:00
bss c41a2211d0 move the cypher roll result code into a common place
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-05-17 12:02:01 -05:00
bss 128bf3aca1 fix the parser to account for comments with other important tokens 2026-05-14 20:23:13 -05:00
bss 4e9661aafa add a placeholder dice page to the website 2026-05-14 18:58:04 -05:00
bss 3f1e0e7af3 rework cypher rolls to be via the lex/yacc parser
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-05-14 16:09:45 -05:00
bss 025cf58b91 couple cleanups and unit tests for acro
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-05-14 16:09:45 -05:00
bss cd79a97f06 fixes and cleanups to make acro vaguely functional again
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-05-14 16:09:45 -05:00
bss 0c7a55044f Revert "wttr.in nests the data in the response now, account for that"
guess they went back to the old format

This reverts commit 4d2b1d5eb0.
2026-03-30 10:30:50 -05:00
bss 4d2b1d5eb0 wttr.in nests the data in the response now, account for that
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-03-18 16:23:14 -05:00
bss a45b8f41eb allow more rolls at once, 30 seems ok
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-03-18 16:23:14 -05:00
bss ddbe32060e use django whitenoise for static files
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-03-18 16:23:11 -05:00
bss 2ceed526d6 fix non-uniqueness oversight is checking for discord bridge
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-03-18 16:23:06 -05:00
bss b212eb0e00 tweak the green-ness of the middle !reaction result
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-03-18 16:22:57 -05:00
bss e7d63ee963 slight tweak to the !reaction reply string
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-24 16:43:44 -06:00
bss 3100efd4a9 add a !reaction dice roll for a very simple oracle-style vibe check
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-24 16:39:06 -06:00
bss 6d8ba18380 cypher: distinguish between a task roll and an attack roll
attack = A, otherwise the same options as with a generic task (= T)

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-08 23:57:23 -06:00
bss 3b7c871a94 cypher: case insensitivity when matching task difficulties
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-08 23:57:23 -06:00
bss fa4815153a cypher: slightly better display of output with no difficulty or mods
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-08 23:57:23 -06:00
bss cc9b110531 fix a display issue in the karma key score
this would probably only matter if adding a key manually that doesn't
have a score, but a fix is a fix

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-08 23:57:23 -06:00
bss af4746fb90 allow Markov contexts to be hidden from the web
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-08 23:57:23 -06:00
bss d0cbc815d1 update post_connect to accept a /command to do things other than "msg"
these are just /msg and /mode for now, and the slash is retained out of
convention

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-12-16 23:47:32 -06:00
bss 166b506843 the weather module depends on requests
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-11-08 08:29:30 -06:00
bss 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
bss 8e22ccb2a3 start a TODO for myself
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-11-07 16:16:12 -06:00
bss 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
bss 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
bss 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
bss f43b9e4de4 updates to the packages under test
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-10-31 13:41:25 -05:00
bss 58fd0d0728 remove unused gitlab CI configuration
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-10-31 13:41:08 -05:00
bss cf8d24187f requirements bumps, pinning Django to 5.0 for now
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-10-31 00:25:55 -05:00
bss 8f76e54d30 add a couple dice roller tests
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-10-31 00:18:56 -05:00
bss 1674300ec3 convert to pyproject.toml (no versioneer)
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-10-31 00:10:03 -05:00
bss 355d17d171 flake8 cleanups
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-07-17 00:30:27 -05:00
bss e5b9f1634a completely remove usages of django-adminplus
it was futzing up some /admin/ login stuff, and I hadn't actually used
the three views using it in years, so probably better to just yeet it
(technical term) into the sun.

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-03 13:24:38 -05:00
bss e63740ca70 replace discord bridge nick with sender nick inside the bot
when the bot has received a message through the discord bridge that
it'll end up reacting to (by creating a countdown item, for instance),
the nick in the event should, for all intents and purposes, be the
sender's nick on the discord side, not the discord bridge itself

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-03 12:52:58 -05:00
bss 03e1269cf2 updates to bump the whole app to Django 5.0
note that this removes support for python 3.8 and 3.9!

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-03 12:44:57 -05:00
bss 2dfb942f91 some trivial setup.py cleanups until we convert
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-03 12:17:09 -05:00
bss a92d0041cf add the REUSE tool, not that this repo is compliant yet
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-03 12:16:43 -05:00
bss 84eddf4b8a requirements bumps
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-01 22:12:40 -05:00
bss b69053b279 add python 3.11 to versions under test in tox
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-01 22:06:23 -05:00
bss a1a256ca3b adopt the DCO and clean up license, etc. information
NOTE: for the moment this project is GPLv3 ONLY (no "or later" was ever
present). I will hopefully reach out to the couple other authors later
to see about adding the "or later" part

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-02-23 00:09:44 -06:00
bss ee6ae7080e remove the bridge-speaker from the message when relevant
this allows downstream event handlers to react to e.g.:

<discord_user> !weather 12345

as if they were normal leading IRC commands
2023-09-12 09:16:38 -05:00
54 changed files with 1963 additions and 3250 deletions
+2
View File
@@ -5,6 +5,7 @@ tags/
*.egg-info/
.tox/
.coverage
staticfiles/
dr.botzo.data
dr.botzo.cfg
localsettings.py
@@ -22,3 +23,4 @@ dr.botzo.markov
*.swp
*.urls
*~
_version.py
-9
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
+35 -1
View File
@@ -1,10 +1,44 @@
## Contributing
# Contributing Guidelines
This is a pretty fun, non-serious hacking project, so if you're interested in
contributing, sign up, clone the project, and submit some merge requests!
There's a lot to add and work on, so join in.
dr.botzo is made available under the GPLv3 license. Contributions are welcome via pull requests. This document outlines
the process to get your contribution accepted.
## Code Style
4 spaces per indent level. 120 character line length. Follow PEP8 as closely
as reasonable. There's a prospector config, use it.
## Sign Offs/Custody of Contributions
I do not request the copyright of contributions be assigned to me or to the project, and I require no provision that I
be allowed to relicense your contributions. My personal oath is to maintain inbound=outbound in my open source projects,
and the expectation is authors are responsible for their contributions.
I am following the the [Developer Certificate of Origin (DCO)](https://developercertificate.org/), also available at
`DCO.txt`. The DCO is a way for contributors to certify that they wrote or otherwise have the right to license their
code contributions to the project. Contributors must sign-off that they adhere to these requirements by adding a
`Signed-off-by` line to their commit message, and/or, for frequent contributors, by signing off on their entry in
`MAINTAINERS.md`.
This process is followed by a number of open source projects, most notably the Linux kernel. Here's the gist of it:
```
[Your normal Git commit message here.]
Signed-off-by: Random J Developer <random@developer.example.org>
```
`git help commit` has more info on adding this:
```
-s, --signoff
Add Signed-off-by line by the committer at the end of the commit log
message. The meaning of a signoff depends on the project, but it typically
certifies that committer has the rights to submit this work under the same
license and agrees to a Developer Certificate of Origin (see
http://developercertificate.org/ for more information).
```
+34
View File
@@ -0,0 +1,34 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
+4 -4
View File
@@ -1,7 +1,7 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
@@ -645,7 +645,7 @@ the "copyright" line and a pointer to where the full notice is found.
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
@@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+10
View File
@@ -0,0 +1,10 @@
# Maintainers
This file contains information about people permitted to make major decisions and direction on the project.
## Contributing Under the DCO
By adding your name and email address to this section, you certify that all of your subsequent contributions to dr.botzo
are made under the terms of the Developer's Certificate of Origin 1.1, available at `DCO.txt`.
* Brian S. Stephan (<bss@incorporeal.org>)
+6
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?
+246 -233
View File
@@ -1,182 +1,154 @@
"""An acromania style game for IRC."""
"""An acromania style game for IRC.
SPDX-FileCopyrightText: © 2026 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0
"""
import logging
import random
import threading
import time
from enum import Enum
from irc.client import NickMask, is_channel
from ircbot.lib import Plugin
log = logging.getLogger('acro.ircplugin')
logger = logging.getLogger('acro.ircplugin')
class Acro(Plugin):
class AcroGameState(Enum):
"""Define states a game of Acromania can be in."""
"""Play a game where users come up with silly definitions for randomly-generated acronyms."""
stopped = 0
game_start = 1
round_submission = 2
round_voting = 3
round_results = 4
class AcroGame(object):
"""Track game details."""
def __init__(self):
"""Initialize basic stuff."""
# running state
self.state = 0
self.quit = False
self.scores = dict()
self.rounds = []
self.channel = ''
class AcroRound(object):
"""Track a particular round of a game."""
def __init__(self):
"""Initialize basic stuff."""
self.acro = ""
self.submissions = dict()
self.sub_shuffle = []
self.votes = dict()
# default options
self.seconds_to_submit = 60
self.seconds_to_vote = 45
self.seconds_to_submit_step = 10
self.seconds_to_pause = 5
def __init__(self, bot, connection, event):
"""Set up the game tracking and such."""
class AcroGame(object):
"""Track game details."""
# game state
self.game = self.AcroGame()
def __init__(self, bot, channel):
"""Initialize basic stuff."""
# running state
self.state = AcroGameState.stopped
self.should_quit = False
self.scores = dict()
self.rounds = []
self.channel = channel
super(Acro, self).__init__(bot, connection, event)
# maintain a reference to the bot to generate messages back to IRC
self.bot = bot
def start(self):
"""Set up handlers."""
@property
def current_round(self):
"""Provide access to the current round of the game."""
return self.rounds[-1]
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+status$',
self.handle_status, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+start$',
self.handle_start, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+submit\s+(.*)$',
self.handle_submit, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+vote\s+(\d+)$',
self.handle_vote, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+quit$',
self.handle_quit, -20)
super(Acro, self).start()
def stop(self):
"""Tear down handlers."""
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_status)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_start)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_submit)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_vote)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_quit)
super(Acro, self).stop()
def handle_status(self, connection, event, match):
"""Return the status of the currently-running game, if there is one."""
if self.game.state == 0:
return self.bot.reply(event, "there currently isn't a game running.")
else:
return self.bot.reply(event, "the game is running.")
def handle_start(self, connection, event, match):
"""Start the game, notify the channel, and begin timers."""
if self.game.state != 0:
return self.bot.reply(event, "the game is already running.")
else:
if is_channel(event.target):
self._start_new_game(event.target)
else:
return self.bot.reply("you must start the game from a channel.")
def handle_submit(self, connection, event, match):
"""Take a submission for an acro."""
nick = NickMask(event.source).nick
submission = match.group(1)
return self.bot.reply(event, self._take_acro_submission(nick, submission))
def handle_vote(self, connection, event, match):
"""Take a vote for an acro."""
nick = NickMask(event.source).nick
vote = match.group(1)
return self.bot.reply(event, self._take_acro_vote(nick, vote))
def handle_quit(self, connection, event, match):
"""Quit the game after the current round ends."""
if self.game.state != 0:
self.game.quit = True
return self.bot.reply(event, "the game will end after the current round.")
def thread_do_process_submissions(self, sleep_time):
"""Wait for players to provide acro submissions, and then kick off voting."""
time.sleep(sleep_time)
self._start_voting()
def thread_do_process_votes(self):
"""Wait for players to provide votes, and then continue or quit."""
time.sleep(self.game.rounds[-1].seconds_to_vote)
self._end_current_round()
def _start_new_game(self, channel):
def start_new_game(self):
"""Begin a new game, which will have multiple rounds."""
if self.state != AcroGameState.stopped:
return "the game is already running."
self.game.state = 1
self.game.channel = channel
self.state = AcroGameState.game_start
self.bot.reply(None, "starting a new game of acro. it will run until you tell it to quit.",
explicit_target=self.game.channel)
explicit_target=self.channel)
self._start_new_round()
def _start_new_round(self):
"""Start a new round for play."""
def start_voting(self):
"""Begin the voting period."""
self.state = AcroGameState.round_voting
self.current_round.shuffled_submissions = list(self.current_round.submissions.keys())
random.SystemRandom().shuffle(self.current_round.shuffled_submissions)
self.bot.reply(None, "here are the results. vote with !acro vote [number]", explicit_target=self.channel)
self._print_round_acros()
self.game.state = 2
self.game.rounds.append(self.AcroRound())
acro = self._generate_acro()
self.game.rounds[-1].acro = acro
sleep_time = self.game.rounds[-1].seconds_to_submit + (self.game.rounds[-1].seconds_to_submit_step * (len(acro)-3))
self.bot.reply(None, "the round has started! your acronym is '{0:s}'. "
"submit within {1:d} seconds via !acro submit [meaning]".format(acro, sleep_time),
explicit_target=self.game.channel)
t = threading.Thread(target=self.thread_do_process_submissions, args=(sleep_time,))
t = threading.Thread(target=self.thread_do_process_votes, args=())
t.daemon = True
t.start()
def take_acro_submission(self, nick, submission):
"""Take an acro submission and record it."""
if self.state == AcroGameState.round_submission:
sub_acro = self._turn_text_into_acro(submission)
if sub_acro == self.current_round.acro:
self.current_round.submissions[nick] = submission
return "your submission has been recorded. it replaced any old submission for this round."
else:
return "the current acro is '{0:s}', not '{1:s}'".format(self.current_round.acro, sub_acro)
def take_acro_vote(self, nick, vote):
"""Take an acro vote and record it."""
if self.state == AcroGameState.round_voting:
acros = self.current_round.submissions
if int(vote) > 0 and int(vote) <= len(acros):
logger.debug("%s is %s", vote, self.current_round.shuffled_submissions[int(vote)-1])
key = self.current_round.shuffled_submissions[int(vote)-1]
if key != nick:
self.current_round.votes[nick] = key
return "your vote has been recorded. it replaced any old vote for this round."
else:
return "you can't vote for yourself!"
else:
return "you must vote for 1-{0:d}".format(len(acros))
def thread_do_process_submissions(self, sleep_time):
"""Wait for players to provide acro submissions, and then kick off voting."""
time.sleep(sleep_time)
self.start_voting()
def thread_do_process_votes(self):
"""Wait for players to provide votes, and then continue or quit."""
time.sleep(self.current_round.seconds_to_vote)
self._end_current_round()
def _add_round_scores_to_game_scores(self):
"""Apply the final round scores to the total scores for the game."""
for s in list(self.current_round.votes.values()):
votes = [x for x in list(self.current_round.votes.values()) if x == s]
if s in list(self.scores.keys()):
self.scores[s] += len(votes)
else:
self.scores[s] = len(votes)
def _continue_or_quit(self):
"""Decide whether the game should continue or quit."""
if self.state == AcroGameState.round_results:
if self.should_quit:
self._end_game()
else:
self._start_new_round()
def _end_current_round(self):
"""Clean up and output for ending the current round."""
self.state = AcroGameState.round_results
self.bot.reply(None, "voting's over! here are the scores for the round:", explicit_target=self.channel)
self._print_round_scores()
self._add_round_scores_to_game_scores()
self._continue_or_quit()
def _end_game(self):
"""Clean up the entire game."""
self.state = AcroGameState.stopped
self.should_quit = False
self.bot.reply(None, "game's over! here are the final scores:", explicit_target=self.channel)
self._print_game_scores()
@staticmethod
def _generate_acro():
def _generate_acro(): # noqa: C901
"""Generate an acro to play with.
Letter frequencies pinched from
http://www.math.cornell.edu/~mec/2003-2004/cryptography/subs/frequencies.html
"""
acro = []
# generate acro 3-8 characters long
for i in range(1, random.randint(4, 9)):
letter = random.randint(1, 182303)
for i in range(1, random.SystemRandom().randint(4, 9)):
letter = random.SystemRandom().randint(1, 182303)
if letter <= 21912:
acro.append('E')
elif letter <= 38499:
@@ -232,122 +204,163 @@ class Acro(Plugin):
return "".join(acro)
def _take_acro_submission(self, nick, submission):
"""Take an acro submission and record it."""
def _print_game_scores(self):
"""Print the final calculated scores."""
for s in list(self.scores.keys()):
self.bot.reply(None, " {0:s}: {1:d}".format(s, self.scores[s]), explicit_target=self.channel)
if self.game.state == 2:
sub_acro = self._turn_text_into_acro(submission)
def _print_round_acros(self):
"""Take the current round's acros and spit them to the channel."""
i = 0
for s in self.current_round.shuffled_submissions:
logger.debug("%s is %s", str(i), s)
self.bot.reply(None, " {0:d}: {1:s}".format(i+1, self.current_round.submissions[s]),
explicit_target=self.channel)
i += 1
if sub_acro == self.game.rounds[-1].acro:
self.game.rounds[-1].submissions[nick] = submission
return "your submission has been recorded. it replaced any old submission for this round."
else:
return "the current acro is '{0:s}', not '{1:s}'".format(self.game.rounds[-1].acro, sub_acro)
def _print_round_scores(self):
"""For the acros in the round, find the votes for them."""
i = 0
for s in list(self.current_round.submissions.keys()):
votes = [x for x in list(self.current_round.votes.values()) if x == s]
self.bot.reply(None, " {0:d} ({1:s}): {2:d}".format(i+1, s, len(votes)),
explicit_target=self.channel)
i += 1
def _start_new_round(self):
"""Start a new round for play."""
self.state = AcroGameState.round_submission
self.rounds.append(AcroRound())
acro = self._generate_acro()
self.current_round.acro = acro
sleep_time = self.current_round.seconds_to_submit + (self.current_round.seconds_to_submit_step * (len(acro)-3))
self.bot.reply(None, "the round has started! your acronym is '{0:s}'. "
"submit within {1:d} seconds via !acro submit [meaning]".format(acro, sleep_time),
explicit_target=self.channel)
t = threading.Thread(target=self.thread_do_process_submissions, args=(sleep_time,))
t.daemon = True
t.start()
@staticmethod
def _turn_text_into_acro(text):
"""Turn text into an acronym."""
words = text.split()
acro = []
for w in words:
acro.append(w[0].upper())
return "".join(acro)
def _start_voting(self):
"""Begin the voting period."""
self.game.state = 3
self.game.rounds[-1].sub_shuffle = list(self.game.rounds[-1].submissions.keys())
random.shuffle(self.game.rounds[-1].sub_shuffle)
self.bot.reply(None, "here are the results. vote with !acro vote [number]", explicit_target=self.game.channel)
self._print_round_acros()
class AcroRound(object):
"""Track a particular round of a game."""
t = threading.Thread(target=self.thread_do_process_votes, args=())
t.daemon = True
t.start()
def __init__(self):
"""Initialize basic stuff."""
self.acro = ""
self.submissions = dict()
self.shuffled_submissions = []
self.votes = dict()
def _print_round_acros(self):
"""Take the current round's acros and spit them to the channel."""
# default options
self.seconds_to_submit = 60
self.seconds_to_vote = 45
self.seconds_to_submit_step = 10
self.seconds_to_pause = 5
i = 0
for s in self.game.rounds[-1].sub_shuffle:
log.debug("%s is %s", str(i), s)
self.bot.reply(None, " {0:d}: {1:s}".format(i+1, self.game.rounds[-1].submissions[s]),
explicit_target=self.game.channel)
i += 1
def _take_acro_vote(self, nick, vote):
"""Take an acro vote and record it."""
class Acro(Plugin):
"""Play a game where users come up with silly definitions for randomly-generated acronyms."""
if self.game.state == 3:
acros = self.game.rounds[-1].submissions
if int(vote) > 0 and int(vote) <= len(acros):
log.debug("%s is %s", vote, self.game.rounds[-1].sub_shuffle[int(vote)-1])
key = self.game.rounds[-1].sub_shuffle[int(vote)-1]
def __init__(self, bot, connection, event):
"""Initialize the collection of games."""
self.games = {}
super(Acro, self).__init__(bot, connection, event)
if key != nick:
self.game.rounds[-1].votes[nick] = key
return "your vote has been recorded. it replaced any old vote for this round."
def start(self):
"""Set up handlers."""
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+status$',
self.handle_status, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+start$',
self.handle_start, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+submit\s+(.*)$',
self.handle_submit, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+vote\s+(\d+)$',
self.handle_vote, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+quit$',
self.handle_quit, -20)
super(Acro, self).start()
def stop(self):
"""Tear down handlers."""
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_status)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_start)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_submit)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_vote)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_quit)
super(Acro, self).stop()
def handle_status(self, connection, event, match):
"""Return the status of the currently-running game, if there is one."""
if is_channel(event.target):
try:
game = self.games[event.target]
if game.state == AcroGameState.stopped:
return self.bot.reply(event, "there currently isn't a game running.")
else:
return "you can't vote for yourself!"
else:
return "you must vote for 1-{0:d}".format(len(acros))
return self.bot.reply(event, "the game is running.")
except KeyError:
return self.bot.reply(event, "there is no game in progress for this channel.")
def _end_current_round(self):
"""Clean up and output for ending the current round."""
def handle_start(self, connection, event, match):
"""Start the game, notify the channel, and begin timers."""
if is_channel(event.target):
try:
game = self.games[event.target]
except KeyError:
game = AcroGame(self.bot, event.target)
self.games[event.target] = game
return self.bot.reply(event, game.start_new_game())
else:
return self.bot.reply(event, "you must start the game from a channel.")
self.game.state = 4
self.bot.reply(None, "voting's over! here are the scores for the round:", explicit_target=self.game.channel)
self._print_round_scores()
self._add_round_scores_to_game_scores()
def handle_submit(self, connection, event, match):
"""Take a submission for an acro."""
try:
# HACK - need to implement a search by acro
game = list(self.games.values())[0]
nick = NickMask(event.source).nick
submission = match.group(1)
return self.bot.reply(event, game.take_acro_submission(nick, submission))
except KeyError:
return self.bot.reply(event, "there is no game in progress.")
# delay a bit
time.sleep(self.game.rounds[-1].seconds_to_pause)
self._continue_or_quit()
def handle_vote(self, connection, event, match):
"""Take a vote for an acro."""
try:
# HACK - need to implement a search by acro
game = list(self.games.values())[0]
nick = NickMask(event.source).nick
vote = match.group(1)
return self.bot.reply(event, game.take_acro_vote(nick, vote))
except KeyError:
return self.bot.reply(event, "there is no game in progress.")
def _print_round_scores(self):
"""For the acros in the round, find the votes for them."""
def handle_quit(self, connection, event, match):
"""Quit the game after the current round ends."""
if is_channel(event.target):
try:
game = self.games[event.target]
if game.state != AcroGameState.stopped:
game.should_quit = True
return self.bot.reply(event, "the game will end after the current round.")
i = 0
for s in list(self.game.rounds[-1].submissions.keys()):
votes = [x for x in list(self.game.rounds[-1].votes.values()) if x == s]
self.bot.reply(None, " {0:d} ({1:s}): {2:d}".format(i+1, s, len(votes)),
explicit_target=self.game.channel)
i += 1
def _add_round_scores_to_game_scores(self):
"""Apply the final round scores to the totall scores for the game."""
for s in list(self.game.rounds[-1].votes.values()):
votes = [x for x in list(self.game.rounds[-1].votes.values()) if x == s]
if s in list(self.game.scores.keys()):
self.game.scores[s] += len(votes)
else:
self.game.scores[s] = len(votes)
def _continue_or_quit(self):
"""Decide whether the game should continue or quit."""
if self.game.state == 4:
if self.game.quit:
self._end_game()
else:
self._start_new_round()
def _end_game(self):
"""Clean up the entire game."""
self.game.state = 0
self.game.quit = False
self.bot.reply(None, "game's over! here are the final scores:", explicit_target=self.game.channel)
self._print_game_scores()
def _print_game_scores(self):
"""Print the final calculated scores."""
for s in list(self.game.scores.keys()):
self.bot.reply(None, " {0:s}: {1:d}".format(s, self.game.scores[s]), explicit_target=self.game.channel)
return self.bot.reply(event, "the game is already stopped.")
except KeyError:
return self.bot.reply(event, "there is no game in progress for this channel.")
plugin = Acro
@@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('countdown', '0007_countdownitem_reminder_target_new'),
]
operations = [
migrations.AlterField(
model_name='countdownitem',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
+4
View File
@@ -0,0 +1,4 @@
"""Initialize the dice roller."""
from dice.roller import DiceRoller
dice_roller = DiceRoller()
+52 -9
View File
@@ -1,33 +1,38 @@
"""Roll dice when asked, intended for RPGs."""
import logging
import random
import re
from django.conf import settings
from irc.client import NickMask
from dice.roller import DiceRoller
from dice import dice_roller
from dice.lib import oracle_roll, reaction_roll
from ircbot.lib import Plugin
logger = logging.getLogger(__name__)
ORACLE_COMMAND_REGEX = r'^!oracle\s+(.*\?)$'
REACTION_COMMAND_REGEX = r'^!reaction$'
class Dice(Plugin):
"""Roll simple or complex dice strings."""
def __init__(self, bot, connection, event):
"""Set up the plugin."""
self.roller = DiceRoller()
dice_roller.ircify = True
super(Dice, self).__init__(bot, connection, event)
def start(self):
"""Set up the handlers."""
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!roll\s+(.*)$',
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!roll(\s+a)?\s+(.*)$',
self.handle_roll, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!random\s+(.*)$',
self.handle_random, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], ORACLE_COMMAND_REGEX,
self.handle_oracle_roll, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], REACTION_COMMAND_REGEX,
self.handle_reaction_roll, -20)
super(Dice, self).start()
@@ -35,6 +40,8 @@ class Dice(Plugin):
"""Tear down handlers."""
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_roll)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_random)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_oracle_roll)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_reaction_roll)
super(Dice, self).stop()
@@ -55,14 +62,50 @@ class Dice(Plugin):
reply = "{0:s}".format(choice)
return self.bot.reply(event, reply)
def handle_oracle_roll(self, connection, event, match):
"""Ask the oracle a question, get a yes/no/etc. answer."""
inquiry = match.group(1)
roll, result = oracle_roll()
result_str = result
if result in ('YES', 'YES, AND'):
result_str = f"9{result_str}"
elif result in ('YES, BUT', ):
result_str = f"3{result_str}"
elif result in ('NO, BUT', ):
result_str = f"14{result_str}"
elif result in ('NO', 'NO, AND'):
result_str = f"4{result_str}"
if result in ('YES, AND', 'NO, AND'):
result_str = f"{result_str}"
result_str = f"{result_str} 14({roll})"
return self.bot.reply(event, f"{inquiry} {result_str}")
def handle_reaction_roll(self, connection, event, match):
"""Handle the !reaction roll."""
nick = NickMask(event.source).nick
roll, label, summary = reaction_roll()
if summary in ('--', '-'):
result_str = f"4{label}"
elif summary == '~':
result_str = f"3{label}"
else:
result_str = f"9{label}"
return self.bot.reply(event, f"{nick}: the current disposition is {result_str} 14({roll})")
def handle_roll(self, connection, event, match):
"""Handle the !roll command which covers most common dice stuff."""
nick = NickMask(event.source).nick
dicestr = match.group(1)
dicestr = match.group(2)
logger.debug(event.recursing)
try:
reply_str = self.roller.do_roll(dicestr)
reply_str = dice_roller.do_roll(dicestr)
except AssertionError as aex:
reply_str = f"Could not roll dice: {aex}"
except ValueError:
@@ -74,7 +117,7 @@ class Dice(Plugin):
reply = "{0:s}: {1:s}".format(nick, reply_str)
else:
reply = "{0:s}".format(reply_str)
return self.bot.reply(event, re.sub(r'(\d+)(.*?\s+)(\(.*?\))', r'\1\214\3', reply))
return self.bot.reply(event, reply)
plugin = Dice
+285
View File
@@ -0,0 +1,285 @@
"""Dice rolling operations (outside of the lex/yacc roller).
SPDX-FileCopyrightText: © 2026 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import random
from typing import Optional, Tuple
rand = random.SystemRandom()
def cypher_roll(difficulty: Optional[int] = None, mod: int = 0, is_attack: bool = False) \
-> Tuple[int, int, Optional[bool], Optional[str], Optional[str], int, Optional[int]]:
"""Make a Cypher System roll.
Args:
difficulty: the original difficulty to beat; if provided, success or failure is indicated in the results
mod: eases(-) and hindrances(+) to apply to the check, as a totaled int
is_attack: if the roll is an attack (in which case the damage-only effects are included)
Returns:
tuple of:
- the result on the d20
- the highest difficulty beaten
- if the difficulty is known, if the target was beat
- miscellaneous effects, if any
- miscellaneous negative effects, if any
- the level of the result on the d20
- the net difficulty level, if known
"""
roll = rand.randint(1, 20)
result_lvl = roll // 3
if difficulty:
net_difficulty = difficulty + mod
else:
net_difficulty = None
effect = None
negative_effect = None
if roll == 17 and is_attack:
effect = '+1 damage'
elif roll == 18 and is_attack:
effect = '+2 damage'
elif roll == 19:
effect = 'a minor effect'
elif roll == 20:
effect = 'a MAJOR EFFECT'
elif roll == 1:
negative_effect = 'a GM intrusion'
# if we know the difficulty, the mod 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 = result_lvl - mod
beats = 0 if beats < 0 else beats
return (roll, beats, difficulty <= beats if difficulty else None, effect, negative_effect,
result_lvl, net_difficulty)
def cypher_result(difficulty: Optional[int] = None, mod: int = 0, is_attack: bool = False, comment: str = '',
stat: str = 'Unknown', ircify: bool = False) \
-> Tuple[int, int, Optional[bool], Optional[str], Optional[str], int, Optional[int], str]:
"""Turn the results of a Cypher System roll into a display string."""
# prep some IRC stuff
if ircify:
good_style_prefix = '9'
bad_style_prefix = '4'
comment_style_prefix = '14'
style_suffix = ''
else:
good_style_prefix = ''
bad_style_prefix = ''
comment_style_prefix = ''
style_suffix = ''
# tweak the comment to show task/attack in cases of empty string
if comment:
display_comment = f"{comment} {stat} roll"
else:
display_comment = f"{stat} roll"
# roll it
result, beats, success, effect, neg_effect, result_lvl, diff_level = cypher_roll(
difficulty=difficulty, mod=mod, is_attack=is_attack,
)
if success is not None:
# we know the difficulty so we should always know the success/failure
if success:
if effect:
result_str = f"{good_style_prefix}succeeded, with {effect}!{style_suffix}"
elif neg_effect:
result_str = (f"{good_style_prefix}succeeded{style_suffix}, "
f"{bad_style_prefix}with {neg_effect}!{style_suffix}")
else:
result_str = f"{good_style_prefix}succeeded!{style_suffix}"
else:
if neg_effect:
result_str = f"{bad_style_prefix}failed, with {neg_effect}!{style_suffix}"
else:
result_str = f"{bad_style_prefix}failed.{style_suffix}"
# show the adjusted difficulty
if mod > 0:
modded_diff = f"{difficulty}+{mod}"
elif mod < 0:
modded_diff = f"{difficulty}{mod}"
else:
modded_diff = str(difficulty)
detail_str = (f"{comment_style_prefix}(result {result_lvl} (d20={result}) vs. difficulty {diff_level} "
f"({modded_diff})){style_suffix}")
else:
# we don't know the difficulty, so don't know success or not
if effect:
result_str = f"beats a difficulty {beats} task, {good_style_prefix}with {effect}!{style_suffix}"
elif neg_effect:
result_str = f"beats a difficulty {beats} task, {bad_style_prefix}with {neg_effect}!{style_suffix}"
else:
result_str = f"beats a difficulty {beats} task."
# show the adjusted difficulty
if mod > 0:
modded_diff = f"-{mod}"
elif mod < 0:
modded_diff = f"+{-1 * mod}"
else:
modded_diff = ""
detail_str = f"{comment_style_prefix}(result {result_lvl}{modded_diff} (d20={result})){style_suffix}"
return (result, beats, success, effect, neg_effect, result_lvl, diff_level,
f"{display_comment} {result_str} {detail_str}")
def generic_roll(keep, dice, size):
"""Takes the parsed dice string for a single roll (eg 3/4d20) and performs
the actual roll. Returns a string representing the result.
"""
a = list(range(dice))
for i in range(dice):
a[i] = random.randint(1, size)
if keep != dice:
b = sorted(a, reverse=True)
b = b[0:keep]
else:
b = a
total = sum(b)
outstr = "[" + ",".join(str(i) for i in a) + "]"
return (total, outstr)
def generic_result(trials, mods, comment, ircify):
"""Processes rolls coming from the parser.
This generates the inputs for the generic_roll() command, and returns
the full string representing the whole current dice string (the part
up to a semicolon or end of line).
"""
rolls = mods[0][0][1] if mods[0][0][1] else 1
if trials:
assert trials <= 30, "Too many rolls (max: 30)."
assert rolls <= 50, "Too many dice (max: 50) in roll."
output = ""
repeat = 1
if trials is not None:
repeat = trials
for i in range(repeat):
mode = 1
total = 0
curr_str = ""
if i > 0:
output += ", "
for m in mods:
keep = 0
dice = 1
res = 0
# if m is a tuple, then it is a die roll
# m[0] = (keep, num dice)
# m[1] = num faces on the die
if type(m) == tuple:
if m[0] is not None:
if m[0][0] is not None:
keep = m[0][0]
dice = m[0][1]
size = m[1]
if keep > dice or keep == 0:
keep = dice
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 = generic_roll(keep, dice, size)
curr_str += "%d%s" % (res[0], res[1])
res = res[0]
elif m == "+":
mode = 1
curr_str += "+"
elif m == "-":
mode = -1
curr_str += "-"
else:
res = m
curr_str += str(m)
total += mode * res
if repeat == 1:
if comment is not None:
if ircify:
output = "%d %s 14(%s)" % (total, comment.strip(), curr_str)
else:
output = "%d %s (%s)" % (total, comment.strip(), curr_str)
else:
if ircify:
output = "%d 14(%s)" % (total, curr_str)
else:
output = "%d (%s)" % (total, curr_str)
else:
if ircify:
output += "%d 14(%s)" % (total, curr_str)
else:
output += "%d (%s)" % (total, curr_str)
if i == repeat - 1:
if comment is not None:
output += " (%s)" % (comment.strip())
return output
def oracle_roll() -> Tuple[int, str]:
"""Make a roll with some specific interpretations for simple yes/no/etc. renderings.
1: no, and (something terrible happens; 5% chance)
2-4: no (shut down; 15% chance)
5-10: no, but (not really, but maybe a compromise twist; 30% chance)
11-16: yes, but (yes, but there's a complication; 30% chance)
17-19: yes (yes, up to creativity to decide if it's simple or with a complication; 15% chance)
20: yes, and (yes, and something cool happens; 5% chance)
"""
roll = rand.randint(1, 20)
if roll == 1:
result_str = 'NO, AND'
elif 2 <= roll <= 4:
result_str = 'NO'
elif 5 <= roll <= 10:
result_str = 'NO, BUT'
elif 11 <= roll <= 16:
result_str = 'YES, BUT'
elif 17 <= roll <= 19:
result_str = 'YES'
elif roll == 20:
result_str = 'YES, AND'
return roll, result_str
def reaction_roll():
"""Make a reaction roll, which gives some oracle-like randomness to uncertain situations (for solo play or similar).
Returns:
tuple of:
- the result on the d20
- the label for the result
- a summary of the result ('++', '+', '~', '-', '--') (best to worst)
"""
roll = rand.randint(1, 20)
if 1 == roll:
label = 'VERY negative'
summary = '--'
elif 2 <= roll <= 6:
label = 'negative'
summary = '-'
elif 7 <= roll <= 14:
label = 'positive, with complications'
summary = '~'
elif 15 <= roll <= 19:
label = 'positive'
summary = '+'
else:
label = 'VERY positive'
summary = '++'
return (roll, label, summary)
+83 -87
View File
@@ -1,23 +1,36 @@
"""Dice rollers used by the views, bots, etc."""
import logging
import random
import re
import ply.lex as lex
import ply.yacc as yacc
import dice.lib
logger = logging.getLogger(__name__)
class DiceRoller(object):
tokens = ['NUMBER', 'TEXT', 'ROLLSEP']
tokens = ['NUMBER', 'TEXT', 'ROLLSEP', 'DIFFICULTYSTR', 'TASKSTR', 'ATTACKSTR',
'MIGHT_STR', 'SPEED_STR', 'INTELLECT_STR']
literals = ['#', '/', '+', '-', 'd']
t_TEXT = r'\s+[^;]+'
t_ROLLSEP = r';\s*'
t_DIFFICULTYSTR = r'difficulty\s*'
t_TASKSTR = r'\s*task'
t_ATTACKSTR = r'\s*attack'
t_MIGHT_STR = r'\s*might\s*'
t_SPEED_STR = r'\s*speed\s*'
t_INTELLECT_STR = r'\s*intellect\s*'
def __init__(self, ircify=False):
"""Initialize the object for dice rolling purposes."""
self.ircify = ircify
def build(self):
lex.lex(module=self)
lex.lex(module=self, reflags=re.IGNORECASE)
yacc.yacc(module=self)
def t_NUMBER(self, t):
@@ -38,88 +51,6 @@ class DiceRoller(object):
output = ""
def roll_dice(self, keep, dice, size):
"""Takes the parsed dice string for a single roll (eg 3/4d20) and performs
the actual roll. Returns a string representing the result.
"""
a = list(range(dice))
for i in range(dice):
a[i] = random.randint(1, size)
if keep != dice:
b = sorted(a, reverse=True)
b = b[0:keep]
else:
b = a
total = sum(b)
outstr = "[" + ",".join(str(i) for i in a) + "]"
return (total, outstr)
def process_roll(self, trials, mods, comment):
"""Processes rolls coming from the parser.
This generates the inputs for the roll_dice() command, and returns
the full string representing the whole current dice string (the part
up to a semicolon or end of line).
"""
rolls = mods[0][0][1] if mods[0][0][1] else 1
if trials:
assert trials <= 10, "Too many rolls (max: 10)."
assert rolls <= 50, "Too many dice (max: 50) in roll."
output = ""
repeat = 1
if trials is not None:
repeat = trials
for i in range(repeat):
mode = 1
total = 0
curr_str = ""
if i > 0:
output += ", "
for m in mods:
keep = 0
dice = 1
res = 0
# if m is a tuple, then it is a die roll
# m[0] = (keep, num dice)
# m[1] = num faces on the die
if type(m) == tuple:
if m[0] is not None:
if m[0][0] is not None:
keep = m[0][0]
dice = m[0][1]
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."
res = self.roll_dice(keep, dice, size)
curr_str += "%d%s" % (res[0], res[1])
res = res[0]
elif m == "+":
mode = 1
curr_str += "+"
elif m == "-":
mode = -1
curr_str += "-"
else:
res = m
curr_str += str(m)
total += mode * res
if repeat == 1:
if comment is not None:
output = "%d %s (%s)" % (total, comment.strip(), curr_str)
else:
output = "%d (%s)" % (total, curr_str)
else:
output += "%d (%s)" % (total, curr_str)
if i == repeat - 1:
if comment is not None:
output += " (%s)" % (comment.strip())
return output
def p_roll_r(self, p):
# Chain rolls together.
@@ -143,7 +74,7 @@ class DiceRoller(object):
mods = p[2]
else:
mods = [p[2]]
p[0] = self.process_roll(p[1], mods, p[3])
p[0] = dice.lib.generic_result(p[1], mods, p[3], self.ircify)
output = p[0]
def p_roll_no_trials(self, p):
@@ -156,13 +87,78 @@ class DiceRoller(object):
mods = p[1]
else:
mods = [p[1]]
p[0] = self.process_roll(None, mods, p[2])
p[0] = dice.lib.generic_result(None, mods, p[2], self.ircify)
output = p[0]
def p_cypher_stat(self, p):
"""cypher_stat : MIGHT_STR
| SPEED_STR
| INTELLECT_STR"""
p[0] = p[1]
def p_cypher_mod_pos(self, p):
"""cypher_mod : '+' NUMBER"""
p[0] = p[2]
def p_cypher_mod_neg(self, p):
"""cypher_mod : '-' NUMBER"""
p[0] = -1 * p[2]
def p_cypher_mod_none(self, p):
"""cypher_mod :"""
p[0] = 0
def p_cypher_mod_multiple(self, p):
"""cypher_mod : cypher_mod '+' NUMBER
| cypher_mod '-' NUMBER"""
if p[2] == '+':
p[0] = p[1] + p[3]
else:
p[0] = p[1] - p[3]
def p_cypher_task_roll(self, p):
"""roll : DIFFICULTYSTR NUMBER cypher_mod cypher_stat TASKSTR comment
| DIFFICULTYSTR NUMBER cypher_mod cypher_stat ATTACKSTR comment"""
global output
difficulty = p[2]
mod = p[3]
stat = p[4].strip()
is_attack = True if re.match(self.t_ATTACKSTR, p[5], re.IGNORECASE) else False
if p[6]:
comment = f"{p[5].strip()} {p[6].strip()}"
else:
comment = f"{p[5].strip()}"
_, _, _, _, _, _, _, p[0] = dice.lib.cypher_result(difficulty=difficulty, mod=mod, is_attack=is_attack,
stat=stat, comment=comment, ircify=self.ircify)
output = p[0]
def p_cypher_graduated_task_roll(self, p):
"""roll : cypher_mod cypher_stat TASKSTR comment
| cypher_mod cypher_stat ATTACKSTR comment"""
global output
mod = p[1]
stat = p[2].strip()
is_attack = True if re.match(self.t_ATTACKSTR, p[3], re.IGNORECASE) else False
if p[4]:
comment = f"{p[3].strip()} {p[4].strip()}"
else:
comment = f"{p[3].strip()}"
_, _, _, _, _, _, _, p[0] = dice.lib.cypher_result(difficulty=None, mod=mod, is_attack=is_attack,
stat=stat, comment=comment, ircify=self.ircify)
output = p[0]
def p_comment(self, p):
# Parse a comment.
'''comment : TEXT
| DIFFICULTYSTR
| TASKSTR
| ATTACKSTR
| MIGHT_STR
| SPEED_STR
| INTELLECT_STR
|'''
if len(p) == 2:
p[0] = p[1]
+10
View File
@@ -0,0 +1,10 @@
{% extends 'base.html' %}
{% block title %}dice{% endblock %}
{% block content %}
<div>
<h1>dice, by dr.botzo</h1>
<p>Under construction.</p>
</div>
{% endblock %}
+2 -1
View File
@@ -1,8 +1,9 @@
"""URL patterns for the dice views."""
from django.urls import path
from dice.views import rpc_roll_dice
from dice.views import index, rpc_roll_dice
urlpatterns = [
path('', index, name='dice_index'),
path('rpc/roll/', rpc_roll_dice, name='dice_rpc_roll_dice'),
]
+9 -4
View File
@@ -2,15 +2,20 @@
import json
import logging
from django.shortcuts import render
from rest_framework.authentication import BasicAuthentication
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from dice.roller import DiceRoller
from dice import dice_roller
logger = logging.getLogger(__name__)
roller = DiceRoller()
def index(request):
"""Display a landing page until I can do more."""
return render(request, 'dice/index.html')
@api_view(['POST'])
@@ -28,7 +33,7 @@ def rpc_roll_dice(request):
return Response({'detail': "Request must be JSON with a 'dice' parameter."}, status=400)
try:
result_str = roller.do_roll(dice_str)
result_str = dice_roller.do_roll(dice_str)
return Response({'dice': dice_str, 'result': result_str})
except AssertionError as aex:
return Response({'detail': f"Could not roll dice: {aex}", 'dice': dice_str}, status=400)
@@ -0,0 +1,23 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatch', '0007_rename_type_dispatcheraction_action_type'),
]
operations = [
migrations.AlterField(
model_name='dispatcher',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='dispatcheraction',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
+1 -4
View File
@@ -1,4 +1 @@
"""Set up the version number of the bot."""
from ._version import get_versions
__version__ = get_versions()['version']
del get_versions
"""Set up the core bot."""
-520
View File
@@ -1,520 +0,0 @@
# This file helps to compute a version number in source trees obtained from
# git-archive tarball (such as those provided by githubs download-from-tag
# feature). Distribution tarballs (built by setup.py sdist) and build
# directories (produced by setup.py build) will contain a much shorter file
# that just contains the computed version number.
# This file is released into the public domain. Generated by
# versioneer-0.18 (https://github.com/warner/python-versioneer)
"""Git implementation of _version.py."""
import errno
import os
import re
import subprocess
import sys
def get_keywords():
"""Get the keywords needed to look up the version information."""
# these strings will be replaced by git during git-archive.
# setup.py/versioneer.py will grep for the variable names, so they must
# each be defined on a line of their own. _version.py will just call
# get_keywords().
git_refnames = "$Format:%d$"
git_full = "$Format:%H$"
git_date = "$Format:%ci$"
keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
return keywords
class VersioneerConfig:
"""Container for Versioneer configuration parameters."""
def get_config():
"""Create, populate and return the VersioneerConfig() object."""
# these strings are filled in when 'setup.py versioneer' creates
# _version.py
cfg = VersioneerConfig()
cfg.VCS = "git"
cfg.style = "pep440"
cfg.tag_prefix = "v"
cfg.parentdir_prefix = "None"
cfg.versionfile_source = "dr_botzo/_version.py"
cfg.verbose = False
return cfg
class NotThisMethod(Exception):
"""Exception raised if a method is not valid for the current scenario."""
LONG_VERSION_PY = {}
HANDLERS = {}
def register_vcs_handler(vcs, method): # decorator
"""Decorator to mark a method as the handler for a particular VCS."""
def decorate(f):
"""Store f in HANDLERS[vcs][method]."""
if vcs not in HANDLERS:
HANDLERS[vcs] = {}
HANDLERS[vcs][method] = f
return f
return decorate
def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
env=None):
"""Call the given command(s)."""
assert isinstance(commands, list)
p = None
for c in commands:
try:
dispcmd = str([c] + args)
# remember shell=False, so use git.cmd on windows, not just git
p = subprocess.Popen([c] + args, cwd=cwd, env=env,
stdout=subprocess.PIPE,
stderr=(subprocess.PIPE if hide_stderr
else None))
break
except EnvironmentError:
e = sys.exc_info()[1]
if e.errno == errno.ENOENT:
continue
if verbose:
print("unable to run %s" % dispcmd)
print(e)
return None, None
else:
if verbose:
print("unable to find command, tried %s" % (commands,))
return None, None
stdout = p.communicate()[0].strip()
if sys.version_info[0] >= 3:
stdout = stdout.decode()
if p.returncode != 0:
if verbose:
print("unable to run %s (error)" % dispcmd)
print("stdout was %s" % stdout)
return None, p.returncode
return stdout, p.returncode
def versions_from_parentdir(parentdir_prefix, root, verbose):
"""Try to determine the version from the parent directory name.
Source tarballs conventionally unpack into a directory that includes both
the project name and a version string. We will also support searching up
two directory levels for an appropriately named parent directory
"""
rootdirs = []
for i in range(3):
dirname = os.path.basename(root)
if dirname.startswith(parentdir_prefix):
return {"version": dirname[len(parentdir_prefix):],
"full-revisionid": None,
"dirty": False, "error": None, "date": None}
else:
rootdirs.append(root)
root = os.path.dirname(root) # up a level
if verbose:
print("Tried directories %s but none started with prefix %s" %
(str(rootdirs), parentdir_prefix))
raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
@register_vcs_handler("git", "get_keywords")
def git_get_keywords(versionfile_abs):
"""Extract version information from the given file."""
# the code embedded in _version.py can just fetch the value of these
# keywords. When used from setup.py, we don't want to import _version.py,
# so we do it with a regexp instead. This function is not used from
# _version.py.
keywords = {}
try:
f = open(versionfile_abs, "r")
for line in f.readlines():
if line.strip().startswith("git_refnames ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["refnames"] = mo.group(1)
if line.strip().startswith("git_full ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["full"] = mo.group(1)
if line.strip().startswith("git_date ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["date"] = mo.group(1)
f.close()
except EnvironmentError:
pass
return keywords
@register_vcs_handler("git", "keywords")
def git_versions_from_keywords(keywords, tag_prefix, verbose):
"""Get version information from git keywords."""
if not keywords:
raise NotThisMethod("no keywords at all, weird")
date = keywords.get("date")
if date is not None:
# git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant
# datestamp. However we prefer "%ci" (which expands to an "ISO-8601
# -like" string, which we must then edit to make compliant), because
# it's been around since git-1.5.3, and it's too difficult to
# discover which version we're using, or to work around using an
# older one.
date = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
refnames = keywords["refnames"].strip()
if refnames.startswith("$Format"):
if verbose:
print("keywords are unexpanded, not using")
raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
refs = set([r.strip() for r in refnames.strip("()").split(",")])
# starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
# just "foo-1.0". If we see a "tag: " prefix, prefer those.
TAG = "tag: "
tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
if not tags:
# Either we're using git < 1.8.3, or there really are no tags. We use
# a heuristic: assume all version tags have a digit. The old git %d
# expansion behaves like git log --decorate=short and strips out the
# refs/heads/ and refs/tags/ prefixes that would let us distinguish
# between branches and tags. By ignoring refnames without digits, we
# filter out many common branch names like "release" and
# "stabilization", as well as "HEAD" and "master".
tags = set([r for r in refs if re.search(r'\d', r)])
if verbose:
print("discarding '%s', no digits" % ",".join(refs - tags))
if verbose:
print("likely tags: %s" % ",".join(sorted(tags)))
for ref in sorted(tags):
# sorting will prefer e.g. "2.0" over "2.0rc1"
if ref.startswith(tag_prefix):
r = ref[len(tag_prefix):]
if verbose:
print("picking %s" % r)
return {"version": r,
"full-revisionid": keywords["full"].strip(),
"dirty": False, "error": None,
"date": date}
# no suitable tags, so version is "0+unknown", but full hex is still there
if verbose:
print("no suitable tags, using unknown + full revision id")
return {"version": "0+unknown",
"full-revisionid": keywords["full"].strip(),
"dirty": False, "error": "no suitable tags", "date": None}
@register_vcs_handler("git", "pieces_from_vcs")
def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
"""Get version from 'git describe' in the root of the source tree.
This only gets called if the git-archive 'subst' keywords were *not*
expanded, and _version.py hasn't already been rewritten with a short
version string, meaning we're inside a checked out source tree.
"""
GITS = ["git"]
if sys.platform == "win32":
GITS = ["git.cmd", "git.exe"]
out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
hide_stderr=True)
if rc != 0:
if verbose:
print("Directory %s not under git control" % root)
raise NotThisMethod("'git rev-parse --git-dir' returned error")
# if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
# if there isn't one, this yields HEX[-dirty] (no NUM)
describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty",
"--always", "--long",
"--match", "%s*" % tag_prefix],
cwd=root)
# --long was added in git-1.5.5
if describe_out is None:
raise NotThisMethod("'git describe' failed")
describe_out = describe_out.strip()
full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root)
if full_out is None:
raise NotThisMethod("'git rev-parse' failed")
full_out = full_out.strip()
pieces = {}
pieces["long"] = full_out
pieces["short"] = full_out[:7] # maybe improved later
pieces["error"] = None
# parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
# TAG might have hyphens.
git_describe = describe_out
# look for -dirty suffix
dirty = git_describe.endswith("-dirty")
pieces["dirty"] = dirty
if dirty:
git_describe = git_describe[:git_describe.rindex("-dirty")]
# now we have TAG-NUM-gHEX or HEX
if "-" in git_describe:
# TAG-NUM-gHEX
mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
if not mo:
# unparseable. Maybe git-describe is misbehaving?
pieces["error"] = ("unable to parse git-describe output: '%s'"
% describe_out)
return pieces
# tag
full_tag = mo.group(1)
if not full_tag.startswith(tag_prefix):
if verbose:
fmt = "tag '%s' doesn't start with prefix '%s'"
print(fmt % (full_tag, tag_prefix))
pieces["error"] = ("tag '%s' doesn't start with prefix '%s'"
% (full_tag, tag_prefix))
return pieces
pieces["closest-tag"] = full_tag[len(tag_prefix):]
# distance: number of commits since tag
pieces["distance"] = int(mo.group(2))
# commit: short hex revision ID
pieces["short"] = mo.group(3)
else:
# HEX: no tags
pieces["closest-tag"] = None
count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
cwd=root)
pieces["distance"] = int(count_out) # total number of commits
# commit date: see ISO-8601 comment in git_versions_from_keywords()
date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"],
cwd=root)[0].strip()
pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
return pieces
def plus_or_dot(pieces):
"""Return a + if we don't already have one, else return a ."""
if "+" in pieces.get("closest-tag", ""):
return "."
return "+"
def render_pep440(pieces):
"""Build up version string, with post-release "local version identifier".
Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
Exceptions:
1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += plus_or_dot(pieces)
rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
if pieces["dirty"]:
rendered += ".dirty"
else:
# exception #1
rendered = "0+untagged.%d.g%s" % (pieces["distance"],
pieces["short"])
if pieces["dirty"]:
rendered += ".dirty"
return rendered
def render_pep440_pre(pieces):
"""TAG[.post.devDISTANCE] -- No -dirty.
Exceptions:
1: no tags. 0.post.devDISTANCE
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"]:
rendered += ".post.dev%d" % pieces["distance"]
else:
# exception #1
rendered = "0.post.dev%d" % pieces["distance"]
return rendered
def render_pep440_post(pieces):
"""TAG[.postDISTANCE[.dev0]+gHEX] .
The ".dev0" means dirty. Note that .dev0 sorts backwards
(a dirty tree will appear "older" than the corresponding clean one),
but you shouldn't be releasing software with -dirty anyways.
Exceptions:
1: no tags. 0.postDISTANCE[.dev0]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += ".post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
rendered += plus_or_dot(pieces)
rendered += "g%s" % pieces["short"]
else:
# exception #1
rendered = "0.post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
rendered += "+g%s" % pieces["short"]
return rendered
def render_pep440_old(pieces):
"""TAG[.postDISTANCE[.dev0]] .
The ".dev0" means dirty.
Eexceptions:
1: no tags. 0.postDISTANCE[.dev0]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += ".post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
else:
# exception #1
rendered = "0.post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
return rendered
def render_git_describe(pieces):
"""TAG[-DISTANCE-gHEX][-dirty].
Like 'git describe --tags --dirty --always'.
Exceptions:
1: no tags. HEX[-dirty] (note: no 'g' prefix)
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"]:
rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
else:
# exception #1
rendered = pieces["short"]
if pieces["dirty"]:
rendered += "-dirty"
return rendered
def render_git_describe_long(pieces):
"""TAG-DISTANCE-gHEX[-dirty].
Like 'git describe --tags --dirty --always -long'.
The distance/hash is unconditional.
Exceptions:
1: no tags. HEX[-dirty] (note: no 'g' prefix)
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
else:
# exception #1
rendered = pieces["short"]
if pieces["dirty"]:
rendered += "-dirty"
return rendered
def render(pieces, style):
"""Render the given version pieces into the requested style."""
if pieces["error"]:
return {"version": "unknown",
"full-revisionid": pieces.get("long"),
"dirty": None,
"error": pieces["error"],
"date": None}
if not style or style == "default":
style = "pep440" # the default
if style == "pep440":
rendered = render_pep440(pieces)
elif style == "pep440-pre":
rendered = render_pep440_pre(pieces)
elif style == "pep440-post":
rendered = render_pep440_post(pieces)
elif style == "pep440-old":
rendered = render_pep440_old(pieces)
elif style == "git-describe":
rendered = render_git_describe(pieces)
elif style == "git-describe-long":
rendered = render_git_describe_long(pieces)
else:
raise ValueError("unknown style '%s'" % style)
return {"version": rendered, "full-revisionid": pieces["long"],
"dirty": pieces["dirty"], "error": None,
"date": pieces.get("date")}
def get_versions():
"""Get version information or return default if unable to do so."""
# I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
# __file__, we can work backwards from there to the root. Some
# py2exe/bbfreeze/non-CPython implementations don't do __file__, in which
# case we can only use expanded keywords.
cfg = get_config()
verbose = cfg.verbose
try:
return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
verbose)
except NotThisMethod:
pass
try:
root = os.path.realpath(__file__)
# versionfile_source is the relative path from the top of the source
# tree (where the .git directory might live) to this file. Invert
# this to find the root from __file__.
for i in cfg.versionfile_source.split('/'):
root = os.path.dirname(root)
except NameError:
return {"version": "0+unknown", "full-revisionid": None,
"dirty": None,
"error": "unable to find root of source tree",
"date": None}
try:
pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
return render(pieces, cfg.style)
except NotThisMethod:
pass
try:
if cfg.parentdir_prefix:
return versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
except NotThisMethod:
pass
return {"version": "0+unknown", "full-revisionid": None,
"dirty": None,
"error": "unable to compute version", "date": None}
+45 -20
View File
@@ -2,20 +2,21 @@
Django settings for dr_botzo project.
For more information on this file, see
https://docs.djangoproject.com/en/1.6/topics/settings/
https://docs.djangoproject.com/en/5.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.6/ref/settings/
https://docs.djangoproject.com/en/5.0/ref/settings/
"""
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
from pathlib import Path
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
# Build paths inside the project like this: BASE_DIR / 'subdir'
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '8@frp#a5wb)40g=#rbbxy($_!ttqw(*t_^os37_a*9kbx1xuvp'
@@ -28,8 +29,8 @@ ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = (
'django.contrib.admin.apps.SimpleAdminConfig',
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
@@ -37,10 +38,10 @@ INSTALLED_APPS = (
'django.contrib.staticfiles',
'django.contrib.sites',
'django_extensions',
'adminplus',
'bootstrap3',
'rest_framework',
'countdown',
'dice',
'dispatch',
'facts',
'ircbot',
@@ -50,17 +51,18 @@ INSTALLED_APPS = (
'races',
'seen',
'storycraft',
)
]
MIDDLEWARE = (
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
)
]
ROOT_URLCONF = 'dr_botzo.urls'
@@ -85,21 +87,37 @@ WSGI_APPLICATION = 'dr_botzo.wsgi.application'
# Database
# https://docs.djangoproject.com/en/1.6/ref/settings/#databases
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# inherited default, look at changing to BigAutoField
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/1.6/topics/i18n/
# https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
@@ -107,8 +125,6 @@ TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
@@ -117,15 +133,21 @@ SITE_ID = 1
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.6/howto/static-files/
# https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = '/static/'
STATIC_URL = 'static/'
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'static'),
)
# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
@@ -145,6 +167,9 @@ BOOTSTRAP3 = {
'javascript_in_head': True,
}
# whitenoise
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
###############
# web options #
###############
-5
View File
@@ -1,14 +1,9 @@
"""General/baselite/site-wide URLs."""
from adminplus.sites import AdminSitePlus
from django.conf.urls import include
from django.contrib import admin
from django.urls import path
from django.views.generic import TemplateView
admin.site = AdminSitePlus()
admin.sites.site = admin.site
admin.autodiscover()
urlpatterns = [
path('', TemplateView.as_view(template_name='index.html'), name='index'),
+1 -2
View File
@@ -2,7 +2,6 @@
from django.db import models, migrations
import datetime
from django.utils.timezone import utc
class Migration(migrations.Migration):
@@ -15,7 +14,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='fact',
name='time',
field=models.DateTimeField(default=datetime.datetime(2015, 6, 20, 15, 22, 20, 481856, tzinfo=utc), auto_now_add=True),
field=models.DateTimeField(default=datetime.datetime(2015, 6, 20, 15, 22, 20, 481856, tzinfo=datetime.timezone.utc), auto_now_add=True),
preserve_default=False,
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('facts', '0006_factcategory_show_all_entries'),
]
operations = [
migrations.AlterField(
model_name='fact',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='factcategory',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
-25
View File
@@ -1,40 +1,15 @@
"""Manage ircbot models and admin actions in the admin interface."""
import logging
import xmlrpc.client
from django.contrib import admin
from django.shortcuts import render
from ircbot.forms import PrivmsgForm
from ircbot.models import Alias, BotUser, IrcChannel, IrcPlugin, IrcServer
log = logging.getLogger('ircbot.admin')
def send_privmsg(request):
"""Send a privmsg over XML-RPC to the IRC bot."""
if request.method == 'POST':
form = PrivmsgForm(request.POST)
if form.is_valid():
target = form.cleaned_data['target']
message = form.cleaned_data['message']
bot_url = 'http://{0:s}:{1:d}/'.format(form.cleaned_data['xmlrpc_host'],
form.cleaned_data['xmlrpc_port'])
bot = xmlrpc.client.ServerProxy(bot_url, allow_none=True)
bot.reply(None, message, False, target)
form = PrivmsgForm()
else:
form = PrivmsgForm()
return render(request, 'privmsg.html', {'form': form})
admin.site.register(Alias)
admin.site.register(BotUser)
admin.site.register(IrcChannel)
admin.site.register(IrcPlugin)
admin.site.register(IrcServer)
admin.site.register_view('ircbot/privmsg/', "Ircbot - privmsg", view=send_privmsg, urlname='ircbot_privmsg')
+16 -5
View File
@@ -20,7 +20,7 @@ from irc.dict import IRCDict
from jaraco.stream import buffer
import ircbot.lib as ircbotlib
from dr_botzo import __version__
from dr_botzo._version import __version__
from ircbot.models import Alias, IrcChannel, IrcPlugin, IrcServer
log = logging.getLogger('ircbot.bot')
@@ -158,10 +158,17 @@ class DrReactor(irc.client.Reactor):
# ignore the first word, a nick, if the speaker is the bridge
try:
channel = IrcChannel.objects.get(name=sent_location)
channel = IrcChannel.objects.get(server=connection.server_config, name=sent_location)
if sender_nick == channel.discord_bridge:
short_what = ' '.join(what.split(' ')[1:])
real_source = re.sub(r'^<(\S+)> .*', r'\1!user@discord-bridge', what)
match = re.match(addressed_pattern, short_what, re.IGNORECASE)
event.arguments[0] = short_what
event.source = real_source
sender_nick = irc.client.NickMask(event.source).nick
event.sender_nick = sender_nick
event.in_privmsg = sender_nick == sent_location
else:
match = re.match(addressed_pattern, what, re.IGNORECASE)
except IrcChannel.DoesNotExist:
@@ -174,10 +181,10 @@ class DrReactor(irc.client.Reactor):
log.debug("all_nicks: %s, addressed: %s", all_nicks, event.addressed)
# only do aliasing for pubmsg/privmsg
log.debug("checking for alias for %s", what)
log.debug("checking for alias for %s", event.arguments[0])
for alias in Alias.objects.all():
repl = alias.replace(what)
repl = alias.replace(event.arguments[0])
if repl:
# we found an alias for our given string, doing a replace
event.arguments[0] = repl
@@ -530,9 +537,13 @@ class IRCBot(irc.client.SimpleIRCClient):
# run automsg commands
if self.server_config.post_connect:
for cmd in self.server_config.post_connect.split('\n'):
cmd_split = cmd.split(' ')
# TODO NOTE: if the bot is sending something that changes the vhost
# (like 'hostserv on') we don't pick it up
self.connection.privmsg(cmd.split(' ')[0], ' '.join(cmd.split(' ')[1:]))
if cmd_split[0] == '/msg':
self.connection.privmsg(cmd.split(' ')[1], ' '.join(cmd.split(' ')[2:]))
elif cmd_split[0] == '/mode':
self.connection.mode(*cmd_split[1:])
# sleep before doing autojoins
time.sleep(self.server_config.delay_before_joins)
@@ -0,0 +1,38 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ircbot', '0019_ircchannel_discord_bridge'),
]
operations = [
migrations.AlterField(
model_name='alias',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='botuser',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='ircchannel',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='ircplugin',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='ircserver',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
-15
View File
@@ -1,15 +0,0 @@
{% extends 'adminplus/index.html' %}
{% block title %}Ircbot - Privmsg{% endblock %}
{% block content %}
<div id="content-main">
<form id="ircbot_privmsg_form" action="{% url 'admin:ircbot_privmsg' %}" method="post">
{% csrf_token %}
<table>
{{ form }}
</table>
<input class="submit-button" type="submit" value="Privmsg"/>
</form>
</div>
{% endblock %}
@@ -0,0 +1,23 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('karma', '0002_auto_20150519_2156'),
]
operations = [
migrations.AlterField(
model_name='karmakey',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='karmalogentry',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
+6 -6
View File
@@ -1,14 +1,13 @@
"""Karma logging models."""
from datetime import timedelta
import logging
from datetime import timedelta
import pytz
from irc.client import NickMask
from django.conf import settings
from django.db import models
from django.utils import timezone
from irc.client import NickMask
log = logging.getLogger('karma.models')
@@ -44,12 +43,13 @@ class KarmaKey(models.Model):
objects = KarmaKeyManager()
def __str__(self):
"""String representation."""
"""Display the karma key and score."""
return "{0:s} ({1:d})".format(self.key, self.score())
def score(self):
"""Determine the score for this karma entry."""
return KarmaLogEntry.objects.filter(key=self).aggregate(models.Sum('delta'))['delta__sum']
score = KarmaLogEntry.objects.filter(key=self).aggregate(models.Sum('delta'))['delta__sum']
return score if score else 0
def rank(self):
"""Determine the rank of this karma entry relative to the others."""
+1 -100
View File
@@ -1,15 +1,9 @@
"""Manage Markov models and administrative commands."""
import logging
from django.contrib import admin
from django.contrib.auth.decorators import permission_required
from django.shortcuts import render
from markov.forms import LogUploadForm, TeachLineForm
import markov.lib as markovlib
from markov.models import MarkovContext, MarkovTarget, MarkovState
from markov.models import MarkovContext, MarkovState, MarkovTarget
log = logging.getLogger('markov.admin')
@@ -17,96 +11,3 @@ log = logging.getLogger('markov.admin')
admin.site.register(MarkovContext)
admin.site.register(MarkovTarget)
admin.site.register(MarkovState)
@permission_required('import_text_file', raise_exception=True)
def import_file(request):
"""Accept a file upload and turn it into markov stuff.
Current file formats supported:
* weechat
"""
if request.method == 'POST':
form = LogUploadForm(request.POST, request.FILES)
if form.is_valid():
if form.cleaned_data['text_file_format'] == LogUploadForm.FILE_FORMAT_WEECHAT:
text_file = request.FILES['text_file']
context = form.cleaned_data['context']
ignores = form.cleaned_data['ignore_nicks'].split(',')
strips = form.cleaned_data['strip_prefixes'].split(' ')
whos = []
for line in text_file:
log.debug(line)
(timestamp, who, what) = line.decode('utf-8').split('\t', 2)
if who in ('-->', '<--', '--', ' *'):
continue
if who in ignores:
continue
whos.append(who)
# this is a line we probably care about now
what = [x for x in what.rstrip().split(' ') if x not in strips]
markovlib.learn_line(' '.join(what), context)
log.debug("learned")
log.debug(set(whos))
form = LogUploadForm()
elif form.cleaned_data['text_file_format'] == LogUploadForm.FILE_FORMAT_RAW_TEXT:
text_file = request.FILES['text_file']
context = form.cleaned_data['context']
k1 = MarkovState._start1
k2 = MarkovState._start2
for line in text_file:
for word in [x for x in line.decode('utf-8') .rstrip().split(' ')]:
log.info(word)
if word:
state, created = MarkovState.objects.get_or_create(context=context, k1=k1,
k2=k2, v=word)
state.count += 1
state.save()
if word[-1] in ['.', '?', '!']:
state, created = MarkovState.objects.get_or_create(context=context, k1=k2,
k2=word, v=MarkovState._stop)
state.count += 1
state.save()
k1 = MarkovState._start1
k2 = MarkovState._start2
else:
k1 = k2
k2 = word
else:
form = LogUploadForm()
return render(request, 'markov/import_file.html', {'form': form})
@permission_required('teach_line', raise_exception=True)
def teach_line(request):
"""Teach one line directly."""
if request.method == 'POST':
form = TeachLineForm(request.POST)
if form.is_valid():
line = form.cleaned_data['line']
context = form.cleaned_data['context']
strips = form.cleaned_data['strip_prefixes'].split(' ')
what = [x for x in line.rstrip().split(' ') if x not in strips]
markovlib.learn_line(' '.join(what), context)
form = TeachLineForm()
else:
form = TeachLineForm()
return render(request, 'markov/teach_line.html', {'form': form})
admin.site.register_view('markov/importfile/', "Markov - Import log file", view=import_file,
urlname='markov_import_file')
admin.site.register_view('markov/teach/', "Markov - Teach line", view=teach_line, urlname='markov_teach_line')
@@ -0,0 +1,28 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('markov', '0008_alter_markovtarget_name'),
]
operations = [
migrations.AlterField(
model_name='markovcontext',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='markovstate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='markovtarget',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.9 on 2024-11-14 15:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('markov', '0009_alter_markovcontext_id_alter_markovstate_id_and_more'),
]
operations = [
migrations.AddField(
model_name='markovcontext',
name='web_enabled',
field=models.BooleanField(default=True),
),
]
+1
View File
@@ -12,6 +12,7 @@ class MarkovContext(models.Model):
"""Define contexts for Markov chains."""
name = models.CharField(max_length=200, unique=True)
web_enabled = models.BooleanField(default=True)
def __str__(self):
"""Provide string representation."""
-15
View File
@@ -1,15 +0,0 @@
{% extends 'adminplus/index.html' %}
{% block title %}Markov - Import log file{% endblock %}
{% block content %}
<div id="content-main">
<form id="markov_import_file_form" enctype="multipart/form-data" action="{% url 'admin:markov_import_file' %}" method="post">
{% csrf_token %}
<table>
{{ form }}
</table>
<input class="submit-button" type="submit" value="Import"/>
</form>
</div>
{% endblock %}
-15
View File
@@ -1,15 +0,0 @@
{% extends 'adminplus/index.html' %}
{% block title %}Markov - Teach line{% endblock %}
{% block content %}
<div id="content-main">
<form id="markov_teach_line_form" action="{% url 'admin:markov_teach_line' %}" method="post">
{% csrf_token %}
<table>
{{ form }}
</table>
<input class="submit-button" type="submit" value="Teach"/>
</form>
</div>
{% endblock %}
+3
View File
@@ -3,6 +3,7 @@ import json
import logging
import time
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render
from rest_framework.authentication import BasicAuthentication
@@ -27,6 +28,8 @@ def context_index(request, context_id):
start_t = time.time()
context = get_object_or_404(MarkovContext, pk=context_id)
if not context.web_enabled:
raise PermissionDenied()
chain = " ".join(markovlib.generate_line(context))
end_t = time.time()
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pi', '0004_simulation_x_y_logging'),
]
operations = [
migrations.AlterField(
model_name='pilog',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
+80
View File
@@ -0,0 +1,80 @@
[build-system]
requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"
[project]
name = "dr.botzo"
description = "A modularized IRC bot with a Django backend."
readme = "README.md"
license = {text = "GPL-3.0"}
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<20.5.0", "numexpr",
"parsedatetime", "ply", "python-dateutil", "python-mpd2", "pytz", "requests", "whitenoise", "zalgo-text"]
dynamic = ["version"]
classifiers = [
"Framework :: Django",
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
"Topic :: Communications :: Chat :: Internet Relay Chat",
]
[project.urls]
"Homepage" = "https://git.incorporeal.org/bss/dr.botzo"
"Changelog" = "https://git.incorporeal.org/bss/dr.botzo/releases"
"Bug Tracker" = "https://git.incorporeal.org/bss/dr.botzo/issues"
[project.optional-dependencies]
dev = ["bandit", "dlint", "flake8", "flake8-blind-except", "flake8-builtins", "flake8-docstrings",
"flake8-executable", "flake8-fixme", "flake8-isort", "flake8-logging-format", "flake8-mutable",
"flake8-pyproject", "pip-tools", "pytest", "pytest-cov", "pytest-django", "reuse", "safety", "tox"]
[tool.flake8]
enable-extensions = "G,M"
exclude = [".tox/", "dr_botzo/_version.py", "**/migrations/"]
extend-ignore = "T101"
max-complexity = 10
max-line-length = 120
[tool.isort]
line_length = 120
# TODO: mypy
[tool.mypy]
ignore_missing_imports = true
[tool.pytest.ini_options]
python_files = ["*_tests.py", "tests.py", "test_*.py"]
DJANGO_SETTINGS_MODULE = "dr_botzo.settings"
django_find_project = false
# I think this can go away if I switch to a src/ based repo
[tool.setuptools]
packages = [
"acro",
"countdown",
"dice",
"dispatch",
"dr_botzo",
"facts",
"history",
"ircbot",
"karma",
"markov",
"mpdbot",
"pi",
"races",
"seen",
"static",
"storycraft",
"text_manip",
"transform",
"weather",
]
[tool.setuptools_scm]
write_to = "dr_botzo/_version.py"
@@ -0,0 +1,23 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('races', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='racer',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='raceupdate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
-26
View File
@@ -1,26 +0,0 @@
-r requirements.in
# testing runner, test reporting, packages used during testing (e.g. requests-mock), etc.
pytest
pytest-cov
pytest-django
# linting and other static code analysis
bandit
dlint
flake8 # flake8 and plugins, for local dev linting in vim
flake8-blind-except
flake8-builtins
flake8-docstrings
flake8-executable
flake8-fixme
flake8-isort
flake8-logging-format
flake8-mutable
safety
# maintenance utilities and tox
pip-tools # pip-compile
tox<4 # CI stuff
tox-wheel # build wheels in tox
versioneer # automatic version numbering
+201 -140
View File
@@ -1,231 +1,292 @@
#
# This file is autogenerated by pip-compile with Python 3.10
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in
# pip-compile --extra=dev --output-file=requirements/requirements-dev.txt
#
asgiref==3.6.0
annotated-types==0.7.0
# via pydantic
asgiref==3.8.1
# via django
attrs==22.2.0
# via pytest
attrs==24.2.0
# via reuse
authlib==1.3.2
# via safety
autocommand==2.2.2
# via jaraco-text
bandit==1.7.4
# via -r requirements/requirements-dev.in
build==0.10.0
backports-tarfile==1.2.0
# via jaraco-context
bandit==1.7.10
# via dr.botzo (pyproject.toml)
binaryornot==0.4.4
# via reuse
boolean-py==4.0
# via
# license-expression
# reuse
build==1.2.2.post1
# via pip-tools
certifi==2022.12.7
cachetools==5.5.0
# via tox
certifi==2024.8.30
# via requests
charset-normalizer==3.0.1
cffi==1.17.1
# via cryptography
chardet==5.2.0
# via
# binaryornot
# python-debian
# tox
charset-normalizer==3.4.0
# via requests
click==8.1.3
click==8.1.7
# via
# pip-tools
# safety
coverage[toml]==7.2.1
# typer
colorama==0.4.6
# via tox
coverage[toml]==7.6.4
# via pytest-cov
distlib==0.3.6
cryptography==43.0.3
# via authlib
distlib==0.3.9
# via virtualenv
django==3.2.18
django==5.0.9
# via
# -r requirements/requirements.in
# django-bootstrap3
# django-extensions
# djangorestframework
django-adminplus==0.5
# via -r requirements/requirements.in
django-bootstrap3==22.2
# via -r requirements/requirements.in
django-extensions==3.2.1
# via -r requirements/requirements.in
djangorestframework==3.14.0
# via -r requirements/requirements.in
dlint==0.14.0
# via -r requirements/requirements-dev.in
dparse==0.6.2
# via safety
exceptiongroup==1.1.0
# via pytest
filelock==3.9.0
# dr.botzo (pyproject.toml)
django-bootstrap3==24.3
# via dr.botzo (pyproject.toml)
django-extensions==3.2.3
# via dr.botzo (pyproject.toml)
djangorestframework==3.15.2
# via dr.botzo (pyproject.toml)
dlint==0.15.0
# via dr.botzo (pyproject.toml)
dparse==0.6.4b0
# via
# safety
# safety-schemas
filelock==3.12.4
# via
# safety
# tox
# virtualenv
flake8==6.0.0
flake8==7.1.1
# via
# -r requirements/requirements-dev.in
# dlint
# dr.botzo (pyproject.toml)
# flake8-builtins
# flake8-docstrings
# flake8-executable
# flake8-isort
# flake8-mutable
# flake8-pyproject
flake8-blind-except==0.2.1
# via -r requirements/requirements-dev.in
flake8-builtins==2.1.0
# via -r requirements/requirements-dev.in
# via dr.botzo (pyproject.toml)
flake8-builtins==2.5.0
# via dr.botzo (pyproject.toml)
flake8-docstrings==1.7.0
# via -r requirements/requirements-dev.in
# via dr.botzo (pyproject.toml)
flake8-executable==2.1.3
# via -r requirements/requirements-dev.in
# via dr.botzo (pyproject.toml)
flake8-fixme==1.1.1
# via -r requirements/requirements-dev.in
flake8-isort==6.0.0
# via -r requirements/requirements-dev.in
flake8-logging-format==0.9.0
# via -r requirements/requirements-dev.in
# via dr.botzo (pyproject.toml)
flake8-isort==6.1.1
# via dr.botzo (pyproject.toml)
flake8-logging-format==2024.24.12
# via dr.botzo (pyproject.toml)
flake8-mutable==1.2.0
# via -r requirements/requirements-dev.in
gitdb==4.0.10
# via gitpython
gitpython==3.1.31
# via bandit
idna==3.4
# via dr.botzo (pyproject.toml)
flake8-pyproject==1.2.3
# via dr.botzo (pyproject.toml)
idna==3.10
# via requests
inflect==6.0.2
# via jaraco-text
iniconfig==2.0.0
# via pytest
irc==20.1.0
# via -r requirements/requirements.in
isort==5.12.0
irc==20.4.3
# via dr.botzo (pyproject.toml)
isort==5.13.2
# via flake8-isort
jaraco-classes==3.2.3
# via jaraco-collections
jaraco-collections==3.8.0
jaraco-collections==5.1.0
# via irc
jaraco-context==4.3.0
jaraco-context==6.0.1
# via jaraco-text
jaraco-functools==3.6.0
jaraco-functools==4.1.0
# via
# irc
# jaraco-text
# tempora
jaraco-logging==3.1.2
jaraco-logging==3.3.0
# via irc
jaraco-stream==3.0.3
jaraco-stream==3.0.4
# via irc
jaraco-text==3.11.1
jaraco-text==4.0.0
# via
# irc
# jaraco-collections
jinja2==3.1.4
# via
# reuse
# safety
license-expression==30.4.0
# via reuse
markdown-it-py==3.0.0
# via rich
markupsafe==3.0.2
# via jinja2
marshmallow==3.23.0
# via safety
mccabe==0.7.0
# via flake8
more-itertools==9.1.0
mdurl==0.1.2
# via markdown-it-py
more-itertools==10.5.0
# via
# irc
# jaraco-classes
# jaraco-functools
# jaraco-stream
# jaraco-text
packaging==21.3
numexpr==2.10.1
# via dr.botzo (pyproject.toml)
numpy==2.1.3
# via numexpr
packaging==24.1
# via
# build
# dparse
# marshmallow
# pyproject-api
# pytest
# safety
# safety-schemas
# tox
parsedatetime==2.6
# via -r requirements/requirements.in
pbr==5.11.1
# via dr.botzo (pyproject.toml)
pbr==6.1.0
# via stevedore
pip-tools==6.12.3
# via -r requirements/requirements-dev.in
platformdirs==3.0.0
# via virtualenv
pluggy==1.0.0
pip-tools==7.4.1
# via dr.botzo (pyproject.toml)
platformdirs==4.3.6
# via
# tox
# virtualenv
pluggy==1.5.0
# via
# pytest
# tox
ply==3.11
# via -r requirements/requirements.in
py==1.11.0
# via tox
pycodestyle==2.10.0
# via dr.botzo (pyproject.toml)
psutil==6.0.0
# via safety
pycodestyle==2.12.1
# via flake8
pydantic==1.10.5
# via inflect
pycparser==2.22
# via cffi
pydantic==2.5.3
# via
# safety
# safety-schemas
pydantic-core==2.14.6
# via pydantic
pydocstyle==6.3.0
# via flake8-docstrings
pyflakes==3.0.1
pyflakes==3.2.0
# via flake8
pyparsing==3.0.9
# via packaging
pyproject-hooks==1.0.0
# via build
pytest==7.2.1
pygments==2.18.0
# via rich
pyproject-api==1.8.0
# via tox
pyproject-hooks==1.2.0
# via
# -r requirements/requirements-dev.in
# build
# pip-tools
pytest==8.3.3
# via
# dr.botzo (pyproject.toml)
# pytest-cov
# pytest-django
pytest-cov==4.0.0
# via -r requirements/requirements-dev.in
pytest-django==4.5.2
# via -r requirements/requirements-dev.in
python-dateutil==2.8.2
# via -r requirements/requirements.in
python-mpd2==3.0.5
# via -r requirements/requirements.in
pytz==2022.7.1
pytest-cov==6.0.0
# via dr.botzo (pyproject.toml)
pytest-django==4.9.0
# via dr.botzo (pyproject.toml)
python-dateutil==2.9.0.post0
# via
# -r requirements/requirements.in
# django
# djangorestframework
# irc
# dr.botzo (pyproject.toml)
# tempora
pyyaml==6.0
# via bandit
requests==2.28.2
# via safety
ruamel-yaml==0.17.21
# via safety
ruamel-yaml-clib==0.2.7
# via ruamel-yaml
safety==2.3.5
# via -r requirements/requirements-dev.in
six==1.16.0
python-debian==0.1.49
# via reuse
python-mpd2==3.1.1
# via dr.botzo (pyproject.toml)
pytz==2024.2
# via
# python-dateutil
# tox
smmap==5.0.0
# via gitdb
# dr.botzo (pyproject.toml)
# irc
pyyaml==6.0.2
# via bandit
requests==2.32.3
# via
# dr.botzo (pyproject.toml)
# safety
reuse==4.0.3
# via dr.botzo (pyproject.toml)
rich==13.9.3
# via
# bandit
# safety
# typer
ruamel-yaml==0.18.6
# via
# safety
# safety-schemas
ruamel-yaml-clib==0.2.12
# via ruamel-yaml
safety==3.2.10
# via dr.botzo (pyproject.toml)
safety-schemas==0.0.8
# via safety
shellingham==1.5.4
# via typer
six==1.16.0
# via python-dateutil
snowballstemmer==2.2.0
# via pydocstyle
sqlparse==0.4.3
sqlparse==0.5.1
# via django
stevedore==5.0.0
stevedore==5.3.0
# via bandit
tempora==5.2.1
tempora==5.7.0
# via
# irc
# jaraco-logging
toml==0.10.2
# via dparse
tomli==2.0.1
tomlkit==0.13.2
# via reuse
tox==4.11.4
# via dr.botzo (pyproject.toml)
typer==0.12.5
# via safety
typing-extensions==4.12.2
# via
# build
# coverage
# pyproject-hooks
# pytest
# tox
tox==3.28.0
# pydantic
# pydantic-core
# safety
# safety-schemas
# typer
urllib3==2.2.3
# via
# -r requirements/requirements-dev.in
# tox-wheel
tox-wheel==1.0.0
# via -r requirements/requirements-dev.in
typing-extensions==4.5.0
# via pydantic
urllib3==1.26.14
# via requests
versioneer==0.28
# via -r requirements/requirements-dev.in
virtualenv==20.20.0
# requests
# safety
virtualenv==20.27.1
# via tox
wheel==0.38.4
# via
# pip-tools
# tox-wheel
wheel==0.44.0
# via pip-tools
whitenoise==6.11.0
# via dr.botzo (pyproject.toml)
zalgo-text==0.6
# via -r requirements/requirements.in
# via dr.botzo (pyproject.toml)
# The following packages are considered to be unsafe in a requirements file:
# pip
-12
View File
@@ -1,12 +0,0 @@
Django<4.0 # core
django-adminplus # admin.site.register_view
django-bootstrap3 # bootstrap layout
django-extensions # more commands
djangorestframework # WS API
irc # core
parsedatetime # relative date stuff in countdown
ply # dice lex/yacc compiler
python-dateutil # countdown relative math
python-mpd2 # client for mpd
pytz # timezone awareness
zalgo-text # zalgoify text
+52 -45
View File
@@ -1,82 +1,89 @@
#
# This file is autogenerated by pip-compile with Python 3.10
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in
# pip-compile --output-file=requirements/requirements.txt
#
asgiref==3.6.0
asgiref==3.8.1
# via django
autocommand==2.2.2
# via jaraco-text
django==3.2.18
backports-tarfile==1.2.0
# via jaraco-context
certifi==2024.8.30
# via requests
charset-normalizer==3.4.0
# via requests
django==5.0.9
# via
# -r requirements/requirements.in
# django-bootstrap3
# django-extensions
# djangorestframework
django-adminplus==0.5
# via -r requirements/requirements.in
django-bootstrap3==22.2
# via -r requirements/requirements.in
django-extensions==3.2.1
# via -r requirements/requirements.in
djangorestframework==3.14.0
# via -r requirements/requirements.in
inflect==6.0.2
# via jaraco-text
irc==20.1.0
# via -r requirements/requirements.in
jaraco-classes==3.2.3
# via jaraco-collections
jaraco-collections==3.8.0
# dr.botzo (pyproject.toml)
django-bootstrap3==24.3
# via dr.botzo (pyproject.toml)
django-extensions==3.2.3
# via dr.botzo (pyproject.toml)
djangorestframework==3.15.2
# via dr.botzo (pyproject.toml)
idna==3.10
# via requests
irc==20.4.3
# via dr.botzo (pyproject.toml)
jaraco-collections==5.1.0
# via irc
jaraco-context==4.3.0
jaraco-context==6.0.1
# via jaraco-text
jaraco-functools==3.6.0
jaraco-functools==4.1.0
# via
# irc
# jaraco-text
# tempora
jaraco-logging==3.1.2
jaraco-logging==3.3.0
# via irc
jaraco-stream==3.0.3
jaraco-stream==3.0.4
# via irc
jaraco-text==3.11.1
jaraco-text==4.0.0
# via
# irc
# jaraco-collections
more-itertools==9.1.0
more-itertools==10.5.0
# via
# irc
# jaraco-classes
# 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 -r requirements/requirements.in
# via dr.botzo (pyproject.toml)
ply==3.11
# via -r requirements/requirements.in
pydantic==1.10.5
# via inflect
python-dateutil==2.8.2
# via -r requirements/requirements.in
python-mpd2==3.0.5
# via -r requirements/requirements.in
pytz==2022.7.1
# via dr.botzo (pyproject.toml)
python-dateutil==2.9.0.post0
# via
# -r requirements/requirements.in
# django
# djangorestframework
# irc
# dr.botzo (pyproject.toml)
# tempora
python-mpd2==3.1.1
# via dr.botzo (pyproject.toml)
pytz==2024.2
# via
# dr.botzo (pyproject.toml)
# irc
requests==2.32.3
# via dr.botzo (pyproject.toml)
six==1.16.0
# via python-dateutil
sqlparse==0.4.3
sqlparse==0.5.1
# via django
tempora==5.2.1
tempora==5.7.0
# via
# irc
# jaraco-logging
typing-extensions==4.5.0
# via pydantic
urllib3==2.2.3
# via requests
whitenoise==6.11.0
# via dr.botzo (pyproject.toml)
zalgo-text==0.6
# via -r requirements/requirements.in
# via dr.botzo (pyproject.toml)
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('seen', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='seennick',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
-6
View File
@@ -1,6 +0,0 @@
[versioneer]
VCS = git
style = pep440
versionfile_source = dr_botzo/_version.py
versionfile_build = dr_botzo/_version.py
tag_prefix = v
-28
View File
@@ -1,28 +0,0 @@
"""Setuptools configuration."""
import os
from setuptools import find_packages, setup
import versioneer
HERE = os.path.dirname(os.path.abspath(__file__))
def extract_requires():
with open(os.path.join(HERE, 'requirements/requirements.in'), 'r') as reqs:
return [line.split(' ')[0] for line in reqs if not line[0] == '-']
setup(
name="dr.botzo",
description="A Django-backed IRC bot that also provides a WS framework for other bots to call.",
url="https://git.incorporeal.org/bss/dr.botzo",
license='GPLv3',
author="Brian S. Stephan",
author_email="bss@incorporeal.org",
version=versioneer.get_version(),
cmdclass=versioneer.get_cmdclass(),
packages=find_packages(),
include_package_data=True,
zip_safe=False,
install_requires=extract_requires(),
)
@@ -2,7 +2,6 @@
from django.db import models, migrations
import datetime
from django.utils.timezone import utc
class Migration(migrations.Migration):
@@ -15,7 +14,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='storycraftgame',
name='create_time',
field=models.DateTimeField(default=datetime.datetime(2015, 6, 20, 1, 51, 18, 778824, tzinfo=utc), auto_now_add=True),
field=models.DateTimeField(default=datetime.datetime(2015, 6, 20, 1, 51, 18, 778824, tzinfo=datetime.timezone.utc), auto_now_add=True),
preserve_default=False,
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('storycraft', '0005_storycraftgame_create_time'),
]
operations = [
migrations.AlterField(
model_name='storycraftgame',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='storycraftline',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='storycraftplayer',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
+46
View File
@@ -0,0 +1,46 @@
"""Tests for the acromania game module.
SPDX-FileCopyrightText: © 2026 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0
"""
from unittest import mock
from django.test import TestCase
from acro.ircplugin import AcroGame, AcroGameState, AcroRound
class AcroGameTestCase(TestCase):
"""Test game state transition stuff."""
def test_game_creation(self):
"""Test the initial state of a created game."""
game = self._create_game()
assert game.state == AcroGameState.stopped
assert game.should_quit is False
assert game.channel == '#test'
def test_game_start(self):
"""Start the game."""
game = self._create_game()
with mock.patch('acro.ircplugin.AcroGame._start_new_round') as round_mock:
resp = game.start_new_game()
assert game.state == AcroGameState.game_start
assert round_mock.called_exactly_once()
assert resp is None
def test_game_start_double_start_error(self):
"""Start the game."""
game = self._create_game()
with mock.patch('acro.ircplugin.AcroGame._start_new_round') as round_mock:
resp = game.start_new_game()
resp = game.start_new_game()
assert round_mock.called_exactly_once()
assert resp == "the game is already running."
def _create_game(self):
"""Create a game, either for a direct test or for subsequent tests.
The bot is mocked out and can be used for validation.
"""
return AcroGame(mock.MagicMock(), '#test')
+144
View File
@@ -0,0 +1,144 @@
"""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 DiceTestCase(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_oracle_roll_strings(self):
"""Simulate incoming oracle requests."""
mock_event = mock.MagicMock()
mock_event.source = 'test!test@test'
mock_event.target = '#test'
mock_event.recursing = False
# very good yes is bold green
mock_event.arguments = ['!oracle is this a test?']
match = re.search(dice.ircplugin.ORACLE_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=20):
self.plugin.handle_oracle_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'is this a test? 9YES, AND 14(20)'
)
# good yes is green
mock_event.arguments = ['!oracle is this a test?']
match = re.search(dice.ircplugin.ORACLE_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=17):
self.plugin.handle_oracle_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'is this a test? 9YES 14(17)'
)
# decent yes is dark green
mock_event.arguments = ['!oracle is this a test?']
match = re.search(dice.ircplugin.ORACLE_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=11):
self.plugin.handle_oracle_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'is this a test? 3YES, BUT 14(11)'
)
# decent no is dark gray
mock_event.arguments = ['!oracle is this a test?']
match = re.search(dice.ircplugin.ORACLE_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=10):
self.plugin.handle_oracle_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'is this a test? 14NO, BUT 14(10)'
)
# no is red
mock_event.arguments = ['!oracle is this a test?']
match = re.search(dice.ircplugin.ORACLE_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=3):
self.plugin.handle_oracle_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'is this a test? 4NO 14(3)'
)
# terrible no is bold red
mock_event.arguments = ['!oracle is this a test?']
match = re.search(dice.ircplugin.ORACLE_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=1):
self.plugin.handle_oracle_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'is this a test? 4NO, AND 14(1)'
)
def test_reaction_roll_strings(self):
"""Simulate incoming reaction requests."""
mock_event = mock.MagicMock()
mock_event.source = 'test!test@test'
mock_event.target = '#test'
mock_event.recursing = False
# good and very good are bold green
mock_event.arguments = ['!reaction']
match = re.search(dice.ircplugin.REACTION_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=18):
self.plugin.handle_reaction_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: the current disposition is 9positive 14(18)'
)
mock_event.arguments = ['!reaction']
match = re.search(dice.ircplugin.REACTION_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=20):
self.plugin.handle_reaction_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: the current disposition is 9VERY positive 14(20)'
)
# decent is green
mock_event.arguments = ['!reaction']
match = re.search(dice.ircplugin.REACTION_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=10):
self.plugin.handle_reaction_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: the current disposition is 3positive, with complications 14(10)'
)
# bad and very bad are bold red
mock_event.arguments = ['!reaction']
match = re.search(dice.ircplugin.REACTION_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=4):
self.plugin.handle_reaction_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: the current disposition is 4negative 14(4)'
)
mock_event.arguments = ['!reaction']
match = re.search(dice.ircplugin.REACTION_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=1):
self.plugin.handle_reaction_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: the current disposition is 4VERY negative 14(1)'
)
+206
View File
@@ -0,0 +1,206 @@
"""Tests for dice operations.
SPDX-FileCopyrightText: © 2026 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
from unittest import mock
from django.test import TestCase
import dice.lib
class CypherRollTestCase(TestCase):
"""Test that a variety of Cypher System dice rolls work as expected."""
def test_simple_check(self):
"""Test a 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, None, 1, 1))
def test_simple_check_failure(self):
"""Test a 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, None, 0, 1))
def test_gm_intrusion(self):
"""Test rolling a 1."""
with mock.patch('random.SystemRandom.randint', return_value=1):
result = dice.lib.cypher_roll(difficulty=1)
self.assertEqual(result, (1, 0, False, None, 'a GM intrusion', 0, 1))
def test_attack_17(self):
"""Test an attack with bonus damage."""
with mock.patch('random.SystemRandom.randint', return_value=17):
result = dice.lib.cypher_roll(difficulty=1, is_attack=True)
self.assertEqual(result, (17, 5, True, '+1 damage', None, 5, 1))
def test_attack_18(self):
"""Test an attack with more bonus damage."""
with mock.patch('random.SystemRandom.randint', return_value=18):
result = dice.lib.cypher_roll(difficulty=1, is_attack=True)
self.assertEqual(result, (18, 6, True, '+2 damage', None, 6, 1))
def test_task_17(self):
"""Test a task 17 has no bonus damage."""
with mock.patch('random.SystemRandom.randint', return_value=17):
result = dice.lib.cypher_roll(difficulty=1)
self.assertEqual(result, (17, 5, True, None, None, 5, 1))
def test_task_18(self):
"""Test a task 18 has no bonus damage."""
with mock.patch('random.SystemRandom.randint', return_value=18):
result = dice.lib.cypher_roll(difficulty=1)
self.assertEqual(result, (18, 6, True, None, None, 6, 1))
def test_19(self):
"""Test a 19 has a minor effect."""
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', None, 6, 1))
def test_20(self):
"""Test a 20 has a minor effect."""
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', None, 6, 1))
def test_modifiers_beat_rolls(self):
"""Test that 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, mod=-5)
self.assertEqual(result, (2, 5, True, None, None, 0, -4))
with mock.patch('random.SystemRandom.randint', return_value=2):
result = dice.lib.cypher_roll(difficulty=3, mod=-3)
self.assertEqual(result, (2, 3, True, None, None, 0, 0))
def test_modifiers_beat_rolls_unknown_difficulty(self):
"""Test that mods affect the result of what the roll beats even without a difficulty known."""
with mock.patch('random.SystemRandom.randint', return_value=2):
result = dice.lib.cypher_roll(mod=-5)
self.assertEqual(result, (2, 5, None, None, None, 0, None))
with mock.patch('random.SystemRandom.randint', return_value=2):
result = dice.lib.cypher_roll()
self.assertEqual(result, (2, 0, None, None, None, 0, None))
with mock.patch('random.SystemRandom.randint', return_value=1):
result = dice.lib.cypher_roll()
self.assertEqual(result, (1, 0, None, None, 'a GM intrusion', 0, None))
def test_unknown_difficulty(self):
"""Test general "don't know the difficulty" kind of checks."""
with mock.patch('random.SystemRandom.randint', return_value=10):
result = dice.lib.cypher_roll(mod=-2)
self.assertEqual(result, (10, 5, None, None, None, 3, 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(mod=2)
self.assertEqual(result, (10, 1, None, None, None, 3, None))
def test_success_despite_intrusion(self):
"""Test that successes can occur even if a 1 is rolled."""
with mock.patch('random.SystemRandom.randint', return_value=1):
result = dice.lib.cypher_roll(difficulty=2, mod=-2)
self.assertEqual(result, (1, 2, True, None, "a GM intrusion", 0, 0))
class CypherRollStringTestCase(TestCase):
"""Test that Cypher System roll results are in the expected string representation."""
def test_simple_result(self):
"""Test the basics of string output."""
with mock.patch('random.SystemRandom.randint', return_value=10):
_, _, _, _, _, _, _, result = dice.lib.cypher_result(difficulty=3, mod=0, stat="Might",
comment="Test")
self.assertEqual(result, "Test Might roll succeeded! (result 3 (d20=10) vs. difficulty 3 (3))")
def test_intrusion_result(self):
"""Test the result of an intrusion."""
with mock.patch('random.SystemRandom.randint', return_value=1):
_, _, _, _, _, _, _, result = dice.lib.cypher_result(difficulty=3, mod=0, stat="Might",
comment="Test")
self.assertEqual(result, "Test Might roll failed, with a GM intrusion! (result 0 (d20=1) vs. difficulty 3 (3))")
def test_unknown_difficulty(self):
"""Test the output when the difficulty is unknown."""
with mock.patch('random.SystemRandom.randint', return_value=10):
_, _, _, _, _, _, _, result = dice.lib.cypher_result(mod=0, stat="Might",
comment="Test")
self.assertEqual(result, "Test Might roll beats a difficulty 3 task. (result 3 (d20=10))")
def test_unknown_difficulty_intrusion(self):
"""Test the output when the difficulty is unknown."""
with mock.patch('random.SystemRandom.randint', return_value=1):
_, _, _, _, _, _, _, result = dice.lib.cypher_result(mod=0, stat="Might",
comment="Test")
self.assertEqual(result, "Test Might roll beats a difficulty 0 task, with a GM intrusion! (result 0 (d20=1))")
def test_unknown_difficulty_effect(self):
"""Test the output when the difficulty is unknown."""
with mock.patch('random.SystemRandom.randint', return_value=20):
_, _, _, _, _, _, _, result = dice.lib.cypher_result(mod=0, stat="Might",
comment="Test")
self.assertEqual(result, "Test Might roll beats a difficulty 6 task, with a MAJOR EFFECT! (result 6 (d20=20))")
def test_success_despite_intrusion(self):
"""Test that successes can occur even if a 1 is rolled."""
with mock.patch('random.SystemRandom.randint', return_value=1):
_, _, _, _, _, _, _, result = dice.lib.cypher_result(difficulty=2, mod=-2, stat="Might",
comment="Test")
self.assertEqual(result,
"Test Might roll succeeded, with a GM intrusion! (result 0 (d20=1) vs. difficulty 0 (2-2))")
class OracleRollTestCase(TestCase):
"""Test that a variety of oracle results are as expected."""
def test_oracle_results(self):
"""Roll possible oracle reactions."""
with mock.patch('random.SystemRandom.randint', return_value=1):
self.assertEqual(dice.lib.oracle_roll(), (1, 'NO, AND'))
with mock.patch('random.SystemRandom.randint', return_value=2):
self.assertEqual(dice.lib.oracle_roll(), (2, 'NO'))
with mock.patch('random.SystemRandom.randint', return_value=4):
self.assertEqual(dice.lib.oracle_roll(), (4, 'NO'))
with mock.patch('random.SystemRandom.randint', return_value=5):
self.assertEqual(dice.lib.oracle_roll(), (5, 'NO, BUT'))
with mock.patch('random.SystemRandom.randint', return_value=10):
self.assertEqual(dice.lib.oracle_roll(), (10, 'NO, BUT'))
with mock.patch('random.SystemRandom.randint', return_value=11):
self.assertEqual(dice.lib.oracle_roll(), (11, 'YES, BUT'))
with mock.patch('random.SystemRandom.randint', return_value=16):
self.assertEqual(dice.lib.oracle_roll(), (16, 'YES, BUT'))
with mock.patch('random.SystemRandom.randint', return_value=17):
self.assertEqual(dice.lib.oracle_roll(), (17, 'YES'))
with mock.patch('random.SystemRandom.randint', return_value=19):
self.assertEqual(dice.lib.oracle_roll(), (19, 'YES'))
with mock.patch('random.SystemRandom.randint', return_value=20):
self.assertEqual(dice.lib.oracle_roll(), (20, 'YES, AND'))
class ReactionRollTestCase(TestCase):
"""Test that a variety of dice rolls work as expected."""
def test_reaction_roll(self):
"""Roll possible reactions."""
with mock.patch('random.SystemRandom.randint', return_value=1):
self.assertEqual(dice.lib.reaction_roll(), (1, 'VERY negative', '--'))
with mock.patch('random.SystemRandom.randint', return_value=2):
self.assertEqual(dice.lib.reaction_roll(), (2, 'negative', '-'))
with mock.patch('random.SystemRandom.randint', return_value=6):
self.assertEqual(dice.lib.reaction_roll(), (6, 'negative', '-'))
with mock.patch('random.SystemRandom.randint', return_value=7):
self.assertEqual(dice.lib.reaction_roll(), (7, 'positive, with complications', '~'))
with mock.patch('random.SystemRandom.randint', return_value=14):
self.assertEqual(dice.lib.reaction_roll(), (14, 'positive, with complications', '~'))
with mock.patch('random.SystemRandom.randint', return_value=15):
self.assertEqual(dice.lib.reaction_roll(), (15, 'positive', '+'))
with mock.patch('random.SystemRandom.randint', return_value=19):
self.assertEqual(dice.lib.reaction_roll(), (19, 'positive', '+'))
with mock.patch('random.SystemRandom.randint', return_value=20):
self.assertEqual(dice.lib.reaction_roll(), (20, 'VERY positive', '++'))
+87
View File
@@ -0,0 +1,87 @@
"""Test the dice parsing and generator."""
from unittest import mock
from django.test import TestCase
from dice import dice_roller
class DiceRollerTestCase(TestCase):
"""Test that a variety of dice rolls can be parsed."""
def setUp(self):
"""Create the dice roller."""
self.roller = dice_roller
self.roller.ircify = False
def test_standard_rolls(self):
"""Roll a variety of normal rolls."""
with mock.patch('random.randint', return_value=5):
result = self.roller.do_roll('1d20')
self.assertEqual(result, '5 (5[5])')
with mock.patch('random.randint', side_effect=[5, 6]):
result = self.roller.do_roll('2d20')
self.assertEqual(result, '11 (11[5,6])')
with mock.patch('random.randint', side_effect=[1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3,
4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6]):
result = self.roller.do_roll('6#3/4d6')
self.assertEqual(result, '3 (3[1,1,1,1]), 6 (6[2,2,2,2]), 9 (9[3,3,3,3]), '
'12 (12[4,4,4,4]), 15 (15[5,5,5,5]), 18 (18[6,6,6,6])')
def test_standard_rolls_with_comments(self):
"""Test slightly more complex parsers."""
with mock.patch('random.randint', return_value=5):
result = self.roller.do_roll('1d20 attack')
self.assertEqual(result, '5 attack (5[5])')
with mock.patch('random.randint', return_value=5):
result = self.roller.do_roll('1d20 task')
self.assertEqual(result, '5 task (5[5])')
with mock.patch('random.randint', return_value=5):
result = self.roller.do_roll('1d20 task check')
self.assertEqual(result, '5 task check (5[5])')
with mock.patch('random.randint', return_value=5):
result = self.roller.do_roll('1d20 this is a difficulty check')
self.assertEqual(result, '5 this is a difficulty check (5[5])')
def test_cypher_roll_normal(self):
"""Roll a normal Cypher System roll."""
with mock.patch('random.SystemRandom.randint', return_value=5):
result = self.roller.do_roll('difficulty 5 might task to muscle good')
self.assertEqual(result, 'task to muscle good might roll failed. (result 1 (d20=5) vs. difficulty 5 (5))')
def test_cypher_roll_normal_with_mods(self):
"""Roll a Cypher System roll with mods."""
with mock.patch('random.SystemRandom.randint', return_value=4):
result = self.roller.do_roll('difficulty 2-2 might attack to hit good')
self.assertEqual(result, 'attack to hit good might roll succeeded! (result 1 (d20=4) vs. difficulty 0 (2-2))')
def test_cypher_roll_normal_with_positive_mods(self):
"""Roll a Cypher System roll with mods."""
with mock.patch('random.SystemRandom.randint', return_value=20):
result = self.roller.do_roll('difficulty 2+2 might attack to hit good')
self.assertEqual(result, 'attack to hit good might roll succeeded, with a MAJOR EFFECT! '
'(result 6 (d20=20) vs. difficulty 4 (2+2))')
def test_cypher_roll_no_difficulty(self):
"""Roll a Cypher System roll without difficulty, just mods."""
with mock.patch('random.SystemRandom.randint', return_value=5):
result = self.roller.do_roll('-1 might task to muscle good')
self.assertEqual(result, 'task to muscle good might roll beats a difficulty 2 task. (result 1+1 (d20=5))')
def test_cypher_roll_no_difficulty_no_comment(self):
"""Roll a Cypher System roll without difficulty, just mods."""
with mock.patch('random.SystemRandom.randint', return_value=5):
result = self.roller.do_roll('-1 might attack')
self.assertEqual(result, 'attack might roll beats a difficulty 2 task. (result 1+1 (d20=5))')
def test_cypher_roll_no_difficulty_intrusion(self):
"""Roll a Cypher System roll without difficulty, just mods."""
with mock.patch('random.SystemRandom.randint', return_value=1):
result = self.roller.do_roll('-1 might attack')
self.assertEqual(result, 'attack might roll beats a difficulty 1 task, '
'with a GM intrusion! (result 0+1 (d20=1))')
+32 -89
View File
@@ -4,21 +4,11 @@
# and then run "tox" from this directory.
[tox]
envlist = begin,py38,py39,py310,coverage,security,lint,bundle
isolated_build = true
envlist = begin,py310,py311,coverage,security,lint
[testenv]
# build a wheel and test it
wheel = true
wheel_build_env = build
# whitelist commands we need
whitelist_externals = cp
# install everything via requirements-dev.txt, so that developer environment
# is the same as the tox environment (for ease of use/no weird gotchas in
# local dev results vs. tox results) and also to avoid ticky-tacky maintenance
# of "oh this particular env has weird results unless I install foo" --- just
# shotgun blast install everything everywhere
allow_externals = pytest, coverage
deps =
-rrequirements/requirements-dev.txt
@@ -31,50 +21,6 @@ deps = setuptools
skip_install = true
commands = coverage erase
[testenv:py38]
# run pytest with coverage
commands =
pytest --cov-append --cov-branch \
--cov={envsitepackagesdir}/acro/ \
--cov={envsitepackagesdir}/countdown/ \
--cov={envsitepackagesdir}/dice/ \
--cov={envsitepackagesdir}/dispatch/ \
--cov={envsitepackagesdir}/dr_botzo/ \
--cov={envsitepackagesdir}/facts/ \
--cov={envsitepackagesdir}/gitlab_bot/ \
--cov={envsitepackagesdir}/ircbot/ \
--cov={envsitepackagesdir}/karma/ \
--cov={envsitepackagesdir}/markov/ \
--cov={envsitepackagesdir}/mpdbot/ \
--cov={envsitepackagesdir}/pi/ \
--cov={envsitepackagesdir}/races/ \
--cov={envsitepackagesdir}/seen/ \
--cov={envsitepackagesdir}/storycraft/ \
--cov={envsitepackagesdir}/transform/ \
--cov={envsitepackagesdir}/weather/
[testenv:py39]
# run pytest with coverage
commands =
pytest --cov-append --cov-branch \
--cov={envsitepackagesdir}/acro/ \
--cov={envsitepackagesdir}/countdown/ \
--cov={envsitepackagesdir}/dice/ \
--cov={envsitepackagesdir}/dispatch/ \
--cov={envsitepackagesdir}/dr_botzo/ \
--cov={envsitepackagesdir}/facts/ \
--cov={envsitepackagesdir}/gitlab_bot/ \
--cov={envsitepackagesdir}/ircbot/ \
--cov={envsitepackagesdir}/karma/ \
--cov={envsitepackagesdir}/markov/ \
--cov={envsitepackagesdir}/mpdbot/ \
--cov={envsitepackagesdir}/pi/ \
--cov={envsitepackagesdir}/races/ \
--cov={envsitepackagesdir}/seen/ \
--cov={envsitepackagesdir}/storycraft/ \
--cov={envsitepackagesdir}/transform/ \
--cov={envsitepackagesdir}/weather/
[testenv:py310]
# run pytest with coverage
commands =
@@ -85,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/ \
@@ -94,6 +40,30 @@ commands =
--cov={envsitepackagesdir}/races/ \
--cov={envsitepackagesdir}/seen/ \
--cov={envsitepackagesdir}/storycraft/ \
--cov={envsitepackagesdir}/text_manip/ \
--cov={envsitepackagesdir}/transform/ \
--cov={envsitepackagesdir}/weather/
[testenv:py311]
# run pytest with coverage
commands =
pytest --cov-append --cov-branch \
--cov={envsitepackagesdir}/acro/ \
--cov={envsitepackagesdir}/countdown/ \
--cov={envsitepackagesdir}/dice/ \
--cov={envsitepackagesdir}/dispatch/ \
--cov={envsitepackagesdir}/dr_botzo/ \
--cov={envsitepackagesdir}/facts/ \
--cov={envsitepackagesdir}/history/ \
--cov={envsitepackagesdir}/ircbot/ \
--cov={envsitepackagesdir}/karma/ \
--cov={envsitepackagesdir}/markov/ \
--cov={envsitepackagesdir}/mpdbot/ \
--cov={envsitepackagesdir}/pi/ \
--cov={envsitepackagesdir}/races/ \
--cov={envsitepackagesdir}/seen/ \
--cov={envsitepackagesdir}/storycraft/ \
--cov={envsitepackagesdir}/text_manip/ \
--cov={envsitepackagesdir}/transform/ \
--cov={envsitepackagesdir}/weather/
@@ -116,7 +86,7 @@ commands =
{envsitepackagesdir}/dispatch/ \
{envsitepackagesdir}/dr_botzo/ \
{envsitepackagesdir}/facts/ \
{envsitepackagesdir}/gitlab_bot/ \
{envsitepackagesdir}/history/ \
{envsitepackagesdir}/ircbot/ \
{envsitepackagesdir}/karma/ \
{envsitepackagesdir}/markov/ \
@@ -125,6 +95,7 @@ commands =
{envsitepackagesdir}/races/ \
{envsitepackagesdir}/seen/ \
{envsitepackagesdir}/storycraft/ \
{envsitepackagesdir}/text_manip/ \
{envsitepackagesdir}/transform/ \
{envsitepackagesdir}/weather/ \
-r
@@ -136,12 +107,6 @@ commands =
flake8
- flake8 --disable-noqa --ignore= --select=E,W,F,C,D,A,G,B,I,T,M,DUO
[testenv:bundle]
# take extra actions (build sdist, sphinx, whatever) to completely package the app
commands =
cp -r {distdir} .
python setup.py sdist
[coverage:paths]
source =
./
@@ -160,7 +125,7 @@ include =
{envsitepackagesdir}/dispatch/
{envsitepackagesdir}/dr_botzo/
{envsitepackagesdir}/facts/
{envsitepackagesdir}/gitlab_bot/
{envsitepackagesdir}/history/
{envsitepackagesdir}/ircbot/
{envsitepackagesdir}/karma/
{envsitepackagesdir}/markov/
@@ -169,31 +134,9 @@ include =
{envsitepackagesdir}/races/
{envsitepackagesdir}/seen/
{envsitepackagesdir}/storycraft/
{envsitepackagesdir}/text_manip/
{envsitepackagesdir}/transform/
{envsitepackagesdir}/weather/
omit =
**/_version.py
[flake8]
enable-extensions = G,M
exclude =
.tox/
versioneer.py
_version.py
**/migrations/
extend-ignore = T101
max-complexity = 10
max-line-length = 120
[isort]
line_length = 120
[pytest]
python_files =
*_tests.py
tests.py
test_*.py
log_level=DEBUG
DJANGO_SETTINGS_MODULE = dr_botzo.settings
django_find_project = false
-1822
View File
File diff suppressed because it is too large Load Diff