Compare commits
10 Commits
54e944b74b
...
15df477fa4
Author | SHA1 | Date | |
---|---|---|---|
15df477fa4 | |||
2730978663 | |||
497619e0ff | |||
27fcecd8aa | |||
04e09af8a9 | |||
fa56e51ce3 | |||
2ad79285b3 | |||
d0531bff53 | |||
d322429157 | |||
f941762f26 |
139
idlerpg/ircplugin.py
Normal file
139
idlerpg/ircplugin.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
"""IRC support for managing and executing the IdleRPG game.
|
||||||
|
|
||||||
|
SPDX-FileCopyrightText: © 2024 Brian S. Stephan <bss@incorporeal.org>
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import irc.client
|
||||||
|
from django.db.utils import IntegrityError
|
||||||
|
from ircbot.lib import Plugin
|
||||||
|
|
||||||
|
from idlerpg.models import Character, Game
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class IdleRPG(Plugin):
|
||||||
|
"""Run a game of IdleRPG."""
|
||||||
|
|
||||||
|
SLEEP_BETWEEN_LEVEL_CHECKS = 1
|
||||||
|
|
||||||
|
LOGIN_COMMAND_PATTERN = r'^LOGIN\s+(?P<name>\S+)\s+(?P<password>\S+)$'
|
||||||
|
LOGOUT_COMMAND_PATTERN = r'^LOGOUT$'
|
||||||
|
REGISTER_COMMAND_PATTERN = r'^REGISTER\s+(?P<name>\S+)\s+(?P<password>\S+)\s+(?P<char_class>.*)$'
|
||||||
|
REMOVEME_COMMAND_PATTERN = r'^REMOVEME$'
|
||||||
|
STATUS_COMMAND_PATTERN = r'^(STATUS|WHOAMI)$'
|
||||||
|
|
||||||
|
def __init__(self, bot, connection, event):
|
||||||
|
"""Initialize miscellaneous state stuff."""
|
||||||
|
self.seen_hostmasks = set()
|
||||||
|
super().__init__(bot, connection, event)
|
||||||
|
|
||||||
|
def handle_join(self, connection, event):
|
||||||
|
"""Track a character as online if their player joins the game channel."""
|
||||||
|
self.seen_hostmasks.add(event.source)
|
||||||
|
logger.info("Added %s to the set of seen hostmasks.", event.source)
|
||||||
|
|
||||||
|
def handle_kick_penalty(self, connection, event):
|
||||||
|
"""Penalize characters for their user getting kicked from the channel."""
|
||||||
|
self.seen_hostmasks.discard(event.source)
|
||||||
|
logger.info("Removed %s from the set of seen hostmasks.", event.source)
|
||||||
|
return self._handle_generic_penalty_and_logout(event.source, event.target, 250,
|
||||||
|
"getting kicked from the game channel",
|
||||||
|
'time_penalized_kicked')
|
||||||
|
|
||||||
|
def handle_nick_penalty(self, connection, event):
|
||||||
|
"""Penalize characters for changing their nick while in the channel."""
|
||||||
|
# TODO: figure out how to update the character and seen hostmasks
|
||||||
|
return self._handle_generic_penalty_and_logout(event.source, event.target, 30,
|
||||||
|
"changing their nick",
|
||||||
|
'time_penalized_nick_change')
|
||||||
|
|
||||||
|
def handle_part_penalty(self, connection, event):
|
||||||
|
"""Penalize characters for their user parting."""
|
||||||
|
self.seen_hostmasks.discard(event.source)
|
||||||
|
logger.info("Removed %s from the set of seen hostmasks.", event.source)
|
||||||
|
return self._handle_generic_penalty_and_logout(event.source, event.target, 200,
|
||||||
|
"parting the game channel",
|
||||||
|
'time_penalized_part')
|
||||||
|
|
||||||
|
def handle_message_penalty(self, connection, event):
|
||||||
|
"""Pay attention to messages in the game channel, and penalize talkers."""
|
||||||
|
hostmask = event.source
|
||||||
|
|
||||||
|
logger.debug("looking for %s to try to penalize them", hostmask)
|
||||||
|
try:
|
||||||
|
character = Character.objects.get(enabled=True, hostmask=hostmask)
|
||||||
|
logger.debug("found character %s", character)
|
||||||
|
message = event.arguments[0]
|
||||||
|
penalty = character.penalize(len(message), "sending a privmsg to the game channel")
|
||||||
|
character.time_penalized_privmsg += penalty
|
||||||
|
character.save()
|
||||||
|
except Character.DoesNotExist:
|
||||||
|
logger.debug("no character found for %s", hostmask)
|
||||||
|
return
|
||||||
|
|
||||||
|
def handle_quit_penalty(self, connection, event):
|
||||||
|
"""Penalize characters for their user quitting IRC."""
|
||||||
|
self.seen_hostmasks.discard(event.source)
|
||||||
|
logger.info("Removed %s from the set of seen hostmasks.", event.source)
|
||||||
|
return self._handle_generic_penalty_and_logout(event.source, event.target, 20,
|
||||||
|
"quitting IRC",
|
||||||
|
'time_penalized_quit')
|
||||||
|
|
||||||
|
def handle_login(self, connection, event, match):
|
||||||
|
"""Log in a character when requested, assuming they're online."""
|
||||||
|
hostmask = event.source
|
||||||
|
|
||||||
|
nick = irc.client.NickMask(hostmask).nick
|
||||||
|
try:
|
||||||
|
character = Character.objects.get(enabled=True, name=match.group('name'))
|
||||||
|
character.check_password(match.group('password'))
|
||||||
|
except (Character.DoesNotExist, ValueError):
|
||||||
|
return self.bot.reply(event, "The requested character does not exist, or has been disabled, "
|
||||||
|
"or your password does not match.")
|
||||||
|
|
||||||
|
if hostmask not in self.seen_hostmasks:
|
||||||
|
return self.bot.reply(event, f"Please join {character.game.channel.name} before logging in.")
|
||||||
|
|
||||||
|
if character.status != Character.CHARACTER_STATUS_OFFLINE:
|
||||||
|
return self.bot.reply(event, f"Cannot log in {character.name}, either they already are, "
|
||||||
|
"or they are in a state that cannot be modified.")
|
||||||
|
|
||||||
|
character.log_in(match.group('password'), hostmask)
|
||||||
|
character.save()
|
||||||
|
self.bot.reply(None, f"{character}, is now online from nickname {nick}. "
|
||||||
|
f"Next level at {character.next_level_str()}.",
|
||||||
|
explicit_target=character.game.channel.name)
|
||||||
|
return self.bot.reply(event, f"{character} has been successfully logged in.")
|
||||||
|
|
||||||
|
def _handle_generic_penalty_and_logout(self, hostmask: str, channel: str, penalty: int, reason: str,
|
||||||
|
penalty_log_attr: str):
|
||||||
|
"""Penalize a character and log them out, for a provided reason.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostmask: the hostmask for the character in question
|
||||||
|
channel: the game channel the event occurred in
|
||||||
|
penalty: the penalty to apply
|
||||||
|
reason: the reason for the penalty
|
||||||
|
"""
|
||||||
|
logger.debug("looking for %s to try to penalize them", hostmask)
|
||||||
|
try:
|
||||||
|
character = Character.objects.get(enabled=True, hostmask=hostmask)
|
||||||
|
logger.debug("character found for %s", hostmask)
|
||||||
|
seconds = character.penalize(penalty, reason)
|
||||||
|
log = getattr(character, penalty_log_attr)
|
||||||
|
setattr(character, penalty_log_attr, log + seconds)
|
||||||
|
try:
|
||||||
|
character.log_out()
|
||||||
|
except ValueError:
|
||||||
|
logger.debug("tried to log out %s but they already were", character)
|
||||||
|
character.save()
|
||||||
|
except Character.DoesNotExist:
|
||||||
|
logger.debug("no character found for %s", hostmask)
|
||||||
|
|
||||||
|
|
||||||
|
plugin = IdleRPG
|
18
idlerpg/migrations/0005_alter_character_status.py
Normal file
18
idlerpg/migrations/0005_alter_character_status.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.5 on 2024-05-13 04:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('idlerpg', '0004_remove_character_no_characters_with_same_name_per_game_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='character',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('DISABLED', 'Disabled'), ('LOGGEDIN', 'Logged in'), ('OFFLINE', 'Offline')], default='OFFLINE', max_length=8),
|
||||||
|
),
|
||||||
|
]
|
18
idlerpg/migrations/0006_alter_character_status.py
Normal file
18
idlerpg/migrations/0006_alter_character_status.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.5 on 2024-05-14 15:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('idlerpg', '0005_alter_character_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='character',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('DISABLED', 'disabled'), ('LOGGEDIN', 'logged in'), ('OFFLINE', 'offline')], default='OFFLINE', max_length=8),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.0.5 on 2024-05-14 16:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('idlerpg', '0006_alter_character_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name='character',
|
||||||
|
name='one_player_character_per_game',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='character',
|
||||||
|
name='enabled',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='character',
|
||||||
|
constraint=models.UniqueConstraint(condition=models.Q(('enabled', True)), fields=('hostmask',), name='one_enabled_character_at_a_time'),
|
||||||
|
),
|
||||||
|
]
|
@ -29,6 +29,10 @@ class Game(models.Model):
|
|||||||
models.UniqueConstraint("active", "channel", name="one_game_per_channel"),
|
models.UniqueConstraint("active", "channel", name="one_game_per_channel"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Provide a string representation of the game."""
|
||||||
|
return f"{self.name} in {self.channel} ({'active' if self.active else 'inactive'})"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def level_formula(current_level: int = 0, base_time: datetime = None) -> timedelta:
|
def level_formula(current_level: int = 0, base_time: datetime = None) -> timedelta:
|
||||||
"""DRY the formula for determining when a character's next level would be.
|
"""DRY the formula for determining when a character's next level would be.
|
||||||
@ -52,6 +56,16 @@ class Game(models.Model):
|
|||||||
class CharacterManager(models.Manager):
|
class CharacterManager(models.Manager):
|
||||||
"""Query manager for creating a Character and taking operations relevant to the game."""
|
"""Query manager for creating a Character and taking operations relevant to the game."""
|
||||||
|
|
||||||
|
def log_out_everyone(self):
|
||||||
|
"""Log out every logged in character.
|
||||||
|
|
||||||
|
This is probably being called on shutdown or another situation when we can't monitor state, so we'll
|
||||||
|
just recreate it whenever it is we come back online.
|
||||||
|
"""
|
||||||
|
self.filter(enabled=True,
|
||||||
|
status=Character.CHARACTER_STATUS_LOGGED_IN).update(status=Character.CHARACTER_STATUS_OFFLINE)
|
||||||
|
logger.info("ALL::ALL: logged out all currently online or logged in characters")
|
||||||
|
|
||||||
def register(self, name: str, game: Game, password: str, hostmask: str, character_class: str):
|
def register(self, name: str, game: Game, password: str, hostmask: str, character_class: str):
|
||||||
"""Create a character, with guardrails.
|
"""Create a character, with guardrails.
|
||||||
|
|
||||||
@ -72,6 +86,19 @@ class CharacterManager(models.Manager):
|
|||||||
character.game.name, character.name, character.next_level)
|
character.game.name, character.name, character.next_level)
|
||||||
return character
|
return character
|
||||||
|
|
||||||
|
def levelable(self, game) -> models.QuerySet:
|
||||||
|
"""Provide the characters that are ready to level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game: the game instance to filter characters on
|
||||||
|
Returns:
|
||||||
|
QuerySet of the characters that can be leveled up
|
||||||
|
"""
|
||||||
|
if not game.active:
|
||||||
|
raise ValueError(f"{game.name} is not an active game!")
|
||||||
|
return self.filter(enabled=True, status=Character.CHARACTER_STATUS_LOGGED_IN,
|
||||||
|
next_level__lte=timezone.now(), game=game)
|
||||||
|
|
||||||
|
|
||||||
class Character(models.Model):
|
class Character(models.Model):
|
||||||
"""A character in a game."""
|
"""A character in a game."""
|
||||||
@ -79,12 +106,10 @@ class Character(models.Model):
|
|||||||
CHARACTER_STATUS_DISABLED = 'DISABLED'
|
CHARACTER_STATUS_DISABLED = 'DISABLED'
|
||||||
CHARACTER_STATUS_LOGGED_IN = 'LOGGEDIN'
|
CHARACTER_STATUS_LOGGED_IN = 'LOGGEDIN'
|
||||||
CHARACTER_STATUS_OFFLINE = 'OFFLINE'
|
CHARACTER_STATUS_OFFLINE = 'OFFLINE'
|
||||||
CHARACTER_STATUS_ONLINE = 'ONLINE'
|
|
||||||
CHARACTER_STATUSES = {
|
CHARACTER_STATUSES = {
|
||||||
CHARACTER_STATUS_DISABLED: "Disabled",
|
CHARACTER_STATUS_DISABLED: "disabled",
|
||||||
CHARACTER_STATUS_LOGGED_IN: "Logged in",
|
CHARACTER_STATUS_LOGGED_IN: "logged in",
|
||||||
CHARACTER_STATUS_OFFLINE: "Offline",
|
CHARACTER_STATUS_OFFLINE: "offline",
|
||||||
CHARACTER_STATUS_ONLINE: "Online",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NICK_CHANGE_P = 30
|
NICK_CHANGE_P = 30
|
||||||
@ -97,6 +122,7 @@ class Character(models.Model):
|
|||||||
password = models.CharField(max_length=256)
|
password = models.CharField(max_length=256)
|
||||||
hostmask = models.CharField(max_length=256)
|
hostmask = models.CharField(max_length=256)
|
||||||
status = models.CharField(max_length=8, choices=CHARACTER_STATUSES, default='OFFLINE')
|
status = models.CharField(max_length=8, choices=CHARACTER_STATUSES, default='OFFLINE')
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
|
||||||
character_class = models.CharField(max_length=30)
|
character_class = models.CharField(max_length=30)
|
||||||
level = models.PositiveIntegerField(default=0)
|
level = models.PositiveIntegerField(default=0)
|
||||||
@ -121,12 +147,17 @@ class Character(models.Model):
|
|||||||
"""Options for the Character and its objects."""
|
"""Options for the Character and its objects."""
|
||||||
|
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint("game", "hostmask", name="one_player_character_per_game"),
|
models.UniqueConstraint(fields=['hostmask'], condition=models.Q(enabled=True),
|
||||||
|
name='one_enabled_character_at_a_time'),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Provide a string representation for display, etc. purposes."""
|
"""Provide a string representation for display, etc. purposes."""
|
||||||
return f"{self.name}, level {self.level} {self.character_class}"
|
return f"{self.name}, the level {self.level} {self.character_class}"
|
||||||
|
|
||||||
|
def next_level_str(self):
|
||||||
|
"""Return a nicely-formatted version of next_level."""
|
||||||
|
return self.next_level.strftime('%Y-%m-%d %H:%M:%S %Z')
|
||||||
|
|
||||||
def calculate_datetime_to_next_level(self):
|
def calculate_datetime_to_next_level(self):
|
||||||
"""Determine when the next level will be reached (barring no penalties or logoffs).
|
"""Determine when the next level will be reached (barring no penalties or logoffs).
|
||||||
@ -144,6 +175,19 @@ class Character(models.Model):
|
|||||||
# character is past their old time and in the process of leveling up, calculate next based on that
|
# character is past their old time and in the process of leveling up, calculate next based on that
|
||||||
return Game.level_formula(self.level, self.next_level)
|
return Game.level_formula(self.level, self.next_level)
|
||||||
|
|
||||||
|
def check_password(self, password: str):
|
||||||
|
"""Check that the provided password is correct for this character.
|
||||||
|
|
||||||
|
Used to authenicate without logging in (yet).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: the password to check
|
||||||
|
Raises:
|
||||||
|
ValueError: if the password is incorrect
|
||||||
|
"""
|
||||||
|
if not check_password(password, self.password):
|
||||||
|
raise ValueError(f"incorrect password for character '{self.name}'!")
|
||||||
|
|
||||||
def level_up(self):
|
def level_up(self):
|
||||||
"""Set this character's level and next time if they meet the necessary criteria.
|
"""Set this character's level and next time if they meet the necessary criteria.
|
||||||
|
|
||||||
@ -169,10 +213,9 @@ class Character(models.Model):
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: if the provided password was incorrect, or the character isn't logged out
|
ValueError: if the provided password was incorrect, or the character isn't logged out
|
||||||
"""
|
"""
|
||||||
if self.status != self.CHARACTER_STATUS_ONLINE:
|
if self.status != self.CHARACTER_STATUS_OFFLINE:
|
||||||
raise ValueError(f"character '{self.name}' can't be logged in, isn't online!")
|
raise ValueError(f"character '{self.name}' can't be logged in, isn't offline!")
|
||||||
if not check_password(password, self.password):
|
self.check_password(password)
|
||||||
raise ValueError(f"incorrect password for character '{self.name}'!")
|
|
||||||
|
|
||||||
logger.debug("logging %s in...", str(self))
|
logger.debug("logging %s in...", str(self))
|
||||||
# we need to apply the time lost to the next level time
|
# we need to apply the time lost to the next level time
|
||||||
|
@ -7,6 +7,8 @@ import logging
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.utils import IntegrityError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -24,7 +26,12 @@ class CharacterTest(TestCase):
|
|||||||
"""Test the basic string summary."""
|
"""Test the basic string summary."""
|
||||||
char = Character.objects.get(pk=1)
|
char = Character.objects.get(pk=1)
|
||||||
logger.debug(str(char))
|
logger.debug(str(char))
|
||||||
assert str(char) == "bss, level 0 tester"
|
assert str(char) == "bss, the level 0 tester"
|
||||||
|
|
||||||
|
def test_next_level_str(self):
|
||||||
|
"""Test the format of the readable next level string."""
|
||||||
|
char = Character.objects.get(pk=1)
|
||||||
|
assert char.next_level_str() == "2024-05-05 05:20:45 UTC"
|
||||||
|
|
||||||
def test_log_out(self):
|
def test_log_out(self):
|
||||||
"""Test basic log out functionality and end result."""
|
"""Test basic log out functionality and end result."""
|
||||||
@ -37,6 +44,20 @@ class CharacterTest(TestCase):
|
|||||||
assert char.status == Character.CHARACTER_STATUS_OFFLINE
|
assert char.status == Character.CHARACTER_STATUS_OFFLINE
|
||||||
assert char.last_login == logout_time
|
assert char.last_login == logout_time
|
||||||
|
|
||||||
|
def test_log_out_everyone(self):
|
||||||
|
"""Test logging out everyone in the database."""
|
||||||
|
game = Game.objects.get(pk=1)
|
||||||
|
# make some data
|
||||||
|
Character.objects.register('loe1', game, 'test', 'loe1', 'log_out_everyone_tester')
|
||||||
|
Character.objects.register('loe2', game, 'test', 'loe2', 'log_out_everyone_tester')
|
||||||
|
Character.objects.register('loe3', game, 'test', 'loe3', 'log_out_everyone_tester')
|
||||||
|
|
||||||
|
assert Character.objects.filter(status=Character.CHARACTER_STATUS_LOGGED_IN).count() >= 3
|
||||||
|
|
||||||
|
Character.objects.log_out_everyone()
|
||||||
|
|
||||||
|
assert Character.objects.filter(status=Character.CHARACTER_STATUS_LOGGED_IN).count() == 0
|
||||||
|
|
||||||
def test_cant_log_out_offline(self):
|
def test_cant_log_out_offline(self):
|
||||||
"""Test that we error if trying to log out a character already offline."""
|
"""Test that we error if trying to log out a character already offline."""
|
||||||
char = Character.objects.get(pk=1)
|
char = Character.objects.get(pk=1)
|
||||||
@ -56,7 +77,6 @@ class CharacterTest(TestCase):
|
|||||||
char.log_out()
|
char.log_out()
|
||||||
# logout has a penalty of its own, so this post-logout value is what will be altered
|
# logout has a penalty of its own, so this post-logout value is what will be altered
|
||||||
old_next_level = char.next_level
|
old_next_level = char.next_level
|
||||||
char.status = Character.CHARACTER_STATUS_ONLINE
|
|
||||||
with patch('django.utils.timezone.now', return_value=login_time):
|
with patch('django.utils.timezone.now', return_value=login_time):
|
||||||
char.log_in('bss', 'bss!bss@test_log_in')
|
char.log_in('bss', 'bss!bss@test_log_in')
|
||||||
|
|
||||||
@ -74,7 +94,6 @@ class CharacterTest(TestCase):
|
|||||||
"""Test that we can't log in the character if we don't have the right password."""
|
"""Test that we can't log in the character if we don't have the right password."""
|
||||||
char = Character.objects.get(pk=1)
|
char = Character.objects.get(pk=1)
|
||||||
char.log_out()
|
char.log_out()
|
||||||
char.status = Character.CHARACTER_STATUS_ONLINE
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
char.log_in('bad pass', 'bss!bss@test_bad_password')
|
char.log_in('bad pass', 'bss!bss@test_bad_password')
|
||||||
|
|
||||||
@ -154,6 +173,27 @@ class CharacterTest(TestCase):
|
|||||||
assert new_char.last_login == register_time
|
assert new_char.last_login == register_time
|
||||||
assert new_char.password[0:13] == 'pbkdf2_sha256'
|
assert new_char.password[0:13] == 'pbkdf2_sha256'
|
||||||
|
|
||||||
|
def test_cant_register_twice(self):
|
||||||
|
"""Test that we get a unique constraint error if we try to make a second enabled character."""
|
||||||
|
game = Game.objects.get(pk=1)
|
||||||
|
|
||||||
|
register_time = timezone.now()
|
||||||
|
with patch('django.utils.timezone.now', return_value=register_time):
|
||||||
|
new_char = Character.objects.register('new1', game, 'pass', 'bss!bss@test_double_register', 'unit tester')
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
with transaction.atomic():
|
||||||
|
Character.objects.register('new2', game, 'pass', 'bss!bss@test_double_register', 'unit tester')
|
||||||
|
|
||||||
|
# try again with the first character un-enabled
|
||||||
|
new_char.enabled = False
|
||||||
|
new_char.save()
|
||||||
|
|
||||||
|
register_time = timezone.now()
|
||||||
|
with patch('django.utils.timezone.now', return_value=register_time):
|
||||||
|
newer_char = Character.objects.register('new2', game, 'pass', 'bss!bss@test_double_register', 'unit tester')
|
||||||
|
|
||||||
|
assert new_char.hostmask == newer_char.hostmask
|
||||||
|
|
||||||
def test_level_up(self):
|
def test_level_up(self):
|
||||||
"""Test the level up functionality."""
|
"""Test the level up functionality."""
|
||||||
char = Character.objects.get(pk=1)
|
char = Character.objects.get(pk=1)
|
||||||
@ -178,3 +218,43 @@ class CharacterTest(TestCase):
|
|||||||
char.log_out()
|
char.log_out()
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
char.level_up()
|
char.level_up()
|
||||||
|
|
||||||
|
def test_levelable_query(self):
|
||||||
|
"""Test that the right things are returned by the levelable query used to find characters to update."""
|
||||||
|
game = Game.objects.get(pk=1)
|
||||||
|
# base data is one character ready to level
|
||||||
|
assert len(Character.objects.levelable(game)) == 1
|
||||||
|
# add one to fiddle with
|
||||||
|
with patch('django.utils.timezone.now', return_value=timezone.now() - timedelta(days=1)):
|
||||||
|
new_char = Character.objects.register('levelable-test', game, 'pass',
|
||||||
|
'bss!bss@levelable_test', 'unit tester')
|
||||||
|
assert len(Character.objects.levelable(game)) == 2
|
||||||
|
# log the new one out
|
||||||
|
new_char.log_out()
|
||||||
|
new_char.save()
|
||||||
|
assert len(Character.objects.levelable(game)) == 1
|
||||||
|
# log the new one back in but penalize it heavily
|
||||||
|
new_char.log_in('pass', 'bss!bss@levelable_test')
|
||||||
|
new_char.save()
|
||||||
|
assert len(Character.objects.levelable(game)) == 2
|
||||||
|
new_char.penalize(60*60*24*2, 'test')
|
||||||
|
new_char.save()
|
||||||
|
assert len(Character.objects.levelable(game)) == 1
|
||||||
|
# actually level them
|
||||||
|
new_char.next_level = timezone.now() - timedelta(seconds=30)
|
||||||
|
new_char.save()
|
||||||
|
assert len(Character.objects.levelable(game)) == 2
|
||||||
|
new_char.level_up()
|
||||||
|
new_char.save()
|
||||||
|
assert len(Character.objects.levelable(game)) == 1
|
||||||
|
|
||||||
|
def test_levelable_query_bad_game(self):
|
||||||
|
"""Test that trying to find levelable characters for a non-active game errors."""
|
||||||
|
game = Game.objects.get(pk=1)
|
||||||
|
game.active = False
|
||||||
|
game.save()
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
Character.objects.levelable(game)
|
||||||
|
# clean up
|
||||||
|
game.active = True
|
||||||
|
game.save()
|
||||||
|
24
tests/test_idlerpg_game.py
Normal file
24
tests/test_idlerpg_game.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"""Test IdleRPG game operations.
|
||||||
|
|
||||||
|
SPDX-FileCopyrightText: © 2024 Brian S. Stephan <bss@incorporeal.org>
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from idlerpg.models import Game
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GameTest(TestCase):
|
||||||
|
"""Test the Character model."""
|
||||||
|
|
||||||
|
fixtures = ['tests/fixtures/simple_character.json']
|
||||||
|
|
||||||
|
def test_string_repr(self):
|
||||||
|
"""Test the basic string summary."""
|
||||||
|
game = Game.objects.get(pk=1)
|
||||||
|
logger.debug(str(game))
|
||||||
|
assert str(game) == "test in #test on default (active)"
|
257
tests/test_idlerpg_ircplugin.py
Normal file
257
tests/test_idlerpg_ircplugin.py
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
"""Test IdleRPG character operations.
|
||||||
|
|
||||||
|
SPDX-FileCopyrightText: © 2024 Brian S. Stephan <bss@incorporeal.org>
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from ircbot.models import IrcChannel, IrcServer
|
||||||
|
from idlerpg.ircplugin import IdleRPG
|
||||||
|
from idlerpg.models import Character, Game
|
||||||
|
|
||||||
|
|
||||||
|
class IrcPluginTest(TestCase):
|
||||||
|
"""Test the IRC integration."""
|
||||||
|
|
||||||
|
fixtures = ['tests/fixtures/simple_character.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 = IdleRPG(self.mock_bot, self.mock_connection, mock.MagicMock())
|
||||||
|
self.game = Game.objects.get(pk=1)
|
||||||
|
|
||||||
|
def test_kick_penalty(self):
|
||||||
|
"""Test that if a character is kicked from the game channel, they get penalized."""
|
||||||
|
mock_event = mock.MagicMock()
|
||||||
|
mock_event.source = 'bss!bss@test_kick_penalty'
|
||||||
|
mock_event.target = '#test'
|
||||||
|
mock_event.recursing = False
|
||||||
|
|
||||||
|
# make a character so as to not disturb other tests
|
||||||
|
test_char = Character.objects.register('testkickpen', self.game, 'test',
|
||||||
|
'bss!bss@test_kick_penalty', 'tester')
|
||||||
|
with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize:
|
||||||
|
with mock.patch('idlerpg.models.Character.log_out') as mock_log_out:
|
||||||
|
self.plugin.handle_kick_penalty(self.mock_connection, mock_event)
|
||||||
|
|
||||||
|
mock_penalize.assert_called_with(250, "getting kicked from the game channel")
|
||||||
|
mock_log_out.assert_called()
|
||||||
|
test_char = Character.objects.get(name='testkickpen')
|
||||||
|
assert test_char.time_penalized_kicked == 5
|
||||||
|
|
||||||
|
test_char.delete()
|
||||||
|
|
||||||
|
def test_generic_penalty_no_penalty_for_unknown(self):
|
||||||
|
"""Test that a kick from an unknown hostmask is ignored."""
|
||||||
|
mock_event = mock.MagicMock()
|
||||||
|
mock_event.source = 'bss!bss@test_kick_penalty'
|
||||||
|
mock_event.target = '#test'
|
||||||
|
mock_event.recursing = False
|
||||||
|
|
||||||
|
with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize:
|
||||||
|
with mock.patch('idlerpg.models.Character.log_out') as mock_log_out:
|
||||||
|
self.plugin.handle_kick_penalty(self.mock_connection, mock_event)
|
||||||
|
|
||||||
|
mock_penalize.assert_not_called()
|
||||||
|
mock_log_out.assert_not_called()
|
||||||
|
|
||||||
|
def test_nick_penalty(self):
|
||||||
|
"""Test that if a user changes their nick, they get penalized."""
|
||||||
|
mock_event = mock.MagicMock()
|
||||||
|
mock_event.source = 'bss!bss@test_nick_penalty'
|
||||||
|
mock_event.target = '#test'
|
||||||
|
mock_event.recursing = False
|
||||||
|
|
||||||
|
# make a character so as to not disturb other tests
|
||||||
|
test_char = Character.objects.register('testnickpen', self.game, 'test',
|
||||||
|
'bss!bss@test_nick_penalty', 'tester')
|
||||||
|
with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize:
|
||||||
|
with mock.patch('idlerpg.models.Character.log_out') as mock_log_out:
|
||||||
|
self.plugin.handle_nick_penalty(self.mock_connection, mock_event)
|
||||||
|
|
||||||
|
mock_penalize.assert_called_with(30, "changing their nick")
|
||||||
|
mock_log_out.assert_called()
|
||||||
|
test_char = Character.objects.get(name='testnickpen')
|
||||||
|
assert test_char.time_penalized_nick_change == 5
|
||||||
|
|
||||||
|
test_char.delete()
|
||||||
|
|
||||||
|
def test_part_penalty(self):
|
||||||
|
"""Test that if a character parts from the game channel, they get penalized."""
|
||||||
|
mock_event = mock.MagicMock()
|
||||||
|
mock_event.source = 'bss!bss@test_part_penalty'
|
||||||
|
mock_event.target = '#test'
|
||||||
|
mock_event.recursing = False
|
||||||
|
|
||||||
|
# make a character so as to not disturb other tests
|
||||||
|
test_char = Character.objects.register('testpartpen', self.game, 'test',
|
||||||
|
'bss!bss@test_part_penalty', 'tester')
|
||||||
|
with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize:
|
||||||
|
with mock.patch('idlerpg.models.Character.log_out') as mock_log_out:
|
||||||
|
self.plugin.handle_part_penalty(self.mock_connection, mock_event)
|
||||||
|
|
||||||
|
mock_penalize.assert_called_with(200, "parting the game channel")
|
||||||
|
mock_log_out.assert_called()
|
||||||
|
test_char = Character.objects.get(name='testpartpen')
|
||||||
|
assert test_char.time_penalized_part == 5
|
||||||
|
|
||||||
|
test_char.delete()
|
||||||
|
|
||||||
|
def test_pubmsg_penalty(self):
|
||||||
|
"""Test that if a character speaks in the game channel, they get penalized."""
|
||||||
|
mock_event = mock.MagicMock()
|
||||||
|
mock_event.arguments = ['this is a test message']
|
||||||
|
mock_event.source = 'bss!bss@test_pubmsg_penalty'
|
||||||
|
mock_event.target = '#test'
|
||||||
|
mock_event.recursing = False
|
||||||
|
|
||||||
|
# make a character so as to not disturb other tests
|
||||||
|
test_char = Character.objects.register('testpubmsgpen', self.game, 'test',
|
||||||
|
'bss!bss@test_pubmsg_penalty', 'tester')
|
||||||
|
with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize:
|
||||||
|
self.plugin.handle_message_penalty(self.mock_connection, mock_event)
|
||||||
|
|
||||||
|
# 22 is the len of the message
|
||||||
|
mock_penalize.assert_called_with(22, "sending a privmsg to the game channel")
|
||||||
|
test_char = Character.objects.get(name='testpubmsgpen')
|
||||||
|
assert test_char.time_penalized_privmsg == 5
|
||||||
|
|
||||||
|
test_char.delete()
|
||||||
|
|
||||||
|
def test_pubmsg_no_penalty_for_unknown(self):
|
||||||
|
"""Test that a speakerwith an unknown hostmask is ignored."""
|
||||||
|
mock_event = mock.MagicMock()
|
||||||
|
mock_event.arguments = ['this is a test message']
|
||||||
|
mock_event.source = 'bss!bss@some_user'
|
||||||
|
mock_event.target = '#test'
|
||||||
|
mock_event.recursing = False
|
||||||
|
|
||||||
|
with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize:
|
||||||
|
self.plugin.handle_message_penalty(self.mock_connection, mock_event)
|
||||||
|
|
||||||
|
mock_penalize.assert_not_called()
|
||||||
|
|
||||||
|
def test_join(self):
|
||||||
|
"""Test that joiners update the seen hostmasks."""
|
||||||
|
mock_event = mock.MagicMock()
|
||||||
|
mock_event.arguments = ['this is a test message']
|
||||||
|
mock_event.source = 'test!test@test_join'
|
||||||
|
mock_event.target = '#test'
|
||||||
|
mock_event.recursing = False
|
||||||
|
|
||||||
|
assert 'test!test@test_join' not in self.plugin.seen_hostmasks
|
||||||
|
self.plugin.handle_join(self.mock_connection, mock_event)
|
||||||
|
assert 'test!test@test_join' in self.plugin.seen_hostmasks
|
||||||
|
|
||||||
|
def test_quit_penalty(self):
|
||||||
|
"""Test that if a character quits from the game channel, they get penalized."""
|
||||||
|
mock_event = mock.MagicMock()
|
||||||
|
mock_event.source = 'bss!bss@test_quit_penalty'
|
||||||
|
mock_event.target = '#test'
|
||||||
|
mock_event.recursing = False
|
||||||
|
|
||||||
|
# make a character so as to not disturb other tests
|
||||||
|
test_char = Character.objects.register('testquitpen', self.game, 'test',
|
||||||
|
'bss!bss@test_quit_penalty', 'tester')
|
||||||
|
with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize:
|
||||||
|
with mock.patch('idlerpg.models.Character.log_out') as mock_log_out:
|
||||||
|
self.plugin.handle_quit_penalty(self.mock_connection, mock_event)
|
||||||
|
|
||||||
|
mock_penalize.assert_called_with(20, "quitting IRC")
|
||||||
|
mock_log_out.assert_called()
|
||||||
|
test_char = Character.objects.get(name='testquitpen')
|
||||||
|
assert test_char.time_penalized_quit == 5
|
||||||
|
|
||||||
|
test_char.delete()
|
||||||
|
|
||||||
|
def test_quit_penalty_even_if_logged_out(self):
|
||||||
|
"""Test that the penalty applies even if the character is already logged out."""
|
||||||
|
mock_event = mock.MagicMock()
|
||||||
|
mock_event.source = 'bss!bss@test_quit_penalty'
|
||||||
|
mock_event.target = '#test'
|
||||||
|
mock_event.recursing = False
|
||||||
|
|
||||||
|
# make a character so as to not disturb other tests
|
||||||
|
test_char = Character.objects.register('testquitpen', self.game, 'test',
|
||||||
|
'bss!bss@test_quit_penalty', 'tester')
|
||||||
|
with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize:
|
||||||
|
with mock.patch('idlerpg.models.Character.log_out', side_effect=ValueError) as mock_log_out:
|
||||||
|
self.plugin.handle_quit_penalty(self.mock_connection, mock_event)
|
||||||
|
|
||||||
|
mock_penalize.assert_called_with(20, "quitting IRC")
|
||||||
|
mock_log_out.assert_called()
|
||||||
|
test_char = Character.objects.get(name='testquitpen')
|
||||||
|
assert test_char.time_penalized_quit == 5
|
||||||
|
|
||||||
|
test_char.delete()
|
||||||
|
|
||||||
|
def test_login_character_not_found(self):
|
||||||
|
"""Test the LOGIN command sent to the bot."""
|
||||||
|
mock_event = mock.MagicMock()
|
||||||
|
mock_event.source = 'bss!bss@test_login'
|
||||||
|
mock_event.target = '#test'
|
||||||
|
mock_event.recursing = False
|
||||||
|
|
||||||
|
match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'LOGIN nope bss')
|
||||||
|
self.plugin.handle_login(self.mock_connection, mock_event, match)
|
||||||
|
|
||||||
|
self.mock_bot.reply.assert_called_once_with(
|
||||||
|
mock_event,
|
||||||
|
"The requested character does not exist, or has been disabled, or your password does not match."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_bad_password(self):
|
||||||
|
"""Test the LOGIN command sent to the bot."""
|
||||||
|
mock_event = mock.MagicMock()
|
||||||
|
mock_event.source = 'bss!bss@test_login'
|
||||||
|
mock_event.target = '#test'
|
||||||
|
mock_event.recursing = False
|
||||||
|
|
||||||
|
match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'LOGIN bss nope')
|
||||||
|
self.plugin.handle_login(self.mock_connection, mock_event, match)
|
||||||
|
|
||||||
|
self.mock_bot.reply.assert_called_once_with(
|
||||||
|
mock_event,
|
||||||
|
"The requested character does not exist, or has been disabled, or your password does not match."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_not_seen(self):
|
||||||
|
"""Test the LOGIN command sent to the bot."""
|
||||||
|
mock_event = mock.MagicMock()
|
||||||
|
mock_event.source = 'bss!bss@test_login'
|
||||||
|
mock_event.target = '#test'
|
||||||
|
mock_event.recursing = False
|
||||||
|
|
||||||
|
match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'LOGIN bss bss')
|
||||||
|
self.plugin.handle_login(self.mock_connection, mock_event, match)
|
||||||
|
|
||||||
|
self.mock_bot.reply.assert_called_once_with(mock_event, "Please join #test before logging in.")
|
||||||
|
|
||||||
|
def test_login_not_logged_off(self):
|
||||||
|
"""Test the LOGIN command sent to the bot."""
|
||||||
|
mock_event = mock.MagicMock()
|
||||||
|
mock_event.source = 'bss!bss@test_login'
|
||||||
|
mock_event.target = '#test'
|
||||||
|
mock_event.recursing = False
|
||||||
|
|
||||||
|
self.plugin.seen_hostmasks.add('bss!bss@test_login')
|
||||||
|
match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'LOGIN bss bss')
|
||||||
|
self.plugin.handle_login(self.mock_connection, mock_event, match)
|
||||||
|
|
||||||
|
self.mock_bot.reply.assert_called_once_with(
|
||||||
|
mock_event,
|
||||||
|
"Cannot log in bss, either they already are, or they are in a state that cannot be modified."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.plugin.seen_hostmasks.remove('bss!bss@test_login')
|
Loading…
x
Reference in New Issue
Block a user