part, quit, kick are all going to have their own need to log out the character without a penalty (because they apply their own), so to avoid double penalties, the log out penalty should be moved into the bot command and managed that way. this was the only place where an action method was also applying a penalty, so hopefully this remains consistent too Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
213 lines
8.6 KiB
Python
213 lines
8.6 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"),
|
|
]
|
|
|
|
@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!")
|
|
|
|
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)
|