diff --git a/config/supervisord.conf b/config/supervisord.conf
index ebb11cb12..e80bb49a3 100644
--- a/config/supervisord.conf
+++ b/config/supervisord.conf
@@ -16,6 +16,13 @@ environment=LOGGING_FILENAME=expiring_vips_%(ENV_SERVER_NUMBER)s.log
startretries=10
autorestart=true
+[program:seed_vip]
+command=/code/manage.py seed_vip
+environment=LOGGING_FILENAME=seed_vip_%(ENV_SERVER_NUMBER)s.log
+startretries=10
+autostart=true
+autorestart=true
+
[program:log_event_loop]
command=/code/manage.py log_loop
diff --git a/rcon/api_commands.py b/rcon/api_commands.py
index b13bb043f..40da2782d 100644
--- a/rcon/api_commands.py
+++ b/rcon/api_commands.py
@@ -50,6 +50,7 @@
from rcon.user_config.rcon_server_settings import RconServerSettingsUserConfig
from rcon.user_config.real_vip import RealVipUserConfig
from rcon.user_config.scorebot import ScorebotUserConfig
+from rcon.user_config.seed_vip import SeedVIPUserConfig
from rcon.user_config.standard_messages import (
StandardBroadcastMessagesUserConfig,
StandardPunishmentMessagesUserConfig,
@@ -81,8 +82,9 @@
def parameter_aliases(alias_to_param: Dict[str, str]):
"""Specify parameter aliases of a function. This might be useful to preserve backwards
compatibility or to handle parameters named after a Python reserved keyword.
-
+
Takes a mapping of aliases to their parameter name."""
+
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
@@ -90,11 +92,13 @@ def wrapper(*args, **kwargs):
if alias in kwargs:
kwargs[param] = kwargs.pop(alias)
return func(*args, **kwargs)
-
+
wrapper._parameter_aliases = alias_to_param
return wrapper
+
return decorator
+
def get_rcon_api(credentials: ServerInfoType | None = None) -> "RconAPI":
"""Return a initialized Rcon connection to the game server
@@ -578,9 +582,11 @@ def get_online_mods(self) -> list[AdminUserType]:
def get_ingame_mods(self) -> list[AdminUserType]:
return ingame_mods()
- @parameter_aliases({
- "from": "from_",
- })
+ @parameter_aliases(
+ {
+ "from": "from_",
+ }
+ )
def get_historical_logs(
self,
player_name: str | None = None,
@@ -1010,6 +1016,43 @@ def set_real_vip_config(
reset_to_default=reset_to_default,
)
+ def get_seed_vip_config(
+ self,
+ ) -> SeedVIPUserConfig:
+ return SeedVIPUserConfig.load_from_db()
+
+ def validate_seed_vip_config(
+ self,
+ by: str,
+ config: dict[str, Any] | BaseUserConfig | None = None,
+ reset_to_default: bool = False,
+ **kwargs,
+ ) -> bool:
+ return self._validate_user_config(
+ command_name=inspect.currentframe().f_code.co_name, # type: ignore
+ by=by,
+ model=SeedVIPUserConfig,
+ data=config or kwargs,
+ dry_run=True,
+ reset_to_default=reset_to_default,
+ )
+
+ def set_seed_vip_config(
+ self,
+ by: str,
+ config: dict[str, Any] | BaseUserConfig | None = None,
+ reset_to_default: bool = False,
+ **kwargs,
+ ) -> bool:
+ return self._validate_user_config(
+ command_name=inspect.currentframe().f_code.co_name, # type: ignore
+ by=by,
+ model=SeedVIPUserConfig,
+ data=config or kwargs,
+ dry_run=False,
+ reset_to_default=reset_to_default,
+ )
+
def get_camera_notification_config(self) -> CameraNotificationUserConfig:
return CameraNotificationUserConfig.load_from_db()
diff --git a/rcon/automods/level_thresholds.py b/rcon/automods/level_thresholds.py
index 3f6386a5f..b3b9e4550 100644
--- a/rcon/automods/level_thresholds.py
+++ b/rcon/automods/level_thresholds.py
@@ -22,10 +22,9 @@
WatchStatus,
)
from rcon.automods.num_or_inf import num_or_inf
-from rcon.types import GameState, GetDetailedPlayer
+from rcon.types import GameStateType, GetDetailedPlayer
from rcon.user_config.auto_mod_level import AutoModLevelUserConfig, Roles
-
LEVEL_THRESHOLDS_RESET_SECS = 120
AUTOMOD_USERNAME = "LevelThresholdsAutomod"
@@ -260,7 +259,7 @@ def punitions_to_apply(
squad_name: str,
team: Literal["axis", "allies"],
squad: dict,
- game_state: GameState,
+ game_state: GameStateType,
) -> PunitionsToApply:
"""
Observe all squads/players
diff --git a/rcon/automods/no_leader.py b/rcon/automods/no_leader.py
index a8089f18b..94c7622ad 100644
--- a/rcon/automods/no_leader.py
+++ b/rcon/automods/no_leader.py
@@ -23,10 +23,9 @@
WatchStatus,
)
from rcon.automods.num_or_inf import num_or_inf
-from rcon.types import GameState
+from rcon.types import GameStateType
from rcon.user_config.auto_mod_no_leader import AutoModNoLeaderUserConfig
-
LEADER_WATCH_RESET_SECS = 120
AUTOMOD_USERNAME = "NoLeaderWatch"
@@ -137,7 +136,7 @@ def punitions_to_apply(
squad_name: str,
team: Literal["axis", "allies"],
squad: dict,
- game_state: GameState,
+ game_state: GameStateType,
) -> PunitionsToApply:
"""
Observe all squads/players
diff --git a/rcon/automods/no_solotank.py b/rcon/automods/no_solotank.py
index 8114d5e6a..b66b9ab85 100644
--- a/rcon/automods/no_solotank.py
+++ b/rcon/automods/no_solotank.py
@@ -22,10 +22,9 @@
WatchStatus,
)
from rcon.automods.num_or_inf import num_or_inf
-from rcon.types import GameState
+from rcon.types import GameStateType
from rcon.user_config.auto_mod_solo_tank import AutoModNoSoloTankUserConfig
-
SOLO_TANK_RESET_SECS = 120
AUTOMOD_USERNAME = "NoSoloTank"
@@ -134,7 +133,7 @@ def punitions_to_apply(
squad_name: str,
team: Literal["axis", "allies"],
squad: dict,
- game_state: GameState,
+ game_state: GameStateType,
) -> PunitionsToApply:
"""
Observe all squads/players
diff --git a/rcon/automods/seeding_rules.py b/rcon/automods/seeding_rules.py
index edf9bb8f2..49e7b7e20 100644
--- a/rcon/automods/seeding_rules.py
+++ b/rcon/automods/seeding_rules.py
@@ -29,10 +29,9 @@
from rcon.game_logs import on_match_start
from rcon.maps import GameMode, parse_layer
from rcon.rcon import StructuredLogLineType
-from rcon.types import GameState, GetDetailedPlayer, Roles
+from rcon.types import GameStateType, GetDetailedPlayer, Roles
from rcon.user_config.auto_mod_seeding import AutoModSeedingUserConfig
-
SEEDING_RULES_RESET_SECS = 120
AUTOMOD_USERNAME = "SeedingRulesAutomod"
SEEDING_RULE_NAMES = ["disallowed_roles", "disallowed_weapons", "enforce_cap_fight"]
@@ -296,7 +295,7 @@ def punitions_to_apply(
squad_name: str,
team: Literal["axis", "allies"],
squad: dict,
- game_state: GameState,
+ game_state: GameStateType,
) -> PunitionsToApply:
"""
Observe all squads/players
diff --git a/rcon/cli.py b/rcon/cli.py
index e89e1f1d4..094ba9863 100644
--- a/rcon/cli.py
+++ b/rcon/cli.py
@@ -12,6 +12,7 @@
from sqlalchemy import select, text, update
import rcon.expiring_vips.service
+import rcon.seed_vip.service
import rcon.user_config
import rcon.user_config.utils
from rcon import auto_settings, broadcast, game_logs, routines
@@ -31,6 +32,7 @@
from rcon.steam_utils import enrich_db_users
from rcon.user_config.auto_settings import AutoSettingsConfig
from rcon.user_config.log_stream import LogStreamUserConfig
+from rcon.user_config.seed_vip import SeedVIPUserConfig
from rcon.user_config.webhooks import (
BaseMentionWebhookUserConfig,
BaseUserConfig,
@@ -126,6 +128,15 @@ def run_expiring_vips():
rcon.expiring_vips.service.run()
+@cli.command(name="seed_vip")
+def run_seed_vip():
+ config = SeedVIPUserConfig.load_from_db()
+ if config.enabled:
+ rcon.seed_vip.service.run()
+ else:
+ logger.info("Seed VIP is not enabled")
+
+
@cli.command(name="automod")
def run_automod():
automod.run()
diff --git a/rcon/rcon.py b/rcon/rcon.py
index 65d6fc54e..a1b43cd23 100644
--- a/rcon/rcon.py
+++ b/rcon/rcon.py
@@ -22,7 +22,7 @@
AdminType,
GameLayoutRandomConstraints,
GameServerBanType,
- GameState,
+ GameStateType,
GetDetailedPlayer,
GetDetailedPlayers,
GetPlayersType,
@@ -747,7 +747,7 @@ def message_player(
)
return res
- def get_gamestate(self) -> GameState:
+ def get_gamestate(self) -> GameStateType:
"""
Returns player counts, team scores, remaining match time and current/next map
@@ -1311,21 +1311,22 @@ def set_maprotation(self, map_names: list[str]) -> list[Layer]:
super().remove_map_from_rotation(current[0], 1)
return self.get_map_rotation()
-
+
@ttl_cache(ttl=10)
def get_objective_row(self, row: int):
return super().get_objective_row(row)
-
+
def get_objective_rows(self) -> List[List[str]]:
- return [
- self.get_objective_row(row)
- for row in range(5)
- ]
+ return [self.get_objective_row(row) for row in range(5)]
- def set_game_layout(self, objectives: Sequence[str | int | None], random_constraints: GameLayoutRandomConstraints = 0):
+ def set_game_layout(
+ self,
+ objectives: Sequence[str | int | None],
+ random_constraints: GameLayoutRandomConstraints = 0,
+ ):
if len(objectives) != 5:
raise ValueError("5 objectives must be provided")
-
+
obj_rows = self.get_objective_rows()
parsed_objs: list[str] = []
for row, (obj, obj_row) in enumerate(zip(objectives, obj_rows)):
@@ -1340,12 +1341,17 @@ def set_game_layout(self, objectives: Sequence[str | int | None], random_constra
elif obj in ("right", "bottom"):
parsed_objs.append(obj_row[2])
else:
- raise ValueError("Objective %s does not exist in row %s" % (obj, row))
-
+ raise ValueError(
+ "Objective %s does not exist in row %s" % (obj, row)
+ )
+
elif isinstance(obj, int):
# Use index of the objective
if not (0 <= obj <= 2):
- raise ValueError("Objective index %s is out of range 0-2 in row %s" % (obj, row + 1))
+ raise ValueError(
+ "Objective index %s is out of range 0-2 in row %s"
+ % (obj, row + 1)
+ )
parsed_objs.append(obj_row[obj])
elif obj is None:
@@ -1375,7 +1381,7 @@ def set_game_layout(self, objectives: Sequence[str | int | None], random_constra
neighbors.append(obj_rows[row - 1].index(parsed_objs[row - 1]))
if row < 4 and parsed_objs[row + 1] is not None:
neighbors.append(obj_rows[row + 1].index(parsed_objs[row + 1]))
-
+
# Skip this row for now if neither of its neighbors had their objective determined yet
if not neighbors:
continue
@@ -1394,7 +1400,7 @@ def set_game_layout(self, objectives: Sequence[str | int | None], random_constra
if random_constraints & GameLayoutRandomConstraints.ALWAYS_DIAGONAL:
# Cannot have two objectives in a straight row
obj_choices[neighbor_idx] = None
-
+
# Pick an objective. If none are viable, discard constraints.
parsed_objs[row] = random.choice(
[c for c in obj_choices if c is not None] or obj_row
diff --git a/rcon/seed_vip/__init__.py b/rcon/seed_vip/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/rcon/seed_vip/models.py b/rcon/seed_vip/models.py
new file mode 100644
index 000000000..7d71a1007
--- /dev/null
+++ b/rcon/seed_vip/models.py
@@ -0,0 +1,61 @@
+from datetime import datetime, timedelta
+from logging import getLogger
+
+import pydantic
+
+from rcon.maps import Layer
+
+logger = getLogger(__name__)
+
+
+class BaseCondition(pydantic.BaseModel):
+ def is_met(self):
+ raise NotImplementedError
+
+
+class PlayerCountCondition(BaseCondition):
+ faction: str
+ min_players: int = pydantic.Field(ge=0, le=50)
+ max_players: int = pydantic.Field(ge=0, le=50)
+
+ current_players: int = pydantic.Field(ge=0, le=50)
+
+ def is_met(self):
+ return self.min_players <= self.current_players <= self.max_players
+
+
+class PlayTimeCondition(BaseCondition):
+ # This is constrained on the user config side
+ min_time_secs: int = pydantic.Field()
+ # This should be constrained to ge=0 but CRCON will sometimes
+ # report players with negative play time
+ current_time_secs: int = pydantic.Field()
+
+ def is_met(self):
+ return self.current_time_secs >= self.min_time_secs
+
+
+class GameState(pydantic.BaseModel):
+ num_allied_players: int
+ num_axis_players: int
+ allied_score: int
+ axis_score: int
+ raw_time_remaining: str
+ time_remaining: timedelta
+ current_map: Layer
+ next_map: Layer
+
+
+class Player(pydantic.BaseModel):
+ name: str
+ player_id: str
+ current_playtime_seconds: int
+
+
+class VipPlayer(pydantic.BaseModel):
+ player: Player
+ expiration_date: datetime | None
+
+
+class ServerPopulation(pydantic.BaseModel):
+ players: dict[str, Player]
diff --git a/rcon/seed_vip/service.py b/rcon/seed_vip/service.py
new file mode 100644
index 000000000..b5bec7d04
--- /dev/null
+++ b/rcon/seed_vip/service.py
@@ -0,0 +1,279 @@
+import sys
+from collections import defaultdict
+from datetime import datetime, timedelta, timezone
+from logging import getLogger
+from time import sleep
+
+import humanize
+
+import discord
+from rcon.api_commands import get_rcon_api
+from rcon.seed_vip.utils import (
+ calc_vip_expiration_timestamp,
+ collect_steam_ids,
+ filter_indefinite_vip_steam_ids,
+ filter_online_players,
+ get_gamestate,
+ get_next_player_bucket,
+ get_online_players,
+ get_vips,
+ is_seeded,
+ make_seed_announcement_embed,
+ message_players,
+ reward_players,
+)
+from rcon.user_config.seed_vip import SeedVIPUserConfig
+
+logger = getLogger(__name__)
+
+
+def run():
+ config = SeedVIPUserConfig.load_from_db()
+ current_lang = config.language
+
+ rcon_api = get_rcon_api()
+
+ to_add_vip_steam_ids: set[str] = set()
+ no_reward_steam_ids: set[str] = set()
+ player_name_lookup: dict[str, str] = {}
+ prev_announced_bucket: int = 0
+ player_buckets = config.player_announce_thresholds
+ if player_buckets:
+ next_player_bucket = player_buckets[0]
+ else:
+ next_player_bucket = None
+ last_bucket_announced = False
+ seeded_timestamp: datetime | None = None
+
+ gamestate = get_gamestate(rcon=rcon_api)
+ is_seeding = not is_seeded(config=config, gamestate=gamestate)
+
+ try:
+ while True:
+ # Reload the config each loop to catch changes to the config
+ config = SeedVIPUserConfig.load_from_db()
+
+ if not config.enabled:
+ logger.info("Seed VIP is not enabled")
+ break
+
+ try:
+ if current_lang != config.language:
+ logger.info(f"Deactivating language={current_lang}")
+ humanize.deactivate()
+
+ if config.language:
+ # The language to translate to if using the `nice_time_delta` and `nice_expiration_date` settings
+ # Any valid language code shoud work, look here for examples: https://gist.github.com/jacobbubu/1836273
+ current_lang = config.language
+ humanize.activate(config.language)
+ logger.info(f"Activated language={config.language}")
+ except FileNotFoundError as e:
+ logger.exception(e)
+ logger.error(
+ f"Unable to activate language={config.language}, defaulting to English"
+ )
+
+ online_players = get_online_players(rcon=rcon_api)
+ if online_players is None:
+ logger.debug(
+ f"Did not receive a usable result from `get_online_players`, sleeping {config.poll_time_seeding} seconds"
+ )
+ sleep(config.poll_time_seeding)
+ continue
+
+ gamestate = get_gamestate(rcon=rcon_api)
+
+ if gamestate is None:
+ logger.debug(
+ f"Did not receive a usable result from `get_gamestate`, sleeping {config.poll_time_seeding} seconds"
+ )
+ sleep(config.poll_time_seeding)
+ continue
+
+ total_players = gamestate.num_allied_players + gamestate.num_axis_players
+
+ player_name_lookup |= {
+ p.player_id: p.name for p in online_players.players.values()
+ }
+
+ logger.debug(
+ f"{is_seeding=} {len(online_players.players.keys())} online players (`get_players`), {gamestate.num_allied_players} allied {gamestate.num_axis_players} axis players (gamestate)",
+ )
+ to_add_vip_steam_ids = collect_steam_ids(
+ config=config,
+ players=online_players,
+ cum_steam_ids=to_add_vip_steam_ids,
+ )
+
+ # Server seeded
+ if is_seeding and is_seeded(config=config, gamestate=gamestate):
+ seeded_timestamp = datetime.now(tz=timezone.utc)
+ logger.info(f"Server seeded at {seeded_timestamp.isoformat()}")
+ current_vips = get_vips(rcon=rcon_api)
+
+ # only include online players in the current_vips
+ current_vips = filter_online_players(current_vips, online_players)
+
+ # no vip reward needed for indefinite vip holders
+ indefinite_vip_steam_ids = filter_indefinite_vip_steam_ids(current_vips)
+ to_add_vip_steam_ids -= indefinite_vip_steam_ids
+
+ # Players who were online when we seeded but didn't meet the criteria for VIP
+ no_reward_steam_ids = {
+ p.player_id for p in online_players.players.values()
+ } - to_add_vip_steam_ids
+
+ expiration_timestamps = defaultdict(
+ lambda: calc_vip_expiration_timestamp(
+ config=config,
+ expiration=None,
+ from_time=seeded_timestamp or datetime.now(tz=timezone.utc),
+ )
+ )
+ for player in current_vips.values():
+ expiration_timestamps[player.player.player_id] = (
+ calc_vip_expiration_timestamp(
+ config=config,
+ expiration=player.expiration_date if player else None,
+ from_time=seeded_timestamp,
+ )
+ )
+
+ # Add or update VIP in CRCON
+ reward_players(
+ rcon=rcon_api,
+ config=config,
+ to_add_vip_steam_ids=to_add_vip_steam_ids,
+ current_vips=current_vips,
+ players_lookup=player_name_lookup,
+ expiration_timestamps=expiration_timestamps,
+ )
+
+ # Message those who earned VIP
+ message_players(
+ rcon=rcon_api,
+ config=config,
+ message=config.player_messages.reward_player_message,
+ steam_ids=to_add_vip_steam_ids,
+ expiration_timestamps=expiration_timestamps,
+ )
+
+ # Message those who did not earn
+ message_players(
+ rcon=rcon_api,
+ config=config,
+ message=config.player_messages.reward_player_message_no_vip,
+ steam_ids=no_reward_steam_ids,
+ expiration_timestamps=None,
+ )
+
+ # Post seeding complete Discord message
+ if config.hooks:
+ logger.debug(
+ f"Making embed for `{config.player_messages.seeding_complete_message}`"
+ )
+ embed = make_seed_announcement_embed(
+ message=config.player_messages.seeding_complete_message,
+ current_map=rcon_api.current_map.pretty_name,
+ time_remaining=gamestate.raw_time_remaining,
+ player_count_message=config.player_messages.player_count_message,
+ num_allied_players=gamestate.num_allied_players,
+ num_axis_players=gamestate.num_axis_players,
+ )
+ if embed:
+ for wh in config.hooks:
+ wh = discord.SyncWebhook.from_url(url=str(wh.url))
+ wh.send(embed=embed)
+
+ # Reset for next seed
+ last_bucket_announced = False
+ prev_announced_bucket = 0
+ to_add_vip_steam_ids.clear()
+ is_seeding = False
+ elif (
+ not is_seeding
+ and not is_seeded(config=config, gamestate=gamestate)
+ and total_players > 0
+ ):
+ delta: timedelta | None = None
+ if seeded_timestamp:
+ delta = datetime.now(tz=timezone.utc) - seeded_timestamp
+
+ if not seeded_timestamp:
+ logger.debug(
+ f"Back in seeding: seeded_timestamp={seeded_timestamp} {delta=} {config.requirements.buffer=}"
+ )
+ is_seeding = True
+ elif delta and (delta > config.requirements.buffer.as_timedelta):
+ logger.debug(
+ f"Back in seeding: seeded_timestamp={seeded_timestamp.isoformat()} {delta=} delta > buffer {delta > config.requirements.buffer.as_timedelta} {config.requirements.buffer=}"
+ )
+ is_seeding = True
+ else:
+ logger.info(
+ f"Delaying seeding mode due to buffer of {config.requirements.buffer} > {delta} time since seeded"
+ )
+
+ if is_seeding:
+ sleep_time = config.poll_time_seeding
+
+ # When we fall back into seeding with players still on the
+ # server we want to announce the largest bucket possible or
+ # it will announce from the smallest to the largest and spam
+ # Discord with unneccessary announcements
+ next_player_bucket = get_next_player_bucket(
+ config.player_announce_thresholds,
+ total_players=total_players,
+ )
+
+ # Announce seeding progress
+ logger.debug(
+ f"whs={[wh.url for wh in config.hooks]} {config.player_announce_thresholds=} {total_players=} {prev_announced_bucket=} {next_player_bucket=} {last_bucket_announced=}"
+ )
+ if (
+ config.hooks
+ and next_player_bucket
+ and not last_bucket_announced
+ and prev_announced_bucket < next_player_bucket
+ and total_players >= next_player_bucket
+ ):
+ prev_announced_bucket = next_player_bucket
+
+ embed = make_seed_announcement_embed(
+ message=config.player_messages.seeding_in_progress_message.format(
+ player_count=total_players
+ ),
+ current_map=rcon_api.current_map.pretty_name,
+ time_remaining=gamestate.raw_time_remaining,
+ player_count_message=config.player_messages.player_count_message,
+ num_allied_players=gamestate.num_allied_players,
+ num_axis_players=gamestate.num_axis_players,
+ )
+ if next_player_bucket == config.player_announce_thresholds[-1]:
+ logger.debug(f"setting last_bucket_announced=True")
+ last_bucket_announced = True
+
+ if embed:
+ for wh in config.hooks:
+ wh = discord.SyncWebhook.from_url(url=str(wh.url))
+ wh.send(embed=embed)
+
+ else:
+ sleep_time = config.poll_time_seeded
+
+ logger.info(f"sleeping {sleep_time=}")
+ sleep(sleep_time)
+ except* Exception as eg:
+ for e in eg.exceptions:
+ logger.exception(e)
+ raise
+
+
+if __name__ == "__main__":
+ try:
+ run()
+ except Exception as e:
+ logger.error("Seed VIP stopped")
+ logger.exception(e)
+ sys.exit(1)
diff --git a/rcon/seed_vip/utils.py b/rcon/seed_vip/utils.py
new file mode 100644
index 000000000..a560a1a75
--- /dev/null
+++ b/rcon/seed_vip/utils.py
@@ -0,0 +1,344 @@
+from collections import defaultdict
+from datetime import datetime, timedelta, timezone
+from logging import getLogger
+from typing import Iterable, Sequence
+
+from humanize import naturaldelta, naturaltime
+
+import discord
+from rcon.api_commands import RconAPI
+from rcon.seed_vip.models import (
+ BaseCondition,
+ GameState,
+ Player,
+ PlayerCountCondition,
+ PlayTimeCondition,
+ ServerPopulation,
+ VipPlayer,
+)
+from rcon.types import GameStateType, GetPlayersType, VipIdType
+from rcon.user_config.seed_vip import SeedVIPUserConfig
+from rcon.utils import INDEFINITE_VIP_DATE
+
+logger = getLogger(__name__)
+
+
+def filter_indefinite_vip_steam_ids(current_vips: dict[str, VipPlayer]) -> set[str]:
+ """Return a set of steam IDs that have indefinite VIP status"""
+ return {
+ player_id
+ for player_id, vip_player in current_vips.items()
+ if has_indefinite_vip(vip_player)
+ }
+
+
+def filter_online_players(
+ vips: dict[str, VipPlayer], players: ServerPopulation
+) -> dict[str, VipPlayer]:
+ """Return a dictionary of players that are online"""
+ return {
+ player_id: vip_player
+ for player_id, vip_player in vips.items()
+ if player_id in players.players
+ }
+
+
+def has_indefinite_vip(player: VipPlayer | None) -> bool:
+ """Return true if the player has an indefinite VIP status"""
+ if player is None or player.expiration_date is None:
+ return False
+ expiration = player.expiration_date
+ return expiration >= INDEFINITE_VIP_DATE
+
+
+def all_met(conditions: Iterable[BaseCondition]) -> bool:
+ return all(c.is_met() for c in conditions)
+
+
+def check_population_conditions(
+ config: SeedVIPUserConfig, gamestate: GameState
+) -> bool:
+ """Return if the current player count is within min/max players for seeding"""
+ player_count_conditions = [
+ PlayerCountCondition(
+ faction="allies",
+ min_players=config.requirements.min_allies,
+ max_players=config.requirements.max_allies,
+ current_players=gamestate.num_allied_players,
+ ),
+ PlayerCountCondition(
+ faction="axis",
+ min_players=config.requirements.min_axis,
+ max_players=config.requirements.max_axis,
+ current_players=gamestate.num_axis_players,
+ ),
+ ]
+
+ logger.debug(
+ f"{player_count_conditions[0]}={player_count_conditions[0].is_met()} {player_count_conditions[1]}={player_count_conditions[1].is_met()} breaking",
+ )
+ if not all_met(player_count_conditions):
+ return False
+
+ return True
+
+
+def check_player_conditions(
+ config: SeedVIPUserConfig, server_pop: ServerPopulation
+) -> set[str]:
+ """Return a set of steam IDs that meet seeding criteria"""
+ return set(
+ player.player_id
+ for player in server_pop.players.values()
+ if PlayTimeCondition(
+ min_time_secs=int(config.requirements.minimum_play_time.total_seconds),
+ current_time_secs=player.current_playtime_seconds,
+ ).is_met()
+ )
+
+
+def is_seeded(config: SeedVIPUserConfig, gamestate: GameState) -> bool:
+ """Return if the server has enough players to be out of seeding"""
+ return (
+ gamestate.num_allied_players >= config.requirements.max_allies
+ and gamestate.num_axis_players >= config.requirements.max_axis
+ )
+
+
+def calc_vip_expiration_timestamp(
+ config: SeedVIPUserConfig, expiration: datetime | None, from_time: datetime
+) -> datetime:
+ """Return the players new expiration date accounting for reward/existing timestamps"""
+ if expiration is None:
+ timestamp = from_time + config.reward.timeframe.as_timedelta
+ return timestamp
+
+ if config.reward.cumulative:
+ return expiration + config.reward.timeframe.as_timedelta
+ else:
+ # Don't step on the old expiration if it's further in the future than the new one
+ timestamp = from_time + config.reward.timeframe.as_timedelta
+ if timestamp < expiration:
+ return expiration
+ else:
+ return timestamp
+
+
+def collect_steam_ids(
+ config: SeedVIPUserConfig,
+ players: ServerPopulation,
+ cum_steam_ids: set[str],
+) -> set[str]:
+ player_conditions_steam_ids = check_player_conditions(
+ config=config, server_pop=players
+ )
+
+ if config.requirements.online_when_seeded:
+ cum_steam_ids = set(player_conditions_steam_ids)
+ else:
+ cum_steam_ids |= player_conditions_steam_ids
+
+ return cum_steam_ids
+
+
+def format_player_message(
+ message: str,
+ vip_reward: timedelta,
+ vip_expiration: datetime,
+ nice_time_delta: bool = True,
+ nice_expiration_date: bool = True,
+) -> str:
+ if nice_time_delta:
+ delta = naturaldelta(vip_reward)
+ else:
+ delta = vip_reward
+
+ if nice_expiration_date:
+ date = naturaltime(vip_expiration)
+ else:
+ date = vip_expiration.isoformat()
+
+ return message.format(vip_reward=delta, vip_expiration=date)
+
+
+def make_seed_announcement_embed(
+ message: str | None,
+ current_map: str,
+ time_remaining: str,
+ player_count_message: str,
+ num_axis_players: int,
+ num_allied_players: int,
+) -> discord.Embed | None:
+ if not message:
+ return
+
+ logger.debug(f"{num_allied_players=} {num_axis_players=}")
+
+ embed = discord.Embed(title=message)
+ embed.timestamp = datetime.now(tz=timezone.utc)
+ embed.add_field(name="Current Map", value=current_map)
+ embed.add_field(name="Time Remaining", value=time_remaining)
+ embed.add_field(
+ name="Players Per Team",
+ value=player_count_message.format(
+ num_allied_players=num_allied_players, num_axis_players=num_axis_players
+ ),
+ )
+
+ return embed
+
+
+def format_vip_reward_name(player_name: str, format_str):
+ return format_str.format(player_name=player_name)
+
+
+def should_announce_seeding_progress(
+ player_buckets: list[int],
+ total_players: int,
+ prev_announced_bucket: int,
+ next_player_bucket: int,
+ last_bucket_announced: bool,
+) -> bool:
+ return (
+ len(player_buckets) > 0
+ and total_players > prev_announced_bucket
+ and total_players >= next_player_bucket
+ and not last_bucket_announced
+ )
+
+
+def message_players(
+ rcon: RconAPI,
+ config: SeedVIPUserConfig,
+ message: str,
+ steam_ids: Iterable[str],
+ expiration_timestamps: defaultdict[str, datetime] | None,
+):
+ for steam_id in steam_ids:
+ if expiration_timestamps:
+ formatted_message = format_player_message(
+ message=message,
+ vip_reward=config.reward.timeframe.as_timedelta,
+ vip_expiration=expiration_timestamps[steam_id],
+ nice_time_delta=config.nice_time_delta,
+ nice_expiration_date=config.nice_expiration_date,
+ )
+ else:
+ formatted_message = message
+
+ if config.dry_run:
+ logger.info(f"{config.dry_run=} messaging {steam_id}: {formatted_message}")
+ else:
+ rcon.message_player(
+ player_id=steam_id,
+ message=formatted_message,
+ )
+
+
+def reward_players(
+ rcon: RconAPI,
+ config: SeedVIPUserConfig,
+ to_add_vip_steam_ids: set[str],
+ current_vips: dict[str, VipPlayer],
+ players_lookup: dict[str, str],
+ expiration_timestamps: defaultdict[str, datetime],
+):
+ logger.info(f"Rewarding players with VIP {config.dry_run=}")
+ logger.info(f"Total={len(to_add_vip_steam_ids)} {to_add_vip_steam_ids=}")
+ logger.info(f"Total={len(current_vips)=} {current_vips=}")
+ for player_id in to_add_vip_steam_ids:
+ player = current_vips.get(player_id)
+ expiration_date = expiration_timestamps[player_id]
+
+ if has_indefinite_vip(player):
+ logger.info(
+ f"{config.dry_run=} Skipping! pre-existing indefinite VIP for {player_id=} {player=} {vip_name=} {expiration_date=}"
+ )
+ continue
+
+ vip_name = (
+ player.player.name
+ if player
+ else format_vip_reward_name(
+ players_lookup.get(player_id, "No player name found"),
+ format_str=config.player_messages.reward_player_message_no_vip,
+ )
+ )
+
+ if not config.dry_run:
+ logger.info(
+ f"{config.dry_run=} adding VIP to {player_id=} {player=} {vip_name=} {expiration_date=}",
+ )
+ rcon.add_vip(
+ player_id=player_id,
+ description=vip_name,
+ expiration=expiration_date.isoformat(),
+ )
+
+ else:
+ logger.info(
+ f"{config.dry_run=} adding VIP to {player_id=} {player=} {vip_name=} {expiration_date=}",
+ )
+
+
+def get_next_player_bucket(
+ player_buckets: Sequence[int],
+ total_players: int,
+) -> int | None:
+ idx = None
+ for idx, ele in enumerate(player_buckets):
+ if ele > total_players:
+ break
+
+ try:
+ if total_players > player_buckets[-1]:
+ return player_buckets[-1]
+ elif idx:
+ return player_buckets[idx - 1]
+ except IndexError:
+ return None
+
+
+def get_online_players(
+ rcon: RconAPI,
+) -> ServerPopulation:
+ result: list[GetPlayersType] = rcon.get_players()
+ players = {}
+ for raw_player in result:
+ name = raw_player["name"]
+ player_id = player_id = raw_player["player_id"]
+ if raw_player["profile"] is None:
+ # Apparently CRCON will occasionally not return a player profile
+ logger.debug(f"No CRCON profile, skipping {raw_player}")
+ continue
+ current_playtime_seconds = raw_player["profile"]["current_playtime_seconds"] # type: ignore
+ p = Player(
+ name=name,
+ player_id=player_id,
+ current_playtime_seconds=current_playtime_seconds,
+ )
+ players[p.player_id] = p
+
+ return ServerPopulation(players=players)
+
+
+def get_gamestate(rcon: RconAPI) -> GameState:
+ result: GameStateType = rcon.get_gamestate()
+ return GameState.model_validate(result)
+
+
+def get_vips(
+ rcon: RconAPI,
+) -> dict[str, VipPlayer]:
+ raw_vips: list[VipIdType] = rcon.get_vip_ids()
+ return {
+ vip["player_id"]: VipPlayer(
+ player=Player(
+ player_id=vip["player_id"],
+ name=vip["name"],
+ current_playtime_seconds=0,
+ ),
+ expiration_date=vip["vip_expiration"],
+ )
+ for vip in raw_vips
+ }
diff --git a/rcon/types.py b/rcon/types.py
index d1b09f172..6df3aa6bd 100644
--- a/rcon/types.py
+++ b/rcon/types.py
@@ -637,7 +637,7 @@ class ParsedLogsType(TypedDict):
logs: list[StructuredLogLineWithMetaData]
-class GameState(TypedDict):
+class GameStateType(TypedDict):
"""TypedDict for Rcon.get_gamestate"""
num_allied_players: int
diff --git a/rcon/user_config/seed_db.py b/rcon/user_config/seed_db.py
index 61652849e..a833c3890 100644
--- a/rcon/user_config/seed_db.py
+++ b/rcon/user_config/seed_db.py
@@ -20,6 +20,7 @@
from rcon.user_config.rcon_server_settings import RconServerSettingsUserConfig
from rcon.user_config.real_vip import RealVipUserConfig
from rcon.user_config.scorebot import ScorebotUserConfig
+from rcon.user_config.seed_vip import SeedVIPUserConfig
from rcon.user_config.standard_messages import (
StandardBroadcastMessagesUserConfig,
StandardPunishmentMessagesUserConfig,
@@ -68,6 +69,7 @@ def seed_default_config():
RconServerSettingsUserConfig.seed_db(sess)
RealVipUserConfig.seed_db(sess)
ScorebotUserConfig.seed_db(sess)
+ SeedVIPUserConfig.seed_db(sess)
StandardBroadcastMessagesUserConfig.seed_db(sess)
StandardPunishmentMessagesUserConfig.seed_db(sess)
StandardWelcomeMessagesUserConfig.seed_db(sess)
diff --git a/rcon/user_config/seed_vip.py b/rcon/user_config/seed_vip.py
new file mode 100644
index 000000000..1d45976f1
--- /dev/null
+++ b/rcon/user_config/seed_vip.py
@@ -0,0 +1,258 @@
+from datetime import timedelta
+from logging import getLogger
+from typing import TypedDict
+
+import pydantic
+from pydantic import Field
+
+import discord
+
+logger = getLogger(__name__)
+
+from rcon.user_config.utils import BaseUserConfig, _listType, key_check, set_user_config
+from rcon.user_config.webhooks import DiscordWebhook, WebhookType
+
+SEEDING_IN_PROGRESS_MESSAGE = "Server has reached {player_count} players"
+SEEDING_COMPLETE_MESSAGE = "Server is live!"
+PLAYER_COUNT_MESSAGE = "{num_allied_players} - {num_axis_players}"
+REWARD_PLAYER_MESSAGE = "Thank you for helping us seed.\n\nYou've been granted {vip_reward} of VIP\n\nYour VIP currently expires: {vip_expiration}"
+REWARD_PLAYER_MESSAGE_NO_VIP = "Thank you for helping us seed.\n\nThe server is now live and the regular rules apply."
+
+PLAYER_NAME_FORMAT_NOT_CURRENT_VIP = "{player_name} - CRCON Seed VIP"
+
+
+class RawBufferType(TypedDict):
+ seconds: int
+ minutes: int
+ hours: int
+
+
+class RawMinPlayTimeType(TypedDict):
+ seconds: int
+ minutes: int
+ hours: int
+
+
+class RawRequirementsType(TypedDict):
+ buffer: RawBufferType
+ min_allies: int
+ min_axis: int
+ max_allies: int
+ max_axis: int
+ online_when_seeded: bool
+ minimum_play_time: RawMinPlayTimeType
+
+
+class RawRewardTimeFrameType(TypedDict):
+ minutes: int
+ hours: int
+ days: int
+ weeks: int
+
+
+class RawRewardType(TypedDict):
+ forward: bool
+ player_name_format_not_current_vip: str
+ cumulative: bool
+ timeframe: RawRewardTimeFrameType
+
+
+class RawPlayerMessagesType(TypedDict):
+ seeding_in_progress_message: str
+ seeding_complete_message: str
+ player_count_message: str
+ reward_player_message: str
+ reward_player_message_no_vip: str
+
+
+class SeedVIPType(TypedDict):
+ enabled: bool
+ dry_run: bool
+ language: str
+ hooks: list[WebhookType]
+ player_announce_thresholds: list[int]
+ poll_time_seeding: int
+ poll_time_seeded: int
+ requirements: RawRequirementsType
+ nice_time_delta: bool
+ nice_expiration_date: bool
+ player_messages: RawPlayerMessagesType
+ reward: RawRewardType
+
+
+class BufferType(pydantic.BaseModel):
+ seconds: int = Field(default=0, ge=0)
+ minutes: int = Field(default=10, ge=0)
+ hours: int = Field(default=0, ge=0)
+
+ @property
+ def as_timedelta(self):
+ return timedelta(
+ seconds=self.seconds,
+ minutes=self.minutes,
+ hours=self.hours,
+ )
+
+
+class MinPlayTime(pydantic.BaseModel):
+ seconds: int = Field(default=0, ge=0)
+ minutes: int = Field(default=5, ge=0)
+ hours: int = Field(default=0, ge=0)
+
+ @property
+ def total_seconds(self):
+ return int(
+ timedelta(
+ seconds=self.seconds, minutes=self.minutes, hours=self.hours
+ ).total_seconds()
+ )
+
+
+class Requirements(pydantic.BaseModel):
+ buffer: BufferType = Field(default_factory=BufferType)
+ min_allies: int = Field(default=0, ge=0, le=50)
+ min_axis: int = Field(default=0, ge=0, le=50)
+ max_allies: int = Field(default=20, ge=0, le=50)
+ max_axis: int = Field(default=20, ge=0, le=50)
+ online_when_seeded: bool = Field(default=True)
+ minimum_play_time: MinPlayTime = Field(default_factory=MinPlayTime)
+
+
+class PlayerMessages(pydantic.BaseModel):
+ seeding_in_progress_message: str = Field(default=SEEDING_IN_PROGRESS_MESSAGE)
+ seeding_complete_message: str = Field(default=SEEDING_COMPLETE_MESSAGE)
+ player_count_message: str = Field(default=PLAYER_COUNT_MESSAGE)
+ reward_player_message: str = Field(default=REWARD_PLAYER_MESSAGE)
+ reward_player_message_no_vip: str = Field(default=REWARD_PLAYER_MESSAGE_NO_VIP)
+
+
+class RewardTimeFrame(pydantic.BaseModel):
+ minutes: int = Field(default=0, ge=0)
+ hours: int = Field(default=0, ge=0)
+ days: int = Field(default=1, ge=0)
+ weeks: int = Field(default=0, ge=0)
+
+ @property
+ def as_timedelta(self):
+ return timedelta(
+ minutes=self.minutes, hours=self.hours, days=self.days, weeks=self.weeks
+ )
+
+ @property
+ def total_seconds(self):
+ return int(self.as_timedelta.total_seconds())
+
+
+class Reward(pydantic.BaseModel):
+ forward: bool = Field(default=False)
+ player_name_format_not_current_vip: str = Field(
+ default=PLAYER_NAME_FORMAT_NOT_CURRENT_VIP
+ )
+ cumulative: bool = Field(default=True)
+ timeframe: RewardTimeFrame = Field(default_factory=RewardTimeFrame)
+
+
+class SeedVIPUserConfig(BaseUserConfig):
+ enabled: bool = Field(default=False)
+ dry_run: bool = Field(default=True)
+ language: str | None = Field(default="en_US")
+ hooks: list[DiscordWebhook] = Field(default_factory=list)
+ player_announce_thresholds: list[int] = Field(default=[10, 20, 30])
+ poll_time_seeding: int = Field(default=30, ge=0)
+ poll_time_seeded: int = Field(default=300, ge=0)
+ nice_time_delta: bool = Field(default=True)
+ nice_expiration_date: bool = Field(default=True)
+ requirements: Requirements = Field(default_factory=Requirements)
+ player_messages: PlayerMessages = Field(default_factory=PlayerMessages)
+ reward: Reward = Field(default_factory=Reward)
+
+ @staticmethod
+ def save_to_db(values: SeedVIPType, dry_run=False):
+ # logger.info(f"{values=}")
+ key_check(SeedVIPType.__required_keys__, values.keys())
+ logger.info(f"after key_check")
+
+ raw_hooks: list[WebhookType] = values.get("hooks")
+ _listType(values=raw_hooks)
+
+ logger.info(f"after listType check")
+ for obj in raw_hooks:
+ key_check(WebhookType.__required_keys__, obj.keys())
+
+ logger.info(f"after key_check for webhooks")
+ validated_hooks = [DiscordWebhook(url=obj.get("url")) for obj in raw_hooks]
+
+ logger.info(f"after hooks validated")
+ raw_player_messages = values.get("player_messages")
+ raw_requirements = values.get("requirements")
+ raw_buffer = raw_requirements.get("buffer")
+ raw_min_play_time = raw_requirements.get("minimum_play_time")
+ raw_reward = values.get("reward")
+ raw_reward_time_frame = raw_reward.get("timeframe")
+
+ validated_player_messages = PlayerMessages(
+ seeding_in_progress_message=raw_player_messages.get(
+ "seeding_in_progress_message"
+ ),
+ seeding_complete_message=raw_player_messages.get(
+ "seeding_complete_message"
+ ),
+ player_count_message=raw_player_messages.get("player_count_message"),
+ reward_player_message=raw_player_messages.get("reward_player_message"),
+ reward_player_message_no_vip=raw_player_messages.get(
+ "reward_player_message_no_vip"
+ ),
+ )
+
+ validated_requirements = Requirements(
+ buffer=BufferType(
+ seconds=raw_buffer.get("seconds"),
+ minutes=raw_buffer.get("minutes"),
+ hours=raw_buffer.get("hours"),
+ ),
+ min_allies=raw_requirements.get("min_allies"),
+ max_allies=raw_requirements.get("max_allies"),
+ min_axis=raw_requirements.get("min_axis"),
+ max_axis=raw_requirements.get("max_axis"),
+ online_when_seeded=raw_requirements.get("online_when_seeded"),
+ minimum_play_time=MinPlayTime(
+ seconds=raw_min_play_time.get("seconds"),
+ minutes=raw_min_play_time.get("minutes"),
+ hours=raw_min_play_time.get("hours"),
+ ),
+ )
+
+ validated_reward_time_frame = RewardTimeFrame(
+ minutes=raw_reward_time_frame.get("minutes"),
+ hours=raw_reward_time_frame.get("hours"),
+ days=raw_reward_time_frame.get("days"),
+ weeks=raw_reward_time_frame.get("weeks"),
+ )
+
+ validated_reward = Reward(
+ forward=raw_reward.get("forward"),
+ player_name_format_not_current_vip=raw_reward.get(
+ "player_name_format_not_current_vip"
+ ),
+ cumulative=raw_reward.get("cumulative"),
+ timeframe=validated_reward_time_frame,
+ )
+
+ validated_conf = SeedVIPUserConfig(
+ enabled=values.get("enabled"),
+ dry_run=values.get("dry_run"),
+ language=values.get("language"),
+ hooks=validated_hooks,
+ player_announce_thresholds=values.get("player_announce_thresholds"),
+ poll_time_seeding=values.get("poll_time_seeding"),
+ poll_time_seeded=values.get("poll_time_seeded"),
+ nice_time_delta=values.get("nice_time_delta"),
+ nice_expiration_date=values.get("nice_expiration_date"),
+ requirements=validated_requirements,
+ player_messages=validated_player_messages,
+ reward=validated_reward,
+ )
+
+ if not dry_run:
+ logger.info(f"setting {validated_conf=}")
+ set_user_config(SeedVIPUserConfig.KEY(), validated_conf)
diff --git a/rcongui/src/App.js b/rcongui/src/App.js
index 3a212e54f..79fc6a088 100644
--- a/rcongui/src/App.js
+++ b/rcongui/src/App.js
@@ -54,6 +54,7 @@ import {
GTXNameChange,
ChatCommands,
LogStream,
+ SeedVIP,
} from "./components/UserSettings/miscellaneous";
import BlacklistRecords from "./components/Blacklist/BlacklistRecords";
import BlacklistLists from "./components/Blacklist/BlacklistLists";
@@ -432,7 +433,9 @@ function App() {
@@ -720,6 +723,17 @@ function App() {
/>
+
+
+
+
+
diff --git a/rcongui/src/components/Header/header.js b/rcongui/src/components/Header/header.js
index fe35722b8..6a3142b19 100644
--- a/rcongui/src/components/Header/header.js
+++ b/rcongui/src/components/Header/header.js
@@ -12,31 +12,33 @@ import { navMenus } from "./nav-data";
import { LoginBox } from "./login";
import { Box, createStyles, makeStyles } from "@material-ui/core";
-const useStyles = makeStyles((theme) => createStyles({
- root: {
- display: "flex",
- flexGrow: 1,
- flexDirection: "column",
- justifyContent: "center",
- alignItems: "start",
- padding: theme.spacing(0.25),
- minHeight: 0,
- gap: theme.spacing(0.25),
- [theme.breakpoints.up("md")]: {
+const useStyles = makeStyles((theme) =>
+ createStyles({
+ root: {
+ display: "flex",
+ flexGrow: 1,
+ flexDirection: "column",
+ justifyContent: "center",
+ alignItems: "start",
+ padding: theme.spacing(0.25),
+ minHeight: 0,
+ gap: theme.spacing(0.25),
+ [theme.breakpoints.up("md")]: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ gap: theme.spacing(2),
+ padding: theme.spacing(0.5),
+ },
+ },
+ nav: {
+ display: "flex",
flexDirection: "row",
+ flexGrow: 1,
justifyContent: "space-between",
- alignItems: "center",
- gap: theme.spacing(2),
- padding: theme.spacing(0.5),
- }
- },
- nav: {
- display: "flex",
- flexDirection: "row",
- flexGrow: 1,
- justifyContent: "space-between",
- },
-}))
+ },
+ })
+);
const initialMenuState = navMenus.reduce((state, menu) => {
state[menu.name] = false;
@@ -47,7 +49,7 @@ const initialMenuState = navMenus.reduce((state, menu) => {
const Header = ({ classes }) => {
const [openedMenu, setOpenedMenu] = React.useState(initialMenuState);
const [anchorEl, setAnchorEl] = React.useState(null);
- const localClasses = useStyles()
+ const localClasses = useStyles();
const handleOpenMenu = (name) => (event) => {
setOpenedMenu({
@@ -85,12 +87,18 @@ const Header = ({ classes }) => {
onClose={handleCloseMenu(menu.name)}
PaperProps={{
style: {
- minWidth: '20ch',
+ minWidth: "20ch",
},
}}
>
{menu.links.map((link) => (
-