Compare commits

...
51 Commits
Author SHA1 Message Date
jenz 74e72bfdac loading gradually player sessions instead all at once 2026-09-12 13:56:53 +02:00
jenz 300000d920 missed and keyword 2026-09-12 12:23:44 +02:00
jenz fb24169428 subquery did not respect cvar hash or stage. does now by just using the zone_category_id as it identifies both correctly 2026-09-12 12:00:43 +02:00
jenz cf2d0e7e8e collecting stages uniquely 2026-09-12 00:31:12 +02:00
jenz b9764dd61c 24 hour format 2026-09-11 14:26:28 +02:00
jenz 0c3407b5bc initial commit of database structure, data moving and current sourcemod plugin version 2026-09-11 13:39:42 +02:00
jenz 176dc58b61 had forgotten putting updated r2 shell script here 2026-09-09 16:05:54 +02:00
jenz ed02682ef4 added number sorting to graphs and updated the graph for repeated days showcase 2026-08-26 09:18:49 +02:00
jenz 0634fde335 added infinite scrolling to frontend and displaying vote information 2026-08-25 21:34:45 +02:00
jenz ab481d6d65 fixing where the previous session gets updated, now only update it in case of it being inserted inside the last 10 minutes 2026-08-25 16:44:31 +02:00
jenz f260578def added lazy clan tag fix 2026-08-25 15:34:42 +02:00
jenz 09107047b9 some more styling to the frontend 2026-08-24 21:52:29 +02:00
jenz 28b4e957ea re-adding PO token support yet again 2026-08-24 16:44:06 +02:00
jenz 0346c0667b AI slop update to switch warp docker ip on bot block 2026-08-24 14:47:44 +02:00
jenz 9c4c02c532 some error debugging and for frontend thicker lines around blocks 2026-08-24 12:59:16 +02:00
jenz 1ae342151e god bless that every first http request send fails and needs a retry where the second then works 2026-08-23 12:49:22 +02:00
jenz 6ace3da113 minor oversight where it might update the end play minutes to 0 if forward was not called in time 2026-08-22 17:19:06 +02:00
jenz 16bb9e14a5 furhter bug fixes and updates 2026-08-22 17:10:27 +02:00
jenz 9e8e8378a2 initial commit of the paytime session frontend 2026-08-22 14:53:27 +02:00
jenz 4ad037bd13 had forgotten to consider the revotes, they are added now as well 2026-08-22 14:23:27 +02:00
jenz b3eb1f92c1 removed map_end_dt column 2026-08-22 13:36:46 +02:00
jenz 06e9845b0a added support for storing the tiers 2026-08-22 01:05:00 +02:00
jenz 4ec38434a2 added tier for display 2026-08-21 22:43:04 +02:00
jenz e13f833fa0 added extra index for steamids latest session 2026-08-21 12:48:29 +02:00
jenz b21a6b6e0b added new index to session table and moved queries some around to be chained 2026-08-21 12:32:28 +02:00
jenz e1a93bd0dd initial commit of plugin just filling the tables with data for the playtime display 2026-08-20 17:12:46 +02:00
jenz e731a1f3fc just running the query every minute instead of 10. so knifeban, entwatc and display playtime have it more updated 2026-08-20 11:35:05 +02:00
jenz 6cca898c7a reading tickrate 66 from mapchooser cfg now 2026-08-18 12:47:56 +02:00
jenz a7b05d776c reduced levels to 6 again 2026-08-15 23:20:18 +02:00
jenz c58a981f32 added note about the new proxy 2026-08-15 23:01:46 +02:00
jenz b86a6b0da9 redid this back to 1 channel as steam is using mono audio and not stereo 2026-08-15 21:33:26 +02:00
jenz fd84509996 reverted the changes from metroid to torchlight 2026-08-15 21:30:51 +02:00
jenz e0e043d2ad just a bit better to read 2026-07-31 23:40:10 +02:00
jenz 5a53772941 added menu message about trails 2026-07-28 20:14:09 +02:00
jenz 31ed5c6f72 reduced the follow params a bit and made sure to not follow nosteamers 2026-07-14 19:09:04 +02:00
jenz 07d344e730 adding some states again for following or mimicking movement, also with coordinates to know if coming too far off course 2026-07-14 11:36:55 +02:00
jenz e3cef31246 changed entity class name 2026-07-11 23:21:35 +02:00
jenz 2136135beb just also downloading the currentmap in case its missing 2026-07-09 18:41:23 +02:00
jenz 9e703da905 also checking next map set by mapvote end 2026-07-08 16:47:52 +02:00
jenz ca95f5a961 replacing reliance on alias for connecting with steam native connect command. downloading maps through sourcemod saying the nextmap. should assure that bots are connected much faster from now on in almost all cases. 2026-07-08 16:34:41 +02:00
jenz 540cad9612 added some colouring to level up messages 2026-07-05 15:39:25 +02:00
jenz 05674446ae lmao error 2026-06-26 00:47:39 +02:00
jenz ea257beb41 missed fakeclient check 2026-06-25 14:08:47 +02:00
jenz d1d99a9e2c initial release of anti laser fag plugin controlled through mapchooser config entries 2026-06-25 14:04:45 +02:00
jenz 5dedfe10cb forward is now invoked later after having applied the extra group flags 2026-06-24 22:05:36 +02:00
jenz 2812dbce8c only removing hlstats related calls 2026-06-23 01:16:19 +02:00
jenz 55f573db8d added racetimer lvl display and colour display to tiers 2026-06-22 12:26:29 +02:00
jenz 8f4e205db0 printing tier rewards when player reaches new tier 2026-06-21 14:31:59 +02:00
jenz 131668a53d having same tier features on one line 2026-06-21 12:56:06 +02:00
jenz 58a5ea51a0 replacing the admins_simple.ini with actually using the probably intended forwards to handle group overrides on player connections 2026-06-21 00:33:55 +02:00
jenz 60ec523d98 rearranged order in menu of items 2026-06-20 14:12:12 +02:00
85 changed files with 12178 additions and 4633 deletions
+5 -3
View File
@@ -11,6 +11,8 @@
apt-get install xvfb xserver-xephyr tigervnc-standalone-server x11-utils gnumeric apt-get install xvfb xserver-xephyr tigervnc-standalone-server x11-utils gnumeric
apt-get install xdotool
#NOTICE: steam might not be able to launch in case of the tmpfs /dev/shm filesystem being full. it seems safe to just delete u1001-Shm_* files and u1002-Shm_ files. It should be fine because the files exists as long as at least one process has them open. So until steam is killed i assume. You dont need to be root for killing them, the user running the bot has permissions to rm the files #NOTICE: steam might not be able to launch in case of the tmpfs /dev/shm filesystem being full. it seems safe to just delete u1001-Shm_* files and u1002-Shm_ files. It should be fine because the files exists as long as at least one process has them open. So until steam is killed i assume. You dont need to be root for killing them, the user running the bot has permissions to rm the files
#intel specific: #intel specific:
@@ -34,7 +36,7 @@
apt install libgtk2.0-0:i386 apt install libgtk2.0-0:i386
/home/gameservers/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/cfg/autoexec.cfg: <br/> /home/gameservers/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/cfg/autoexec.cfg: <br/>
alias loop "exec looptest.cfg; wait 5; loop;"; wait 5; loop; <br/> alias loop "exec looptest.cfg; wait 5; loop;"; wait 5; loop;
exporting correct SDL_VIDEO_X11_VISUALID value is important before steam login for starting games correctly <br/> exporting correct SDL_VIDEO_X11_VISUALID value is important before steam login for starting games correctly <br/>
<br/> With the virtual desktop related to the VNC/x2go/nomachine client you can log into steam using the normal GUI -> <br/> <br/> With the virtual desktop related to the VNC/x2go/nomachine client you can log into steam using the normal GUI -> <br/>
@@ -46,6 +48,7 @@
echo 'SDL_VIDEO_X11_VISUALID: ' $SDL_VIDEO_X11_VISUALID echo 'SDL_VIDEO_X11_VISUALID: ' $SDL_VIDEO_X11_VISUALID
export SDL_VIDEO_X11_VISUALID=0x205 export SDL_VIDEO_X11_VISUALID=0x205
echo 'SDL_VIDEO_X11_VISUALID: ' $SDL_VIDEO_X11_VISUALID echo 'SDL_VIDEO_X11_VISUALID: ' $SDL_VIDEO_X11_VISUALID
echo "DISPLAY=$DISPLAY" > /home/$(whoami)/ze_runner/display.txt
dbus-run-session /usr/games/steam -applaunch 240 -novid -noipx -nojoy -noshaderapi dbus-run-session /usr/games/steam -applaunch 240 -novid -noipx -nojoy -noshaderapi
fi fi
@@ -88,5 +91,4 @@ limiting cpu can throttle the download speed as well. if bots cant connect and a
#pip3 extra #pip3 extra
pip3 install watchdog pip3 install watchdog
pip3 install requests
+77 -16
View File
@@ -7,16 +7,18 @@ import pwd
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
import random import random
import bz2
import signal import signal
import socket import socket
import codecs import codecs
import json import json
import requests
import datetime import datetime
import time import time
import glob import glob
import pwd import pwd
restart_time = datetime.datetime.now() + datetime.timedelta(hours=3) restart_time = datetime.datetime.now() + datetime.timedelta(hours=6)
whoami = subprocess.getoutput("whoami") whoami = subprocess.getoutput("whoami")
with open(f'/home/{whoami}/ze_runner/config.json') as jsonfile: with open(f'/home/{whoami}/ze_runner/config.json') as jsonfile:
@@ -24,10 +26,6 @@ with open(f'/home/{whoami}/ze_runner/config.json') as jsonfile:
looptestPath = f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/cfg/looptest.cfg" looptestPath = f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/cfg/looptest.cfg"
chatmsg = "" chatmsg = ""
def overwrite_file_access():
#setting defaults with setfacl for the download directory does not work seemingly
subprocess.Popen(["chmod", "-R", "g+rwx", f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/download"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).communicate()
def writeCfgInput(Input_user): def writeCfgInput(Input_user):
with open(looptestPath, 'w') as f: with open(looptestPath, 'w') as f:
f.write(Input_user) f.write(Input_user)
@@ -39,6 +37,36 @@ def writeCfgInput(Input_user):
time.sleep(0.35) time.sleep(0.35)
open(looptestPath, 'w').close() #clearing file. open(looptestPath, 'w').close() #clearing file.
def get_display_env_from_file():
display_path = f"/home/{whoami}/ze_runner/display.txt"
env = {}
try:
with open(display_path) as f:
for line in f:
line = line.strip()
if "=" in line:
key, _, value = line.partition("=")
env[key] = value
except FileNotFoundError:
return None
return env
def check_instance_conflict_dialog():
display_env = get_display_env_from_file() or {}
env = os.environ.copy()
env["DISPLAY"] = display_env.get("DISPLAY")
result = subprocess.run(
["xdotool", "search", "--name", "Source - Warning"],
env=env, capture_output=True, text=True,
)
if result.returncode == 0:
return 0
return None
def overwrite_file_access():
#setting defaults with setfacl for the download directory does not work seemingly
subprocess.Popen(["chmod", "-R", "g+rwx", f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/download"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).communicate()
def clean_up_files(): def clean_up_files():
#deleting POSIX shared memory objects, as long as one process has them open they exist. THis is to prevent /dev/shm from being full #deleting POSIX shared memory objects, as long as one process has them open they exist. THis is to prevent /dev/shm from being full
#due to steam child processes. #due to steam child processes.
@@ -78,7 +106,6 @@ def delete_bsp_files():
def exit_handler(): def exit_handler():
print('reached exithandler') print('reached exithandler')
#securing the looptest.cfg wont be stuck accidently with commands
kill_owned_process("pidof cstrike_linux64") kill_owned_process("pidof cstrike_linux64")
kill_owned_process("pidof xterm") kill_owned_process("pidof xterm")
@@ -126,12 +153,19 @@ def restart_sdl_and_steam():
def bot_connect(data): def bot_connect(data):
#use whatever ip you want here to connect with #use whatever ip you want here to connect with
str1 = "" server = ""
if "connect to ze" == data: if "connect to ze" == data:
str1 = f"connect {data_ports['server_ip_port_ze']};" server = f"{data_ports['server_ip_port_ze']};"
elif "connect to ze2" == data: elif "connect to ze2" == data:
str1 = f"connect {data_ports['server_ip_port_ze2']};" #wait 15000; server = f"{data_ports['server_ip_port_ze2']};"
writeCfgInput(str1)
display_env = get_display_env_from_file() or {}
env = os.environ.copy()
env["DISPLAY"] = display_env.get("DISPLAY")
subprocess.run(
["/usr/games/steam", f"steam://connect/{server}"],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
def cpulimit_pid_of_game(): def cpulimit_pid_of_game():
# ' > /dev/null' redirects stdout to /dev/null # ' > /dev/null' redirects stdout to /dev/null
@@ -180,7 +214,7 @@ if __name__ == '__main__':
sock.settimeout(5.0) sock.settimeout(5.0)
messager_name = "" messager_name = ""
failed_connect_count = 0 fail_safe_counter = 0
launch_game() launch_game()
try: try:
@@ -212,24 +246,51 @@ if __name__ == '__main__':
elif "autismo connected to ze" == data: elif "autismo connected to ze" == data:
print('Bot connected to ze!') print('Bot connected to ze!')
is_bot_connected_to_ze2 = False is_bot_connected_to_ze2 = False
failed_connect_count = 0 fail_safe_counter = 0
elif "not connected to ze2" == data: elif "not connected to ze2" == data:
is_bot_connected_to_ze2 = False is_bot_connected_to_ze2 = False
elif "autismo connected to ze2" == data: elif "autismo connected to ze2" == data:
print('Bot connected to ze2!') print('Bot connected to ze2!')
is_bot_connected_to_ze2 = True is_bot_connected_to_ze2 = True
failed_connect_count = 0 fail_safe_counter = 0
elif data.startswith("download map:"):
map_to_download = data.split("download map:")[1].strip()
map_to_download_url = "https://uk-fastdl.unloze.com/css_ze/maps/" + map_to_download + ".bsp.bz2"
map_folder = f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/download/maps/"
bz2_path = map_folder + map_to_download + ".bsp.bz2"
bsp_path = map_folder + map_to_download + ".bsp"
if os.path.exists(bsp_path):
continue
elif os.path.exists(bz2_path):
continue
else:
with requests.get(map_to_download_url, stream=True, timeout=30) as r:
r.raise_for_status()
with open(bz2_path, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
with open(bz2_path, "rb") as f:
compressed_data = f.read()
decompressed_data = bz2.decompress(compressed_data)
with open(bsp_path, "wb") as f:
f.write(decompressed_data)
os.remove(bz2_path)
print(f"downloaded and extracted map: {bsp_path}")
overwrite_file_access()
elif "connect to ze" == data or ("connect to ze2" == data and not is_bot_connected_to_ze2): elif "connect to ze" == data or ("connect to ze2" == data and not is_bot_connected_to_ze2):
if datetime.datetime.now() >= restart_time or return_user_owned_pid("pidof cstrike_linux64") is None: if datetime.datetime.now() >= restart_time or return_user_owned_pid("pidof cstrike_linux64") is None or check_instance_conflict_dialog() is not None:
kill_owned_process("pidof cstrike_linux64") kill_owned_process("pidof cstrike_linux64")
sock.close() sock.close()
print('exiting after running for several hours.') print('exiting after running for several hours.')
sys.exit(1) sys.exit(1)
print('data: ', data) print('data: ', data)
overwrite_file_access() overwrite_file_access()
check_if_bz2_downloading()
bot_connect(data) bot_connect(data)
failed_connect_count += 1
if failed_connect_count >= 2: fail_safe_counter += 1
if fail_safe_counter >= 3:
kill_owned_process("pidof cstrike_linux64") kill_owned_process("pidof cstrike_linux64")
sock.close() sock.close()
print('exiting after running for several hours.') print('exiting after running for several hours.')
@@ -1,237 +0,0 @@
#!/home/autismbot1/ze_runner/venv/bin/python3
import os
import sys
import subprocess
import atexit
import pwd
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import random
import signal
import socket
import codecs
import json
import datetime
import time
import glob
restart_time = datetime.datetime.now() + datetime.timedelta(hours=3)
the_undesired_crash_counter = 0 #hate this monkey solution
whoami = subprocess.getoutput("whoami")
with open(f'/home/{whoami}/ze_runner/config.json') as jsonfile:
data_ports = json.load(jsonfile)
looptestPath = f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/cfg/looptest.cfg"
chatmsg = ""
def overwrite_file_access():
#setting defaults with setfacl for the download directory does not work seemingly
subprocess.Popen(["chmod", "-R", "g+rwx", f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/download"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).communicate()
def writeCfgInput(Input_user):
with open(looptestPath, 'w') as f:
f.write(Input_user)
#print("wrote to file: ", Input_user)
if "connect" in Input_user or "retry;" in Input_user:
#give extra time for disconnect, retry and connect commands
time.sleep(1.0)
else:
time.sleep(0.35)
open(looptestPath, 'w').close() #clearing file.
def clean_up_files():
#deleting POSIX shared memory objects, as long as one process has them open they exist. THis is to prevent /dev/shm from being full
#due to steam child processes.
#even with steam turned offline might there be chromium web browsers left over from steam who still hold processes open.
#only kind of potential issues from this is steam cloud being out of sync, which is really fucking irrelevant.
for pattern in ["/tmp/steam*", "/tmp/dbus*", "/tmp/pressure*", "/tmp/tigervnc*", "/tmp/dumps*", "/dev/shm/u100*"]:
for path in glob.glob(pattern):
subprocess.run(["rm", "-rf", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
#clean up the game cache as it otherwise just keeps growing in gigabyte size
subprocess.run(["rm", "-rf", f"/home/{whoami}/.steam/steam/steamapps/common/Counter-Strike Source/cstrike/cache"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def check_if_bz2_downloading():
#users should only have permissions to delete their own files probably
#check if any bz2 files left over.
for f in glob.glob(f"/home/{whoami}/.steam/steam/steamapps/common/Counter-Strike Source/cstrike/download/**/*.bz2", recursive=True):
file_size = None
while True:
time.sleep(10)
if not os.path.exists(f):
break # finished downloading, bz2 file is removed.
cur_file_size = os.path.getsize(f)
if file_size == cur_file_size:
# delete the bz2 file if its not progressing downloading
subprocess.run(["rm", "-rf", f], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
break
file_size = cur_file_size
def delete_bsp_files():
#quite a more radical approach to just always delete all maps, well the download should be quick and the remaining bots have the bsp in memory...
#the bots would have struggle connecting sometimes due to bsp files that were already downloaded before hand (maybe corrupted?)
for f in glob.glob(f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/download/maps/*"):
#ai slop IF statement for checking if file modified less than 10 minutes ago.
if time.time() - os.path.getmtime(f) < 600:
continue
subprocess.run(["rm", "-rf", f], stdout=subprocess.PIPE)
def exit_handler():
print('reached exithandler')
#securing the looptest.cfg wont be stuck accidently with commands
kill_owned_process("pidof cstrike_linux64")
kill_owned_process("pidof xterm")
clean_up_files()
check_if_bz2_downloading()
delete_bsp_files()
subprocess.getoutput(f"pkill -9 -u {whoami}")
def kill_user_owned_pid(pid):
#print('pid: ', pid, ' killed')
pid = int(pid.strip())
os.kill(pid, signal.SIGKILL)
time.sleep(10)
def return_user_owned_pid(pidof):
owner_pid = subprocess.getoutput([f"{pidof}"])
if owner_pid:
for pid in owner_pid.split(" "):
username = subprocess.getoutput([f"""ps -o user= -p {pid}"""])
if username == whoami:
return pid
return None
def kill_owned_process(pidof):
pid = return_user_owned_pid(pidof)
while pid:
kill_user_owned_pid(pid)
pid = return_user_owned_pid(pidof)
def restart_sdl_and_steam():
#subprocess.getoutput(["screen -XS XTERM quit"])
kill_owned_process("pidof cstrike_linux64")
kill_owned_process("pidof xterm")
subprocess.getoutput([f'vncserver -kill']) #only displays vncservers for the specific user.
subprocess.getoutput([f'vncserver -list -cleanstale']) #cleans stale sessions by removing files from disk
time.sleep(5)
cmd = f'vncserver -localhost no -geometry 1x1 -depth 24'
#cmd = f'vncserver -localhost no -geometry 800x800 -depth 24'
#print(f'cmd: {cmd}')
subprocess.getoutput(cmd)
print('reached .bashrc executing steam and variables')
def bot_connect(data):
#use whatever ip you want here to connect with
str1 = ""
if "connect to ze" == data:
str1 = f"connect {data_ports['server_ip_port_ze']};"
elif "connect to ze2" == data:
str1 = f"connect {data_ports['server_ip_port_ze2']};" #wait 15000;
writeCfgInput(str1)
def cpulimit_pid_of_game():
# ' > /dev/null' redirects stdout to /dev/null
# '2>&1' redirects stderr to the same place as stdout
pid = return_user_owned_pid("pidof cstrike_linux64")
if pid is None:
return
cmd = f"cpulimit --pid={pid} --limit=25 --background > /dev/null 2>&1"
#cmd = f"cpulimit --pid={pid} --limit=55 --background > /dev/null 2>&1"
subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).communicate()[0]
time.sleep(3)
for source_engine_lock in glob.glob("/tmp/source_engine_*"):
subprocess.run(["rm", "-rf", source_engine_lock], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
overwrite_file_access()
if __name__ == '__main__':
atexit.register(exit_handler)
local_port = data_ports['udp_port']
external_port_messages = data_ports['chat_external_port']
buffer_size = 4096 #potentially not large enough?
clean_up_files()
check_if_bz2_downloading()
delete_bsp_files()
restart_sdl_and_steam()
is_bot_connected_to_ze2 = False
print('preparing to launch game....')
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("", local_port))
sock.settimeout(5.0)
messager_name = ""
#give the game some time to launch
fail_safe = 5
while return_user_owned_pid("pidof cstrike_linux64") is None:
#just a fail safe to prevent being stuck forever.
if fail_safe <= 0:
sys.exit(1)
time.sleep(10)
print("launching game...")
fail_safe -= 1
time.sleep(10)
cpulimit_pid_of_game()
try:
while True:
try:
#print("socket waiting for recvfrom")
data, addr = sock.recvfrom(buffer_size)
except socket.timeout:
continue
data = codecs.decode(data, "utf-8", "ignore")
ip = addr[0]
port = addr[1]
#print('port: ', port, " ip: ", ip)
#print('data: ', data)
if not data:
continue
#print("ip: ", ip, " port: ", port)
if ip == data_ports['discord_bot_ip'] and port == external_port_messages:
if messager_name in data:
messager_name = ""
response_msg = f"""say {messager_name} {data}"""
print("remote UDP packet response_msg: ", response_msg)
writeCfgInput(response_msg)
if ip != data_ports['ovh_ip']:
continue
elif data == "bot kicked server full":
print('bot kicked server full: ', datetime.datetime.now().time())
elif "autismo connected to ze" == data:
print('Bot connected to ze!')
is_bot_connected_to_ze2 = False
the_undesired_crash_counter = 0
elif "not connected to ze2" == data:
is_bot_connected_to_ze2 = False
elif "autismo connected to ze2" == data:
print('Bot connected to ze2!')
the_undesired_crash_counter = 0
is_bot_connected_to_ze2 = True
elif "connect to ze" == data or ("connect to ze2" == data and not is_bot_connected_to_ze2):
if datetime.datetime.now() >= restart_time or return_user_owned_pid("pidof cstrike_linux64") is None or the_undesired_crash_counter >= 5:
#it already finished downloading the contnet, it just cant connect. probably stuck. shitty solution welp
kill_owned_process("pidof cstrike_linux64")
sock.close()
print('exiting after running the game for several hours or game being stuck on map change.')
sys.exit(1)
print('data: ', data, ' the_undesired_crash_counter: ', the_undesired_crash_counter)
the_undesired_crash_counter += 1
check_if_bz2_downloading()
overwrite_file_access()
bot_connect(data)
elif "clientmessage:" in data:
messager_name = data.split("clientmessage:", 1)[1].split(f" {data_ports['magic_secret']}")[0]
databyte_send_message = messager_name + data.split(f"{data_ports['magic_secret']}")[1]
sock.sendto(databyte_send_message.encode(), (data_ports["discord_bot_ip"], external_port_messages))
#print('databyte_send_message: ', databyte_send_message)
finally:
sock.close()
File diff suppressed because it is too large Load Diff
@@ -3,31 +3,78 @@
#define DEBUG #define DEBUG
#define PLUGIN_AUTHOR "jenz" #define PLUGIN_AUTHOR "jenz"
#define PLUGIN_VERSION "1.9" #define PLUGIN_VERSION "2.2"
#define generic_length 256 #define generic_length 256
#include <sourcemod> #include <sourcemod>
#include <sdktools> #include <sdktools>
#include <AFKManager> #include <AFKManager>
#include <sdkhooks> #include <sdkhooks>
#include <PlayerManager>
#include <cstrike> #include <cstrike>
#include <socket> #include <socket>
#include <UNLOZE.secret> #include <UNLOZE.secret>
#include <mapchooser_extended>
#include <smlib>
int target_client[5]; //4 autism bots. int target_client[5]; //4 autism bots.
#define BUFFER_SIZE 198 // ~2 seconds at 66 tick // ~5.3s of history at 66 tick. Plenty of headroom for approach + backlog +
// resync cycles. At ~48 bytes/frame this is only a few hundred KB total.
#define BUFFER_SIZE 350
enum struct FrameData { enum struct FrameData {
int buttons; int buttons;
float vel[3]; float vel[3];
float angles[3]; float angles[3];
float origin[3]; // where the real client actually was when this frame was recorded
int impulse; int impulse;
int weaponSlot; // 0=none/untracked, 1=primary, 2=secondary, 3=knife, 4=grenade
} }
FrameData g_Buffer[MAXPLAYERS+1][BUFFER_SIZE]; FrameData g_Buffer[MAXPLAYERS+1][BUFFER_SIZE];
int g_WriteHead[MAXPLAYERS+1]; // where were writing next int g_WriteHead[MAXPLAYERS+1]; // where were writing next, indexed by REAL client
int g_FrameCount[MAXPLAYERS+1]; // total frames captured int g_FrameCount[MAXPLAYERS+1]; // total frames captured, indexed by REAL client
int g_DelayTicks = 128; // delay
// --- movement state machine (bots only) ---
#define BOT_STATE_IDLE 0
#define BOT_STATE_APPROACH 1
#define BOT_STATE_MIMIC 2
#define ARRIVE_RADIUS 18.0 // horizontal distance to anchor that counts as "arrived"
#define ARRIVE_Z_SLACK 256.0 // max height difference to anchor that still counts as "arrived"
#define APPROACH_SPEED 250.0 // forward move value while walking to the anchor
// Must stay comfortably bigger than ARRIVE_RADIUS or the bot can flap
// between MIMIC and APPROACH right at the boundary.
#define DESYNC_RADIUS 64.0 // if actual pos strays this far from the frame its about to play, resync
// Anti-loop / anti-stuck tuning
#define RESYNC_COOLDOWN 3.0 // min seconds between two resyncs; drift during cooldown just keeps mimicking
#define MAX_RESYNC_STRIKES 2 // this many resyncs on the same target -> give up, pick a new target
#define STUCK_CHECK_INTERVAL 1.0 // seconds between stuck checks while approaching
#define STUCK_MOVE_EPSILON 30.0 // if the bot moved less than this between checks, it counts as stuck
#define MAX_STUCK_STRIKES 2 // first strike re-anchors + jumps, second strike drops the target
int g_BotState[MAXPLAYERS+1]; // indexed by BOT client
float g_AnchorPos[MAXPLAYERS+1][3]; // indexed by BOT client - the single fixed point it walks to
int g_ReadHead[MAXPLAYERS+1]; // indexed by BOT client - read index into g_Buffer[target][]
int g_LastWeaponSlot[MAXPLAYERS+1]; // indexed by BOT client - last weaponSlot category we told it to switch to
// Anti-loop / anti-stuck state, all indexed by BOT client
float g_LastResyncTime[MAXPLAYERS+1]; // GetGameTime() of the last drift-triggered resync
int g_ResyncStrikes[MAXPLAYERS+1]; // consecutive resyncs against the current target
float g_StuckCheckTime[MAXPLAYERS+1]; // GetGameTime() of the last stuck check
float g_StuckCheckPos[MAXPLAYERS+1][3]; // where the bot was at the last stuck check
int g_StuckStrikes[MAXPLAYERS+1]; // consecutive stuck detections while approaching
bool g_WantJump[MAXPLAYERS+1]; // one-shot: press jump on the next approach tick (unstick aid)
// Weapon category is cheap to get slightly stale, so we only actually
// re-check it every WEAPON_CHECK_INTERVAL seconds per real client instead of
// every tick, and just keep writing the cached value into each frame.
#define WEAPON_CHECK_INTERVAL 5.0
int g_CachedWeaponSlot[MAXPLAYERS+1]; // indexed by REAL client - last computed category
float g_NextWeaponCheck[MAXPLAYERS+1]; // indexed by REAL client - GetGameTime() of next allowed check
int ports[7] = {48470, 48471, 48472, 48473, 48474, 48479, 48480}; //last three indexes are ports its sending udp from in plugin int ports[7] = {48470, 48471, 48472, 48473, 48474, 48479, 48480}; //last three indexes are ports its sending udp from in plugin
int server_ports[2] = {27015, 27035}; //server ports: ze, ze2 int server_ports[2] = {27015, 27035}; //server ports: ze, ze2
@@ -40,6 +87,9 @@ Handle global_socket;
//timer handle //timer handle
Handle g_hTimer_pressing = null; Handle g_hTimer_pressing = null;
Handle g_hTimer_bot_connect = null; Handle g_hTimer_bot_connect = null;
Handle g_hTimer_bot_connect2 = null;
Handle g_hTimer_bot_connect3 = null;
Handle g_hTimer_bot_connect4 = null;
int specific_bot_player[MAXPLAYERS + 1]; int specific_bot_player[MAXPLAYERS + 1];
@@ -47,7 +97,7 @@ public Plugin myinfo =
{ {
name = "autism bot simulate player movement", name = "autism bot simulate player movement",
author = PLUGIN_AUTHOR, author = PLUGIN_AUTHOR,
description = "just copies player movement to the autism bots", description = "just copies player movement to the autism bots. now also AI slopped.",
version = PLUGIN_VERSION, version = PLUGIN_VERSION,
url = "" url = ""
}; };
@@ -57,6 +107,7 @@ public void OnPluginStart()
//talking //talking
RegConsoleCmd("sm_autism", cmd_talk, "talking to the bot through java application"); RegConsoleCmd("sm_autism", cmd_talk, "talking to the bot through java application");
RegConsoleCmd("sm_botrtv", cmd_botrtv, "making bots rtv"); RegConsoleCmd("sm_botrtv", cmd_botrtv, "making bots rtv");
RegAdminCmd("sm_botstate", cmd_botstate, ADMFLAG_GENERIC, "debug: show state of each autism bot");
HookEvent("round_start", Event_RoundStart, EventHookMode_PostNoCopy); HookEvent("round_start", Event_RoundStart, EventHookMode_PostNoCopy);
@@ -64,7 +115,11 @@ public void OnPluginStart()
connect_socket(); connect_socket();
chat_cooldown = false; chat_cooldown = false;
g_hTimer_bot_connect = CreateTimer(90.0, bot_check_connect, _, TIMER_REPEAT); g_hTimer_bot_connect = CreateTimer(60.0, bot_check_connect, _, TIMER_REPEAT);
CreateTimer(5.0, activate_timer2);
CreateTimer(10.0, activate_timer3);
CreateTimer(15.0, activate_timer4);
g_hTimer_pressing = CreateTimer(2.0, check_team, _, TIMER_REPEAT); g_hTimer_pressing = CreateTimer(2.0, check_team, _, TIMER_REPEAT);
target_client[1] = -1; target_client[1] = -1;
@@ -81,6 +136,24 @@ public void OnPluginStart()
} }
} }
public Action activate_timer2(Handle timer, any data)
{
g_hTimer_bot_connect2 = CreateTimer(60.0, bot_check_connect2, _, TIMER_REPEAT);
return Plugin_Continue;
}
public Action activate_timer3(Handle timer, any data)
{
g_hTimer_bot_connect3 = CreateTimer(60.0, bot_check_connect3, _, TIMER_REPEAT);
return Plugin_Continue;
}
public Action activate_timer4(Handle timer, any data)
{
g_hTimer_bot_connect4 = CreateTimer(60.0, bot_check_connect4, _, TIMER_REPEAT);
return Plugin_Continue;
}
public void OnPluginEnd() public void OnPluginEnd()
{ {
if (g_hTimer_pressing != null) if (g_hTimer_pressing != null)
@@ -91,30 +164,41 @@ public void OnPluginEnd()
{ {
delete g_hTimer_bot_connect; delete g_hTimer_bot_connect;
} }
if (g_hTimer_bot_connect2 != null)
{
delete g_hTimer_bot_connect2;
}
if (g_hTimer_bot_connect3 != null)
{
delete g_hTimer_bot_connect3;
}
if (g_hTimer_bot_connect4 != null)
{
delete g_hTimer_bot_connect4;
}
} }
public void OnMapEnd() // Classifies the clients currently held weapon into a slot category. This
// servers loadout is fixed: raw inventory slot 0=primary, 1=secondary,
// 2=knife, 3=HE grenade (no smoke/flash, no slot 4 ever exists), so we can
// just compare the active weapon against each fixed slot directly.
stock int GetActiveWeaponSlotCategory(int client)
{ {
int autism_bot_counter = 0; int activeWeapon = GetEntPropEnt(client, Prop_Data, "m_hActiveWeapon", 0);
for (int i = 1; i <= MaxClients; i++) if (activeWeapon <= -1 || !IsValidEntity(activeWeapon))
{ {
if (IsValidClient(i) && specific_bot_player[i] != -1) return 0;
}
for (int rawSlot = 0; rawSlot <= 3; rawSlot++)
{ {
autism_bot_counter++; if (activeWeapon == GetPlayerWeaponSlot(client, rawSlot))
} {
} return rawSlot + 1; // 1=primary, 2=secondary, 3=knife, 4=grenade
for (int i = 1; i <= MaxClients; i++)
{
if (autism_bot_counter <= 1)
{
break;
}
if (IsValidClient(i) && specific_bot_player[i] != -1)
{
KickClient(i);
autism_bot_counter--;
} }
} }
return 0;
} }
public void OnPlayerRunCmdPost(int client, int buttons, int impulse, const float vel[3], const float angles[3], int weapon, int subtype, int cmdnum, int tickcount, int seed, const int mouse[2]) public void OnPlayerRunCmdPost(int client, int buttons, int impulse, const float vel[3], const float angles[3], int weapon, int subtype, int cmdnum, int tickcount, int seed, const int mouse[2])
@@ -128,9 +212,122 @@ public void OnPlayerRunCmdPost(int client, int buttons, int impulse, const float
g_Buffer[client][head].impulse = impulse; g_Buffer[client][head].impulse = impulse;
g_Buffer[client][head].vel = vel; g_Buffer[client][head].vel = vel;
g_Buffer[client][head].angles = angles; g_Buffer[client][head].angles = angles;
GetClientAbsOrigin(client, g_Buffer[client][head].origin);
float now = GetGameTime();
if (now >= g_NextWeaponCheck[client])
{
g_CachedWeaponSlot[client] = GetActiveWeaponSlotCategory(client);
g_NextWeaponCheck[client] = now + WEAPON_CHECK_INTERVAL;
}
g_Buffer[client][head].weaponSlot = g_CachedWeaponSlot[client];
g_WriteHead[client] = (head + 1) % BUFFER_SIZE; g_WriteHead[client] = (head + 1) % BUFFER_SIZE;
g_FrameCount[client]++; g_FrameCount[client]++;
// If any bot currently mimicking/approaching this client is about to have
// its unread backlog overwritten by this write, nudge its read head
// forward so it never reads stale/overwritten frames. With 4 bots max
// this loop is cheap.
for (int slot = 1; slot <= 4; slot++)
{
if (target_client[slot] != client)
{
continue;
}
int bot = GetBotClientForSlot(slot);
if (bot == -1 || g_BotState[bot] == BOT_STATE_IDLE)
{
continue;
}
if (g_ReadHead[bot] == g_WriteHead[client])
{
g_ReadHead[bot] = (g_ReadHead[bot] + 1) % BUFFER_SIZE;
}
}
}
// Horizontal (XY) distance between two points. The old 3D check made bots
// standing directly under/over an anchor on another floor "never arrive",
// which also produced the crazy spinning (near-vertical direction vectors
// have unstable yaw).
stock float GetHorizontalDistance(const float a[3], const float b[3])
{
float dx = a[0] - b[0];
float dy = a[1] - b[1];
return SquareRoot(dx * dx + dy * dy);
}
// Faces the bot toward `anchor` (yaw from horizontal components only, so a
// mostly-vertical offset cant destabilize it) and pushes it forward. Shared
// by the normal APPROACH state and by the mid-mimic resync path below.
void ApplyApproachMovement(int bot, const float anchor[3], int &buttons, int &impulse, float vel[3], float angles[3])
{
float botPos[3];
GetClientAbsOrigin(bot, botPos);
float dir[3];
SubtractVectors(anchor, botPos, dir);
dir[2] = 0.0; // yaw only - never derive facing from the vertical component
impulse = 0;
vel[1] = 0.0;
vel[2] = 0.0;
angles[0] = 0.0;
angles[2] = 0.0;
if (GetVectorLength(dir) < 1.0)
{
// Horizontally on top of the anchor but not "arrived" (must be a
// height mismatch). Running forward would just grind into geometry
// and the yaw would be garbage - hold position, keep current yaw,
// and let stuck detection deal with it.
buttons = 0;
vel[0] = 0.0;
return;
}
float faceAngles[3];
GetVectorAngles(dir, faceAngles);
angles[1] = faceAngles[1];
buttons = IN_FORWARD;
vel[0] = APPROACH_SPEED;
if (g_WantJump[bot])
{
buttons |= IN_JUMP;
g_WantJump[bot] = false;
}
}
void SyncWeaponSlot(int bot, int wantSlot)
{
if (wantSlot == 0 || wantSlot == g_LastWeaponSlot[bot])
{
return;
}
int weapon = GetPlayerWeaponSlot(bot, wantSlot - 1);
if (weapon != -1 && Weapon_IsValid(weapon))
{
Client_SetActiveWeapon(bot, weapon);
}
g_LastWeaponSlot[bot] = wantSlot;
}
// Drops the bots current target entirely and goes idle. check_team runs
// every 2 seconds and will hand it a fresh target (possibly the same player,
// but with a brand-new anchor and clean strike counters).
void GiveUpOnTarget(int bot)
{
int slot = specific_bot_player[bot];
if (slot != -1)
{
target_client[slot] = -1;
}
g_BotState[bot] = BOT_STATE_IDLE;
} }
public Action OnPlayerRunCmd(int client, int& buttons, int& impulse, float vel[3], float angles[3], int& weapon, int& subtype, int& cmdnum, int& tickcount, int& seed, int mouse[2]) public Action OnPlayerRunCmd(int client, int& buttons, int& impulse, float vel[3], float angles[3], int& weapon, int& subtype, int& cmdnum, int& tickcount, int& seed, int mouse[2])
@@ -140,17 +337,211 @@ public Action OnPlayerRunCmd(int client, int& buttons, int& impulse, float vel[3
return Plugin_Continue; return Plugin_Continue;
} }
int target = target_client[specific_bot_player[client]]; int target = target_client[specific_bot_player[client]];
// Read from the slot that is DelayTicks behind current write head float now = GetGameTime();
int readSlot = (g_WriteHead[target] - g_DelayTicks + BUFFER_SIZE) % BUFFER_SIZE;
buttons = g_Buffer[target][readSlot].buttons; if (g_BotState[client] == BOT_STATE_APPROACH)
impulse = g_Buffer[target][readSlot].impulse; {
vel = g_Buffer[target][readSlot].vel; float botPos[3];
angles = g_Buffer[target][readSlot].angles; GetClientAbsOrigin(client, botPos);
// Anti-stuck: if the bot has barely moved since the last check, its
// wedged on geometry. First strike: re-anchor to the targets CURRENT
// position (theyve moved on; the fresh anchor may have a clear path)
// and hop to clear small ledges. Second strike: give up on this
// target and let check_team assign a new one.
if (now - g_StuckCheckTime[client] >= STUCK_CHECK_INTERVAL)
{
if (GetVectorDistance(botPos, g_StuckCheckPos[client]) < STUCK_MOVE_EPSILON)
{
g_StuckStrikes[client]++;
if (g_StuckStrikes[client] >= MAX_STUCK_STRIKES)
{
GiveUpOnTarget(client);
return Plugin_Continue;
}
GetClientAbsOrigin(target, g_AnchorPos[client]);
g_ReadHead[client] = g_WriteHead[target];
g_WantJump[client] = true;
}
else
{
g_StuckStrikes[client] = 0;
}
g_StuckCheckPos[client] = botPos;
g_StuckCheckTime[client] = now;
}
// Arrival is judged horizontally with generous height slack. A pure
// 3D radius made anchors on other floors unreachable forever.
if (GetHorizontalDistance(botPos, g_AnchorPos[client]) <= ARRIVE_RADIUS
&& FloatAbs(botPos[2] - g_AnchorPos[client][2]) <= ARRIVE_Z_SLACK)
{
// arrived - start draining whatever the target did while we were walking here
g_BotState[client] = BOT_STATE_MIMIC;
g_StuckStrikes[client] = 0;
// fall through into mimic logic below on this same tick
}
else
{
ApplyApproachMovement(client, g_AnchorPos[client], buttons, impulse, vel, angles);
return Plugin_Changed;
}
}
if (g_BotState[client] != BOT_STATE_MIMIC)
{
return Plugin_Continue;
}
// Drift check: compare where the bot actually is against where the
// target actually was for the frame were about to play. If its too
// far off (missed a knockback, got stuck on geometry mid-mimic, etc.),
// drop back into APPROACH toward the targets current live position
// instead of continuing to mimic from a wrong spot.
//
// Two guards against the approach<->mimic ping-pong loop:
// - a cooldown, so drift during the cooldown window just keeps
// mimicking instead of instantly re-approaching
// - a strike counter, so a target whose replay keeps desyncing on the
// same geometry gets dropped entirely instead of looping forever
if (g_ReadHead[client] != g_WriteHead[target])
{
int nextSlot = g_ReadHead[client];
float botPos[3];
GetClientAbsOrigin(client, botPos);
if (GetVectorDistance(botPos, g_Buffer[target][nextSlot].origin) > DESYNC_RADIUS
&& now - g_LastResyncTime[client] >= RESYNC_COOLDOWN)
{
g_ResyncStrikes[client]++;
if (g_ResyncStrikes[client] >= MAX_RESYNC_STRIKES)
{
GiveUpOnTarget(client);
return Plugin_Continue;
}
g_LastResyncTime[client] = now;
GetClientAbsOrigin(target, g_AnchorPos[client]);
g_BotState[client] = BOT_STATE_APPROACH;
g_ReadHead[client] = g_WriteHead[target]; // drop the stale backlog, start fresh once we arrive
// reset the stuck tracker for the fresh approach leg
g_StuckStrikes[client] = 0;
g_StuckCheckTime[client] = now;
GetClientAbsOrigin(client, g_StuckCheckPos[client]);
ApplyApproachMovement(client, g_AnchorPos[client], buttons, impulse, vel, angles);
return Plugin_Changed;
}
}
if (g_ReadHead[client] == g_WriteHead[target])
{
// fully drained - nothing new recorded yet, hold still until more frames arrive
return Plugin_Continue;
}
int slot = g_ReadHead[client];
buttons = g_Buffer[target][slot].buttons;
impulse = g_Buffer[target][slot].impulse;
vel = g_Buffer[target][slot].vel;
angles = g_Buffer[target][slot].angles;
SyncWeaponSlot(client, g_Buffer[target][slot].weaponSlot);
// "cleans them out after processing" - advancing the read head past this
// slot means it will never be handed out again, which is the queue
// equivalent of clearing it.
g_ReadHead[client] = (slot + 1) % BUFFER_SIZE;
return Plugin_Changed; return Plugin_Changed;
} }
// Looks up which bot client (if any) currently occupies a given bot slot (1-4).
int GetBotClientForSlot(int slot)
{
for (int i = 1; i <= MaxClients; i++)
{
if (IsValidClient(i) && specific_bot_player[i] == slot)
{
return i;
}
}
return -1;
}
// Assigns `target` as the client that `bot` should mimic. Captures a single,
// fixed anchor point (the targets position right now) for the bot to walk
// to, and marks the point in the targets buffer where recording of the
// "walk-over" backlog begins. No teleport happens here.
void SetBotTarget(int bot, int target)
{
int slot = specific_bot_player[bot];
if (slot == -1)
{
return;
}
target_client[slot] = target;
g_LastWeaponSlot[bot] = 0; // force weapon re-evaluation once mimicking starts
// clean slate for the anti-loop/anti-stuck trackers
g_ResyncStrikes[bot] = 0;
g_StuckStrikes[bot] = 0;
g_LastResyncTime[bot] = 0.0;
g_StuckCheckTime[bot] = GetGameTime();
g_WantJump[bot] = false;
if (IsValidClient(bot))
{
GetClientAbsOrigin(bot, g_StuckCheckPos[bot]);
}
if (!IsValidClient(target))
{
g_BotState[bot] = BOT_STATE_IDLE;
return;
}
GetClientAbsOrigin(target, g_AnchorPos[bot]);
g_BotState[bot] = BOT_STATE_APPROACH;
g_ReadHead[bot] = g_WriteHead[target]; // everything the target does from now on gets replayed once we arrive
}
// Debug command: prints each bots state machine status so loops/stuck
// situations can be diagnosed live instead of guessed at.
public Action cmd_botstate(int client, int args)
{
static const char stateNames[][] = { "IDLE", "APPROACH", "MIMIC" };
bool found = false;
for (int i = 1; i <= MaxClients; i++)
{
if (!IsValidClient(i) || specific_bot_player[i] == -1)
{
continue;
}
found = true;
int slot = specific_bot_player[i];
int target = target_client[slot];
int backlog = 0;
char targetName[64];
strcopy(targetName, sizeof(targetName), "<none>");
if (IsValidClient(target))
{
GetClientName(target, targetName, sizeof(targetName));
backlog = (g_WriteHead[target] - g_ReadHead[i] + BUFFER_SIZE) % BUFFER_SIZE;
}
ReplyToCommand(client,
"[bot %d] %N | state=%s | target=%s | backlog=%d frames | resync_strikes=%d | stuck_strikes=%d",
slot, i, stateNames[g_BotState[i]], targetName, backlog,
g_ResyncStrikes[i], g_StuckStrikes[i]);
}
if (!found)
{
ReplyToCommand(client, "no autism bots currently connected");
}
return Plugin_Handled;
}
public void Event_RoundStart(Handle event, const char[] name, bool dontBroadcast) public void Event_RoundStart(Handle event, const char[] name, bool dontBroadcast)
{ {
target_client[1] = -1; target_client[1] = -1;
@@ -163,21 +554,11 @@ public void Event_RoundStart(Handle event, const char[] name, bool dontBroadcast
{ {
if (IsValidClient(i) && IsPlayerAlive(i) && specific_bot_player[i] != -1) if (IsValidClient(i) && IsPlayerAlive(i) && specific_bot_player[i] != -1)
{ {
for (int j = 1; j <= MaxClients; j++) g_BotState[i] = BOT_STATE_IDLE;
{ g_LastWeaponSlot[i] = 0;
if (IsValidClient(j) && GetClientTeam(j) == GetClientTeam(i) && IsPlayerAlive(j) && GetClientIdleTime(j) < 20) g_ResyncStrikes[i] = 0;
{ g_StuckStrikes[i] = 0;
if (target_client[1] == j || target_client[2] == j || target_client[3] == j || target_client[4]) g_WantJump[i] = false;
{
continue; //one of the autism bots already got this dude as their target.
}
float vec[3];
GetClientAbsOrigin(j, vec);
TeleportEntity(i, vec, NULL_VECTOR, NULL_VECTOR);
target_client[specific_bot_player[i]] = j;
break;
}
}
} }
} }
} }
@@ -296,25 +677,192 @@ public void send_socket_msg(char[] query_msg, int len, int port)
SocketSendTo(global_socket, query_msg, len, "127.0.0.1", port); //udp SocketSendTo(global_socket, query_msg, len, "127.0.0.1", port); //udp
} }
public void OnMapVoteEnd(const char[] map)
{
if (StrEqual(map, "##extend##") || StrEqual(map, "##dontchange##"))
{
return;
}
char msg[generic_length];
Format(msg, sizeof(msg), "download map:%s", map);
send_socket_msg(msg, strlen(msg), ports[0]);
}
public Action bot_check_connect(Handle timer, any data) public Action bot_check_connect(Handle timer, any data)
{ {
char nextmap[64];
char msg[generic_length];
if (GetNextMap(nextmap, sizeof(nextmap)))
{
Format(msg, sizeof(msg), "download map:%s", nextmap);
send_socket_msg(msg, strlen(msg), ports[0]);
}
GetCurrentMap(nextmap, sizeof(nextmap));
Format(msg, sizeof(msg), "download map:%s", nextmap);
send_socket_msg(msg, strlen(msg), ports[0]);
//this is designed for being ran from several css servers //this is designed for being ran from several css servers
int client_count = GetClientCount(false); int client_count = GetClientCount(false);
char msg[generic_length];
int i_port = GetConVarInt(FindConVar("hostport")); int i_port = GetConVarInt(FindConVar("hostport"));
bool bot1_connected = false; bool bot1_connected = false;
bool bot2_connected = false;
bool bot3_connected = false;
bool bot4_connected = false;
for (int i = 1; i <= MaxClients; i++) for (int i = 1; i <= MaxClients; i++)
if (IsValidClient(i) && !IsFakeClient(i)) if (IsValidClient(i) && !IsFakeClient(i))
{ {
if (specific_bot_player[i] == 1) if (specific_bot_player[i] == 1)
bot1_connected = true; bot1_connected = true;
}
if (i_port == server_ports[0])
{
int max_allowed_players = 55;
char current_map[128];
GetCurrentMap(current_map, sizeof(current_map));
if (client_count > max_allowed_players)
{
Format(msg, sizeof(msg), "connect to ze2");
send_socket_msg(msg, strlen(msg), ports[0]);
}
else if (client_count < max_allowed_players - 5)
{
Format(msg, sizeof(msg), "connect to ze");
if (!bot1_connected)
send_socket_msg(msg, strlen(msg), ports[0]);
}
}
else
{
//in case the bot is not connected to ze2 anymore make sure that is_bot_connected_to_ze2 is set to false again
Format(msg, sizeof(msg), "not connected to ze2");
if (!bot1_connected)
send_socket_msg(msg, strlen(msg), ports[0]);
}
return Plugin_Continue;
}
public Action bot_check_connect2(Handle timer, any data)
{
char nextmap[64];
char msg[generic_length];
if (GetNextMap(nextmap, sizeof(nextmap)))
{
Format(msg, sizeof(msg), "download map:%s", nextmap);
send_socket_msg(msg, strlen(msg), ports[0]);
}
GetCurrentMap(nextmap, sizeof(nextmap));
Format(msg, sizeof(msg), "download map:%s", nextmap);
send_socket_msg(msg, strlen(msg), ports[0]);
//this is designed for being ran from several css servers
int client_count = GetClientCount(false);
int i_port = GetConVarInt(FindConVar("hostport"));
bool bot2_connected = false;
for (int i = 1; i <= MaxClients; i++)
if (IsValidClient(i) && !IsFakeClient(i))
{
if (specific_bot_player[i] == 2) if (specific_bot_player[i] == 2)
bot2_connected = true; bot2_connected = true;
}
if (i_port == server_ports[0])
{
int max_allowed_players = 55;
char current_map[128];
GetCurrentMap(current_map, sizeof(current_map));
if (client_count > max_allowed_players)
{
Format(msg, sizeof(msg), "connect to ze2");
send_socket_msg(msg, strlen(msg), ports[1]);
}
else if (client_count < max_allowed_players - 5)
{
Format(msg, sizeof(msg), "connect to ze");
if (!bot2_connected)
send_socket_msg(msg, strlen(msg), ports[1]);
}
}
else
{
//in case the bot is not connected to ze2 anymore make sure that is_bot_connected_to_ze2 is set to false again
Format(msg, sizeof(msg), "not connected to ze2");
if (!bot2_connected)
send_socket_msg(msg, strlen(msg), ports[1]);
}
return Plugin_Continue;
}
public Action bot_check_connect3(Handle timer, any data)
{
char nextmap[64];
char msg[generic_length];
if (GetNextMap(nextmap, sizeof(nextmap)))
{
Format(msg, sizeof(msg), "download map:%s", nextmap);
send_socket_msg(msg, strlen(msg), ports[0]);
}
GetCurrentMap(nextmap, sizeof(nextmap));
Format(msg, sizeof(msg), "download map:%s", nextmap);
//this is designed for being ran from several css servers
int client_count = GetClientCount(false);
int i_port = GetConVarInt(FindConVar("hostport"));
bool bot3_connected = false;
for (int i = 1; i <= MaxClients; i++)
if (IsValidClient(i) && !IsFakeClient(i))
{
if (specific_bot_player[i] == 3) if (specific_bot_player[i] == 3)
bot3_connected = true; bot3_connected = true;
}
if (i_port == server_ports[0])
{
int max_allowed_players = 55;
char current_map[128];
GetCurrentMap(current_map, sizeof(current_map));
if (client_count > max_allowed_players)
{
Format(msg, sizeof(msg), "connect to ze2");
send_socket_msg(msg, strlen(msg), ports[2]);
}
else if (client_count < max_allowed_players - 5)
{
Format(msg, sizeof(msg), "connect to ze");
if (!bot3_connected)
send_socket_msg(msg, strlen(msg), ports[2]);
}
}
else
{
//in case the bot is not connected to ze2 anymore make sure that is_bot_connected_to_ze2 is set to false again
Format(msg, sizeof(msg), "not connected to ze2");
if (!bot3_connected)
send_socket_msg(msg, strlen(msg), ports[2]);
}
return Plugin_Continue;
}
public Action bot_check_connect4(Handle timer, any data)
{
char nextmap[64];
char msg[generic_length];
if (GetNextMap(nextmap, sizeof(nextmap)))
{
Format(msg, sizeof(msg), "download map:%s", nextmap);
send_socket_msg(msg, strlen(msg), ports[0]);
}
GetCurrentMap(nextmap, sizeof(nextmap));
Format(msg, sizeof(msg), "download map:%s", nextmap);
//this is designed for being ran from several css servers
int client_count = GetClientCount(false);
int i_port = GetConVarInt(FindConVar("hostport"));
bool bot4_connected = false;
for (int i = 1; i <= MaxClients; i++)
if (IsValidClient(i) && !IsFakeClient(i))
{
if (specific_bot_player[i] == 4) if (specific_bot_player[i] == 4)
bot4_connected = true; bot4_connected = true;
} }
@@ -327,20 +875,11 @@ public Action bot_check_connect(Handle timer, any data)
if (client_count > max_allowed_players) if (client_count > max_allowed_players)
{ {
Format(msg, sizeof(msg), "connect to ze2"); Format(msg, sizeof(msg), "connect to ze2");
send_socket_msg(msg, strlen(msg), ports[0]);
send_socket_msg(msg, strlen(msg), ports[1]);
send_socket_msg(msg, strlen(msg), ports[2]);
send_socket_msg(msg, strlen(msg), ports[3]); send_socket_msg(msg, strlen(msg), ports[3]);
} }
else if (client_count < max_allowed_players - 5) else if (client_count < max_allowed_players - 5)
{ {
Format(msg, sizeof(msg), "connect to ze"); Format(msg, sizeof(msg), "connect to ze");
if (!bot1_connected)
send_socket_msg(msg, strlen(msg), ports[0]);
if (!bot2_connected)
send_socket_msg(msg, strlen(msg), ports[1]);
if (!bot3_connected)
send_socket_msg(msg, strlen(msg), ports[2]);
if (!bot4_connected) if (!bot4_connected)
send_socket_msg(msg, strlen(msg), ports[3]); send_socket_msg(msg, strlen(msg), ports[3]);
} }
@@ -349,12 +888,6 @@ public Action bot_check_connect(Handle timer, any data)
{ {
//in case the bot is not connected to ze2 anymore make sure that is_bot_connected_to_ze2 is set to false again //in case the bot is not connected to ze2 anymore make sure that is_bot_connected_to_ze2 is set to false again
Format(msg, sizeof(msg), "not connected to ze2"); Format(msg, sizeof(msg), "not connected to ze2");
if (!bot1_connected)
send_socket_msg(msg, strlen(msg), ports[0]);
if (!bot2_connected)
send_socket_msg(msg, strlen(msg), ports[1]);
if (!bot3_connected)
send_socket_msg(msg, strlen(msg), ports[2]);
if (!bot4_connected) if (!bot4_connected)
send_socket_msg(msg, strlen(msg), ports[3]); send_socket_msg(msg, strlen(msg), ports[3]);
} }
@@ -379,7 +912,15 @@ public Action check_team(Handle timer, any data)
{ {
if (IsPlayerAlive(client)) if (IsPlayerAlive(client))
{ {
target_client[specific_bot_player[client]] = GetClosestClient_option1(client); int newTarget = GetClosestClient_option1(client);
if (newTarget != target_client[specific_bot_player[client]])
{
// only reset the walk-over/mimic state when the target
// actually changes - otherwise a bot re-targeting the
// same player every 2 seconds would keep getting yanked
// back into APPROACH state
SetBotTarget(client, newTarget);
}
} }
} }
} }
@@ -437,7 +978,7 @@ public int GetClosestClient_option1(int autism_client)
{ {
if (IsValidClient(i) && !IsFakeClient(i) && IsPlayerAlive(i) && i != autism_client) if (IsValidClient(i) && !IsFakeClient(i) && IsPlayerAlive(i) && i != autism_client)
{ {
if (GetClientIdleTime(i) > 20 || GetClientTeam(autism_client) != GetClientTeam(i)) if (GetClientIdleTime(i) > 3 || GetClientTeam(autism_client) != GetClientTeam(i) || !PM_IsPlayerSteam(i)) //not following nosteamers is a bit quality check
{ {
continue; continue;
} }
@@ -470,11 +1011,19 @@ public float get_power_distance(int target_player, float pos[3])
public void OnClientPutInServer(int client) public void OnClientPutInServer(int client)
{ {
specific_bot_player[client] = -1; specific_bot_player[client] = -1;
g_BotState[client] = BOT_STATE_IDLE;
g_LastWeaponSlot[client] = 0;
g_NextWeaponCheck[client] = 0.0; // force an immediate recheck rather than waiting out a stale timer
g_ResyncStrikes[client] = 0;
g_StuckStrikes[client] = 0;
g_WantJump[client] = false;
} }
public void OnClientPostAdminCheck(int client) public void OnClientPostAdminCheck(int client)
{ {
specific_bot_player[client] = -1; specific_bot_player[client] = -1;
g_BotState[client] = BOT_STATE_IDLE;
g_LastWeaponSlot[client] = 0;
is_autism_bot1(client); is_autism_bot1(client);
is_autism_bot2(client); is_autism_bot2(client);
is_autism_bot3(client); is_autism_bot3(client);
@@ -527,6 +1076,24 @@ stock void connect_socket()
public void OnClientDisconnect(int client) public void OnClientDisconnect(int client)
{ {
specific_bot_player[client] = -1; specific_bot_player[client] = -1;
g_BotState[client] = BOT_STATE_IDLE;
g_LastWeaponSlot[client] = 0;
// if this client was somebodys target, put that bot back to idle so it
// doesnt keep trying to read/approach a client thats gone
for (int slot = 1; slot <= 4; slot++)
{
if (target_client[slot] == client)
{
target_client[slot] = -1;
int bot = GetBotClientForSlot(slot);
if (bot != -1)
{
g_BotState[bot] = BOT_STATE_IDLE;
g_LastWeaponSlot[bot] = 0;
}
}
}
} }
//Socket callback //Socket callback
+81 -15
View File
@@ -3,21 +3,31 @@
#include <json> #include <json>
#include <ccc> #include <ccc>
#include <cstrike> #include <cstrike>
#include <unloze_playtime>
public Plugin myinfo = public Plugin myinfo =
{ {
name = "Racetimer Rank", name = "Racetimer Rank",
author = "jenz", author = "jenz",
description = "Give racetimer points as a level clan tag to players, also for chat tag", description = "Give racetimer points as a level clan tag to players, also for chat tag",
version = "1.1", version = "1.3",
url = "" url = ""
}; };
bool g_b_ignoring_tags[MAXPLAYERS + 1]; bool g_b_ignoring_tags[MAXPLAYERS + 1];
Database g_hDatabase; Database g_hDatabase;
ConVar g_Cvar_TierChatLVL;
ConVar g_Cvar_TierChatLVLMultiColor;
ConVar g_Cvar_TierChatLVLAllColor;
ConVar g_Cvar_TierClanLVL;
public void OnPluginStart() public void OnPluginStart()
{ {
g_Cvar_TierClanLVL = CreateConVar("sm_display_lvl_clan", "5", "Tier required to display your LVL as clan tag");
g_Cvar_TierChatLVL = CreateConVar("sm_display_lvl_chat", "8", "Tier required to display your LVL in chat");
g_Cvar_TierChatLVLMultiColor = CreateConVar("sm_display_lvl_chat_multi_colour", "10", "Tier required to display your LVL with multi colours");
g_Cvar_TierChatLVLAllColor = CreateConVar("sm_display_lvl_chat_all_colour", "12", "Tier required to display your LVL with all possible colour combinations");
RegConsoleCmd("sm_lvl", Command_LvTag, "Turns the Lv. tag feature on or off"); RegConsoleCmd("sm_lvl", Command_LvTag, "Turns the Lv. tag feature on or off");
if (!g_hDatabase) if (!g_hDatabase)
{ {
@@ -28,9 +38,10 @@ public void OnPluginStart()
{ {
if(IsClientInGame(client)) if(IsClientInGame(client))
{ {
OnClientPostAdminFilter(client); OnClientPostAdminCheck(client);
} }
} }
} }
public void OnMapStart() public void OnMapStart()
@@ -55,7 +66,7 @@ public void SQL_OnDatabaseConnect(Database db, const char[] error, any data)
{ {
if(IsClientInGame(client)) if(IsClientInGame(client))
{ {
OnClientPostAdminFilter(client); OnClientPostAdminCheck(client);
} }
} }
} }
@@ -66,6 +77,15 @@ public Action Command_LvTag(int client, int args)
{ {
return Plugin_Handled; return Plugin_Handled;
} }
int tier_required = GetConVarInt(g_Cvar_TierClanLVL);
int client_tier = GetPlayerTier_native(client);
if (tier_required > client_tier)
{
PrintToChat(client, "You are Tier %i and need Tier %i for using this command", client_tier, tier_required);
return Plugin_Handled;
}
//i am only adding this shit because lighty wanted it to be turn-off able, after half a year probably only 7 people will have made use of it. //i am only adding this shit because lighty wanted it to be turn-off able, after half a year probably only 7 people will have made use of it.
//kinda hate wasting time on pointless stuff like this. //kinda hate wasting time on pointless stuff like this.
char sSID[64]; char sSID[64];
@@ -107,7 +127,7 @@ public void SQL_FinishedQuery(Database db, DBResultSet results, const char[] err
else else
{ {
PrintToChat(client, "Now displaying LVL tag again."); PrintToChat(client, "Now displaying LVL tag again.");
OnClientPostAdminFilter(client); OnClientPostAdminCheck(client);
} }
} }
@@ -116,20 +136,18 @@ public void OnClientDisconnect(int client)
g_b_ignoring_tags[client] = false; g_b_ignoring_tags[client] = false;
} }
public void OnClientPostAdminFilter(int client) public void OnClientPostAdminCheck(int client)
{ {
if(!IsClientAuthorized(client) || IsClientSourceTV(client)) if(!IsClientAuthorized(client) || IsClientSourceTV(client))
return; return;
if (IsFakeClient(client)) if (IsFakeClient(client))
{ {
char tag[64];
Format(tag, sizeof(tag), "[LVL 1]");
CS_SetClientClanTag(client, tag);
return; return;
} }
g_b_ignoring_tags[client] = false; g_b_ignoring_tags[client] = false;
check_ignoring_tags(client); check_ignoring_tags(client);
} }
@@ -236,19 +254,22 @@ public int OnTransferResponse(char[] sData, int iSerial)
I_player_points = 1000; //setting level 1 I_player_points = 1000; //setting level 1
} }
if (!HasClanTag(client)) int tier_required = GetConVarInt(g_Cvar_TierClanLVL);
int client_tier = GetPlayerTier_native(client);
if (!HasClanTag(client) && client_tier >= tier_required)
{ {
char tag[64]; char tag[64];
Format(tag, sizeof(tag), "[LVL %i]", I_player_points/1000); Format(tag, sizeof(tag), "[LVL %i]", I_player_points/1000);
CS_SetClientClanTag(client, tag); CS_SetClientClanTag(client, tag);
} }
int I_rank = obj.GetInt("Rank") //if no endpoint for steamID default value is -1 tier_required = GetConVarInt(g_Cvar_TierChatLVL);
if (0 < I_rank < 1000) //only give chat tag to top 1000 client_tier = GetPlayerTier_native(client);
if (client_tier >= tier_required)
{ {
if (player_has_no_chattag(client)) if (player_has_no_chattag(client))
{ {
set_level_tag(client, I_player_points/1000, I_rank); set_level_tag(client, I_player_points/1000);
} }
} }
json_cleanup_and_delete(obj); json_cleanup_and_delete(obj);
@@ -262,7 +283,7 @@ public bool player_has_no_chattag(int client)
return strlen(tag) < 2; return strlen(tag) < 2;
} }
public void set_level_tag(int client, int lvl, int I_rank) public void set_level_tag(int client, int lvl)
{ {
char hexadecimals[16]; char hexadecimals[16];
char hexadecimals2[16]; char hexadecimals2[16];
@@ -271,7 +292,11 @@ public void set_level_tag(int client, int lvl, int I_rank)
int green = ((lvl + 50) * 13) % 256; int green = ((lvl + 50) * 13) % 256;
int blue = ((lvl + 100) * 19) % 256; int blue = ((lvl + 100) * 19) % 256;
Format(hexadecimals, sizeof(hexadecimals), "%02X%02X%02X", red, green, blue); Format(hexadecimals, sizeof(hexadecimals), "%02X%02X%02X", red, green, blue);
if (I_rank > 250)
int tier_required = GetConVarInt(g_Cvar_TierChatLVLMultiColor);
int client_tier = GetPlayerTier_native(client);
//we display LVL with one colour
if (client_tier < tier_required)
{ {
char tag[64]; char tag[64];
Format(tag, sizeof(tag), "[LVL %i] ", lvl); Format(tag, sizeof(tag), "[LVL %i] ", lvl);
@@ -279,7 +304,9 @@ public void set_level_tag(int client, int lvl, int I_rank)
CCC_SetColor(client, CCC_TagColor, StringToInt(hexadecimals, 16), false); CCC_SetColor(client, CCC_TagColor, StringToInt(hexadecimals, 16), false);
return; return;
} }
if (I_rank > 50) tier_required = GetConVarInt(g_Cvar_TierChatLVLAllColor);
//we display LVL with some of the multi colour combinations
if (client_tier < tier_required)
{ {
red = (lvl * 27) % 256; red = (lvl * 27) % 256;
green = ((lvl + 50) * 23) % 256; green = ((lvl + 50) * 23) % 256;
@@ -312,6 +339,8 @@ public void set_level_tag(int client, int lvl, int I_rank)
return; return;
} }
//we display LVL with ALL of the multi colour combinations
red = (GetRandomInt(0, 255)) % 256; red = (GetRandomInt(0, 255)) % 256;
green = (GetRandomInt(0, 255)) % 256; green = (GetRandomInt(0, 255)) % 256;
blue = (GetRandomInt(0, 255)) % 256; blue = (GetRandomInt(0, 255)) % 256;
@@ -344,3 +373,40 @@ public void set_level_tag(int client, int lvl, int I_rank)
Format(tag, sizeof(tag), "\x07%s[L\x07%sV\x07%sL %s] ", hexadecimals, hexadecimals2, hexadecimals3, coloured_numbers); Format(tag, sizeof(tag), "\x07%s[L\x07%sV\x07%sL %s] ", hexadecimals, hexadecimals2, hexadecimals3, coloured_numbers);
CCC_SetTag(client, tag); CCC_SetTag(client, tag);
} }
//just a lazy place for applying server side fix for clan tags
//needed since august 24th 2026.
public Action OnClientCommandKeyValues(int client, KeyValues kv)
{
if (client <= 0 || client > MaxClients || !IsClientInGame(client) || IsFakeClient(client))
return Plugin_Continue;
char clanTag[32];
kv.GetString("tag", clanTag, sizeof(clanTag), "");
if (clanTag[0] == '\0')
return Plugin_Continue;
DataPack dp;
CreateDataTimer(0.1, Timer_SetClanTag, dp);
dp.WriteCell(GetClientUserId(client));
dp.WriteString(clanTag);
return Plugin_Continue;
}
public Action Timer_SetClanTag(Handle timer, DataPack pack)
{
pack.Reset();
int userId = pack.ReadCell();
int client = GetClientOfUserId(userId);
char clanTag[32];
pack.ReadString(clanTag, sizeof(clanTag));
if (client && IsClientInGame(client))
{
CS_SetClientClanTag(client, clanTag);
}
return Plugin_Stop;
}
@@ -42,7 +42,7 @@ public int Native_Isspecialmap(Handle handler, int numParams)
//---------------------------------------------------------------------------------------------------- //----------------------------------------------------------------------------------------------------
public int GetCurrentStage() public int GetCurrentStage()
{ {
int iLevelCounterEnt = FindEntityByTargetname(INVALID_ENT_REFERENCE, "LevelCounter", "math_counter"); int iLevelCounterEnt = FindEntityByTargetname(INVALID_ENT_REFERENCE, "LevelCounter", "info_target");
if (iLevelCounterEnt == -1) if (iLevelCounterEnt == -1)
return 0; return 0;
int offset = FindDataMapInfo(iLevelCounterEnt, "m_OutValue"); int offset = FindDataMapInfo(iLevelCounterEnt, "m_OutValue");
+1
View File
@@ -0,0 +1 @@
pip install mysql-connector-python
+426
View File
@@ -0,0 +1,426 @@
MIGRATED_COLUMNS = [ # (map_name, stage, old_column_name)
('ze_rooftop_runaway1_v4', 1, 'ze_rooftop_runaway1_v4S1'),
('ze_boatescape6_remix_fix2', 1, 'ze_boatescape6_remix_fix2S1'),
('ze_random_v9', 1, 'ze_random_v9S1'),
('ze_ancient_wrath_v1_fix2', 1, 'ze_ancient_wrath_v1_fix2S1'),
('ze_ancient_wrath_v1_fix2', 2, 'ze_ancient_wrath_v1_fix2S2'),
('ze_death_star_escape_v4_3', 1, 'ze_death_star_escape_v4_3S1'),
('ze_castlevania_v1_3', 1, 'ze_castlevania_v1_3S1'),
('ze_biohazard2_rpd_v4e_004', 1, 'ze_biohazard2_rpd_v4e_004S1'),
('ze_thelostworld_redux_101jj', 1, 'ze_thelostworld_redux_101jjS1'),
('ze_mirrors_edge_reborn_v4_3', 1, 'ze_mirrors_edge_reborn_v4_3S1'),
('ze_surf_shonyudo_v1_1s', 1, 'ze_surf_shonyudo_v1_1sS1'),
('ze_surf_shonyudo_v1_1s', 2, 'ze_surf_shonyudo_v1_1sS2'),
('ze_surf_shonyudo_v1_1s', 3, 'ze_surf_shonyudo_v1_1sS3'),
('ze_surf_shonyudo_v1_1s', 4, 'ze_surf_shonyudo_v1_1sS4'),
('ze_surf_facility_v2_3', 1, 'ze_surf_facility_v2_3S1'),
('ze_surf_facility_v2_3', 2, 'ze_surf_facility_v2_3S2'),
('ze_surf_facility_v2_3', 3, 'ze_surf_facility_v2_3S3'),
('ze_surf_facility_v2_3', 4, 'ze_surf_facility_v2_3S4'),
('ze_surf_facility_v3_pre2', 1, 'ze_surf_facility_v3_pre2S1'),
('ze_surf_facility_v3_pre2', 2, 'ze_surf_facility_v3_pre2S2'),
('ze_surf_facility_v3_pre2', 3, 'ze_surf_facility_v3_pre2S3'),
('ze_surf_facility_v3_pre2', 4, 'ze_surf_facility_v3_pre2S4'),
('ze_surf_facility_v3_pre2', 5, 'ze_surf_facility_v3_pre2S5'),
('ze_surf_facility_v3_pre2', 6, 'ze_surf_facility_v3_pre2S6'),
('ze_surf_bona_s2', 1, 'ze_surf_bona_s2S1'),
('ze_surf_bona_s2', 2, 'ze_surf_bona_s2S2'),
('ze_surf_bona_s2', 3, 'ze_surf_bona_s2S3'),
('ze_surf_bona_s2', 4, 'ze_surf_bona_s2S4'),
('ze_surf_outside_s3', 1, 'ze_surf_outside_s3S1'),
('ze_surf_outside_s3', 2, 'ze_surf_outside_s3S2'),
('ze_surf_outside_s3', 3, 'ze_surf_outside_s3S3'),
('ze_jurassicpark_escape_v1', 1, 'ze_jurassicpark_escape_v1S1'),
('ze_elevator_escape_jbg_final', 1, 'ze_elevator_escape_jbg_finalS1'),
('ze_minecraft_v1_1a', 1, 'ze_minecraft_v1_1aS1'),
('ze_echo_boatescape_extended', 1, 'ze_echo_boatescape_extendedS1'),
('ze_potc_v3_4fix', 1, 'ze_potc_v3_4fixS1'),
('ze_potc_v3_4fix', 2, 'ze_potc_v3_4fixS2'),
('ze_potc_v3_4fix', 3, 'ze_potc_v3_4fixS3'),
('ze_potc_v3_4fix', 4, 'ze_potc_v3_4fixS4'),
('ze_potc_v3_4fix', 5, 'ze_potc_v3_4fixS5'),
('ze_licciana_escape_v2', 1, 'ze_licciana_escape_v2S1'),
('ze_30_seconds__b21', 1, 'ze_30_seconds__b21S1'),
('ze_sorrento_escape_v5', 1, 'ze_sorrento_escape_v5S1'),
('ze_lotr_mines_of_moria_v6_3testfix', 1, 'ze_lotr_mines_of_moria_v6_3testfixS1'),
('ze_pirates_port_royal_v5_4s2', 1, 'ze_pirates_port_royal_v5_4s2S1'),
('ze_lotr_mount_doom_v3', 1, 'ze_lotr_mount_doom_v3S1'),
('ze_firewall_laboratory_part1_3', 1, 'ze_firewall_laboratory_part1_3S1'),
('ze_firewall_laboratory_part1_3', 2, 'ze_firewall_laboratory_part1_3S2'),
('ze_surf_vortex_v1_9s', 1, 'ze_surf_vortex_v1_9sS1'),
('ze_surf_vortex_v1_9s', 2, 'ze_surf_vortex_v1_9sS2'),
('ze_surf_vortex_v1_9s', 3, 'ze_surf_vortex_v1_9sS3'),
('ze_surf_vortex_v1_9s', 4, 'ze_surf_vortex_v1_9sS4'),
('ze_surf_vortex_v1_9s', 5, 'ze_surf_vortex_v1_9sS5'),
('ze_surf_sahok_t5_9', 1, 'ze_surf_sahok_t5_9S1'),
('ze_surf_sahok_t5_9', 2, 'ze_surf_sahok_t5_9S2'),
('ze_rooftop_runaway2_v5', 1, 'ze_rooftop_runaway2_v5S1'),
('ze_haunted_lab_escape_v2c', 1, 'ze_haunted_lab_escape_v2cS1'),
('ze_jurassicpark_c1v4', 1, 'ze_jurassicpark_c1v4S1'),
('ze_predator_ultimate_v3', 1, 'ze_predator_ultimate_v3S1'),
('ze_predator_ultimate_v3', 2, 'ze_predator_ultimate_v3S2'),
('ze_predator_ultimate_v3', 3, 'ze_predator_ultimate_v3S3'),
('ze_rocket_escape_r1', 1, 'ze_rocket_escape_r1S1'),
('ze_trainescape_final', 1, 'ze_trainescape_finalS1'),
('ze_pirates_port_royal_v3_6', 1, 'ze_pirates_port_royal_v3_6S1'),
('ze_pirates_port_royal_v3_6', 2, 'ze_pirates_port_royal_v3_6S2'),
('ze_pirates_port_royal_v3_6', 3, 'ze_pirates_port_royal_v3_6S3'),
('ze_pirates_port_royal_v3_6', 4, 'ze_pirates_port_royal_v3_6S4'),
('ze_chicken_lords_tower_b2mix5', 1, 'ze_chicken_lords_tower_b2mix5S1'),
('ze_halo_v1_3', 1, 'ze_halo_v1_3S1'),
('ze_dangerous_waters_v1a', 1, 'ze_dangerous_waters_v1aS1'),
('ze_lotr_helms_deep_v5', 1, 'ze_lotr_helms_deep_v5S1'),
('ze_lotr_helms_deep_v5', 2, 'ze_lotr_helms_deep_v5S2'),
('ze_lotr_helms_deep_v5', 3, 'ze_lotr_helms_deep_v5S3'),
('ze_isla_nublar_v3', 1, 'ze_isla_nublar_v3S1'),
('ze_isla_nublar_v3', 2, 'ze_isla_nublar_v3S2'),
('ze_gargantua_v2_0', 1, 'ze_gargantua_v2_0S1'),
('ze_gargantua_v2_0', 2, 'ze_gargantua_v2_0S2'),
('ze_gargantua_v2_0', 3, 'ze_gargantua_v2_0S3'),
('ze_skill_escape_v09s', 1, 'ze_skill_escape_v09sS1'),
('ze_skill_escape_v09s', 2, 'ze_skill_escape_v09sS2'),
('ze_infected_tramway_v3_3', 1, 'ze_infected_tramway_v3_3S1'),
('ze_minecraft_adventure_v1_2c', 1, 'ze_minecraft_adventure_v1_2cS1'),
('ze_minecraft_adventure_v1_2c', 2, 'ze_minecraft_adventure_v1_2cS2'),
('ze_arctic_escape_c1_fix3', 1, 'ze_arctic_escape_c1_fix3S1'),
('ze_sorrento_escape_v5', 2, 'ze_sorrento_escape_v5S2'),
('ze_sorrento_escape_v5', 3, 'ze_sorrento_escape_v5S3'),
('ze_sorrento_escape_v5', 4, 'ze_sorrento_escape_v5S4'),
('ze_sorrento_escape_v5', 5, 'ze_sorrento_escape_v5S5'),
('ze_boatescape6_remix_fix2', 2, 'ze_boatescape6_remix_fix2S2'),
('ze_minecraft_adventure_v1_2c', 3, 'ze_minecraft_adventure_v1_2cS3'),
('ze_minecraft_adventure_v1_2c', 4, 'ze_minecraft_adventure_v1_2cS4'),
('ze_lotr_helms_deep_v5', 4, 'ze_lotr_helms_deep_v5S4'),
('ze_lotr_helms_deep_v5', 5, 'ze_lotr_helms_deep_v5S5'),
('ze_elevator_escape_jbg_final', 2, 'ze_elevator_escape_jbg_finalS2'),
('ze_elevator_escape_jbg_final', 3, 'ze_elevator_escape_jbg_finalS3'),
('ze_elevator_escape_jbg_final', 4, 'ze_elevator_escape_jbg_finalS4'),
('ze_skill_escape_v09s', 3, 'ze_skill_escape_v09sS3'),
('ze_skill_escape_v09s', 4, 'ze_skill_escape_v09sS4'),
('ze_pirates_port_royal_v5_4s2', 2, 'ze_pirates_port_royal_v5_4s2S2'),
('ze_isla_nublar_v3', 3, 'ze_isla_nublar_v3S3'),
('ze_rooftop_runaway2_v5', 2, 'ze_rooftop_runaway2_v5S2'),
('ze_dangerous_waters_v1a', 2, 'ze_dangerous_waters_v1aS2'),
('ze_dangerous_waters_v1a', 3, 'ze_dangerous_waters_v1aS3'),
('ze_dangerous_waters_v1a', 4, 'ze_dangerous_waters_v1aS4'),
('ze_jurassicpark_v2_10_ob', 1, 'ze_jurassicpark_v2_10_obS1'),
('ze_jurassicpark_v2_10_ob', 2, 'ze_jurassicpark_v2_10_obS2'),
('ze_jurassicpark_v2_10_ob', 3, 'ze_jurassicpark_v2_10_obS3'),
('ze_jurassicpark_v2_10_ob', 4, 'ze_jurassicpark_v2_10_obS4'),
('ze_jurassicpark_v2_10_ob', 5, 'ze_jurassicpark_v2_10_obS5'),
('ze_biohazard2_rpd_v4e_004', 2, 'ze_biohazard2_rpd_v4e_004S2'),
('ze_biohazard2_rpd_v4e_004', 3, 'ze_biohazard2_rpd_v4e_004S3'),
('ze_biohazard2_rpd_v4e_004', 4, 'ze_biohazard2_rpd_v4e_004S4'),
('ze_biohazard2_rpd_v4e_004', 5, 'ze_biohazard2_rpd_v4e_004S5'),
('ze_rooftop_runaway2_v5', 3, 'ze_rooftop_runaway2_v5S3'),
('ze_rooftop_runaway2_v5', 4, 'ze_rooftop_runaway2_v5S4'),
('ze_rooftop_runaway2_v5', 5, 'ze_rooftop_runaway2_v5S5'),
('ze_rooftop_runaway2_v5', 6, 'ze_rooftop_runaway2_v5S6'),
('ze_indiana_jones_v4a_004', 1, 'ze_indiana_jones_v4a_004S1'),
('ze_indiana_jones_v4a_004', 2, 'ze_indiana_jones_v4a_004S2'),
('ze_indiana_jones_v4a_004', 3, 'ze_indiana_jones_v4a_004S3'),
('ze_indiana_jones_v4a_004', 4, 'ze_indiana_jones_v4a_004S4'),
('ze_indiana_jones_v4a_004', 5, 'ze_indiana_jones_v4a_004S5'),
('ze_random_v9', 2, 'ze_random_v9S2'),
('ze_random_v9', 3, 'ze_random_v9S3'),
('ze_skill_escape_v09s', 5, 'ze_skill_escape_v09sS5'),
('ze_surf_sahok_t5_9', 3, 'ze_surf_sahok_t5_9S3'),
('ze_echo_boatescape_extended', 2, 'ze_echo_boatescape_extendedS2'),
('ze_echo_boatescape_extended', 3, 'ze_echo_boatescape_extendedS3'),
('ze_echo_boatescape_extended', 4, 'ze_echo_boatescape_extendedS4'),
('ze_echo_boatescape_extended', 5, 'ze_echo_boatescape_extendedS5'),
('ze_ffvii_mako_reactor_v5_3', 1, 'ze_ffvii_mako_reactor_v5_3S1'),
('ze_ffvii_mako_reactor_v5_3', 2, 'ze_ffvii_mako_reactor_v5_3S2'),
('ze_ffvii_mako_reactor_v5_3', 3, 'ze_ffvii_mako_reactor_v5_3S3'),
('ze_ffvii_mako_reactor_v5_3', 4, 'ze_ffvii_mako_reactor_v5_3S4'),
('ze_ffvii_mako_reactor_v5_3', 5, 'ze_ffvii_mako_reactor_v5_3S5'),
('ze_ffvii_mako_reactor_v5_3', 6, 'ze_ffvii_mako_reactor_v5_3S6'),
('ze_licciana_escape_v2', 2, 'ze_licciana_escape_v2S2'),
('ze_licciana_escape_v2', 3, 'ze_licciana_escape_v2S3'),
('ze_licciana_escape_v2', 4, 'ze_licciana_escape_v2S4'),
('ze_licciana_escape_v2', 5, 'ze_licciana_escape_v2S5'),
('ze_river_escape_v3_fix', 1, 'ze_river_escape_v3_fixS1'),
('ze_get_to_the_chopper_undead_6', 1, 'ze_get_to_the_chopper_undead_6S1'),
('ze_rocket_escape_r1', 2, 'ze_rocket_escape_r1S2'),
('ze_rocket_escape_r1', 3, 'ze_rocket_escape_r1S3'),
('ze_rocket_escape_r1', 4, 'ze_rocket_escape_r1S4'),
('ze_rocket_escape_r1', 5, 'ze_rocket_escape_r1S5'),
('ze_rocket_escape_r1', 6, 'ze_rocket_escape_r1S6'),
('ze_jurassicpark_escape_v1', 2, 'ze_jurassicpark_escape_v1S2'),
('ze_jurassicpark_escape_v1', 3, 'ze_jurassicpark_escape_v1S3'),
('ze_jurassicpark_escape_v1', 4, 'ze_jurassicpark_escape_v1S4'),
('ze_jurassicpark_escape_v1', 5, 'ze_jurassicpark_escape_v1S5'),
('ze_rooftop_runaway1_v4', 2, 'ze_rooftop_runaway1_v4S2'),
('ze_rooftop_runaway1_v4', 3, 'ze_rooftop_runaway1_v4S3'),
('ze_hypernova_v2fix', 1, 'ze_hypernova_v2fixS1'),
('ze_30_seconds__b21', 2, 'ze_30_seconds__b21S2'),
('ze_30_seconds__b21', 3, 'ze_30_seconds__b21S3'),
('ze_30_seconds__b21', 4, 'ze_30_seconds__b21S4'),
('ze_30_seconds__b21', 5, 'ze_30_seconds__b21S5'),
('ze_30_seconds__b21', 6, 'ze_30_seconds__b21S6'),
('ze_gargantua_v2_0', 4, 'ze_gargantua_v2_0S4'),
('ze_haunted_lab_escape_v2c', 2, 'ze_haunted_lab_escape_v2cS2'),
('ze_haunted_lab_escape_v2c', 3, 'ze_haunted_lab_escape_v2cS3'),
('ze_surf_shonyudo_v1_1s', 5, 'ze_surf_shonyudo_v1_1sS5'),
('ze_surf_shonyudo_v1_1s', 6, 'ze_surf_shonyudo_v1_1sS6'),
('ze_surf_sahok_t5_9', 4, 'ze_surf_sahok_t5_9S4'),
('ze_surf_sahok_t5_9', 5, 'ze_surf_sahok_t5_9S5'),
('ze_castlevania_v1_3', 2, 'ze_castlevania_v1_3S2'),
('ze_castlevania_v1_3', 3, 'ze_castlevania_v1_3S3'),
('ze_castlevania_v1_3', 4, 'ze_castlevania_v1_3S4'),
('ze_trainescape_final', 2, 'ze_trainescape_finalS2'),
('ze_trainescape_final', 3, 'ze_trainescape_finalS3'),
('ze_elevator_escape_jbg_final', 5, 'ze_elevator_escape_jbg_finalS5'),
('ze_trainescape_final', 4, 'ze_trainescape_finalS4'),
('ze_trainescape_final', 5, 'ze_trainescape_finalS5'),
('ze_random_v9', 5, 'ze_random_v9S5'),
('ze_random_v9', 6, 'ze_random_v9S6'),
('ze_minecraft_adventure_v1_2c', 5, 'ze_minecraft_adventure_v1_2cS5'),
('ze_lotr_mines_of_moria_v6_3testfix', 2, 'ze_lotr_mines_of_moria_v6_3testfixS2'),
('ze_hypernova_v2fix', 2, 'ze_hypernova_v2fixS2'),
('ze_hypernova_v2fix', 3, 'ze_hypernova_v2fixS3'),
('ze_hypernova_v2fix', 4, 'ze_hypernova_v2fixS4'),
('ze_hypernova_v2fix', 5, 'ze_hypernova_v2fixS5'),
('ze_hypernova_v2fix', 6, 'ze_hypernova_v2fixS6'),
('ze_surf_sahok_t5_9', 6, 'ze_surf_sahok_t5_9S6'),
('ze_pirates_port_royal_v3_6', 5, 'ze_pirates_port_royal_v3_6S5'),
('ze_minecraft_v1_1a', 2, 'ze_minecraft_v1_1aS2'),
('ze_minecraft_v1_1a', 3, 'ze_minecraft_v1_1aS3'),
('ze_lotr_mount_doom_v3', 2, 'ze_lotr_mount_doom_v3S2'),
('ze_lotr_mount_doom_v3', 3, 'ze_lotr_mount_doom_v3S3'),
('ze_lotr_mount_doom_v3', 4, 'ze_lotr_mount_doom_v3S4'),
('ze_lotr_mount_doom_v3', 5, 'ze_lotr_mount_doom_v3S5'),
('ze_lotr_mines_of_moria_v6_3testfix', 3, 'ze_lotr_mines_of_moria_v6_3testfixS3'),
('ze_rush_b_s1', 1, 'ze_rush_b_s1S1'),
('ze_death_star_escape_v4_3', 2, 'ze_death_star_escape_v4_3S2'),
('ze_death_star_escape_v4_3', 3, 'ze_death_star_escape_v4_3S3'),
('ze_death_star_escape_v4_3', 4, 'ze_death_star_escape_v4_3S4'),
('ze_death_star_escape_v4_3', 5, 'ze_death_star_escape_v4_3S5'),
('ze_jurassicpark_c1v4', 2, 'ze_jurassicpark_c1v4S2'),
('ze_jurassicpark_c1v4', 3, 'ze_jurassicpark_c1v4S3'),
('ze_rooftop_runaway1_v4', 4, 'ze_rooftop_runaway1_v4S4'),
('ze_rooftop_runaway1_v4', 5, 'ze_rooftop_runaway1_v4S5'),
('ze_arctic_escape_c1_fix3', 2, 'ze_arctic_escape_c1_fix3S2'),
('ze_arctic_escape_c1_fix3', 3, 'ze_arctic_escape_c1_fix3S3'),
('ze_arctic_escape_c1_fix3', 4, 'ze_arctic_escape_c1_fix3S4'),
('ze_arctic_escape_c1_fix3', 5, 'ze_arctic_escape_c1_fix3S5'),
('ze_minecraft_v1_1a', 4, 'ze_minecraft_v1_1aS4'),
('ze_infected_tramway_v3_3', 2, 'ze_infected_tramway_v3_3S2'),
('ze_infected_tramway_v3_3', 3, 'ze_infected_tramway_v3_3S3'),
('ze_infected_tramway_v3_3', 4, 'ze_infected_tramway_v3_3S4'),
('ze_infected_tramway_v3_3', 5, 'ze_infected_tramway_v3_3S5'),
('ze_games_v2_2a', 1, 'ze_games_v2_2aS1'),
('ze_jurassicpark_c1v4', 4, 'ze_jurassicpark_c1v4S4'),
('ze_jurassicpark_c1v4', 5, 'ze_jurassicpark_c1v4S5'),
('ze_lotr_mines_of_moria_v6_3testfix', 4, 'ze_lotr_mines_of_moria_v6_3testfixS4'),
('ze_get_to_the_chopper_undead_6', 2, 'ze_get_to_the_chopper_undead_6S2'),
('ze_get_to_the_chopper_undead_6', 3, 'ze_get_to_the_chopper_undead_6S3'),
('ze_get_to_the_chopper_undead_6', 4, 'ze_get_to_the_chopper_undead_6S4'),
('ze_get_to_the_chopper_undead_6', 5, 'ze_get_to_the_chopper_undead_6S5'),
('ze_predator_ultimate_v3', 4, 'ze_predator_ultimate_v3S4'),
('ze_surf_bona_s2', 5, 'ze_surf_bona_s2S5'),
('ze_surf_outside_s3', 4, 'ze_surf_outside_s3S4'),
('ze_isla_nublar_v3', 4, 'ze_isla_nublar_v3S4'),
('ze_surf_outside_s3', 5, 'ze_surf_outside_s3S5'),
('ze_dangerous_waters_v1a', 5, 'ze_dangerous_waters_v1aS5'),
('ze_barrage_v1', 1, 'ze_barrage_v1S1'),
('ze_sewer_escape_2012_b1', 1, 'ze_sewer_escape_2012_b1S1'),
('ze_sewer_escape_2012_b1', 2, 'ze_sewer_escape_2012_b1S2'),
('ze_motanuminc_ob', 1, 'ze_motanuminc_obS1'),
('ze_pizzatime_v4s', 1, 'ze_pizzatime_v4sS1'),
('ze_surf_saltfish_css2', 1, 'ze_surf_saltfish_css2S1'),
('ze_surf_saltfish_css2', 2, 'ze_surf_saltfish_css2S2'),
('ze_surf_saltfish_css2', 3, 'ze_surf_saltfish_css2S3'),
('ze_sorrento_xmas_v4', 1, 'ze_sorrento_xmas_v4S1'),
('ze_licciana_xmas_v1', 1, 'ze_licciana_xmas_v1S1'),
('ze_tesv_skyrim_v4fix', 1, 'ze_tesv_skyrim_v4fixS1'),
('ze_tesv_skyrim_v4fix', 2, 'ze_tesv_skyrim_v4fixS2'),
('ze_lotr_mines_of_moria_v6_3', 1, 'ze_lotr_mines_of_moria_v6_3S1'),
('ze_surf_happy_b7s2', 1, 'ze_surf_happy_b7s2S1'),
('ze_surf_happy_b7s2', 2, 'ze_surf_happy_b7s2S2'),
('ze_surf_happy_b7s2', 3, 'ze_surf_happy_b7s2S3'),
('ze_surf_happy_b7s2', 4, 'ze_surf_happy_b7s2S4'),
('ze_jurassic_park_story_v1', 1, 'ze_jurassic_park_story_v1S1'),
('ze_randomizer_c1s', 1, 'ze_randomizer_c1sS1'),
('ze_escape_horizon_c1v2', 1, 'ze_escape_horizon_c1v2S1'),
('ze_grand_boat_escape_day_v4_3', 1, 'ze_grand_boat_escape_day_v4_3S1'),
('ze_surf_facility_v2_3', 5, 'ze_surf_facility_v2_3S5'),
('ze_stargate_escape_v8', 1, 'ze_stargate_escape_v8S1'),
('ze_icecave_escape_v2', 1, 'ze_icecave_escape_v2S1'),
('ze_parkour_fix', 1, 'ze_parkour_fixS1'),
('ze_madness_v2_1', 1, 'ze_madness_v2_1S1'),
('ze_surf_shonyudo_v5_2s', 1, 'ze_surf_shonyudo_v5_2sS1'),
('ze_surf_shonyudo_v5_2s', 2, 'ze_surf_shonyudo_v5_2sS2'),
('ze_surf_shonyudo_v5_2s', 3, 'ze_surf_shonyudo_v5_2sS3'),
('ze_surf_shonyudo_v5_2s', 4, 'ze_surf_shonyudo_v5_2sS4'),
('ze_surf_shonyudo_v5_2s', 5, 'ze_surf_shonyudo_v5_2sS5'),
('ze_surf_shonyudo_v5_2s', 6, 'ze_surf_shonyudo_v5_2sS6'),
('ze_surf_shonyudo_v5_2s', 7, 'ze_surf_shonyudo_v5_2sS7'),
('ze_tesv_skyrim_v4fix', 3, 'ze_tesv_skyrim_v4fixS3'),
('ze_grand_boat_escape_v1_9', 1, 'ze_grand_boat_escape_v1_9S1'),
('ze_icecap_escape_v3', 1, 'ze_icecap_escape_v3S1'),
('ze_titanic_escape_v2_3', 1, 'ze_titanic_escape_v2_3S1'),
('ze_bathroom_v2_5s', 1, 'ze_bathroom_v2_5sS1'),
('ze_ashen_keep_v0_3', 1, 'ze_ashen_keep_v0_3S1'),
('ze_surf_froyo_css3', 1, 'ze_surf_froyo_css3S1'),
('ze_surf_froyo_css3', 2, 'ze_surf_froyo_css3S2'),
('ze_surf_froyo_css3', 3, 'ze_surf_froyo_css3S3'),
('ze_surf_froyo_css3', 4, 'ze_surf_froyo_css3S4'),
('ze_surf_froyo_css3', 5, 'ze_surf_froyo_css3S5'),
('ze_surf_froyo_css3', 6, 'ze_surf_froyo_css3S6'),
('ze_surf_froyo_css3', 7, 'ze_surf_froyo_css3S7'),
('ze_surf_froyo_css3', 8, 'ze_surf_froyo_css3S8'),
('ze_surf_froyo_css3', 9, 'ze_surf_froyo_css3S9'),
('ze_surf_froyo_css3', 10, 'ze_surf_froyo_css3S10'),
('ze_surf_froyo_css3', 11, 'ze_surf_froyo_css3S11'),
('ze_surf_froyo_css3', 12, 'ze_surf_froyo_css3S12'),
('ze_surf_gypt_v1_3s', 1, 'ze_surf_gypt_v1_3sS1'),
('ze_surf_gypt_v1_3s', 2, 'ze_surf_gypt_v1_3sS2'),
('ze_surf_gypt_v1_3s', 3, 'ze_surf_gypt_v1_3sS3'),
('ze_surf_gypt_v1_3s', 4, 'ze_surf_gypt_v1_3sS4'),
('ze_surf_gypt_v1_3s', 5, 'ze_surf_gypt_v1_3sS5'),
('ze_randomizer_c1s4', 1, 'ze_randomizer_c1s4S1'),
('ze_randomizer_c1s4', 2, 'ze_randomizer_c1s4S2'),
('ze_randomizer_c1s4', 3, 'ze_randomizer_c1s4S3'),
('ze_randomizer_c1s4', 4, 'ze_randomizer_c1s4S4'),
('ze_collab_ware_a5_8', 1, 'ze_collab_ware_a5_8S1'),
('ze_surf_gypt_v1_3s', 6, 'ze_surf_gypt_v1_3sS6'),
('ze_surf_gypt_v1_3s', 7, 'ze_surf_gypt_v1_3sS7'),
('ze_surf_facility_v2_3', 6, 'ze_surf_facility_v2_3S6'),
('ze_ancient_wrath_v2_test27', 1, 'ze_ancient_wrath_v2_test27S1'),
('ze_rush_b_s1', 2, 'ze_rush_b_s1S2'),
('ze_lotr_mount_doom_v4_2', 1, 'ze_lotr_mount_doom_v4_2S1'),
('ze_paradise_v1', 1, 'ze_paradise_v1S1'),
('ze_2012_fix2', 1, 'ze_2012_fix2S1'),
('ze_2012_fix2', 2, 'ze_2012_fix2S2'),
('ze_djinn_v1', 1, 'ze_djinn_v1S1'),
('ze_djinn_v1', 2, 'ze_djinn_v1S2'),
('ze_djinn_v1', 3, 'ze_djinn_v1S3'),
('ze_jump_king_b3', 1, 'ze_jump_king_b3S1'),
('ze_jump_king_b3', 2, 'ze_jump_king_b3S2'),
('ze_jump_king_b3', 3, 'ze_jump_king_b3S3'),
('ze_jump_king_b3', 4, 'ze_jump_king_b3S4'),
('ze_jump_king_b3', 5, 'ze_jump_king_b3S5'),
('ze_jump_king_b3', 6, 'ze_jump_king_b3S6'),
('ze_jump_king_b3', 7, 'ze_jump_king_b3S7'),
('ze_best_korea_css3', 1, 'ze_best_korea_css3S1'),
('ze_surf_cherry_blossom_v4_1', 1, 'ze_surf_cherry_blossom_v4_1S1'),
('ze_surf_cherry_blossom_v4_1', 2, 'ze_surf_cherry_blossom_v4_1S2'),
('ze_surf_cherry_blossom_v4_1', 3, 'ze_surf_cherry_blossom_v4_1S3'),
('ze_lumiverse_a3', 1, 'ze_lumiverse_a3S1'),
('ze_indiana_jones_iv_v2_4', 1, 'ze_indiana_jones_iv_v2_4S1'),
('ze_jump_king_b3', 8, 'ze_jump_king_b3S8'),
('ze_randomizer_c1s4', 5, 'ze_randomizer_c1s4S5'),
('ze_surf_daybreak_v1_1', 1, 'ze_surf_daybreak_v1_1S1'),
('ze_surf_daybreak_v1_1', 2, 'ze_surf_daybreak_v1_1S2'),
('ze_surf_daybreak_v1_1', 3, 'ze_surf_daybreak_v1_1S3'),
('ze_surf_daybreak_v1_1', 4, 'ze_surf_daybreak_v1_1S4'),
('ze_traak_b1', 1, 'ze_traak_b1S1'),
('ze_illya_b4', 1, 'ze_illya_b4S1'),
('ze_surf_sparks_v3_f3', 1, 'ze_surf_sparks_v3_f3S1'),
('ze_surf_sparks_v3_f3', 2, 'ze_surf_sparks_v3_f3S2'),
('ze_surf_sparks_v3_f3', 3, 'ze_surf_sparks_v3_f3S3'),
('ze_surf_hp_v1_2_s2', 1, 'ze_surf_hp_v1_2_s2S1'),
('ze_surf_hp_v1_2_s2', 2, 'ze_surf_hp_v1_2_s2S2'),
('ze_surf_sparks_v3_f4', 1, 'ze_surf_sparks_v3_f4S1'),
('ze_surf_sparks_v3_f4', 2, 'ze_surf_sparks_v3_f4S2'),
('ze_surf_sparks_v3_f4', 3, 'ze_surf_sparks_v3_f4S3'),
('ze_surf_hp_v1_2_s2', 3, 'ze_surf_hp_v1_2_s2S3'),
('ze_surf_hp_v1_2_s2', 4, 'ze_surf_hp_v1_2_s2S4'),
('ze_surf_hp_v1_2_s2', 5, 'ze_surf_hp_v1_2_s2S5'),
('ze_survivors_b5', 1, 'ze_survivors_b5S1'),
('ze_shaun_of_the_dead_b3', 1, 'ze_shaun_of_the_dead_b3S1'),
('ze_barrage_v1', 2, 'ze_barrage_v1S2'),
('ze_surf_sphinx_stronghold_a8', 1, 'ze_surf_sphinx_stronghold_a8S1'),
('ze_surf_sphinx_stronghold_a8', 2, 'ze_surf_sphinx_stronghold_a8S2'),
('ze_arid_lands_of_king_theba_b1_0', 1, 'ze_arid_lands_of_king_theba_b1_0S1'),
('ze_surf_sphinx_stronghold_a8', 3, 'ze_surf_sphinx_stronghold_a8S3'),
('ze_mirrors_edge_reborn_v4_3', 2, 'ze_mirrors_edge_reborn_v4_3S2'),
('ze_surf_charge_v2_f2', 1, 'ze_surf_charge_v2_f2S1'),
('ze_surf_charge_v2_f2', 2, 'ze_surf_charge_v2_f2S2'),
('ze_surf_charge_v2_f2', 3, 'ze_surf_charge_v2_f2S3'),
('ze_christmas_infection_v2_3', 1, 'ze_christmas_infection_v2_3S1'),
('ze_parkers_pit_b8', 1, 'ze_parkers_pit_b8S1'),
('ze_parkers_pit_b8', 2, 'ze_parkers_pit_b8S2'),
('ze_necromanteion_v3_1s_fix', 1, 'ze_necromanteion_v3_1s_fixS1'),
('ze_natural_css', 1, 'ze_natural_cssS1'),
('ze_ancient_wrath_v3_a', 1, 'ze_ancient_wrath_v3_aS1'),
('ze_ancient_wrath_v3_d', 1, 'ze_ancient_wrath_v3_dS1'),
('ze_stone_pit_v2_b5', 1, 'ze_stone_pit_v2_b5S1'),
('ze_a_e_s_t_h_e_t_i_c_v1_1s', 1, 'ze_a_e_s_t_h_e_t_i_c_v1_1sS1'),
('ze_super_mario_64_v2_b9', 1, 'ze_super_mario_64_v2_b9S1'),
('ze_pirates_port_royal_v5_4s2', 3, 'ze_pirates_port_royal_v5_4s2S3'),
('ze_pirates_port_royal_v5_4s2', 4, 'ze_pirates_port_royal_v5_4s2S4'),
('ze_gibletz_establishment_b6', 1, 'ze_gibletz_establishment_b6S1'),
('ze_sunlight_v2_0', 1, 'ze_sunlight_v2_0S1'),
('ze_lotr_isengard_v2_3', 1, 'ze_lotr_isengard_v2_3S1'),
('ze_ffxii_feywood_b3_1', 1, 'ze_ffxii_feywood_b3_1S1'),
('ze_ffvii_cosmo_canyon_v5fix', 1, 'ze_ffvii_cosmo_canyon_v5fixS1'),
('ze_crashbandicoot_v1fix', 1, 'ze_crashbandicoot_v1fixS1'),
('ze_crashbandicoot_v1fix', 2, 'ze_crashbandicoot_v1fixS2'),
('ze_surf_sphinx_stronghold_a8', 4, 'ze_surf_sphinx_stronghold_a8S4'),
('ze_no_title_s1', 1, 'ze_no_title_s1S1'),
('ze_necromanteion_v3_1s_fix', 2, 'ze_necromanteion_v3_1s_fixS2'),
('ze_shroomforest_v4_5', 1, 'ze_shroomforest_v4_5S1'),
('ze_shroomforest_v4_5', 2, 'ze_shroomforest_v4_5S2'),
('ze_shroomforest_v4_5', 3, 'ze_shroomforest_v4_5S3'),
('ze_indiana_jones_iv_v2_4', 2, 'ze_indiana_jones_iv_v2_4S2'),
('ze_shroomforest3_b6_2', 1, 'ze_shroomforest3_b6_2S1'),
('ze_shroomforest3_b6_2', 2, 'ze_shroomforest3_b6_2S2'),
('ze_shroomforest3_b6_2', 3, 'ze_shroomforest3_b6_2S3'),
('ze_eternal_grove_v3_css', 1, 'ze_eternal_grove_v3_cssS1'),
('ze_eternal_grove_v3_css', 2, 'ze_eternal_grove_v3_cssS2'),
('ze_bioshock_v7_1', 1, 'ze_bioshock_v7_1S1'),
('ze_immortal_flame_v1_2', 1, 'ze_immortal_flame_v1_2S1'),
('ze_silent_hill_2_illusion_kn1', 1, 'ze_silent_hill_2_illusion_kn1S1'),
('ze_bathroom_v2_5s', 2, 'ze_bathroom_v2_5sS2'),
('ze_prototype_v2', 1, 'ze_prototype_v2S1'),
('ze_dark_souls_ptd_v0_4', 1, 'ze_dark_souls_ptd_v0_4S1'),
('ze_kage_a2s', 1, 'ze_kage_a2sS1'),
('ze_ffxii_westersand_v7_2', 1, 'ze_ffxii_westersand_v7_2S1'),
('ze_ffxii_westersand_v7_2', 2, 'ze_ffxii_westersand_v7_2S2'),
('ze_ffxii_westersand_v8zeta1', 1, 'ze_ffxii_westersand_v8zeta1S1'),
('bhop_monster_jam', 1, 'bhop_monster_jamS1'),
('ze_halo_poa', 1, 'ze_halo_poaS1'),
('ze_shroomforest2_v1', 1, 'ze_shroomforest2_v1S1'),
('ze_crazy_escape_css2', 1, 'ze_crazy_escape_css2S1'),
('ze_crazy_escape_css2', 2, 'ze_crazy_escape_css2S2'),
('ze_venice_escape_b2', 1, 'ze_venice_escape_b2S1'),
('ze_icecap_escape_v5', 1, 'ze_icecap_escape_v5S1'),
('ze_moon_base_v1', 1, 'ze_moon_base_v1S1'),
('ze_sewage_processing_b7b', 1, 'ze_sewage_processing_b7bS1'),
('ze_standart_map_final_fix3', 1, 'ze_standart_map_final_fix3S1'),
('ze_fapescape_v1_2', 1, 'ze_fapescape_v1_2S1'),
('ze_totemo_roka_b5s', 1, 'ze_totemo_roka_b5sS1'),
('ze_abandoned_industry_va2s', 1, 'ze_abandoned_industry_va2sS1'),
('ze_dystopia_a4_css1', 1, 'ze_dystopia_a4_css1S1'),
('ze_dystopia_a4_css1', 2, 'ze_dystopia_a4_css1S2'),
('ze_exit_this_earths_atomosphere_c1', 1, 'ze_exit_this_earths_atomosphere_c1S1'),
('ze_exit_this_earths_atomosphere_c1', 2, 'ze_exit_this_earths_atomosphere_c1S2'),
('ze_exit_this_earths_atomosphere_c1', 3, 'ze_exit_this_earths_atomosphere_c1S3'),
('ze_aztecorum_b3', 1, 'ze_aztecorum_b3S1'),
('ze_aztecorum_b3', 2, 'ze_aztecorum_b3S2'),
('ze_a_e_s_t_h_e_t_i_c_v1_1sv', 1, 'ze_a_e_s_t_h_e_t_i_c_v1_1svS1'),
('ze_scourge_v1', 1, 'ze_scourge_v1S1'),
('ze_scourge_v1', 2, 'ze_scourge_v1S2'),
('ze_scourge_v1', 3, 'ze_scourge_v1S3'),
('ze_scourge_v1', 4, 'ze_scourge_v1S4'),
('ze_cyberia_css1', 1, 'ze_cyberia_css1S1'),
('ze_voodoo_islands_v8_5', 1, 'ze_voodoo_islands_v8_5S1'),
('ze_boring_map_alpha4', 1, 'ze_boring_map_alpha4S1'),
('ze_boring_map_alpha4', 2, 'ze_boring_map_alpha4S2'),
('ze_boring_map_alpha4', 3, 'ze_boring_map_alpha4S3'),
('ze_lotr_minas_tirith_v3_3', 1, 'ze_lotr_minas_tirith_v3_3S1'),
('ze_lotr_minas_tirith_v3_3', 2, 'ze_lotr_minas_tirith_v3_3S2'),
('ze_fapescape_v1_2', 2, 'ze_fapescape_v1_2S2'),
('ze_lotr_minas_tirith_v3_3', 3, 'ze_lotr_minas_tirith_v3_3S3'),
('ze_surf_aerial_archipelago_v1', 1, 'ze_surf_aerial_archipelago_v1S1'),
('ze_surf_aerial_archipelago_v1', 2, 'ze_surf_aerial_archipelago_v1S2'),
('ze_surf_aerial_archipelago_v1', 3, 'ze_surf_aerial_archipelago_v1S3'),
('ze_surf_aerial_archipelago_v1', 4, 'ze_surf_aerial_archipelago_v1S4'),
('ze_surf_bona_s3', 1, 'ze_surf_bona_s3S1'),
('ze_surf_bona_s3', 2, 'ze_surf_bona_s3S2'),
('ze_surf_bona_s3', 3, 'ze_surf_bona_s3S3'),
('ze_surf_bona_s3', 4, 'ze_surf_bona_s3S4'),
]
+120
View File
@@ -0,0 +1,120 @@
#!/home/nonroot/unloze_racetimer_2026_insert/venv/bin/python3
from settings import get_connection_unloze_racetimer
from columns import MIGRATED_COLUMNS
BATCH_SIZE = 2000
FIXED_CVARS_HASH = -706992435 # FNV-1a of "CLASSIC RACETIMER ze1", same value used for the zone_categories migration
def load_zone_category_lookup(conn):
"""Returns {(map_name, stage): zone_category_id} for the migrated legacy categories."""
cur = conn.cursor()
cur.execute(
"SELECT id, map_name, stage FROM unloze_racetimer_css_2026.zone_categories WHERE cvars_hash = %s",
(FIXED_CVARS_HASH,),
)
lookup = {(map_name, stage): zid for zid, map_name, stage in cur.fetchall()}
cur.close()
print(f"loaded {len(lookup)} zone_category rows for lookup")
return lookup
def flush_batch(write_conn, zone_lookup, batch, stats):
"""batch: list of (steam_auth, steam_name, map_name, stage, time_value)"""
if not batch:
return
cur = write_conn.cursor()
# --- timer_improvements: one multi-row INSERT for the whole batch ---
placeholders = ", ".join(["(%s, %s)"] * len(batch))
params = []
for steam_auth, steam_name, _map_name, _stage, _time_value in batch:
params.extend((steam_auth, steam_name))
cur.execute(
f"INSERT INTO unloze_racetimer_css_2026.timer_improvements (steam_auth, steam_name) VALUES {placeholders}",
params,
)
first_id = cur.lastrowid
if not first_id:
raise RuntimeError("lastrowid was 0/None after batch insert - aborting, correlation would be wrong")
# --- timer_records: same batch, ids computed as first_id + offset ---
records_placeholders = ", ".join(["(%s, %s, %s)"] * len(batch))
records_params = []
skipped_no_category = 0
included = 0
for i, (steam_auth, steam_name, map_name, stage, time_value) in enumerate(batch):
improvement_id = first_id + i
zone_category_id = zone_lookup.get((map_name, stage))
if zone_category_id is None:
skipped_no_category += 1
continue
records_params.extend((improvement_id, zone_category_id, time_value))
included += 1
if included:
# rebuild placeholders in case some rows were skipped for missing categories
records_placeholders = ", ".join(["(%s, %s, %s)"] * included)
cur.execute(
f"INSERT INTO unloze_racetimer_css_2026.timer_records (improvement_id, zone_category_id, time_value) VALUES {records_placeholders}",
records_params,
)
write_conn.commit()
cur.close()
stats["improvements"] += len(batch)
stats["records"] += included
stats["skipped_no_category"] += skipped_no_category
def main():
read_conn = get_connection_unloze_racetimer()
write_conn = get_connection_unloze_racetimer()
write_conn.autocommit = False
zone_lookup = load_zone_category_lookup(write_conn)
column_names = ", ".join(f"`{colname}`" for _m, _s, colname in MIGRATED_COLUMNS)
query = f"SELECT steam_auth, name, {column_names} FROM unloze_racetimer_css.zetimer_table_new"
# unbuffered cursor: streams rows instead of loading all ~137k into memory at once
read_cur = read_conn.cursor(buffered=False)
read_cur.execute(query)
stats = {"players": 0, "improvements": 0, "records": 0, "skipped_no_category": 0}
batch = []
for row in read_cur:
stats["players"] += 1
steam_auth = row[0]
steam_name = row[1]
values = row[2:]
for (map_name, stage, _colname), time_value in zip(MIGRATED_COLUMNS, values):
if time_value is None or time_value <= 0:
continue
batch.append((steam_auth, steam_name, map_name, stage, time_value))
if len(batch) >= BATCH_SIZE:
flush_batch(write_conn, zone_lookup, batch, stats)
batch = []
if stats["players"] % 5000 == 0:
print(f"...processed {stats['players']} players, {stats['improvements']} improvements so far")
flush_batch(write_conn, zone_lookup, batch, stats)
read_cur.close()
read_conn.close()
write_conn.close()
print("done.")
print(stats)
if stats["skipped_no_category"]:
print(f"WARNING: {stats['skipped_no_category']} rows had no matching zone_categories entry and were skipped - investigate before trusting the migration is complete.")
if __name__ == '__main__':
main()
+9
View File
@@ -0,0 +1,9 @@
import mysql.connector
def get_connection_unloze_racetimer():
return mysql.connector.connect(
host="127.0.0.1",
port=3306,
user="",
password="")
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
-- unloze_racetimer_css_2026.players definition
CREATE TABLE `players` (
`steam_auth` varchar(64) NOT NULL,
`name` varchar(254) NOT NULL,
`is_ignoring` tinyint(1) NOT NULL DEFAULT 0,
`first_seen` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`steam_auth`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- unloze_racetimer_css_2026.zone_categories definition
CREATE TABLE `zone_categories` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`map_name` varchar(254) NOT NULL,
`stage` int(10) unsigned NOT NULL DEFAULT 1,
`server_tag` varchar(16) NOT NULL,
`server_cvars` text NOT NULL,
`cvars_hash` int(11) NOT NULL,
`is_invalid` tinyint(1) NOT NULL DEFAULT 0,
`first_recorded_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uq_zone_cvarset` (`map_name`,`stage`,`cvars_hash`),
KEY `idx_zone_category_order` (`map_name`,`stage`,`first_recorded_at`,`id`)
) ENGINE=InnoDB AUTO_INCREMENT=447 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- unloze_racetimer_css_2026.timer_improvements definition
CREATE TABLE `timer_improvements` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`steam_auth` varchar(64) NOT NULL,
`steam_name` varchar(254) NOT NULL,
`recorded_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_player_history` (`steam_auth`,`recorded_at`),
CONSTRAINT `fk_improvement_player` FOREIGN KEY (`steam_auth`) REFERENCES `players` (`steam_auth`)
) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- unloze_racetimer_css_2026.timer_records definition
CREATE TABLE `timer_records` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`improvement_id` bigint(20) unsigned NOT NULL,
`zone_category_id` int(10) unsigned NOT NULL,
`time_value` decimal(10,3) NOT NULL,
`is_invalid` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_leaderboard` (`zone_category_id`,`time_value`),
KEY `idx_improvement` (`improvement_id`),
CONSTRAINT `fk_record_category` FOREIGN KEY (`zone_category_id`) REFERENCES `zone_categories` (`id`),
CONSTRAINT `fk_record_improvement` FOREIGN KEY (`improvement_id`) REFERENCES `timer_improvements` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=23 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+433
View File
@@ -0,0 +1,433 @@
INSERT INTO players (steam_auth, name)
SELECT steam_auth, name
FROM zetimer_table_new
WHERE steam_auth NOT IN ('STEAM_ID_STOP_IGNORING_RETVALS', 'STEAM_ID_PENDING');
INSERT INTO zone_categories (map_name, stage, server_tag, server_cvars, cvars_hash) VALUES
('ze_rooftop_runaway1_v4', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_boatescape6_remix_fix2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_random_v9', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ancient_wrath_v1_fix2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ancient_wrath_v1_fix2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_death_star_escape_v4_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_castlevania_v1_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_biohazard2_rpd_v4e_004', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_thelostworld_redux_101jj', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_mirrors_edge_reborn_v4_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_shonyudo_v1_1s', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_shonyudo_v1_1s', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_shonyudo_v1_1s', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_shonyudo_v1_1s', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_facility_v2_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_facility_v2_3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_facility_v2_3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_facility_v2_3', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_facility_v3_pre2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_facility_v3_pre2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_facility_v3_pre2', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_facility_v3_pre2', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_facility_v3_pre2', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_facility_v3_pre2', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_bona_s2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_bona_s2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_bona_s2', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_bona_s2', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_outside_s3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_outside_s3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_outside_s3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_escape_v1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_elevator_escape_jbg_final', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_minecraft_v1_1a', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_echo_boatescape_extended', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_potc_v3_4fix', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_potc_v3_4fix', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_potc_v3_4fix', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_potc_v3_4fix', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_potc_v3_4fix', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_licciana_escape_v2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_30_seconds__b21', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_sorrento_escape_v5', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_mines_of_moria_v6_3testfix', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_pirates_port_royal_v5_4s2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_mount_doom_v3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_firewall_laboratory_part1_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_firewall_laboratory_part1_3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_vortex_v1_9s', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_vortex_v1_9s', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_vortex_v1_9s', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_vortex_v1_9s', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_vortex_v1_9s', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sahok_t5_9', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sahok_t5_9', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rooftop_runaway2_v5', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_haunted_lab_escape_v2c', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_c1v4', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_predator_ultimate_v3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_predator_ultimate_v3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_predator_ultimate_v3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rocket_escape_r1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_trainescape_final', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_pirates_port_royal_v3_6', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_pirates_port_royal_v3_6', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_pirates_port_royal_v3_6', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_pirates_port_royal_v3_6', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_chicken_lords_tower_b2mix5', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_halo_v1_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_dangerous_waters_v1a', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_helms_deep_v5', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_helms_deep_v5', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_helms_deep_v5', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_isla_nublar_v3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_isla_nublar_v3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_gargantua_v2_0', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_gargantua_v2_0', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_gargantua_v2_0', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_skill_escape_v09s', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_skill_escape_v09s', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_infected_tramway_v3_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_minecraft_adventure_v1_2c', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_minecraft_adventure_v1_2c', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_arctic_escape_c1_fix3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_sorrento_escape_v5', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_sorrento_escape_v5', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_sorrento_escape_v5', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_sorrento_escape_v5', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_boatescape6_remix_fix2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_minecraft_adventure_v1_2c', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_minecraft_adventure_v1_2c', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_helms_deep_v5', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_helms_deep_v5', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_elevator_escape_jbg_final', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_elevator_escape_jbg_final', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_elevator_escape_jbg_final', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_skill_escape_v09s', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_skill_escape_v09s', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_pirates_port_royal_v5_4s2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_isla_nublar_v3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rooftop_runaway2_v5', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_dangerous_waters_v1a', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_dangerous_waters_v1a', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_dangerous_waters_v1a', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_v2_10_ob', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_v2_10_ob', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_v2_10_ob', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_v2_10_ob', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_v2_10_ob', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_biohazard2_rpd_v4e_004', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_biohazard2_rpd_v4e_004', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_biohazard2_rpd_v4e_004', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_biohazard2_rpd_v4e_004', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rooftop_runaway2_v5', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rooftop_runaway2_v5', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rooftop_runaway2_v5', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rooftop_runaway2_v5', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_indiana_jones_v4a_004', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_indiana_jones_v4a_004', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_indiana_jones_v4a_004', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_indiana_jones_v4a_004', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_indiana_jones_v4a_004', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_random_v9', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_random_v9', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_skill_escape_v09s', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sahok_t5_9', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_echo_boatescape_extended', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_echo_boatescape_extended', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_echo_boatescape_extended', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_echo_boatescape_extended', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ffvii_mako_reactor_v5_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ffvii_mako_reactor_v5_3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ffvii_mako_reactor_v5_3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ffvii_mako_reactor_v5_3', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ffvii_mako_reactor_v5_3', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ffvii_mako_reactor_v5_3', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_licciana_escape_v2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_licciana_escape_v2', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_licciana_escape_v2', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_licciana_escape_v2', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_river_escape_v3_fix', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_get_to_the_chopper_undead_6', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rocket_escape_r1', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rocket_escape_r1', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rocket_escape_r1', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rocket_escape_r1', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rocket_escape_r1', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_escape_v1', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_escape_v1', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_escape_v1', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_escape_v1', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rooftop_runaway1_v4', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rooftop_runaway1_v4', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_hypernova_v2fix', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_30_seconds__b21', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_30_seconds__b21', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_30_seconds__b21', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_30_seconds__b21', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_30_seconds__b21', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_gargantua_v2_0', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_haunted_lab_escape_v2c', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_haunted_lab_escape_v2c', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_shonyudo_v1_1s', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_shonyudo_v1_1s', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sahok_t5_9', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sahok_t5_9', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_castlevania_v1_3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_castlevania_v1_3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_castlevania_v1_3', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_trainescape_final', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_trainescape_final', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_elevator_escape_jbg_final', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_trainescape_final', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_trainescape_final', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_random_v9', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_random_v9', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_minecraft_adventure_v1_2c', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_mines_of_moria_v6_3testfix', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_hypernova_v2fix', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_hypernova_v2fix', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_hypernova_v2fix', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_hypernova_v2fix', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_hypernova_v2fix', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sahok_t5_9', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_pirates_port_royal_v3_6', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_minecraft_v1_1a', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_minecraft_v1_1a', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_mount_doom_v3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_mount_doom_v3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_mount_doom_v3', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_mount_doom_v3', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_mines_of_moria_v6_3testfix', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rush_b_s1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_death_star_escape_v4_3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_death_star_escape_v4_3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_death_star_escape_v4_3', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_death_star_escape_v4_3', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_c1v4', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_c1v4', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rooftop_runaway1_v4', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rooftop_runaway1_v4', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_arctic_escape_c1_fix3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_arctic_escape_c1_fix3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_arctic_escape_c1_fix3', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_arctic_escape_c1_fix3', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_minecraft_v1_1a', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_infected_tramway_v3_3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_infected_tramway_v3_3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_infected_tramway_v3_3', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_infected_tramway_v3_3', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_games_v2_2a', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_c1v4', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassicpark_c1v4', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_mines_of_moria_v6_3testfix', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_get_to_the_chopper_undead_6', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_get_to_the_chopper_undead_6', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_get_to_the_chopper_undead_6', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_get_to_the_chopper_undead_6', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_predator_ultimate_v3', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_bona_s2', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_outside_s3', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_isla_nublar_v3', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_outside_s3', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_dangerous_waters_v1a', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_barrage_v1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_sewer_escape_2012_b1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_sewer_escape_2012_b1', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_motanuminc_ob', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_pizzatime_v4s', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_saltfish_css2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_saltfish_css2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_saltfish_css2', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_sorrento_xmas_v4', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_licciana_xmas_v1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_tesv_skyrim_v4fix', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_tesv_skyrim_v4fix', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_mines_of_moria_v6_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_happy_b7s2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_happy_b7s2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_happy_b7s2', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_happy_b7s2', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jurassic_park_story_v1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_randomizer_c1s', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_escape_horizon_c1v2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_grand_boat_escape_day_v4_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_facility_v2_3', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_stargate_escape_v8', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_icecave_escape_v2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_parkour_fix', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_madness_v2_1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_shonyudo_v5_2s', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_shonyudo_v5_2s', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_shonyudo_v5_2s', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_shonyudo_v5_2s', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_shonyudo_v5_2s', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_shonyudo_v5_2s', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_shonyudo_v5_2s', 7, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_tesv_skyrim_v4fix', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_grand_boat_escape_v1_9', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_icecap_escape_v3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_titanic_escape_v2_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_bathroom_v2_5s', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ashen_keep_v0_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_froyo_css3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_froyo_css3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_froyo_css3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_froyo_css3', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_froyo_css3', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_froyo_css3', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_froyo_css3', 7, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_froyo_css3', 8, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_froyo_css3', 9, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_froyo_css3', 10, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_froyo_css3', 11, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_froyo_css3', 12, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_gypt_v1_3s', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_gypt_v1_3s', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_gypt_v1_3s', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_gypt_v1_3s', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_gypt_v1_3s', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_randomizer_c1s4', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_randomizer_c1s4', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_randomizer_c1s4', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_randomizer_c1s4', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_collab_ware_a5_8', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_gypt_v1_3s', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_gypt_v1_3s', 7, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_facility_v2_3', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ancient_wrath_v2_test27', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_rush_b_s1', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_mount_doom_v4_2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_paradise_v1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_2012_fix2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_2012_fix2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_djinn_v1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_djinn_v1', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_djinn_v1', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jump_king_b3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jump_king_b3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jump_king_b3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jump_king_b3', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jump_king_b3', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jump_king_b3', 6, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jump_king_b3', 7, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_best_korea_css3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_cherry_blossom_v4_1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_cherry_blossom_v4_1', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_cherry_blossom_v4_1', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lumiverse_a3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_indiana_jones_iv_v2_4', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_jump_king_b3', 8, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_randomizer_c1s4', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_daybreak_v1_1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_daybreak_v1_1', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_daybreak_v1_1', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_daybreak_v1_1', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_traak_b1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_illya_b4', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sparks_v3_f3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sparks_v3_f3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sparks_v3_f3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_hp_v1_2_s2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_hp_v1_2_s2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sparks_v3_f4', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sparks_v3_f4', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sparks_v3_f4', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_hp_v1_2_s2', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_hp_v1_2_s2', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_hp_v1_2_s2', 5, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_survivors_b5', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_shaun_of_the_dead_b3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_barrage_v1', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sphinx_stronghold_a8', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sphinx_stronghold_a8', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_arid_lands_of_king_theba_b1_0', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sphinx_stronghold_a8', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_mirrors_edge_reborn_v4_3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_charge_v2_f2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_charge_v2_f2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_charge_v2_f2', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_christmas_infection_v2_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_parkers_pit_b8', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_parkers_pit_b8', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_necromanteion_v3_1s_fix', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_natural_css', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ancient_wrath_v3_a', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ancient_wrath_v3_d', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_stone_pit_v2_b5', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_a_e_s_t_h_e_t_i_c_v1_1s', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_super_mario_64_v2_b9', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_pirates_port_royal_v5_4s2', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_pirates_port_royal_v5_4s2', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_gibletz_establishment_b6', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_sunlight_v2_0', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_isengard_v2_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ffxii_feywood_b3_1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ffvii_cosmo_canyon_v5fix', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_crashbandicoot_v1fix', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_crashbandicoot_v1fix', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_sphinx_stronghold_a8', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_no_title_s1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_necromanteion_v3_1s_fix', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_shroomforest_v4_5', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_shroomforest_v4_5', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_shroomforest_v4_5', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_indiana_jones_iv_v2_4', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_shroomforest3_b6_2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_shroomforest3_b6_2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_shroomforest3_b6_2', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_eternal_grove_v3_css', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_eternal_grove_v3_css', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_bioshock_v7_1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_immortal_flame_v1_2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_silent_hill_2_illusion_kn1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_bathroom_v2_5s', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_prototype_v2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_dark_souls_ptd_v0_4', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_kage_a2s', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ffxii_westersand_v7_2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ffxii_westersand_v7_2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_ffxii_westersand_v8zeta1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('bhop_monster_jam', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_halo_poa', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_shroomforest2_v1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_crazy_escape_css2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_crazy_escape_css2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_venice_escape_b2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_icecap_escape_v5', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_moon_base_v1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_sewage_processing_b7b', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_standart_map_final_fix3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_fapescape_v1_2', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_totemo_roka_b5s', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_abandoned_industry_va2s', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_dystopia_a4_css1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_dystopia_a4_css1', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_exit_this_earths_atomosphere_c1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_exit_this_earths_atomosphere_c1', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_exit_this_earths_atomosphere_c1', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_aztecorum_b3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_aztecorum_b3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_a_e_s_t_h_e_t_i_c_v1_1sv', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_scourge_v1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_scourge_v1', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_scourge_v1', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_scourge_v1', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_cyberia_css1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_voodoo_islands_v8_5', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_boring_map_alpha4', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_boring_map_alpha4', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_boring_map_alpha4', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_minas_tirith_v3_3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_minas_tirith_v3_3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_fapescape_v1_2', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_lotr_minas_tirith_v3_3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_aerial_archipelago_v1', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_aerial_archipelago_v1', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_aerial_archipelago_v1', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_aerial_archipelago_v1', 4, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_bona_s3', 1, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_bona_s3', 2, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_bona_s3', 3, 'ze1', 'CLASSIC RACETIMER', -706992435),
('ze_surf_bona_s3', 4, 'ze1', 'CLASSIC RACETIMER', -706992435);
@@ -0,0 +1,9 @@
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=playtime_readonly
DB_PASSWORD=changeme
DB_NAME=unloze_playtimestats
# Required for avatars to load — without it, the fallback question-mark
# image is used instead. Get a key at https://steamcommunity.com/dev/apikey
STEAM_API_KEY=
@@ -0,0 +1,17 @@
# dependencies
node_modules/
# next.js build output
.next/
out/
# env files — never commit real secrets
.env
.env.local
.env.*.local
.env.production
# misc
.DS_Store
*.pem
npm-debug.log*
@@ -0,0 +1,92 @@
# unloze playtime display
Next.js dashboard for the `unloze_playtimestats` database. Runs as a single
Node process; nginx reverse-proxies a subdomain to it.
## Stack
- Next.js 14 (App Router) + TypeScript
- Tailwind CSS
- Recharts for graphs
- `mysql2` connecting directly to MySQL (server-side only, in API routes —
the browser never sees DB credentials)
## Pages built so far
- `/` — Overview: concurrent-player population graph (hand-rolled SVG, not
a charting library — scroll to zoom, drag to pan all the way back to
2015-07-16, click any point for who was online then), a map-to-map
population bar chart (same zoom/pan, pool of 150 maps), and a "recurring
window" chart for questions like "who's online 615h every Tuesday"
(pick days of week + time window + date range)
- `/players` — search players by current/previous name or SteamID (shows
which old name matched, if any), sortable by recent activity, name, or
highest playtime
- `/players/[steamid]` — avatar, previous names, SteamID, Steam profile link,
RaceTimer rank/level + profile link, total playtime, and a session-by-session
history (with the map(s) each session overlapped)
- `/maps` — search maps by name
- `/maps/[mapname]` — every time period that map was played
- `/maps/period/[mapId]` — one specific occurrence: players present at any
point during it, votes cast (grouped by vote event, since a map could
theoretically see more than one), and prev/next map navigation
- `/countries` — every country with at least one player, sorted by player
count descending
- `/countries/[code]` — players from that country, sortable by highest
playtime or most recently played
A `favicon.ico` in `app/` is picked up automatically by Next — no config needed.
Every feature from the original planning conversation is now built.
## Deploying on the Hetzner box
```bash
npm install
npm run build
pm2 start ecosystem.config.js
pm2 save # persist across reboots
pm2 startup # follow the printed instructions once, to enable on boot
```
Then point nginx at it — see `nginx.example.conf` for a working server block
(adjust the domain and cert paths). After linking it into
`/etc/nginx/sites-enabled/`, reload nginx to pick it up.
## Redeploying
```
rm -rf node_modules .next
npm install
npm run build
pm2 restart unloze-playtime-display
```
## Notes
- `session_end_dt` is refreshed roughly every 60s by the plugin as a
heartbeat while a player is connected (not just on disconnect) — see
`lib/db.ts` usage in `/api/population`, which treats a `NULL` or
recent `session_end_dt` as "still active."
- Running on **Next.js 15**. One thing to remember when building the
`/players/[steamid]` and `/maps/[mapname]` dynamic pages next: Next 15
made the `params` (and `searchParams`) prop passed to pages/route handlers
a `Promise` you need to `await`, e.g.
`export default async function Page({ params }: { params: Promise<{ steamid: string }> }) { const { steamid } = await params; ... }`.
Doesn't affect anything currently built (no dynamic segments yet), but
will the moment those pages get added.
- SteamIDs are stored in Steam2 format (`STEAM_0:1:12345678`) to match the
existing `player_time` table. `lib/steam.ts` converts to Steam64 on the
fly for profile links/avatars — nothing needs to be stored pre-converted.
- **Timezone**: `ecosystem.config.js` pins `TZ=Europe/Berlin`, confirmed
against `SELECT @@global.time_zone, @@session.time_zone, NOW();` on the
actual server (MySQL runs `SYSTEM`, which resolves to German local time).
All the datetime parsing (`lib/dates.ts`, `/api/population`) reads MySQL
`DATETIME` strings with no timezone info attached, so Node's own local
timezone determines how they're interpreted — it must match MySQL's, or
"how many players are online right now" ends up quietly wrong (this was
a real bug, not hypothetical — it undercounted the live player count by
roughly half before this fix). If the DB server ever moves or MySQL's
timezone changes, update this value to match. After changing it,
`pm2 restart ecosystem.config.js --update-env` is required — a plain
`pm2 restart <name>` won't pick up the new env var.
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlayersPresentDuringMap } from '@/lib/queries';
import { attachAvatars } from '@/lib/steamApi';
export async function GET(req: NextRequest) {
const mapIdParam = req.nextUrl.searchParams.get('mapId');
const mapId = Number(mapIdParam);
if (!mapIdParam || !Number.isInteger(mapId)) {
return NextResponse.json({ error: 'mapId query param is required' }, { status: 400 });
}
const rows = await getPlayersPresentDuringMap(mapId);
const enriched = await attachAvatars(rows);
const players = enriched.map((p) => ({
steamid: p.steamid,
name: p.current_name,
country: p.current_country,
avatarUrl: p.avatarUrl,
}));
return NextResponse.json({ players });
}
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { getMapPopulationSeries } from '@/lib/queries';
import { formatDbDateTime } from '@/lib/timezone';
export async function GET(req: NextRequest) {
const limitParam = req.nextUrl.searchParams.get('limit');
const beforeParam = req.nextUrl.searchParams.get('before'); // ISO string — page further back for pan-to-load-more
const limit = Math.min(Math.max(Number(limitParam) || 40, 1), 200);
let before: string | undefined;
if (beforeParam) {
const d = new Date(beforeParam);
if (Number.isNaN(d.getTime())) {
return NextResponse.json({ error: 'invalid before param' }, { status: 400 });
}
before = formatDbDateTime(d);
}
const points = await getMapPopulationSeries(limit, before);
return NextResponse.json({ points });
}
@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
import { searchMapNames } from '@/lib/queries';
const PAGE_SIZE = 100;
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
const offset = Math.max(0, Number(req.nextUrl.searchParams.get('offset')) || 0);
const maps = await searchMapNames(q, PAGE_SIZE, offset);
const hasMore = maps.length === PAGE_SIZE;
return NextResponse.json({ maps, hasMore });
}
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSessions } from '@/lib/queries';
const PAGE_SIZE = 10;
export async function GET(req: NextRequest, { params }: { params: Promise<{ steamid: string }> }) {
const { steamid: rawSteamid } = await params;
const steamid = decodeURIComponent(rawSteamid);
const offset = Math.max(0, Number(req.nextUrl.searchParams.get('offset')) || 0);
const sessions = await getSessions(steamid, PAGE_SIZE, offset);
const hasMore = sessions.length === PAGE_SIZE;
return NextResponse.json({ sessions, hasMore });
}
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { attachAvatars } from '@/lib/steamApi';
interface PlayerRow {
steamid: string;
current_name: string;
current_country: string | null;
matched_name: string | null;
total_minutes: number | null;
last_connected: string | null;
}
const PAGE_SIZE = 100;
const TOTAL_MINUTES_SUBQUERY = `
(SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes)
FROM playtime_display_sessions s
WHERE s.steamid = p.steamid
ORDER BY s.session_id DESC
LIMIT 1) AS total_minutes
`;
const LAST_CONNECTED_SUBQUERY = `
(SELECT MAX(s.session_end_dt)
FROM playtime_display_sessions s
WHERE s.steamid = p.steamid) AS last_connected
`;
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
const sort = req.nextUrl.searchParams.get('sort') ?? 'recent';
const offset = Math.max(0, Number(req.nextUrl.searchParams.get('offset')) || 0);
// Tie-break on steamid too — without a fully deterministic ORDER BY,
// paging with LIMIT/OFFSET can show duplicates or skip rows whenever two
// players tie on the primary sort key (e.g. same last_seen timestamp).
const orderBy =
sort === 'playtime'
? 'total_minutes IS NULL, total_minutes DESC, p.steamid'
: sort === 'name'
? 'p.current_name, p.steamid'
: 'p.last_seen DESC, p.steamid';
let rows: PlayerRow[];
if (!q) {
rows = await query<PlayerRow>(
`SELECT p.steamid, p.current_name, p.current_country, NULL AS matched_name,
${TOTAL_MINUTES_SUBQUERY},
${LAST_CONNECTED_SUBQUERY}
FROM playtime_display_players p
ORDER BY ${orderBy}
LIMIT ? OFFSET ?`,
[PAGE_SIZE, offset],
);
} else {
const like = `%${q}%`;
rows = await query<PlayerRow>(
`SELECT p.steamid, p.current_name, p.current_country, h.player_name AS matched_name,
${TOTAL_MINUTES_SUBQUERY},
${LAST_CONNECTED_SUBQUERY}
FROM playtime_display_players p
LEFT JOIN playtime_display_name_history h
ON h.steamid = p.steamid AND h.player_name LIKE ?
WHERE p.steamid LIKE ?
OR p.current_name LIKE ?
OR EXISTS (
SELECT 1 FROM playtime_display_name_history h2
WHERE h2.steamid = p.steamid AND h2.player_name LIKE ?
)
GROUP BY p.steamid
ORDER BY ${orderBy}
LIMIT ? OFFSET ?`,
[like, like, like, like, PAGE_SIZE, offset],
);
}
const players = await attachAvatars(rows);
// A full page came back, so there might be more; a short page means we've
// hit the end. Simple and correct without needing a separate COUNT query.
const hasMore = rows.length === PAGE_SIZE;
return NextResponse.json({ players, hasMore });
}
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { getCurrentPlayers } from '@/lib/queries';
import { attachAvatars } from '@/lib/steamApi';
export async function GET() {
const rows = await getCurrentPlayers(3);
const enriched = await attachAvatars(rows);
const players = enriched.map((p) => ({
steamid: p.steamid,
name: p.current_name,
country: p.current_country,
avatarUrl: p.avatarUrl,
}));
return NextResponse.json({ players, count: players.length });
}
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlayersAtTime } from '@/lib/queries';
import { attachAvatars } from '@/lib/steamApi';
import { formatDbDateTime } from '@/lib/timezone';
export async function GET(req: NextRequest) {
const at = req.nextUrl.searchParams.get('at');
if (!at) {
return NextResponse.json({ error: 'at query param is required' }, { status: 400 });
}
const atDate = new Date(at);
if (Number.isNaN(atDate.getTime())) {
return NextResponse.json({ error: 'invalid at timestamp' }, { status: 400 });
}
const rows = await getPlayersAtTime(formatDbDateTime(atDate));
const enriched = await attachAvatars(rows);
const players = enriched.map((p) => ({
steamid: p.steamid,
name: p.current_name,
country: p.current_country,
avatarUrl: p.avatarUrl,
}));
return NextResponse.json({ players });
}
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { attachAvatars } from '@/lib/steamApi';
import { parseDbDateTime } from '@/lib/timezone';
function pad(n: number) {
return String(n).padStart(2, '0');
}
const MAX_DATES = 120; // same safety cap as the summary endpoint
const MAX_RESULTS = 200; // bound the leaderboard response size
interface SessionRow {
steamid: string;
current_name: string;
current_country: string | null;
session_start_dt: string;
session_end_dt: string | null;
}
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams;
const daysParam = sp.get('days');
const startTime = sp.get('startTime');
const endTime = sp.get('endTime');
const startDate = sp.get('start');
const endDate = sp.get('end');
if (!daysParam || !startTime || !endTime || !startDate || !endDate) {
return NextResponse.json({ error: 'days, startTime, endTime, start, end are all required' }, { status: 400 });
}
const days = daysParam.split(',').map(Number).filter((d) => d >= 0 && d <= 6);
if (days.length === 0) {
return NextResponse.json({ error: 'no valid days selected' }, { status: 400 });
}
if (!/^\d{2}:\d{2}$/.test(startTime) || !/^\d{2}:\d{2}$/.test(endTime) || startTime >= endTime) {
return NextResponse.json({ error: 'startTime must be before endTime, both as HH:MM' }, { status: 400 });
}
const [startY, startM, startD] = startDate.split('-').map(Number);
const [endY, endM, endD] = endDate.split('-').map(Number);
if (!startY || !startM || !startD || !endY || !endM || !endD) {
return NextResponse.json({ error: 'invalid start/end date' }, { status: 400 });
}
// Same UTC-anchored calendar-date enumeration as the summary endpoint —
// see that route for why this avoids timezone ambiguity.
const cursor = new Date(Date.UTC(startY, startM - 1, startD));
const endCursor = new Date(Date.UTC(endY, endM - 1, endD));
if (cursor > endCursor) {
return NextResponse.json({ error: 'start date must be before end date' }, { status: 400 });
}
const matchingDates: string[] = [];
while (cursor <= endCursor && matchingDates.length < MAX_DATES) {
if (days.includes(cursor.getUTCDay())) {
matchingDates.push(`${cursor.getUTCFullYear()}-${pad(cursor.getUTCMonth() + 1)}-${pad(cursor.getUTCDate())}`);
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
const truncated = matchingDates.length >= MAX_DATES && cursor <= endCursor;
interface PlayerAgg {
name: string;
country: string | null;
totalMinutes: number;
daysPresent: Set<string>;
}
const byPlayer = new Map<string, PlayerAgg>();
// One query per matching date, run concurrently — same pattern as the
// summary endpoint. Each date's rows are folded into the shared map
// synchronously once that date's query resolves, so despite running
// concurrently there's no race: JS's single-threaded event loop means
// each date's aggregation loop runs to completion without another date's
// callback interleaving mid-loop.
await Promise.all(
matchingDates.map(async (dateStr) => {
const windowStartStr = `${dateStr} ${startTime}:00`;
const windowEndStr = `${dateStr} ${endTime}:00`;
const windowStartMs = parseDbDateTime(windowStartStr).getTime();
const windowEndMs = parseDbDateTime(windowEndStr).getTime();
const rows = await query<SessionRow>(
`SELECT s.steamid, COALESCE(p.current_name, s.player_name) AS current_name, p.current_country,
s.session_start_dt, s.session_end_dt
FROM playtime_display_sessions s
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
WHERE s.session_start_dt < ?
AND (s.session_end_dt IS NULL OR s.session_end_dt > ?)`,
[windowEndStr, windowStartStr],
);
for (const r of rows) {
const sStart = parseDbDateTime(r.session_start_dt).getTime();
const sEnd = r.session_end_dt ? parseDbDateTime(r.session_end_dt).getTime() : Date.now();
const clippedStart = Math.max(sStart, windowStartMs);
const clippedEnd = Math.min(sEnd, windowEndMs);
if (clippedEnd <= clippedStart) continue;
const minutes = Math.round((clippedEnd - clippedStart) / 60000);
if (!byPlayer.has(r.steamid)) {
byPlayer.set(r.steamid, { name: r.current_name, country: r.current_country, totalMinutes: 0, daysPresent: new Set() });
}
const agg = byPlayer.get(r.steamid)!;
agg.totalMinutes += minutes;
agg.daysPresent.add(dateStr);
}
}),
);
const unenriched = [...byPlayer.entries()]
.map(([steamid, agg]) => ({
steamid,
name: agg.name,
country: agg.country,
totalMinutes: agg.totalMinutes,
daysPresent: agg.daysPresent.size,
}))
.sort((a, b) => b.totalMinutes - a.totalMinutes)
.slice(0, MAX_RESULTS);
const players = await attachAvatars(unenriched);
return NextResponse.json({
players,
totalMatchingDates: matchingDates.length,
truncatedDates: truncated,
truncatedPlayers: byPlayer.size > MAX_RESULTS,
});
}
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { attachAvatars } from '@/lib/steamApi';
import { parseDbDateTime } from '@/lib/timezone';
interface SessionRow {
steamid: string;
current_name: string;
current_country: string | null;
session_start_dt: string;
session_end_dt: string | null;
}
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams;
const date = sp.get('date');
const startTime = sp.get('startTime');
const endTime = sp.get('endTime');
if (!date || !startTime || !endTime) {
return NextResponse.json({ error: 'date, startTime, endTime are required' }, { status: 400 });
}
const windowStartStr = `${date} ${startTime}:00`;
const windowEndStr = `${date} ${endTime}:00`;
const windowStartMs = parseDbDateTime(windowStartStr).getTime();
const windowEndMs = parseDbDateTime(windowEndStr).getTime();
const windowMinutes = Math.round((windowEndMs - windowStartMs) / 60000);
const rows = await query<SessionRow>(
`SELECT s.steamid, COALESCE(p.current_name, s.player_name) AS current_name, p.current_country,
s.session_start_dt, s.session_end_dt
FROM playtime_display_sessions s
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
WHERE s.session_start_dt < ?
AND (s.session_end_dt IS NULL OR s.session_end_dt > ?)`,
[windowEndStr, windowStartStr],
);
// Clip each session row to the window and accumulate per-player segments —
// a player can have more than one segment if they disconnected and
// reconnected within the window.
interface Segment {
startMin: number;
endMin: number;
}
const byPlayer = new Map<
string,
{ name: string; country: string | null; segments: Segment[]; totalMinutes: number }
>();
for (const r of rows) {
const sStart = parseDbDateTime(r.session_start_dt).getTime();
const sEnd = r.session_end_dt ? parseDbDateTime(r.session_end_dt).getTime() : Date.now();
const clippedStart = Math.max(sStart, windowStartMs);
const clippedEnd = Math.min(sEnd, windowEndMs);
if (clippedEnd <= clippedStart) continue;
const startMin = Math.round((clippedStart - windowStartMs) / 60000);
const endMin = Math.round((clippedEnd - windowStartMs) / 60000);
if (!byPlayer.has(r.steamid)) {
byPlayer.set(r.steamid, { name: r.current_name, country: r.current_country, segments: [], totalMinutes: 0 });
}
const entry = byPlayer.get(r.steamid)!;
entry.segments.push({ startMin, endMin });
entry.totalMinutes += endMin - startMin;
}
const unenriched = [...byPlayer.entries()].map(([steamid, data]) => ({
steamid,
name: data.name,
country: data.country,
totalMinutes: data.totalMinutes,
segments: data.segments,
}));
const players = (await attachAvatars(unenriched)).sort((a, b) => b.totalMinutes - a.totalMinutes);
return NextResponse.json({ windowMinutes, players });
}
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
function pad(n: number) {
return String(n).padStart(2, '0');
}
const MAX_DATES = 120; // safety cap — one query per matching date
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams;
const daysParam = sp.get('days'); // comma list, JS convention: 0=Sun..6=Sat
const startTime = sp.get('startTime'); // "06:00"
const endTime = sp.get('endTime'); // "15:00"
const startDate = sp.get('start'); // "2026-07-01"
const endDate = sp.get('end'); // "2026-08-22"
if (!daysParam || !startTime || !endTime || !startDate || !endDate) {
return NextResponse.json({ error: 'days, startTime, endTime, start, end are all required' }, { status: 400 });
}
const days = daysParam.split(',').map(Number).filter((d) => d >= 0 && d <= 6);
if (days.length === 0) {
return NextResponse.json({ error: 'no valid days selected' }, { status: 400 });
}
if (!/^\d{2}:\d{2}$/.test(startTime) || !/^\d{2}:\d{2}$/.test(endTime) || startTime >= endTime) {
return NextResponse.json({ error: 'startTime must be before endTime, both as HH:MM' }, { status: 400 });
}
const [startY, startM, startD] = startDate.split('-').map(Number);
const [endY, endM, endD] = endDate.split('-').map(Number);
if (!startY || !startM || !startD || !endY || !endM || !endD) {
return NextResponse.json({ error: 'invalid start/end date' }, { status: 400 });
}
// Calendar-date arithmetic anchored to UTC purely as a way to enumerate
// plain Y-M-D dates and their day-of-week unambiguously — these Date
// objects never represent a real instant, just a calendar day.
const cursor = new Date(Date.UTC(startY, startM - 1, startD));
const endCursor = new Date(Date.UTC(endY, endM - 1, endD));
if (cursor > endCursor) {
return NextResponse.json({ error: 'start date must be before end date' }, { status: 400 });
}
const matchingDates: string[] = [];
while (cursor <= endCursor && matchingDates.length < MAX_DATES) {
if (days.includes(cursor.getUTCDay())) {
matchingDates.push(`${cursor.getUTCFullYear()}-${pad(cursor.getUTCMonth() + 1)}-${pad(cursor.getUTCDate())}`);
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
const truncated = matchingDates.length >= MAX_DATES && cursor <= endCursor;
// Each window boundary is built directly as a "YYYY-MM-DD HH:MM:SS"
// string — already in the exact format the DB stores, so no Date
// round-trip (and no timezone ambiguity) is needed for these queries.
const points = await Promise.all(
matchingDates.map(async (dateStr) => {
const windowStart = `${dateStr} ${startTime}:00`;
const windowEnd = `${dateStr} ${endTime}:00`;
const rows = await query<{ steamid: string }>(
`SELECT DISTINCT s.steamid
FROM playtime_display_sessions s
WHERE s.session_start_dt < ?
AND (s.session_end_dt IS NULL OR s.session_end_dt > ?)`,
[windowEnd, windowStart],
);
return { date: dateStr, count: rows.length };
}),
);
return NextResponse.json({ points, truncated });
}
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { parseDbDate } from '@/lib/dates';
import { formatDbDateTime } from '@/lib/timezone';
interface SessionRow {
session_start_dt: string;
session_end_dt: string | null;
}
interface MapRow {
map_id: number;
map_name: string;
map_start_dt: string;
}
// Picks a bucket size that keeps the number of points in a sane range
// (roughly 60-300 buckets) regardless of how wide the requested range is.
function pickBucketMinutes(rangeMinutes: number): number {
const target = 150;
const raw = Math.ceil(rangeMinutes / target);
const steps = [1, 5, 15, 30, 60, 120, 240, 360, 720, 1440]; // minutes
return steps.find((s) => s >= raw) ?? steps[steps.length - 1];
}
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams;
const startParam = searchParams.get('start');
const endParam = searchParams.get('end');
if (!startParam || !endParam) {
return NextResponse.json({ error: 'start and end query params are required (ISO datetimes)' }, { status: 400 });
}
const rangeStart = new Date(startParam);
const rangeEnd = new Date(endParam);
if (Number.isNaN(rangeStart.getTime()) || Number.isNaN(rangeEnd.getTime()) || rangeStart >= rangeEnd) {
return NextResponse.json({ error: 'invalid or inverted start/end range' }, { status: 400 });
}
// Convert to MySQL-native "YYYY-MM-DD HH:MM:SS" strings (in the DB's own
// timezone) rather than passing raw ISO-Z strings as query parameters —
// MySQL DATETIME columns don't reliably parse the T/Z/milliseconds ISO
// format, which was silently dropping/mismatching rows in this fetch.
const formattedEnd = formatDbDateTime(rangeEnd);
const formattedStart = formatDbDateTime(rangeStart);
// Generous safety margin so the map fetch below doesn't scan the entire
// history table as it grows over months/years — 24h is far longer than
// any realistic single map duration, so this can never miss the map that
// was actually active at rangeStart.
const formattedLookback = formatDbDateTime(new Date(rangeStart.getTime() - 24 * 60 * 60 * 1000));
const [sessionRows, mapRows] = await Promise.all([
query<SessionRow>(
`SELECT session_start_dt, session_end_dt
FROM playtime_display_sessions
WHERE session_start_dt < ?
AND (session_end_dt IS NULL OR session_end_dt > ?)`,
[formattedEnd, formattedStart],
),
// map_end_dt was dropped from the schema (redundant with, and
// occasionally out of sync with, the next row's map_start_dt). Filter
// tickrate-restart artifacts (<2min) using the gap to the next map;
// the currently-running map (no next row yet) always passes.
query<MapRow>(
`SELECT m.map_id, m.map_name, m.map_start_dt
FROM playtime_display_map_history m
WHERE m.map_start_dt < ?
AND m.map_start_dt > ?
AND (
(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > m.map_start_dt) IS NULL
OR TIMESTAMPDIFF(
MINUTE, m.map_start_dt,
(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > m.map_start_dt)
) >= 2
)
ORDER BY m.map_start_dt`,
[formattedEnd, formattedLookback],
),
]);
const sessions = sessionRows.map((r) => ({
start: parseDbDate(r.session_start_dt),
end: r.session_end_dt ? parseDbDate(r.session_end_dt) : new Date(), // still-open session heartbeat, see plugin notes
}));
const maps = mapRows.map((r) => ({
mapId: r.map_id,
mapName: r.map_name,
start: parseDbDate(r.map_start_dt),
}));
// `maps` is sorted ascending by start_dt (from the ORDER BY in the query
// above). Deliberately does NOT check each row's own end time — a map
// whose map_end_dt never got closed (e.g. OnMapEnd not firing for a
// specific transition, due to a crash/restart/admin changelevel) would
// otherwise look "still running" forever and permanently shadow every
// real map that came after it. Instead: whichever map most recently
// started by time t is what was playing then, regardless of whether its
// own end timestamp was ever reliably recorded.
function mapNameAt(t: Date): string | null {
let result: string | null = null;
for (const m of maps) {
if (m.start <= t) {
result = m.mapName;
} else {
break;
}
}
return result;
}
const rangeMinutes = (rangeEnd.getTime() - rangeStart.getTime()) / 60000;
const bucketMinutes = pickBucketMinutes(rangeMinutes);
const points: { t: string; players: number; mapName: string | null }[] = [];
for (let t = rangeStart.getTime(); t < rangeEnd.getTime(); t += bucketMinutes * 60000) {
const bucketStart = new Date(t);
const bucketEnd = new Date(t + bucketMinutes * 60000);
const count = sessions.filter((s) => s.start < bucketEnd && s.end > bucketStart).length;
points.push({ t: bucketStart.toISOString(), players: count, mapName: mapNameAt(bucketStart) });
}
// Map-change boundaries within the visible range, for drawing vertical
// separator lines on the chart — only the ones whose start actually
// falls inside [rangeStart, rangeEnd).
const mapBoundaries = maps
.filter((m) => m.start >= rangeStart && m.start < rangeEnd)
.map((m) => ({ mapId: m.mapId, mapName: m.mapName, t: m.start.toISOString() }));
return NextResponse.json({ bucketMinutes, points, mapBoundaries });
}
@@ -0,0 +1,102 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getPlayersByCountry } from '@/lib/queries';
import { attachAvatars } from '@/lib/steamApi';
import { countryCodeToFlag, countryCodeToName, formatMinutes } from '@/lib/steam';
// Direct DB query, no dynamic route data needed at build time — same
// reasoning as the /countries listing page.
export const dynamic = 'force-dynamic';
export default async function CountryPlayersPage({
params,
searchParams,
}: {
params: Promise<{ code: string }>;
searchParams: Promise<{ sort?: string }>;
}) {
const { code: rawCode } = await params;
const decoded = decodeURIComponent(rawCode);
const isUnknown = decoded.toLowerCase() === 'unknown';
const code = isUnknown ? null : decoded.toUpperCase();
const { sort: rawSort } = await searchParams;
const sort = rawSort === 'playtime' ? 'playtime' : 'recent';
const rows = await getPlayersByCountry(code, sort);
if (rows.length === 0) {
notFound();
}
const players = await attachAvatars(rows);
const linkBase = isUnknown ? '/countries/unknown' : `/countries/${encodeURIComponent(code!)}`;
return (
<div className="space-y-6">
<Link href="/countries" className="text-sm text-ink-muted hover:text-accent transition-colors">
All countries
</Link>
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl text-ink mb-1 flex items-center gap-2">
<span aria-hidden>{countryCodeToFlag(code)}</span>
{countryCodeToName(code)}
</h1>
<p className="text-sm text-ink-muted">
{players.length} player{players.length === 1 ? '' : 's'}
</p>
</div>
<div className="flex gap-2 text-sm">
<Link
href={`${linkBase}?sort=recent`}
className={`px-3 py-1.5 rounded border transition-colors ${
sort === 'recent'
? 'border-accent/50 text-accent bg-accent-dim/20'
: 'border-base-border text-ink-muted hover:text-ink'
}`}
>
Most recently played
</Link>
<Link
href={`${linkBase}?sort=playtime`}
className={`px-3 py-1.5 rounded border transition-colors ${
sort === 'playtime'
? 'border-accent/50 text-accent bg-accent-dim/20'
: 'border-base-border text-ink-muted hover:text-ink'
}`}
>
Highest playtime
</Link>
</div>
</div>
<div className="panel row-divide">
{players.map((p) => (
<Link
key={p.steamid}
href={`/players/${encodeURIComponent(p.steamid)}`}
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
>
<div className="flex items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.avatarUrl}
alt=""
className="w-9 h-9 rounded-full object-cover ring-1 ring-base-border shrink-0"
/>
<div className="text-ink text-sm">{p.current_name}</div>
</div>
<div className="flex items-center gap-4 text-xs">
<span className="text-ink-faint mono">{p.steamid}</span>
<span className="text-ink-muted mono w-16 text-right">
{formatMinutes(p.total_minutes)}
</span>
</div>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import Link from 'next/link';
import { getCountriesSummary } from '@/lib/queries';
import { countryCodeToFlag, countryCodeToName, formatMinutes } from '@/lib/steam';
// This page queries the DB directly with no dynamic route segment, so
// without this Next.js tries to statically pre-render it at BUILD time
// (requiring a live DB connection then, not just at request time).
export const dynamic = 'force-dynamic';
export default async function CountriesPage() {
const countries = await getCountriesSummary();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl text-ink mb-1">Countries</h1>
<p className="text-sm text-ink-muted">
Where the server's players connect from, most players first.
</p>
</div>
{countries.length === 0 ? (
<div className="panel p-6 text-sm text-ink-muted">No countries recorded yet.</div>
) : (
<div className="panel row-divide">
<div className="grid grid-cols-[1fr_auto_auto] gap-6 px-4 py-2 text-xs text-ink-faint uppercase tracking-wide">
<div>Country</div>
<div className="text-right w-24">Total playtime</div>
<div className="text-right w-24">Avg per player</div>
</div>
{countries.map((c) => (
<Link
key={c.current_country ?? 'unknown'}
href={`/countries/${c.current_country ? encodeURIComponent(c.current_country) : 'unknown'}`}
className="grid grid-cols-[1fr_auto_auto] gap-6 items-center px-4 py-3 hover:bg-base transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-lg shrink-0" aria-hidden>
{countryCodeToFlag(c.current_country)}
</span>
<span className="text-ink text-sm truncate">{countryCodeToName(c.current_country)}</span>
{c.current_country && (
<span className="text-ink-faint text-xs mono shrink-0">{c.current_country}</span>
)}
<span className="text-ink-muted text-xs mono shrink-0">
{c.player_count} player{c.player_count === 1 ? '' : 's'}
</span>
</div>
<div className="text-sm text-ink mono text-right w-24">{formatMinutes(c.total_minutes)}</div>
<div className="text-sm text-ink-muted mono text-right w-24">{formatMinutes(Math.round(c.avg_minutes))}</div>
</Link>
))}
</div>
)}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,56 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-base text-ink font-sans antialiased;
background-image: radial-gradient(ellipse 70% 45% at 50% -10%, rgba(63, 211, 122, 0.07), transparent 70%);
background-attachment: fixed;
}
::selection {
@apply bg-accent/30 text-ink;
}
a:focus-visible,
button:focus-visible,
input:focus-visible {
@apply outline-none ring-2 ring-accent ring-offset-2 ring-offset-base;
}
}
@layer components {
.panel {
@apply bg-base-panel border border-base-border rounded-xl;
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0) 40%);
box-shadow:
0 1px 0 0 rgba(255, 255, 255, 0.04) inset,
0 12px 32px -16px rgba(0, 0, 0, 0.65);
}
.stat-label {
@apply text-xs uppercase tracking-wider text-ink-muted font-mono;
}
.mono {
@apply font-mono text-sm;
}
/* Subtle lift + accent-tinted background on interactive rows, used
across search results, tables, and lists so it's consistent everywhere. */
.row-interactive {
@apply transition-all duration-150;
}
.row-interactive:hover {
background-color: rgba(63, 211, 122, 0.05);
}
/* Clearly visible separation between stacked list rows — a plain 1px
divide-y read as almost invisible once rows have very different
heights (e.g. a session with 15 map names next to one with 2). */
.row-divide > * + * {
border-top-width: 2px;
border-top-color: theme('colors.base.divider');
}
}
@@ -0,0 +1,54 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import './globals.css';
export const metadata: Metadata = {
title: 'unloze — server stats',
description: 'Playtime, population and map stats for the unloze server',
};
const NAV_ITEMS = [
{ href: '/', label: 'Overview' },
{ href: '/players', label: 'Players' },
{ href: '/maps', label: 'Maps' },
{ href: '/countries', label: 'Countries' },
];
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<div className="min-h-screen flex flex-col">
<header className="border-b border-base-border sticky top-0 z-10 bg-base/80 backdrop-blur-md">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent shadow-[0_0_8px_theme(colors.accent.DEFAULT)]" />
<span className="font-mono text-sm tracking-widest text-ink uppercase">
unloze playtime sessions
</span>
</Link>
<nav className="flex gap-6">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className="relative text-sm text-ink-muted hover:text-accent transition-colors group py-1"
>
{item.label}
<span className="absolute left-0 -bottom-0.5 h-px w-0 bg-accent transition-all duration-200 group-hover:w-full" />
</Link>
))}
</nav>
</div>
</header>
<main className="flex-1 max-w-6xl w-full mx-auto px-6 py-8">{children}</main>
<footer className="border-t border-base-border py-4">
<div className="max-w-6xl mx-auto px-6 text-xs text-ink-faint font-mono">
unloze playtime stats
</div>
</footer>
</div>
</body>
</html>
);
}
@@ -0,0 +1,84 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getMapPeriods } from '@/lib/queries';
import { diffMinutes } from '@/lib/dates';
import { formatMinutes } from '@/lib/steam';
export default async function MapPeriodsPage({
params,
}: {
params: Promise<{ mapname: string }>;
}) {
const { mapname: rawMapname } = await params;
const mapName = decodeURIComponent(rawMapname);
const periods = await getMapPeriods(mapName);
if (periods.length === 0) {
notFound();
}
const totalMinutes = periods.reduce((sum, p) => {
const end = p.map_end_dt ?? new Date().toISOString().slice(0, 19).replace('T', ' ');
return sum + (diffMinutes(p.map_start_dt, end) ?? 0);
}, 0);
return (
<div className="space-y-6">
<Link href="/maps" className="text-sm text-ink-muted hover:text-accent transition-colors">
Back to maps
</Link>
<div>
<h1 className="text-2xl text-ink mb-1">{mapName}</h1>
<p className="text-sm text-ink-muted">
Played {periods.length} time{periods.length === 1 ? '' : 's'} · {formatMinutes(totalMinutes)} total
</p>
</div>
<div className="panel row-divide">
<div className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-2 text-xs text-ink-faint uppercase tracking-wide">
<div>Session</div>
<div className="text-right w-16">Players</div>
<div className="text-right w-20">Duration</div>
</div>
{periods.map((p) => (
<Link
key={p.map_id}
href={`/maps/period/${p.map_id}`}
className="group grid grid-cols-[1fr_auto_auto] gap-4 items-center px-4 py-3 hover:bg-base transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
<div>
<div className="text-ink text-sm">
{new Date(p.map_start_dt.replace(' ', 'T')).toLocaleString()}
{p.map_end_dt && (
<span className="text-ink-faint">
{' '}
{new Date(p.map_end_dt.replace(' ', 'T')).toLocaleTimeString()}
</span>
)}
</div>
{!p.map_end_dt && <div className="text-xs text-accent">currently running</div>}
</div>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-ink-faint group-hover:text-accent group-hover:translate-x-0.5 transition-all shrink-0 ml-auto"
>
<path d="M9 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="mono text-ink-muted text-sm text-right w-16">{p.player_count}</div>
<div className="mono text-ink-faint text-sm text-right w-20">
{p.map_end_dt ? formatMinutes(diffMinutes(p.map_start_dt, p.map_end_dt)) : '—'}
</div>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,13 @@
import MapSearch from '@/components/MapSearch';
export default function MapsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl text-ink mb-1">Maps</h1>
<p className="text-sm text-ink-muted">Search for a map to see when it was played.</p>
</div>
<MapSearch />
</div>
);
}
@@ -0,0 +1,255 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import {
getMapPeriod,
getPlayersPresentDuringMap,
getMapVotes,
getAdjacentMaps,
} from '@/lib/queries';
import { attachAvatars } from '@/lib/steamApi';
import { diffMinutes } from '@/lib/dates';
import { formatMinutes, countryCodeToFlag } from '@/lib/steam';
export default async function MapPeriodDetailPage({
params,
}: {
params: Promise<{ mapId: string }>;
}) {
const { mapId: rawMapId } = await params;
const mapId = Number(rawMapId);
if (!Number.isInteger(mapId)) {
notFound();
}
const period = await getMapPeriod(mapId);
if (!period) {
notFound();
}
const [playersRaw, votesRaw, adjacent] = await Promise.all([
getPlayersPresentDuringMap(mapId),
getMapVotes(mapId),
getAdjacentMaps(period.map_start_dt),
]);
const [players, votes] = await Promise.all([attachAvatars(playersRaw), attachAvatars(votesRaw)]);
// Group vote rows by their vote event (a map can theoretically have more
// than one vote called during its runtime), and tally each map's total
// (weighted) votes within that event, descending. "Total votes cast" is
// computed from the same weighted sum as the tally below it, so the two
// numbers always agree — counting a weighted vote as 1 instead of its
// actual weight is exactly what caused them to disagree before.
interface VoteTallyRow {
map: string;
weightedVotes: number;
voterCount: number;
}
const voteEvents = new Map<
number,
{
vote_time: string;
result: string | null;
choices: typeof votes;
tally: VoteTallyRow[];
totalWeightedVotes: number;
}
>();
for (const v of votes) {
if (!voteEvents.has(v.vote_id)) {
voteEvents.set(v.vote_id, {
vote_time: v.vote_time,
result: v.result,
choices: [],
tally: [],
totalWeightedVotes: 0,
});
}
voteEvents.get(v.vote_id)!.choices.push(v);
}
for (const event of voteEvents.values()) {
const byMap = new Map<string, VoteTallyRow>();
for (const c of event.choices) {
const row = byMap.get(c.vote_choice) ?? { map: c.vote_choice, weightedVotes: 0, voterCount: 0 };
row.weightedVotes += c.vote_weight;
row.voterCount += 1;
byMap.set(c.vote_choice, row);
}
event.tally = [...byMap.values()].sort((a, b) => b.weightedVotes - a.weightedVotes);
event.totalWeightedVotes = event.tally.reduce((sum, row) => sum + row.weightedVotes, 0);
}
return (
<div className="space-y-6">
<Link
href={`/maps/${encodeURIComponent(period.map_name)}`}
className="text-sm text-ink-muted hover:text-accent transition-colors"
>
All plays of {period.map_name}
</Link>
{/* Header with prev/next navigation */}
<div className="panel p-6">
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
{adjacent.prev ? (
<Link
href={`/maps/period/${adjacent.prev.map_id}`}
className="text-sm text-ink-muted hover:text-accent transition-colors"
>
{adjacent.prev.map_name}
</Link>
) : (
<span className="text-sm text-ink-faint">start of history</span>
)}
</div>
<div className="text-center">
<h1 className="text-2xl text-ink">{period.map_name}</h1>
<div className="text-xs text-ink-faint mt-1">
{new Date(period.map_start_dt.replace(' ', 'T')).toLocaleString()}
{' — '}
{period.map_end_dt
? new Date(period.map_end_dt.replace(' ', 'T')).toLocaleTimeString()
: 'still running'}
{period.map_end_dt && (
<span className="ml-2">
({formatMinutes(diffMinutes(period.map_start_dt, period.map_end_dt))})
</span>
)}
</div>
</div>
<div className="flex-1 text-right">
{adjacent.next ? (
<Link
href={`/maps/period/${adjacent.next.map_id}`}
className="text-sm text-ink-muted hover:text-accent transition-colors"
>
{adjacent.next.map_name}
</Link>
) : (
<span className="text-sm text-ink-faint">end of history</span>
)}
</div>
</div>
</div>
{/* Players present + Map votes, side by side */}
<div className="grid md:grid-cols-2 gap-6">
<div className="panel p-6">
<div className="stat-label mb-3">
Connected during this session ({players.length})
</div>
{players.length === 0 ? (
<div className="text-sm text-ink-muted">No recorded players during this period.</div>
) : (
<div className="row-divide">
{players.map((p, i) => (
<Link
key={`${p.steamid}-${i}`}
href={`/players/${encodeURIComponent(p.steamid)}`}
className="flex items-center justify-between py-2 text-sm hover:bg-base transition-colors px-2 -mx-2 rounded"
>
<div className="flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.avatarUrl}
alt=""
className="w-7 h-7 rounded-full object-cover ring-1 ring-base-border shrink-0"
/>
<span aria-hidden>{countryCodeToFlag(p.current_country)}</span>
<span className="text-ink">{p.current_name}</span>
</div>
<div className="text-ink-faint text-xs mono">
{new Date(p.session_start_dt.replace(' ', 'T')).toLocaleTimeString()}
{' '}
{p.session_end_dt
? new Date(p.session_end_dt.replace(' ', 'T')).toLocaleTimeString()
: 'now'}
</div>
</Link>
))}
</div>
)}
</div>
<div className="panel p-6">
<div className="stat-label mb-3">Map votes</div>
{voteEvents.size === 0 ? (
<div className="text-sm text-ink-muted">No recorded vote during this period.</div>
) : (
<div className="space-y-6">
{[...voteEvents.entries()].map(([voteId, event]) => (
<div key={voteId}>
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-ink-faint mono">
{new Date(event.vote_time.replace(' ', 'T')).toLocaleString()}
{' · '}
{event.totalWeightedVotes} vote{event.totalWeightedVotes === 1 ? '' : 's'} cast
{event.totalWeightedVotes !== event.choices.length && (
<span className="text-ink-faint"> ({event.choices.length} voter{event.choices.length === 1 ? '' : 's'})</span>
)}
</div>
<div className="text-sm text-accent">Result: {event.result ?? '—'}</div>
</div>
{/* Per-map tally, highest first */}
<div className="space-y-1 mb-4">
{event.tally.map((t) => {
const pct = event.totalWeightedVotes > 0 ? (t.weightedVotes / event.tally[0].weightedVotes) * 100 : 0;
return (
<div key={t.map} className="flex items-center gap-2 text-xs">
<span className="text-ink w-40 truncate shrink-0">{t.map}</span>
<div className="flex-1 h-3 bg-base rounded overflow-hidden border border-base-border">
<div
className="h-full rounded-sm"
style={{
width: `${Math.max(pct, 3)}%`,
background: 'linear-gradient(90deg, #2A9C5C, #5EE596)',
}}
/>
</div>
<span className="text-ink-muted mono w-20 text-right shrink-0">
{t.weightedVotes} vote{t.weightedVotes === 1 ? '' : 's'}
</span>
</div>
);
})}
</div>
<div className="row-divide">
{event.choices.map((c, i) => (
<Link
key={`${c.steamid}-${i}`}
href={`/players/${encodeURIComponent(c.steamid)}`}
className="flex items-center justify-between py-2 text-sm hover:bg-base transition-colors px-2 -mx-2 rounded"
>
<div className="flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={c.avatarUrl}
alt=""
className="w-7 h-7 rounded-full object-cover ring-1 ring-base-border shrink-0"
/>
<span aria-hidden>{countryCodeToFlag(c.current_country)}</span>
<span className="text-ink">{c.current_name}</span>
</div>
<span className="text-ink-muted">
voted <span className="text-ink">{c.vote_choice}</span>
{c.vote_weight !== 1 && (
<span className="text-ink-faint"> (weight {c.vote_weight})</span>
)}
</span>
</Link>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
import PopulationChart from '@/components/PopulationChart';
import MapPopulationChart from '@/components/MapPopulationChart';
import RecurringWindowChart from '@/components/RecurringWindowChart';
export default function OverviewPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl text-ink mb-1">Overview</h1>
<p className="text-sm text-ink-muted">Population and activity across the server.</p>
</div>
<PopulationChart />
<MapPopulationChart />
<RecurringWindowChart />
</div>
);
}
@@ -0,0 +1,133 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { getPlayer, getNameHistory, getTotalPlaytimeMinutes } from '@/lib/queries';
import { steam2ToSteam64, steamProfileUrl, formatMinutes, countryCodeToFlag } from '@/lib/steam';
import { getSteamSummary, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
import { getRaceTimerSummary } from '@/lib/racetimer';
import PlayerSessionsList from '@/components/PlayerSessionsList';
export default async function PlayerDetailPage({
params,
}: {
params: Promise<{ steamid: string }>;
}) {
const { steamid: rawSteamid } = await params;
const steamid = decodeURIComponent(rawSteamid);
const player = await getPlayer(steamid);
if (!player) {
notFound();
}
const steamId64 = steam2ToSteam64(steamid);
const profileUrl = steamProfileUrl(steamid);
// Sessions are deliberately NOT fetched here — that query's per-row
// maps_played subquery gets slower as a player accumulates history, and
// was making this page take 20-30+ seconds for long-tenured players
// regardless of whether anyone scrolled far enough to see old sessions.
// PlayerSessionsList below fetches its own first page client-side, so
// everything else on this page renders immediately.
const [nameHistory, totalMinutes, steamSummary, raceTimer] = await Promise.all([
getNameHistory(steamid),
getTotalPlaytimeMinutes(steamid),
steamId64 ? getSteamSummary(steamId64) : Promise.resolve(null),
getRaceTimerSummary(steamid),
]);
const avatarUrl = steamSummary?.avatarUrl ?? DEFAULT_AVATAR_URL;
return (
<div className="space-y-8">
<Link href="/players" className="text-sm text-ink-muted hover:text-accent transition-colors">
Back to players
</Link>
{/* Header: avatar, name, steamid, profile link, total playtime */}
<div className="panel p-6 flex flex-wrap items-center gap-6">
<div className="w-20 h-20 rounded-lg overflow-hidden bg-base border border-base-border shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={avatarUrl} alt="" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-[200px]">
<div className="flex items-center gap-2">
<h1 className="text-2xl text-ink">{player.current_name}</h1>
<span className="text-lg" aria-hidden>
{countryCodeToFlag(player.current_country)}
</span>
</div>
<div className="mono text-ink-faint mt-1">{steamid}</div>
<div className="flex items-center gap-3 flex-wrap">
{profileUrl && (
<a
href={profileUrl}
target="_blank"
rel="noreferrer"
className="text-sm text-accent hover:underline"
>
View Steam profile
</a>
)}
{raceTimer && (
<a
href={raceTimer.profileUrl}
target="_blank"
rel="noreferrer"
className="text-sm text-accent hover:underline"
>
View RaceTimer profile
</a>
)}
</div>
</div>
<div className="text-right">
<div className="stat-label">Total playtime</div>
<div className="text-2xl text-ink mono">
{totalMinutes != null ? formatMinutes(totalMinutes) : '—'}
</div>
</div>
{raceTimer && (
<div className="text-right border-l border-base-border pl-6">
<div className="stat-label">RaceTimer</div>
<div className="text-2xl text-ink mono">Lv {raceTimer.level}</div>
<div className="text-xs text-ink-faint mono">Rank #{raceTimer.rank}</div>
</div>
)}
<div className="text-right border-l border-base-border pl-6">
<div className="stat-label">Player Tier</div>
<div className="text-2xl text-ink mono">{player.player_tier}</div>
</div>
</div>
{/* Previous names */}
{nameHistory.length > 1 && (
<div className="panel p-6">
<div className="stat-label mb-3">Previous names</div>
<div className="flex flex-wrap gap-2">
{nameHistory
.filter((n) => n.player_name !== player.current_name)
.map((n) => (
<span
key={n.player_name}
className="text-sm bg-base border border-base-border rounded px-3 py-1 text-ink-muted"
title={`Last seen ${new Date(n.last_seen.replace(' ', 'T')).toLocaleString()}`}
>
{n.player_name}
</span>
))}
</div>
</div>
)}
{/* Sessions */}
<div className="panel p-6">
<div className="stat-label mb-3">Play sessions</div>
<PlayerSessionsList steamid={steamid} />
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import PlayerSearch from '@/components/PlayerSearch';
export default function PlayersPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl text-ink mb-1">Players</h1>
<p className="text-sm text-ink-muted">
Search by current or previous name, or SteamID.
</p>
</div>
<PlayerSearch />
</div>
);
}
@@ -0,0 +1,63 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { formatMinutes } from '@/lib/steam';
interface CountryPlayer {
steamid: string;
current_name: string;
last_seen: string;
total_minutes: number | null;
}
export default function CountryPlayerList({ code }: { code: string }) {
const [sort, setSort] = useState<'playtime' | 'recent'>('playtime');
const [players, setPlayers] = useState<CountryPlayer[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/countries/${code}?sort=${sort}`)
.then((res) => res.json())
.then((data) => setPlayers(data.players))
.finally(() => setLoading(false));
}, [code, sort]);
return (
<div className="space-y-4">
<div className="flex justify-end">
<select
value={sort}
onChange={(e) => setSort(e.target.value as typeof sort)}
className="bg-base-panel border border-base-border rounded-lg px-3 py-2 text-sm text-ink-muted focus:border-accent/50 transition-colors"
>
<option value="playtime">Highest playtime</option>
<option value="recent">Most recently played</option>
</select>
</div>
<div className="panel divide-y divide-base-border">
{loading ? (
<div className="p-4 text-sm text-ink-faint font-mono">loading</div>
) : players.length === 0 ? (
<div className="p-4 text-sm text-ink-muted">No players found.</div>
) : (
players.map((p) => (
<Link
key={p.steamid}
href={`/players/${encodeURIComponent(p.steamid)}`}
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
>
<div className="text-ink text-sm">{p.current_name}</div>
<div className="flex items-center gap-4 text-xs text-ink-faint">
<span className="mono">{formatMinutes(p.total_minutes)}</span>
<span>{new Date(p.last_seen.replace(' ', 'T')).toLocaleDateString()}</span>
</div>
</Link>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,390 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useContainerWidth } from '@/lib/useContainerWidth';
import PlayerChipList from './PlayerChipList';
interface MapPoint {
map_id: number;
map_name: string;
map_start_dt: string;
map_end_dt: string | null;
total_players: number;
}
interface PlayerChip {
steamid: string;
name: string;
avatarUrl: string;
country?: string | null;
}
const HEIGHT = 288;
const PAD_LEFT = 32;
const PAD_RIGHT = 8;
const PAD_TOP = 8;
const PAD_BOTTOM = 12; // no map-name labels anymore, so far less bottom padding needed
const INITIAL_FETCH = 150;
const PAGE_FETCH = 100; // fetched when panning back beyond what's loaded
const DEFAULT_VISIBLE = 40;
const MIN_VISIBLE = 5;
const HARD_FLOOR_EPOCH = Date.UTC(2015, 6, 16); // won't page back further than July 16, 2015
function parseDt(s: string): number {
return new Date(s.replace(' ', 'T')).getTime();
}
export default function MapPopulationChart() {
const { ref: containerRef, width } = useContainerWidth<HTMLDivElement>();
const svgRef = useRef<SVGSVGElement>(null);
const [points, setPoints] = useState<MapPoint[]>([]);
const [loading, setLoading] = useState(true);
const [fetchingMore, setFetchingMore] = useState(false);
const [reachedStart, setReachedStart] = useState(false);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const [viewStartIdx, setViewStartIdx] = useState(0);
const [viewEndIdx, setViewEndIdx] = useState(0);
const dragStartRef = useRef<{ x: number; startIdx: number; endIdx: number } | null>(null);
const dragMovedRef = useRef(false);
const fetchInFlightRef = useRef(false);
const [selectedMap, setSelectedMap] = useState<MapPoint | null>(null);
const [selectedPlayers, setSelectedPlayers] = useState<PlayerChip[]>([]);
const [loadingPlayers, setLoadingPlayers] = useState(false);
useEffect(() => {
fetch(`/api/map-population?limit=${INITIAL_FETCH}`)
.then((res) => res.json())
.then((data) => {
const pts: MapPoint[] = data.points ?? [];
setPoints(pts);
const startIdx = Math.max(0, pts.length - DEFAULT_VISIBLE);
setViewStartIdx(startIdx);
setViewEndIdx(pts.length);
const latest = pts[pts.length - 1];
if (latest) loadPlayersFor(latest);
})
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function loadPlayersFor(point: MapPoint) {
setSelectedMap(point);
setLoadingPlayers(true);
try {
const res = await fetch(`/api/map-population/players?mapId=${point.map_id}`);
const data = await res.json();
setSelectedPlayers(data.players ?? []);
} catch {
setSelectedPlayers([]);
} finally {
setLoadingPlayers(false);
}
}
// If panning/zooming brings the view near index 0, fetch more (older)
// maps and prepend them, shifting indices to keep the visual window
// stable. Stops once the earliest loaded map is at/before the hard floor.
function ensureMoreIfNeeded(nextStartIdx: number) {
if (fetchInFlightRef.current || reachedStart) return;
if (nextStartIdx > 5) return; // only bother once we're close to the loaded edge
const earliest = points[0];
if (!earliest) return;
if (parseDt(earliest.map_start_dt) <= HARD_FLOOR_EPOCH) {
setReachedStart(true);
return;
}
fetchInFlightRef.current = true;
setFetchingMore(true);
fetch(`/api/map-population?limit=${PAGE_FETCH}&before=${encodeURIComponent(new Date(parseDt(earliest.map_start_dt)).toISOString())}`)
.then((res) => res.json())
.then((data) => {
const older: MapPoint[] = data.points ?? [];
if (older.length === 0) {
setReachedStart(true);
return;
}
setPoints((prev) => [...older, ...prev]);
setViewStartIdx((v) => v + older.length);
setViewEndIdx((v) => v + older.length);
})
.finally(() => {
fetchInFlightRef.current = false;
setFetchingMore(false);
});
}
const visiblePoints = points.slice(viewStartIdx, viewEndIdx);
const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT);
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
const maxValue = Math.max(1, ...visiblePoints.map((p) => p.total_players));
const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5;
const slotWidth = visiblePoints.length > 0 ? plotWidth / visiblePoints.length : 0;
const barWidth = Math.max(2, slotWidth * 0.6);
function barX(i: number) {
return PAD_LEFT + i * slotWidth + (slotWidth - barWidth) / 2;
}
function barY(value: number) {
const h = (value / axisMax) * plotHeight;
return PAD_TOP + (plotHeight - h);
}
function barHeight(value: number) {
return Math.max(value > 0 ? 2 : 0, (value / axisMax) * plotHeight);
}
function indexForX(x: number): number {
if (slotWidth === 0) return 0;
return Math.max(0, Math.min(visiblePoints.length - 1, Math.floor((x - PAD_LEFT) / slotWidth)));
}
function handleMouseDown(e: React.MouseEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect();
dragStartRef.current = { x: e.clientX - rect.left, startIdx: viewStartIdx, endIdx: viewEndIdx };
dragMovedRef.current = false;
}
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
if (dragStartRef.current) {
const dx = x - dragStartRef.current.x;
if (Math.abs(dx) > 3) dragMovedRef.current = true;
if (dragMovedRef.current && slotWidth > 0) {
const shiftBars = -Math.round(dx / slotWidth);
const windowSize = dragStartRef.current.endIdx - dragStartRef.current.startIdx;
let newStart = dragStartRef.current.startIdx + shiftBars;
let newEnd = dragStartRef.current.endIdx + shiftBars;
if (newStart < 0) {
newStart = 0;
newEnd = windowSize;
}
if (newEnd > points.length) {
newEnd = points.length;
newStart = newEnd - windowSize;
}
setViewStartIdx(newStart);
setViewEndIdx(newEnd);
ensureMoreIfNeeded(newStart);
}
} else {
setHoverIndex(indexForX(x));
}
}
function handleMouseUp() {
dragStartRef.current = null;
}
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
function onWheel(e: WheelEvent) {
e.preventDefault();
if (points.length === 0) return;
const rect = svg!.getBoundingClientRect();
const x = e.clientX - rect.left;
const anchorIdxInView = indexForX(x);
const anchorAbsIdx = viewStartIdx + anchorIdxInView;
const currentSize = viewEndIdx - viewStartIdx;
const zoomFactor = e.deltaY > 0 ? 1.2 : 1 / 1.2;
let newSize = Math.round(currentSize * zoomFactor);
newSize = Math.max(MIN_VISIBLE, Math.min(points.length, newSize));
const ratio = currentSize > 0 ? (anchorAbsIdx - viewStartIdx) / currentSize : 0.5;
let newStart = Math.round(anchorAbsIdx - ratio * newSize);
let newEnd = newStart + newSize;
if (newStart < 0) {
newStart = 0;
newEnd = newSize;
}
if (newEnd > points.length) {
newEnd = points.length;
newStart = newEnd - newSize;
}
setViewStartIdx(newStart);
setViewEndIdx(newEnd);
ensureMoreIfNeeded(newStart);
}
svg.addEventListener('wheel', onWheel, { passive: false });
return () => svg.removeEventListener('wheel', onWheel);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [points.length, viewStartIdx, viewEndIdx]);
function resetZoom() {
const startIdx = Math.max(0, points.length - DEFAULT_VISIBLE);
setViewStartIdx(startIdx);
setViewEndIdx(points.length);
}
const isZoomed = viewStartIdx > Math.max(0, points.length - DEFAULT_VISIBLE) || viewEndIdx < points.length;
const yTicks = [0, Math.round(axisMax / 2), axisMax];
return (
<div className="panel p-6">
<div className="mb-6">
<div className="stat-label mb-1">Population by map</div>
<h2 className="text-lg text-ink">
{visiblePoints.length} of {points.length}
{reachedStart ? '' : '+'} maps
</h2>
</div>
<div className="flex items-center justify-between gap-4 mb-2">
<div className="flex items-center gap-1.5 text-xs text-accent">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
<circle cx="12" cy="12" r="9" />
</svg>
Scroll to zoom, drag to pan (all the way back to 2015), click a bar for who was online
{fetchingMore && <span className="text-ink-faint"> loading more</span>}
</div>
{isZoomed && (
<button onClick={resetZoom} className="text-xs text-ink-muted hover:text-accent transition-colors underline">
Reset zoom
</button>
)}
</div>
<div ref={containerRef} className="h-72 w-full relative">
{loading ? (
<div className="h-full flex items-center justify-center text-ink-faint text-sm font-mono">
loading
</div>
) : points.length === 0 ? (
<div className="h-full flex items-center justify-center text-ink-muted text-sm">
No map history recorded yet.
</div>
) : (
<>
<svg
ref={svgRef}
width={width}
height={HEIGHT}
style={{ cursor: 'crosshair', touchAction: 'none' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={() => {
setHoverIndex(null);
dragStartRef.current = null;
}}
onClick={() => {
if (dragMovedRef.current) {
dragMovedRef.current = false;
return;
}
if (hoverIndex != null && visiblePoints[hoverIndex]) {
loadPlayersFor(visiblePoints[hoverIndex]);
}
}}
>
<defs>
<linearGradient id="mapBarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#5EE596" />
<stop offset="100%" stopColor="#2A9C5C" />
</linearGradient>
</defs>
{yTicks.map((v) => {
const y = barY(v);
return (
<g key={v}>
<line x1={PAD_LEFT} y1={y} x2={width - PAD_RIGHT} y2={y} stroke="#2A332E" />
<text x={PAD_LEFT - 6} y={y + 3} textAnchor="end" fontSize={11} fill="#8A9691">
{v}
</text>
</g>
);
})}
{visiblePoints.map((p, i) => (
<rect
key={p.map_id}
x={barX(i)}
y={barY(p.total_players)}
width={barWidth}
height={barHeight(p.total_players)}
rx={3}
fill="url(#mapBarGradient)"
fillOpacity={hoverIndex === i ? 1 : 0.8}
style={hoverIndex === i ? { filter: 'drop-shadow(0 0 6px rgba(63, 211, 122, 0.55))' } : undefined}
/>
))}
<line
x1={PAD_LEFT}
y1={PAD_TOP + plotHeight}
x2={width - PAD_RIGHT}
y2={PAD_TOP + plotHeight}
stroke="#4C5652"
/>
</svg>
{hoverIndex != null && visiblePoints[hoverIndex] && (
<div
className="absolute pointer-events-none"
style={{
left: Math.min(width - 200, Math.max(0, barX(hoverIndex) - 60)),
top: barY(visiblePoints[hoverIndex].total_players) - 70,
background: '#121715',
border: '1px solid #1F2723',
borderRadius: 6,
fontSize: 12,
padding: '8px 10px',
whiteSpace: 'nowrap',
}}
>
<div style={{ color: '#E4EBE7' }}>{visiblePoints[hoverIndex].map_name}</div>
<div style={{ color: '#8A9691' }}>
{new Date(visiblePoints[hoverIndex].map_start_dt.replace(' ', 'T')).toLocaleString()}
</div>
<div style={{ color: '#3FD37A' }}>
{visiblePoints[hoverIndex].total_players} total players during this session
</div>
</div>
)}
</>
)}
</div>
{points.length > 0 && (
<div className="mt-2 text-xs text-ink-faint">
Visit the{' '}
<Link href="/maps" className="text-accent hover:underline">
maps page
</Link>{' '}
for full detail (votes, prev/next map).
</div>
)}
{selectedMap && (
<div className="mt-4 pt-4 border-t-2 border-base-divider">
<div className="stat-label mb-1">
Players during{' '}
<Link href={`/maps/period/${selectedMap.map_id}`} className="text-accent hover:underline">
{selectedMap.map_name}
</Link>{' '}
({selectedPlayers.length} player{selectedPlayers.length === 1 ? '' : 's'})
</div>
<div className="text-xs text-ink-faint mb-3">
{new Date(selectedMap.map_start_dt.replace(' ', 'T')).toLocaleString()}
{' '}
{selectedMap.map_end_dt
? new Date(selectedMap.map_end_dt.replace(' ', 'T')).toLocaleString()
: 'still running'}
</div>
<PlayerChipList players={selectedPlayers} loading={loadingPlayers} />
</div>
)}
</div>
);
}
@@ -0,0 +1,127 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { formatMinutes } from '@/lib/steam';
interface MapSummary {
map_name: string;
times_played: number;
last_played: string;
total_minutes: number;
}
export default function MapSearch() {
const [q, setQ] = useState('');
const [maps, setMaps] = useState<MapSummary[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const offsetRef = useRef(0);
const loadingMoreRef = useRef(false);
const sentinelRef = useRef<HTMLDivElement>(null);
async function fetchPage(searchQ: string, offset: number) {
const params = new URLSearchParams({ q: searchQ, offset: String(offset) });
const res = await fetch(`/api/maps?${params.toString()}`);
return res.json() as Promise<{ maps: MapSummary[]; hasMore: boolean }>;
}
useEffect(() => {
const handle = setTimeout(async () => {
setLoading(true);
offsetRef.current = 0;
const data = await fetchPage(q, 0);
setMaps(data.maps);
setHasMore(data.hasMore);
offsetRef.current = data.maps.length;
setLoading(false);
}, 250);
return () => clearTimeout(handle);
}, [q]);
async function loadMore() {
if (loadingMoreRef.current || !hasMore || loading) return;
loadingMoreRef.current = true;
setLoadingMore(true);
const data = await fetchPage(q, offsetRef.current);
setMaps((prev) => [...prev, ...data.maps]);
setHasMore(data.hasMore);
offsetRef.current += data.maps.length;
setLoadingMore(false);
loadingMoreRef.current = false;
}
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ rootMargin: '400px' },
);
observer.observe(el);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasMore, loading, q]);
return (
<div className="space-y-4">
<input
type="text"
placeholder="Search by map name…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-full bg-base-panel border border-base-border rounded-lg px-4 py-3 text-ink placeholder:text-ink-faint focus:border-accent/50 transition-colors"
/>
<div className="panel row-divide">
{loading ? (
<div className="p-4 text-sm text-ink-faint font-mono">loading</div>
) : maps.length === 0 ? (
<div className="p-4 text-sm text-ink-muted">No maps found.</div>
) : (
maps.map((m) => (
<Link
key={m.map_name}
href={`/maps/${encodeURIComponent(m.map_name)}`}
className="group flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
>
<div className="text-ink text-sm">{m.map_name}</div>
<div className="flex items-center gap-4">
<div className="flex flex-col items-end text-xs text-ink-faint font-mono gap-0.5">
<span>{m.times_played}× played · {formatMinutes(m.total_minutes)} total</span>
<span>last played {new Date(m.last_played.replace(' ', 'T')).toLocaleString()}</span>
</div>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-ink-faint group-hover:text-accent group-hover:translate-x-0.5 transition-all shrink-0"
>
<path d="M9 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</Link>
))
)}
{!loading && maps.length > 0 && (
<div ref={sentinelRef} className="p-4 text-center">
{loadingMore ? (
<span className="text-xs text-ink-faint font-mono">loading more</span>
) : !hasMore ? (
<span className="text-xs text-ink-faint"> end of list </span>
) : null}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,48 @@
'use client';
import Link from 'next/link';
import { countryCodeToFlag } from '@/lib/steam';
interface PlayerChip {
steamid: string;
name: string;
avatarUrl: string;
country?: string | null;
}
export default function PlayerChipList({
players,
loading,
}: {
players: PlayerChip[];
loading: boolean;
}) {
if (loading) {
return <div className="text-sm text-ink-faint font-mono">loading players</div>;
}
if (players.length === 0) {
return <div className="text-sm text-ink-muted">No players recorded at this point.</div>;
}
return (
<div className="flex flex-wrap gap-3">
{players.map((p) => (
<Link
key={p.steamid}
href={`/players/${encodeURIComponent(p.steamid)}`}
className="flex items-center gap-2 bg-base border border-base-border rounded-full pl-1 pr-3 py-1 shadow-sm hover:border-accent/60 hover:shadow-[0_0_10px_rgba(63,211,122,0.25)] hover:-translate-y-0.5 transition-all duration-150"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={p.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover ring-1 ring-base-border" />
{p.country !== undefined && (
<span className="text-xs" aria-hidden>
{countryCodeToFlag(p.country)}
</span>
)}
<span className="text-sm text-ink">{p.name}</span>
</Link>
))}
</div>
);
}
@@ -0,0 +1,154 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { countryCodeToFlag, formatMinutes } from '@/lib/steam';
import { formatRelativeTime } from '@/lib/dates';
interface Player {
steamid: string;
current_name: string;
current_country: string | null;
matched_name: string | null;
total_minutes: number | null;
last_connected: string | null;
avatarUrl: string;
}
export default function PlayerSearch() {
const [q, setQ] = useState('');
const [sort, setSort] = useState<'recent' | 'name' | 'playtime'>('recent');
const [players, setPlayers] = useState<Player[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const offsetRef = useRef(0);
const loadingMoreRef = useRef(false); // guards against the observer firing twice for one fetch
const sentinelRef = useRef<HTMLDivElement>(null);
async function fetchPage(searchQ: string, searchSort: string, offset: number) {
const params = new URLSearchParams({ q: searchQ, sort: searchSort, offset: String(offset) });
const res = await fetch(`/api/players?${params.toString()}`);
return res.json() as Promise<{ players: Player[]; hasMore: boolean }>;
}
// Search or sort changed: reset and load page 1 fresh, replacing the list.
useEffect(() => {
const handle = setTimeout(async () => {
setLoading(true);
offsetRef.current = 0;
const data = await fetchPage(q, sort, 0);
setPlayers(data.players);
setHasMore(data.hasMore);
offsetRef.current = data.players.length;
setLoading(false);
}, 250); // debounce so we're not hitting the DB on every keystroke
return () => clearTimeout(handle);
}, [q, sort]);
async function loadMore() {
if (loadingMoreRef.current || !hasMore || loading) return;
loadingMoreRef.current = true;
setLoadingMore(true);
const data = await fetchPage(q, sort, offsetRef.current);
setPlayers((prev) => [...prev, ...data.players]);
setHasMore(data.hasMore);
offsetRef.current += data.players.length;
setLoadingMore(false);
loadingMoreRef.current = false;
}
// Fetch the next page once the sentinel at the bottom of the list scrolls
// into view, rather than waiting for an explicit "load more" click.
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ rootMargin: '400px' }, // start fetching a bit before it's actually visible
);
observer.observe(el);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasMore, loading, q, sort]);
return (
<div className="space-y-4">
<div className="flex gap-3">
<input
type="text"
placeholder="Search by name or SteamID…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="flex-1 bg-base-panel border border-base-border rounded-lg px-4 py-3 text-ink placeholder:text-ink-faint focus:border-accent/50 transition-colors"
/>
<select
value={sort}
onChange={(e) => setSort(e.target.value as typeof sort)}
className="bg-base-panel border border-base-border rounded-lg px-3 text-sm text-ink-muted focus:border-accent/50 transition-colors"
>
<option value="recent">Recently active</option>
<option value="name">Name (AZ)</option>
<option value="playtime">Highest playtime</option>
</select>
</div>
<div className="panel row-divide">
{loading ? (
<div className="p-4 text-sm text-ink-faint font-mono">loading</div>
) : players.length === 0 ? (
<div className="p-4 text-sm text-ink-muted">No players found.</div>
) : (
players.map((p, i) => (
<Link
key={p.steamid}
href={`/players/${encodeURIComponent(p.steamid)}`}
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-ink-faint text-xs w-8 text-right shrink-0">{i + 1}</span>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.avatarUrl}
alt=""
className="w-9 h-9 rounded-full object-cover ring-1 ring-base-border shrink-0"
/>
<span aria-hidden>{countryCodeToFlag(p.current_country)}</span>
<div>
<div className="text-ink text-sm">{p.current_name}</div>
{p.matched_name && p.matched_name !== p.current_name && (
<div className="text-xs text-ink-faint">previously: {p.matched_name}</div>
)}
</div>
</div>
<div className="flex items-center gap-4">
<span className="mono text-ink-faint text-xs w-16 text-right">
{formatRelativeTime(p.last_connected)}
</span>
<span className="mono text-ink-faint text-xs w-20 text-right">
{formatMinutes(p.total_minutes)}
</span>
<span className="mono text-ink-faint">{p.steamid}</span>
</div>
</Link>
))
)}
{/* Sentinel — fetches the next page once this scrolls into view */}
{!loading && players.length > 0 && (
<div ref={sentinelRef} className="p-4 text-center">
{loadingMore ? (
<span className="text-xs text-ink-faint font-mono">loading more</span>
) : !hasMore ? (
<span className="text-xs text-ink-faint"> end of list </span>
) : null}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,106 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { formatMinutes } from '@/lib/steam';
interface SessionEntry {
session_id: number;
session_start_dt: string;
session_end_dt: string | null;
session_active_minutes: number | null;
maps_played: string | null;
}
export default function PlayerSessionsList({ steamid }: { steamid: string }) {
const [sessions, setSessions] = useState<SessionEntry[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const offsetRef = useRef(0);
const loadingMoreRef = useRef(false);
const sentinelRef = useRef<HTMLDivElement>(null);
async function fetchPage(offset: number) {
const res = await fetch(`/api/players/${encodeURIComponent(steamid)}/sessions?offset=${offset}`);
return res.json() as Promise<{ sessions: SessionEntry[]; hasMore: boolean }>;
}
useEffect(() => {
(async () => {
setLoading(true);
offsetRef.current = 0;
const data = await fetchPage(0);
setSessions(data.sessions);
setHasMore(data.hasMore);
offsetRef.current = data.sessions.length;
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [steamid]);
async function loadMore() {
if (loadingMoreRef.current || !hasMore || loading) return;
loadingMoreRef.current = true;
setLoadingMore(true);
const data = await fetchPage(offsetRef.current);
setSessions((prev) => [...prev, ...data.sessions]);
setHasMore(data.hasMore);
offsetRef.current += data.sessions.length;
setLoadingMore(false);
loadingMoreRef.current = false;
}
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ rootMargin: '400px' },
);
observer.observe(el);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasMore, loading, steamid]);
if (loading) {
return <div className="text-sm text-ink-faint font-mono">loading</div>;
}
if (sessions.length === 0) {
return <div className="text-sm text-ink-muted">No recorded sessions yet.</div>;
}
return (
<div className="row-divide">
<div className="grid grid-cols-[1fr_1fr_100px] gap-4 pb-2 text-xs text-ink-faint uppercase tracking-wide">
<div>Started</div>
<div>Map(s)</div>
<div className="text-right">Playtime</div>
</div>
{sessions.map((s) => (
<div key={s.session_id} className="grid grid-cols-[1fr_1fr_100px] gap-4 py-3 text-sm items-center">
<div>
<div className="text-ink">{new Date(s.session_start_dt.replace(' ', 'T')).toLocaleString()}</div>
<div className="text-xs text-ink-faint">
{s.session_end_dt
? `until ${new Date(s.session_end_dt.replace(' ', 'T')).toLocaleTimeString()}`
: 'still connected'}
</div>
</div>
<div className="text-ink-muted">{s.maps_played ?? '—'}</div>
<div className="text-right mono text-ink">{formatMinutes(s.session_active_minutes)}</div>
</div>
))}
<div ref={sentinelRef} className="pt-3 text-center">
{loadingMore ? (
<span className="text-xs text-ink-faint font-mono">loading more</span>
) : !hasMore ? (
<span className="text-xs text-ink-faint"> oldest session reached </span>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,93 @@
'use client';
import Link from 'next/link';
import { formatMinutes, countryCodeToFlag } from '@/lib/steam';
interface Segment {
startMin: number;
endMin: number;
}
interface TimelinePlayer {
steamid: string;
name: string;
avatarUrl: string;
country?: string | null;
totalMinutes: number;
segments: Segment[];
}
export default function PlayerTimelineChart({
players,
windowMinutes,
windowStartLabel,
windowEndLabel,
loading,
}: {
players: TimelinePlayer[];
windowMinutes: number;
windowStartLabel: string;
windowEndLabel: string;
loading: boolean;
}) {
if (loading) {
return <div className="text-sm text-ink-faint font-mono">loading players</div>;
}
if (players.length === 0) {
return <div className="text-sm text-ink-muted">No players recorded during this window.</div>;
}
return (
<div>
{/* time axis */}
<div className="flex justify-between text-xs text-ink-faint font-mono mb-2 pl-[196px]">
<span>{windowStartLabel}</span>
<span>{windowEndLabel}</span>
</div>
<div className="space-y-1.5">
{players.map((p, i) => (
<div key={p.steamid} className="flex items-center gap-3">
<span className="text-ink-faint text-xs w-5 text-right shrink-0">{i + 1}</span>
<Link
href={`/players/${encodeURIComponent(p.steamid)}`}
className="flex items-center gap-2 w-[150px] shrink-0 hover:text-accent transition-colors"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={p.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover ring-1 ring-base-border shrink-0" />
{p.country !== undefined && (
<span className="text-xs shrink-0" aria-hidden>
{countryCodeToFlag(p.country)}
</span>
)}
<span className="text-sm text-ink truncate">{p.name}</span>
</Link>
<div className="flex-1 h-4 bg-base rounded overflow-hidden border border-base-border relative">
{p.segments.map((seg, i) => {
const left = (seg.startMin / windowMinutes) * 100;
const width = Math.max(0.6, ((seg.endMin - seg.startMin) / windowMinutes) * 100);
return (
<div
key={i}
className="absolute top-0 bottom-0 rounded-sm"
style={{
left: `${left}%`,
width: `${width}%`,
background: 'linear-gradient(180deg, #5EE596, #2A9C5C)',
boxShadow: '0 0 6px rgba(63, 211, 122, 0.4)',
}}
/>
);
})}
</div>
<span className="text-xs text-ink-muted mono w-16 text-right shrink-0">
{formatMinutes(p.totalMinutes)}
</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,573 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useContainerWidth } from '@/lib/useContainerWidth';
import PlayerChipList from './PlayerChipList';
interface Point {
t: string;
tEpoch: number;
players: number;
mapName: string | null;
}
interface MapBoundary {
mapId: number;
mapName: string;
tEpoch: number;
}
interface PlayerChip {
steamid: string;
name: string;
avatarUrl: string;
country?: string | null;
}
const HEIGHT = 320;
const PAD_LEFT = 36;
const PAD_RIGHT = 8;
const PAD_TOP = 8;
const PAD_BOTTOM = 30;
const Y_MAX = 64; // game's hard player cap
const MIN_ZOOM_SPAN_MS = 5 * 60_000; // don't zoom in tighter than 5 minutes
const HARD_FLOOR_EPOCH = Date.UTC(2015, 6, 16); // won't pan back further than July 16, 2015
const FETCH_DEBOUNCE_MS = 400;
function toLocalInputValue(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default function PopulationChart() {
const { ref: containerRef, width } = useContainerWidth<HTMLDivElement>();
const svgRef = useRef<SVGSVGElement>(null);
const now = new Date();
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const [start, setStart] = useState(toLocalInputValue(dayAgo));
const [end, setEnd] = useState(toLocalInputValue(now));
const [points, setPoints] = useState<Point[]>([]);
const [mapBoundaries, setMapBoundaries] = useState<MapBoundary[]>([]);
const [loading, setLoading] = useState(false);
const [fetchingMore, setFetchingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
// Bounds of everything currently loaded (grows as panning/zooming reaches
// beyond it and triggers a new fetch — see ensureCoverage below).
const [dataMinEpoch, setDataMinEpoch] = useState(0);
const [dataMaxEpoch, setDataMaxEpoch] = useState(0);
// The "home" range to return to on Reset — whatever was last explicitly
// requested via the calendar Start/End + Update, as opposed to wherever
// panning/zooming has since wandered off to.
const [homeStart, setHomeStart] = useState(0);
const [homeEnd, setHomeEnd] = useState(0);
// Currently VISIBLE window.
const [viewStart, setViewStart] = useState(0);
const [viewEnd, setViewEnd] = useState(0);
const dragStartRef = useRef<{ x: number; viewStart: number; viewEnd: number } | null>(null);
const dragMovedRef = useRef(false);
const fetchInFlightRef = useRef(false);
const wheelDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [hoverPoint, setHoverPoint] = useState<Point | null>(null);
const [hoverX, setHoverX] = useState<number | null>(null);
// Currently online — authoritative, always-live, independent of the chart's date range.
const [currentPlayers, setCurrentPlayers] = useState<PlayerChip[]>([]);
const [currentCount, setCurrentCount] = useState<number | null>(null);
const [loadingCurrent, setLoadingCurrent] = useState(true);
const [currentUpdatedAt, setCurrentUpdatedAt] = useState<number | null>(null);
const [secondsAgo, setSecondsAgo] = useState(0);
// A specific HISTORICAL point clicked (or auto-selected as the latest) on the chart.
const [selectedPoint, setSelectedPoint] = useState<Point | null>(null);
const [selectedPlayers, setSelectedPlayers] = useState<PlayerChip[]>([]);
const [loadingSelected, setLoadingSelected] = useState(false);
async function loadPlayersAt(point: Point) {
setSelectedPoint(point);
setLoadingSelected(true);
try {
const res = await fetch(`/api/population/players?at=${encodeURIComponent(point.t)}`);
const data = await res.json();
setSelectedPlayers(data.players ?? []);
} catch {
setSelectedPlayers([]);
} finally {
setLoadingSelected(false);
}
}
async function fetchRange(
rangeStartMs: number,
rangeEndMs: number,
opts: { preserveView?: boolean; selectLatest?: boolean } = {},
) {
const { preserveView = false, selectLatest = false } = opts;
if (preserveView) setFetchingMore(true);
else setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
start: new Date(rangeStartMs).toISOString(),
end: new Date(rangeEndMs).toISOString(),
});
const res = await fetch(`/api/population?${params.toString()}`);
if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load');
const data = await res.json();
const pts: Point[] = (data.points as any[]).map((p: any) => ({ ...p, tEpoch: new Date(p.t).getTime() }));
const bounds: MapBoundary[] = (data.mapBoundaries as any[]).map((b: any) => ({ ...b, tEpoch: new Date(b.t).getTime() }));
setPoints(pts);
setMapBoundaries(bounds);
if (pts.length > 0) {
const minE = pts[0].tEpoch;
const maxE = pts[pts.length - 1].tEpoch;
setDataMinEpoch(minE);
setDataMaxEpoch(maxE);
// Deliberately NOT syncing the start/end calendar fields to minE/maxE
// here — those are bucket-boundary timestamps (up to one bucket
// width earlier than what was actually requested), not the real
// request range. Overwriting the inputs with them caused Update to
// silently shrink the End field by one bucket width on every click.
// The calendar fields should only ever reflect what was typed.
if (!preserveView) {
setViewStart(minE);
setViewEnd(maxE);
setHomeStart(minE);
setHomeEnd(maxE);
}
if (selectLatest) loadPlayersAt(pts[pts.length - 1]);
}
} catch (e: any) {
setError(e.message ?? 'something went wrong');
} finally {
setLoading(false);
setFetchingMore(false);
}
}
async function loadCurrentPlayers() {
setLoadingCurrent(true);
try {
const res = await fetch('/api/population/current');
const data = await res.json();
setCurrentPlayers(data.players ?? []);
setCurrentCount(data.count ?? 0);
setCurrentUpdatedAt(Date.now());
} catch {
setCurrentPlayers([]);
setCurrentCount(null);
} finally {
setLoadingCurrent(false);
}
}
useEffect(() => {
fetchRange(dayAgo.getTime(), now.getTime(), { selectLatest: true });
loadCurrentPlayers();
const poll = setInterval(loadCurrentPlayers, 30_000);
return () => clearInterval(poll);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const tick = setInterval(() => {
if (currentUpdatedAt) setSecondsAgo(Math.round((Date.now() - currentUpdatedAt) / 1000));
}, 1000);
return () => clearInterval(tick);
}, [currentUpdatedAt]);
// If the visible window extends beyond what's loaded, fetch more —
// fires after a drag ends or a short pause after scroll-zooming, not on
// every intermediate frame.
function ensureCoverage(neededStart: number, neededEnd: number) {
if (fetchInFlightRef.current) return;
if (neededStart >= dataMinEpoch && neededEnd <= dataMaxEpoch) return;
fetchInFlightRef.current = true;
const fetchStart = Math.min(neededStart, dataMinEpoch);
const fetchEnd = Math.max(neededEnd, dataMaxEpoch);
fetchRange(fetchStart, fetchEnd, { preserveView: true }).finally(() => {
fetchInFlightRef.current = false;
});
}
const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT);
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
function xForEpoch(epoch: number): number {
if (viewEnd === viewStart) return PAD_LEFT;
return PAD_LEFT + ((epoch - viewStart) / (viewEnd - viewStart)) * plotWidth;
}
function yForValue(v: number): number {
return PAD_TOP + (1 - Math.min(v, Y_MAX) / Y_MAX) * plotHeight;
}
function epochForX(x: number): number {
if (plotWidth === 0) return viewStart;
return viewStart + ((x - PAD_LEFT) / plotWidth) * (viewEnd - viewStart);
}
let firstIdx = points.findIndex((p) => p.tEpoch >= viewStart);
if (firstIdx === -1) firstIdx = points.length - 1;
let lastIdx = -1;
for (let i = points.length - 1; i >= 0; i--) {
if (points[i].tEpoch <= viewEnd) {
lastIdx = i;
break;
}
}
const renderStart = Math.max(0, firstIdx - 1);
const renderEnd = Math.min(points.length - 1, Math.max(lastIdx, firstIdx) + 1);
const renderPoints = points.length > 0 ? points.slice(renderStart, renderEnd + 1) : [];
const linePath = renderPoints
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xForEpoch(p.tEpoch).toFixed(1)} ${yForValue(p.players).toFixed(1)}`)
.join(' ');
const baselineY = PAD_TOP + plotHeight;
const areaPath =
renderPoints.length > 0
? `${linePath} L ${xForEpoch(renderPoints[renderPoints.length - 1].tEpoch).toFixed(1)} ${baselineY} L ${xForEpoch(renderPoints[0].tEpoch).toFixed(1)} ${baselineY} Z`
: '';
const visibleBoundaries = mapBoundaries.filter((b) => b.tEpoch >= viewStart && b.tEpoch <= viewEnd);
function findNearestPoint(x: number): Point | null {
if (renderPoints.length === 0) return null;
const targetEpoch = epochForX(x);
let nearest = renderPoints[0];
let bestDist = Math.abs(renderPoints[0].tEpoch - targetEpoch);
for (const p of renderPoints) {
const d = Math.abs(p.tEpoch - targetEpoch);
if (d < bestDist) {
bestDist = d;
nearest = p;
}
}
return nearest;
}
function handleMouseDown(e: React.MouseEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect();
dragStartRef.current = { x: e.clientX - rect.left, viewStart, viewEnd };
dragMovedRef.current = false;
}
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
setHoverX(x);
setHoverPoint(findNearestPoint(x));
if (dragStartRef.current) {
const dx = x - dragStartRef.current.x;
if (Math.abs(dx) > 3) dragMovedRef.current = true;
if (dragMovedRef.current && plotWidth > 0) {
const span = dragStartRef.current.viewEnd - dragStartRef.current.viewStart;
const epochDelta = -(dx / plotWidth) * span;
let newStart = dragStartRef.current.viewStart + epochDelta;
let newEnd = dragStartRef.current.viewEnd + epochDelta;
if (newStart < HARD_FLOOR_EPOCH) {
newStart = HARD_FLOOR_EPOCH;
newEnd = newStart + span;
}
const rightLimit = Date.now();
if (newEnd > rightLimit) {
newEnd = rightLimit;
newStart = newEnd - span;
}
setViewStart(newStart);
setViewEnd(newEnd);
}
}
}
function handleMouseUp() {
if (dragMovedRef.current) {
ensureCoverage(viewStart, viewEnd);
}
dragStartRef.current = null;
}
function handleMouseLeave() {
setHoverPoint(null);
setHoverX(null);
dragStartRef.current = null;
}
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
if (dragMovedRef.current) {
dragMovedRef.current = false;
return; // was a drag, not a click-to-select
}
const rect = e.currentTarget.getBoundingClientRect();
const p = findNearestPoint(e.clientX - rect.left);
if (p) loadPlayersAt(p);
}
// Native (non-passive) wheel listener — React's onWheel is passive by
// default, so e.preventDefault() silently does nothing there and the
// page scrolls along with the zoom. This is the only way to actually
// stop that.
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
function onWheel(e: WheelEvent) {
e.preventDefault();
if (plotWidth === 0) return;
const rect = svg!.getBoundingClientRect();
const x = e.clientX - rect.left;
const anchorEpoch = epochForX(x);
const zoomFactor = e.deltaY > 0 ? 1.15 : 1 / 1.15;
const fullSpan = Date.now() - HARD_FLOOR_EPOCH;
let newSpan = (viewEnd - viewStart) * zoomFactor;
newSpan = Math.min(newSpan, fullSpan);
newSpan = Math.max(newSpan, MIN_ZOOM_SPAN_MS);
const ratio = (anchorEpoch - viewStart) / ((viewEnd - viewStart) || 1);
let newStart = anchorEpoch - ratio * newSpan;
let newEnd = newStart + newSpan;
if (newStart < HARD_FLOOR_EPOCH) {
newStart = HARD_FLOOR_EPOCH;
newEnd = newStart + newSpan;
}
const rightLimit = Date.now();
if (newEnd > rightLimit) {
newEnd = rightLimit;
newStart = newEnd - newSpan;
}
setViewStart(newStart);
setViewEnd(newEnd);
if (wheelDebounceRef.current) clearTimeout(wheelDebounceRef.current);
wheelDebounceRef.current = setTimeout(() => ensureCoverage(newStart, newEnd), FETCH_DEBOUNCE_MS);
}
svg.addEventListener('wheel', onWheel, { passive: false });
return () => svg.removeEventListener('wheel', onWheel);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewStart, viewEnd, plotWidth, dataMinEpoch, dataMaxEpoch]);
function resetZoom() {
setViewStart(homeStart);
setViewEnd(homeEnd);
}
const isZoomed = viewStart > homeStart + 1000 || viewEnd < homeEnd - 1000;
const yTicks = [0, 16, 32, 48, 64];
const xTickCount = 5;
const xTicks =
viewEnd > viewStart
? Array.from({ length: xTickCount }, (_, i) => viewStart + (i / (xTickCount - 1)) * (viewEnd - viewStart))
: [];
return (
<div className="panel p-6">
{/* Currently online — always visible, always live, not tied to the date range below */}
<div className="mb-6 pb-6 border-b-2 border-base-divider">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="relative flex w-2 h-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-accent opacity-60" />
<span className="relative inline-flex rounded-full w-2 h-2 bg-accent shadow-[0_0_6px_theme(colors.accent.DEFAULT)]" />
</span>
<div className="stat-label">
Currently online{currentCount != null ? `${currentCount} player${currentCount === 1 ? '' : 's'}` : ''}
</div>
</div>
{currentUpdatedAt && <div className="text-xs text-ink-faint font-mono">updated {secondsAgo}s ago</div>}
</div>
<PlayerChipList players={currentPlayers} loading={loadingCurrent} />
</div>
<div className="flex flex-wrap items-end justify-between gap-4 mb-4">
<div>
<div className="stat-label mb-1">Concurrent players</div>
<h2 className="text-lg text-ink">Population over time</h2>
</div>
<form
className="flex flex-wrap items-center gap-3"
onSubmit={(e) => {
e.preventDefault();
fetchRange(new Date(start).getTime(), new Date(end).getTime(), { selectLatest: true });
}}
>
<label className="flex flex-col text-xs text-ink-muted gap-1">
Start
<input
type="datetime-local"
value={start}
onChange={(e) => setStart(e.target.value)}
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
/>
</label>
<label className="flex flex-col text-xs text-ink-muted gap-1">
End
<input
type="datetime-local"
value={end}
onChange={(e) => setEnd(e.target.value)}
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
/>
</label>
<button
type="submit"
className="bg-accent-dim hover:bg-accent hover:text-base text-accent border border-accent/40 rounded px-4 py-2 text-sm font-medium transition-colors self-end"
>
Update
</button>
</form>
</div>
{error && <div className="text-sm text-red-400 mb-4">{error}</div>}
<div className="flex items-center justify-between gap-4 mb-2">
<div className="flex items-center gap-1.5 text-xs text-accent">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
<circle cx="12" cy="12" r="9" />
</svg>
Scroll to zoom, drag to pan (all the way back to 2015), click a point for who was online
{fetchingMore && <span className="text-ink-faint"> loading more</span>}
</div>
{isZoomed && (
<button onClick={resetZoom} className="text-xs text-ink-muted hover:text-accent transition-colors underline">
Reset zoom
</button>
)}
</div>
<div ref={containerRef} className="w-full relative" style={{ height: HEIGHT }}>
{loading ? (
<div className="h-full flex items-center justify-center text-ink-faint text-sm font-mono">loading</div>
) : points.length === 0 ? (
<div className="h-full flex items-center justify-center text-ink-muted text-sm">No data in this range.</div>
) : (
<svg
ref={svgRef}
width={width}
height={HEIGHT}
style={{ cursor: 'crosshair', touchAction: 'none' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
<defs>
<linearGradient id="popAreaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3FD37A" stopOpacity="0.28" />
<stop offset="100%" stopColor="#3FD37A" stopOpacity="0" />
</linearGradient>
</defs>
{yTicks.map((v) => {
const y = yForValue(v);
return (
<g key={v}>
<line x1={PAD_LEFT} y1={y} x2={width - PAD_RIGHT} y2={y} stroke="#2A332E" />
<text x={PAD_LEFT - 6} y={y + 3} textAnchor="end" fontSize={11} fill="#8A9691">
{v}
</text>
</g>
);
})}
{visibleBoundaries.map((b) => (
<line
key={b.mapId}
x1={xForEpoch(b.tEpoch)}
y1={PAD_TOP}
x2={xForEpoch(b.tEpoch)}
y2={PAD_TOP + plotHeight}
stroke="#4C5652"
strokeDasharray="3 3"
/>
))}
{xTicks.map((epoch, i) => (
<text key={i} x={xForEpoch(epoch)} y={HEIGHT - PAD_BOTTOM + 16} textAnchor="middle" fontSize={10} fill="#8A9691">
{new Date(epoch).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</text>
))}
<line x1={PAD_LEFT} y1={PAD_TOP + plotHeight} x2={width - PAD_RIGHT} y2={PAD_TOP + plotHeight} stroke="#4C5652" />
<path d={areaPath} fill="url(#popAreaGradient)" stroke="none" />
<path
d={linePath}
fill="none"
stroke="#3FD37A"
strokeWidth={2}
strokeLinejoin="round"
style={{ filter: 'drop-shadow(0 0 5px rgba(63, 211, 122, 0.45))' }}
/>
{hoverPoint && (
<g>
<circle
cx={xForEpoch(hoverPoint.tEpoch)}
cy={yForValue(hoverPoint.players)}
r={9}
fill="#3FD37A"
fillOpacity={0.15}
/>
<circle
cx={xForEpoch(hoverPoint.tEpoch)}
cy={yForValue(hoverPoint.players)}
r={4}
fill="#3FD37A"
stroke="#0B0F0E"
strokeWidth={1.5}
/>
</g>
)}
</svg>
)}
{hoverPoint && hoverX != null && !loading && (
<div
className="absolute pointer-events-none"
style={{
left: Math.min(Math.max(width, 200) - 190, Math.max(0, hoverX + 12)),
top: Math.max(0, yForValue(hoverPoint.players) - 70),
background: '#121715',
border: '1px solid #1F2723',
borderRadius: 6,
fontSize: 12,
padding: '8px 10px',
whiteSpace: 'nowrap',
}}
>
<div style={{ color: '#8A9691' }}>{new Date(hoverPoint.t).toLocaleString()}</div>
<div style={{ color: '#3FD37A', marginTop: 2 }}>{hoverPoint.players} players</div>
{hoverPoint.mapName && <div style={{ color: '#E4EBE7', marginTop: 2 }}>{hoverPoint.mapName}</div>}
</div>
)}
</div>
{selectedPoint && (
<div className="mt-4 pt-4 border-t-2 border-base-divider">
<div className="stat-label mb-3">
Online at {new Date(selectedPoint.t).toLocaleString()}
{selectedPoint.mapName && (
<>
{' '}
playing <span className="text-ink">{selectedPoint.mapName}</span>
</>
)}{' '}
({selectedPoint.players} player{selectedPoint.players === 1 ? '' : 's'} from historical data, may lag slightly)
</div>
<PlayerChipList players={selectedPlayers} loading={loadingSelected} />
</div>
)}
</div>
);
}
@@ -0,0 +1,399 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import PlayerTimelineChart from './PlayerTimelineChart';
import TimeInput24 from './TimeInput24';
import { formatMinutes, countryCodeToFlag } from '@/lib/steam';
interface RecurringPoint {
date: string; // "2026-07-01"
count: number;
}
interface Segment {
startMin: number;
endMin: number;
}
interface TimelinePlayer {
steamid: string;
name: string;
avatarUrl: string;
country?: string | null;
totalMinutes: number;
segments: Segment[];
}
interface AggregatePlayer {
steamid: string;
name: string;
avatarUrl: string;
country?: string | null;
totalMinutes: number;
daysPresent: number;
}
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const HEIGHT = 220;
const PAD_LEFT = 32;
const PAD_RIGHT = 8;
const PAD_TOP = 8;
const PAD_BOTTOM = 30;
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function daysAgoStr(n: number): string {
const d = new Date();
d.setDate(d.getDate() - n);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export default function RecurringWindowChart() {
const [selectedDays, setSelectedDays] = useState<number[]>([2]); // default: Tuesday
const [startTime, setStartTime] = useState('06:00');
const [endTime, setEndTime] = useState('15:00');
const [rangeStart, setRangeStart] = useState(daysAgoStr(90));
const [rangeEnd, setRangeEnd] = useState(todayStr());
const [points, setPoints] = useState<RecurringPoint[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [truncated, setTruncated] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [selected, setSelected] = useState<RecurringPoint | null>(null);
const [selectedPlayers, setSelectedPlayers] = useState<TimelinePlayer[]>([]);
const [windowMinutes, setWindowMinutes] = useState(0);
const [loadingPlayers, setLoadingPlayers] = useState(false);
const [aggregatePlayers, setAggregatePlayers] = useState<AggregatePlayer[]>([]);
const [aggregateDates, setAggregateDates] = useState(0);
const [loadingAggregate, setLoadingAggregate] = useState(false);
const [aggregateTruncated, setAggregateTruncated] = useState(false);
function toggleDay(d: number) {
setSelectedDays((prev) => (prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d].sort()));
}
async function search() {
if (selectedDays.length === 0) {
setError('Pick at least one day of the week.');
return;
}
setLoading(true);
setLoadingAggregate(true);
setError(null);
setSelected(null);
setHasSearched(true);
const params = new URLSearchParams({
days: selectedDays.join(','),
startTime,
endTime,
start: rangeStart,
end: rangeEnd,
});
const summaryPromise = (async () => {
try {
const res = await fetch(`/api/population/recurring?${params.toString()}`);
if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load');
const data = await res.json();
setPoints(data.points ?? []);
setTruncated(!!data.truncated);
} catch (e: any) {
setError(e.message ?? 'something went wrong');
setPoints([]);
} finally {
setLoading(false);
}
})();
const aggregatePromise = (async () => {
try {
const res = await fetch(`/api/population/recurring/aggregate?${params.toString()}`);
const data = await res.json();
setAggregatePlayers(data.players ?? []);
setAggregateDates(data.totalMatchingDates ?? 0);
setAggregateTruncated(!!data.truncatedPlayers);
} catch {
setAggregatePlayers([]);
setAggregateDates(0);
} finally {
setLoadingAggregate(false);
}
})();
await Promise.all([summaryPromise, aggregatePromise]);
}
async function loadPlayersFor(point: RecurringPoint) {
setSelected(point);
setLoadingPlayers(true);
try {
const params = new URLSearchParams({ date: point.date, startTime, endTime });
const res = await fetch(`/api/population/recurring/players?${params.toString()}`);
const data = await res.json();
setSelectedPlayers(data.players ?? []);
setWindowMinutes(data.windowMinutes ?? 0);
} catch {
setSelectedPlayers([]);
setWindowMinutes(0);
} finally {
setLoadingPlayers(false);
}
}
const maxValue = Math.max(1, ...points.map((p) => p.count));
const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5;
const yTicks = [0, Math.round(axisMax / 2), axisMax];
return (
<div className="panel p-6">
<div className="mb-4">
<div className="stat-label mb-1">Recurring window</div>
<h2 className="text-lg text-ink">Population during a repeating time slot</h2>
<p className="text-sm text-ink-muted mt-1">
e.g. "who was online 6:0015:00 every Tuesday" pick the days, the time window, and the date range to search within.
</p>
</div>
<div className="flex flex-wrap items-end gap-4 mb-4">
<div>
<div className="text-xs text-ink-muted mb-1">Days</div>
<div className="flex flex-wrap gap-1">
{[0, 1, 2, 3, 4, 5, 6].map((d) => (
<button
key={d}
type="button"
onClick={() => toggleDay(d)}
className={`px-2.5 py-1.5 rounded text-xs border transition-colors ${
selectedDays.includes(d)
? 'border-accent/50 text-accent bg-accent-dim/20'
: 'border-base-border text-ink-muted hover:text-ink'
}`}
>
{DAY_LABELS[d]}
</button>
))}
</div>
</div>
<label className="flex flex-col text-xs text-ink-muted gap-1">
From <span className="text-ink-faint">(24h)</span>
<TimeInput24 value={startTime} onChange={setStartTime} />
</label>
<label className="flex flex-col text-xs text-ink-muted gap-1">
Until <span className="text-ink-faint">(24h)</span>
<TimeInput24 value={endTime} onChange={setEndTime} />
</label>
<label className="flex flex-col text-xs text-ink-muted gap-1">
Since
<input
type="date"
value={rangeStart}
onChange={(e) => setRangeStart(e.target.value)}
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
/>
</label>
<label className="flex flex-col text-xs text-ink-muted gap-1">
Until
<input
type="date"
value={rangeEnd}
onChange={(e) => setRangeEnd(e.target.value)}
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
/>
</label>
<button
onClick={search}
className="bg-accent-dim hover:bg-accent hover:text-base text-accent border border-accent/40 rounded px-4 py-2 text-sm font-medium transition-colors"
>
Search
</button>
</div>
{error && <div className="text-sm text-red-400 mb-4">{error}</div>}
{truncated && (
<div className="text-xs text-ink-faint mb-4">
Showing the first 120 matching dates narrow the date range for a complete picture.
</div>
)}
{!hasSearched ? (
<div className="text-sm text-ink-muted py-8 text-center">Pick your days and time window, then hit Search.</div>
) : loading ? (
<div className="text-sm text-ink-faint font-mono py-8 text-center">loading</div>
) : points.length === 0 ? (
<div className="text-sm text-ink-muted py-8 text-center">No matching dates in that range.</div>
) : (
<>
<div className="flex items-center gap-1.5 text-xs text-accent mb-2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
<circle cx="12" cy="12" r="9" />
</svg>
Click any bar to see each player's time within that window
</div>
<div className="relative" style={{ height: HEIGHT }}>
<svg width="100%" height={HEIGHT} viewBox={`0 0 800 ${HEIGHT}`} preserveAspectRatio="none">
<defs>
<linearGradient id="recurringBarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#5EE596" />
<stop offset="100%" stopColor="#2A9C5C" />
</linearGradient>
</defs>
{yTicks.map((v) => {
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
const y = PAD_TOP + (1 - v / axisMax) * plotHeight;
return (
<g key={v}>
<line x1={PAD_LEFT} y1={y} x2={800 - PAD_RIGHT} y2={y} stroke="#2A332E" />
<text x={PAD_LEFT - 6} y={y + 3} textAnchor="end" fontSize={11} fill="#8A9691">
{v}
</text>
</g>
);
})}
{(() => {
const plotWidth = 800 - PAD_LEFT - PAD_RIGHT;
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
const slotWidth = plotWidth / points.length;
const barWidth = Math.max(2, slotWidth * 0.6);
return points.map((p, i) => {
const barH = Math.max(p.count > 0 ? 2 : 0, (p.count / axisMax) * plotHeight);
const x = PAD_LEFT + i * slotWidth + (slotWidth - barWidth) / 2;
const y = PAD_TOP + (plotHeight - barH);
const showEvery = Math.max(1, Math.ceil(points.length / 10));
return (
<g key={p.date}>
<rect
x={x}
y={y}
width={barWidth}
height={barH}
rx={3}
fill="url(#recurringBarGradient)"
fillOpacity={selected?.date === p.date ? 1 : 0.8}
style={{
cursor: 'pointer',
...(selected?.date === p.date
? { filter: 'drop-shadow(0 0 6px rgba(63, 211, 122, 0.55))' }
: {}),
}}
onClick={() => loadPlayersFor(p)}
/>
{(i === 0 || i === points.length - 1 || i % showEvery === 0) && (
<text x={x + barWidth / 2} y={HEIGHT - PAD_BOTTOM + 14} textAnchor="middle" fontSize={9} fill="#8A9691">
{new Date(`${p.date}T00:00:00Z`).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</text>
)}
</g>
);
});
})()}
<line
x1={PAD_LEFT}
y1={PAD_TOP + (HEIGHT - PAD_TOP - PAD_BOTTOM)}
x2={800 - PAD_RIGHT}
y2={PAD_TOP + (HEIGHT - PAD_TOP - PAD_BOTTOM)}
stroke="#4C5652"
/>
</svg>
</div>
</>
)}
{hasSearched && !loading && points.length > 0 && (
<div className="mt-6 pt-6 border-t-2 border-base-divider grid md:grid-cols-2 gap-6">
{/* Overall — combined across every matching day */}
<div>
<div className="stat-label mb-1">
Overall — combined across all {aggregateDates} matching day{aggregateDates === 1 ? '' : 's'}, {startTime}{endTime}
</div>
<p className="text-xs text-ink-muted mb-3">
Each player's time spent inside the window, summed across every matching date.
</p>
{loadingAggregate ? (
<div className="text-sm text-ink-faint font-mono py-4">loading</div>
) : aggregatePlayers.length === 0 ? (
<div className="text-sm text-ink-muted py-4">No players recorded across these dates.</div>
) : (
<>
<div className="row-divide">
{aggregatePlayers.map((p, i) => (
<Link
key={p.steamid}
href={`/players/${encodeURIComponent(p.steamid)}`}
className="flex items-center justify-between py-2.5 text-sm hover:bg-base transition-colors px-2 -mx-2 rounded"
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-ink-faint text-xs w-5 text-right shrink-0">{i + 1}</span>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.avatarUrl}
alt=""
className="w-7 h-7 rounded-full object-cover ring-1 ring-base-border shrink-0"
/>
{p.country !== undefined && (
<span className="text-xs shrink-0" aria-hidden>
{countryCodeToFlag(p.country)}
</span>
)}
<span className="text-ink truncate">{p.name}</span>
</div>
<div className="flex items-center gap-4 shrink-0">
<span className="text-ink-faint text-xs mono">
{p.daysPresent} of {aggregateDates} day{aggregateDates === 1 ? '' : 's'}
</span>
<span className="text-ink mono text-xs w-16 text-right">{formatMinutes(p.totalMinutes)}</span>
</div>
</Link>
))}
</div>
{aggregateTruncated && (
<div className="text-xs text-ink-faint mt-2">
Showing the top {aggregatePlayers.length} players by time narrow the search for a complete list.
</div>
)}
</>
)}
</div>
{/* One specific day — populated once a bar is clicked, visible (empty) before that */}
<div>
{selected ? (
<>
<div className="stat-label mb-3">
{new Date(`${selected.date}T00:00:00Z`).toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' })}, {startTime}{endTime} {selectedPlayers.length} player
{selectedPlayers.length === 1 ? '' : 's'}, sorted by time spent in the window
</div>
<PlayerTimelineChart
players={selectedPlayers}
windowMinutes={windowMinutes || 1}
windowStartLabel={startTime}
windowEndLabel={endTime}
loading={loadingPlayers}
/>
</>
) : (
<div className="min-h-[200px] border border-dashed border-base-border rounded-lg p-4">
<div className="flex items-center gap-1.5 text-sm text-accent">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" className="shrink-0">
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
<circle cx="12" cy="12" r="9" />
</svg>
<span>Click any green bar above to see who was online and exactly when on that specific day.</span>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,51 @@
'use client';
// A deliberately unambiguous 24-hour time picker. Native <input type="time">
// displays 12-hour AM/PM or 24-hour format depending on the *browser's own
// locale settings* — completely outside this app's control — which makes
// "12:00 PM" (noon) easy to misread as "end of day" on a 12-hour-locale
// browser. Two plain <select> dropdowns sidestep that entirely: every
// visitor sees the same unambiguous HH:MM regardless of their locale.
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
export default function TimeInput24({
value,
onChange,
}: {
value: string; // "HH:MM", 24-hour
onChange: (value: string) => void;
}) {
const [hh, mm] = value.split(':');
return (
<div className="flex items-center gap-1 bg-base border border-base-border rounded px-1.5 py-1">
<select
value={hh}
onChange={(e) => onChange(`${e.target.value}:${mm}`)}
className="bg-transparent text-ink text-sm font-mono focus:outline-none"
aria-label="Hour (24-hour)"
>
{HOURS.map((h) => (
<option key={h} value={h} className="bg-base">
{h}
</option>
))}
</select>
<span className="text-ink-faint">:</span>
<select
value={mm}
onChange={(e) => onChange(`${hh}:${e.target.value}`)}
className="bg-transparent text-ink text-sm font-mono focus:outline-none"
aria-label="Minute"
>
{MINUTES.map((m) => (
<option key={m} value={m} className="bg-base">
{m}
</option>
))}
</select>
</div>
);
}
@@ -0,0 +1,21 @@
module.exports = {
apps: [
{
name: 'unloze-playtime-display',
cwd: __dirname,
script: 'npm',
args: 'start',
env: {
NODE_ENV: 'production',
PORT: 3547,
// No TZ var needed here — all DB datetime handling goes through
// lib/timezone.ts, which uses explicit Intl-based conversion
// hardcoded to Europe/Berlin (matching MySQL's configured
// timezone), independent of the Node process's own environment.
},
autorestart: true,
max_restarts: 10,
watch: false,
},
],
};
@@ -0,0 +1,30 @@
import { parseDbDateTime } from './timezone';
// Parses a MySQL DATETIME string correctly regardless of the Node
// process's own timezone — see lib/timezone.ts.
export function parseDbDate(s: string): Date {
return parseDbDateTime(s);
}
export function diffMinutes(startStr: string, endStr: string | null): number | null {
if (!endStr) return null;
const start = parseDbDate(startStr);
const end = parseDbDate(endStr);
return Math.round((end.getTime() - start.getTime()) / 60000);
}
// "2h ago", "3d ago", etc. — used for "last connected" displays.
export function formatRelativeTime(dbDateStr: string | null): string {
if (!dbDateStr) return '—';
const diffMs = Date.now() - parseDbDate(dbDateStr).getTime();
const diffMin = Math.round(diffMs / 60_000);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.round(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
const diffDay = Math.round(diffHr / 24);
if (diffDay < 30) return `${diffDay}d ago`;
const diffMonth = Math.round(diffDay / 30);
if (diffMonth < 12) return `${diffMonth}mo ago`;
return `${Math.round(diffMonth / 12)}y ago`;
}
@@ -0,0 +1,28 @@
import mysql from 'mysql2/promise';
// A single pooled connection reused across all API routes. mysql2's pool
// handles reconnects/idle connections itself — no need to manually manage
// connect/disconnect per request.
let pool: mysql.Pool | null = null;
export function getPool(): mysql.Pool {
if (!pool) {
pool = mysql.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME ?? 'unloze_playtimestats',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
dateStrings: true, // return DATETIME columns as 'YYYY-MM-DD HH:MM:SS' strings, not JS Date objects with TZ surprises
});
}
return pool;
}
export async function query<T = any>(sql: string, params: any[] = []): Promise<T[]> {
const [rows] = await getPool().query(sql, params);
return rows as T[];
}
@@ -0,0 +1,429 @@
import { query } from '@/lib/db';
// map_end_dt was dropped from the schema — it was redundant with (and
// occasionally got out of sync with) the next row's map_start_dt, since a
// map's end is definitionally when the next one begins. This subquery
// derives the same information: NULL for the currently-running map (no
// later row exists yet), otherwise the next map's start time.
const NEXT_MAP_START = (alias: string) =>
`(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > ${alias}.map_start_dt)`;
// Tickrate changes sometimes require a very quick map restart, which
// produces a second map_history row for the same map seconds later. Treat
// anything under 2 minutes as such an artifact rather than a real play —
// applied everywhere a map_history row is being counted/listed as an
// actual occurrence. The currently-running map (no next row yet) is always
// included since we can't know its eventual duration yet.
const REAL_MAP_PERIOD = `(
${NEXT_MAP_START('m')} IS NULL
OR TIMESTAMPDIFF(MINUTE, m.map_start_dt, ${NEXT_MAP_START('m')}) >= 2
)`;
export interface PlayerRecord {
steamid: string;
current_name: string;
current_country: string | null;
player_tier: number;
first_seen: string;
last_seen: string;
}
export interface NameHistoryEntry {
player_name: string;
first_seen: string;
last_seen: string;
}
export interface SessionEntry {
session_id: number;
session_start_dt: string;
session_end_dt: string | null;
session_active_minutes: number | null;
maps_played: string | null; // comma-separated map names overlapping this session
}
export async function getPlayer(steamid: string): Promise<PlayerRecord | null> {
const rows = await query<PlayerRecord>(
`SELECT steamid, current_name, current_country, player_tier, first_seen, last_seen
FROM playtime_display_players
WHERE steamid = ?`,
[steamid],
);
return rows[0] ?? null;
}
export async function getNameHistory(steamid: string): Promise<NameHistoryEntry[]> {
return query<NameHistoryEntry>(
`SELECT player_name, first_seen, last_seen
FROM playtime_display_name_history
WHERE steamid = ?
ORDER BY last_seen DESC`,
[steamid],
);
}
export async function getSessions(steamid: string, limit = 10, offset = 0): Promise<SessionEntry[]> {
// The maps_played subquery finds every map_history period that overlaps
// this session's [start, end] window (same overlap logic used elsewhere
// in the app), and concatenates the names. A session can span more than
// one map if the player stayed connected through a map change.
//
// This subquery runs once per returned row, and gets slower as
// map_history grows — which is exactly why this is paginated at a small
// page size (10) rather than fetching a large batch at once. A profile
// with thousands of historical sessions was taking 20-30+ seconds to
// load because this ran against a much larger LIMIT on every visit,
// regardless of whether anyone ever scrolled down to see older sessions.
return query<SessionEntry>(
`SELECT
s.session_id,
s.session_start_dt,
s.session_end_dt,
s.session_active_minutes,
(
SELECT GROUP_CONCAT(DISTINCT m.map_name ORDER BY m.map_start_dt SEPARATOR ', ')
FROM playtime_display_map_history m
WHERE m.map_start_dt < COALESCE(s.session_end_dt, NOW())
AND COALESCE(${NEXT_MAP_START('m')}, NOW()) > s.session_start_dt
AND ${REAL_MAP_PERIOD}
) AS maps_played
FROM playtime_display_sessions s
WHERE s.steamid = ?
ORDER BY s.session_start_dt DESC
LIMIT ? OFFSET ?`,
[steamid, limit, offset],
);
}
export interface MapPopulationPoint {
map_id: number;
map_name: string;
map_start_dt: string;
map_end_dt: string | null;
total_players: number;
}
export async function getMapPopulationSeries(limit = 40, before?: string): Promise<MapPopulationPoint[]> {
// Total DISTINCT players who were connected at any point during each map's
// full session (not just at its start) — one data point per map. Fetched
// most-recent-first (for the LIMIT), then the caller should reverse it
// back to chronological order for display.
// `before` (a DB-formatted datetime string) lets the caller page further
// back into history than the initial batch, for pan-to-load-more charts.
const rows = await query<MapPopulationPoint>(
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt,
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW())
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
) AS total_players
FROM playtime_display_map_history m
WHERE ${REAL_MAP_PERIOD}
${before ? 'AND m.map_start_dt < ?' : ''}
ORDER BY m.map_start_dt DESC
LIMIT ?`,
before ? [before, limit] : [limit],
);
return rows.reverse();
}
export interface CountrySummary {
current_country: string | null; // null represents the "Unknown" bucket
player_count: number;
total_minutes: number;
avg_minutes: number;
}
export async function getCountriesSummary(): Promise<CountrySummary[]> {
// "Unknown" covers both NULL and '' — the actual sentinel written by the
// plugin when GeoipCode2 can't resolve a country (private/local IP, GeoIP
// miss, etc.) is an empty string, not SQL NULL, since the upsert always
// writes some value rather than leaving the column untouched. NULL is
// also handled in case any row ever ends up there some other way (e.g.
// the column's own DEFAULT).
const UNKNOWN = `(p.current_country IS NULL OR p.current_country = '')`;
const KNOWN = `(p.current_country IS NOT NULL AND p.current_country != '')`;
const rows = await query<CountrySummary>(
`SELECT * FROM (
SELECT p.current_country, COUNT(*) AS player_count,
SUM(pt.total_minutes) AS total_minutes,
AVG(pt.total_minutes) AS avg_minutes
FROM playtime_display_players p
JOIN (
SELECT p2.steamid,
(SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes)
FROM playtime_display_sessions s
WHERE s.steamid = p2.steamid
ORDER BY s.session_id DESC
LIMIT 1) AS total_minutes
FROM playtime_display_players p2
) pt ON pt.steamid = p.steamid
WHERE ${KNOWN}
GROUP BY p.current_country
UNION ALL
SELECT NULL AS current_country, COUNT(*) AS player_count,
SUM(pt.total_minutes) AS total_minutes,
AVG(pt.total_minutes) AS avg_minutes
FROM playtime_display_players p
JOIN (
SELECT p2.steamid,
(SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes)
FROM playtime_display_sessions s
WHERE s.steamid = p2.steamid
ORDER BY s.session_id DESC
LIMIT 1) AS total_minutes
FROM playtime_display_players p2
) pt ON pt.steamid = p.steamid
WHERE ${UNKNOWN}
) t
ORDER BY player_count DESC, current_country IS NULL, current_country ASC`,
);
// Skip the Unknown bucket entirely if it's empty, rather than showing a
// dead "0 players" row.
return rows.filter((r) => r.current_country !== null || r.player_count > 0);
}
export interface CountryPlayer {
steamid: string;
current_name: string;
last_seen: string;
total_minutes: number | null;
}
export async function getPlayersByCountry(
countryCode: string | null,
sort: 'playtime' | 'recent',
): Promise<CountryPlayer[]> {
const orderBy = sort === 'playtime' ? 'total_minutes DESC' : 'p.last_seen DESC';
return query<CountryPlayer>(
`SELECT p.steamid, p.current_name, p.last_seen,
(SELECT COALESCE(session_end_playtime_minutes, session_start_playtime_minutes)
FROM playtime_display_sessions s
WHERE s.steamid = p.steamid
ORDER BY s.session_id DESC
LIMIT 1
) AS total_minutes
FROM playtime_display_players p
WHERE ${countryCode === null ? "(p.current_country IS NULL OR p.current_country = '')" : 'p.current_country = ?'}
ORDER BY ${orderBy}
LIMIT 200`,
countryCode === null ? [] : [countryCode],
);
}
export interface MapSummary {
map_name: string;
times_played: number;
last_played: string;
total_minutes: number;
}
export interface MapPeriod {
map_id: number;
map_name: string;
map_start_dt: string;
map_end_dt: string | null;
player_count: number;
}
export interface MapPresentPlayer {
steamid: string;
current_name: string;
current_country: string | null;
session_start_dt: string;
session_end_dt: string | null;
}
export interface MapVoteRow {
vote_id: number;
vote_time: string;
result: string | null;
steamid: string;
current_name: string;
current_country: string | null;
vote_choice: string;
vote_weight: number;
}
export async function searchMapNames(q: string, limit = 50, offset = 0): Promise<MapSummary[]> {
const like = `%${q}%`;
return query<MapSummary>(
`SELECT m.map_name, COUNT(*) AS times_played, MAX(m.map_start_dt) AS last_played,
SUM(TIMESTAMPDIFF(MINUTE, m.map_start_dt, COALESCE(${NEXT_MAP_START('m')}, NOW()))) AS total_minutes
FROM playtime_display_map_history m
WHERE m.map_name LIKE ?
AND ${REAL_MAP_PERIOD}
GROUP BY m.map_name
ORDER BY last_played DESC
LIMIT ? OFFSET ?`,
[like, limit, offset],
);
}
export async function getMapPeriods(mapName: string): Promise<MapPeriod[]> {
return query<MapPeriod>(
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt,
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW())
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
) AS player_count
FROM playtime_display_map_history m
WHERE m.map_name = ?
AND ${REAL_MAP_PERIOD}
ORDER BY m.map_start_dt DESC`,
[mapName],
);
}
export async function getMapPeriod(mapId: number): Promise<MapPeriod | null> {
const rows = await query<MapPeriod>(
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt,
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW())
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
) AS player_count
FROM playtime_display_map_history m
WHERE m.map_id = ?`,
[mapId],
);
return rows[0] ?? null;
}
export async function getPlayersPresentDuringMap(mapId: number): Promise<MapPresentPlayer[]> {
return query<MapPresentPlayer>(
`SELECT s.steamid, COALESCE(p.current_name, s.player_name) AS current_name,
p.current_country, s.session_start_dt, s.session_end_dt
FROM playtime_display_sessions s
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
JOIN playtime_display_map_history m ON m.map_id = ?
WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW())
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
ORDER BY s.session_start_dt`,
[mapId],
);
}
export async function getMapVotes(mapId: number): Promise<MapVoteRow[]> {
return query<MapVoteRow>(
`SELECT e.vote_id, e.vote_time, e.result,
c.steamid, COALESCE(p.current_name, c.steamid) AS current_name, p.current_country,
c.vote_choice, c.vote_weight
FROM playtime_display_map_vote_events e
JOIN playtime_display_map_vote_choices c ON c.vote_id = e.vote_id
LEFT JOIN playtime_display_players p ON p.steamid = c.steamid
WHERE e.map_id = ?
ORDER BY e.vote_time, c.vote_weight DESC`,
[mapId],
);
}
export async function getAdjacentMaps(
mapStartDt: string,
): Promise<{ prev: MapPeriod | null; next: MapPeriod | null }> {
const [prevRows, nextRows] = await Promise.all([
query<MapPeriod>(
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt
FROM playtime_display_map_history m
WHERE m.map_start_dt < ?
AND ${REAL_MAP_PERIOD}
ORDER BY m.map_start_dt DESC
LIMIT 1`,
[mapStartDt],
),
query<MapPeriod>(
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt
FROM playtime_display_map_history m
WHERE m.map_start_dt > ?
AND ${REAL_MAP_PERIOD}
ORDER BY m.map_start_dt ASC
LIMIT 1`,
[mapStartDt],
),
]);
return { prev: prevRows[0] ?? null, next: nextRows[0] ?? null };
}
export interface CurrentPlayer {
steamid: string;
current_name: string;
current_country: string | null;
}
/**
* "Currently online" defined explicitly: a session whose last heartbeat
* (session_end_dt) was within the last N minutes. This runs NOW() inside
* MySQL itself rather than passing a JS-computed timestamp, so it's immune
* to any Node/MySQL timezone mismatch — same check the admin already
* validated by hand (27 vs a real count of 29).
*
* This is deliberately separate from the historical bucket-overlap queries
* used elsewhere (population-over-time chart, getPlayersAtTime): those have
* no freshness concept at all, which is correct for a genuinely historical
* point in time, but wrong for "right now" — a session whose heartbeat
* stopped an hour ago (player disconnected without a clean close) would
* still satisfy a historical overlap check forever, silently inflating
* "currently online" counts.
*/
export async function getCurrentPlayers(freshnessMinutes = 3): Promise<CurrentPlayer[]> {
// Capped at 64 — the game's hard player limit. In principle this query
// should never find more than that many truly-simultaneous players, but
// rapid reconnects within the freshness window could in theory produce
// more distinct steamids than were ever actually online at once. Ordering
// by most-recent heartbeat and capping keeps the freshest 64, discarding
// anything older if that edge case ever occurs.
//
// LEFT JOIN (not INNER) + COALESCE fallback to the session's own stored
// player_name: an INNER JOIN here was silently dropping any session whose
// matching players row wasn't found, undercounting "currently online"
// for reasons unrelated to whether the player is actually connected.
return query<CurrentPlayer>(
`SELECT s.steamid, COALESCE(MAX(p.current_name), MAX(s.player_name)) AS current_name,
MAX(p.current_country) AS current_country
FROM playtime_display_sessions s
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
WHERE s.session_end_dt > NOW() - INTERVAL ? MINUTE
GROUP BY s.steamid
ORDER BY MAX(s.session_end_dt) DESC
LIMIT 64`,
[freshnessMinutes],
);
}
export interface PlayerAtTime {
steamid: string;
current_name: string;
current_country: string | null;
}
export async function getPlayersAtTime(atIso: string): Promise<PlayerAtTime[]> {
return query<PlayerAtTime>(
`SELECT s.steamid, COALESCE(MAX(p.current_name), MAX(s.player_name)) AS current_name,
MAX(p.current_country) AS current_country
FROM playtime_display_sessions s
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
WHERE s.session_start_dt <= ?
AND (s.session_end_dt IS NULL OR s.session_end_dt >= ?)
GROUP BY s.steamid`,
[atIso, atIso],
);
}
/**
* A player's current total playtime is just the counter snapshot on their
* most recent session row — session_end_playtime_minutes if that session
* has had at least one heartbeat/close, otherwise session_start_playtime_minutes
* (covers the brief window right after connect, before the first update).
* No need to touch `player_time` at all — this value already reflects it.
*/
export async function getTotalPlaytimeMinutes(steamid: string): Promise<number | null> {
const rows = await query<{ minutes: number | null }>(
`SELECT COALESCE(session_end_playtime_minutes, session_start_playtime_minutes) AS minutes
FROM playtime_display_sessions
WHERE steamid = ?
ORDER BY session_id DESC
LIMIT 1`,
[steamid],
);
return rows[0]?.minutes ?? null;
}
@@ -0,0 +1,39 @@
export interface RaceTimerSummary {
rank: number;
level: number; // PlayerPoints / 1000, per how the server defines "level"
profileUrl: string;
}
const RACETIMER_API_BASE = 'https://racebackend.unloze.com/racetimer_endpoints-1.0/api/timers/player';
const RACETIMER_PROFILE_BASE = 'https://racetimerweb.unloze.com/#/player';
/**
* Fetches a player's RaceTimer rank/points from the RaceTimer backend.
* Returns null if the player has no RaceTimer record (e.g. never played a
* timed map) or the request fails for any reason — this is a secondary,
* best-effort enrichment, not something that should ever break the page.
*
* Cached for an hour via Next's extended fetch() — same reasoning as the
* Steam avatar lookup.
*/
export async function getRaceTimerSummary(steamId2: string): Promise<RaceTimerSummary | null> {
try {
const res = await fetch(`${RACETIMER_API_BASE}/${encodeURIComponent(steamId2)}`, {
next: { revalidate: 3600 },
});
if (!res.ok) return null;
const data = await res.json();
if (typeof data?.PlayerPoints !== 'number' || typeof data?.Rank !== 'number') {
return null;
}
return {
rank: data.Rank,
level: Math.floor(data.PlayerPoints / 1000),
profileUrl: `${RACETIMER_PROFILE_BASE}/${encodeURIComponent(steamId2)}`,
};
} catch {
return null;
}
}
@@ -0,0 +1,47 @@
// Converts a Steam2 id ("STEAM_0:1:12345678", as stored by the plugin) into
// a Steam64 id, which is what profile URLs and avatar lookups need.
const STEAM64_BASE = 76561197960265728n;
export function steam2ToSteam64(steamId2: string): string | null {
const match = /^STEAM_[0-5]:([01]):(\d+)$/.exec(steamId2.trim());
if (!match) return null;
const y = BigInt(match[1]);
const z = BigInt(match[2]);
return (STEAM64_BASE + y + z * 2n).toString();
}
export function steamProfileUrl(steamId2: string): string | null {
const id64 = steam2ToSteam64(steamId2);
return id64 ? `https://steamcommunity.com/profiles/${id64}` : null;
}
// Formats total minutes as "123h 45m" for display. Negative values (which
// shouldn't be possible, but have shown up from anomalous underlying data —
// e.g. a player_time counter that decreased between two session snapshots)
// are shown as "—" rather than a nonsensical negative duration.
export function formatMinutes(totalMinutes: number | null): string {
if (totalMinutes == null || Number.isNaN(totalMinutes) || totalMinutes < 0) return '—';
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
return `${h}h ${m}m`;
}
// ISO 3166-1 alpha-2 -> flag emoji, e.g. "DE" -> "🇩🇪". No external assets needed.
export function countryCodeToFlag(code: string | null): string {
if (!code || code.length !== 2) return '🏳️';
const codePoints = [...code.toUpperCase()].map((c) => 0x1f1a5 + c.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
// ISO 3166-1 alpha-2 -> English display name, e.g. "DE" -> "Germany".
// Uses the built-in Intl.DisplayNames (Node 14+ / all modern browsers) —
// no extra dependency or lookup table needed.
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
export function countryCodeToName(code: string | null): string {
if (!code || code.length !== 2) return 'Unknown';
try {
return regionNames.of(code.toUpperCase()) ?? code;
} catch {
return code;
}
}
@@ -0,0 +1,121 @@
import { steam2ToSteam64 } from './steam';
export interface SteamSummary {
avatarUrl: string;
personaName: string | null;
}
// Steam's generic "no avatar set" question-mark image — used whenever a
// player has no avatar, or the lookup fails for any reason.
export const DEFAULT_AVATAR_URL =
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg';
/**
* Fetches avatar + persona name from Steam's GetPlayerSummaries endpoint.
* Always resolves to a usable avatarUrl (falls back to the default
* question-mark image on any failure — including a missing STEAM_API_KEY)
* rather than null, so callers don't need their own fallback logic.
*
* Cached for an hour via Next's extended fetch() — avatars rarely change,
* and this keeps calls well within Steam's rate limits without needing a
* separate DB cache table.
*/
export async function getSteamSummary(steamId64: string): Promise<SteamSummary> {
const apiKey = process.env.STEAM_API_KEY;
if (!apiKey) {
return { avatarUrl: DEFAULT_AVATAR_URL, personaName: null };
}
try {
const res = await fetch(
`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${apiKey}&steamids=${steamId64}`,
{ next: { revalidate: 3600 } },
);
if (!res.ok) {
return { avatarUrl: DEFAULT_AVATAR_URL, personaName: null };
}
const data = await res.json();
const player = data?.response?.players?.[0];
return {
avatarUrl: player?.avatarfull || DEFAULT_AVATAR_URL,
personaName: player?.personaname ?? null,
};
} catch {
return { avatarUrl: DEFAULT_AVATAR_URL, personaName: null };
}
}
/**
* Same as getSteamSummary, but for many players in one go — Steam's
* GetPlayerSummaries accepts up to 100 comma-separated steamids per call,
* so fetching a list of players (e.g. "who was online at this point in
* time") is one HTTP call per 100 players rather than one per player.
* Returns a map keyed by SteamID64; missing entries mean "use the default
* avatar" at the call site.
*/
export async function getSteamSummariesBatch(
steamId64List: string[],
): Promise<Map<string, SteamSummary>> {
const result = new Map<string, SteamSummary>();
const apiKey = process.env.STEAM_API_KEY;
if (!apiKey || steamId64List.length === 0) {
return result;
}
const chunks: string[][] = [];
for (let i = 0; i < steamId64List.length; i += 100) {
chunks.push(steamId64List.slice(i, i + 100));
}
await Promise.all(
chunks.map(async (chunk) => {
try {
const res = await fetch(
`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${apiKey}&steamids=${chunk.join(',')}`,
{ next: { revalidate: 3600 } },
);
if (!res.ok) return;
const data = await res.json();
const players = data?.response?.players ?? [];
for (const player of players) {
result.set(player.steamid, {
avatarUrl: player.avatarfull || DEFAULT_AVATAR_URL,
personaName: player.personaname ?? null,
});
}
} catch {
// this chunk's players just fall back to the default avatar downstream
}
}),
);
return result;
}
/**
* Attaches avatarUrl to any list of objects that have a `steamid` (Steam2
* format) field — the shared enrichment step used everywhere a list of
* players gets rendered, so every list in the app shows avatars
* consistently without duplicating the batch-fetch/fallback logic per call
* site.
*/
export async function attachAvatars<T extends { steamid: string }>(
players: T[],
): Promise<(T & { avatarUrl: string })[]> {
const steam64ByPlayer = new Map<string, string>();
for (const p of players) {
const id64 = steam2ToSteam64(p.steamid);
if (id64) steam64ByPlayer.set(p.steamid, id64);
}
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
return players.map((p) => {
const id64 = steam64ByPlayer.get(p.steamid);
const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL;
return { ...p, avatarUrl };
});
}
@@ -0,0 +1,58 @@
// The MySQL server stores/returns naive DATETIME strings (no timezone
// attached) representing wall-clock time in this zone. Hardcoded here and
// used via native Intl APIs rather than relying on the Node process's own
// TZ environment variable — this makes date handling correct regardless of
// how the process is deployed/restarted, sidestepping an entire class of
// "did the env var actually reload" deployment issues.
const DB_TIMEZONE = 'Europe/Berlin';
const partsFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: DB_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
function getParts(date: Date): Record<string, string> {
const parts: Record<string, string> = {};
for (const p of partsFormatter.formatToParts(date)) {
if (p.type !== 'literal') parts[p.type] = p.value;
}
if (parts.hour === '24') parts.hour = '00'; // some locales report midnight as 24
return parts;
}
/**
* Parses a MySQL DATETIME string (e.g. "2026-08-21 15:31:15"), which
* represents a wall-clock moment in DB_TIMEZONE, into the correct absolute
* Date/instant — regardless of the Node process's own configured timezone.
*/
export function parseDbDateTime(dbString: string): Date {
const naiveUTC = new Date(`${dbString.replace(' ', 'T')}Z`);
const parts = getParts(naiveUTC);
const asIfLocal = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second),
);
const offsetMs = naiveUTC.getTime() - asIfLocal;
return new Date(naiveUTC.getTime() + offsetMs);
}
/**
* Converts an absolute Date/instant into a MySQL DATETIME string
* ("YYYY-MM-DD HH:MM:SS") representing that instant's wall-clock time in
* DB_TIMEZONE — for building query parameters that compare correctly
* against stored DATETIME columns.
*/
export function formatDbDateTime(date: Date): string {
const parts = getParts(date);
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
}
@@ -0,0 +1,31 @@
'use client';
import { useEffect, useRef, useState } from 'react';
/**
* Tracks a container element's width with a plain resize listener, instead
* of relying on Recharts' ResponsiveContainer (which uses a ResizeObserver
* internally and can silently never fire in some browser/extension setups
* — when that happens, ResponsiveContainer renders an empty div forever,
* with no error and no fallback). This sidesteps that failure mode
* entirely: charts get an explicit pixel width from plain DOM measurement,
* which always works.
*/
export function useContainerWidth<T extends HTMLElement>(fallback = 600) {
const ref = useRef<T>(null);
const [width, setWidth] = useState(fallback);
useEffect(() => {
function measure() {
if (ref.current) {
setWidth(ref.current.clientWidth || fallback);
}
}
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [fallback]);
return { ref, width };
}
@@ -0,0 +1,32 @@
'use client';
import { useEffect, useRef } from 'react';
/**
* Calls `onIntersect` when the returned sentinel ref becomes visible near
* the bottom of the viewport — the standard "load more as you scroll"
* pattern, via IntersectionObserver rather than a scroll listener (cheaper,
* no manual throttling needed).
*/
export function useInfiniteScroll(onIntersect: () => void, enabled: boolean) {
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!enabled) return;
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) onIntersect();
},
{ rootMargin: '400px' }, // start loading a bit before the sentinel is actually on screen
);
observer.observe(el);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, onIntersect]);
return sentinelRef;
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = nextConfig;
@@ -0,0 +1,28 @@
# Example server block for stats.yourdomain.com
# Adjust the domain and cert paths, then symlink into sites-enabled.
server {
listen 80;
server_name stats.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name stats.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/stats.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stats.yourdomain.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3547;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
@@ -0,0 +1,30 @@
{
"name": "unloze-playtime-display",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3547",
"build": "next build",
"start": "next start -p 3547",
"lint": "next lint"
},
"dependencies": {
"next": "15.5.23",
"react": "18.3.1",
"react-dom": "18.3.1",
"mysql2": "3.11.3"
},
"devDependencies": {
"typescript": "5.6.3",
"@types/node": "20.16.11",
"@types/react": "18.3.11",
"@types/react-dom": "18.3.1",
"tailwindcss": "3.4.13",
"postcss": "8.5.26",
"autoprefixer": "10.4.20"
},
"overrides": {
"postcss": "8.5.26",
"sharp": "0.35.3"
}
}
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -0,0 +1,33 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
base: {
DEFAULT: '#0B0F0E', // near-black, slight green cast — server-ops feel
panel: '#121715',
border: '#1F2723',
divider: '#3C4A44', // brighter, deliberately higher-contrast — for separating list rows, not just outlining a panel
},
accent: {
DEFAULT: '#3FD37A', // "server online" green
dim: '#245C3E',
},
ink: {
DEFAULT: '#E4EBE7',
muted: '#8A9691',
faint: '#4C5652',
},
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
},
},
},
plugins: [],
};
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,232 @@
#pragma semicolon 1
#define PLUGIN_AUTHOR "jenz"
#define PLUGIN_VERSION "1.0"
#include <sourcemod>
#include <cstrike>
#include <unloze_playtime>
#include <mapchooser_extended>
#include <leader>
Database g_hDatabase;
Handle g_h_time_activity = null;
bool g_bLaserFagMap;
int g_iPlayerLaserFagTier;
int g_iPlayerKickLaserFag;
int g_iPlayerLaserFagHourRatio;
public Plugin myinfo =
{
name = "anti laser fag plugin",
author = PLUGIN_AUTHOR,
description = "stop laser fags if they play too many laser fag maps",
version = PLUGIN_VERSION,
url = "www.unloze.com"
};
public void OnPluginStart()
{
g_h_time_activity = CreateTimer(10.0, time_update_laser_fag_activity, INVALID_HANDLE, TIMER_REPEAT);
OnMapStart();
if (!g_hDatabase)
{
Database.Connect(SQL_OnDatabaseConnect, "unloze_playtimestats");
}
ConVar cvar;
HookConVarChange((cvar = CreateConVar("sm_mapchooser_laser_fag_tier", "17", "Tier required for not being kicked even if a laserfag")), Cvar_playerTierLaserFag);
g_iPlayerLaserFagTier = cvar.IntValue;
delete cvar;
ConVar cvar1;
HookConVarChange((cvar1 = CreateConVar("sm_mapchooser_laser_fag_playercount_kick", "55", "How many players on server before kicking laserfags")), Cvar_playerCountKickLaserFag);
g_iPlayerKickLaserFag = cvar1.IntValue;
delete cvar1;
ConVar cvar2;
HookConVarChange((cvar2 = CreateConVar("sm_mapchooser_laser_fag_hour_ratio", "5", "Tollerated ratio between non laser maps and laser maps played")), Cvar_playerLaserFagHourRatio);
g_iPlayerLaserFagHourRatio = cvar2.IntValue;
delete cvar2;
}
public void OnPluginEnd()
{
if (g_h_time_activity != null)
delete g_h_time_activity;
}
public Action time_update_laser_fag_activity(Handle timer, any data)
{
if (!g_hDatabase)
{
return Plugin_Continue;
}
for (int client = 1; client <= MaxClients; client++)
{
if (IsValidClient(client) && !IsFakeClient(client))
{
//if its laser fag map count all time on server, if its not laser fag map only count time when client is alive.
if (!g_bLaserFagMap && !IsPlayerAlive(client))
{
continue;
}
char sQuery[512];
char sName[MAX_NAME_LENGTH];
GetClientName(client, sName, sizeof(sName));
int size2 = 2 * strlen(sName) + 1;
char[] sEscapedName = new char[size2 + 1];
g_hDatabase.Escape(sName, sEscapedName, size2 + 1);
char sSID[64];
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
if (g_bLaserFagMap)
{
Format(sQuery, sizeof(sQuery), "update unloze_playtimestats.play_time_laser_fag set laser_fag_time = laser_fag_time + 10 where steam_id = '%s'", sSID);
}
else
{
Format(sQuery, sizeof(sQuery), "update unloze_playtimestats.play_time_laser_fag set not_laser_fag_time = not_laser_fag_time + 10 where steam_id = '%s'", sSID);
}
g_hDatabase.Query(SQL_FinishedQuery, sQuery, _, DBPrio_Low);
}
}
return Plugin_Continue;
}
public void insert_client(int client)
{
char sName[MAX_NAME_LENGTH];
GetClientName(client, sName, sizeof(sName));
int size2 = 2 * strlen(sName) + 1;
char[] sEscapedName = new char[size2 + 1];
g_hDatabase.Escape(sName, sEscapedName, size2 + 1);
char sQuery[512];
char sSID[64];
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
Format(sQuery, sizeof(sQuery), "INSERT INTO `play_time_laser_fag` (`steam_id`, `player_name`, `laser_fag_time`, `not_laser_fag_time`) VALUES ('%s', '%s', 0, 0) ON DUPLICATE KEY UPDATE `player_name` = '%s'", sSID, sEscapedName, sEscapedName);
g_hDatabase.Query(SQL_FinishedQuery, sQuery, _, DBPrio_Low);
}
public void SQL_FinishedQuery(Database db, DBResultSet results, const char[] error, any data)
{
if (!db || strlen(error))
{
LogError("Query error 3: %s", error);
}
delete results;
}
public void Cvar_playerLaserFagHourRatio(ConVar convar, const char[] oldValue, const char[] newValue)
{
g_iPlayerLaserFagHourRatio = convar.IntValue;
}
public void Cvar_playerCountKickLaserFag(ConVar convar, const char[] oldValue, const char[] newValue)
{
g_iPlayerKickLaserFag = convar.IntValue;
}
public void Cvar_playerTierLaserFag(ConVar convar, const char[] oldValue, const char[] newValue)
{
g_iPlayerLaserFagTier = convar.IntValue;
}
public void SQL_OnDatabaseConnect(Database db, const char[] error, any data)
{
if(!db || strlen(error))
{
LogError("Database error: %s", error);
return;
}
g_hDatabase = db;
for (int i = 1; i <= MaxClients; i++)
{
if(IsValidClient(i) && !IsFakeClient(i))
{
OnClientPostAdminCheck(i);
}
}
}
public void OnMapStart()
{
char map[PLATFORM_MAX_PATH];
GetCurrentMap(map, PLATFORM_MAX_PATH);
g_bLaserFagMap = GetMapLaserFag(map) == 0 ? false : true;
if (!g_hDatabase)
{
Database.Connect(SQL_OnDatabaseConnect, "unloze_playtimestats");
return;
}
}
public void OnClientPostAdminCheck(int client)
{
if (IsFakeClient(client))
{
return;
}
insert_client(client);
if (!g_bLaserFagMap)
{
return;
}
if (GetClientCount(false) < g_iPlayerKickLaserFag)
{
return;
}
int flags = GetUserFlagBits(client);
if (GetPlayerTier_native(client) >= g_iPlayerLaserFagTier || Leader_Is(client) || flags & ADMFLAG_RESERVATION)
{
return;
}
char sQuery[512];
char sSID[64];
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
Format(sQuery, sizeof(sQuery), "select laser_fag_time, not_laser_fag_time from unloze_playtimestats.play_time_laser_fag pt where pt.steam_id = '%s'", sSID);
g_hDatabase.Query(SQL_OnQueryCompleted, sQuery, GetClientSerial(client), DBPrio_High);
}
public void SQL_OnQueryCompleted(Database db, DBResultSet results, const char[] error, int iSerial)
{
if (!db || strlen(error))
{
delete results;
LogError("Query error 3: %s", error);
return;
}
int client;
if ((client = GetClientFromSerial(iSerial)) == 0)
{
delete results;
return;
}
if (results.RowCount && results.FetchRow())
{
int laser_fag_hours = (results.FetchInt(0) / 60) / 60;
int non_laser_fag_hours = (results.FetchInt(1) / 60 / 60);
if (laser_fag_hours > g_iPlayerLaserFagHourRatio && laser_fag_hours / g_iPlayerLaserFagHourRatio > non_laser_fag_hours)
{
LogMessage("Kicked Client %N for being laser fag. laser fag hours: %i. non laser fag hours: %i.", client, laser_fag_hours, non_laser_fag_hours);
KickClientEx(client, "You play too much laserfag maps. Go play normal maps or buy vip.");
}
}
delete results;
}
stock bool IsValidClient(int client)
{
if (client > 0 && client <= MaxClients && IsClientConnected(client) && IsClientInGame(client))
return true;
return false;
}
@@ -29,6 +29,9 @@ StringMap g_GroupOverrides; // group name -> comma-separated override list
static Handle g_hForwardPlayerHours; static Handle g_hForwardPlayerHours;
static Handle g_hForwardPlayerTier; static Handle g_hForwardPlayerTier;
bool G_bResponsePassed[MAXPLAYERS+1];
bool G_bPreAdminChecked[MAXPLAYERS+1];
public Plugin myinfo = public Plugin myinfo =
{ {
name = "UNLOZE_player_time", name = "UNLOZE_player_time",
@@ -159,7 +162,7 @@ public void OnPluginStart()
RegConsoleCmd("sm_topplaytime", Command_TopTime, "retreives top 1000 playtime highscores on all connected servers"); RegConsoleCmd("sm_topplaytime", Command_TopTime, "retreives top 1000 playtime highscores on all connected servers");
g_h_time_activity = CreateTimer(10.0, time_query_activity, INVALID_HANDLE, TIMER_REPEAT); g_h_time_activity = CreateTimer(10.0, time_query_activity, INVALID_HANDLE, TIMER_REPEAT);
CreateTimer(600.0, UpdateForward, _, TIMER_REPEAT); CreateTimer(60.0, UpdateForward, _, TIMER_REPEAT);
ConVar cvar; ConVar cvar;
HookConVarChange((cvar = CreateConVar("sm_mapchooser_afk_detect_time", "120", "Time in seconds until a player is considered as AFK and therefore excluded from player average time.")), Cvar_playerAFKTime); HookConVarChange((cvar = CreateConVar("sm_mapchooser_afk_detect_time", "120", "Time in seconds until a player is considered as AFK and therefore excluded from player average time.")), Cvar_playerAFKTime);
@@ -285,8 +288,6 @@ public int Native_GetPlayerWorthRTV_boost(Handle plugin, int numParams)
return view_as<int>(-1); return view_as<int>(-1);
} }
int avg = GetAveragePlayerActiveTimeServer();
//give vips the rtv/nomination/mapvote boost by default //give vips the rtv/nomination/mapvote boost by default
AdminId id = GetUserAdmin(client); AdminId id = GetUserAdmin(client);
if (id != INVALID_ADMIN_ID && GetAdminFlag(id, Admin_Reservation)) if (id != INVALID_ADMIN_ID && GetAdminFlag(id, Admin_Reservation))
@@ -300,21 +301,8 @@ public int Native_GetPlayerWorthRTV_boost(Handle plugin, int numParams)
return g_iPlayerRTVCapacity; return g_iPlayerRTVCapacity;
} }
if (g_iPlayerTimeServer[client] <= avg || avg == 0 || is_bot_player[client] || IsFakeClient(client))
{
return 1; return 1;
} }
if ((float(g_iPlayerTimeServer[client]) / float(avg)) > g_iPlayerRTVCapacity)
{
return g_iPlayerRTVCapacity; //1.0-5.0 booster probably
}
int val = RoundToFloor((g_iPlayerTimeServer[client]) / float(avg));
if (val < 1)
{
val = 1;
}
return val;
}
public void OnPluginEnd() public void OnPluginEnd()
{ {
@@ -336,20 +324,20 @@ public void SQL_OnDatabaseConnect(Database db, const char[] error, any data)
{ {
if(IsValidClient(i) && !IsFakeClient(i)) if(IsValidClient(i) && !IsFakeClient(i))
{ {
OnClientPostAdminCheck(i); OnClientAuthorized(i, g_csSID[i]);
} }
} }
OnMapStart(); OnMapStart();
} }
public void OnClientPostAdminCheck(int client) public void OnClientAuthorized(int client, const char[] sSteamID32)
{ {
g_iPlayerNextTierHours[client] = 0; g_iPlayerNextTierHours[client] = 0;
GetClientAuthId(client, AuthId_Steam2, g_csSID[client], sizeof(g_csSID[])); GetClientAuthId(client, AuthId_Steam2, g_csSID[client], sizeof(g_csSID[]));
is_bot_player[client] = false; is_bot_player[client] = false;
g_iPlayerTier[client] = -1; g_iPlayerTier[client] = -1;
g_iPlayerTimeServer[client] = 0; g_iPlayerTimeServer[client] = 0;
if(!IsValidClient(client) || IsFakeClient(client)) if(IsFakeClient(client))
return; return;
if (!g_hDatabase) if (!g_hDatabase)
{ {
@@ -436,125 +424,20 @@ public void SQL_OnQueryCompletedTimeServer(Database db, DBResultSet results, con
if (g_iPlayerTier[client] == -1) if (g_iPlayerTier[client] == -1)
{ {
SetPlayerTier(client); SetPlayerTier(client);
if (g_iPlayerTier[client] > 0) G_bResponsePassed[client] = true;
if (G_bPreAdminChecked[client])
{ {
WritePlayerTierToFile(client); //checks if all tiers have to be written NotifyPostAdminCheck(client);
} }
} }
else if (check_if_client_new_tier(client)) //called every 10 minutes to fill new times from DB else if (check_if_client_new_tier(client))
{ {
SetPlayerTier(client); SetPlayerTier(client);
WritePlayerTierToFile(client); //write only the new tier to the file at the end.
}
}
public void WritePlayerTierToFile(int client)
{
char sPath[PLATFORM_MAX_PATH];
BuildPath(Path_SM, sPath, sizeof(sPath), "configs/admins_simple.ini");
// --- Read entire file into memory ---
File f = OpenFile(sPath, "r");
if (!f)
{
LogError("WritePlayerTierToFile: failed to open %s for reading", sPath);
return;
}
bool foundSeparator = false;
//always write the steamIDs as steamID 2 format.
char sSID[64];
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
bool write_all_tiers = true;
bool write_newest_tier = false;
char line[256];
while (f.ReadLine(line, sizeof(line)))
{
int len = strlen(line);
while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r'))
line[--len] = '\0';
if (StrEqual(line, "//here begins the unloze_play_time plugin tiers", false))
{
foundSeparator = true;
continue;
}
if (!foundSeparator)
{
continue;
}
// Each line looks like: "STEAM_0:0:12345" "@tier3"
// Extract steam ID and tier number
char steamid[65];
char group[64];
int pos = 1; //skip opening quote
int sidStart = pos;
while (line[pos] != '"')
{
pos++;
}
int sidLen = pos - sidStart;
strcopy(steamid, sidLen + 1, line[sidStart]);
if (!StrEqual(steamid, sSID))
{
continue;
}
//if steam ID does not exist write all tiers
write_all_tiers = false;
write_newest_tier = true;
pos++; // skip closing quote of steamid
while (line[pos] == ' ' || line[pos] == '\t') pos++; // skip whitespace
pos++; // skip opening quote of group
int grpStart = pos;
while (line[pos] != '"') pos++;
int grpLen = pos - grpStart;
strcopy(group, grpLen + 1, line[grpStart]);
// group is "@tier3", extract tier number
char tierStr[8];
strcopy(tierStr, sizeof(tierStr), group[5]); // skip "@tier"
int tierNum = StringToInt(tierStr);
if (tierNum == g_iPlayerTier[client])
{
write_newest_tier = false;
break; //we confirmed the most recent tier was already written here.
}
}
delete f;
if (write_all_tiers)
{
// Open file for appending
File fAppend = OpenFile(sPath, "a");
for (int t = 1; t <= g_iPlayerTier[client]; t++)
{
char linewrite[256];
Format(linewrite, sizeof(linewrite), "\"%s\" \"@tier%i\"\n", sSID, t);
fAppend.WriteString(linewrite, false);
}
delete fAppend;
SetTierRewards(client);
}
else if (write_newest_tier)
{
// Open file for appending
File fAppend = OpenFile(sPath, "a");
char linewrite[256];
Format(linewrite, sizeof(linewrite), "\"%s\" \"@tier%i\"\n", sSID, g_iPlayerTier[client]);
fAppend.WriteString(linewrite, false);
delete fAppend;
SetTierRewards(client); SetTierRewards(client);
} }
} }
public bool check_if_client_new_tier(int client) public bool check_if_client_new_tier(int client) //called every 10 minutes to fill new times from DB
{ {
char sPath[PLATFORM_MAX_PATH]; char sPath[PLATFORM_MAX_PATH];
BuildPath(Path_SM, sPath, sizeof(sPath), "configs/unloze_playt_time_tiers.cfg"); BuildPath(Path_SM, sPath, sizeof(sPath), "configs/unloze_playt_time_tiers.cfg");
@@ -589,7 +472,36 @@ public bool check_if_client_new_tier(int client)
} }
if (tier > g_iPlayerTier[client]) if (tier > g_iPlayerTier[client])
{ {
PrintToChatAll("%N Leveled up to Tier: %i!", client, tier); char new_rewards[512];
if (g_iRtvBoost_tier == g_iPlayerTier[client])
{
Format(new_rewards, sizeof(new_rewards), "%ix Mapvote/RTV/Nomination boost", g_iPlayerRTVCapacity);
PrintToChatAll("\x03%N \x01Leveled up to Tier \x04%i\x01 and unlocked: \x04%s", client, tier, new_rewards);
}
if (FindConVar("sm_spraymanager_tier_required").IntValue == g_iPlayerTier[client])
{
Format(new_rewards, sizeof(new_rewards), "Spray access");
PrintToChatAll("\x03%N \x01Leveled up to Tier \x04%i\x01 and unlocked: \x04%s", client, tier, new_rewards);
}
if (FindConVar("sm_entwatch_tier_requirement").IntValue == g_iPlayerTier[client])
{
Format(new_rewards, sizeof(new_rewards), "Item access");
PrintToChatAll("\x03%N \x01Leveled up to Tier \x04%i\x01 and unlocked: \x04%s", client, tier, new_rewards);
}
if (FindConVar("mce_multiple_nominations_tier").IntValue == g_iPlayerTier[client])
{
Format(new_rewards, sizeof(new_rewards), "Multiple nominations");
PrintToChatAll("\x03%N \x01Leveled up to Tier \x04%i\x01 and unlocked: \x04%s", client, tier, new_rewards);
}
if (g_GroupFlags[g_iPlayerTier[client]][0] != '\0' && StrEqual(g_GroupFlags[g_iPlayerTier[client]], "aop"))
{
Format(new_rewards, sizeof(new_rewards), "VIP");
PrintToChatAll("\x03%N \x01Leveled up to Tier \x04%i\x01 and unlocked: \x04%s", client, tier, new_rewards);
}
if (g_GroupOverrides.GetString(g_GroupNames[g_iPlayerTier[client]], new_rewards, sizeof(new_rewards)))
{
PrintToChatAll("\x03%N \x01Leveled up to Tier \x04%i\x01 and unlocked: \x04%s", client, tier, new_rewards);
}
upgrade_tier = true; upgrade_tier = true;
} }
@@ -598,20 +510,6 @@ public bool check_if_client_new_tier(int client)
return upgrade_tier; return upgrade_tier;
} }
public void SetTierRewards(int client)
{
if (g_iPlayerTier[client] < 1)
{
return;
}
for (int i = 1; i <= g_iPlayerTier[client]; i++)
{
char groupname[64];
Format(groupname, sizeof(groupname), "tier%i", i);
AddClientToGroup(client, groupname);
}
}
public void AddClientToGroup(int client, const char[] groupName) public void AddClientToGroup(int client, const char[] groupName)
{ {
AdminId id = GetUserAdmin(client); AdminId id = GetUserAdmin(client);
@@ -673,16 +571,10 @@ public void SetPlayerTier(int client)
g_iPlayerTier[client] = 0; g_iPlayerTier[client] = 0;
} }
Call_StartForward(g_hForwardPlayerTier);
Call_PushCell(client);
Call_PushCell(g_iPlayerTier[client]);
Call_Finish();
if (g_iPlayerTimeServer[client] < next_hours) if (g_iPlayerTimeServer[client] < next_hours)
{ {
g_iPlayerNextTierHours[client] = next_hours - g_iPlayerTimeServer[client]; g_iPlayerNextTierHours[client] = next_hours - g_iPlayerTimeServer[client];
PrintToChat(client, "You need %i hours to reach next tier. Check your features with !sm_tier", g_iPlayerNextTierHours[client]);
} }
else
delete kv; delete kv;
} }
@@ -694,6 +586,8 @@ public void OnClientDisconnect(int client)
Format(g_csSID[client], sizeof(g_csSID[]), ""); Format(g_csSID[client], sizeof(g_csSID[]), "");
is_bot_player[client] = false; is_bot_player[client] = false;
g_iPlayerTimeServer[client] = 0; g_iPlayerTimeServer[client] = 0;
G_bPreAdminChecked[client] = false;
G_bResponsePassed[client] = false;
} }
public void insert_client(int client) public void insert_client(int client)
@@ -809,44 +703,74 @@ public void PrintClientGroupOverrides(int client)
char multi_nomination_tier[32]; char multi_nomination_tier[32];
Format(multi_nomination_tier, sizeof(multi_nomination_tier), "tier%i", FindConVar("mce_multiple_nominations_tier").IntValue); Format(multi_nomination_tier, sizeof(multi_nomination_tier), "tier%i", FindConVar("mce_multiple_nominations_tier").IntValue);
char lvl_chat_tag[32];
Format(lvl_chat_tag, sizeof(lvl_chat_tag), "tier%i", FindConVar("sm_display_lvl_chat").IntValue);
char lvl_clan_tag[32];
Format(lvl_clan_tag, sizeof(lvl_clan_tag), "tier%i", FindConVar("sm_display_lvl_clan").IntValue);
char lvl_chat_tag_multicolour[32];
Format(lvl_chat_tag_multicolour, sizeof(lvl_chat_tag_multicolour), "tier%i", FindConVar("sm_display_lvl_chat_multi_colour").IntValue);
char lvl_chat_tag_allcolour[32];
Format(lvl_chat_tag_allcolour, sizeof(lvl_chat_tag_allcolour), "tier%i", FindConVar("sm_display_lvl_chat_all_colour").IntValue);
menu.AddItem("-1", "Several tiers: playermodels in !zclass.", ITEMDRAW_DISABLED);
menu.AddItem("-1", "Several tiers: grenade skins in !grenadeskins.", ITEMDRAW_DISABLED);
menu.AddItem("-1", "Several tiers: Trail sprites in !trails.", ITEMDRAW_DISABLED);
for (int i = 0; i < g_GroupNameCount; i++) for (int i = 0; i < g_GroupNameCount; i++)
{ {
char entry[128]; char entry[512];
char overrides[512]; char overrides[512];
if (StrEqual(g_GroupNames[i], rtv_boost_tier)) if (StrEqual(g_GroupNames[i], rtv_boost_tier))
{ {
Format(entry, sizeof(entry), "%s: %ix Mapvote/RTV/Nomination boost", g_GroupNames[i], g_iPlayerRTVCapacity); Format(entry, sizeof(entry), "%s%s: %ix Mapvote/RTV/Nomination boost, ", entry, g_GroupNames[i], g_iPlayerRTVCapacity);
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
} }
if (StrEqual(g_GroupNames[i], spray_tier)) if (StrEqual(g_GroupNames[i], spray_tier))
{ {
Format(entry, sizeof(entry), "%s: Sprays", g_GroupNames[i]); Format(entry, sizeof(entry), "%s%s: Sprays, ", entry, g_GroupNames[i]);
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
} }
if (StrEqual(g_GroupNames[i], entwatch_tier)) if (StrEqual(g_GroupNames[i], entwatch_tier))
{ {
Format(entry, sizeof(entry), "%s: Items", g_GroupNames[i]); Format(entry, sizeof(entry), "%s%s: Items, ", entry, g_GroupNames[i]);
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
} }
if (StrEqual(g_GroupNames[i], multi_nomination_tier)) if (StrEqual(g_GroupNames[i], multi_nomination_tier))
{ {
Format(entry, sizeof(entry), "%s: Multiple nominations", g_GroupNames[i]); Format(entry, sizeof(entry), "%s%s: Multiple nominations, ", entry, g_GroupNames[i]);
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
} }
if (StrEqual(g_GroupNames[i], lvl_chat_tag))
{
Format(entry, sizeof(entry), "%s%s: LVL chat tag with one colour, ", entry, g_GroupNames[i]);
}
if (StrEqual(g_GroupNames[i], lvl_clan_tag))
{
Format(entry, sizeof(entry), "%s%s: LVL clan tag, ", entry, g_GroupNames[i]);
}
if (StrEqual(g_GroupNames[i], lvl_chat_tag_multicolour))
{
Format(entry, sizeof(entry), "%s%s: LVL chat tag with multi colours, ", entry, g_GroupNames[i]);
}
if (StrEqual(g_GroupNames[i], lvl_chat_tag_allcolour))
{
Format(entry, sizeof(entry), "%s%s: LVL chat tag with all colours, ", entry, g_GroupNames[i]);
}
if (g_GroupFlags[i][0] != '\0' && StrEqual(g_GroupFlags[i], "aop")) if (g_GroupFlags[i][0] != '\0' && StrEqual(g_GroupFlags[i], "aop"))
{ {
Format(entry, sizeof(entry), "%s: VIP", g_GroupNames[i]); Format(entry, sizeof(entry), "%s%s: VIP, ", entry, g_GroupNames[i]);
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
} }
if (g_GroupOverrides.GetString(g_GroupNames[i], overrides, sizeof(overrides))) if (g_GroupOverrides.GetString(g_GroupNames[i], overrides, sizeof(overrides)))
{ {
Format(entry, sizeof(entry), "%s: %s", g_GroupNames[i], overrides); Format(entry, sizeof(entry), "%s%s: %s, ", entry, g_GroupNames[i], overrides);
}
if (strlen(entry) > 0)
{
menu.AddItem("-1", entry, ITEMDRAW_DISABLED); menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
} }
} }
menu.AddItem("-1", "any tier: playermodels in zclass.", ITEMDRAW_DISABLED);
menu.ExitButton = true; menu.ExitButton = true;
menu.Display(client, 0); menu.Display(client, 0);
} }
@@ -989,3 +913,78 @@ public int Handler_Menu(Menu menu, MenuAction action, int param1, int param2)
} }
return 0; return 0;
} }
public void SetTierRewards(int client)
{
if (!G_bResponsePassed[client])
{
return;
}
PrintToChat(client, "You need %i hours to reach next tier. Check your features with !sm_tier", g_iPlayerNextTierHours[client]);
if (g_iPlayerTier[client] < 1)
{
return;
}
for (int i = 1; i <= g_iPlayerTier[client]; i++)
{
char groupname[64];
Format(groupname, sizeof(groupname), "tier%i", i);
AddClientToGroup(client, groupname);
}
}
//----------------------------------------------------------------------------------------------------
// Purpose:
//----------------------------------------------------------------------------------------------------
public Action OnRebuildAdminCachePost(Handle hTimer)
{
for (int client = 1; client <= MaxClients; client++)
{
if(G_bResponsePassed[client] && G_bPreAdminChecked[client])
SetTierRewards(client);
}
return Plugin_Handled;
}
public Action OnClientPreAdminCheck(int client)
{
G_bPreAdminChecked[client] = true;
if (G_bResponsePassed[client])
{
return Plugin_Continue;
}
RunAdminCacheChecks(client);
return Plugin_Handled;
}
public void OnClientConnected(int client)
{
G_bPreAdminChecked[client] = false;
G_bResponsePassed[client] = false;
}
public void OnClientPostAdminFilter(int client)
{
if (IsFakeClient(client))
{
return;
}
SetTierRewards(client);
Call_StartForward(g_hForwardPlayerTier);
Call_PushCell(client);
Call_PushCell(g_iPlayerTier[client]);
Call_Finish();
}
public void OnRebuildAdminCache(AdminCachePart part)
{
if (part != AdminCache_Admins)
return;
CreateTimer(1.0, OnRebuildAdminCachePost, INVALID_HANDLE, TIMER_FLAG_NO_MAPCHANGE);
}
@@ -0,0 +1,338 @@
#pragma semicolon 1
#define PLUGIN_AUTHOR "jenz"
#define PLUGIN_VERSION "1.1"
#include <sourcemod>
#include <geoip>
#include <unloze_playtime>
#include <mapchooser_extended>
#include <cstrike>
Database g_hDatabase;
bool g_bCorrectServer = false;
int g_iClientHours[MAXPLAYERS + 1];
int g_iClientMinutes[MAXPLAYERS + 1];
ArrayList g_hPendingVoteChoices;
public Plugin myinfo =
{
name = "UNLOZE_playtime_display",
author = PLUGIN_AUTHOR,
description = "collects all info for playtime display",
version = PLUGIN_VERSION,
url = "www.unloze.com"
};
enum struct VoteChoiceRecord
{
char SteamId[64];
char VoteChoice[64];
int VoteWeight;
}
public void OnPluginStart()
{
if (!g_hDatabase)
{
Database.Connect(SQL_OnDatabaseConnect, "unloze_playtimestats");
}
g_hPendingVoteChoices = new ArrayList(sizeof(VoteChoiceRecord));
g_bCorrectServer = GetConVarInt(FindConVar("hostport")) == 27015 ? true : false;
}
public void SQL_OnDatabaseConnect(Database db, const char[] error, any data)
{
if(!db || strlen(error))
{
LogError("Database error: %s", error);
return;
}
g_hDatabase = db;
OnMapStart();
}
public void OnMapStart()
{
if (!g_bCorrectServer)
{
return;
}
if (!g_hDatabase)
{
Database.Connect(SQL_OnDatabaseConnect, "unloze_playtimestats");
return;
}
char sMapname[64];
GetCurrentMap(sMapname, sizeof(sMapname));
insert_map(sMapname);
}
public void insert_map(char sMapname[64])
{
char sQuery[512];
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_map_history` (map_name) VALUES('%s')", sMapname);
g_hDatabase.Query(SQL_FinishedQuery, sQuery, _, DBPrio_Normal);
}
//forward is called from OnClientAuthorized but also from timer every 1 minute.
public void GetPlayerHoursServer(int client, int hours, int minutes)
{
if (!g_bCorrectServer)
{
return;
}
g_iClientHours[client] = hours;
g_iClientMinutes[client] = minutes;
select_user_session(client);
}
public void select_user_session(int client)
{
char sSID[64];
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
char sQuery[512];
Format(sQuery, sizeof(sQuery), "select 1 from `playtime_display_sessions` where steamid = '%s' and session_end_dt > NOW() - INTERVAL 10 MINUTE limit 1", sSID);
g_hDatabase.Query(SQL_FinishedQuerySelectUserSession, sQuery, GetClientSerial(client), DBPrio_Normal);
}
public void SQL_FinishedQuerySelectUserSession(Database db, DBResultSet results, const char[] error, int Serial)
{
if (!db || strlen(error))
{
LogError("Query error 3: %s", error);
delete results;
return;
}
int client;
if ((client = GetClientFromSerial(Serial)) == 0)
{
delete results;
return;
}
if (!IsValidClient(client))
{
delete results;
return;
}
if (results.RowCount && results.FetchRow())
{
//active session that we keep updating
update_play_session(client, g_iClientHours[client], g_iClientMinutes[client]);
}
else
{
//new session that we insert
//chaining the calls
insert_player(client);
}
delete results;
}
public void update_play_session(int client, int client_hours, int client_minutes)
{
char sSID[64];
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
//we only update the latest session row if it actually was updated inside the last 10 minutes. otherwise its an old session that should not be updated.
char sQuery[512];
Format(sQuery, sizeof(sQuery), "UPDATE `playtime_display_sessions` SET session_end_playtime_minutes='%i', session_end_dt=NOW() WHERE steamid = '%s' and session_end_dt > NOW() - INTERVAL 10 MINUTE order by session_id desc limit 1", (client_hours * 60) + client_minutes, sSID);
g_hDatabase.Query(SQL_FinishedQuery, sQuery, _, DBPrio_Normal);
}
public void insert_player(int client)
{
char sName[MAX_NAME_LENGTH];
GetClientName(client, sName, sizeof(sName));
int size2 = 2 * strlen(sName) + 1;
char[] sEscapedName = new char[size2 + 1];
g_hDatabase.Escape(sName, sEscapedName, size2 + 1);
char sIP[32];
GetClientIP(client, sIP, sizeof(sIP));
char countryCode[3];
GeoipCode2(sIP, countryCode);
char sSID[64];
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
int client_tier = GetPlayerTier_native(client);
char sQuery[512];
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_players` (`steamid`, `current_name`, `current_country`, `player_tier`) VALUES('%s', '%s', '%s', '%i') ON DUPLICATE KEY UPDATE `current_name` = '%s', `current_country` = '%s', `last_seen` = CURRENT_TIMESTAMP, `player_tier` = '%i'", sSID, sEscapedName, countryCode, client_tier, sEscapedName, countryCode, client_tier);
g_hDatabase.Query(SQL_FinishedQueryUpsertPlayerCallback, sQuery, GetClientSerial(client), DBPrio_Normal);
}
public void SQL_FinishedQueryUpsertPlayerCallback(Database db, DBResultSet results, const char[] error, int Serial)
{
if (!db || strlen(error))
{
LogError("Query error 3: %s", error);
delete results;
return;
}
int client;
if ((client = GetClientFromSerial(Serial)) == 0)
{
delete results;
return;
}
if (!IsValidClient(client))
{
delete results;
return;
}
delete results;
upsert_name_history(client);
}
public void upsert_name_history(int client)
{
char sName[MAX_NAME_LENGTH];
GetClientName(client, sName, sizeof(sName));
int size2 = 2 * strlen(sName) + 1;
char[] sEscapedName = new char[size2 + 1];
g_hDatabase.Escape(sName, sEscapedName, size2 + 1);
char sSID[64];
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
char sQuery[512];
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_name_history` (`steamid`, `player_name`) VALUES('%s', '%s') ON DUPLICATE KEY UPDATE `player_name` = '%s', `last_seen` = CURRENT_TIMESTAMP", sSID, sEscapedName, sEscapedName);
g_hDatabase.Query(SQL_FinishedQueryUpsertNameHistoryCallback, sQuery, GetClientSerial(client), DBPrio_Normal);
}
public void SQL_FinishedQueryUpsertNameHistoryCallback(Database db, DBResultSet results, const char[] error, int Serial)
{
if (!db || strlen(error))
{
LogError("Query error 3: %s", error);
delete results;
return;
}
int client;
if ((client = GetClientFromSerial(Serial)) == 0)
{
delete results;
return;
}
if (!IsValidClient(client))
{
delete results;
return;
}
delete results;
insert_play_session(client, g_iClientHours[client], g_iClientMinutes[client]);
}
public void insert_play_session(int client, int hours, int minutes)
{
char sName[MAX_NAME_LENGTH];
GetClientName(client, sName, sizeof(sName));
int size2 = 2 * strlen(sName) + 1;
char[] sEscapedName = new char[size2 + 1];
g_hDatabase.Escape(sName, sEscapedName, size2 + 1);
char sIP[32];
GetClientIP(client, sIP, sizeof(sIP));
char countryCode[3];
GeoipCode2(sIP, countryCode);
char sSID[64];
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
char sQuery[512];
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_sessions` (`steamid`, `country_code`, `player_name`, `session_start_playtime_minutes`, `session_end_dt`) VALUES('%s', '%s','%s','%i', CURRENT_TIMESTAMP)", sSID, countryCode, sEscapedName, (hours * 60) + minutes);
g_hDatabase.Query(SQL_FinishedQuery, sQuery, _, DBPrio_Normal);
}
public void SQL_FinishedQuery(Database db, DBResultSet results, const char[] error, any data)
{
if (!db || strlen(error))
{
LogError("Query error 3: %s", error);
}
delete results;
}
public void OnClientDisconnect(int client)
{
if (g_iClientHours[client] == 0 && g_iClientMinutes[client] == 0)
{
return;
}
update_play_session(client, g_iClientHours[client], g_iClientMinutes[client]);
g_iClientHours[client] = 0;
g_iClientMinutes[client] = 0;
}
public void OnMapVoteRunnoffWarningStart()
{
if (!g_bCorrectServer)
{
return;
}
char sMapname[64];
GetCurrentMap(sMapname, sizeof(sMapname));
char sQuery[512]; //picking max() is usually considered haram
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_map_vote_events` (`map_id`, `current_map`, `result`) VALUES((select max(map_id) from `playtime_display_map_history`), '%s','Revote needed!')", sMapname);
g_hDatabase.Query(SQL_FinishedQueryMapVoteEndCallback, sQuery, _, DBPrio_Normal);
}
public void OnMapVoteEnd(const char[] map)
{
if (!g_bCorrectServer)
{
return;
}
char sMapname[64];
GetCurrentMap(sMapname, sizeof(sMapname));
char sQuery[512]; //picking max() is usually considered haram
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_map_vote_events` (`map_id`, `current_map`, `result`) VALUES((select max(map_id) from `playtime_display_map_history`), '%s','%s')", sMapname, map);
g_hDatabase.Query(SQL_FinishedQueryMapVoteEndCallback, sQuery, _, DBPrio_Normal);
}
public void SQL_FinishedQueryMapVoteEndCallback(Database db, DBResultSet results, const char[] error, any data)
{
if (!db || strlen(error))
{
LogError("Query error 3: %s", error);
}
delete results;
for (int j = 0; j < GetArraySize(g_hPendingVoteChoices); j++)
{
VoteChoiceRecord entry;
GetArrayArray(g_hPendingVoteChoices, j, entry);
char sQuery[512]; //again, selecting with max() is generally not very good but should be reliable given the context here.
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_map_vote_choices` (`vote_id`, `steamid`, `vote_choice`, `vote_weight`) VALUES ((select max(vote_id) from `playtime_display_map_vote_events`), '%s', '%s','%i')", entry.SteamId, entry.VoteChoice, entry.VoteWeight);
g_hDatabase.Query(SQL_FinishedQuery, sQuery, _, DBPrio_Normal);
}
g_hPendingVoteChoices.Clear();
}
public void OnMapVoteClientResult(char[] steamID, char[] vote_choice, int voteweight)
{
VoteChoiceRecord entry;
strcopy(entry.SteamId, sizeof(entry.SteamId), steamID);
strcopy(entry.VoteChoice, sizeof(entry.VoteChoice), vote_choice);
entry.VoteWeight = voteweight;
g_hPendingVoteChoices.PushArray(entry);
}
stock bool IsValidClient(int client)
{
if (client > 0 && client <= MaxClients && IsClientConnected(client) && IsClientInGame(client))
return true;
return false;
}
@@ -0,0 +1,7 @@
CREATE TABLE unloze_playtimestats.play_time_laser_fag (
`steam_id` varchar(64) NOT NULL,
`player_name` varchar(256) NOT NULL,
`laser_fag_time` bigint(20) NOT NULL,
`not_laser_fag_time` bigint(20) NOT NULL,
PRIMARY KEY (`steam_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
@@ -0,0 +1,84 @@
CREATE TABLE `playtime_display_map_history` (
`map_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`map_name` varchar(64) NOT NULL,
`map_start_dt` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`map_id`),
KEY `idx_map_name` (`map_name`,`map_start_dt`),
KEY `idx_start` (`map_start_dt`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `playtime_display_map_vote_events` (
`vote_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`map_id` bigint(20) unsigned DEFAULT NULL,
`vote_time` datetime NOT NULL DEFAULT current_timestamp(),
`current_map` varchar(64) DEFAULT NULL,
`result` varchar(64) DEFAULT NULL,
PRIMARY KEY (`vote_id`),
KEY `idx_map_id` (`map_id`),
CONSTRAINT `fk_voteevent_map` FOREIGN KEY (`map_id`) REFERENCES `playtime_display_map_history` (`map_id`) ON DELETE SET NULL
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `playtime_display_players` (
`steamid` varchar(64) NOT NULL,
`current_name` varchar(128) NOT NULL,
`current_country` char(2) DEFAULT NULL,
`first_seen` datetime NOT NULL DEFAULT current_timestamp(),
`last_seen` datetime NOT NULL DEFAULT current_timestamp(),
`player_tier` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`steamid`),
KEY `idx_country` (`current_country`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `playtime_display_name_history` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`steamid` varchar(64) NOT NULL,
`player_name` varchar(128) NOT NULL,
`first_seen` datetime NOT NULL DEFAULT current_timestamp(),
`last_seen` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uq_steamid_name` (`steamid`,`player_name`),
KEY `idx_player_name` (`player_name`),
CONSTRAINT `fk_namehist_steamid` FOREIGN KEY (`steamid`) REFERENCES `playtime_display_players` (`steamid`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=197 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `playtime_display_map_vote_choices` (
`vote_id` int(10) unsigned NOT NULL,
`steamid` varchar(64) NOT NULL,
`vote_choice` varchar(64) NOT NULL,
`vote_weight` int(11) NOT NULL DEFAULT 1,
PRIMARY KEY (`vote_id`,`steamid`),
KEY `idx_vote_choice` (`vote_choice`),
KEY `fk_votechoice_steamid` (`steamid`),
CONSTRAINT `fk_votechoice_event` FOREIGN KEY (`vote_id`) REFERENCES `playtime_display_map_vote_events` (`vote_id`) ON DELETE CASCADE,
CONSTRAINT `fk_votechoice_steamid` FOREIGN KEY (`steamid`) REFERENCES `playtime_display_players` (`steamid`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `playtime_display_sessions` (
`session_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`steamid` varchar(64) NOT NULL,
`country_code` char(2) DEFAULT NULL,
`player_name` varchar(128) NOT NULL,
`session_start_playtime_minutes` bigint(20) NOT NULL,
`session_end_playtime_minutes` bigint(20) DEFAULT NULL,
`session_start_dt` datetime NOT NULL DEFAULT current_timestamp(),
`session_end_dt` datetime DEFAULT NULL,
`session_active_minutes` bigint(20) GENERATED ALWAYS AS (`session_end_playtime_minutes` - `session_start_playtime_minutes`) STORED,
`session_start_dow` tinyint(4) GENERATED ALWAYS AS (dayofweek(`session_start_dt`)) STORED,
`session_start_tod` time GENERATED ALWAYS AS (cast(`session_start_dt` as time)) STORED,
PRIMARY KEY (`session_id`),
KEY `idx_steamid_start` (`steamid`,`session_start_dt`),
KEY `idx_interval` (`session_start_dt`,`session_end_dt`),
KEY `idx_dow_tod` (`session_start_dow`,`session_start_tod`),
KEY `idx_country` (`country_code`),
KEY `idx_steamid_end` (`steamid`,`session_end_dt`),
KEY `idx_steamid_session_id` (`steamid`,`session_id`),
CONSTRAINT `fk_session_steamid` FOREIGN KEY (`steamid`) REFERENCES `playtime_display_players` (`steamid`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+37 -16
View File
@@ -4,27 +4,48 @@ if [ $# -eq 1 ]
then then
echo 'doing long run with all files being updated.' echo 'doing long run with all files being updated.'
#svencoop #svencoop
rclone copy /home/gameservers/fastdl/svencoop/svencoop_addon r2demo:/svencoop/ rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/svencoop/svencoop_addon r2demo:unloze-r2/svencoop/
#gmod zs
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/maps r2demo:/gmod_zs/maps/ #ZE server
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/materials r2demo:/gmod_zs/materials/ rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/materials r2demo:unloze-r2/css_ze/materials/
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/models r2demo:/gmod_zs/models/ rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/models r2demo:unloze-r2/css_ze/models/
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/sound r2demo:/gmod_zs/sound/ rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/sound r2demo:unloze-r2/css_ze/sound/
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/resource r2demo:/gmod_zs/resource/ rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/maps r2demo:unloze-r2/css_ze/maps/
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/particles r2demo:/gmod_zs/particles/
#ZR server
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/materials r2demo:unloze-r2/css_zr/materials/
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/models r2demo:unloze-r2/css_zr/models/
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/sound r2demo:unloze-r2/css_zr/sound/
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/maps r2demo:unloze-r2/css_zr/maps/
#MG server
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/materials r2demo:unloze-r2/css_mg/materials/
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/models r2demo:unloze-r2/css_mg/models/
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/sound r2demo:unloze-r2/css_mg/sound/
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/maps r2demo:unloze-r2/css_mg/maps/
echo 'Finished long run with all files updated.' echo 'Finished long run with all files updated.'
#else if no arguement given run default with 30minute maximum. #else if no arguement given run default with 30minute maximum.
else else
echo 'doing short run with files younger than 30 minutes.' echo 'doing short run with files younger than 30 minutes.'
#svencoop #svencoop
rclone copy --max-age 30m --no-traverse /home/gameservers/fastdl/svencoop/svencoop_addon r2demo:/svencoop/ rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/svencoop/svencoop_addon r2demo:unloze-r2/svencoop/
#gmod zs #ZE server
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/maps r2demo:/gmod_zs/maps/ rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/materials r2demo:unloze-r2/css_ze/materials/
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/materials r2demo:/gmod_zs/materials/ rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/models r2demo:unloze-r2/css_ze/models/
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/models r2demo:/gmod_zs/models/ rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/sound r2demo:unloze-r2/css_ze/sound/
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/sound r2demo:/gmod_zs/sound/ rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/maps r2demo:unloze-r2/css_ze/maps/
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/resource r2demo:/gmod_zs/resource/
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/particles r2demo:/gmod_zs/particles/ #ZR server
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/materials r2demo:unloze-r2/css_zr/materials/
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/models r2demo:unloze-r2/css_zr/models/
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/sound r2demo:unloze-r2/css_zr/sound/
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/maps r2demo:unloze-r2/css_zr/maps/
#MG server
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/materials r2demo:unloze-r2/css_mg/materials/
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/models r2demo:unloze-r2/css_mg/models/
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/sound r2demo:unloze-r2/css_mg/sound/
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/maps r2demo:unloze-r2/css_mg/maps/
echo 'Finished short run with files younger than 30 minutes.' echo 'Finished short run with files younger than 30 minutes.'
fi fi
@@ -2,7 +2,8 @@
#include <sdktools> #include <sdktools>
int map_switches_count = -1; int map_switches_count = -1;
char g_cMaps[500][256]; char g_cMaps[800][256];
int g_i_rotate_time = 1;
//---------------------------------------------------------------------------------------------------- //----------------------------------------------------------------------------------------------------
// Purpose: // Purpose:
@@ -16,11 +17,20 @@ public Plugin myinfo =
url = "www.unloze.com" url = "www.unloze.com"
}; };
public void Cvar_rotate_time(ConVar convar, const char[] oldValue, const char[] newValue)
{
g_i_rotate_time = convar.IntValue;
}
//---------------------------------------------------------------------------------------------------- //----------------------------------------------------------------------------------------------------
// Purpose: // Purpose:
//---------------------------------------------------------------------------------------------------- //----------------------------------------------------------------------------------------------------
public void OnPluginStart() public void OnPluginStart()
{ {
ConVar cvar;
HookConVarChange((cvar = CreateConVar("sm_map_rotating_time", "5", "time in seconds for map rotation")), Cvar_rotate_time);
g_i_rotate_time = cvar.IntValue;
delete cvar;
RegAdminCmd("sm_restartmaps_test", cmd_restart_the_map, ADMFLAG_GENERIC); RegAdminCmd("sm_restartmaps_test", cmd_restart_the_map, ADMFLAG_GENERIC);
new Handle:fileHandle = OpenFile("wtf.txt", "r" ); new Handle:fileHandle = OpenFile("wtf.txt", "r" );
@@ -45,17 +55,17 @@ public Action cmd_restart_the_map(int client, int args)
public void OnMapStart() public void OnMapStart()
{ {
if (map_switches_count != -1 && map_switches_count < 400) if (map_switches_count != -1 && map_switches_count < 500)
{ {
CreateTimer(5.0, time_query_activity); CreateTimer(float(g_i_rotate_time), time_query_activity);
} }
} }
public Action time_query_activity(Handle timer, any data) public Action time_query_activity(Handle timer, any data)
{ {
LogError("restart count reached: %i", map_switches_count);
int local = map_switches_count; int local = map_switches_count;
map_switches_count++; map_switches_count++;
LogError("restart count reached: %i. nextmap: %s", map_switches_count, g_cMaps[local]);
ForceChangeLevel(g_cMaps[local], ""); ForceChangeLevel(g_cMaps[local], "");
return Plugin_Handled; return Plugin_Handled;
} }
@@ -4,6 +4,7 @@
#include <sourcemod> #include <sourcemod>
#include <dhooks> #include <dhooks>
#include <mapchooser_extended>
//https://github.com/Mikusch/SM-TickrateChanger/tree/main //https://github.com/Mikusch/SM-TickrateChanger/tree/main
@@ -70,19 +71,7 @@ public MRESReturn ChangeLevel(DHookReturn hReturn, DHookParam hParams)
public void check_map(const char []map) public void check_map(const char []map)
{ {
new Handle:fileHandle = OpenFile("maps_66_tick.txt", "r" ); bool changeTick = GetMapTickRate(map) == 0 ? false : true;
char lineBuffer[256];
bool changeTick = false;
while(!IsEndOfFile(fileHandle) && ReadFileLine(fileHandle, lineBuffer, sizeof(lineBuffer)))
{
TrimString(lineBuffer);
if (StrEqual(map, lineBuffer, false))
{
changeTick = true;
break;
}
}
CloseHandle(fileHandle);
float interveral_per_tick_pre = GetConVarFloat(FindConVar("sm_interval_per_tick")); float interveral_per_tick_pre = GetConVarFloat(FindConVar("sm_interval_per_tick"));
bool double_map_change = false; bool double_map_change = false;
-1
View File
@@ -1,3 +1,2 @@
replicate
requests requests
Pillow Pillow
+33 -6
View File
@@ -6,7 +6,6 @@ import time
import base64 import base64
import re, json import re, json
import os import os
import replicate as replicate_client
from PIL import Image from PIL import Image
import io import io
@@ -182,12 +181,40 @@ def send_claude_message(reacted_emotes, metrics, custom_emotes_json, attachment_
return parsed return parsed
def replicate_fetch_image(image_prompt): def replicate_fetch_image(image_prompt):
os.environ["REPLICATE_API_TOKEN"] = replicate_api_key headers = {
output = replicate_client.run( "Authorization": f"Bearer {replicate_api_key}",
"black-forest-labs/flux-schnell", "Content-Type": "application/json"
input={"prompt": image_prompt} }
resp = requests.post(
"https://api.replicate.com/v1/models/black-forest-labs/flux-schnell/predictions",
headers=headers,
json={"input": {"prompt": image_prompt}}
) )
return output[0].read() resp.raise_for_status()
prediction = resp.json()
get_url = prediction["urls"]["get"]
max_retries = 5
retry_count = 0
while prediction["status"] not in ("succeeded", "failed", "canceled"):
time.sleep(1)
r = requests.get(get_url, headers=headers)
if r.status_code >= 500:
retry_count += 1
print(f"Replicate poll got {r.status_code} (retry {retry_count}/{max_retries}): {r.text}")
if retry_count > max_retries:
r.raise_for_status()
continue
r.raise_for_status()
prediction = r.json()
retry_count = 0
if prediction["status"] != "succeeded":
raise RuntimeError(f"Replicate prediction failed: {prediction['status']} - {prediction.get('error')}")
output = prediction["output"]
image_url = output[0] if isinstance(output, list) else output
return requests.get(image_url).content
def main(): def main():
custom_emotes_json = get_custom_emotes() custom_emotes_json = get_custom_emotes()
@@ -26,4 +26,6 @@ Adapt config.json.
##### Make sure you are in the virtualenv! (`. venv/bin/activate`) ##### Make sure you are in the virtualenv! (`. venv/bin/activate`)
Run: `python main.py` Run: `python main.py`
Glacius was here. xd /home/gameservers/.config/yt-dlp/config:
--proxy "http://127.0.0.1:1080"
--js-runtimes deno:/home/gameservers/.deno/bin/deno
@@ -2,28 +2,10 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging import logging
import sys import sys
import io
import math import math
import time
from .FFmpegAudioPlayer import FFmpegAudioPlayerFactory from .FFmpegAudioPlayer import FFmpegAudioPlayerFactory
def _progress_delta(old_position, new_position, elapsed = None, jitter = 0.25):
try:
delta = abs(float(new_position) - float(old_position))
except (TypeError, ValueError):
return 0.0
if not math.isfinite(delta):
return 0.0
if elapsed is not None:
try:
elapsed = float(elapsed)
except (TypeError, ValueError):
return 0.0
if not math.isfinite(elapsed) or elapsed < 0.0:
return 0.0
delta = min(delta, elapsed + jitter)
return delta
class AudioPlayerFactory(): class AudioPlayerFactory():
AUDIOPLAYER_FFMPEG = 1 AUDIOPLAYER_FFMPEG = 1
@@ -40,12 +22,9 @@ class AudioPlayerFactory():
def NewPlayer(self, _type): def NewPlayer(self, _type):
if _type == self.AUDIOPLAYER_FFMPEG: if _type == self.AUDIOPLAYER_FFMPEG:
return self.FFmpegAudioPlayerFactory.NewPlayer() return self.FFmpegAudioPlayerFactory.NewPlayer()
raise ValueError("Unsupported audio player type: {0}".format(_type))
class AntiSpam(): class AntiSpam():
GLOBAL_PLAY_TIME_IMMUNITY_LEVEL = 10
def __init__(self, master): def __init__(self, master):
self.Logger = logging.getLogger(__class__.__name__) self.Logger = logging.getLogger(__class__.__name__)
self.Master = master self.Master = master
@@ -53,9 +32,7 @@ class AntiSpam():
self.LastClips = dict() self.LastClips = dict()
self.DisabledTime = None self.DisabledTime = None
self.SaidHint = False
def _counts_toward_global_play_time(self, level):
return level < self.GLOBAL_PLAY_TIME_IMMUNITY_LEVEL
def CheckAntiSpam(self, player): def CheckAntiSpam(self, player):
if self.DisabledTime and self.DisabledTime > self.Torchlight().Master.Loop.time() and \ if self.DisabledTime and self.DisabledTime > self.Torchlight().Master.Loop.time() and \
@@ -67,40 +44,36 @@ class AntiSpam():
return True return True
def SpamCheck(self): def SpamCheck(self, Delta):
Now = self.Torchlight().Master.Loop.time() Now = self.Torchlight().Master.Loop.time()
Duration = 0.0 Duration = 0.0
config = self.Torchlight().Config["AntiSpam"]
for Key, Clip in list(self.LastClips.items()): for Key, Clip in list(self.LastClips.items()):
if not Clip["timestamp"]: if not Clip["timestamp"]:
continue continue
if Clip["timestamp"] + Clip["duration"] + config["MaxUsageSpan"] < Now: if Clip["timestamp"] + Clip["duration"] + self.Torchlight().Config["AntiSpam"]["MaxUsageSpan"] < Now:
if not Clip["active"]: if not Clip["active"]:
del self.LastClips[Key] del self.LastClips[Key]
continue continue
Duration += Clip["duration"] Duration += Clip["duration"]
if Duration > config["MaxUsageTime"]: if Duration > self.Torchlight().Config["AntiSpam"]["MaxUsageTime"]:
self.DisabledTime = self.Torchlight().Master.Loop.time() + config["PunishDelay"] self.DisabledTime = self.Torchlight().Master.Loop.time() + self.Torchlight().Config["AntiSpam"]["PunishDelay"]
self.Torchlight().SayChat("Blocked voice commands for the next {0} seconds. Used {1} seconds within {2} seconds.".format( self.Torchlight().SayChat("Blocked voice commands for the next {0} seconds. Used {1} seconds within {2} seconds.".format(
config["PunishDelay"], config["MaxUsageTime"], config["MaxUsageSpan"])) self.Torchlight().Config["AntiSpam"]["PunishDelay"], self.Torchlight().Config["AntiSpam"]["MaxUsageTime"], self.Torchlight().Config["AntiSpam"]["MaxUsageSpan"]))
# Make a copy of the list since AudioClip.Stop() will change the list # Make a copy of the list since AudioClip.Stop() will change the list
for AudioClip in self.Master.AudioClips[:]: for AudioClip in self.Master.AudioClips[:]:
if AudioClip.Level < config["ImmunityLevel"]: if AudioClip.Level < self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]:
AudioClip.Stop() AudioClip.Stop()
self.LastClips.clear() self.LastClips.clear()
def OnPlay(self, clip): def OnPlay(self, clip):
if not self._counts_toward_global_play_time(clip.Level):
return
Now = self.Torchlight().Master.Loop.time() Now = self.Torchlight().Master.Loop.time()
self.LastClips[clip] = dict({"timestamp": Now, "duration": 0.0, "dominant": False, "active": True, "last_update_realtime": time.time()}) self.LastClips[hash(clip)] = dict({"timestamp": Now, "duration": 0.0, "dominant": False, "active": True})
HasDominant = False HasDominant = False
for Key, Clip in self.LastClips.items(): for Key, Clip in self.LastClips.items():
@@ -108,50 +81,31 @@ class AntiSpam():
HasDominant = True HasDominant = True
break break
self.LastClips[clip]["dominant"] = not HasDominant self.LastClips[hash(clip)]["dominant"] = not HasDominant
def OnStop(self, clip): def OnStop(self, clip):
if not self._counts_toward_global_play_time(clip.Level): if hash(clip) not in self.LastClips:
return return
if clip not in self.LastClips: self.LastClips[hash(clip)]["active"] = False
return
self.LastClips[clip]["active"] = False if self.LastClips[hash(clip)]["dominant"]:
if self.LastClips[clip]["dominant"]:
for Key, Clip in self.LastClips.items(): for Key, Clip in self.LastClips.items():
if Clip["active"]: if Clip["active"]:
Clip["dominant"] = True Clip["dominant"] = True
break break
self.LastClips[clip]["dominant"] = False self.LastClips[hash(clip)]["dominant"] = False
def OnUpdate(self, clip, old_position, new_position): def OnUpdate(self, clip, old_position, new_position):
if not self._counts_toward_global_play_time(clip.Level): Delta = new_position - old_position
return Clip = self.LastClips[hash(clip)]
if clip not in self.LastClips: if not Clip["dominant"]:
if self.Logger.isEnabledFor(logging.DEBUG):
self.Logger.debug(
"OnUpdate called for unknown clip key %r; %d known keys",
clip,
len(self.LastClips),
)
return
Clip = self.LastClips[clip]
now = time.time()
elapsed = None
if Clip.get("last_update_realtime") is not None:
elapsed = now - Clip["last_update_realtime"]
Clip["last_update_realtime"] = now
Delta = _progress_delta(old_position, new_position, elapsed = elapsed)
if Delta <= 0.0 or not Clip["dominant"]:
return return
Clip["duration"] += Delta Clip["duration"] += Delta
self.SpamCheck() self.SpamCheck(Delta)
class Advertiser(): class Advertiser():
@@ -161,33 +115,37 @@ class Advertiser():
self.Torchlight = self.Master.Torchlight self.Torchlight = self.Master.Torchlight
self.LastClips = dict() self.LastClips = dict()
self.AdStop = 0
self.NextAdStop = 0
def Think(self, clip): def Think(self, Delta):
Now = self.Torchlight().Master.Loop.time() Now = self.Torchlight().Master.Loop.time()
config = self.Torchlight().Config["Advertiser"] Duration = 0.0
for Key, Clip in list(self.LastClips.items()): for Key, Clip in list(self.LastClips.items()):
if not Clip["timestamp"]: if not Clip["timestamp"]:
continue continue
if Clip["timestamp"] + Clip["duration"] + config["MaxSpan"] < Now: if Clip["timestamp"] + Clip["duration"] + self.Torchlight().Config["Advertiser"]["MaxSpan"] < Now:
if not Clip["active"]: if not Clip["active"]:
del self.LastClips[Key] del self.LastClips[Key]
continue continue
Duration += Clip["duration"]
Clip = self.LastClips.get(clip) self.NextAdStop -= Delta
if not Clip: CeilDur = math.ceil(Duration)
return if CeilDur > self.AdStop and self.NextAdStop <= 0 and CeilDur % self.Torchlight().Config["Advertiser"]["AdStop"] == 0:
if not clip.StopHinted and not Clip.get("hinted") and Clip["duration"] >= config["AdStop"]:
self.Torchlight().SayChat("Hint: Type \x07FF0000!stop(ze) !pls(mg)\x01 to stop all currently playing sounds.") self.Torchlight().SayChat("Hint: Type \x07FF0000!stop(ze) !pls(mg)\x01 to stop all currently playing sounds.")
Clip["hinted"] = True self.AdStop = CeilDur
clip.StopHinted = True self.NextAdStop = 0
elif CeilDur < self.AdStop:
self.AdStop = 0
self.NextAdStop = self.Torchlight().Config["Advertiser"]["AdStop"] / 2
def OnPlay(self, clip): def OnPlay(self, clip):
Now = self.Torchlight().Master.Loop.time() Now = self.Torchlight().Master.Loop.time()
self.LastClips[clip] = dict({"timestamp": Now, "duration": 0.0, "dominant": False, "active": True, "hinted": False, "last_update_realtime": time.time()}) self.LastClips[hash(clip)] = dict({"timestamp": Now, "duration": 0.0, "dominant": False, "active": True})
HasDominant = False HasDominant = False
for Key, Clip in self.LastClips.items(): for Key, Clip in self.LastClips.items():
@@ -195,38 +153,31 @@ class Advertiser():
HasDominant = True HasDominant = True
break break
self.LastClips[clip]["dominant"] = not HasDominant self.LastClips[hash(clip)]["dominant"] = not HasDominant
def OnStop(self, clip): def OnStop(self, clip):
if clip not in self.LastClips: if hash(clip) not in self.LastClips:
return return
self.LastClips[clip]["active"] = False self.LastClips[hash(clip)]["active"] = False
if self.LastClips[clip]["dominant"]: if self.LastClips[hash(clip)]["dominant"]:
for Key, Clip in self.LastClips.items(): for Key, Clip in self.LastClips.items():
if Clip["active"]: if Clip["active"]:
Clip["dominant"] = True Clip["dominant"] = True
break break
self.LastClips[clip]["dominant"] = False self.LastClips[hash(clip)]["dominant"] = False
def OnUpdate(self, clip, old_position, new_position): def OnUpdate(self, clip, old_position, new_position):
if clip not in self.LastClips: Delta = new_position - old_position
return Clip = self.LastClips[hash(clip)]
Clip = self.LastClips[clip]
now = time.time()
elapsed = None
if Clip.get("last_update_realtime") is not None:
elapsed = now - Clip["last_update_realtime"]
Clip["last_update_realtime"] = now
Delta = _progress_delta(old_position, new_position, elapsed = elapsed)
if Delta <= 0.0 or not Clip["dominant"]: if not Clip["dominant"]:
return return
Clip["duration"] += Delta Clip["duration"] += Delta
self.Think(clip) self.Think(Delta)
class AudioManager(): class AudioManager():
@@ -246,23 +197,21 @@ class AudioManager():
if player.Access: if player.Access:
Level = player.Access["level"] Level = player.Access["level"]
config = self.Torchlight().Config["AudioLimits"] if str(Level) in self.Torchlight().Config["AudioLimits"]:
if str(Level) in config: if self.Torchlight().Config["AudioLimits"][str(Level)]["Uses"] >= 0 and \
level_config = config[str(Level)] player.Storage["Audio"]["Uses"] >= self.Torchlight().Config["AudioLimits"][str(Level)]["Uses"]:
if level_config["Uses"] >= 0 and \
player.Storage["Audio"]["Uses"] >= level_config["Uses"]:
self.Torchlight().SayPrivate(player, "You have used up all of your free uses! ({0} uses)".format( self.Torchlight().SayPrivate(player, "You have used up all of your free uses! ({0} uses)".format(
level_config["Uses"])) self.Torchlight().Config["AudioLimits"][str(Level)]["Uses"]))
return False return False
if player.Storage["Audio"]["TimeUsed"] >= level_config["TotalTime"]: if player.Storage["Audio"]["TimeUsed"] >= self.Torchlight().Config["AudioLimits"][str(Level)]["TotalTime"]:
self.Torchlight().SayPrivate(player, "You have used up all of your free time! ({0} seconds)".format( self.Torchlight().SayPrivate(player, "You have used up all of your free time! ({0} seconds)".format(
level_config["TotalTime"])) self.Torchlight().Config["AudioLimits"][str(Level)]["TotalTime"]))
return False return False
TimeElapsed = self.Torchlight().Master.Loop.time() - player.Storage["Audio"]["LastUse"] TimeElapsed = self.Torchlight().Master.Loop.time() - player.Storage["Audio"]["LastUse"]
UseDelay = player.Storage["Audio"]["LastUseLength"] * level_config["DelayFactor"] UseDelay = player.Storage["Audio"]["LastUseLength"] * self.Torchlight().Config["AudioLimits"][str(Level)]["DelayFactor"]
if TimeElapsed < UseDelay: if TimeElapsed < UseDelay:
self.Torchlight().SayPrivate(player, "You are currently on cooldown! ({0} seconds left)".format( self.Torchlight().SayPrivate(player, "You are currently on cooldown! ({0} seconds left)".format(
@@ -272,34 +221,31 @@ class AudioManager():
return True return True
def Stop(self, player, extra): def Stop(self, player, extra):
Result = {"active": 0, "matched": 0, "stopped": 0, "pending": 0} Level = 0
stopper_level = player.Access["level"] if player.Access else 0 if player.Access:
Level = player.Access["level"]
for AudioClip in self.AudioClips[:]: for AudioClip in self.AudioClips[:]:
Result["active"] += 1 if extra and not extra.lower() in AudioClip.Player.Name.lower():
target_level = AudioClip.Player.Access["level"] if AudioClip.Player.Access else 0
same_player = False
if getattr(player, "UniqueID", None) is not None and getattr(AudioClip.Player, "UniqueID", None) is not None:
same_player = player.UniqueID == AudioClip.Player.UniqueID
elif getattr(player, "UserID", None) is not None and getattr(AudioClip.Player, "UserID", None) is not None:
same_player = player.UserID == AudioClip.Player.UserID
else:
same_player = player == AudioClip.Player
if not same_player and target_level > stopper_level:
Result["pending"] += 1
continue continue
Result["matched"] += 1
if not Level or (Level < AudioClip.Level and Level < self.Torchlight().Config["AntiSpam"]["StopLevel"]):
AudioClip.Stops.add(player.UserID)
if len(AudioClip.Stops) >= 3:
AudioClip.Stop() AudioClip.Stop()
Result["stopped"] += 1
self.Torchlight().SayPrivate(AudioClip.Player, "Your audio clip was stopped.") self.Torchlight().SayPrivate(AudioClip.Player, "Your audio clip was stopped.")
if not same_player: if player != AudioClip.Player:
self.Torchlight().SayPrivate(player, "Stopped \"{0}\"({1}) audio clip.".format(AudioClip.Player.Name, AudioClip.Player.UserID))
else:
self.Torchlight().SayPrivate(player, "This audio clip needs {0} more !stop's.".format(3 - len(AudioClip.Stops)))
else:
AudioClip.Stop()
self.Torchlight().SayPrivate(AudioClip.Player, "Your audio clip was stopped.")
if player != AudioClip.Player:
self.Torchlight().SayPrivate(player, "Stopped \"{0}\"({1}) audio clip.".format(AudioClip.Player.Name, AudioClip.Player.UserID)) self.Torchlight().SayPrivate(player, "Stopped \"{0}\"({1}) audio clip.".format(AudioClip.Player.Name, AudioClip.Player.UserID))
return Result def AudioClip(self, player, uri, _type = AudioPlayerFactory.AUDIOPLAYER_FFMPEG):
def CreateAudioClip(self, player, uri, _type = AudioPlayerFactory.AUDIOPLAYER_FFMPEG):
Level = 0 Level = 0
if player.Access: if player.Access:
Level = player.Access["level"] Level = player.Access["level"]
@@ -314,19 +260,10 @@ class AudioManager():
if not self.CheckLimits(player): if not self.CheckLimits(player):
return None return None
try:
Clip = AudioClip(self, player, uri, _type) Clip = AudioClip(self, player, uri, _type)
except ValueError as ex:
self.Logger.warning("Rejected unsupported audio player type %r: %s", _type, ex)
self.Torchlight().SayPrivate(player, "Unsupported audio player type.")
return None
self.AudioClips.append(Clip) self.AudioClips.append(Clip)
anti_spam_track_ceiling = min( if not player.Access or player.Access["level"] < self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]:
self.Torchlight().Config["AntiSpam"]["ImmunityLevel"],
self.AntiSpam.GLOBAL_PLAY_TIME_IMMUNITY_LEVEL,
)
if not player.Access or player.Access["level"] < anti_spam_track_ceiling:
Clip.AudioPlayer.AddCallback("Play", lambda *args: self.AntiSpam.OnPlay(Clip, *args)) Clip.AudioPlayer.AddCallback("Play", lambda *args: self.AntiSpam.OnPlay(Clip, *args))
Clip.AudioPlayer.AddCallback("Stop", lambda *args: self.AntiSpam.OnStop(Clip, *args)) Clip.AudioPlayer.AddCallback("Stop", lambda *args: self.AntiSpam.OnStop(Clip, *args))
Clip.AudioPlayer.AddCallback("Update", lambda *args: self.AntiSpam.OnUpdate(Clip, *args)) Clip.AudioPlayer.AddCallback("Update", lambda *args: self.AntiSpam.OnUpdate(Clip, *args))
@@ -337,9 +274,6 @@ class AudioManager():
return Clip return Clip
def AudioClip(self, player, uri, _type = AudioPlayerFactory.AUDIOPLAYER_FFMPEG):
return self.CreateAudioClip(player, uri, _type)
def OnDisconnect(self, player): def OnDisconnect(self, player):
for AudioClip in self.AudioClips[:]: for AudioClip in self.AudioClips[:]:
if AudioClip.Player == player: if AudioClip.Player == player:
@@ -355,9 +289,6 @@ class AudioClip():
self.Type = _type self.Type = _type
self.URI = uri self.URI = uri
self.LastPosition = None self.LastPosition = None
self.LastUpdateRealtime = None
self.StartedAt = None
self.StopHinted = False
self.Stops = set() self.Stops = set()
self.Level = 0 self.Level = 0
@@ -385,28 +316,15 @@ class AudioClip():
self.Player.Storage["Audio"]["Uses"] += 1 self.Player.Storage["Audio"]["Uses"] += 1
self.Player.Storage["Audio"]["LastUse"] = self.Torchlight().Master.Loop.time() self.Player.Storage["Audio"]["LastUse"] = self.Torchlight().Master.Loop.time()
self.Player.Storage["Audio"]["LastUseLength"] = 0.0 self.Player.Storage["Audio"]["LastUseLength"] = 0.0
self.StartedAt = self.Player.Storage["Audio"]["LastUse"]
self.LastUpdateRealtime = time.time()
self.StopHinted = False
def OnStop(self): def OnStop(self):
self.Logger.debug(sys._getframe().f_code.co_name + ' ' + self.URI) self.Logger.debug(sys._getframe().f_code.co_name + ' ' + self.URI)
if self in self.Master.AudioClips:
self.Master.AudioClips.remove(self) self.Master.AudioClips.remove(self)
played = None if self.AudioPlayer.Playing:
if self.AudioPlayer and self.AudioPlayer.StartedPlaying is not None: Delta = self.AudioPlayer.Position - self.LastPosition
elapsed = time.time() - self.AudioPlayer.StartedPlaying self.Player.Storage["Audio"]["TimeUsed"] += Delta
if self.AudioPlayer.Seconds: self.Player.Storage["Audio"]["LastUseLength"] += Delta
elapsed = min(elapsed, self.AudioPlayer.Seconds)
played = max(0.0, elapsed)
if played is not None:
last_length = self.Player.Storage["Audio"]["LastUseLength"]
if played > last_length:
delta = played - last_length
self.Player.Storage["Audio"]["TimeUsed"] += delta
self.Player.Storage["Audio"]["LastUseLength"] += delta
if str(self.Level) in self.Torchlight().Config["AudioLimits"]: if str(self.Level) in self.Torchlight().Config["AudioLimits"]:
if self.Player.Storage: if self.Player.Storage:
@@ -420,18 +338,9 @@ class AudioClip():
del self.AudioPlayer del self.AudioPlayer
def OnUpdate(self, old_position, new_position): def OnUpdate(self, old_position, new_position):
now = time.time() Delta = new_position - old_position
elapsed = None
if self.LastUpdateRealtime is not None:
elapsed = now - self.LastUpdateRealtime
self.LastUpdateRealtime = now
Delta = _progress_delta(old_position, new_position, elapsed = elapsed)
self.LastPosition = new_position self.LastPosition = new_position
if Delta <= 0.0:
return
self.Player.Storage["Audio"]["TimeUsed"] += Delta self.Player.Storage["Audio"]["TimeUsed"] += Delta
self.Player.Storage["Audio"]["LastUseLength"] += Delta self.Player.Storage["Audio"]["LastUseLength"] += Delta
@@ -89,7 +89,7 @@ class CommandHandler():
Ret = await Command._rfunc(line, RMatch, player) Ret = await Command._rfunc(line, RMatch, player)
else: else:
#self.Logger.debug(f"_rfunc line: {line}") #self.Logger.debug(f"_rfunc line: {line}")
if line.startswith("!yt"): #transfering the line if its !yt or !yts in case people want to include pitch or tempo. if line.lower().startswith("!yt"): #transfering the line if its !yt or !yts in case people want to include pitch or tempo. (case-insensitive: trigger matching itself is case-insensitive, so this must be too)
Ret = await Command._func(Message, player, line) Ret = await Command._func(Message, player, line)
else: else:
Ret = await Command._func(Message, player) Ret = await Command._func(Message, player)
File diff suppressed because it is too large Load Diff
@@ -8,9 +8,27 @@ import time
import socket import socket
import struct import struct
import sys import sys
import os
SAMPLEBYTES = 2 SAMPLEBYTES = 2
# Playing a googlevideo.com URL directly with ffmpeg turned out unreliable:
# the signed URL (and the exact headers/User-Agent yt-dlp used to obtain it)
# is tied to the specific request that generated it, and a *separate* ffmpeg
# process making its own connection -- even through the same proxy, even with
# matching headers copied from the --dump-json output -- still got HTTP 403
# from Google. Matching proxy + headers wasn't enough because the header set
# also drifts (yt-dlp's impersonated Chrome build number alone changed
# between two of our test runs a few minutes apart), so hardcoding it is a
# losing game.
#
# The reliable fix is to stop trying to replay yt-dlp's signed URL from a
# second process at all: let yt-dlp itself fetch the media (same process,
# same session, same proxy/headers it used to resolve the URL) and stream its
# output straight into ffmpeg's stdin via a pipe. URIs that should be played
# this way are prefixed with YTDLP_URI_PREFIX by Commands.py.
YTDLP_URI_PREFIX = "ytdlp:"
class FFmpegAudioPlayerFactory(): class FFmpegAudioPlayerFactory():
VALID_CALLBACKS = ["Play", "Stop", "Update"] VALID_CALLBACKS = ["Play", "Stop", "Update"]
@@ -43,7 +61,6 @@ class FFmpegAudioPlayer():
self.Torchlight().Config["VoiceServer"]["Port"] self.Torchlight().Config["VoiceServer"]["Port"]
) )
self.SampleRate = float(self.Torchlight().Config["VoiceServer"]["SampleRate"]) self.SampleRate = float(self.Torchlight().Config["VoiceServer"]["SampleRate"])
self.Channels = int(self.Torchlight().Config["VoiceServer"].get("Channels", 2))
self.StartedPlaying = None self.StartedPlaying = None
self.StoppedPlaying = None self.StoppedPlaying = None
@@ -51,6 +68,10 @@ class FFmpegAudioPlayer():
self.Writer = None self.Writer = None
self.Process = None self.Process = None
# Only set in ytdlp-pipe mode (see PlayURI): the upstream yt-dlp
# process feeding ffmpeg's stdin. Needs to be killed alongside
# self.Process on Stop(), or it lingers as an orphaned process.
self.SourceProcess = None
self.Callbacks = [] self.Callbacks = []
@@ -60,75 +81,58 @@ class FFmpegAudioPlayer():
def PlayURI(self, uri, position, rubberband = None, dec_params = None, bitrate = None, def PlayURI(self, uri, position, rubberband = None, dec_params = None, bitrate = None,
backwards = None, *args): backwards = None, *args):
if self.Playing: IsYtdlp = uri.startswith(YTDLP_URI_PREFIX)
self.Stop() # Commands.py may append a "#t=..." fragment to the URI purely so a
# downstream handler can re-parse it into a start position (see
proxy_url = "" # URLFilter.URLInfo) -- strip it back off here since the actual seek
if uri.startswith("http://") or uri.startswith("https://"): # already happens via `position`/"-ss" below, and yt-dlp doesn't need
proxy_url = self.Torchlight().Config["Proxy"] # (or want) an arbitrary fragment tacked onto the watch URL.
SourceUrl = uri[len(YTDLP_URI_PREFIX):].split("#")[0] if IsYtdlp else None
if IsYtdlp:
# Input is a pipe (yt-dlp's stdout), which isn't seekable, so a
# requested start position has to be an *output*-side "-ss"
# (placed after "-i") rather than the normal fast input-side
# seek. Slower (ffmpeg decodes and discards up to that point
# instead of jumping straight there) but correct.
Command = ["/usr/bin/ffmpeg", "-i", "pipe:0"]
if position: if position:
Command += ["-ss", str(datetime.timedelta(seconds = position))]
Command += ["-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args]
elif position:
PosStr = str(datetime.timedelta(seconds = position)) PosStr = str(datetime.timedelta(seconds = position))
Command = ["/usr/bin/ffmpeg"] Command = ["/usr/bin/ffmpeg", "-ss", PosStr, "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args]
if proxy_url:
Command += ["-http_proxy", proxy_url]
Command += ["-ss", PosStr, "-i", uri, "-acodec", "pcm_s16le", "-ac", str(self.Channels), "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn"]
else: else:
Command = ["/usr/bin/ffmpeg"] Command = ["/usr/bin/ffmpeg", "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args]
if proxy_url:
Command += ["-http_proxy", proxy_url]
Command += ["-i", uri, "-acodec", "pcm_s16le", "-ac", str(self.Channels), "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn"]
if args:
Command += list(args)
# Reset per-play counters/state in case this instance is reused.
self.Seconds = 0.0
self.StartedPlaying = None
self.StoppedPlaying = None
self._byte_remainder = 0
self.Playing = True self.Playing = True
if dec_params: if dec_params:
Command += dec_params Command += dec_params
filter_chain = ["highpass=f=20"] if rubberband and backwards:
Command += ["-filter:a"]
rubberCommand = ""
for rubber in rubberband:
rubberCommand += rubber + ", "
rubberCommand = rubberCommand[:-2]
Command += [rubberCommand + "[reversed];[reversed]areverse"] #[reversed] is intermediate stream label so reverse knows what stream label to reverse
else:
if rubberband: if rubberband:
# Treat rubberband as either a single pre-built filtergraph string Command += ["-filter:a"]
# or an iterable of simple, comma-safe filters. Validate to avoid rubberCommand = ""
# generating invalid ffmpeg filter syntax. for rubber in rubberband:
rubberband_filters = [] rubberCommand += rubber + ", "
if isinstance(rubberband, str): rubberCommand = rubberCommand[:-2]
rubberband_filters = [rubberband] Command += [rubberCommand]
else:
try:
iterator = iter(rubberband)
except TypeError:
# Not iterable: coerce to string and treat as single element.
rubberband_filters = [str(rubberband)]
else:
for f in iterator:
if not isinstance(f, str):
f = str(f)
# Reject potentially complex/unsafe filter fragments that
# could break the filtergraph when joined with commas.
if any(ch in f for ch in [",", ";", "[", "]"]):
self.Master.Logger.error(
"Skipping unsafe rubberband filter fragment %r: "
"contains ',', ';', '[' or ']'", f
)
continue
rubberband_filters.append(f)
filter_chain.extend(rubberband_filters)
if backwards: if backwards:
filter_chain.append("areverse") Command += ["-af"]
Command += ["-filter:a", ",".join(filter_chain)] Command += ["areverse"]
if bitrate: if bitrate:
Command += ["-b:a", f"{bitrate}k"] Command += ["-ab ", str(bitrate), "k"]
self.Master.Logger.debug(f"command: {Command}") self.Master.Logger.debug(f"command: {Command}")
Command += ["-"] Command += ["-"]
#self.Master.Logger.debug(f"command: {Command}") #self.Master.Logger.debug(f"command: {Command}")
asyncio.ensure_future(self._stream_subprocess(Command)) asyncio.ensure_future(self._stream_subprocess(Command, SourceUrl))
return True return True
def Stop(self, force = True): def Stop(self, force = True):
@@ -143,6 +147,14 @@ class FFmpegAudioPlayer():
except ProcessLookupError: except ProcessLookupError:
pass pass
if self.SourceProcess:
try:
self.SourceProcess.terminate()
self.SourceProcess.kill()
self.SourceProcess = None
except ProcessLookupError:
pass
if self.Writer: if self.Writer:
if force: if force:
Socket = self.Writer.transport.get_extra_info("socket") Socket = self.Writer.transport.get_extra_info("socket")
@@ -157,7 +169,7 @@ class FFmpegAudioPlayer():
self.Playing = False self.Playing = False
self.Callback("Stop") self.Callback("Stop")
self.Callbacks = [] del self.Callbacks
return True return True
@@ -186,48 +198,31 @@ class FFmpegAudioPlayer():
SecondsElapsed = self.Seconds SecondsElapsed = self.Seconds
self.Callback("Update", LastSecondsElapsed, SecondsElapsed) self.Callback("Update", LastSecondsElapsed, SecondsElapsed)
LastSecondsElapsed = SecondsElapsed
if SecondsElapsed >= self.Seconds: if SecondsElapsed >= self.Seconds:
if not self.StoppedPlaying: if not self.StoppedPlaying:
self.Master.Logger.warning("BUFFER UNDERRUN") print("BUFFER UNDERRUN!")
await asyncio.sleep(0.05)
continue
self.Stop(False) self.Stop(False)
return return
LastSecondsElapsed = SecondsElapsed
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
async def _read_stream(self, stream, writer): async def _read_stream(self, stream, writer):
Started = False Started = False
try:
while stream and self.Playing: while stream and self.Playing:
Data = await stream.read(65536) Data = await stream.read(65536)
if Data: if Data:
try:
writer.write(Data) writer.write(Data)
await writer.drain() await writer.drain()
except (ConnectionError, BrokenPipeError, OSError) as ex:
self.Master.Logger.warning("Voice socket write failed: %s", ex)
break
Bytes = len(Data) Bytes = len(Data)
frame_size = SAMPLEBYTES * self.Channels Samples = Bytes / SAMPLEBYTES
Seconds = Samples / self.SampleRate
# Guard against invalid configuration and use integral frame counting
if frame_size <= 0 or self.SampleRate <= 0:
self.Master.Logger.error(
"Invalid audio configuration: Channels=%r, SampleRate=%r",
self.Channels,
self.SampleRate,
)
else:
# Accumulate remainder bytes to avoid systematic undercounting
total_bytes = getattr(self, "_byte_remainder", 0) + Bytes
Frames = total_bytes // frame_size
self._byte_remainder = total_bytes % frame_size
Seconds = Frames / self.SampleRate
self.Seconds += Seconds self.Seconds += Seconds
if not Started: if not Started:
@@ -238,31 +233,102 @@ class FFmpegAudioPlayer():
else: else:
self.Process = None self.Process = None
break break
finally:
self.StoppedPlaying = time.time() self.StoppedPlaying = time.time()
async def _stream_subprocess(self, cmd): async def _stream_subprocess(self, cmd, source_url = None):
if not self.Playing: if not self.Playing:
return return
try:
_, self.Writer = await asyncio.open_connection(self.Host[0], self.Host[1]) _, self.Writer = await asyncio.open_connection(self.Host[0], self.Host[1])
# The metadata fetch (talks to youtube.com) and the actual media
# fetch (talks to googlevideo.com) are separate TCP connections even
# within one yt-dlp run, and WARP's proxy doesn't guarantee the same
# apparent exit IP across separate connections -- Google signs the
# media URL to whichever IP it saw on the first connection, and a
# mismatched second connection gets 403'd. A retry opens a fresh
# connection through WARP and has a decent chance of landing on a
# matching IP, so we give it a few tries before giving up.
MaxAttempts = 3 if source_url else 1
for Attempt in range(1, MaxAttempts + 1):
if not self.Playing:
return
SourceProcess = None
SourceStdErrTask = None
StdinArg = None
ReadFd = None
if source_url:
# asyncio.subprocess.PIPE gives back a StreamReader, which is
# NOT a real file descriptor and can't be handed to a second
# subprocess as stdin -- so we make an actual OS pipe and pass
# the raw fds instead, exactly like a shell "|" would, letting
# the kernel move the bytes without going through this
# process.
ReadFd, WriteFd = os.pipe()
try:
SourceProcess = await asyncio.create_subprocess_exec(
"yt-dlp", "-f", "bestaudio/best", "-o", "-", "--no-playlist", "--quiet", source_url,
stdout = WriteFd, stderr = asyncio.subprocess.PIPE)
finally:
os.close(WriteFd) # our copy; the child inherited its own
self.SourceProcess = SourceProcess
SourceStdErrTask = asyncio.ensure_future(SourceProcess.stderr.read())
StdinArg = ReadFd
Process = await asyncio.create_subprocess_exec(*cmd, Process = await asyncio.create_subprocess_exec(*cmd,
stdout = asyncio.subprocess.PIPE, stderr = asyncio.subprocess.DEVNULL) stdin = StdinArg,
stdout = asyncio.subprocess.PIPE, stderr = asyncio.subprocess.PIPE)
self.Process = Process self.Process = Process
if source_url:
os.close(ReadFd) # our copy; the child inherited its own
# Drain stderr concurrently so it can't fill its pipe buffer and
# stall ffmpeg; we only care about it if playback produced
# nothing.
StdErrTask = asyncio.ensure_future(Process.stderr.read())
self.Seconds = 0.0
await self._read_stream(Process.stdout, self.Writer) await self._read_stream(Process.stdout, self.Writer)
await Process.wait() await Process.wait()
if SourceProcess:
await SourceProcess.wait()
if self.Seconds > 0.0:
return # success
StdErr = b""
try:
StdErr = await asyncio.wait_for(StdErrTask, timeout = 2)
except asyncio.TimeoutError:
pass
SourceStdErr = b""
if SourceStdErrTask:
try:
SourceStdErr = await asyncio.wait_for(SourceStdErrTask, timeout = 2)
except asyncio.TimeoutError:
pass
Is403 = source_url and b"403" in SourceStdErr
if Is403 and Attempt < MaxAttempts and self.Playing:
self.Master.Logger.warning("yt-dlp download got 403 (attempt %s/%s), retrying: %s",
Attempt, MaxAttempts, source_url)
continue
self.Master.Logger.warning(
"no audio produced (ffmpeg rc=%s cmd=%s stderr=%s) (yt-dlp rc=%s url=%s stderr=%s)",
Process.returncode, cmd, StdErr.decode(errors = "replace")[-1500:],
SourceProcess.returncode if SourceProcess else None, source_url,
SourceStdErr.decode(errors = "replace")[-1500:])
if Is403:
self.Torchlight().SayChat("Error: YouTube blocked this stream after {0} attempts, try again.".format(MaxAttempts))
if self.Playing:
# Keep the clip active until the updater reaches self.Seconds.
# This allows !stop to interrupt short clips that are already
# buffered to the voice socket but still audible in-game.
if self.StartedPlaying is None:
self.Stop(False)
except asyncio.CancelledError:
raise
except Exception:
self.Master.Logger.error(traceback.format_exc())
if self.Playing:
self.Stop() self.Stop()
return
@@ -21,6 +21,8 @@ urllib3
yarl yarl
cython cython
geoip2 geoip2
bgutil-ytdlp-pot-provider
yt-dlp @ git+https://github.com/yt-dlp/yt-dlp@master yt-dlp @ git+https://github.com/yt-dlp/yt-dlp@master
python3 -m pip install -U https://github.com/coletdjnz/yt-dlp-youtube-oauth2/archive/refs/heads/master.zip python3 -m pip install -U https://github.com/coletdjnz/yt-dlp-youtube-oauth2/archive/refs/heads/master.zip
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# rotate_warp.sh — force warp-docker to register fresh and get a new exit IP.
# Called automatically by Commands.py (run_ytdlp_with_rotation) when yt-dlp
# hits YouTube's "Sign in to confirm you're not a bot" check.
#
# Lives at /home/gameservers/css_ze/torchlight3/rotate_warp.sh — same folder
# as _start.sh. Make sure it's executable: chmod +x rotate_warp.sh
set -euo pipefail
WARP_DIR="/home/gameservers/warp-docker"
PROXY="http://127.0.0.1:1080"
cd "$WARP_DIR"
docker compose down
rm -rf ./data/*
docker compose up -d
# Wait for the new WARP registration to actually come online.
for i in $(seq 1 15); do
if curl -s -x "$PROXY" https://cloudflare.com/cdn-cgi/trace 2>/dev/null | grep -q "warp=on"; then
echo "WARP rotated, new IP confirmed"
exit 0
fi
sleep 2
done
echo "WARP did not come back up in time" >&2
exit 1
@@ -0,0 +1,27 @@
version: "3"
services:
warp:
image: caomingjun/warp
container_name: warp
restart: always
device_cgroup_rules:
- 'c 10:200 rwm'
ports:
- "127.0.0.1:1080:1080"
environment:
- WARP_SLEEP=2
cap_add:
- MKNOD
- AUDIT_WRITE
- NET_ADMIN
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
- net.ipv4.conf.all.src_valid_mark=1
volumes:
- ./data:/var/lib/cloudflare-warp
bgutil-pot:
image: brainicism/bgutil-ytdlp-pot-provider
container_name: bgutil-pot
restart: always
network_mode: "host"
File diff suppressed because it is too large Load Diff