Compare commits
53
Commits
d476d8c8bd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74e72bfdac | ||
|
|
300000d920 | ||
|
|
fb24169428 | ||
|
|
cf2d0e7e8e | ||
|
|
b9764dd61c | ||
|
|
0c3407b5bc | ||
|
|
176dc58b61 | ||
|
|
ed02682ef4 | ||
|
|
0634fde335 | ||
|
|
ab481d6d65 | ||
|
|
f260578def | ||
|
|
09107047b9 | ||
|
|
28b4e957ea | ||
|
|
0346c0667b | ||
|
|
9c4c02c532 | ||
|
|
1ae342151e | ||
|
|
6ace3da113 | ||
|
|
16bb9e14a5 | ||
|
|
9e8e8378a2 | ||
|
|
4ad037bd13 | ||
|
|
b3eb1f92c1 | ||
|
|
06e9845b0a | ||
|
|
4ec38434a2 | ||
|
|
e13f833fa0 | ||
|
|
b21a6b6e0b | ||
|
|
e1a93bd0dd | ||
|
|
e731a1f3fc | ||
|
|
6cca898c7a | ||
|
|
a7b05d776c | ||
|
|
c58a981f32 | ||
|
|
b86a6b0da9 | ||
|
|
fd84509996 | ||
|
|
e0e043d2ad | ||
|
|
5a53772941 | ||
|
|
31ed5c6f72 | ||
|
|
07d344e730 | ||
|
|
e3cef31246 | ||
|
|
2136135beb | ||
|
|
9e703da905 | ||
|
|
ca95f5a961 | ||
|
|
540cad9612 | ||
|
|
05674446ae | ||
|
|
ea257beb41 | ||
|
|
d1d99a9e2c | ||
|
|
5dedfe10cb | ||
|
|
2812dbce8c | ||
|
|
55f573db8d | ||
|
|
8f4e205db0 | ||
|
|
131668a53d | ||
|
|
58a5ea51a0 | ||
|
|
60ec523d98 | ||
|
|
f7263ffdef | ||
|
|
9e69a92cc5 |
@@ -11,6 +11,8 @@
|
||||
|
||||
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
|
||||
|
||||
#intel specific:
|
||||
@@ -34,7 +36,7 @@
|
||||
apt install libgtk2.0-0:i386
|
||||
|
||||
/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/>
|
||||
<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
|
||||
export SDL_VIDEO_X11_VISUALID=0x205
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -88,5 +91,4 @@ limiting cpu can throttle the download speed as well. if bots cant connect and a
|
||||
#pip3 extra
|
||||
|
||||
pip3 install watchdog
|
||||
|
||||
|
||||
pip3 install requests
|
||||
|
||||
@@ -7,16 +7,18 @@ import pwd
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
import random
|
||||
import bz2
|
||||
import signal
|
||||
import socket
|
||||
import codecs
|
||||
import json
|
||||
import requests
|
||||
import datetime
|
||||
import time
|
||||
import glob
|
||||
import pwd
|
||||
|
||||
restart_time = datetime.datetime.now() + datetime.timedelta(hours=3)
|
||||
restart_time = datetime.datetime.now() + datetime.timedelta(hours=6)
|
||||
|
||||
whoami = subprocess.getoutput("whoami")
|
||||
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"
|
||||
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)
|
||||
@@ -39,6 +37,36 @@ def writeCfgInput(Input_user):
|
||||
time.sleep(0.35)
|
||||
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():
|
||||
#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.
|
||||
@@ -78,7 +106,6 @@ def delete_bsp_files():
|
||||
|
||||
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")
|
||||
|
||||
@@ -126,12 +153,19 @@ def restart_sdl_and_steam():
|
||||
|
||||
def bot_connect(data):
|
||||
#use whatever ip you want here to connect with
|
||||
str1 = ""
|
||||
server = ""
|
||||
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:
|
||||
str1 = f"connect {data_ports['server_ip_port_ze2']};" #wait 15000;
|
||||
writeCfgInput(str1)
|
||||
server = f"{data_ports['server_ip_port_ze2']};"
|
||||
|
||||
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():
|
||||
# ' > /dev/null' redirects stdout to /dev/null
|
||||
@@ -180,7 +214,7 @@ if __name__ == '__main__':
|
||||
sock.settimeout(5.0)
|
||||
messager_name = ""
|
||||
|
||||
failed_connect_count = 0
|
||||
fail_safe_counter = 0
|
||||
|
||||
launch_game()
|
||||
try:
|
||||
@@ -212,24 +246,51 @@ if __name__ == '__main__':
|
||||
elif "autismo connected to ze" == data:
|
||||
print('Bot connected to ze!')
|
||||
is_bot_connected_to_ze2 = False
|
||||
failed_connect_count = 0
|
||||
fail_safe_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!')
|
||||
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):
|
||||
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")
|
||||
sock.close()
|
||||
print('exiting after running for several hours.')
|
||||
sys.exit(1)
|
||||
print('data: ', data)
|
||||
overwrite_file_access()
|
||||
check_if_bz2_downloading()
|
||||
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")
|
||||
sock.close()
|
||||
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 PLUGIN_AUTHOR "jenz"
|
||||
#define PLUGIN_VERSION "1.9"
|
||||
#define PLUGIN_VERSION "2.2"
|
||||
#define generic_length 256
|
||||
|
||||
#include <sourcemod>
|
||||
#include <sdktools>
|
||||
#include <AFKManager>
|
||||
#include <sdkhooks>
|
||||
#include <PlayerManager>
|
||||
#include <cstrike>
|
||||
#include <socket>
|
||||
#include <UNLOZE.secret>
|
||||
#include <mapchooser_extended>
|
||||
#include <smlib>
|
||||
|
||||
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 {
|
||||
int buttons;
|
||||
float vel[3];
|
||||
float angles[3];
|
||||
float origin[3]; // where the real client actually was when this frame was recorded
|
||||
int impulse;
|
||||
int weaponSlot; // 0=none/untracked, 1=primary, 2=secondary, 3=knife, 4=grenade
|
||||
}
|
||||
|
||||
FrameData g_Buffer[MAXPLAYERS+1][BUFFER_SIZE];
|
||||
int g_WriteHead[MAXPLAYERS+1]; // where were writing next
|
||||
int g_FrameCount[MAXPLAYERS+1]; // total frames captured
|
||||
int g_DelayTicks = 128; // delay
|
||||
int g_WriteHead[MAXPLAYERS+1]; // where were writing next, indexed by REAL client
|
||||
int g_FrameCount[MAXPLAYERS+1]; // total frames captured, indexed by REAL client
|
||||
|
||||
// --- 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 server_ports[2] = {27015, 27035}; //server ports: ze, ze2
|
||||
@@ -40,6 +87,9 @@ Handle global_socket;
|
||||
//timer handle
|
||||
Handle g_hTimer_pressing = 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];
|
||||
|
||||
@@ -47,7 +97,7 @@ public Plugin myinfo =
|
||||
{
|
||||
name = "autism bot simulate player movement",
|
||||
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,
|
||||
url = ""
|
||||
};
|
||||
@@ -57,6 +107,7 @@ public void OnPluginStart()
|
||||
//talking
|
||||
RegConsoleCmd("sm_autism", cmd_talk, "talking to the bot through java application");
|
||||
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);
|
||||
|
||||
@@ -64,7 +115,11 @@ public void OnPluginStart()
|
||||
connect_socket();
|
||||
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);
|
||||
|
||||
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()
|
||||
{
|
||||
if (g_hTimer_pressing != null)
|
||||
@@ -91,30 +164,41 @@ public void OnPluginEnd()
|
||||
{
|
||||
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;
|
||||
for (int i = 1; i <= MaxClients; i++)
|
||||
int activeWeapon = GetEntPropEnt(client, Prop_Data, "m_hActiveWeapon", 0);
|
||||
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++;
|
||||
}
|
||||
}
|
||||
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--;
|
||||
if (activeWeapon == GetPlayerWeaponSlot(client, rawSlot))
|
||||
{
|
||||
return rawSlot + 1; // 1=primary, 2=secondary, 3=knife, 4=grenade
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
@@ -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].vel = vel;
|
||||
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_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])
|
||||
@@ -140,17 +337,211 @@ public Action OnPlayerRunCmd(int client, int& buttons, int& impulse, float vel[3
|
||||
return Plugin_Continue;
|
||||
}
|
||||
int target = target_client[specific_bot_player[client]];
|
||||
// Read from the slot that is DelayTicks behind current write head
|
||||
int readSlot = (g_WriteHead[target] - g_DelayTicks + BUFFER_SIZE) % BUFFER_SIZE;
|
||||
float now = GetGameTime();
|
||||
|
||||
buttons = g_Buffer[target][readSlot].buttons;
|
||||
impulse = g_Buffer[target][readSlot].impulse;
|
||||
vel = g_Buffer[target][readSlot].vel;
|
||||
angles = g_Buffer[target][readSlot].angles;
|
||||
if (g_BotState[client] == BOT_STATE_APPROACH)
|
||||
{
|
||||
float botPos[3];
|
||||
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;
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
for (int j = 1; j <= MaxClients; j++)
|
||||
{
|
||||
if (IsValidClient(j) && GetClientTeam(j) == GetClientTeam(i) && IsPlayerAlive(j) && GetClientIdleTime(j) < 20)
|
||||
{
|
||||
if (target_client[1] == j || target_client[2] == j || target_client[3] == j || target_client[4])
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
g_BotState[i] = BOT_STATE_IDLE;
|
||||
g_LastWeaponSlot[i] = 0;
|
||||
g_ResyncStrikes[i] = 0;
|
||||
g_StuckStrikes[i] = 0;
|
||||
g_WantJump[i] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
char msg[generic_length];
|
||||
int i_port = GetConVarInt(FindConVar("hostport"));
|
||||
bool bot1_connected = false;
|
||||
bool bot2_connected = false;
|
||||
bool bot3_connected = false;
|
||||
bool bot4_connected = false;
|
||||
for (int i = 1; i <= MaxClients; i++)
|
||||
if (IsValidClient(i) && !IsFakeClient(i))
|
||||
{
|
||||
if (specific_bot_player[i] == 1)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
bot4_connected = true;
|
||||
}
|
||||
@@ -327,20 +875,11 @@ public Action bot_check_connect(Handle timer, any data)
|
||||
if (client_count > max_allowed_players)
|
||||
{
|
||||
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]);
|
||||
}
|
||||
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]);
|
||||
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)
|
||||
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
|
||||
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)
|
||||
send_socket_msg(msg, strlen(msg), ports[3]);
|
||||
}
|
||||
@@ -379,7 +912,15 @@ public Action check_team(Handle timer, any data)
|
||||
{
|
||||
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 (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;
|
||||
}
|
||||
@@ -470,11 +1011,19 @@ public float get_power_distance(int target_player, float pos[3])
|
||||
public void OnClientPutInServer(int client)
|
||||
{
|
||||
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)
|
||||
{
|
||||
specific_bot_player[client] = -1;
|
||||
g_BotState[client] = BOT_STATE_IDLE;
|
||||
g_LastWeaponSlot[client] = 0;
|
||||
is_autism_bot1(client);
|
||||
is_autism_bot2(client);
|
||||
is_autism_bot3(client);
|
||||
@@ -527,6 +1076,24 @@ stock void connect_socket()
|
||||
public void OnClientDisconnect(int client)
|
||||
{
|
||||
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
|
||||
|
||||
@@ -3,21 +3,31 @@
|
||||
#include <json>
|
||||
#include <ccc>
|
||||
#include <cstrike>
|
||||
#include <unloze_playtime>
|
||||
|
||||
public Plugin myinfo =
|
||||
{
|
||||
name = "Racetimer Rank",
|
||||
author = "jenz",
|
||||
description = "Give racetimer points as a level clan tag to players, also for chat tag",
|
||||
version = "1.1",
|
||||
version = "1.3",
|
||||
url = ""
|
||||
};
|
||||
|
||||
bool g_b_ignoring_tags[MAXPLAYERS + 1];
|
||||
Database g_hDatabase;
|
||||
ConVar g_Cvar_TierChatLVL;
|
||||
ConVar g_Cvar_TierChatLVLMultiColor;
|
||||
ConVar g_Cvar_TierChatLVLAllColor;
|
||||
ConVar g_Cvar_TierClanLVL;
|
||||
|
||||
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");
|
||||
if (!g_hDatabase)
|
||||
{
|
||||
@@ -28,9 +38,10 @@ public void OnPluginStart()
|
||||
{
|
||||
if(IsClientInGame(client))
|
||||
{
|
||||
OnClientPostAdminFilter(client);
|
||||
OnClientPostAdminCheck(client);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void OnMapStart()
|
||||
@@ -55,7 +66,7 @@ public void SQL_OnDatabaseConnect(Database db, const char[] error, any data)
|
||||
{
|
||||
if(IsClientInGame(client))
|
||||
{
|
||||
OnClientPostAdminFilter(client);
|
||||
OnClientPostAdminCheck(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +77,15 @@ public Action Command_LvTag(int client, int args)
|
||||
{
|
||||
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.
|
||||
//kinda hate wasting time on pointless stuff like this.
|
||||
char sSID[64];
|
||||
@@ -107,7 +127,7 @@ public void SQL_FinishedQuery(Database db, DBResultSet results, const char[] err
|
||||
else
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
public void OnClientPostAdminFilter(int client)
|
||||
public void OnClientPostAdminCheck(int client)
|
||||
{
|
||||
if(!IsClientAuthorized(client) || IsClientSourceTV(client))
|
||||
return;
|
||||
|
||||
if (IsFakeClient(client))
|
||||
{
|
||||
char tag[64];
|
||||
Format(tag, sizeof(tag), "[LVL 1]");
|
||||
CS_SetClientClanTag(client, tag);
|
||||
return;
|
||||
}
|
||||
|
||||
g_b_ignoring_tags[client] = false;
|
||||
|
||||
check_ignoring_tags(client);
|
||||
}
|
||||
|
||||
@@ -236,19 +254,22 @@ public int OnTransferResponse(char[] sData, int iSerial)
|
||||
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];
|
||||
Format(tag, sizeof(tag), "[LVL %i]", I_player_points/1000);
|
||||
CS_SetClientClanTag(client, tag);
|
||||
}
|
||||
|
||||
int I_rank = obj.GetInt("Rank") //if no endpoint for steamID default value is -1
|
||||
if (0 < I_rank < 1000) //only give chat tag to top 1000
|
||||
tier_required = GetConVarInt(g_Cvar_TierChatLVL);
|
||||
client_tier = GetPlayerTier_native(client);
|
||||
if (client_tier >= tier_required)
|
||||
{
|
||||
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);
|
||||
@@ -262,7 +283,7 @@ public bool player_has_no_chattag(int client)
|
||||
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 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 blue = ((lvl + 100) * 19) % 256;
|
||||
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];
|
||||
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);
|
||||
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;
|
||||
green = ((lvl + 50) * 23) % 256;
|
||||
@@ -312,6 +339,8 @@ public void set_level_tag(int client, int lvl, int I_rank)
|
||||
return;
|
||||
}
|
||||
|
||||
//we display LVL with ALL of the multi colour combinations
|
||||
|
||||
red = (GetRandomInt(0, 255)) % 256;
|
||||
green = (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);
|
||||
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()
|
||||
{
|
||||
int iLevelCounterEnt = FindEntityByTargetname(INVALID_ENT_REFERENCE, "LevelCounter", "math_counter");
|
||||
int iLevelCounterEnt = FindEntityByTargetname(INVALID_ENT_REFERENCE, "LevelCounter", "info_target");
|
||||
if (iLevelCounterEnt == -1)
|
||||
return 0;
|
||||
int offset = FindDataMapInfo(iLevelCounterEnt, "m_OutValue");
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pip install mysql-connector-python
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
Executable
+120
@@ -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()
|
||||
@@ -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="")
|
||||
|
||||
+1810
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
|
||||
@@ -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 6–15h 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 });
|
||||
}
|
||||
+132
@@ -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 (A–Z)</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:00–15: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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -14,7 +14,6 @@ char g_csSID[MAXPLAYERS + 1][65];
|
||||
char g_cTimeRecords[1000][128];
|
||||
int g_iPlayerTimeServer[MAXPLAYERS + 1];
|
||||
int g_iPlayerAFKTime;
|
||||
int g_iPlayerCount_excludeSpec;
|
||||
int g_iPlayerRTVCapacity;
|
||||
int g_iAvgHour_Contribution_per_player;
|
||||
int g_iRtvBoost_tier;
|
||||
@@ -30,6 +29,9 @@ StringMap g_GroupOverrides; // group name -> comma-separated override list
|
||||
static Handle g_hForwardPlayerHours;
|
||||
static Handle g_hForwardPlayerTier;
|
||||
|
||||
bool G_bResponsePassed[MAXPLAYERS+1];
|
||||
bool G_bPreAdminChecked[MAXPLAYERS+1];
|
||||
|
||||
public Plugin myinfo =
|
||||
{
|
||||
name = "UNLOZE_player_time",
|
||||
@@ -160,18 +162,13 @@ public void OnPluginStart()
|
||||
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);
|
||||
CreateTimer(600.0, UpdateForward, _, TIMER_REPEAT);
|
||||
CreateTimer(60.0, UpdateForward, _, TIMER_REPEAT);
|
||||
|
||||
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);
|
||||
g_iPlayerAFKTime = cvar.IntValue;
|
||||
delete cvar;
|
||||
|
||||
ConVar cvar1;
|
||||
HookConVarChange((cvar1 = CreateConVar("sm_exclude_specs_avghour", "20", "How many players must be considered active on the server before specs are included in generating the average hour count")), Cvar_playerExcludeSpec);
|
||||
g_iPlayerCount_excludeSpec = cvar1.IntValue;
|
||||
delete cvar1;
|
||||
|
||||
ConVar cvar2;
|
||||
HookConVarChange((cvar2 = CreateConVar("sm_rtv_avg_capacity", "5", "The capacity for how many times the average a players rtv can be worth.")), Cvar_playerRTVAverageCap);
|
||||
g_iPlayerRTVCapacity = cvar2.IntValue;
|
||||
@@ -205,11 +202,6 @@ public void Cvar_playerAFKTime(ConVar convar, const char[] oldValue, const char[
|
||||
g_iPlayerAFKTime = convar.IntValue;
|
||||
}
|
||||
|
||||
public void Cvar_playerExcludeSpec(ConVar convar, const char[] oldValue, const char[] newValue)
|
||||
{
|
||||
g_iPlayerCount_excludeSpec = convar.IntValue;
|
||||
}
|
||||
|
||||
public void Cvar_playerRTVAverageCap(ConVar convar, const char[] oldValue, const char[] newValue)
|
||||
{
|
||||
g_iPlayerRTVCapacity = convar.IntValue;
|
||||
@@ -240,26 +232,10 @@ public int GetAveragePlayerActiveTimeServer()
|
||||
{
|
||||
int total_hours = 0;
|
||||
int total_players = 0;
|
||||
int active_player_count = 0;
|
||||
for (int i = 0; i < MaxClients; i++)
|
||||
{
|
||||
//checking player count before deciding if including or excluding spectators from actual average
|
||||
//the purpose of this is me (jenz) not boosting the hour average insanely when the server is very empty. (i often sit spec without playing and that ruins the generated average when low population because the count gets way too high due to me)
|
||||
if (IsValidClient(i) && !IsFakeClient(i) && !IsClientSourceTV(i) && !is_bot_player[i]
|
||||
&& (GetClientTeam(i) == CS_TEAM_T || GetClientTeam(i) == CS_TEAM_CT))
|
||||
{
|
||||
active_player_count++;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < MaxClients; i++)
|
||||
{
|
||||
if (IsValidClient(i) && !IsFakeClient(i) && !IsClientSourceTV(i) && !is_bot_player[i] && GetClientIdleTime(i) < g_iPlayerAFKTime)
|
||||
{
|
||||
if (active_player_count < g_iPlayerCount_excludeSpec && GetClientTeam(i) != CS_TEAM_T && GetClientTeam(i) != CS_TEAM_CT)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
//re-introducing the hour capacity but putting it here is better than putting it at the mysql query
|
||||
//at the time of making this change its only affeting me jenz and nobody else. this is meant to prevent
|
||||
//the average hour from being too skewed by high playtime people.
|
||||
@@ -312,8 +288,6 @@ public int Native_GetPlayerWorthRTV_boost(Handle plugin, int numParams)
|
||||
return view_as<int>(-1);
|
||||
}
|
||||
|
||||
int avg = GetAveragePlayerActiveTimeServer();
|
||||
|
||||
//give vips the rtv/nomination/mapvote boost by default
|
||||
AdminId id = GetUserAdmin(client);
|
||||
if (id != INVALID_ADMIN_ID && GetAdminFlag(id, Admin_Reservation))
|
||||
@@ -327,21 +301,8 @@ public int Native_GetPlayerWorthRTV_boost(Handle plugin, int numParams)
|
||||
return g_iPlayerRTVCapacity;
|
||||
}
|
||||
|
||||
if (g_iPlayerTimeServer[client] <= avg || avg == 0 || is_bot_player[client] || IsFakeClient(client))
|
||||
{
|
||||
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()
|
||||
{
|
||||
@@ -363,20 +324,20 @@ public void SQL_OnDatabaseConnect(Database db, const char[] error, any data)
|
||||
{
|
||||
if(IsValidClient(i) && !IsFakeClient(i))
|
||||
{
|
||||
OnClientPostAdminCheck(i);
|
||||
OnClientAuthorized(i, g_csSID[i]);
|
||||
}
|
||||
}
|
||||
OnMapStart();
|
||||
}
|
||||
|
||||
public void OnClientPostAdminCheck(int client)
|
||||
public void OnClientAuthorized(int client, const char[] sSteamID32)
|
||||
{
|
||||
g_iPlayerNextTierHours[client] = 0;
|
||||
GetClientAuthId(client, AuthId_Steam2, g_csSID[client], sizeof(g_csSID[]));
|
||||
is_bot_player[client] = false;
|
||||
g_iPlayerTier[client] = -1;
|
||||
g_iPlayerTimeServer[client] = 0;
|
||||
if(!IsValidClient(client) || IsFakeClient(client))
|
||||
if(IsFakeClient(client))
|
||||
return;
|
||||
if (!g_hDatabase)
|
||||
{
|
||||
@@ -463,125 +424,20 @@ public void SQL_OnQueryCompletedTimeServer(Database db, DBResultSet results, con
|
||||
if (g_iPlayerTier[client] == -1)
|
||||
{
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
BuildPath(Path_SM, sPath, sizeof(sPath), "configs/unloze_playt_time_tiers.cfg");
|
||||
@@ -616,7 +472,36 @@ public bool check_if_client_new_tier(int 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;
|
||||
}
|
||||
|
||||
@@ -625,20 +510,6 @@ public bool check_if_client_new_tier(int client)
|
||||
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)
|
||||
{
|
||||
AdminId id = GetUserAdmin(client);
|
||||
@@ -700,16 +571,10 @@ public void SetPlayerTier(int client)
|
||||
g_iPlayerTier[client] = 0;
|
||||
}
|
||||
|
||||
Call_StartForward(g_hForwardPlayerTier);
|
||||
Call_PushCell(client);
|
||||
Call_PushCell(g_iPlayerTier[client]);
|
||||
Call_Finish();
|
||||
if (g_iPlayerTimeServer[client] < next_hours)
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -721,6 +586,8 @@ public void OnClientDisconnect(int client)
|
||||
Format(g_csSID[client], sizeof(g_csSID[]), "");
|
||||
is_bot_player[client] = false;
|
||||
g_iPlayerTimeServer[client] = 0;
|
||||
G_bPreAdminChecked[client] = false;
|
||||
G_bResponsePassed[client] = false;
|
||||
}
|
||||
|
||||
public void insert_client(int client)
|
||||
@@ -836,44 +703,74 @@ public void PrintClientGroupOverrides(int client)
|
||||
char multi_nomination_tier[32];
|
||||
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++)
|
||||
{
|
||||
char entry[128];
|
||||
char entry[512];
|
||||
char overrides[512];
|
||||
|
||||
if (StrEqual(g_GroupNames[i], rtv_boost_tier))
|
||||
{
|
||||
Format(entry, sizeof(entry), "%s: %ix Mapvote/RTV/Nomination boost", g_GroupNames[i], g_iPlayerRTVCapacity);
|
||||
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
|
||||
Format(entry, sizeof(entry), "%s%s: %ix Mapvote/RTV/Nomination boost, ", entry, g_GroupNames[i], g_iPlayerRTVCapacity);
|
||||
}
|
||||
if (StrEqual(g_GroupNames[i], spray_tier))
|
||||
{
|
||||
Format(entry, sizeof(entry), "%s: Sprays", g_GroupNames[i]);
|
||||
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
|
||||
Format(entry, sizeof(entry), "%s%s: Sprays, ", entry, g_GroupNames[i]);
|
||||
}
|
||||
if (StrEqual(g_GroupNames[i], entwatch_tier))
|
||||
{
|
||||
Format(entry, sizeof(entry), "%s: Items", g_GroupNames[i]);
|
||||
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
|
||||
Format(entry, sizeof(entry), "%s%s: Items, ", entry, g_GroupNames[i]);
|
||||
}
|
||||
if (StrEqual(g_GroupNames[i], multi_nomination_tier))
|
||||
{
|
||||
Format(entry, sizeof(entry), "%s: Multiple nominations", g_GroupNames[i]);
|
||||
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
|
||||
Format(entry, sizeof(entry), "%s%s: Multiple nominations, ", entry, g_GroupNames[i]);
|
||||
}
|
||||
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"))
|
||||
{
|
||||
Format(entry, sizeof(entry), "%s: VIP", g_GroupNames[i]);
|
||||
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
|
||||
Format(entry, sizeof(entry), "%s%s: VIP, ", entry, g_GroupNames[i]);
|
||||
}
|
||||
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", "any tier: playermodels in zclass.", ITEMDRAW_DISABLED);
|
||||
menu.ExitButton = true;
|
||||
menu.Display(client, 0);
|
||||
}
|
||||
@@ -1016,3 +913,78 @@ public int Handler_Menu(Menu menu, MenuAction action, int param1, int param2)
|
||||
}
|
||||
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
@@ -4,27 +4,48 @@ if [ $# -eq 1 ]
|
||||
then
|
||||
echo 'doing long run with all files being updated.'
|
||||
#svencoop
|
||||
rclone copy /home/gameservers/fastdl/svencoop/svencoop_addon r2demo:/svencoop/
|
||||
#gmod zs
|
||||
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/maps r2demo:/gmod_zs/maps/
|
||||
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/gmod_zs/models r2demo:/gmod_zs/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/gmod_zs/resource r2demo:/gmod_zs/resource/
|
||||
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/particles r2demo:/gmod_zs/particles/
|
||||
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/svencoop/svencoop_addon r2demo:unloze-r2/svencoop/
|
||||
|
||||
#ZE server
|
||||
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/css_ze/models r2demo:unloze-r2/css_ze/models/
|
||||
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/css_ze/maps r2demo:unloze-r2/css_ze/maps/
|
||||
|
||||
#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.'
|
||||
#else if no arguement given run default with 30minute maximum.
|
||||
else
|
||||
echo 'doing short run with files younger than 30 minutes.'
|
||||
#svencoop
|
||||
rclone copy --max-age 30m --no-traverse /home/gameservers/fastdl/svencoop/svencoop_addon r2demo:/svencoop/
|
||||
#gmod zs
|
||||
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/gmod_zs/materials r2demo:/gmod_zs/materials/
|
||||
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/gmod_zs/sound r2demo:/gmod_zs/sound/
|
||||
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/
|
||||
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/svencoop/svencoop_addon r2demo:unloze-r2/svencoop/
|
||||
#ZE server
|
||||
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/css_ze/models r2demo:unloze-r2/css_ze/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/css_ze/maps r2demo:unloze-r2/css_ze/maps/
|
||||
|
||||
#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.'
|
||||
fi
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
#include <sdktools>
|
||||
|
||||
int map_switches_count = -1;
|
||||
char g_cMaps[500][256];
|
||||
char g_cMaps[800][256];
|
||||
int g_i_rotate_time = 1;
|
||||
|
||||
//----------------------------------------------------------------------------------------------------
|
||||
// Purpose:
|
||||
@@ -16,11 +17,20 @@ public Plugin myinfo =
|
||||
url = "www.unloze.com"
|
||||
};
|
||||
|
||||
public void Cvar_rotate_time(ConVar convar, const char[] oldValue, const char[] newValue)
|
||||
{
|
||||
g_i_rotate_time = convar.IntValue;
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------
|
||||
// Purpose:
|
||||
//----------------------------------------------------------------------------------------------------
|
||||
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);
|
||||
|
||||
new Handle:fileHandle = OpenFile("wtf.txt", "r" );
|
||||
@@ -45,17 +55,17 @@ public Action cmd_restart_the_map(int client, int args)
|
||||
|
||||
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)
|
||||
{
|
||||
LogError("restart count reached: %i", map_switches_count);
|
||||
int local = map_switches_count;
|
||||
map_switches_count++;
|
||||
LogError("restart count reached: %i. nextmap: %s", map_switches_count, g_cMaps[local]);
|
||||
ForceChangeLevel(g_cMaps[local], "");
|
||||
return Plugin_Handled;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <sourcemod>
|
||||
#include <dhooks>
|
||||
#include <mapchooser_extended>
|
||||
|
||||
//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)
|
||||
{
|
||||
new Handle:fileHandle = OpenFile("maps_66_tick.txt", "r" );
|
||||
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);
|
||||
bool changeTick = GetMapTickRate(map) == 0 ? false : true;
|
||||
float interveral_per_tick_pre = GetConVarFloat(FindConVar("sm_interval_per_tick"));
|
||||
bool double_map_change = false;
|
||||
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
replicate
|
||||
requests
|
||||
Pillow
|
||||
|
||||
@@ -6,7 +6,6 @@ import time
|
||||
import base64
|
||||
import re, json
|
||||
import os
|
||||
import replicate as replicate_client
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
@@ -182,12 +181,40 @@ def send_claude_message(reacted_emotes, metrics, custom_emotes_json, attachment_
|
||||
return parsed
|
||||
|
||||
def replicate_fetch_image(image_prompt):
|
||||
os.environ["REPLICATE_API_TOKEN"] = replicate_api_key
|
||||
output = replicate_client.run(
|
||||
"black-forest-labs/flux-schnell",
|
||||
input={"prompt": image_prompt}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {replicate_api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
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():
|
||||
custom_emotes_json = get_custom_emotes()
|
||||
|
||||
@@ -26,4 +26,6 @@ Adapt config.json.
|
||||
##### Make sure you are in the virtualenv! (`. venv/bin/activate`)
|
||||
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 -*-
|
||||
import logging
|
||||
import sys
|
||||
import io
|
||||
import math
|
||||
import time
|
||||
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():
|
||||
AUDIOPLAYER_FFMPEG = 1
|
||||
|
||||
@@ -40,12 +22,9 @@ class AudioPlayerFactory():
|
||||
def NewPlayer(self, _type):
|
||||
if _type == self.AUDIOPLAYER_FFMPEG:
|
||||
return self.FFmpegAudioPlayerFactory.NewPlayer()
|
||||
raise ValueError("Unsupported audio player type: {0}".format(_type))
|
||||
|
||||
|
||||
class AntiSpam():
|
||||
GLOBAL_PLAY_TIME_IMMUNITY_LEVEL = 10
|
||||
|
||||
def __init__(self, master):
|
||||
self.Logger = logging.getLogger(__class__.__name__)
|
||||
self.Master = master
|
||||
@@ -53,9 +32,7 @@ class AntiSpam():
|
||||
|
||||
self.LastClips = dict()
|
||||
self.DisabledTime = None
|
||||
|
||||
def _counts_toward_global_play_time(self, level):
|
||||
return level < self.GLOBAL_PLAY_TIME_IMMUNITY_LEVEL
|
||||
self.SaidHint = False
|
||||
|
||||
def CheckAntiSpam(self, player):
|
||||
if self.DisabledTime and self.DisabledTime > self.Torchlight().Master.Loop.time() and \
|
||||
@@ -67,40 +44,36 @@ class AntiSpam():
|
||||
|
||||
return True
|
||||
|
||||
def SpamCheck(self):
|
||||
def SpamCheck(self, Delta):
|
||||
Now = self.Torchlight().Master.Loop.time()
|
||||
Duration = 0.0
|
||||
config = self.Torchlight().Config["AntiSpam"]
|
||||
|
||||
for Key, Clip in list(self.LastClips.items()):
|
||||
if not Clip["timestamp"]:
|
||||
continue
|
||||
|
||||
if Clip["timestamp"] + Clip["duration"] + config["MaxUsageSpan"] < Now:
|
||||
if Clip["timestamp"] + Clip["duration"] + self.Torchlight().Config["AntiSpam"]["MaxUsageSpan"] < Now:
|
||||
if not Clip["active"]:
|
||||
del self.LastClips[Key]
|
||||
continue
|
||||
|
||||
Duration += Clip["duration"]
|
||||
|
||||
if Duration > config["MaxUsageTime"]:
|
||||
self.DisabledTime = self.Torchlight().Master.Loop.time() + config["PunishDelay"]
|
||||
if Duration > self.Torchlight().Config["AntiSpam"]["MaxUsageTime"]:
|
||||
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(
|
||||
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
|
||||
for AudioClip in self.Master.AudioClips[:]:
|
||||
if AudioClip.Level < config["ImmunityLevel"]:
|
||||
if AudioClip.Level < self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]:
|
||||
AudioClip.Stop()
|
||||
|
||||
self.LastClips.clear()
|
||||
|
||||
def OnPlay(self, clip):
|
||||
if not self._counts_toward_global_play_time(clip.Level):
|
||||
return
|
||||
|
||||
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
|
||||
for Key, Clip in self.LastClips.items():
|
||||
@@ -108,50 +81,31 @@ class AntiSpam():
|
||||
HasDominant = True
|
||||
break
|
||||
|
||||
self.LastClips[clip]["dominant"] = not HasDominant
|
||||
self.LastClips[hash(clip)]["dominant"] = not HasDominant
|
||||
|
||||
def OnStop(self, clip):
|
||||
if not self._counts_toward_global_play_time(clip.Level):
|
||||
if hash(clip) not in self.LastClips:
|
||||
return
|
||||
|
||||
if clip not in self.LastClips:
|
||||
return
|
||||
self.LastClips[hash(clip)]["active"] = False
|
||||
|
||||
self.LastClips[clip]["active"] = False
|
||||
|
||||
if self.LastClips[clip]["dominant"]:
|
||||
if self.LastClips[hash(clip)]["dominant"]:
|
||||
for Key, Clip in self.LastClips.items():
|
||||
if Clip["active"]:
|
||||
Clip["dominant"] = True
|
||||
break
|
||||
|
||||
self.LastClips[clip]["dominant"] = False
|
||||
self.LastClips[hash(clip)]["dominant"] = False
|
||||
|
||||
def OnUpdate(self, clip, old_position, new_position):
|
||||
if not self._counts_toward_global_play_time(clip.Level):
|
||||
return
|
||||
Delta = new_position - old_position
|
||||
Clip = self.LastClips[hash(clip)]
|
||||
|
||||
if clip not in self.LastClips:
|
||||
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"]:
|
||||
if not Clip["dominant"]:
|
||||
return
|
||||
|
||||
Clip["duration"] += Delta
|
||||
self.SpamCheck()
|
||||
self.SpamCheck(Delta)
|
||||
|
||||
|
||||
class Advertiser():
|
||||
@@ -161,33 +115,37 @@ class Advertiser():
|
||||
self.Torchlight = self.Master.Torchlight
|
||||
|
||||
self.LastClips = dict()
|
||||
self.AdStop = 0
|
||||
self.NextAdStop = 0
|
||||
|
||||
def Think(self, clip):
|
||||
def Think(self, Delta):
|
||||
Now = self.Torchlight().Master.Loop.time()
|
||||
config = self.Torchlight().Config["Advertiser"]
|
||||
Duration = 0.0
|
||||
|
||||
for Key, Clip in list(self.LastClips.items()):
|
||||
if not Clip["timestamp"]:
|
||||
continue
|
||||
|
||||
if Clip["timestamp"] + Clip["duration"] + config["MaxSpan"] < Now:
|
||||
if Clip["timestamp"] + Clip["duration"] + self.Torchlight().Config["Advertiser"]["MaxSpan"] < Now:
|
||||
if not Clip["active"]:
|
||||
del self.LastClips[Key]
|
||||
continue
|
||||
|
||||
Duration += Clip["duration"]
|
||||
|
||||
Clip = self.LastClips.get(clip)
|
||||
if not Clip:
|
||||
return
|
||||
|
||||
if not clip.StopHinted and not Clip.get("hinted") and Clip["duration"] >= config["AdStop"]:
|
||||
self.NextAdStop -= Delta
|
||||
CeilDur = math.ceil(Duration)
|
||||
if CeilDur > self.AdStop and self.NextAdStop <= 0 and CeilDur % self.Torchlight().Config["Advertiser"]["AdStop"] == 0:
|
||||
self.Torchlight().SayChat("Hint: Type \x07FF0000!stop(ze) !pls(mg)\x01 to stop all currently playing sounds.")
|
||||
Clip["hinted"] = True
|
||||
clip.StopHinted = True
|
||||
self.AdStop = CeilDur
|
||||
self.NextAdStop = 0
|
||||
elif CeilDur < self.AdStop:
|
||||
self.AdStop = 0
|
||||
self.NextAdStop = self.Torchlight().Config["Advertiser"]["AdStop"] / 2
|
||||
|
||||
def OnPlay(self, clip):
|
||||
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
|
||||
for Key, Clip in self.LastClips.items():
|
||||
@@ -195,38 +153,31 @@ class Advertiser():
|
||||
HasDominant = True
|
||||
break
|
||||
|
||||
self.LastClips[clip]["dominant"] = not HasDominant
|
||||
self.LastClips[hash(clip)]["dominant"] = not HasDominant
|
||||
|
||||
def OnStop(self, clip):
|
||||
if clip not in self.LastClips:
|
||||
if hash(clip) not in self.LastClips:
|
||||
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():
|
||||
if Clip["active"]:
|
||||
Clip["dominant"] = True
|
||||
break
|
||||
|
||||
self.LastClips[clip]["dominant"] = False
|
||||
self.LastClips[hash(clip)]["dominant"] = False
|
||||
|
||||
def OnUpdate(self, clip, old_position, new_position):
|
||||
if clip not in 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)
|
||||
Delta = new_position - old_position
|
||||
Clip = self.LastClips[hash(clip)]
|
||||
|
||||
if Delta <= 0.0 or not Clip["dominant"]:
|
||||
if not Clip["dominant"]:
|
||||
return
|
||||
|
||||
Clip["duration"] += Delta
|
||||
self.Think(clip)
|
||||
self.Think(Delta)
|
||||
|
||||
|
||||
class AudioManager():
|
||||
@@ -246,23 +197,21 @@ class AudioManager():
|
||||
if player.Access:
|
||||
Level = player.Access["level"]
|
||||
|
||||
config = self.Torchlight().Config["AudioLimits"]
|
||||
if str(Level) in config:
|
||||
level_config = config[str(Level)]
|
||||
if level_config["Uses"] >= 0 and \
|
||||
player.Storage["Audio"]["Uses"] >= level_config["Uses"]:
|
||||
if str(Level) in self.Torchlight().Config["AudioLimits"]:
|
||||
if self.Torchlight().Config["AudioLimits"][str(Level)]["Uses"] >= 0 and \
|
||||
player.Storage["Audio"]["Uses"] >= self.Torchlight().Config["AudioLimits"][str(Level)]["Uses"]:
|
||||
|
||||
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
|
||||
|
||||
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(
|
||||
level_config["TotalTime"]))
|
||||
self.Torchlight().Config["AudioLimits"][str(Level)]["TotalTime"]))
|
||||
return False
|
||||
|
||||
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:
|
||||
self.Torchlight().SayPrivate(player, "You are currently on cooldown! ({0} seconds left)".format(
|
||||
@@ -272,34 +221,31 @@ class AudioManager():
|
||||
return True
|
||||
|
||||
def Stop(self, player, extra):
|
||||
Result = {"active": 0, "matched": 0, "stopped": 0, "pending": 0}
|
||||
stopper_level = player.Access["level"] if player.Access else 0
|
||||
Level = 0
|
||||
if player.Access:
|
||||
Level = player.Access["level"]
|
||||
|
||||
for AudioClip in self.AudioClips[:]:
|
||||
Result["active"] += 1
|
||||
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
|
||||
if extra and not extra.lower() in AudioClip.Player.Name.lower():
|
||||
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()
|
||||
Result["stopped"] += 1
|
||||
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))
|
||||
|
||||
return Result
|
||||
|
||||
def CreateAudioClip(self, player, uri, _type = AudioPlayerFactory.AUDIOPLAYER_FFMPEG):
|
||||
def AudioClip(self, player, uri, _type = AudioPlayerFactory.AUDIOPLAYER_FFMPEG):
|
||||
Level = 0
|
||||
if player.Access:
|
||||
Level = player.Access["level"]
|
||||
@@ -314,19 +260,10 @@ class AudioManager():
|
||||
if not self.CheckLimits(player):
|
||||
return None
|
||||
|
||||
try:
|
||||
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)
|
||||
|
||||
anti_spam_track_ceiling = min(
|
||||
self.Torchlight().Config["AntiSpam"]["ImmunityLevel"],
|
||||
self.AntiSpam.GLOBAL_PLAY_TIME_IMMUNITY_LEVEL,
|
||||
)
|
||||
if not player.Access or player.Access["level"] < anti_spam_track_ceiling:
|
||||
if not player.Access or player.Access["level"] < self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]:
|
||||
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("Update", lambda *args: self.AntiSpam.OnUpdate(Clip, *args))
|
||||
@@ -337,9 +274,6 @@ class AudioManager():
|
||||
|
||||
return Clip
|
||||
|
||||
def AudioClip(self, player, uri, _type = AudioPlayerFactory.AUDIOPLAYER_FFMPEG):
|
||||
return self.CreateAudioClip(player, uri, _type)
|
||||
|
||||
def OnDisconnect(self, player):
|
||||
for AudioClip in self.AudioClips[:]:
|
||||
if AudioClip.Player == player:
|
||||
@@ -355,9 +289,6 @@ class AudioClip():
|
||||
self.Type = _type
|
||||
self.URI = uri
|
||||
self.LastPosition = None
|
||||
self.LastUpdateRealtime = None
|
||||
self.StartedAt = None
|
||||
self.StopHinted = False
|
||||
self.Stops = set()
|
||||
|
||||
self.Level = 0
|
||||
@@ -385,28 +316,15 @@ class AudioClip():
|
||||
self.Player.Storage["Audio"]["Uses"] += 1
|
||||
self.Player.Storage["Audio"]["LastUse"] = self.Torchlight().Master.Loop.time()
|
||||
self.Player.Storage["Audio"]["LastUseLength"] = 0.0
|
||||
self.StartedAt = self.Player.Storage["Audio"]["LastUse"]
|
||||
self.LastUpdateRealtime = time.time()
|
||||
self.StopHinted = False
|
||||
|
||||
def OnStop(self):
|
||||
self.Logger.debug(sys._getframe().f_code.co_name + ' ' + self.URI)
|
||||
if self in self.Master.AudioClips:
|
||||
self.Master.AudioClips.remove(self)
|
||||
|
||||
played = None
|
||||
if self.AudioPlayer and self.AudioPlayer.StartedPlaying is not None:
|
||||
elapsed = time.time() - self.AudioPlayer.StartedPlaying
|
||||
if self.AudioPlayer.Seconds:
|
||||
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 self.AudioPlayer.Playing:
|
||||
Delta = self.AudioPlayer.Position - self.LastPosition
|
||||
self.Player.Storage["Audio"]["TimeUsed"] += Delta
|
||||
self.Player.Storage["Audio"]["LastUseLength"] += Delta
|
||||
|
||||
if str(self.Level) in self.Torchlight().Config["AudioLimits"]:
|
||||
if self.Player.Storage:
|
||||
@@ -420,18 +338,9 @@ class AudioClip():
|
||||
del self.AudioPlayer
|
||||
|
||||
def OnUpdate(self, old_position, new_position):
|
||||
now = time.time()
|
||||
elapsed = None
|
||||
if self.LastUpdateRealtime is not None:
|
||||
elapsed = now - self.LastUpdateRealtime
|
||||
self.LastUpdateRealtime = now
|
||||
|
||||
Delta = _progress_delta(old_position, new_position, elapsed = elapsed)
|
||||
Delta = new_position - old_position
|
||||
self.LastPosition = new_position
|
||||
|
||||
if Delta <= 0.0:
|
||||
return
|
||||
|
||||
self.Player.Storage["Audio"]["TimeUsed"] += Delta
|
||||
self.Player.Storage["Audio"]["LastUseLength"] += Delta
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ class CommandHandler():
|
||||
Ret = await Command._rfunc(line, RMatch, player)
|
||||
else:
|
||||
#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)
|
||||
else:
|
||||
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 struct
|
||||
import sys
|
||||
import os
|
||||
|
||||
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():
|
||||
VALID_CALLBACKS = ["Play", "Stop", "Update"]
|
||||
|
||||
@@ -43,7 +61,6 @@ class FFmpegAudioPlayer():
|
||||
self.Torchlight().Config["VoiceServer"]["Port"]
|
||||
)
|
||||
self.SampleRate = float(self.Torchlight().Config["VoiceServer"]["SampleRate"])
|
||||
self.Channels = int(self.Torchlight().Config["VoiceServer"].get("Channels", 2))
|
||||
|
||||
self.StartedPlaying = None
|
||||
self.StoppedPlaying = None
|
||||
@@ -51,6 +68,10 @@ class FFmpegAudioPlayer():
|
||||
|
||||
self.Writer = 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 = []
|
||||
|
||||
@@ -60,75 +81,58 @@ class FFmpegAudioPlayer():
|
||||
|
||||
def PlayURI(self, uri, position, rubberband = None, dec_params = None, bitrate = None,
|
||||
backwards = None, *args):
|
||||
if self.Playing:
|
||||
self.Stop()
|
||||
|
||||
proxy_url = ""
|
||||
if uri.startswith("http://") or uri.startswith("https://"):
|
||||
proxy_url = self.Torchlight().Config["Proxy"]
|
||||
IsYtdlp = uri.startswith(YTDLP_URI_PREFIX)
|
||||
# Commands.py may append a "#t=..." fragment to the URI purely so a
|
||||
# downstream handler can re-parse it into a start position (see
|
||||
# URLFilter.URLInfo) -- strip it back off here since the actual seek
|
||||
# already happens via `position`/"-ss" below, and yt-dlp doesn't need
|
||||
# (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:
|
||||
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))
|
||||
Command = ["/usr/bin/ffmpeg"]
|
||||
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"]
|
||||
Command = ["/usr/bin/ffmpeg", "-ss", PosStr, "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args]
|
||||
else:
|
||||
Command = ["/usr/bin/ffmpeg"]
|
||||
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"]
|
||||
Command = ["/usr/bin/ffmpeg", "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args]
|
||||
|
||||
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
|
||||
|
||||
if 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:
|
||||
# Treat rubberband as either a single pre-built filtergraph string
|
||||
# or an iterable of simple, comma-safe filters. Validate to avoid
|
||||
# generating invalid ffmpeg filter syntax.
|
||||
rubberband_filters = []
|
||||
if isinstance(rubberband, str):
|
||||
rubberband_filters = [rubberband]
|
||||
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)
|
||||
Command += ["-filter:a"]
|
||||
rubberCommand = ""
|
||||
for rubber in rubberband:
|
||||
rubberCommand += rubber + ", "
|
||||
rubberCommand = rubberCommand[:-2]
|
||||
Command += [rubberCommand]
|
||||
if backwards:
|
||||
filter_chain.append("areverse")
|
||||
Command += ["-filter:a", ",".join(filter_chain)]
|
||||
Command += ["-af"]
|
||||
Command += ["areverse"]
|
||||
if bitrate:
|
||||
Command += ["-b:a", f"{bitrate}k"]
|
||||
Command += ["-ab ", str(bitrate), "k"]
|
||||
self.Master.Logger.debug(f"command: {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
|
||||
|
||||
def Stop(self, force = True):
|
||||
@@ -143,6 +147,14 @@ class FFmpegAudioPlayer():
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
if self.SourceProcess:
|
||||
try:
|
||||
self.SourceProcess.terminate()
|
||||
self.SourceProcess.kill()
|
||||
self.SourceProcess = None
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
if self.Writer:
|
||||
if force:
|
||||
Socket = self.Writer.transport.get_extra_info("socket")
|
||||
@@ -157,7 +169,7 @@ class FFmpegAudioPlayer():
|
||||
self.Playing = False
|
||||
|
||||
self.Callback("Stop")
|
||||
self.Callbacks = []
|
||||
del self.Callbacks
|
||||
|
||||
return True
|
||||
|
||||
@@ -186,48 +198,31 @@ class FFmpegAudioPlayer():
|
||||
SecondsElapsed = self.Seconds
|
||||
|
||||
self.Callback("Update", LastSecondsElapsed, SecondsElapsed)
|
||||
LastSecondsElapsed = SecondsElapsed
|
||||
|
||||
if SecondsElapsed >= self.Seconds:
|
||||
if not self.StoppedPlaying:
|
||||
self.Master.Logger.warning("BUFFER UNDERRUN")
|
||||
await asyncio.sleep(0.05)
|
||||
continue
|
||||
print("BUFFER UNDERRUN!")
|
||||
self.Stop(False)
|
||||
return
|
||||
|
||||
LastSecondsElapsed = SecondsElapsed
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def _read_stream(self, stream, writer):
|
||||
Started = False
|
||||
try:
|
||||
|
||||
while stream and self.Playing:
|
||||
Data = await stream.read(65536)
|
||||
|
||||
if Data:
|
||||
try:
|
||||
writer.write(Data)
|
||||
await writer.drain()
|
||||
except (ConnectionError, BrokenPipeError, OSError) as ex:
|
||||
self.Master.Logger.warning("Voice socket write failed: %s", ex)
|
||||
break
|
||||
|
||||
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
|
||||
|
||||
if not Started:
|
||||
@@ -238,31 +233,102 @@ class FFmpegAudioPlayer():
|
||||
else:
|
||||
self.Process = None
|
||||
break
|
||||
finally:
|
||||
|
||||
self.StoppedPlaying = time.time()
|
||||
|
||||
async def _stream_subprocess(self, cmd):
|
||||
async def _stream_subprocess(self, cmd, source_url = None):
|
||||
if not self.Playing:
|
||||
return
|
||||
try:
|
||||
|
||||
_, 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,
|
||||
stdout = asyncio.subprocess.PIPE, stderr = asyncio.subprocess.DEVNULL)
|
||||
stdin = StdinArg,
|
||||
stdout = asyncio.subprocess.PIPE, stderr = asyncio.subprocess.PIPE)
|
||||
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 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()
|
||||
return
|
||||
|
||||
@@ -21,6 +21,8 @@ urllib3
|
||||
yarl
|
||||
cython
|
||||
geoip2
|
||||
bgutil-ytdlp-pot-provider
|
||||
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
|
||||
|
||||
+30
@@ -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"
|
||||
+1314
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user