From d862aa16d85219f41755d0b3654f7f3cffc108a2 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 5 May 2024 23:05:05 -0500 Subject: [PATCH] implement the bones of character management this provides character-level operations such as character creation, logging in/out, leveling them, and penalizing them. this isn't a game, yet, but it does implement and test a lot of the core functionality, just without triggers or bulk operations Signed-off-by: Brian S. Stephan --- .reuse/dep5 | 4 +- idlerpg/admin.py | 11 ++ idlerpg/migrations/0001_initial.py | 61 ++++++++ idlerpg/migrations/__init__.py | 1 + idlerpg/models.py | 214 +++++++++++++++++++++++++++ tests/fixtures/simple_character.json | 68 +++++++++ tests/test_idlerpg_character.py | 182 +++++++++++++++++++++++ 7 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 idlerpg/admin.py create mode 100644 idlerpg/migrations/0001_initial.py create mode 100644 idlerpg/migrations/__init__.py create mode 100644 idlerpg/models.py create mode 100644 tests/fixtures/simple_character.json create mode 100644 tests/test_idlerpg_character.py diff --git a/.reuse/dep5 b/.reuse/dep5 index 7fb0517..b2059f7 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -18,8 +18,8 @@ Files: MANIFEST.in pyproject.toml tox.ini requirements/* Copyright: © 2024 Brian S. Stephan License: AGPL-3.0-or-later -# Django-generated files -Files: idlerpg/migrations/* +# Django-generated files and testing errata +Files: idlerpg/migrations/* tests/fixtures/* Copyright: © 2024 Brian S. Stephan License: AGPL-3.0-or-later diff --git a/idlerpg/admin.py b/idlerpg/admin.py new file mode 100644 index 0000000..04b262d --- /dev/null +++ b/idlerpg/admin.py @@ -0,0 +1,11 @@ +"""Manage IdleRPG models. + +SPDX-FileCopyrightText: © 2024 Brian S. Stephan +SPDX-License-Identifier: AGPL-3.0-or-later +""" +from django.contrib import admin + +from idlerpg.models import Character, Game + +admin.site.register(Character) +admin.site.register(Game) diff --git a/idlerpg/migrations/0001_initial.py b/idlerpg/migrations/0001_initial.py new file mode 100644 index 0000000..2ea3662 --- /dev/null +++ b/idlerpg/migrations/0001_initial.py @@ -0,0 +1,61 @@ +# Generated by Django 5.0.4 on 2024-05-06 05:07 + +import django.db.models.deletion +import idlerpg.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('ircbot', '0020_alter_alias_id_alter_botuser_id_alter_ircchannel_id_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Game', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ('active', models.BooleanField(default=False)), + ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ircbot.ircchannel')), + ], + ), + migrations.CreateModel( + name='Character', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=16)), + ('password', models.CharField(max_length=256)), + ('hostmask', models.CharField(max_length=256)), + ('status', models.CharField(choices=[('OFFLINE', 'Offline'), ('ONLINE', 'Online'), ('DISABLED', 'Disabled')], default='OFFLINE', max_length=8)), + ('character_class', models.CharField(max_length=30)), + ('level', models.PositiveIntegerField(default=0)), + ('next_level', models.DateTimeField(default=idlerpg.models.Game.level_formula)), + ('created', models.DateTimeField(auto_now_add=True)), + ('last_login', models.DateTimeField(blank=True, null=True)), + ('time_penalized_nick_change', models.PositiveIntegerField(default=0)), + ('time_penalized_part', models.PositiveIntegerField(default=0)), + ('time_penalized_quit', models.PositiveIntegerField(default=0)), + ('time_penalized_logout', models.PositiveIntegerField(default=0)), + ('time_penalized_kicked', models.PositiveIntegerField(default=0)), + ('time_penalized_privmsg', models.PositiveIntegerField(default=0)), + ('time_penalized_notice', models.PositiveIntegerField(default=0)), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='idlerpg.game')), + ], + ), + migrations.AddConstraint( + model_name='game', + constraint=models.UniqueConstraint(models.F('active'), models.F('channel'), name='one_game_per_channel'), + ), + migrations.AddConstraint( + model_name='character', + constraint=models.UniqueConstraint(models.F('game'), models.F('hostmask'), name='one_player_character_per_game'), + ), + migrations.AddConstraint( + model_name='character', + constraint=models.UniqueConstraint(models.F('game'), models.F('name'), name='no_characters_with_same_name_per_game'), + ), + ] diff --git a/idlerpg/migrations/__init__.py b/idlerpg/migrations/__init__.py new file mode 100644 index 0000000..59ee3f7 --- /dev/null +++ b/idlerpg/migrations/__init__.py @@ -0,0 +1 @@ +"""Django-generated migrations for the IdleRPG models.""" diff --git a/idlerpg/models.py b/idlerpg/models.py new file mode 100644 index 0000000..ce746d2 --- /dev/null +++ b/idlerpg/models.py @@ -0,0 +1,214 @@ +"""Models for the IdleRPG game. + +SPDX-FileCopyrightText: © 2024 Brian S. Stephan +SPDX-License-Identifier: AGPL-3.0-or-later +""" +import logging +import math +from datetime import datetime, timedelta + +from django.contrib.auth.hashers import check_password, make_password +from django.db import models +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +class Game(models.Model): + """Collect game and world configuration and elements.""" + + name = models.CharField(max_length=30) + active = models.BooleanField(default=False) + + channel = models.ForeignKey('ircbot.IrcChannel', on_delete=models.CASCADE) + + class Meta: + """Options for the Game and its objects.""" + + constraints = [ + models.UniqueConstraint("active", "channel", name="one_game_per_channel"), + ] + + @staticmethod + 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. + + Defaults are for the default Character.next_level value. + + Args: + current_level: current character level, which influences the next level (arg of 1 means time to level 2) + base_time: the time to delta from, or now if None + Returns: + datetime of when a character as specified would next level up + """ + if not base_time: + base_time = timezone.now() + if current_level >= 60: + # "The exponent method code had simply gotten to that point that levels were taking too long to complete" + return base_time + timedelta(seconds=math.ceil((600*(1.16**59)) + ((60*60*24)*(current_level-59)))) + return base_time + timedelta(seconds=math.ceil(600*(1.16**current_level))) + + +class CharacterManager(models.Manager): + """Query manager for creating a Character and taking operations relevant to the game.""" + + def register(self, name: str, game: Game, password: str, hostmask: str, character_class: str): + """Create a character, with guardrails. + + Args: + name: name of the character + game: the game to associate this character to + password: plaintext password to be hashed in the DB + hostmask: hostmask to assign to the character initially + character_class: class for the character + Returns: + the created character + """ + hashed_pass = make_password(password) + character = self.create(name=name, game=game, password=hashed_pass, hostmask=hostmask, + character_class=character_class, status=Character.CHARACTER_STATUS_ONLINE, + last_login=timezone.now()) + logger.info("%s::%s: created and logged in, next level @ %s", + character.game.name, character.name, character.next_level) + return character + + +class Character(models.Model): + """A character in a game.""" + + CHARACTER_STATUS_DISABLED = 'DISABLED' + CHARACTER_STATUS_OFFLINE = 'OFFLINE' + CHARACTER_STATUS_ONLINE = 'ONLINE' + CHARACTER_STATUSES = { + CHARACTER_STATUS_OFFLINE: "Offline", + CHARACTER_STATUS_ONLINE: "Online", + CHARACTER_STATUS_DISABLED: "Disabled", + } + + NICK_CHANGE_P = 30 + PART_P = 200 + QUIT_P = 20 + LOGOUT_P = 20 + KICKED_P = 250 + + name = models.CharField(max_length=16) + password = models.CharField(max_length=256) + hostmask = models.CharField(max_length=256) + status = models.CharField(max_length=8, choices=CHARACTER_STATUSES, default='OFFLINE') + + character_class = models.CharField(max_length=30) + level = models.PositiveIntegerField(default=0) + next_level = models.DateTimeField(default=Game.level_formula) + + created = models.DateTimeField(auto_now_add=True) + last_login = models.DateTimeField(null=True, blank=True) + + time_penalized_nick_change = models.PositiveIntegerField(default=0) + time_penalized_part = models.PositiveIntegerField(default=0) + time_penalized_quit = models.PositiveIntegerField(default=0) + time_penalized_logout = models.PositiveIntegerField(default=0) + time_penalized_kicked = models.PositiveIntegerField(default=0) + time_penalized_privmsg = models.PositiveIntegerField(default=0) + time_penalized_notice = models.PositiveIntegerField(default=0) + + game = models.ForeignKey('Game', on_delete=models.CASCADE) + + objects = CharacterManager() + + class Meta: + """Options for the Character and its objects.""" + + constraints = [ + models.UniqueConstraint("game", "hostmask", name="one_player_character_per_game"), + models.UniqueConstraint("game", "name", name="no_characters_with_same_name_per_game"), + ] + + def __str__(self): + """Provide a string representation for display, etc. purposes.""" + return f"{self.name}, level {self.level} {self.character_class}" + + def calculate_datetime_to_next_level(self): + """Determine when the next level will be reached (barring no penalties or logoffs). + + This is based on their previous timestamp, to get around any timing issues (load, bot was offline, etc.) + that would otherwise lead to drift if we calculated based on right now. + + Returns: + the datetime when this character would level up + """ + # if the character hasn't reached the next level yet, just use that + if self.next_level > timezone.now(): + return self.next_level + + # 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) + + def level_up(self): + """Set this character's level and next time if they meet the necessary criteria. + + Raises: + ValueError: if the character can't level up right now for some reason + """ + # error if this is being called before it should be + if self.next_level > timezone.now(): + raise ValueError(f"character '{self.name}' can't level, it isn't yet {self.next_level}!") + if self.status != self.CHARACTER_STATUS_ONLINE: + raise ValueError(f"character '{self.name}' can't level, it isn't logged in!") + + logger.debug("leveling up %s...", str(self)) + self.next_level = self.calculate_datetime_to_next_level() + self.level += 1 + logger.info("%s::%s: leveled up to %d, next level @ %s", self.game.name, self.name, self.level, self.next_level) + + def log_in(self, password: str, hostmask: str): + """Log the character in, assuming the provided password is correct. + + Args: + password: the password to validate + Raises: + ValueError: if the provided password was incorrect, or the character isn't logged out + """ + if self.status != self.CHARACTER_STATUS_OFFLINE: + raise ValueError(f"character '{self.name}' can't be logged in, isn't logged out!") + if not check_password(password, self.password): + raise ValueError(f"incorrect password for character '{self.name}'!") + + logger.debug("logging %s in...", str(self)) + # we need to apply the time lost to the next level time + self.next_level += timezone.now() - self.last_login + self.status = self.CHARACTER_STATUS_ONLINE + self.hostmask = hostmask + logger.info("%s::%s: logged in, next level @ %s", self.game.name, self.name, self.next_level) + + def log_out(self): + """Log out the character and stop their progress for now. + + Raises: + ValueError: the character isn't logged in + """ + if self.status != self.CHARACTER_STATUS_ONLINE: + raise ValueError(f"character '{self.name}' can't be logged out, isn't logged in!") + + seconds = self.penalize(self.LOGOUT_P, "logging out") + self.time_penalized_logout += seconds + self.status = self.CHARACTER_STATUS_OFFLINE + self.last_login = timezone.now() + logger.info("%s::%s: logged out", self.game.name, self.name) + + def penalize(self, penalty: int, reason: str) -> int: + """Add a penalty to the character's time to level up. + + Args: + penalty: the base penalty (see FOO_P members) to penalize the character + reason: the reason for this penalty + """ + seconds = math.ceil(penalty*(1.14**self.level)) + self.next_level += timedelta(seconds=seconds) + logger.info("%s::%s: penalized %d seconds for %s", self.game.name, self.name, seconds, reason) + return seconds + + def set_password(self, new_password): + """Set the password for the character to the new value.""" + logger.debug("setting password for %s...", self.name) + self.password = make_password(new_password) + logger.info("%s::%s: password changed", self.game.name, self.name) diff --git a/tests/fixtures/simple_character.json b/tests/fixtures/simple_character.json new file mode 100644 index 0000000..d2dbc49 --- /dev/null +++ b/tests/fixtures/simple_character.json @@ -0,0 +1,68 @@ +[ +{ + "model": "ircbot.ircserver", + "pk": 1, + "fields": { + "name": "default", + "hostname": "irc.example.org", + "port": 6667, + "password": null, + "nickname": "", + "realname": "", + "additional_addressed_nicks": "", + "use_ssl": false, + "use_ipv6": false, + "post_connect": "", + "delay_before_joins": 0, + "xmlrpc_host": "localhost", + "xmlrpc_port": 13132, + "replace_irc_control_with_markdown": false + } +}, +{ + "model": "ircbot.ircchannel", + "pk": 1, + "fields": { + "name": "#test", + "server": 1, + "autojoin": false, + "topic_msg": "", + "topic_time": "2024-05-06T05:10:25.154Z", + "topic_by": "", + "markov_learn_from_channel": true, + "discord_bridge": "" + } +}, +{ + "model": "idlerpg.game", + "pk": 1, + "fields": { + "name": "test", + "active": false, + "channel": 1 + } +}, +{ + "model": "idlerpg.character", + "pk": 1, + "fields": { + "name": "bss", + "password": "pbkdf2_sha256$720000$A941t4dL96zzqeldCFucrr$Pof137/IjT3p//ZR+iYNoBnGmYPG6jLbNqenwMA3hHY=", + "hostmask": "bss!bss@bss", + "status": "ONLINE", + "character_class": "tester", + "level": 0, + "next_level": "2024-05-05T05:20:45.437Z", + "created": "2024-05-05T05:10:45.438Z", + "last_login": "2024-05-05T05:10:45.437Z", + "time_penalized_nick_change": 0, + "time_penalized_part": 0, + "time_penalized_quit": 0, + "time_penalized_logout": 0, + "time_penalized_kicked": 0, + "time_penalized_privmsg": 0, + "time_penalized_notice": 0, + "game": 1 + } +} +] diff --git a/tests/test_idlerpg_character.py b/tests/test_idlerpg_character.py new file mode 100644 index 0000000..1469c09 --- /dev/null +++ b/tests/test_idlerpg_character.py @@ -0,0 +1,182 @@ +"""Test IdleRPG character operations. + +SPDX-FileCopyrightText: © 2024 Brian S. Stephan +SPDX-License-Identifier: AGPL-3.0-or-later +""" +import logging +from datetime import timedelta +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone + +from idlerpg.models import Character, Game + +logger = logging.getLogger(__name__) + + +class CharacterTest(TestCase): + """Test the Character model.""" + + fixtures = ['tests/fixtures/simple_character.json'] + + def test_string_repr(self): + """Test the basic string summary.""" + char = Character.objects.get(pk=1) + logger.debug(str(char)) + assert str(char) == "bss, level 0 tester" + + def test_log_out(self): + """Test basic log out functionality and end result.""" + char = Character.objects.get(pk=1) + # retain some data for comparison after logging out + old_next_level = char.next_level + + logout_time = timezone.now() + with patch('django.utils.timezone.now', return_value=logout_time): + char.log_out() + + assert char.next_level == old_next_level + timedelta(seconds=20) + assert char.status == Character.CHARACTER_STATUS_OFFLINE + assert char.last_login == logout_time + assert char.time_penalized_logout == 20 + + def test_cant_log_out_offline(self): + """Test that we error if trying to log out a character already offline.""" + char = Character.objects.get(pk=1) + char.log_out() + with self.assertRaises(ValueError): + char.log_out() + + def test_log_in(self): + """Test the result of logging in.""" + char = Character.objects.get(pk=1) + + logout_time = timezone.now() + login_time = logout_time + timedelta(seconds=300) + # manipulate the time so that we log out and then log in 300 seconds later, which + # adds 20 seconds to the next level time + with patch('django.utils.timezone.now', return_value=logout_time): + char.log_out() + # logout has a penalty of its own, so this post-logout value is what will be altered + old_next_level = char.next_level + with patch('django.utils.timezone.now', return_value=login_time): + char.log_in('bss', 'bss!bss@test_log_in') + + assert char.next_level == old_next_level + timedelta(seconds=300) + assert char.status == Character.CHARACTER_STATUS_ONLINE + assert char.hostmask == 'bss!bss@test_log_in' + + def test_cant_log_in_when_already_online(self): + """Test that we can't log in the character if they're already online.""" + char = Character.objects.get(pk=1) + with self.assertRaises(ValueError): + char.log_in('bss', 'bss!bss@test_online') + + def test_cant_log_in_bad_password(self): + """Test that we can't log in the character if we don't have the right password.""" + char = Character.objects.get(pk=1) + char.log_out() + with self.assertRaises(ValueError): + char.log_in('bad pass', 'bss!bss@test_bad_password') + + def test_set_password(self): + """Test that the password is actually changed when requested.""" + char = Character.objects.get(pk=1) + old_password = char.password + char.set_password('something') + assert old_password != char.password + assert char.password[0:13] == 'pbkdf2_sha256' + + def test_penalize(self): + """Test that penalties apply properly, including level adjustments.""" + char = Character.objects.get(pk=1) + next_level = char.next_level + + # level 0 + next_level += timedelta(seconds=20) + char.penalize(20, 'test') + assert char.next_level == next_level + # level 1 + char.level = 1 + next_level += timedelta(seconds=23) + char.penalize(20, 'test') + assert char.next_level == next_level + # level 5 + char.level = 5 + next_level += timedelta(seconds=39) + char.penalize(20, 'test') + assert char.next_level == next_level + # level 25 + char.level = 25 + next_level += timedelta(seconds=530) + char.penalize(20, 'test') + assert char.next_level == next_level + + def test_calculate_datetime_to_next_level(self): + """Test that the next level calculation behaves as expected in a number of situations.""" + char = Character.objects.get(pk=1) + + # level 0 -> 1 + assert char.calculate_datetime_to_next_level() == char.next_level + timedelta(seconds=600) + # level 1 -> 2 + char.level = 1 + assert char.calculate_datetime_to_next_level() == char.next_level + timedelta(seconds=696) + # level 24 -> 25 + char.level = 24 + assert char.calculate_datetime_to_next_level() == char.next_level + timedelta(seconds=21142) + # level 59 -> 60 + char.level = 59 + assert char.calculate_datetime_to_next_level() == char.next_level + timedelta(seconds=3812174) + # level 60 -> 61 + char.level = 60 + assert char.calculate_datetime_to_next_level() == char.next_level + (timedelta(seconds=3812174) + + timedelta(seconds=86400)) + # level 61 -> 62 + char.level = 61 + assert char.calculate_datetime_to_next_level() == char.next_level + (timedelta(seconds=3812174) + + timedelta(seconds=86400*2)) + + def test_calculate_datetime_to_next_level_not_time_yet(self): + """Test that we just bail with the current next_level if it hasn't been reached yet.""" + char = Character.objects.get(pk=1) + with patch('django.utils.timezone.now', return_value=char.next_level - timedelta(days=1)): + assert char.calculate_datetime_to_next_level() == char.next_level + + def test_register(self): + """Test the primary way to create a character with proper initial values.""" + 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('new', game, 'pass', 'bss!bss@test_register', 'unit tester') + + assert new_char.status == Character.CHARACTER_STATUS_ONLINE + assert new_char.next_level == register_time + timedelta(seconds=600) + assert new_char.last_login == register_time + assert new_char.password[0:13] == 'pbkdf2_sha256' + + def test_level_up(self): + """Test the level up functionality.""" + char = Character.objects.get(pk=1) + + old_level = char.level + old_next_level = char.next_level + char.level_up() + + assert char.level == old_level + 1 + assert char.next_level == old_next_level + timedelta(seconds=600) + + def test_level_up_fail_not_time(self): + """Test the failure condition for trying to level up before the timestamp.""" + char = Character.objects.get(pk=1) + with patch('django.utils.timezone.now', return_value=char.next_level-timedelta(minutes=30)): + with self.assertRaises(ValueError): + char.level_up() + + def test_level_up_fail_offline(self): + """Test the failure condition for trying to level up a character not online.""" + char = Character.objects.get(pk=1) + char.log_out() + with self.assertRaises(ValueError): + char.level_up()