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 <bss@incorporeal.org>
This commit is contained in:
Brian S. Stephan 2024-05-05 23:05:05 -05:00
parent 9540c98d18
commit d862aa16d8
Signed by: bss
GPG Key ID: 3DE06D3180895FCB
7 changed files with 539 additions and 2 deletions

View File

@ -18,8 +18,8 @@ Files: MANIFEST.in pyproject.toml tox.ini requirements/*
Copyright: © 2024 Brian S. Stephan <bss@incorporeal.org>
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 <bss@incorporeal.org>
License: AGPL-3.0-or-later

11
idlerpg/admin.py Normal file
View File

@ -0,0 +1,11 @@
"""Manage IdleRPG models.
SPDX-FileCopyrightText: © 2024 Brian S. Stephan <bss@incorporeal.org>
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)

View File

@ -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'),
),
]

View File

@ -0,0 +1 @@
"""Django-generated migrations for the IdleRPG models."""

214
idlerpg/models.py Normal file
View File

@ -0,0 +1,214 @@
"""Models for the IdleRPG game.
SPDX-FileCopyrightText: © 2024 Brian S. Stephan <bss@incorporeal.org>
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)

68
tests/fixtures/simple_character.json vendored Normal file
View File

@ -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
}
}
]

View File

@ -0,0 +1,182 @@
"""Test IdleRPG character operations.
SPDX-FileCopyrightText: © 2024 Brian S. Stephan <bss@incorporeal.org>
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()