256 lines
10 KiB
Python
256 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()
|
|
|
|
# this is an logarithmic curve that starts peaking at 10ish days per level
|
|
return base_time + timedelta(seconds=int(24*60*60*1.4*(5+math.log(0.01*(current_level+1)))))
|
|
|
|
|
|
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)
|