drbotzo-idlerpg/idlerpg/models.py

257 lines
10 KiB
Python

"""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"),
]
def __str__(self):
"""Provide a string representation of the game."""
return f"{self.name} in {self.channel} ({'active' if self.active else 'inactive'})"
@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 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):
"""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_LOGGED_IN,
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
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):
"""A character in a game."""
CHARACTER_STATUS_DISABLED = 'DISABLED'
CHARACTER_STATUS_LOGGED_IN = 'LOGGEDIN'
CHARACTER_STATUS_OFFLINE = 'OFFLINE'
CHARACTER_STATUSES = {
CHARACTER_STATUS_DISABLED: "disabled",
CHARACTER_STATUS_LOGGED_IN: "logged in",
CHARACTER_STATUS_OFFLINE: "offline",
}
NICK_CHANGE_P = 30
PART_P = 200
QUIT_P = 20
LOGOUT_P = 20
KICKED_P = 250
name = models.CharField(max_length=16, unique=True)
password = models.CharField(max_length=256)
hostmask = models.CharField(max_length=256)
status = models.CharField(max_length=8, choices=CHARACTER_STATUSES, default='OFFLINE')
enabled = models.BooleanField(default=True)
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(fields=['hostmask'], condition=models.Q(enabled=True),
name='one_enabled_character_at_a_time'),
]
def __str__(self):
"""Provide a string representation for display, etc. purposes."""
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):
"""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 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):
"""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_LOGGED_IN:
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 offline!")
self.check_password(password)
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_LOGGED_IN
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_LOGGED_IN:
raise ValueError(f"character '{self.name}' can't be logged out, isn't logged in!")
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)