Compare commits
51
Commits
f7263ffdef
..
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 |
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
apt-get install xvfb xserver-xephyr tigervnc-standalone-server x11-utils gnumeric
|
apt-get install xvfb xserver-xephyr tigervnc-standalone-server x11-utils gnumeric
|
||||||
|
|
||||||
|
apt-get install xdotool
|
||||||
|
|
||||||
#NOTICE: steam might not be able to launch in case of the tmpfs /dev/shm filesystem being full. it seems safe to just delete u1001-Shm_* files and u1002-Shm_ files. It should be fine because the files exists as long as at least one process has them open. So until steam is killed i assume. You dont need to be root for killing them, the user running the bot has permissions to rm the files
|
#NOTICE: steam might not be able to launch in case of the tmpfs /dev/shm filesystem being full. it seems safe to just delete u1001-Shm_* files and u1002-Shm_ files. It should be fine because the files exists as long as at least one process has them open. So until steam is killed i assume. You dont need to be root for killing them, the user running the bot has permissions to rm the files
|
||||||
|
|
||||||
#intel specific:
|
#intel specific:
|
||||||
@@ -34,7 +36,7 @@
|
|||||||
apt install libgtk2.0-0:i386
|
apt install libgtk2.0-0:i386
|
||||||
|
|
||||||
/home/gameservers/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/cfg/autoexec.cfg: <br/>
|
/home/gameservers/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/cfg/autoexec.cfg: <br/>
|
||||||
alias loop "exec looptest.cfg; wait 5; loop;"; wait 5; loop; <br/>
|
alias loop "exec looptest.cfg; wait 5; loop;"; wait 5; loop;
|
||||||
|
|
||||||
exporting correct SDL_VIDEO_X11_VISUALID value is important before steam login for starting games correctly <br/>
|
exporting correct SDL_VIDEO_X11_VISUALID value is important before steam login for starting games correctly <br/>
|
||||||
<br/> With the virtual desktop related to the VNC/x2go/nomachine client you can log into steam using the normal GUI -> <br/>
|
<br/> With the virtual desktop related to the VNC/x2go/nomachine client you can log into steam using the normal GUI -> <br/>
|
||||||
@@ -46,6 +48,7 @@
|
|||||||
echo 'SDL_VIDEO_X11_VISUALID: ' $SDL_VIDEO_X11_VISUALID
|
echo 'SDL_VIDEO_X11_VISUALID: ' $SDL_VIDEO_X11_VISUALID
|
||||||
export SDL_VIDEO_X11_VISUALID=0x205
|
export SDL_VIDEO_X11_VISUALID=0x205
|
||||||
echo 'SDL_VIDEO_X11_VISUALID: ' $SDL_VIDEO_X11_VISUALID
|
echo 'SDL_VIDEO_X11_VISUALID: ' $SDL_VIDEO_X11_VISUALID
|
||||||
|
echo "DISPLAY=$DISPLAY" > /home/$(whoami)/ze_runner/display.txt
|
||||||
dbus-run-session /usr/games/steam -applaunch 240 -novid -noipx -nojoy -noshaderapi
|
dbus-run-session /usr/games/steam -applaunch 240 -novid -noipx -nojoy -noshaderapi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -88,5 +91,4 @@ limiting cpu can throttle the download speed as well. if bots cant connect and a
|
|||||||
#pip3 extra
|
#pip3 extra
|
||||||
|
|
||||||
pip3 install watchdog
|
pip3 install watchdog
|
||||||
|
pip3 install requests
|
||||||
|
|
||||||
|
|||||||
@@ -7,16 +7,18 @@ import pwd
|
|||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
from watchdog.events import FileSystemEventHandler
|
from watchdog.events import FileSystemEventHandler
|
||||||
import random
|
import random
|
||||||
|
import bz2
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import codecs
|
import codecs
|
||||||
import json
|
import json
|
||||||
|
import requests
|
||||||
import datetime
|
import datetime
|
||||||
import time
|
import time
|
||||||
import glob
|
import glob
|
||||||
import pwd
|
import pwd
|
||||||
|
|
||||||
restart_time = datetime.datetime.now() + datetime.timedelta(hours=3)
|
restart_time = datetime.datetime.now() + datetime.timedelta(hours=6)
|
||||||
|
|
||||||
whoami = subprocess.getoutput("whoami")
|
whoami = subprocess.getoutput("whoami")
|
||||||
with open(f'/home/{whoami}/ze_runner/config.json') as jsonfile:
|
with open(f'/home/{whoami}/ze_runner/config.json') as jsonfile:
|
||||||
@@ -24,10 +26,6 @@ with open(f'/home/{whoami}/ze_runner/config.json') as jsonfile:
|
|||||||
looptestPath = f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/cfg/looptest.cfg"
|
looptestPath = f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/cfg/looptest.cfg"
|
||||||
chatmsg = ""
|
chatmsg = ""
|
||||||
|
|
||||||
def overwrite_file_access():
|
|
||||||
#setting defaults with setfacl for the download directory does not work seemingly
|
|
||||||
subprocess.Popen(["chmod", "-R", "g+rwx", f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/download"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).communicate()
|
|
||||||
|
|
||||||
def writeCfgInput(Input_user):
|
def writeCfgInput(Input_user):
|
||||||
with open(looptestPath, 'w') as f:
|
with open(looptestPath, 'w') as f:
|
||||||
f.write(Input_user)
|
f.write(Input_user)
|
||||||
@@ -39,6 +37,36 @@ def writeCfgInput(Input_user):
|
|||||||
time.sleep(0.35)
|
time.sleep(0.35)
|
||||||
open(looptestPath, 'w').close() #clearing file.
|
open(looptestPath, 'w').close() #clearing file.
|
||||||
|
|
||||||
|
def get_display_env_from_file():
|
||||||
|
display_path = f"/home/{whoami}/ze_runner/display.txt"
|
||||||
|
env = {}
|
||||||
|
try:
|
||||||
|
with open(display_path) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if "=" in line:
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
env[key] = value
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
return env
|
||||||
|
|
||||||
|
def check_instance_conflict_dialog():
|
||||||
|
display_env = get_display_env_from_file() or {}
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["DISPLAY"] = display_env.get("DISPLAY")
|
||||||
|
result = subprocess.run(
|
||||||
|
["xdotool", "search", "--name", "Source - Warning"],
|
||||||
|
env=env, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
def overwrite_file_access():
|
||||||
|
#setting defaults with setfacl for the download directory does not work seemingly
|
||||||
|
subprocess.Popen(["chmod", "-R", "g+rwx", f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/download"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).communicate()
|
||||||
|
|
||||||
def clean_up_files():
|
def clean_up_files():
|
||||||
#deleting POSIX shared memory objects, as long as one process has them open they exist. THis is to prevent /dev/shm from being full
|
#deleting POSIX shared memory objects, as long as one process has them open they exist. THis is to prevent /dev/shm from being full
|
||||||
#due to steam child processes.
|
#due to steam child processes.
|
||||||
@@ -78,7 +106,6 @@ def delete_bsp_files():
|
|||||||
|
|
||||||
def exit_handler():
|
def exit_handler():
|
||||||
print('reached exithandler')
|
print('reached exithandler')
|
||||||
#securing the looptest.cfg wont be stuck accidently with commands
|
|
||||||
kill_owned_process("pidof cstrike_linux64")
|
kill_owned_process("pidof cstrike_linux64")
|
||||||
kill_owned_process("pidof xterm")
|
kill_owned_process("pidof xterm")
|
||||||
|
|
||||||
@@ -126,12 +153,19 @@ def restart_sdl_and_steam():
|
|||||||
|
|
||||||
def bot_connect(data):
|
def bot_connect(data):
|
||||||
#use whatever ip you want here to connect with
|
#use whatever ip you want here to connect with
|
||||||
str1 = ""
|
server = ""
|
||||||
if "connect to ze" == data:
|
if "connect to ze" == data:
|
||||||
str1 = f"connect {data_ports['server_ip_port_ze']};"
|
server = f"{data_ports['server_ip_port_ze']};"
|
||||||
elif "connect to ze2" == data:
|
elif "connect to ze2" == data:
|
||||||
str1 = f"connect {data_ports['server_ip_port_ze2']};" #wait 15000;
|
server = f"{data_ports['server_ip_port_ze2']};"
|
||||||
writeCfgInput(str1)
|
|
||||||
|
display_env = get_display_env_from_file() or {}
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["DISPLAY"] = display_env.get("DISPLAY")
|
||||||
|
subprocess.run(
|
||||||
|
["/usr/games/steam", f"steam://connect/{server}"],
|
||||||
|
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
def cpulimit_pid_of_game():
|
def cpulimit_pid_of_game():
|
||||||
# ' > /dev/null' redirects stdout to /dev/null
|
# ' > /dev/null' redirects stdout to /dev/null
|
||||||
@@ -179,9 +213,9 @@ if __name__ == '__main__':
|
|||||||
sock.bind(("", local_port))
|
sock.bind(("", local_port))
|
||||||
sock.settimeout(5.0)
|
sock.settimeout(5.0)
|
||||||
messager_name = ""
|
messager_name = ""
|
||||||
|
|
||||||
failed_connect_count = 0
|
|
||||||
|
|
||||||
|
fail_safe_counter = 0
|
||||||
|
|
||||||
launch_game()
|
launch_game()
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -212,24 +246,51 @@ if __name__ == '__main__':
|
|||||||
elif "autismo connected to ze" == data:
|
elif "autismo connected to ze" == data:
|
||||||
print('Bot connected to ze!')
|
print('Bot connected to ze!')
|
||||||
is_bot_connected_to_ze2 = False
|
is_bot_connected_to_ze2 = False
|
||||||
failed_connect_count = 0
|
fail_safe_counter = 0
|
||||||
elif "not connected to ze2" == data:
|
elif "not connected to ze2" == data:
|
||||||
is_bot_connected_to_ze2 = False
|
is_bot_connected_to_ze2 = False
|
||||||
elif "autismo connected to ze2" == data:
|
elif "autismo connected to ze2" == data:
|
||||||
print('Bot connected to ze2!')
|
print('Bot connected to ze2!')
|
||||||
is_bot_connected_to_ze2 = True
|
is_bot_connected_to_ze2 = True
|
||||||
failed_connect_count = 0
|
fail_safe_counter = 0
|
||||||
|
elif data.startswith("download map:"):
|
||||||
|
map_to_download = data.split("download map:")[1].strip()
|
||||||
|
map_to_download_url = "https://uk-fastdl.unloze.com/css_ze/maps/" + map_to_download + ".bsp.bz2"
|
||||||
|
map_folder = f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/download/maps/"
|
||||||
|
bz2_path = map_folder + map_to_download + ".bsp.bz2"
|
||||||
|
bsp_path = map_folder + map_to_download + ".bsp"
|
||||||
|
|
||||||
|
if os.path.exists(bsp_path):
|
||||||
|
continue
|
||||||
|
elif os.path.exists(bz2_path):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
with requests.get(map_to_download_url, stream=True, timeout=30) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
with open(bz2_path, "wb") as f:
|
||||||
|
for chunk in r.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
with open(bz2_path, "rb") as f:
|
||||||
|
compressed_data = f.read()
|
||||||
|
decompressed_data = bz2.decompress(compressed_data)
|
||||||
|
with open(bsp_path, "wb") as f:
|
||||||
|
f.write(decompressed_data)
|
||||||
|
os.remove(bz2_path)
|
||||||
|
print(f"downloaded and extracted map: {bsp_path}")
|
||||||
|
overwrite_file_access()
|
||||||
elif "connect to ze" == data or ("connect to ze2" == data and not is_bot_connected_to_ze2):
|
elif "connect to ze" == data or ("connect to ze2" == data and not is_bot_connected_to_ze2):
|
||||||
if datetime.datetime.now() >= restart_time or return_user_owned_pid("pidof cstrike_linux64") is None:
|
if datetime.datetime.now() >= restart_time or return_user_owned_pid("pidof cstrike_linux64") is None or check_instance_conflict_dialog() is not None:
|
||||||
kill_owned_process("pidof cstrike_linux64")
|
kill_owned_process("pidof cstrike_linux64")
|
||||||
sock.close()
|
sock.close()
|
||||||
print('exiting after running for several hours.')
|
print('exiting after running for several hours.')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
print('data: ', data)
|
print('data: ', data)
|
||||||
overwrite_file_access()
|
overwrite_file_access()
|
||||||
|
check_if_bz2_downloading()
|
||||||
bot_connect(data)
|
bot_connect(data)
|
||||||
failed_connect_count += 1
|
|
||||||
if failed_connect_count >= 2:
|
fail_safe_counter += 1
|
||||||
|
if fail_safe_counter >= 3:
|
||||||
kill_owned_process("pidof cstrike_linux64")
|
kill_owned_process("pidof cstrike_linux64")
|
||||||
sock.close()
|
sock.close()
|
||||||
print('exiting after running for several hours.')
|
print('exiting after running for several hours.')
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
#!/home/autismbot1/ze_runner/venv/bin/python3
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import atexit
|
|
||||||
import pwd
|
|
||||||
from watchdog.observers import Observer
|
|
||||||
from watchdog.events import FileSystemEventHandler
|
|
||||||
import random
|
|
||||||
import signal
|
|
||||||
import socket
|
|
||||||
import codecs
|
|
||||||
import json
|
|
||||||
import datetime
|
|
||||||
import time
|
|
||||||
import glob
|
|
||||||
|
|
||||||
restart_time = datetime.datetime.now() + datetime.timedelta(hours=3)
|
|
||||||
the_undesired_crash_counter = 0 #hate this monkey solution
|
|
||||||
|
|
||||||
whoami = subprocess.getoutput("whoami")
|
|
||||||
with open(f'/home/{whoami}/ze_runner/config.json') as jsonfile:
|
|
||||||
data_ports = json.load(jsonfile)
|
|
||||||
looptestPath = f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/cfg/looptest.cfg"
|
|
||||||
chatmsg = ""
|
|
||||||
|
|
||||||
def overwrite_file_access():
|
|
||||||
#setting defaults with setfacl for the download directory does not work seemingly
|
|
||||||
subprocess.Popen(["chmod", "-R", "g+rwx", f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/download"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).communicate()
|
|
||||||
|
|
||||||
def writeCfgInput(Input_user):
|
|
||||||
with open(looptestPath, 'w') as f:
|
|
||||||
f.write(Input_user)
|
|
||||||
#print("wrote to file: ", Input_user)
|
|
||||||
if "connect" in Input_user or "retry;" in Input_user:
|
|
||||||
#give extra time for disconnect, retry and connect commands
|
|
||||||
time.sleep(1.0)
|
|
||||||
else:
|
|
||||||
time.sleep(0.35)
|
|
||||||
open(looptestPath, 'w').close() #clearing file.
|
|
||||||
|
|
||||||
def clean_up_files():
|
|
||||||
#deleting POSIX shared memory objects, as long as one process has them open they exist. THis is to prevent /dev/shm from being full
|
|
||||||
#due to steam child processes.
|
|
||||||
#even with steam turned offline might there be chromium web browsers left over from steam who still hold processes open.
|
|
||||||
#only kind of potential issues from this is steam cloud being out of sync, which is really fucking irrelevant.
|
|
||||||
for pattern in ["/tmp/steam*", "/tmp/dbus*", "/tmp/pressure*", "/tmp/tigervnc*", "/tmp/dumps*", "/dev/shm/u100*"]:
|
|
||||||
for path in glob.glob(pattern):
|
|
||||||
subprocess.run(["rm", "-rf", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
|
|
||||||
#clean up the game cache as it otherwise just keeps growing in gigabyte size
|
|
||||||
subprocess.run(["rm", "-rf", f"/home/{whoami}/.steam/steam/steamapps/common/Counter-Strike Source/cstrike/cache"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
|
|
||||||
def check_if_bz2_downloading():
|
|
||||||
#users should only have permissions to delete their own files probably
|
|
||||||
#check if any bz2 files left over.
|
|
||||||
for f in glob.glob(f"/home/{whoami}/.steam/steam/steamapps/common/Counter-Strike Source/cstrike/download/**/*.bz2", recursive=True):
|
|
||||||
file_size = None
|
|
||||||
while True:
|
|
||||||
time.sleep(10)
|
|
||||||
if not os.path.exists(f):
|
|
||||||
break # finished downloading, bz2 file is removed.
|
|
||||||
cur_file_size = os.path.getsize(f)
|
|
||||||
if file_size == cur_file_size:
|
|
||||||
# delete the bz2 file if its not progressing downloading
|
|
||||||
subprocess.run(["rm", "-rf", f], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
break
|
|
||||||
file_size = cur_file_size
|
|
||||||
|
|
||||||
def delete_bsp_files():
|
|
||||||
#quite a more radical approach to just always delete all maps, well the download should be quick and the remaining bots have the bsp in memory...
|
|
||||||
#the bots would have struggle connecting sometimes due to bsp files that were already downloaded before hand (maybe corrupted?)
|
|
||||||
for f in glob.glob(f"/home/{whoami}/.steam/debian-installation/steamapps/common/Counter-Strike Source/cstrike/download/maps/*"):
|
|
||||||
#ai slop IF statement for checking if file modified less than 10 minutes ago.
|
|
||||||
if time.time() - os.path.getmtime(f) < 600:
|
|
||||||
continue
|
|
||||||
subprocess.run(["rm", "-rf", f], stdout=subprocess.PIPE)
|
|
||||||
|
|
||||||
def exit_handler():
|
|
||||||
print('reached exithandler')
|
|
||||||
#securing the looptest.cfg wont be stuck accidently with commands
|
|
||||||
kill_owned_process("pidof cstrike_linux64")
|
|
||||||
kill_owned_process("pidof xterm")
|
|
||||||
|
|
||||||
clean_up_files()
|
|
||||||
check_if_bz2_downloading()
|
|
||||||
delete_bsp_files()
|
|
||||||
|
|
||||||
subprocess.getoutput(f"pkill -9 -u {whoami}")
|
|
||||||
|
|
||||||
def kill_user_owned_pid(pid):
|
|
||||||
#print('pid: ', pid, ' killed')
|
|
||||||
pid = int(pid.strip())
|
|
||||||
os.kill(pid, signal.SIGKILL)
|
|
||||||
time.sleep(10)
|
|
||||||
|
|
||||||
def return_user_owned_pid(pidof):
|
|
||||||
owner_pid = subprocess.getoutput([f"{pidof}"])
|
|
||||||
if owner_pid:
|
|
||||||
for pid in owner_pid.split(" "):
|
|
||||||
username = subprocess.getoutput([f"""ps -o user= -p {pid}"""])
|
|
||||||
if username == whoami:
|
|
||||||
return pid
|
|
||||||
return None
|
|
||||||
|
|
||||||
def kill_owned_process(pidof):
|
|
||||||
pid = return_user_owned_pid(pidof)
|
|
||||||
while pid:
|
|
||||||
kill_user_owned_pid(pid)
|
|
||||||
pid = return_user_owned_pid(pidof)
|
|
||||||
|
|
||||||
def restart_sdl_and_steam():
|
|
||||||
#subprocess.getoutput(["screen -XS XTERM quit"])
|
|
||||||
kill_owned_process("pidof cstrike_linux64")
|
|
||||||
kill_owned_process("pidof xterm")
|
|
||||||
|
|
||||||
subprocess.getoutput([f'vncserver -kill']) #only displays vncservers for the specific user.
|
|
||||||
subprocess.getoutput([f'vncserver -list -cleanstale']) #cleans stale sessions by removing files from disk
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
cmd = f'vncserver -localhost no -geometry 1x1 -depth 24'
|
|
||||||
#cmd = f'vncserver -localhost no -geometry 800x800 -depth 24'
|
|
||||||
#print(f'cmd: {cmd}')
|
|
||||||
subprocess.getoutput(cmd)
|
|
||||||
print('reached .bashrc executing steam and variables')
|
|
||||||
|
|
||||||
def bot_connect(data):
|
|
||||||
#use whatever ip you want here to connect with
|
|
||||||
str1 = ""
|
|
||||||
if "connect to ze" == data:
|
|
||||||
str1 = f"connect {data_ports['server_ip_port_ze']};"
|
|
||||||
elif "connect to ze2" == data:
|
|
||||||
str1 = f"connect {data_ports['server_ip_port_ze2']};" #wait 15000;
|
|
||||||
writeCfgInput(str1)
|
|
||||||
|
|
||||||
def cpulimit_pid_of_game():
|
|
||||||
# ' > /dev/null' redirects stdout to /dev/null
|
|
||||||
# '2>&1' redirects stderr to the same place as stdout
|
|
||||||
pid = return_user_owned_pid("pidof cstrike_linux64")
|
|
||||||
if pid is None:
|
|
||||||
return
|
|
||||||
cmd = f"cpulimit --pid={pid} --limit=25 --background > /dev/null 2>&1"
|
|
||||||
#cmd = f"cpulimit --pid={pid} --limit=55 --background > /dev/null 2>&1"
|
|
||||||
subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).communicate()[0]
|
|
||||||
|
|
||||||
time.sleep(3)
|
|
||||||
for source_engine_lock in glob.glob("/tmp/source_engine_*"):
|
|
||||||
subprocess.run(["rm", "-rf", source_engine_lock], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
overwrite_file_access()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
atexit.register(exit_handler)
|
|
||||||
local_port = data_ports['udp_port']
|
|
||||||
external_port_messages = data_ports['chat_external_port']
|
|
||||||
buffer_size = 4096 #potentially not large enough?
|
|
||||||
|
|
||||||
clean_up_files()
|
|
||||||
check_if_bz2_downloading()
|
|
||||||
delete_bsp_files()
|
|
||||||
|
|
||||||
restart_sdl_and_steam()
|
|
||||||
is_bot_connected_to_ze2 = False
|
|
||||||
|
|
||||||
print('preparing to launch game....')
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
sock.bind(("", local_port))
|
|
||||||
sock.settimeout(5.0)
|
|
||||||
messager_name = ""
|
|
||||||
|
|
||||||
#give the game some time to launch
|
|
||||||
fail_safe = 5
|
|
||||||
while return_user_owned_pid("pidof cstrike_linux64") is None:
|
|
||||||
#just a fail safe to prevent being stuck forever.
|
|
||||||
if fail_safe <= 0:
|
|
||||||
sys.exit(1)
|
|
||||||
time.sleep(10)
|
|
||||||
print("launching game...")
|
|
||||||
fail_safe -= 1
|
|
||||||
time.sleep(10)
|
|
||||||
cpulimit_pid_of_game()
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
#print("socket waiting for recvfrom")
|
|
||||||
data, addr = sock.recvfrom(buffer_size)
|
|
||||||
except socket.timeout:
|
|
||||||
continue
|
|
||||||
data = codecs.decode(data, "utf-8", "ignore")
|
|
||||||
ip = addr[0]
|
|
||||||
port = addr[1]
|
|
||||||
#print('port: ', port, " ip: ", ip)
|
|
||||||
#print('data: ', data)
|
|
||||||
if not data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
#print("ip: ", ip, " port: ", port)
|
|
||||||
if ip == data_ports['discord_bot_ip'] and port == external_port_messages:
|
|
||||||
if messager_name in data:
|
|
||||||
messager_name = ""
|
|
||||||
response_msg = f"""say {messager_name} {data}"""
|
|
||||||
print("remote UDP packet response_msg: ", response_msg)
|
|
||||||
writeCfgInput(response_msg)
|
|
||||||
if ip != data_ports['ovh_ip']:
|
|
||||||
continue
|
|
||||||
elif data == "bot kicked server full":
|
|
||||||
print('bot kicked server full: ', datetime.datetime.now().time())
|
|
||||||
elif "autismo connected to ze" == data:
|
|
||||||
print('Bot connected to ze!')
|
|
||||||
is_bot_connected_to_ze2 = False
|
|
||||||
the_undesired_crash_counter = 0
|
|
||||||
elif "not connected to ze2" == data:
|
|
||||||
is_bot_connected_to_ze2 = False
|
|
||||||
elif "autismo connected to ze2" == data:
|
|
||||||
print('Bot connected to ze2!')
|
|
||||||
the_undesired_crash_counter = 0
|
|
||||||
is_bot_connected_to_ze2 = True
|
|
||||||
elif "connect to ze" == data or ("connect to ze2" == data and not is_bot_connected_to_ze2):
|
|
||||||
if datetime.datetime.now() >= restart_time or return_user_owned_pid("pidof cstrike_linux64") is None or the_undesired_crash_counter >= 5:
|
|
||||||
#it already finished downloading the contnet, it just cant connect. probably stuck. shitty solution welp
|
|
||||||
kill_owned_process("pidof cstrike_linux64")
|
|
||||||
sock.close()
|
|
||||||
print('exiting after running the game for several hours or game being stuck on map change.')
|
|
||||||
sys.exit(1)
|
|
||||||
print('data: ', data, ' the_undesired_crash_counter: ', the_undesired_crash_counter)
|
|
||||||
the_undesired_crash_counter += 1
|
|
||||||
check_if_bz2_downloading()
|
|
||||||
overwrite_file_access()
|
|
||||||
bot_connect(data)
|
|
||||||
elif "clientmessage:" in data:
|
|
||||||
messager_name = data.split("clientmessage:", 1)[1].split(f" {data_ports['magic_secret']}")[0]
|
|
||||||
databyte_send_message = messager_name + data.split(f"{data_ports['magic_secret']}")[1]
|
|
||||||
sock.sendto(databyte_send_message.encode(), (data_ports["discord_bot_ip"], external_port_messages))
|
|
||||||
#print('databyte_send_message: ', databyte_send_message)
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,31 +3,78 @@
|
|||||||
#define DEBUG
|
#define DEBUG
|
||||||
|
|
||||||
#define PLUGIN_AUTHOR "jenz"
|
#define PLUGIN_AUTHOR "jenz"
|
||||||
#define PLUGIN_VERSION "1.9"
|
#define PLUGIN_VERSION "2.2"
|
||||||
#define generic_length 256
|
#define generic_length 256
|
||||||
|
|
||||||
#include <sourcemod>
|
#include <sourcemod>
|
||||||
#include <sdktools>
|
#include <sdktools>
|
||||||
#include <AFKManager>
|
#include <AFKManager>
|
||||||
#include <sdkhooks>
|
#include <sdkhooks>
|
||||||
|
#include <PlayerManager>
|
||||||
#include <cstrike>
|
#include <cstrike>
|
||||||
#include <socket>
|
#include <socket>
|
||||||
#include <UNLOZE.secret>
|
#include <UNLOZE.secret>
|
||||||
|
#include <mapchooser_extended>
|
||||||
|
#include <smlib>
|
||||||
|
|
||||||
int target_client[5]; //4 autism bots.
|
int target_client[5]; //4 autism bots.
|
||||||
|
|
||||||
#define BUFFER_SIZE 198 // ~2 seconds at 66 tick
|
// ~5.3s of history at 66 tick. Plenty of headroom for approach + backlog +
|
||||||
|
// resync cycles. At ~48 bytes/frame this is only a few hundred KB total.
|
||||||
|
#define BUFFER_SIZE 350
|
||||||
|
|
||||||
enum struct FrameData {
|
enum struct FrameData {
|
||||||
int buttons;
|
int buttons;
|
||||||
float vel[3];
|
float vel[3];
|
||||||
float angles[3];
|
float angles[3];
|
||||||
|
float origin[3]; // where the real client actually was when this frame was recorded
|
||||||
int impulse;
|
int impulse;
|
||||||
|
int weaponSlot; // 0=none/untracked, 1=primary, 2=secondary, 3=knife, 4=grenade
|
||||||
}
|
}
|
||||||
|
|
||||||
FrameData g_Buffer[MAXPLAYERS+1][BUFFER_SIZE];
|
FrameData g_Buffer[MAXPLAYERS+1][BUFFER_SIZE];
|
||||||
int g_WriteHead[MAXPLAYERS+1]; // where were writing next
|
int g_WriteHead[MAXPLAYERS+1]; // where were writing next, indexed by REAL client
|
||||||
int g_FrameCount[MAXPLAYERS+1]; // total frames captured
|
int g_FrameCount[MAXPLAYERS+1]; // total frames captured, indexed by REAL client
|
||||||
int g_DelayTicks = 128; // delay
|
|
||||||
|
// --- movement state machine (bots only) ---
|
||||||
|
#define BOT_STATE_IDLE 0
|
||||||
|
#define BOT_STATE_APPROACH 1
|
||||||
|
#define BOT_STATE_MIMIC 2
|
||||||
|
|
||||||
|
#define ARRIVE_RADIUS 18.0 // horizontal distance to anchor that counts as "arrived"
|
||||||
|
#define ARRIVE_Z_SLACK 256.0 // max height difference to anchor that still counts as "arrived"
|
||||||
|
#define APPROACH_SPEED 250.0 // forward move value while walking to the anchor
|
||||||
|
|
||||||
|
// Must stay comfortably bigger than ARRIVE_RADIUS or the bot can flap
|
||||||
|
// between MIMIC and APPROACH right at the boundary.
|
||||||
|
#define DESYNC_RADIUS 64.0 // if actual pos strays this far from the frame its about to play, resync
|
||||||
|
|
||||||
|
// Anti-loop / anti-stuck tuning
|
||||||
|
#define RESYNC_COOLDOWN 3.0 // min seconds between two resyncs; drift during cooldown just keeps mimicking
|
||||||
|
#define MAX_RESYNC_STRIKES 2 // this many resyncs on the same target -> give up, pick a new target
|
||||||
|
#define STUCK_CHECK_INTERVAL 1.0 // seconds between stuck checks while approaching
|
||||||
|
#define STUCK_MOVE_EPSILON 30.0 // if the bot moved less than this between checks, it counts as stuck
|
||||||
|
#define MAX_STUCK_STRIKES 2 // first strike re-anchors + jumps, second strike drops the target
|
||||||
|
|
||||||
|
int g_BotState[MAXPLAYERS+1]; // indexed by BOT client
|
||||||
|
float g_AnchorPos[MAXPLAYERS+1][3]; // indexed by BOT client - the single fixed point it walks to
|
||||||
|
int g_ReadHead[MAXPLAYERS+1]; // indexed by BOT client - read index into g_Buffer[target][]
|
||||||
|
int g_LastWeaponSlot[MAXPLAYERS+1]; // indexed by BOT client - last weaponSlot category we told it to switch to
|
||||||
|
|
||||||
|
// Anti-loop / anti-stuck state, all indexed by BOT client
|
||||||
|
float g_LastResyncTime[MAXPLAYERS+1]; // GetGameTime() of the last drift-triggered resync
|
||||||
|
int g_ResyncStrikes[MAXPLAYERS+1]; // consecutive resyncs against the current target
|
||||||
|
float g_StuckCheckTime[MAXPLAYERS+1]; // GetGameTime() of the last stuck check
|
||||||
|
float g_StuckCheckPos[MAXPLAYERS+1][3]; // where the bot was at the last stuck check
|
||||||
|
int g_StuckStrikes[MAXPLAYERS+1]; // consecutive stuck detections while approaching
|
||||||
|
bool g_WantJump[MAXPLAYERS+1]; // one-shot: press jump on the next approach tick (unstick aid)
|
||||||
|
|
||||||
|
// Weapon category is cheap to get slightly stale, so we only actually
|
||||||
|
// re-check it every WEAPON_CHECK_INTERVAL seconds per real client instead of
|
||||||
|
// every tick, and just keep writing the cached value into each frame.
|
||||||
|
#define WEAPON_CHECK_INTERVAL 5.0
|
||||||
|
int g_CachedWeaponSlot[MAXPLAYERS+1]; // indexed by REAL client - last computed category
|
||||||
|
float g_NextWeaponCheck[MAXPLAYERS+1]; // indexed by REAL client - GetGameTime() of next allowed check
|
||||||
|
|
||||||
int ports[7] = {48470, 48471, 48472, 48473, 48474, 48479, 48480}; //last three indexes are ports its sending udp from in plugin
|
int ports[7] = {48470, 48471, 48472, 48473, 48474, 48479, 48480}; //last three indexes are ports its sending udp from in plugin
|
||||||
int server_ports[2] = {27015, 27035}; //server ports: ze, ze2
|
int server_ports[2] = {27015, 27035}; //server ports: ze, ze2
|
||||||
@@ -40,6 +87,9 @@ Handle global_socket;
|
|||||||
//timer handle
|
//timer handle
|
||||||
Handle g_hTimer_pressing = null;
|
Handle g_hTimer_pressing = null;
|
||||||
Handle g_hTimer_bot_connect = null;
|
Handle g_hTimer_bot_connect = null;
|
||||||
|
Handle g_hTimer_bot_connect2 = null;
|
||||||
|
Handle g_hTimer_bot_connect3 = null;
|
||||||
|
Handle g_hTimer_bot_connect4 = null;
|
||||||
|
|
||||||
int specific_bot_player[MAXPLAYERS + 1];
|
int specific_bot_player[MAXPLAYERS + 1];
|
||||||
|
|
||||||
@@ -47,7 +97,7 @@ public Plugin myinfo =
|
|||||||
{
|
{
|
||||||
name = "autism bot simulate player movement",
|
name = "autism bot simulate player movement",
|
||||||
author = PLUGIN_AUTHOR,
|
author = PLUGIN_AUTHOR,
|
||||||
description = "just copies player movement to the autism bots",
|
description = "just copies player movement to the autism bots. now also AI slopped.",
|
||||||
version = PLUGIN_VERSION,
|
version = PLUGIN_VERSION,
|
||||||
url = ""
|
url = ""
|
||||||
};
|
};
|
||||||
@@ -57,6 +107,7 @@ public void OnPluginStart()
|
|||||||
//talking
|
//talking
|
||||||
RegConsoleCmd("sm_autism", cmd_talk, "talking to the bot through java application");
|
RegConsoleCmd("sm_autism", cmd_talk, "talking to the bot through java application");
|
||||||
RegConsoleCmd("sm_botrtv", cmd_botrtv, "making bots rtv");
|
RegConsoleCmd("sm_botrtv", cmd_botrtv, "making bots rtv");
|
||||||
|
RegAdminCmd("sm_botstate", cmd_botstate, ADMFLAG_GENERIC, "debug: show state of each autism bot");
|
||||||
|
|
||||||
HookEvent("round_start", Event_RoundStart, EventHookMode_PostNoCopy);
|
HookEvent("round_start", Event_RoundStart, EventHookMode_PostNoCopy);
|
||||||
|
|
||||||
@@ -64,7 +115,11 @@ public void OnPluginStart()
|
|||||||
connect_socket();
|
connect_socket();
|
||||||
chat_cooldown = false;
|
chat_cooldown = false;
|
||||||
|
|
||||||
g_hTimer_bot_connect = CreateTimer(90.0, bot_check_connect, _, TIMER_REPEAT);
|
g_hTimer_bot_connect = CreateTimer(60.0, bot_check_connect, _, TIMER_REPEAT);
|
||||||
|
CreateTimer(5.0, activate_timer2);
|
||||||
|
CreateTimer(10.0, activate_timer3);
|
||||||
|
CreateTimer(15.0, activate_timer4);
|
||||||
|
|
||||||
g_hTimer_pressing = CreateTimer(2.0, check_team, _, TIMER_REPEAT);
|
g_hTimer_pressing = CreateTimer(2.0, check_team, _, TIMER_REPEAT);
|
||||||
|
|
||||||
target_client[1] = -1;
|
target_client[1] = -1;
|
||||||
@@ -81,6 +136,24 @@ public void OnPluginStart()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Action activate_timer2(Handle timer, any data)
|
||||||
|
{
|
||||||
|
g_hTimer_bot_connect2 = CreateTimer(60.0, bot_check_connect2, _, TIMER_REPEAT);
|
||||||
|
return Plugin_Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action activate_timer3(Handle timer, any data)
|
||||||
|
{
|
||||||
|
g_hTimer_bot_connect3 = CreateTimer(60.0, bot_check_connect3, _, TIMER_REPEAT);
|
||||||
|
return Plugin_Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action activate_timer4(Handle timer, any data)
|
||||||
|
{
|
||||||
|
g_hTimer_bot_connect4 = CreateTimer(60.0, bot_check_connect4, _, TIMER_REPEAT);
|
||||||
|
return Plugin_Continue;
|
||||||
|
}
|
||||||
|
|
||||||
public void OnPluginEnd()
|
public void OnPluginEnd()
|
||||||
{
|
{
|
||||||
if (g_hTimer_pressing != null)
|
if (g_hTimer_pressing != null)
|
||||||
@@ -91,30 +164,41 @@ public void OnPluginEnd()
|
|||||||
{
|
{
|
||||||
delete g_hTimer_bot_connect;
|
delete g_hTimer_bot_connect;
|
||||||
}
|
}
|
||||||
|
if (g_hTimer_bot_connect2 != null)
|
||||||
|
{
|
||||||
|
delete g_hTimer_bot_connect2;
|
||||||
|
}
|
||||||
|
if (g_hTimer_bot_connect3 != null)
|
||||||
|
{
|
||||||
|
delete g_hTimer_bot_connect3;
|
||||||
|
}
|
||||||
|
if (g_hTimer_bot_connect4 != null)
|
||||||
|
{
|
||||||
|
delete g_hTimer_bot_connect4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnMapEnd()
|
// Classifies the clients currently held weapon into a slot category. This
|
||||||
|
// servers loadout is fixed: raw inventory slot 0=primary, 1=secondary,
|
||||||
|
// 2=knife, 3=HE grenade (no smoke/flash, no slot 4 ever exists), so we can
|
||||||
|
// just compare the active weapon against each fixed slot directly.
|
||||||
|
stock int GetActiveWeaponSlotCategory(int client)
|
||||||
{
|
{
|
||||||
int autism_bot_counter = 0;
|
int activeWeapon = GetEntPropEnt(client, Prop_Data, "m_hActiveWeapon", 0);
|
||||||
for (int i = 1; i <= MaxClients; i++)
|
if (activeWeapon <= -1 || !IsValidEntity(activeWeapon))
|
||||||
{
|
{
|
||||||
if (IsValidClient(i) && specific_bot_player[i] != -1)
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int rawSlot = 0; rawSlot <= 3; rawSlot++)
|
||||||
|
{
|
||||||
|
if (activeWeapon == GetPlayerWeaponSlot(client, rawSlot))
|
||||||
{
|
{
|
||||||
autism_bot_counter++;
|
return rawSlot + 1; // 1=primary, 2=secondary, 3=knife, 4=grenade
|
||||||
}
|
|
||||||
}
|
|
||||||
for (int i = 1; i <= MaxClients; i++)
|
|
||||||
{
|
|
||||||
if (autism_bot_counter <= 1)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (IsValidClient(i) && specific_bot_player[i] != -1)
|
|
||||||
{
|
|
||||||
KickClient(i);
|
|
||||||
autism_bot_counter--;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnPlayerRunCmdPost(int client, int buttons, int impulse, const float vel[3], const float angles[3], int weapon, int subtype, int cmdnum, int tickcount, int seed, const int mouse[2])
|
public void OnPlayerRunCmdPost(int client, int buttons, int impulse, const float vel[3], const float angles[3], int weapon, int subtype, int cmdnum, int tickcount, int seed, const int mouse[2])
|
||||||
@@ -128,9 +212,122 @@ public void OnPlayerRunCmdPost(int client, int buttons, int impulse, const float
|
|||||||
g_Buffer[client][head].impulse = impulse;
|
g_Buffer[client][head].impulse = impulse;
|
||||||
g_Buffer[client][head].vel = vel;
|
g_Buffer[client][head].vel = vel;
|
||||||
g_Buffer[client][head].angles = angles;
|
g_Buffer[client][head].angles = angles;
|
||||||
|
GetClientAbsOrigin(client, g_Buffer[client][head].origin);
|
||||||
|
|
||||||
|
float now = GetGameTime();
|
||||||
|
if (now >= g_NextWeaponCheck[client])
|
||||||
|
{
|
||||||
|
g_CachedWeaponSlot[client] = GetActiveWeaponSlotCategory(client);
|
||||||
|
g_NextWeaponCheck[client] = now + WEAPON_CHECK_INTERVAL;
|
||||||
|
}
|
||||||
|
g_Buffer[client][head].weaponSlot = g_CachedWeaponSlot[client];
|
||||||
|
|
||||||
g_WriteHead[client] = (head + 1) % BUFFER_SIZE;
|
g_WriteHead[client] = (head + 1) % BUFFER_SIZE;
|
||||||
g_FrameCount[client]++;
|
g_FrameCount[client]++;
|
||||||
|
|
||||||
|
// If any bot currently mimicking/approaching this client is about to have
|
||||||
|
// its unread backlog overwritten by this write, nudge its read head
|
||||||
|
// forward so it never reads stale/overwritten frames. With 4 bots max
|
||||||
|
// this loop is cheap.
|
||||||
|
for (int slot = 1; slot <= 4; slot++)
|
||||||
|
{
|
||||||
|
if (target_client[slot] != client)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int bot = GetBotClientForSlot(slot);
|
||||||
|
if (bot == -1 || g_BotState[bot] == BOT_STATE_IDLE)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (g_ReadHead[bot] == g_WriteHead[client])
|
||||||
|
{
|
||||||
|
g_ReadHead[bot] = (g_ReadHead[bot] + 1) % BUFFER_SIZE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal (XY) distance between two points. The old 3D check made bots
|
||||||
|
// standing directly under/over an anchor on another floor "never arrive",
|
||||||
|
// which also produced the crazy spinning (near-vertical direction vectors
|
||||||
|
// have unstable yaw).
|
||||||
|
stock float GetHorizontalDistance(const float a[3], const float b[3])
|
||||||
|
{
|
||||||
|
float dx = a[0] - b[0];
|
||||||
|
float dy = a[1] - b[1];
|
||||||
|
return SquareRoot(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faces the bot toward `anchor` (yaw from horizontal components only, so a
|
||||||
|
// mostly-vertical offset cant destabilize it) and pushes it forward. Shared
|
||||||
|
// by the normal APPROACH state and by the mid-mimic resync path below.
|
||||||
|
void ApplyApproachMovement(int bot, const float anchor[3], int &buttons, int &impulse, float vel[3], float angles[3])
|
||||||
|
{
|
||||||
|
float botPos[3];
|
||||||
|
GetClientAbsOrigin(bot, botPos);
|
||||||
|
|
||||||
|
float dir[3];
|
||||||
|
SubtractVectors(anchor, botPos, dir);
|
||||||
|
dir[2] = 0.0; // yaw only - never derive facing from the vertical component
|
||||||
|
|
||||||
|
impulse = 0;
|
||||||
|
vel[1] = 0.0;
|
||||||
|
vel[2] = 0.0;
|
||||||
|
angles[0] = 0.0;
|
||||||
|
angles[2] = 0.0;
|
||||||
|
|
||||||
|
if (GetVectorLength(dir) < 1.0)
|
||||||
|
{
|
||||||
|
// Horizontally on top of the anchor but not "arrived" (must be a
|
||||||
|
// height mismatch). Running forward would just grind into geometry
|
||||||
|
// and the yaw would be garbage - hold position, keep current yaw,
|
||||||
|
// and let stuck detection deal with it.
|
||||||
|
buttons = 0;
|
||||||
|
vel[0] = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float faceAngles[3];
|
||||||
|
GetVectorAngles(dir, faceAngles);
|
||||||
|
angles[1] = faceAngles[1];
|
||||||
|
|
||||||
|
buttons = IN_FORWARD;
|
||||||
|
vel[0] = APPROACH_SPEED;
|
||||||
|
|
||||||
|
if (g_WantJump[bot])
|
||||||
|
{
|
||||||
|
buttons |= IN_JUMP;
|
||||||
|
g_WantJump[bot] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncWeaponSlot(int bot, int wantSlot)
|
||||||
|
{
|
||||||
|
if (wantSlot == 0 || wantSlot == g_LastWeaponSlot[bot])
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int weapon = GetPlayerWeaponSlot(bot, wantSlot - 1);
|
||||||
|
if (weapon != -1 && Weapon_IsValid(weapon))
|
||||||
|
{
|
||||||
|
Client_SetActiveWeapon(bot, weapon);
|
||||||
|
}
|
||||||
|
|
||||||
|
g_LastWeaponSlot[bot] = wantSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drops the bots current target entirely and goes idle. check_team runs
|
||||||
|
// every 2 seconds and will hand it a fresh target (possibly the same player,
|
||||||
|
// but with a brand-new anchor and clean strike counters).
|
||||||
|
void GiveUpOnTarget(int bot)
|
||||||
|
{
|
||||||
|
int slot = specific_bot_player[bot];
|
||||||
|
if (slot != -1)
|
||||||
|
{
|
||||||
|
target_client[slot] = -1;
|
||||||
|
}
|
||||||
|
g_BotState[bot] = BOT_STATE_IDLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Action OnPlayerRunCmd(int client, int& buttons, int& impulse, float vel[3], float angles[3], int& weapon, int& subtype, int& cmdnum, int& tickcount, int& seed, int mouse[2])
|
public Action OnPlayerRunCmd(int client, int& buttons, int& impulse, float vel[3], float angles[3], int& weapon, int& subtype, int& cmdnum, int& tickcount, int& seed, int mouse[2])
|
||||||
@@ -140,44 +337,228 @@ public Action OnPlayerRunCmd(int client, int& buttons, int& impulse, float vel[3
|
|||||||
return Plugin_Continue;
|
return Plugin_Continue;
|
||||||
}
|
}
|
||||||
int target = target_client[specific_bot_player[client]];
|
int target = target_client[specific_bot_player[client]];
|
||||||
// Read from the slot that is DelayTicks behind current write head
|
float now = GetGameTime();
|
||||||
int readSlot = (g_WriteHead[target] - g_DelayTicks + BUFFER_SIZE) % BUFFER_SIZE;
|
|
||||||
|
|
||||||
buttons = g_Buffer[target][readSlot].buttons;
|
if (g_BotState[client] == BOT_STATE_APPROACH)
|
||||||
impulse = g_Buffer[target][readSlot].impulse;
|
{
|
||||||
vel = g_Buffer[target][readSlot].vel;
|
float botPos[3];
|
||||||
angles = g_Buffer[target][readSlot].angles;
|
GetClientAbsOrigin(client, botPos);
|
||||||
|
|
||||||
|
// Anti-stuck: if the bot has barely moved since the last check, its
|
||||||
|
// wedged on geometry. First strike: re-anchor to the targets CURRENT
|
||||||
|
// position (theyve moved on; the fresh anchor may have a clear path)
|
||||||
|
// and hop to clear small ledges. Second strike: give up on this
|
||||||
|
// target and let check_team assign a new one.
|
||||||
|
if (now - g_StuckCheckTime[client] >= STUCK_CHECK_INTERVAL)
|
||||||
|
{
|
||||||
|
if (GetVectorDistance(botPos, g_StuckCheckPos[client]) < STUCK_MOVE_EPSILON)
|
||||||
|
{
|
||||||
|
g_StuckStrikes[client]++;
|
||||||
|
if (g_StuckStrikes[client] >= MAX_STUCK_STRIKES)
|
||||||
|
{
|
||||||
|
GiveUpOnTarget(client);
|
||||||
|
return Plugin_Continue;
|
||||||
|
}
|
||||||
|
GetClientAbsOrigin(target, g_AnchorPos[client]);
|
||||||
|
g_ReadHead[client] = g_WriteHead[target];
|
||||||
|
g_WantJump[client] = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
g_StuckStrikes[client] = 0;
|
||||||
|
}
|
||||||
|
g_StuckCheckPos[client] = botPos;
|
||||||
|
g_StuckCheckTime[client] = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrival is judged horizontally with generous height slack. A pure
|
||||||
|
// 3D radius made anchors on other floors unreachable forever.
|
||||||
|
if (GetHorizontalDistance(botPos, g_AnchorPos[client]) <= ARRIVE_RADIUS
|
||||||
|
&& FloatAbs(botPos[2] - g_AnchorPos[client][2]) <= ARRIVE_Z_SLACK)
|
||||||
|
{
|
||||||
|
// arrived - start draining whatever the target did while we were walking here
|
||||||
|
g_BotState[client] = BOT_STATE_MIMIC;
|
||||||
|
g_StuckStrikes[client] = 0;
|
||||||
|
// fall through into mimic logic below on this same tick
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ApplyApproachMovement(client, g_AnchorPos[client], buttons, impulse, vel, angles);
|
||||||
|
return Plugin_Changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g_BotState[client] != BOT_STATE_MIMIC)
|
||||||
|
{
|
||||||
|
return Plugin_Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drift check: compare where the bot actually is against where the
|
||||||
|
// target actually was for the frame were about to play. If its too
|
||||||
|
// far off (missed a knockback, got stuck on geometry mid-mimic, etc.),
|
||||||
|
// drop back into APPROACH toward the targets current live position
|
||||||
|
// instead of continuing to mimic from a wrong spot.
|
||||||
|
//
|
||||||
|
// Two guards against the approach<->mimic ping-pong loop:
|
||||||
|
// - a cooldown, so drift during the cooldown window just keeps
|
||||||
|
// mimicking instead of instantly re-approaching
|
||||||
|
// - a strike counter, so a target whose replay keeps desyncing on the
|
||||||
|
// same geometry gets dropped entirely instead of looping forever
|
||||||
|
if (g_ReadHead[client] != g_WriteHead[target])
|
||||||
|
{
|
||||||
|
int nextSlot = g_ReadHead[client];
|
||||||
|
float botPos[3];
|
||||||
|
GetClientAbsOrigin(client, botPos);
|
||||||
|
|
||||||
|
if (GetVectorDistance(botPos, g_Buffer[target][nextSlot].origin) > DESYNC_RADIUS
|
||||||
|
&& now - g_LastResyncTime[client] >= RESYNC_COOLDOWN)
|
||||||
|
{
|
||||||
|
g_ResyncStrikes[client]++;
|
||||||
|
if (g_ResyncStrikes[client] >= MAX_RESYNC_STRIKES)
|
||||||
|
{
|
||||||
|
GiveUpOnTarget(client);
|
||||||
|
return Plugin_Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_LastResyncTime[client] = now;
|
||||||
|
GetClientAbsOrigin(target, g_AnchorPos[client]);
|
||||||
|
g_BotState[client] = BOT_STATE_APPROACH;
|
||||||
|
g_ReadHead[client] = g_WriteHead[target]; // drop the stale backlog, start fresh once we arrive
|
||||||
|
|
||||||
|
// reset the stuck tracker for the fresh approach leg
|
||||||
|
g_StuckStrikes[client] = 0;
|
||||||
|
g_StuckCheckTime[client] = now;
|
||||||
|
GetClientAbsOrigin(client, g_StuckCheckPos[client]);
|
||||||
|
|
||||||
|
ApplyApproachMovement(client, g_AnchorPos[client], buttons, impulse, vel, angles);
|
||||||
|
return Plugin_Changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g_ReadHead[client] == g_WriteHead[target])
|
||||||
|
{
|
||||||
|
// fully drained - nothing new recorded yet, hold still until more frames arrive
|
||||||
|
return Plugin_Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int slot = g_ReadHead[client];
|
||||||
|
buttons = g_Buffer[target][slot].buttons;
|
||||||
|
impulse = g_Buffer[target][slot].impulse;
|
||||||
|
vel = g_Buffer[target][slot].vel;
|
||||||
|
angles = g_Buffer[target][slot].angles;
|
||||||
|
|
||||||
|
SyncWeaponSlot(client, g_Buffer[target][slot].weaponSlot);
|
||||||
|
|
||||||
|
// "cleans them out after processing" - advancing the read head past this
|
||||||
|
// slot means it will never be handed out again, which is the queue
|
||||||
|
// equivalent of clearing it.
|
||||||
|
g_ReadHead[client] = (slot + 1) % BUFFER_SIZE;
|
||||||
|
|
||||||
return Plugin_Changed;
|
return Plugin_Changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Looks up which bot client (if any) currently occupies a given bot slot (1-4).
|
||||||
|
int GetBotClientForSlot(int slot)
|
||||||
|
{
|
||||||
|
for (int i = 1; i <= MaxClients; i++)
|
||||||
|
{
|
||||||
|
if (IsValidClient(i) && specific_bot_player[i] == slot)
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assigns `target` as the client that `bot` should mimic. Captures a single,
|
||||||
|
// fixed anchor point (the targets position right now) for the bot to walk
|
||||||
|
// to, and marks the point in the targets buffer where recording of the
|
||||||
|
// "walk-over" backlog begins. No teleport happens here.
|
||||||
|
void SetBotTarget(int bot, int target)
|
||||||
|
{
|
||||||
|
int slot = specific_bot_player[bot];
|
||||||
|
if (slot == -1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target_client[slot] = target;
|
||||||
|
g_LastWeaponSlot[bot] = 0; // force weapon re-evaluation once mimicking starts
|
||||||
|
|
||||||
|
// clean slate for the anti-loop/anti-stuck trackers
|
||||||
|
g_ResyncStrikes[bot] = 0;
|
||||||
|
g_StuckStrikes[bot] = 0;
|
||||||
|
g_LastResyncTime[bot] = 0.0;
|
||||||
|
g_StuckCheckTime[bot] = GetGameTime();
|
||||||
|
g_WantJump[bot] = false;
|
||||||
|
if (IsValidClient(bot))
|
||||||
|
{
|
||||||
|
GetClientAbsOrigin(bot, g_StuckCheckPos[bot]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsValidClient(target))
|
||||||
|
{
|
||||||
|
g_BotState[bot] = BOT_STATE_IDLE;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GetClientAbsOrigin(target, g_AnchorPos[bot]);
|
||||||
|
g_BotState[bot] = BOT_STATE_APPROACH;
|
||||||
|
g_ReadHead[bot] = g_WriteHead[target]; // everything the target does from now on gets replayed once we arrive
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug command: prints each bots state machine status so loops/stuck
|
||||||
|
// situations can be diagnosed live instead of guessed at.
|
||||||
|
public Action cmd_botstate(int client, int args)
|
||||||
|
{
|
||||||
|
static const char stateNames[][] = { "IDLE", "APPROACH", "MIMIC" };
|
||||||
|
bool found = false;
|
||||||
|
for (int i = 1; i <= MaxClients; i++)
|
||||||
|
{
|
||||||
|
if (!IsValidClient(i) || specific_bot_player[i] == -1)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
found = true;
|
||||||
|
int slot = specific_bot_player[i];
|
||||||
|
int target = target_client[slot];
|
||||||
|
int backlog = 0;
|
||||||
|
char targetName[64];
|
||||||
|
strcopy(targetName, sizeof(targetName), "<none>");
|
||||||
|
if (IsValidClient(target))
|
||||||
|
{
|
||||||
|
GetClientName(target, targetName, sizeof(targetName));
|
||||||
|
backlog = (g_WriteHead[target] - g_ReadHead[i] + BUFFER_SIZE) % BUFFER_SIZE;
|
||||||
|
}
|
||||||
|
ReplyToCommand(client,
|
||||||
|
"[bot %d] %N | state=%s | target=%s | backlog=%d frames | resync_strikes=%d | stuck_strikes=%d",
|
||||||
|
slot, i, stateNames[g_BotState[i]], targetName, backlog,
|
||||||
|
g_ResyncStrikes[i], g_StuckStrikes[i]);
|
||||||
|
}
|
||||||
|
if (!found)
|
||||||
|
{
|
||||||
|
ReplyToCommand(client, "no autism bots currently connected");
|
||||||
|
}
|
||||||
|
return Plugin_Handled;
|
||||||
|
}
|
||||||
|
|
||||||
public void Event_RoundStart(Handle event, const char[] name, bool dontBroadcast)
|
public void Event_RoundStart(Handle event, const char[] name, bool dontBroadcast)
|
||||||
{
|
{
|
||||||
target_client[1] = -1;
|
target_client[1] = -1;
|
||||||
target_client[2] = -1;
|
target_client[2] = -1;
|
||||||
target_client[3] = -1;
|
target_client[3] = -1;
|
||||||
target_client[4] = -1;
|
target_client[4] = -1;
|
||||||
|
|
||||||
chat_cooldown = false;
|
chat_cooldown = false;
|
||||||
for (int i = 1; i <= MaxClients; i++)
|
for (int i = 1; i <= MaxClients; i++)
|
||||||
{
|
{
|
||||||
if (IsValidClient(i) && IsPlayerAlive(i) && specific_bot_player[i] != -1)
|
if (IsValidClient(i) && IsPlayerAlive(i) && specific_bot_player[i] != -1)
|
||||||
{
|
{
|
||||||
for (int j = 1; j <= MaxClients; j++)
|
g_BotState[i] = BOT_STATE_IDLE;
|
||||||
{
|
g_LastWeaponSlot[i] = 0;
|
||||||
if (IsValidClient(j) && GetClientTeam(j) == GetClientTeam(i) && IsPlayerAlive(j) && GetClientIdleTime(j) < 20)
|
g_ResyncStrikes[i] = 0;
|
||||||
{
|
g_StuckStrikes[i] = 0;
|
||||||
if (target_client[1] == j || target_client[2] == j || target_client[3] == j || target_client[4])
|
g_WantJump[i] = false;
|
||||||
{
|
|
||||||
continue; //one of the autism bots already got this dude as their target.
|
|
||||||
}
|
|
||||||
float vec[3];
|
|
||||||
GetClientAbsOrigin(j, vec);
|
|
||||||
TeleportEntity(i, vec, NULL_VECTOR, NULL_VECTOR);
|
|
||||||
target_client[specific_bot_player[i]] = j;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,25 +677,192 @@ public void send_socket_msg(char[] query_msg, int len, int port)
|
|||||||
SocketSendTo(global_socket, query_msg, len, "127.0.0.1", port); //udp
|
SocketSendTo(global_socket, query_msg, len, "127.0.0.1", port); //udp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnMapVoteEnd(const char[] map)
|
||||||
|
{
|
||||||
|
if (StrEqual(map, "##extend##") || StrEqual(map, "##dontchange##"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char msg[generic_length];
|
||||||
|
Format(msg, sizeof(msg), "download map:%s", map);
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[0]);
|
||||||
|
}
|
||||||
|
|
||||||
public Action bot_check_connect(Handle timer, any data)
|
public Action bot_check_connect(Handle timer, any data)
|
||||||
{
|
{
|
||||||
|
char nextmap[64];
|
||||||
|
char msg[generic_length];
|
||||||
|
if (GetNextMap(nextmap, sizeof(nextmap)))
|
||||||
|
{
|
||||||
|
Format(msg, sizeof(msg), "download map:%s", nextmap);
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
GetCurrentMap(nextmap, sizeof(nextmap));
|
||||||
|
Format(msg, sizeof(msg), "download map:%s", nextmap);
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[0]);
|
||||||
|
|
||||||
|
|
||||||
//this is designed for being ran from several css servers
|
//this is designed for being ran from several css servers
|
||||||
int client_count = GetClientCount(false);
|
int client_count = GetClientCount(false);
|
||||||
char msg[generic_length];
|
|
||||||
int i_port = GetConVarInt(FindConVar("hostport"));
|
int i_port = GetConVarInt(FindConVar("hostport"));
|
||||||
bool bot1_connected = false;
|
bool bot1_connected = false;
|
||||||
bool bot2_connected = false;
|
|
||||||
bool bot3_connected = false;
|
|
||||||
bool bot4_connected = false;
|
|
||||||
for (int i = 1; i <= MaxClients; i++)
|
for (int i = 1; i <= MaxClients; i++)
|
||||||
if (IsValidClient(i) && !IsFakeClient(i))
|
if (IsValidClient(i) && !IsFakeClient(i))
|
||||||
{
|
{
|
||||||
if (specific_bot_player[i] == 1)
|
if (specific_bot_player[i] == 1)
|
||||||
bot1_connected = true;
|
bot1_connected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i_port == server_ports[0])
|
||||||
|
{
|
||||||
|
int max_allowed_players = 55;
|
||||||
|
char current_map[128];
|
||||||
|
GetCurrentMap(current_map, sizeof(current_map));
|
||||||
|
if (client_count > max_allowed_players)
|
||||||
|
{
|
||||||
|
Format(msg, sizeof(msg), "connect to ze2");
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[0]);
|
||||||
|
}
|
||||||
|
else if (client_count < max_allowed_players - 5)
|
||||||
|
{
|
||||||
|
Format(msg, sizeof(msg), "connect to ze");
|
||||||
|
if (!bot1_connected)
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
//in case the bot is not connected to ze2 anymore make sure that is_bot_connected_to_ze2 is set to false again
|
||||||
|
Format(msg, sizeof(msg), "not connected to ze2");
|
||||||
|
if (!bot1_connected)
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[0]);
|
||||||
|
}
|
||||||
|
return Plugin_Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action bot_check_connect2(Handle timer, any data)
|
||||||
|
{
|
||||||
|
char nextmap[64];
|
||||||
|
char msg[generic_length];
|
||||||
|
if (GetNextMap(nextmap, sizeof(nextmap)))
|
||||||
|
{
|
||||||
|
Format(msg, sizeof(msg), "download map:%s", nextmap);
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
GetCurrentMap(nextmap, sizeof(nextmap));
|
||||||
|
Format(msg, sizeof(msg), "download map:%s", nextmap);
|
||||||
|
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[0]);
|
||||||
|
//this is designed for being ran from several css servers
|
||||||
|
int client_count = GetClientCount(false);
|
||||||
|
int i_port = GetConVarInt(FindConVar("hostport"));
|
||||||
|
bool bot2_connected = false;
|
||||||
|
for (int i = 1; i <= MaxClients; i++)
|
||||||
|
if (IsValidClient(i) && !IsFakeClient(i))
|
||||||
|
{
|
||||||
if (specific_bot_player[i] == 2)
|
if (specific_bot_player[i] == 2)
|
||||||
bot2_connected = true;
|
bot2_connected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i_port == server_ports[0])
|
||||||
|
{
|
||||||
|
int max_allowed_players = 55;
|
||||||
|
char current_map[128];
|
||||||
|
GetCurrentMap(current_map, sizeof(current_map));
|
||||||
|
if (client_count > max_allowed_players)
|
||||||
|
{
|
||||||
|
Format(msg, sizeof(msg), "connect to ze2");
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[1]);
|
||||||
|
}
|
||||||
|
else if (client_count < max_allowed_players - 5)
|
||||||
|
{
|
||||||
|
Format(msg, sizeof(msg), "connect to ze");
|
||||||
|
if (!bot2_connected)
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
//in case the bot is not connected to ze2 anymore make sure that is_bot_connected_to_ze2 is set to false again
|
||||||
|
Format(msg, sizeof(msg), "not connected to ze2");
|
||||||
|
if (!bot2_connected)
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[1]);
|
||||||
|
}
|
||||||
|
return Plugin_Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action bot_check_connect3(Handle timer, any data)
|
||||||
|
{
|
||||||
|
char nextmap[64];
|
||||||
|
char msg[generic_length];
|
||||||
|
if (GetNextMap(nextmap, sizeof(nextmap)))
|
||||||
|
{
|
||||||
|
Format(msg, sizeof(msg), "download map:%s", nextmap);
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[0]);
|
||||||
|
}
|
||||||
|
GetCurrentMap(nextmap, sizeof(nextmap));
|
||||||
|
Format(msg, sizeof(msg), "download map:%s", nextmap);
|
||||||
|
|
||||||
|
//this is designed for being ran from several css servers
|
||||||
|
int client_count = GetClientCount(false);
|
||||||
|
int i_port = GetConVarInt(FindConVar("hostport"));
|
||||||
|
bool bot3_connected = false;
|
||||||
|
for (int i = 1; i <= MaxClients; i++)
|
||||||
|
if (IsValidClient(i) && !IsFakeClient(i))
|
||||||
|
{
|
||||||
if (specific_bot_player[i] == 3)
|
if (specific_bot_player[i] == 3)
|
||||||
bot3_connected = true;
|
bot3_connected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i_port == server_ports[0])
|
||||||
|
{
|
||||||
|
int max_allowed_players = 55;
|
||||||
|
char current_map[128];
|
||||||
|
GetCurrentMap(current_map, sizeof(current_map));
|
||||||
|
if (client_count > max_allowed_players)
|
||||||
|
{
|
||||||
|
Format(msg, sizeof(msg), "connect to ze2");
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[2]);
|
||||||
|
}
|
||||||
|
else if (client_count < max_allowed_players - 5)
|
||||||
|
{
|
||||||
|
Format(msg, sizeof(msg), "connect to ze");
|
||||||
|
if (!bot3_connected)
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
//in case the bot is not connected to ze2 anymore make sure that is_bot_connected_to_ze2 is set to false again
|
||||||
|
Format(msg, sizeof(msg), "not connected to ze2");
|
||||||
|
if (!bot3_connected)
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[2]);
|
||||||
|
}
|
||||||
|
return Plugin_Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action bot_check_connect4(Handle timer, any data)
|
||||||
|
{
|
||||||
|
char nextmap[64];
|
||||||
|
char msg[generic_length];
|
||||||
|
if (GetNextMap(nextmap, sizeof(nextmap)))
|
||||||
|
{
|
||||||
|
Format(msg, sizeof(msg), "download map:%s", nextmap);
|
||||||
|
send_socket_msg(msg, strlen(msg), ports[0]);
|
||||||
|
}
|
||||||
|
GetCurrentMap(nextmap, sizeof(nextmap));
|
||||||
|
Format(msg, sizeof(msg), "download map:%s", nextmap);
|
||||||
|
|
||||||
|
//this is designed for being ran from several css servers
|
||||||
|
int client_count = GetClientCount(false);
|
||||||
|
int i_port = GetConVarInt(FindConVar("hostport"));
|
||||||
|
bool bot4_connected = false;
|
||||||
|
for (int i = 1; i <= MaxClients; i++)
|
||||||
|
if (IsValidClient(i) && !IsFakeClient(i))
|
||||||
|
{
|
||||||
if (specific_bot_player[i] == 4)
|
if (specific_bot_player[i] == 4)
|
||||||
bot4_connected = true;
|
bot4_connected = true;
|
||||||
}
|
}
|
||||||
@@ -327,20 +875,11 @@ public Action bot_check_connect(Handle timer, any data)
|
|||||||
if (client_count > max_allowed_players)
|
if (client_count > max_allowed_players)
|
||||||
{
|
{
|
||||||
Format(msg, sizeof(msg), "connect to ze2");
|
Format(msg, sizeof(msg), "connect to ze2");
|
||||||
send_socket_msg(msg, strlen(msg), ports[0]);
|
|
||||||
send_socket_msg(msg, strlen(msg), ports[1]);
|
|
||||||
send_socket_msg(msg, strlen(msg), ports[2]);
|
|
||||||
send_socket_msg(msg, strlen(msg), ports[3]);
|
send_socket_msg(msg, strlen(msg), ports[3]);
|
||||||
}
|
}
|
||||||
else if (client_count < max_allowed_players - 5)
|
else if (client_count < max_allowed_players - 5)
|
||||||
{
|
{
|
||||||
Format(msg, sizeof(msg), "connect to ze");
|
Format(msg, sizeof(msg), "connect to ze");
|
||||||
if (!bot1_connected)
|
|
||||||
send_socket_msg(msg, strlen(msg), ports[0]);
|
|
||||||
if (!bot2_connected)
|
|
||||||
send_socket_msg(msg, strlen(msg), ports[1]);
|
|
||||||
if (!bot3_connected)
|
|
||||||
send_socket_msg(msg, strlen(msg), ports[2]);
|
|
||||||
if (!bot4_connected)
|
if (!bot4_connected)
|
||||||
send_socket_msg(msg, strlen(msg), ports[3]);
|
send_socket_msg(msg, strlen(msg), ports[3]);
|
||||||
}
|
}
|
||||||
@@ -349,12 +888,6 @@ public Action bot_check_connect(Handle timer, any data)
|
|||||||
{
|
{
|
||||||
//in case the bot is not connected to ze2 anymore make sure that is_bot_connected_to_ze2 is set to false again
|
//in case the bot is not connected to ze2 anymore make sure that is_bot_connected_to_ze2 is set to false again
|
||||||
Format(msg, sizeof(msg), "not connected to ze2");
|
Format(msg, sizeof(msg), "not connected to ze2");
|
||||||
if (!bot1_connected)
|
|
||||||
send_socket_msg(msg, strlen(msg), ports[0]);
|
|
||||||
if (!bot2_connected)
|
|
||||||
send_socket_msg(msg, strlen(msg), ports[1]);
|
|
||||||
if (!bot3_connected)
|
|
||||||
send_socket_msg(msg, strlen(msg), ports[2]);
|
|
||||||
if (!bot4_connected)
|
if (!bot4_connected)
|
||||||
send_socket_msg(msg, strlen(msg), ports[3]);
|
send_socket_msg(msg, strlen(msg), ports[3]);
|
||||||
}
|
}
|
||||||
@@ -379,7 +912,15 @@ public Action check_team(Handle timer, any data)
|
|||||||
{
|
{
|
||||||
if (IsPlayerAlive(client))
|
if (IsPlayerAlive(client))
|
||||||
{
|
{
|
||||||
target_client[specific_bot_player[client]] = GetClosestClient_option1(client);
|
int newTarget = GetClosestClient_option1(client);
|
||||||
|
if (newTarget != target_client[specific_bot_player[client]])
|
||||||
|
{
|
||||||
|
// only reset the walk-over/mimic state when the target
|
||||||
|
// actually changes - otherwise a bot re-targeting the
|
||||||
|
// same player every 2 seconds would keep getting yanked
|
||||||
|
// back into APPROACH state
|
||||||
|
SetBotTarget(client, newTarget);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,7 +978,7 @@ public int GetClosestClient_option1(int autism_client)
|
|||||||
{
|
{
|
||||||
if (IsValidClient(i) && !IsFakeClient(i) && IsPlayerAlive(i) && i != autism_client)
|
if (IsValidClient(i) && !IsFakeClient(i) && IsPlayerAlive(i) && i != autism_client)
|
||||||
{
|
{
|
||||||
if (GetClientIdleTime(i) > 20 || GetClientTeam(autism_client) != GetClientTeam(i))
|
if (GetClientIdleTime(i) > 3 || GetClientTeam(autism_client) != GetClientTeam(i) || !PM_IsPlayerSteam(i)) //not following nosteamers is a bit quality check
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -470,11 +1011,19 @@ public float get_power_distance(int target_player, float pos[3])
|
|||||||
public void OnClientPutInServer(int client)
|
public void OnClientPutInServer(int client)
|
||||||
{
|
{
|
||||||
specific_bot_player[client] = -1;
|
specific_bot_player[client] = -1;
|
||||||
|
g_BotState[client] = BOT_STATE_IDLE;
|
||||||
|
g_LastWeaponSlot[client] = 0;
|
||||||
|
g_NextWeaponCheck[client] = 0.0; // force an immediate recheck rather than waiting out a stale timer
|
||||||
|
g_ResyncStrikes[client] = 0;
|
||||||
|
g_StuckStrikes[client] = 0;
|
||||||
|
g_WantJump[client] = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnClientPostAdminCheck(int client)
|
public void OnClientPostAdminCheck(int client)
|
||||||
{
|
{
|
||||||
specific_bot_player[client] = -1;
|
specific_bot_player[client] = -1;
|
||||||
|
g_BotState[client] = BOT_STATE_IDLE;
|
||||||
|
g_LastWeaponSlot[client] = 0;
|
||||||
is_autism_bot1(client);
|
is_autism_bot1(client);
|
||||||
is_autism_bot2(client);
|
is_autism_bot2(client);
|
||||||
is_autism_bot3(client);
|
is_autism_bot3(client);
|
||||||
@@ -527,6 +1076,24 @@ stock void connect_socket()
|
|||||||
public void OnClientDisconnect(int client)
|
public void OnClientDisconnect(int client)
|
||||||
{
|
{
|
||||||
specific_bot_player[client] = -1;
|
specific_bot_player[client] = -1;
|
||||||
|
g_BotState[client] = BOT_STATE_IDLE;
|
||||||
|
g_LastWeaponSlot[client] = 0;
|
||||||
|
|
||||||
|
// if this client was somebodys target, put that bot back to idle so it
|
||||||
|
// doesnt keep trying to read/approach a client thats gone
|
||||||
|
for (int slot = 1; slot <= 4; slot++)
|
||||||
|
{
|
||||||
|
if (target_client[slot] == client)
|
||||||
|
{
|
||||||
|
target_client[slot] = -1;
|
||||||
|
int bot = GetBotClientForSlot(slot);
|
||||||
|
if (bot != -1)
|
||||||
|
{
|
||||||
|
g_BotState[bot] = BOT_STATE_IDLE;
|
||||||
|
g_LastWeaponSlot[bot] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//Socket callback
|
//Socket callback
|
||||||
|
|||||||
@@ -3,21 +3,31 @@
|
|||||||
#include <json>
|
#include <json>
|
||||||
#include <ccc>
|
#include <ccc>
|
||||||
#include <cstrike>
|
#include <cstrike>
|
||||||
|
#include <unloze_playtime>
|
||||||
|
|
||||||
public Plugin myinfo =
|
public Plugin myinfo =
|
||||||
{
|
{
|
||||||
name = "Racetimer Rank",
|
name = "Racetimer Rank",
|
||||||
author = "jenz",
|
author = "jenz",
|
||||||
description = "Give racetimer points as a level clan tag to players, also for chat tag",
|
description = "Give racetimer points as a level clan tag to players, also for chat tag",
|
||||||
version = "1.1",
|
version = "1.3",
|
||||||
url = ""
|
url = ""
|
||||||
};
|
};
|
||||||
|
|
||||||
bool g_b_ignoring_tags[MAXPLAYERS + 1];
|
bool g_b_ignoring_tags[MAXPLAYERS + 1];
|
||||||
Database g_hDatabase;
|
Database g_hDatabase;
|
||||||
|
ConVar g_Cvar_TierChatLVL;
|
||||||
|
ConVar g_Cvar_TierChatLVLMultiColor;
|
||||||
|
ConVar g_Cvar_TierChatLVLAllColor;
|
||||||
|
ConVar g_Cvar_TierClanLVL;
|
||||||
|
|
||||||
public void OnPluginStart()
|
public void OnPluginStart()
|
||||||
{
|
{
|
||||||
|
g_Cvar_TierClanLVL = CreateConVar("sm_display_lvl_clan", "5", "Tier required to display your LVL as clan tag");
|
||||||
|
g_Cvar_TierChatLVL = CreateConVar("sm_display_lvl_chat", "8", "Tier required to display your LVL in chat");
|
||||||
|
g_Cvar_TierChatLVLMultiColor = CreateConVar("sm_display_lvl_chat_multi_colour", "10", "Tier required to display your LVL with multi colours");
|
||||||
|
g_Cvar_TierChatLVLAllColor = CreateConVar("sm_display_lvl_chat_all_colour", "12", "Tier required to display your LVL with all possible colour combinations");
|
||||||
|
|
||||||
RegConsoleCmd("sm_lvl", Command_LvTag, "Turns the Lv. tag feature on or off");
|
RegConsoleCmd("sm_lvl", Command_LvTag, "Turns the Lv. tag feature on or off");
|
||||||
if (!g_hDatabase)
|
if (!g_hDatabase)
|
||||||
{
|
{
|
||||||
@@ -28,9 +38,10 @@ public void OnPluginStart()
|
|||||||
{
|
{
|
||||||
if(IsClientInGame(client))
|
if(IsClientInGame(client))
|
||||||
{
|
{
|
||||||
OnClientPostAdminFilter(client);
|
OnClientPostAdminCheck(client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnMapStart()
|
public void OnMapStart()
|
||||||
@@ -55,7 +66,7 @@ public void SQL_OnDatabaseConnect(Database db, const char[] error, any data)
|
|||||||
{
|
{
|
||||||
if(IsClientInGame(client))
|
if(IsClientInGame(client))
|
||||||
{
|
{
|
||||||
OnClientPostAdminFilter(client);
|
OnClientPostAdminCheck(client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +77,15 @@ public Action Command_LvTag(int client, int args)
|
|||||||
{
|
{
|
||||||
return Plugin_Handled;
|
return Plugin_Handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int tier_required = GetConVarInt(g_Cvar_TierClanLVL);
|
||||||
|
int client_tier = GetPlayerTier_native(client);
|
||||||
|
if (tier_required > client_tier)
|
||||||
|
{
|
||||||
|
PrintToChat(client, "You are Tier %i and need Tier %i for using this command", client_tier, tier_required);
|
||||||
|
return Plugin_Handled;
|
||||||
|
}
|
||||||
|
|
||||||
//i am only adding this shit because lighty wanted it to be turn-off able, after half a year probably only 7 people will have made use of it.
|
//i am only adding this shit because lighty wanted it to be turn-off able, after half a year probably only 7 people will have made use of it.
|
||||||
//kinda hate wasting time on pointless stuff like this.
|
//kinda hate wasting time on pointless stuff like this.
|
||||||
char sSID[64];
|
char sSID[64];
|
||||||
@@ -107,7 +127,7 @@ public void SQL_FinishedQuery(Database db, DBResultSet results, const char[] err
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
PrintToChat(client, "Now displaying LVL tag again.");
|
PrintToChat(client, "Now displaying LVL tag again.");
|
||||||
OnClientPostAdminFilter(client);
|
OnClientPostAdminCheck(client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,20 +136,18 @@ public void OnClientDisconnect(int client)
|
|||||||
g_b_ignoring_tags[client] = false;
|
g_b_ignoring_tags[client] = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnClientPostAdminFilter(int client)
|
public void OnClientPostAdminCheck(int client)
|
||||||
{
|
{
|
||||||
if(!IsClientAuthorized(client) || IsClientSourceTV(client))
|
if(!IsClientAuthorized(client) || IsClientSourceTV(client))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (IsFakeClient(client))
|
if (IsFakeClient(client))
|
||||||
{
|
{
|
||||||
char tag[64];
|
|
||||||
Format(tag, sizeof(tag), "[LVL 1]");
|
|
||||||
CS_SetClientClanTag(client, tag);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
g_b_ignoring_tags[client] = false;
|
g_b_ignoring_tags[client] = false;
|
||||||
|
|
||||||
check_ignoring_tags(client);
|
check_ignoring_tags(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,19 +254,22 @@ public int OnTransferResponse(char[] sData, int iSerial)
|
|||||||
I_player_points = 1000; //setting level 1
|
I_player_points = 1000; //setting level 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!HasClanTag(client))
|
int tier_required = GetConVarInt(g_Cvar_TierClanLVL);
|
||||||
|
int client_tier = GetPlayerTier_native(client);
|
||||||
|
if (!HasClanTag(client) && client_tier >= tier_required)
|
||||||
{
|
{
|
||||||
char tag[64];
|
char tag[64];
|
||||||
Format(tag, sizeof(tag), "[LVL %i]", I_player_points/1000);
|
Format(tag, sizeof(tag), "[LVL %i]", I_player_points/1000);
|
||||||
CS_SetClientClanTag(client, tag);
|
CS_SetClientClanTag(client, tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
int I_rank = obj.GetInt("Rank") //if no endpoint for steamID default value is -1
|
tier_required = GetConVarInt(g_Cvar_TierChatLVL);
|
||||||
if (0 < I_rank < 1000) //only give chat tag to top 1000
|
client_tier = GetPlayerTier_native(client);
|
||||||
|
if (client_tier >= tier_required)
|
||||||
{
|
{
|
||||||
if (player_has_no_chattag(client))
|
if (player_has_no_chattag(client))
|
||||||
{
|
{
|
||||||
set_level_tag(client, I_player_points/1000, I_rank);
|
set_level_tag(client, I_player_points/1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
json_cleanup_and_delete(obj);
|
json_cleanup_and_delete(obj);
|
||||||
@@ -262,7 +283,7 @@ public bool player_has_no_chattag(int client)
|
|||||||
return strlen(tag) < 2;
|
return strlen(tag) < 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void set_level_tag(int client, int lvl, int I_rank)
|
public void set_level_tag(int client, int lvl)
|
||||||
{
|
{
|
||||||
char hexadecimals[16];
|
char hexadecimals[16];
|
||||||
char hexadecimals2[16];
|
char hexadecimals2[16];
|
||||||
@@ -271,7 +292,11 @@ public void set_level_tag(int client, int lvl, int I_rank)
|
|||||||
int green = ((lvl + 50) * 13) % 256;
|
int green = ((lvl + 50) * 13) % 256;
|
||||||
int blue = ((lvl + 100) * 19) % 256;
|
int blue = ((lvl + 100) * 19) % 256;
|
||||||
Format(hexadecimals, sizeof(hexadecimals), "%02X%02X%02X", red, green, blue);
|
Format(hexadecimals, sizeof(hexadecimals), "%02X%02X%02X", red, green, blue);
|
||||||
if (I_rank > 250)
|
|
||||||
|
int tier_required = GetConVarInt(g_Cvar_TierChatLVLMultiColor);
|
||||||
|
int client_tier = GetPlayerTier_native(client);
|
||||||
|
//we display LVL with one colour
|
||||||
|
if (client_tier < tier_required)
|
||||||
{
|
{
|
||||||
char tag[64];
|
char tag[64];
|
||||||
Format(tag, sizeof(tag), "[LVL %i] ", lvl);
|
Format(tag, sizeof(tag), "[LVL %i] ", lvl);
|
||||||
@@ -279,7 +304,9 @@ public void set_level_tag(int client, int lvl, int I_rank)
|
|||||||
CCC_SetColor(client, CCC_TagColor, StringToInt(hexadecimals, 16), false);
|
CCC_SetColor(client, CCC_TagColor, StringToInt(hexadecimals, 16), false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (I_rank > 50)
|
tier_required = GetConVarInt(g_Cvar_TierChatLVLAllColor);
|
||||||
|
//we display LVL with some of the multi colour combinations
|
||||||
|
if (client_tier < tier_required)
|
||||||
{
|
{
|
||||||
red = (lvl * 27) % 256;
|
red = (lvl * 27) % 256;
|
||||||
green = ((lvl + 50) * 23) % 256;
|
green = ((lvl + 50) * 23) % 256;
|
||||||
@@ -312,6 +339,8 @@ public void set_level_tag(int client, int lvl, int I_rank)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//we display LVL with ALL of the multi colour combinations
|
||||||
|
|
||||||
red = (GetRandomInt(0, 255)) % 256;
|
red = (GetRandomInt(0, 255)) % 256;
|
||||||
green = (GetRandomInt(0, 255)) % 256;
|
green = (GetRandomInt(0, 255)) % 256;
|
||||||
blue = (GetRandomInt(0, 255)) % 256;
|
blue = (GetRandomInt(0, 255)) % 256;
|
||||||
@@ -344,3 +373,40 @@ public void set_level_tag(int client, int lvl, int I_rank)
|
|||||||
Format(tag, sizeof(tag), "\x07%s[L\x07%sV\x07%sL %s] ", hexadecimals, hexadecimals2, hexadecimals3, coloured_numbers);
|
Format(tag, sizeof(tag), "\x07%s[L\x07%sV\x07%sL %s] ", hexadecimals, hexadecimals2, hexadecimals3, coloured_numbers);
|
||||||
CCC_SetTag(client, tag);
|
CCC_SetTag(client, tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//just a lazy place for applying server side fix for clan tags
|
||||||
|
//needed since august 24th 2026.
|
||||||
|
public Action OnClientCommandKeyValues(int client, KeyValues kv)
|
||||||
|
{
|
||||||
|
if (client <= 0 || client > MaxClients || !IsClientInGame(client) || IsFakeClient(client))
|
||||||
|
return Plugin_Continue;
|
||||||
|
|
||||||
|
char clanTag[32];
|
||||||
|
kv.GetString("tag", clanTag, sizeof(clanTag), "");
|
||||||
|
|
||||||
|
if (clanTag[0] == '\0')
|
||||||
|
return Plugin_Continue;
|
||||||
|
|
||||||
|
DataPack dp;
|
||||||
|
CreateDataTimer(0.1, Timer_SetClanTag, dp);
|
||||||
|
dp.WriteCell(GetClientUserId(client));
|
||||||
|
dp.WriteString(clanTag);
|
||||||
|
|
||||||
|
return Plugin_Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action Timer_SetClanTag(Handle timer, DataPack pack)
|
||||||
|
{
|
||||||
|
pack.Reset();
|
||||||
|
int userId = pack.ReadCell();
|
||||||
|
int client = GetClientOfUserId(userId);
|
||||||
|
|
||||||
|
char clanTag[32];
|
||||||
|
pack.ReadString(clanTag, sizeof(clanTag));
|
||||||
|
|
||||||
|
if (client && IsClientInGame(client))
|
||||||
|
{
|
||||||
|
CS_SetClientClanTag(client, clanTag);
|
||||||
|
}
|
||||||
|
return Plugin_Stop;
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public int Native_Isspecialmap(Handle handler, int numParams)
|
|||||||
//----------------------------------------------------------------------------------------------------
|
//----------------------------------------------------------------------------------------------------
|
||||||
public int GetCurrentStage()
|
public int GetCurrentStage()
|
||||||
{
|
{
|
||||||
int iLevelCounterEnt = FindEntityByTargetname(INVALID_ENT_REFERENCE, "LevelCounter", "math_counter");
|
int iLevelCounterEnt = FindEntityByTargetname(INVALID_ENT_REFERENCE, "LevelCounter", "info_target");
|
||||||
if (iLevelCounterEnt == -1)
|
if (iLevelCounterEnt == -1)
|
||||||
return 0;
|
return 0;
|
||||||
int offset = FindDataMapInfo(iLevelCounterEnt, "m_OutValue");
|
int offset = FindDataMapInfo(iLevelCounterEnt, "m_OutValue");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ StringMap g_GroupOverrides; // group name -> comma-separated override list
|
|||||||
static Handle g_hForwardPlayerHours;
|
static Handle g_hForwardPlayerHours;
|
||||||
static Handle g_hForwardPlayerTier;
|
static Handle g_hForwardPlayerTier;
|
||||||
|
|
||||||
|
bool G_bResponsePassed[MAXPLAYERS+1];
|
||||||
|
bool G_bPreAdminChecked[MAXPLAYERS+1];
|
||||||
|
|
||||||
public Plugin myinfo =
|
public Plugin myinfo =
|
||||||
{
|
{
|
||||||
name = "UNLOZE_player_time",
|
name = "UNLOZE_player_time",
|
||||||
@@ -159,7 +162,7 @@ public void OnPluginStart()
|
|||||||
RegConsoleCmd("sm_topplaytime", Command_TopTime, "retreives top 1000 playtime highscores on all connected servers");
|
RegConsoleCmd("sm_topplaytime", Command_TopTime, "retreives top 1000 playtime highscores on all connected servers");
|
||||||
|
|
||||||
g_h_time_activity = CreateTimer(10.0, time_query_activity, INVALID_HANDLE, TIMER_REPEAT);
|
g_h_time_activity = CreateTimer(10.0, time_query_activity, INVALID_HANDLE, TIMER_REPEAT);
|
||||||
CreateTimer(600.0, UpdateForward, _, TIMER_REPEAT);
|
CreateTimer(60.0, UpdateForward, _, TIMER_REPEAT);
|
||||||
|
|
||||||
ConVar cvar;
|
ConVar cvar;
|
||||||
HookConVarChange((cvar = CreateConVar("sm_mapchooser_afk_detect_time", "120", "Time in seconds until a player is considered as AFK and therefore excluded from player average time.")), Cvar_playerAFKTime);
|
HookConVarChange((cvar = CreateConVar("sm_mapchooser_afk_detect_time", "120", "Time in seconds until a player is considered as AFK and therefore excluded from player average time.")), Cvar_playerAFKTime);
|
||||||
@@ -285,8 +288,6 @@ public int Native_GetPlayerWorthRTV_boost(Handle plugin, int numParams)
|
|||||||
return view_as<int>(-1);
|
return view_as<int>(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
int avg = GetAveragePlayerActiveTimeServer();
|
|
||||||
|
|
||||||
//give vips the rtv/nomination/mapvote boost by default
|
//give vips the rtv/nomination/mapvote boost by default
|
||||||
AdminId id = GetUserAdmin(client);
|
AdminId id = GetUserAdmin(client);
|
||||||
if (id != INVALID_ADMIN_ID && GetAdminFlag(id, Admin_Reservation))
|
if (id != INVALID_ADMIN_ID && GetAdminFlag(id, Admin_Reservation))
|
||||||
@@ -300,20 +301,7 @@ public int Native_GetPlayerWorthRTV_boost(Handle plugin, int numParams)
|
|||||||
return g_iPlayerRTVCapacity;
|
return g_iPlayerRTVCapacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (g_iPlayerTimeServer[client] <= avg || avg == 0 || is_bot_player[client] || IsFakeClient(client))
|
return 1;
|
||||||
{
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if ((float(g_iPlayerTimeServer[client]) / float(avg)) > g_iPlayerRTVCapacity)
|
|
||||||
{
|
|
||||||
return g_iPlayerRTVCapacity; //1.0-5.0 booster probably
|
|
||||||
}
|
|
||||||
int val = RoundToFloor((g_iPlayerTimeServer[client]) / float(avg));
|
|
||||||
if (val < 1)
|
|
||||||
{
|
|
||||||
val = 1;
|
|
||||||
}
|
|
||||||
return val;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnPluginEnd()
|
public void OnPluginEnd()
|
||||||
@@ -336,20 +324,20 @@ public void SQL_OnDatabaseConnect(Database db, const char[] error, any data)
|
|||||||
{
|
{
|
||||||
if(IsValidClient(i) && !IsFakeClient(i))
|
if(IsValidClient(i) && !IsFakeClient(i))
|
||||||
{
|
{
|
||||||
OnClientPostAdminCheck(i);
|
OnClientAuthorized(i, g_csSID[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OnMapStart();
|
OnMapStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnClientPostAdminCheck(int client)
|
public void OnClientAuthorized(int client, const char[] sSteamID32)
|
||||||
{
|
{
|
||||||
g_iPlayerNextTierHours[client] = 0;
|
g_iPlayerNextTierHours[client] = 0;
|
||||||
GetClientAuthId(client, AuthId_Steam2, g_csSID[client], sizeof(g_csSID[]));
|
GetClientAuthId(client, AuthId_Steam2, g_csSID[client], sizeof(g_csSID[]));
|
||||||
is_bot_player[client] = false;
|
is_bot_player[client] = false;
|
||||||
g_iPlayerTier[client] = -1;
|
g_iPlayerTier[client] = -1;
|
||||||
g_iPlayerTimeServer[client] = 0;
|
g_iPlayerTimeServer[client] = 0;
|
||||||
if(!IsValidClient(client) || IsFakeClient(client))
|
if(IsFakeClient(client))
|
||||||
return;
|
return;
|
||||||
if (!g_hDatabase)
|
if (!g_hDatabase)
|
||||||
{
|
{
|
||||||
@@ -433,128 +421,23 @@ public void SQL_OnQueryCompletedTimeServer(Database db, DBResultSet results, con
|
|||||||
Call_PushCell(iMinutes_Server);
|
Call_PushCell(iMinutes_Server);
|
||||||
Call_Finish();
|
Call_Finish();
|
||||||
|
|
||||||
if (g_iPlayerTier[client] == -1)
|
if (g_iPlayerTier[client] == -1)
|
||||||
{
|
{
|
||||||
SetPlayerTier(client);
|
SetPlayerTier(client);
|
||||||
if (g_iPlayerTier[client] > 0)
|
G_bResponsePassed[client] = true;
|
||||||
|
if (G_bPreAdminChecked[client])
|
||||||
{
|
{
|
||||||
WritePlayerTierToFile(client); //checks if all tiers have to be written
|
NotifyPostAdminCheck(client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (check_if_client_new_tier(client)) //called every 10 minutes to fill new times from DB
|
else if (check_if_client_new_tier(client))
|
||||||
{
|
{
|
||||||
SetPlayerTier(client);
|
SetPlayerTier(client);
|
||||||
WritePlayerTierToFile(client); //write only the new tier to the file at the end.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void WritePlayerTierToFile(int client)
|
|
||||||
{
|
|
||||||
char sPath[PLATFORM_MAX_PATH];
|
|
||||||
BuildPath(Path_SM, sPath, sizeof(sPath), "configs/admins_simple.ini");
|
|
||||||
|
|
||||||
// --- Read entire file into memory ---
|
|
||||||
File f = OpenFile(sPath, "r");
|
|
||||||
if (!f)
|
|
||||||
{
|
|
||||||
LogError("WritePlayerTierToFile: failed to open %s for reading", sPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool foundSeparator = false;
|
|
||||||
//always write the steamIDs as steamID 2 format.
|
|
||||||
char sSID[64];
|
|
||||||
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
|
|
||||||
bool write_all_tiers = true;
|
|
||||||
bool write_newest_tier = false;
|
|
||||||
|
|
||||||
char line[256];
|
|
||||||
while (f.ReadLine(line, sizeof(line)))
|
|
||||||
{
|
|
||||||
int len = strlen(line);
|
|
||||||
while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r'))
|
|
||||||
line[--len] = '\0';
|
|
||||||
|
|
||||||
if (StrEqual(line, "//here begins the unloze_play_time plugin tiers", false))
|
|
||||||
{
|
|
||||||
foundSeparator = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!foundSeparator)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each line looks like: "STEAM_0:0:12345" "@tier3"
|
|
||||||
// Extract steam ID and tier number
|
|
||||||
char steamid[65];
|
|
||||||
char group[64];
|
|
||||||
|
|
||||||
int pos = 1; //skip opening quote
|
|
||||||
int sidStart = pos;
|
|
||||||
while (line[pos] != '"')
|
|
||||||
{
|
|
||||||
pos++;
|
|
||||||
}
|
|
||||||
int sidLen = pos - sidStart;
|
|
||||||
strcopy(steamid, sidLen + 1, line[sidStart]);
|
|
||||||
if (!StrEqual(steamid, sSID))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
//if steam ID does not exist write all tiers
|
|
||||||
write_all_tiers = false;
|
|
||||||
write_newest_tier = true;
|
|
||||||
|
|
||||||
pos++; // skip closing quote of steamid
|
|
||||||
while (line[pos] == ' ' || line[pos] == '\t') pos++; // skip whitespace
|
|
||||||
pos++; // skip opening quote of group
|
|
||||||
|
|
||||||
int grpStart = pos;
|
|
||||||
while (line[pos] != '"') pos++;
|
|
||||||
int grpLen = pos - grpStart;
|
|
||||||
strcopy(group, grpLen + 1, line[grpStart]);
|
|
||||||
|
|
||||||
// group is "@tier3", extract tier number
|
|
||||||
char tierStr[8];
|
|
||||||
strcopy(tierStr, sizeof(tierStr), group[5]); // skip "@tier"
|
|
||||||
int tierNum = StringToInt(tierStr);
|
|
||||||
if (tierNum == g_iPlayerTier[client])
|
|
||||||
{
|
|
||||||
write_newest_tier = false;
|
|
||||||
break; //we confirmed the most recent tier was already written here.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete f;
|
|
||||||
|
|
||||||
if (write_all_tiers)
|
|
||||||
{
|
|
||||||
// Open file for appending
|
|
||||||
File fAppend = OpenFile(sPath, "a");
|
|
||||||
|
|
||||||
for (int t = 1; t <= g_iPlayerTier[client]; t++)
|
|
||||||
{
|
|
||||||
char linewrite[256];
|
|
||||||
Format(linewrite, sizeof(linewrite), "\"%s\" \"@tier%i\"\n", sSID, t);
|
|
||||||
fAppend.WriteString(linewrite, false);
|
|
||||||
}
|
|
||||||
delete fAppend;
|
|
||||||
SetTierRewards(client);
|
|
||||||
|
|
||||||
}
|
|
||||||
else if (write_newest_tier)
|
|
||||||
{
|
|
||||||
// Open file for appending
|
|
||||||
File fAppend = OpenFile(sPath, "a");
|
|
||||||
char linewrite[256];
|
|
||||||
Format(linewrite, sizeof(linewrite), "\"%s\" \"@tier%i\"\n", sSID, g_iPlayerTier[client]);
|
|
||||||
fAppend.WriteString(linewrite, false);
|
|
||||||
delete fAppend;
|
|
||||||
SetTierRewards(client);
|
SetTierRewards(client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool check_if_client_new_tier(int client)
|
public bool check_if_client_new_tier(int client) //called every 10 minutes to fill new times from DB
|
||||||
{
|
{
|
||||||
char sPath[PLATFORM_MAX_PATH];
|
char sPath[PLATFORM_MAX_PATH];
|
||||||
BuildPath(Path_SM, sPath, sizeof(sPath), "configs/unloze_playt_time_tiers.cfg");
|
BuildPath(Path_SM, sPath, sizeof(sPath), "configs/unloze_playt_time_tiers.cfg");
|
||||||
@@ -589,7 +472,36 @@ public bool check_if_client_new_tier(int client)
|
|||||||
}
|
}
|
||||||
if (tier > g_iPlayerTier[client])
|
if (tier > g_iPlayerTier[client])
|
||||||
{
|
{
|
||||||
PrintToChatAll("%N Leveled up to Tier: %i!", client, tier);
|
char new_rewards[512];
|
||||||
|
if (g_iRtvBoost_tier == g_iPlayerTier[client])
|
||||||
|
{
|
||||||
|
Format(new_rewards, sizeof(new_rewards), "%ix Mapvote/RTV/Nomination boost", g_iPlayerRTVCapacity);
|
||||||
|
PrintToChatAll("\x03%N \x01Leveled up to Tier \x04%i\x01 and unlocked: \x04%s", client, tier, new_rewards);
|
||||||
|
}
|
||||||
|
if (FindConVar("sm_spraymanager_tier_required").IntValue == g_iPlayerTier[client])
|
||||||
|
{
|
||||||
|
Format(new_rewards, sizeof(new_rewards), "Spray access");
|
||||||
|
PrintToChatAll("\x03%N \x01Leveled up to Tier \x04%i\x01 and unlocked: \x04%s", client, tier, new_rewards);
|
||||||
|
}
|
||||||
|
if (FindConVar("sm_entwatch_tier_requirement").IntValue == g_iPlayerTier[client])
|
||||||
|
{
|
||||||
|
Format(new_rewards, sizeof(new_rewards), "Item access");
|
||||||
|
PrintToChatAll("\x03%N \x01Leveled up to Tier \x04%i\x01 and unlocked: \x04%s", client, tier, new_rewards);
|
||||||
|
}
|
||||||
|
if (FindConVar("mce_multiple_nominations_tier").IntValue == g_iPlayerTier[client])
|
||||||
|
{
|
||||||
|
Format(new_rewards, sizeof(new_rewards), "Multiple nominations");
|
||||||
|
PrintToChatAll("\x03%N \x01Leveled up to Tier \x04%i\x01 and unlocked: \x04%s", client, tier, new_rewards);
|
||||||
|
}
|
||||||
|
if (g_GroupFlags[g_iPlayerTier[client]][0] != '\0' && StrEqual(g_GroupFlags[g_iPlayerTier[client]], "aop"))
|
||||||
|
{
|
||||||
|
Format(new_rewards, sizeof(new_rewards), "VIP");
|
||||||
|
PrintToChatAll("\x03%N \x01Leveled up to Tier \x04%i\x01 and unlocked: \x04%s", client, tier, new_rewards);
|
||||||
|
}
|
||||||
|
if (g_GroupOverrides.GetString(g_GroupNames[g_iPlayerTier[client]], new_rewards, sizeof(new_rewards)))
|
||||||
|
{
|
||||||
|
PrintToChatAll("\x03%N \x01Leveled up to Tier \x04%i\x01 and unlocked: \x04%s", client, tier, new_rewards);
|
||||||
|
}
|
||||||
upgrade_tier = true;
|
upgrade_tier = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,20 +510,6 @@ public bool check_if_client_new_tier(int client)
|
|||||||
return upgrade_tier;
|
return upgrade_tier;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetTierRewards(int client)
|
|
||||||
{
|
|
||||||
if (g_iPlayerTier[client] < 1)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (int i = 1; i <= g_iPlayerTier[client]; i++)
|
|
||||||
{
|
|
||||||
char groupname[64];
|
|
||||||
Format(groupname, sizeof(groupname), "tier%i", i);
|
|
||||||
AddClientToGroup(client, groupname);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddClientToGroup(int client, const char[] groupName)
|
public void AddClientToGroup(int client, const char[] groupName)
|
||||||
{
|
{
|
||||||
AdminId id = GetUserAdmin(client);
|
AdminId id = GetUserAdmin(client);
|
||||||
@@ -673,16 +571,10 @@ public void SetPlayerTier(int client)
|
|||||||
g_iPlayerTier[client] = 0;
|
g_iPlayerTier[client] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Call_StartForward(g_hForwardPlayerTier);
|
|
||||||
Call_PushCell(client);
|
|
||||||
Call_PushCell(g_iPlayerTier[client]);
|
|
||||||
Call_Finish();
|
|
||||||
if (g_iPlayerTimeServer[client] < next_hours)
|
if (g_iPlayerTimeServer[client] < next_hours)
|
||||||
{
|
{
|
||||||
g_iPlayerNextTierHours[client] = next_hours - g_iPlayerTimeServer[client];
|
g_iPlayerNextTierHours[client] = next_hours - g_iPlayerTimeServer[client];
|
||||||
PrintToChat(client, "You need %i hours to reach next tier. Check your features with !sm_tier", g_iPlayerNextTierHours[client]);
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
|
||||||
delete kv;
|
delete kv;
|
||||||
}
|
}
|
||||||
@@ -694,6 +586,8 @@ public void OnClientDisconnect(int client)
|
|||||||
Format(g_csSID[client], sizeof(g_csSID[]), "");
|
Format(g_csSID[client], sizeof(g_csSID[]), "");
|
||||||
is_bot_player[client] = false;
|
is_bot_player[client] = false;
|
||||||
g_iPlayerTimeServer[client] = 0;
|
g_iPlayerTimeServer[client] = 0;
|
||||||
|
G_bPreAdminChecked[client] = false;
|
||||||
|
G_bResponsePassed[client] = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void insert_client(int client)
|
public void insert_client(int client)
|
||||||
@@ -809,44 +703,74 @@ public void PrintClientGroupOverrides(int client)
|
|||||||
char multi_nomination_tier[32];
|
char multi_nomination_tier[32];
|
||||||
Format(multi_nomination_tier, sizeof(multi_nomination_tier), "tier%i", FindConVar("mce_multiple_nominations_tier").IntValue);
|
Format(multi_nomination_tier, sizeof(multi_nomination_tier), "tier%i", FindConVar("mce_multiple_nominations_tier").IntValue);
|
||||||
|
|
||||||
|
char lvl_chat_tag[32];
|
||||||
|
Format(lvl_chat_tag, sizeof(lvl_chat_tag), "tier%i", FindConVar("sm_display_lvl_chat").IntValue);
|
||||||
|
|
||||||
|
char lvl_clan_tag[32];
|
||||||
|
Format(lvl_clan_tag, sizeof(lvl_clan_tag), "tier%i", FindConVar("sm_display_lvl_clan").IntValue);
|
||||||
|
|
||||||
|
char lvl_chat_tag_multicolour[32];
|
||||||
|
Format(lvl_chat_tag_multicolour, sizeof(lvl_chat_tag_multicolour), "tier%i", FindConVar("sm_display_lvl_chat_multi_colour").IntValue);
|
||||||
|
|
||||||
|
char lvl_chat_tag_allcolour[32];
|
||||||
|
Format(lvl_chat_tag_allcolour, sizeof(lvl_chat_tag_allcolour), "tier%i", FindConVar("sm_display_lvl_chat_all_colour").IntValue);
|
||||||
|
|
||||||
|
menu.AddItem("-1", "Several tiers: playermodels in !zclass.", ITEMDRAW_DISABLED);
|
||||||
|
menu.AddItem("-1", "Several tiers: grenade skins in !grenadeskins.", ITEMDRAW_DISABLED);
|
||||||
|
menu.AddItem("-1", "Several tiers: Trail sprites in !trails.", ITEMDRAW_DISABLED);
|
||||||
|
|
||||||
for (int i = 0; i < g_GroupNameCount; i++)
|
for (int i = 0; i < g_GroupNameCount; i++)
|
||||||
{
|
{
|
||||||
char entry[128];
|
char entry[512];
|
||||||
char overrides[512];
|
char overrides[512];
|
||||||
|
|
||||||
if (StrEqual(g_GroupNames[i], rtv_boost_tier))
|
if (StrEqual(g_GroupNames[i], rtv_boost_tier))
|
||||||
{
|
{
|
||||||
Format(entry, sizeof(entry), "%s: %ix Mapvote/RTV/Nomination boost", g_GroupNames[i], g_iPlayerRTVCapacity);
|
Format(entry, sizeof(entry), "%s%s: %ix Mapvote/RTV/Nomination boost, ", entry, g_GroupNames[i], g_iPlayerRTVCapacity);
|
||||||
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
|
|
||||||
}
|
}
|
||||||
if (StrEqual(g_GroupNames[i], spray_tier))
|
if (StrEqual(g_GroupNames[i], spray_tier))
|
||||||
{
|
{
|
||||||
Format(entry, sizeof(entry), "%s: Sprays", g_GroupNames[i]);
|
Format(entry, sizeof(entry), "%s%s: Sprays, ", entry, g_GroupNames[i]);
|
||||||
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
|
|
||||||
}
|
}
|
||||||
if (StrEqual(g_GroupNames[i], entwatch_tier))
|
if (StrEqual(g_GroupNames[i], entwatch_tier))
|
||||||
{
|
{
|
||||||
Format(entry, sizeof(entry), "%s: Items", g_GroupNames[i]);
|
Format(entry, sizeof(entry), "%s%s: Items, ", entry, g_GroupNames[i]);
|
||||||
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
|
|
||||||
}
|
}
|
||||||
if (StrEqual(g_GroupNames[i], multi_nomination_tier))
|
if (StrEqual(g_GroupNames[i], multi_nomination_tier))
|
||||||
{
|
{
|
||||||
Format(entry, sizeof(entry), "%s: Multiple nominations", g_GroupNames[i]);
|
Format(entry, sizeof(entry), "%s%s: Multiple nominations, ", entry, g_GroupNames[i]);
|
||||||
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
|
|
||||||
}
|
}
|
||||||
|
if (StrEqual(g_GroupNames[i], lvl_chat_tag))
|
||||||
|
{
|
||||||
|
Format(entry, sizeof(entry), "%s%s: LVL chat tag with one colour, ", entry, g_GroupNames[i]);
|
||||||
|
}
|
||||||
|
if (StrEqual(g_GroupNames[i], lvl_clan_tag))
|
||||||
|
{
|
||||||
|
Format(entry, sizeof(entry), "%s%s: LVL clan tag, ", entry, g_GroupNames[i]);
|
||||||
|
}
|
||||||
|
if (StrEqual(g_GroupNames[i], lvl_chat_tag_multicolour))
|
||||||
|
{
|
||||||
|
Format(entry, sizeof(entry), "%s%s: LVL chat tag with multi colours, ", entry, g_GroupNames[i]);
|
||||||
|
}
|
||||||
|
if (StrEqual(g_GroupNames[i], lvl_chat_tag_allcolour))
|
||||||
|
{
|
||||||
|
Format(entry, sizeof(entry), "%s%s: LVL chat tag with all colours, ", entry, g_GroupNames[i]);
|
||||||
|
}
|
||||||
|
|
||||||
if (g_GroupFlags[i][0] != '\0' && StrEqual(g_GroupFlags[i], "aop"))
|
if (g_GroupFlags[i][0] != '\0' && StrEqual(g_GroupFlags[i], "aop"))
|
||||||
{
|
{
|
||||||
Format(entry, sizeof(entry), "%s: VIP", g_GroupNames[i]);
|
Format(entry, sizeof(entry), "%s%s: VIP, ", entry, g_GroupNames[i]);
|
||||||
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
|
|
||||||
}
|
}
|
||||||
if (g_GroupOverrides.GetString(g_GroupNames[i], overrides, sizeof(overrides)))
|
if (g_GroupOverrides.GetString(g_GroupNames[i], overrides, sizeof(overrides)))
|
||||||
{
|
{
|
||||||
Format(entry, sizeof(entry), "%s: %s", g_GroupNames[i], overrides);
|
Format(entry, sizeof(entry), "%s%s: %s, ", entry, g_GroupNames[i], overrides);
|
||||||
|
}
|
||||||
|
if (strlen(entry) > 0)
|
||||||
|
{
|
||||||
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
|
menu.AddItem("-1", entry, ITEMDRAW_DISABLED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.AddItem("-1", "any tier: playermodels in zclass.", ITEMDRAW_DISABLED);
|
|
||||||
menu.ExitButton = true;
|
menu.ExitButton = true;
|
||||||
menu.Display(client, 0);
|
menu.Display(client, 0);
|
||||||
}
|
}
|
||||||
@@ -989,3 +913,78 @@ public int Handler_Menu(Menu menu, MenuAction action, int param1, int param2)
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetTierRewards(int client)
|
||||||
|
{
|
||||||
|
if (!G_bResponsePassed[client])
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PrintToChat(client, "You need %i hours to reach next tier. Check your features with !sm_tier", g_iPlayerNextTierHours[client]);
|
||||||
|
if (g_iPlayerTier[client] < 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int i = 1; i <= g_iPlayerTier[client]; i++)
|
||||||
|
{
|
||||||
|
char groupname[64];
|
||||||
|
Format(groupname, sizeof(groupname), "tier%i", i);
|
||||||
|
AddClientToGroup(client, groupname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//----------------------------------------------------------------------------------------------------
|
||||||
|
// Purpose:
|
||||||
|
//----------------------------------------------------------------------------------------------------
|
||||||
|
public Action OnRebuildAdminCachePost(Handle hTimer)
|
||||||
|
{
|
||||||
|
for (int client = 1; client <= MaxClients; client++)
|
||||||
|
{
|
||||||
|
if(G_bResponsePassed[client] && G_bPreAdminChecked[client])
|
||||||
|
SetTierRewards(client);
|
||||||
|
}
|
||||||
|
return Plugin_Handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action OnClientPreAdminCheck(int client)
|
||||||
|
{
|
||||||
|
G_bPreAdminChecked[client] = true;
|
||||||
|
|
||||||
|
if (G_bResponsePassed[client])
|
||||||
|
{
|
||||||
|
return Plugin_Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
RunAdminCacheChecks(client);
|
||||||
|
return Plugin_Handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClientConnected(int client)
|
||||||
|
{
|
||||||
|
G_bPreAdminChecked[client] = false;
|
||||||
|
G_bResponsePassed[client] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClientPostAdminFilter(int client)
|
||||||
|
{
|
||||||
|
if (IsFakeClient(client))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SetTierRewards(client);
|
||||||
|
|
||||||
|
Call_StartForward(g_hForwardPlayerTier);
|
||||||
|
Call_PushCell(client);
|
||||||
|
Call_PushCell(g_iPlayerTier[client]);
|
||||||
|
Call_Finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnRebuildAdminCache(AdminCachePart part)
|
||||||
|
{
|
||||||
|
if (part != AdminCache_Admins)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CreateTimer(1.0, OnRebuildAdminCachePost, INVALID_HANDLE, TIMER_FLAG_NO_MAPCHANGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
#pragma semicolon 1
|
||||||
|
#define PLUGIN_AUTHOR "jenz"
|
||||||
|
#define PLUGIN_VERSION "1.1"
|
||||||
|
#include <sourcemod>
|
||||||
|
#include <geoip>
|
||||||
|
#include <unloze_playtime>
|
||||||
|
#include <mapchooser_extended>
|
||||||
|
#include <cstrike>
|
||||||
|
|
||||||
|
Database g_hDatabase;
|
||||||
|
bool g_bCorrectServer = false;
|
||||||
|
|
||||||
|
int g_iClientHours[MAXPLAYERS + 1];
|
||||||
|
int g_iClientMinutes[MAXPLAYERS + 1];
|
||||||
|
|
||||||
|
ArrayList g_hPendingVoteChoices;
|
||||||
|
|
||||||
|
public Plugin myinfo =
|
||||||
|
{
|
||||||
|
name = "UNLOZE_playtime_display",
|
||||||
|
author = PLUGIN_AUTHOR,
|
||||||
|
description = "collects all info for playtime display",
|
||||||
|
version = PLUGIN_VERSION,
|
||||||
|
url = "www.unloze.com"
|
||||||
|
};
|
||||||
|
|
||||||
|
enum struct VoteChoiceRecord
|
||||||
|
{
|
||||||
|
char SteamId[64];
|
||||||
|
char VoteChoice[64];
|
||||||
|
int VoteWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnPluginStart()
|
||||||
|
{
|
||||||
|
if (!g_hDatabase)
|
||||||
|
{
|
||||||
|
Database.Connect(SQL_OnDatabaseConnect, "unloze_playtimestats");
|
||||||
|
}
|
||||||
|
g_hPendingVoteChoices = new ArrayList(sizeof(VoteChoiceRecord));
|
||||||
|
g_bCorrectServer = GetConVarInt(FindConVar("hostport")) == 27015 ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SQL_OnDatabaseConnect(Database db, const char[] error, any data)
|
||||||
|
{
|
||||||
|
if(!db || strlen(error))
|
||||||
|
{
|
||||||
|
LogError("Database error: %s", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_hDatabase = db;
|
||||||
|
OnMapStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnMapStart()
|
||||||
|
{
|
||||||
|
if (!g_bCorrectServer)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!g_hDatabase)
|
||||||
|
{
|
||||||
|
Database.Connect(SQL_OnDatabaseConnect, "unloze_playtimestats");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char sMapname[64];
|
||||||
|
GetCurrentMap(sMapname, sizeof(sMapname));
|
||||||
|
insert_map(sMapname);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insert_map(char sMapname[64])
|
||||||
|
{
|
||||||
|
char sQuery[512];
|
||||||
|
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_map_history` (map_name) VALUES('%s')", sMapname);
|
||||||
|
g_hDatabase.Query(SQL_FinishedQuery, sQuery, _, DBPrio_Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
//forward is called from OnClientAuthorized but also from timer every 1 minute.
|
||||||
|
public void GetPlayerHoursServer(int client, int hours, int minutes)
|
||||||
|
{
|
||||||
|
if (!g_bCorrectServer)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_iClientHours[client] = hours;
|
||||||
|
g_iClientMinutes[client] = minutes;
|
||||||
|
|
||||||
|
select_user_session(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void select_user_session(int client)
|
||||||
|
{
|
||||||
|
char sSID[64];
|
||||||
|
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
|
||||||
|
|
||||||
|
char sQuery[512];
|
||||||
|
Format(sQuery, sizeof(sQuery), "select 1 from `playtime_display_sessions` where steamid = '%s' and session_end_dt > NOW() - INTERVAL 10 MINUTE limit 1", sSID);
|
||||||
|
g_hDatabase.Query(SQL_FinishedQuerySelectUserSession, sQuery, GetClientSerial(client), DBPrio_Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SQL_FinishedQuerySelectUserSession(Database db, DBResultSet results, const char[] error, int Serial)
|
||||||
|
{
|
||||||
|
if (!db || strlen(error))
|
||||||
|
{
|
||||||
|
LogError("Query error 3: %s", error);
|
||||||
|
delete results;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int client;
|
||||||
|
if ((client = GetClientFromSerial(Serial)) == 0)
|
||||||
|
{
|
||||||
|
delete results;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!IsValidClient(client))
|
||||||
|
{
|
||||||
|
delete results;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (results.RowCount && results.FetchRow())
|
||||||
|
{
|
||||||
|
//active session that we keep updating
|
||||||
|
update_play_session(client, g_iClientHours[client], g_iClientMinutes[client]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
//new session that we insert
|
||||||
|
//chaining the calls
|
||||||
|
insert_player(client);
|
||||||
|
}
|
||||||
|
delete results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update_play_session(int client, int client_hours, int client_minutes)
|
||||||
|
{
|
||||||
|
char sSID[64];
|
||||||
|
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
|
||||||
|
|
||||||
|
//we only update the latest session row if it actually was updated inside the last 10 minutes. otherwise its an old session that should not be updated.
|
||||||
|
char sQuery[512];
|
||||||
|
Format(sQuery, sizeof(sQuery), "UPDATE `playtime_display_sessions` SET session_end_playtime_minutes='%i', session_end_dt=NOW() WHERE steamid = '%s' and session_end_dt > NOW() - INTERVAL 10 MINUTE order by session_id desc limit 1", (client_hours * 60) + client_minutes, sSID);
|
||||||
|
|
||||||
|
g_hDatabase.Query(SQL_FinishedQuery, sQuery, _, DBPrio_Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insert_player(int client)
|
||||||
|
{
|
||||||
|
char sName[MAX_NAME_LENGTH];
|
||||||
|
GetClientName(client, sName, sizeof(sName));
|
||||||
|
int size2 = 2 * strlen(sName) + 1;
|
||||||
|
char[] sEscapedName = new char[size2 + 1];
|
||||||
|
g_hDatabase.Escape(sName, sEscapedName, size2 + 1);
|
||||||
|
|
||||||
|
char sIP[32];
|
||||||
|
GetClientIP(client, sIP, sizeof(sIP));
|
||||||
|
|
||||||
|
char countryCode[3];
|
||||||
|
GeoipCode2(sIP, countryCode);
|
||||||
|
|
||||||
|
char sSID[64];
|
||||||
|
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
|
||||||
|
|
||||||
|
int client_tier = GetPlayerTier_native(client);
|
||||||
|
|
||||||
|
char sQuery[512];
|
||||||
|
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_players` (`steamid`, `current_name`, `current_country`, `player_tier`) VALUES('%s', '%s', '%s', '%i') ON DUPLICATE KEY UPDATE `current_name` = '%s', `current_country` = '%s', `last_seen` = CURRENT_TIMESTAMP, `player_tier` = '%i'", sSID, sEscapedName, countryCode, client_tier, sEscapedName, countryCode, client_tier);
|
||||||
|
g_hDatabase.Query(SQL_FinishedQueryUpsertPlayerCallback, sQuery, GetClientSerial(client), DBPrio_Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SQL_FinishedQueryUpsertPlayerCallback(Database db, DBResultSet results, const char[] error, int Serial)
|
||||||
|
{
|
||||||
|
if (!db || strlen(error))
|
||||||
|
{
|
||||||
|
LogError("Query error 3: %s", error);
|
||||||
|
delete results;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int client;
|
||||||
|
if ((client = GetClientFromSerial(Serial)) == 0)
|
||||||
|
{
|
||||||
|
delete results;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!IsValidClient(client))
|
||||||
|
{
|
||||||
|
delete results;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete results;
|
||||||
|
upsert_name_history(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void upsert_name_history(int client)
|
||||||
|
{
|
||||||
|
char sName[MAX_NAME_LENGTH];
|
||||||
|
GetClientName(client, sName, sizeof(sName));
|
||||||
|
int size2 = 2 * strlen(sName) + 1;
|
||||||
|
char[] sEscapedName = new char[size2 + 1];
|
||||||
|
g_hDatabase.Escape(sName, sEscapedName, size2 + 1);
|
||||||
|
|
||||||
|
char sSID[64];
|
||||||
|
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
|
||||||
|
char sQuery[512];
|
||||||
|
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_name_history` (`steamid`, `player_name`) VALUES('%s', '%s') ON DUPLICATE KEY UPDATE `player_name` = '%s', `last_seen` = CURRENT_TIMESTAMP", sSID, sEscapedName, sEscapedName);
|
||||||
|
g_hDatabase.Query(SQL_FinishedQueryUpsertNameHistoryCallback, sQuery, GetClientSerial(client), DBPrio_Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SQL_FinishedQueryUpsertNameHistoryCallback(Database db, DBResultSet results, const char[] error, int Serial)
|
||||||
|
{
|
||||||
|
if (!db || strlen(error))
|
||||||
|
{
|
||||||
|
LogError("Query error 3: %s", error);
|
||||||
|
delete results;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int client;
|
||||||
|
if ((client = GetClientFromSerial(Serial)) == 0)
|
||||||
|
{
|
||||||
|
delete results;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!IsValidClient(client))
|
||||||
|
{
|
||||||
|
delete results;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete results;
|
||||||
|
insert_play_session(client, g_iClientHours[client], g_iClientMinutes[client]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insert_play_session(int client, int hours, int minutes)
|
||||||
|
{
|
||||||
|
char sName[MAX_NAME_LENGTH];
|
||||||
|
GetClientName(client, sName, sizeof(sName));
|
||||||
|
int size2 = 2 * strlen(sName) + 1;
|
||||||
|
char[] sEscapedName = new char[size2 + 1];
|
||||||
|
g_hDatabase.Escape(sName, sEscapedName, size2 + 1);
|
||||||
|
|
||||||
|
char sIP[32];
|
||||||
|
GetClientIP(client, sIP, sizeof(sIP));
|
||||||
|
|
||||||
|
char countryCode[3];
|
||||||
|
GeoipCode2(sIP, countryCode);
|
||||||
|
|
||||||
|
char sSID[64];
|
||||||
|
GetClientAuthId(client, AuthId_Steam2, sSID, sizeof(sSID));
|
||||||
|
char sQuery[512];
|
||||||
|
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_sessions` (`steamid`, `country_code`, `player_name`, `session_start_playtime_minutes`, `session_end_dt`) VALUES('%s', '%s','%s','%i', CURRENT_TIMESTAMP)", sSID, countryCode, sEscapedName, (hours * 60) + minutes);
|
||||||
|
g_hDatabase.Query(SQL_FinishedQuery, sQuery, _, DBPrio_Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SQL_FinishedQuery(Database db, DBResultSet results, const char[] error, any data)
|
||||||
|
{
|
||||||
|
if (!db || strlen(error))
|
||||||
|
{
|
||||||
|
LogError("Query error 3: %s", error);
|
||||||
|
}
|
||||||
|
delete results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClientDisconnect(int client)
|
||||||
|
{
|
||||||
|
if (g_iClientHours[client] == 0 && g_iClientMinutes[client] == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
update_play_session(client, g_iClientHours[client], g_iClientMinutes[client]);
|
||||||
|
g_iClientHours[client] = 0;
|
||||||
|
g_iClientMinutes[client] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnMapVoteRunnoffWarningStart()
|
||||||
|
{
|
||||||
|
if (!g_bCorrectServer)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char sMapname[64];
|
||||||
|
GetCurrentMap(sMapname, sizeof(sMapname));
|
||||||
|
|
||||||
|
char sQuery[512]; //picking max() is usually considered haram
|
||||||
|
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_map_vote_events` (`map_id`, `current_map`, `result`) VALUES((select max(map_id) from `playtime_display_map_history`), '%s','Revote needed!')", sMapname);
|
||||||
|
g_hDatabase.Query(SQL_FinishedQueryMapVoteEndCallback, sQuery, _, DBPrio_Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnMapVoteEnd(const char[] map)
|
||||||
|
{
|
||||||
|
if (!g_bCorrectServer)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char sMapname[64];
|
||||||
|
GetCurrentMap(sMapname, sizeof(sMapname));
|
||||||
|
|
||||||
|
char sQuery[512]; //picking max() is usually considered haram
|
||||||
|
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_map_vote_events` (`map_id`, `current_map`, `result`) VALUES((select max(map_id) from `playtime_display_map_history`), '%s','%s')", sMapname, map);
|
||||||
|
g_hDatabase.Query(SQL_FinishedQueryMapVoteEndCallback, sQuery, _, DBPrio_Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SQL_FinishedQueryMapVoteEndCallback(Database db, DBResultSet results, const char[] error, any data)
|
||||||
|
{
|
||||||
|
if (!db || strlen(error))
|
||||||
|
{
|
||||||
|
LogError("Query error 3: %s", error);
|
||||||
|
}
|
||||||
|
delete results;
|
||||||
|
|
||||||
|
for (int j = 0; j < GetArraySize(g_hPendingVoteChoices); j++)
|
||||||
|
{
|
||||||
|
VoteChoiceRecord entry;
|
||||||
|
GetArrayArray(g_hPendingVoteChoices, j, entry);
|
||||||
|
|
||||||
|
char sQuery[512]; //again, selecting with max() is generally not very good but should be reliable given the context here.
|
||||||
|
Format(sQuery, sizeof(sQuery), "INSERT INTO `playtime_display_map_vote_choices` (`vote_id`, `steamid`, `vote_choice`, `vote_weight`) VALUES ((select max(vote_id) from `playtime_display_map_vote_events`), '%s', '%s','%i')", entry.SteamId, entry.VoteChoice, entry.VoteWeight);
|
||||||
|
g_hDatabase.Query(SQL_FinishedQuery, sQuery, _, DBPrio_Normal);
|
||||||
|
}
|
||||||
|
g_hPendingVoteChoices.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnMapVoteClientResult(char[] steamID, char[] vote_choice, int voteweight)
|
||||||
|
{
|
||||||
|
VoteChoiceRecord entry;
|
||||||
|
strcopy(entry.SteamId, sizeof(entry.SteamId), steamID);
|
||||||
|
strcopy(entry.VoteChoice, sizeof(entry.VoteChoice), vote_choice);
|
||||||
|
entry.VoteWeight = voteweight;
|
||||||
|
g_hPendingVoteChoices.PushArray(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
stock bool IsValidClient(int client)
|
||||||
|
{
|
||||||
|
if (client > 0 && client <= MaxClients && IsClientConnected(client) && IsClientInGame(client))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE unloze_playtimestats.play_time_laser_fag (
|
||||||
|
`steam_id` varchar(64) NOT NULL,
|
||||||
|
`player_name` varchar(256) NOT NULL,
|
||||||
|
`laser_fag_time` bigint(20) NOT NULL,
|
||||||
|
`not_laser_fag_time` bigint(20) NOT NULL,
|
||||||
|
PRIMARY KEY (`steam_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
|
||||||
|
CREATE TABLE `playtime_display_map_history` (
|
||||||
|
`map_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`map_name` varchar(64) NOT NULL,
|
||||||
|
`map_start_dt` datetime NOT NULL DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`map_id`),
|
||||||
|
KEY `idx_map_name` (`map_name`,`map_start_dt`),
|
||||||
|
KEY `idx_start` (`map_start_dt`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE `playtime_display_map_vote_events` (
|
||||||
|
`vote_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`map_id` bigint(20) unsigned DEFAULT NULL,
|
||||||
|
`vote_time` datetime NOT NULL DEFAULT current_timestamp(),
|
||||||
|
`current_map` varchar(64) DEFAULT NULL,
|
||||||
|
`result` varchar(64) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`vote_id`),
|
||||||
|
KEY `idx_map_id` (`map_id`),
|
||||||
|
CONSTRAINT `fk_voteevent_map` FOREIGN KEY (`map_id`) REFERENCES `playtime_display_map_history` (`map_id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE `playtime_display_players` (
|
||||||
|
`steamid` varchar(64) NOT NULL,
|
||||||
|
`current_name` varchar(128) NOT NULL,
|
||||||
|
`current_country` char(2) DEFAULT NULL,
|
||||||
|
`first_seen` datetime NOT NULL DEFAULT current_timestamp(),
|
||||||
|
`last_seen` datetime NOT NULL DEFAULT current_timestamp(),
|
||||||
|
`player_tier` int(11) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`steamid`),
|
||||||
|
KEY `idx_country` (`current_country`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE `playtime_display_name_history` (
|
||||||
|
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`steamid` varchar(64) NOT NULL,
|
||||||
|
`player_name` varchar(128) NOT NULL,
|
||||||
|
`first_seen` datetime NOT NULL DEFAULT current_timestamp(),
|
||||||
|
`last_seen` datetime NOT NULL DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_steamid_name` (`steamid`,`player_name`),
|
||||||
|
KEY `idx_player_name` (`player_name`),
|
||||||
|
CONSTRAINT `fk_namehist_steamid` FOREIGN KEY (`steamid`) REFERENCES `playtime_display_players` (`steamid`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=197 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE `playtime_display_map_vote_choices` (
|
||||||
|
`vote_id` int(10) unsigned NOT NULL,
|
||||||
|
`steamid` varchar(64) NOT NULL,
|
||||||
|
`vote_choice` varchar(64) NOT NULL,
|
||||||
|
`vote_weight` int(11) NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (`vote_id`,`steamid`),
|
||||||
|
KEY `idx_vote_choice` (`vote_choice`),
|
||||||
|
KEY `fk_votechoice_steamid` (`steamid`),
|
||||||
|
CONSTRAINT `fk_votechoice_event` FOREIGN KEY (`vote_id`) REFERENCES `playtime_display_map_vote_events` (`vote_id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_votechoice_steamid` FOREIGN KEY (`steamid`) REFERENCES `playtime_display_players` (`steamid`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE `playtime_display_sessions` (
|
||||||
|
`session_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`steamid` varchar(64) NOT NULL,
|
||||||
|
`country_code` char(2) DEFAULT NULL,
|
||||||
|
`player_name` varchar(128) NOT NULL,
|
||||||
|
`session_start_playtime_minutes` bigint(20) NOT NULL,
|
||||||
|
`session_end_playtime_minutes` bigint(20) DEFAULT NULL,
|
||||||
|
`session_start_dt` datetime NOT NULL DEFAULT current_timestamp(),
|
||||||
|
`session_end_dt` datetime DEFAULT NULL,
|
||||||
|
`session_active_minutes` bigint(20) GENERATED ALWAYS AS (`session_end_playtime_minutes` - `session_start_playtime_minutes`) STORED,
|
||||||
|
`session_start_dow` tinyint(4) GENERATED ALWAYS AS (dayofweek(`session_start_dt`)) STORED,
|
||||||
|
`session_start_tod` time GENERATED ALWAYS AS (cast(`session_start_dt` as time)) STORED,
|
||||||
|
PRIMARY KEY (`session_id`),
|
||||||
|
KEY `idx_steamid_start` (`steamid`,`session_start_dt`),
|
||||||
|
KEY `idx_interval` (`session_start_dt`,`session_end_dt`),
|
||||||
|
KEY `idx_dow_tod` (`session_start_dow`,`session_start_tod`),
|
||||||
|
KEY `idx_country` (`country_code`),
|
||||||
|
KEY `idx_steamid_end` (`steamid`,`session_end_dt`),
|
||||||
|
KEY `idx_steamid_session_id` (`steamid`,`session_id`),
|
||||||
|
CONSTRAINT `fk_session_steamid` FOREIGN KEY (`steamid`) REFERENCES `playtime_display_players` (`steamid`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
+43
-22
@@ -1,30 +1,51 @@
|
|||||||
#!/usr/bin/sh
|
#!/usr/bin/sh
|
||||||
#run full reclone when arguement is given
|
#run full reclone when arguement is given
|
||||||
if [ $# -eq 1 ]
|
if [ $# -eq 1 ]
|
||||||
then
|
then
|
||||||
echo 'doing long run with all files being updated.'
|
echo 'doing long run with all files being updated.'
|
||||||
#svencoop
|
#svencoop
|
||||||
rclone copy /home/gameservers/fastdl/svencoop/svencoop_addon r2demo:/svencoop/
|
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/svencoop/svencoop_addon r2demo:unloze-r2/svencoop/
|
||||||
#gmod zs
|
|
||||||
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/maps r2demo:/gmod_zs/maps/
|
#ZE server
|
||||||
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/materials r2demo:/gmod_zs/materials/
|
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/materials r2demo:unloze-r2/css_ze/materials/
|
||||||
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/models r2demo:/gmod_zs/models/
|
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/models r2demo:unloze-r2/css_ze/models/
|
||||||
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/sound r2demo:/gmod_zs/sound/
|
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/sound r2demo:unloze-r2/css_ze/sound/
|
||||||
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/resource r2demo:/gmod_zs/resource/
|
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/maps r2demo:unloze-r2/css_ze/maps/
|
||||||
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/particles r2demo:/gmod_zs/particles/
|
|
||||||
|
#ZR server
|
||||||
|
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/materials r2demo:unloze-r2/css_zr/materials/
|
||||||
|
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/models r2demo:unloze-r2/css_zr/models/
|
||||||
|
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/sound r2demo:unloze-r2/css_zr/sound/
|
||||||
|
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/maps r2demo:unloze-r2/css_zr/maps/
|
||||||
|
|
||||||
|
#MG server
|
||||||
|
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/materials r2demo:unloze-r2/css_mg/materials/
|
||||||
|
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/models r2demo:unloze-r2/css_mg/models/
|
||||||
|
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/sound r2demo:unloze-r2/css_mg/sound/
|
||||||
|
rclone copy --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/maps r2demo:unloze-r2/css_mg/maps/
|
||||||
echo 'Finished long run with all files updated.'
|
echo 'Finished long run with all files updated.'
|
||||||
#else if no arguement given run default with 30minute maximum.
|
#else if no arguement given run default with 30minute maximum.
|
||||||
else
|
else
|
||||||
echo 'doing short run with files younger than 30 minutes.'
|
echo 'doing short run with files younger than 30 minutes.'
|
||||||
#svencoop
|
#svencoop
|
||||||
rclone copy --max-age 30m --no-traverse /home/gameservers/fastdl/svencoop/svencoop_addon r2demo:/svencoop/
|
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/svencoop/svencoop_addon r2demo:unloze-r2/svencoop/
|
||||||
#gmod zs
|
#ZE server
|
||||||
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/maps r2demo:/gmod_zs/maps/
|
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/materials r2demo:unloze-r2/css_ze/materials/
|
||||||
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/materials r2demo:/gmod_zs/materials/
|
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/models r2demo:unloze-r2/css_ze/models/
|
||||||
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/models r2demo:/gmod_zs/models/
|
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/sound r2demo:unloze-r2/css_ze/sound/
|
||||||
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/sound r2demo:/gmod_zs/sound/
|
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_ze/maps r2demo:unloze-r2/css_ze/maps/
|
||||||
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/resource r2demo:/gmod_zs/resource/
|
|
||||||
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/gmod_zs/particles r2demo:/gmod_zs/particles/
|
#ZR server
|
||||||
|
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/materials r2demo:unloze-r2/css_zr/materials/
|
||||||
|
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/models r2demo:unloze-r2/css_zr/models/
|
||||||
|
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/sound r2demo:unloze-r2/css_zr/sound/
|
||||||
|
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_zr/maps r2demo:unloze-r2/css_zr/maps/
|
||||||
|
|
||||||
|
#MG server
|
||||||
|
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/materials r2demo:unloze-r2/css_mg/materials/
|
||||||
|
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/models r2demo:unloze-r2/css_mg/models/
|
||||||
|
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/sound r2demo:unloze-r2/css_mg/sound/
|
||||||
|
rclone copy --max-age 30m --no-traverse --no-update-modtime /home/gameservers/fastdl/css_mg/maps r2demo:unloze-r2/css_mg/maps/
|
||||||
echo 'Finished short run with files younger than 30 minutes.'
|
echo 'Finished short run with files younger than 30 minutes.'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
#include <sdktools>
|
#include <sdktools>
|
||||||
|
|
||||||
int map_switches_count = -1;
|
int map_switches_count = -1;
|
||||||
char g_cMaps[500][256];
|
char g_cMaps[800][256];
|
||||||
|
int g_i_rotate_time = 1;
|
||||||
|
|
||||||
//----------------------------------------------------------------------------------------------------
|
//----------------------------------------------------------------------------------------------------
|
||||||
// Purpose:
|
// Purpose:
|
||||||
@@ -16,11 +17,20 @@ public Plugin myinfo =
|
|||||||
url = "www.unloze.com"
|
url = "www.unloze.com"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public void Cvar_rotate_time(ConVar convar, const char[] oldValue, const char[] newValue)
|
||||||
|
{
|
||||||
|
g_i_rotate_time = convar.IntValue;
|
||||||
|
}
|
||||||
|
|
||||||
//----------------------------------------------------------------------------------------------------
|
//----------------------------------------------------------------------------------------------------
|
||||||
// Purpose:
|
// Purpose:
|
||||||
//----------------------------------------------------------------------------------------------------
|
//----------------------------------------------------------------------------------------------------
|
||||||
public void OnPluginStart()
|
public void OnPluginStart()
|
||||||
{
|
{
|
||||||
|
ConVar cvar;
|
||||||
|
HookConVarChange((cvar = CreateConVar("sm_map_rotating_time", "5", "time in seconds for map rotation")), Cvar_rotate_time);
|
||||||
|
g_i_rotate_time = cvar.IntValue;
|
||||||
|
delete cvar;
|
||||||
RegAdminCmd("sm_restartmaps_test", cmd_restart_the_map, ADMFLAG_GENERIC);
|
RegAdminCmd("sm_restartmaps_test", cmd_restart_the_map, ADMFLAG_GENERIC);
|
||||||
|
|
||||||
new Handle:fileHandle = OpenFile("wtf.txt", "r" );
|
new Handle:fileHandle = OpenFile("wtf.txt", "r" );
|
||||||
@@ -45,17 +55,17 @@ public Action cmd_restart_the_map(int client, int args)
|
|||||||
|
|
||||||
public void OnMapStart()
|
public void OnMapStart()
|
||||||
{
|
{
|
||||||
if (map_switches_count != -1 && map_switches_count < 400)
|
if (map_switches_count != -1 && map_switches_count < 500)
|
||||||
{
|
{
|
||||||
CreateTimer(5.0, time_query_activity);
|
CreateTimer(float(g_i_rotate_time), time_query_activity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Action time_query_activity(Handle timer, any data)
|
public Action time_query_activity(Handle timer, any data)
|
||||||
{
|
{
|
||||||
LogError("restart count reached: %i", map_switches_count);
|
|
||||||
int local = map_switches_count;
|
int local = map_switches_count;
|
||||||
map_switches_count++;
|
map_switches_count++;
|
||||||
|
LogError("restart count reached: %i. nextmap: %s", map_switches_count, g_cMaps[local]);
|
||||||
ForceChangeLevel(g_cMaps[local], "");
|
ForceChangeLevel(g_cMaps[local], "");
|
||||||
return Plugin_Handled;
|
return Plugin_Handled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
#include <sourcemod>
|
#include <sourcemod>
|
||||||
#include <dhooks>
|
#include <dhooks>
|
||||||
|
#include <mapchooser_extended>
|
||||||
|
|
||||||
//https://github.com/Mikusch/SM-TickrateChanger/tree/main
|
//https://github.com/Mikusch/SM-TickrateChanger/tree/main
|
||||||
|
|
||||||
@@ -70,19 +71,7 @@ public MRESReturn ChangeLevel(DHookReturn hReturn, DHookParam hParams)
|
|||||||
|
|
||||||
public void check_map(const char []map)
|
public void check_map(const char []map)
|
||||||
{
|
{
|
||||||
new Handle:fileHandle = OpenFile("maps_66_tick.txt", "r" );
|
bool changeTick = GetMapTickRate(map) == 0 ? false : true;
|
||||||
char lineBuffer[256];
|
|
||||||
bool changeTick = false;
|
|
||||||
while(!IsEndOfFile(fileHandle) && ReadFileLine(fileHandle, lineBuffer, sizeof(lineBuffer)))
|
|
||||||
{
|
|
||||||
TrimString(lineBuffer);
|
|
||||||
if (StrEqual(map, lineBuffer, false))
|
|
||||||
{
|
|
||||||
changeTick = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CloseHandle(fileHandle);
|
|
||||||
float interveral_per_tick_pre = GetConVarFloat(FindConVar("sm_interval_per_tick"));
|
float interveral_per_tick_pre = GetConVarFloat(FindConVar("sm_interval_per_tick"));
|
||||||
bool double_map_change = false;
|
bool double_map_change = false;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
replicate
|
|
||||||
requests
|
requests
|
||||||
Pillow
|
Pillow
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import time
|
|||||||
import base64
|
import base64
|
||||||
import re, json
|
import re, json
|
||||||
import os
|
import os
|
||||||
import replicate as replicate_client
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import io
|
import io
|
||||||
|
|
||||||
@@ -182,12 +181,40 @@ def send_claude_message(reacted_emotes, metrics, custom_emotes_json, attachment_
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
def replicate_fetch_image(image_prompt):
|
def replicate_fetch_image(image_prompt):
|
||||||
os.environ["REPLICATE_API_TOKEN"] = replicate_api_key
|
headers = {
|
||||||
output = replicate_client.run(
|
"Authorization": f"Bearer {replicate_api_key}",
|
||||||
"black-forest-labs/flux-schnell",
|
"Content-Type": "application/json"
|
||||||
input={"prompt": image_prompt}
|
}
|
||||||
|
resp = requests.post(
|
||||||
|
"https://api.replicate.com/v1/models/black-forest-labs/flux-schnell/predictions",
|
||||||
|
headers=headers,
|
||||||
|
json={"input": {"prompt": image_prompt}}
|
||||||
)
|
)
|
||||||
return output[0].read()
|
resp.raise_for_status()
|
||||||
|
prediction = resp.json()
|
||||||
|
get_url = prediction["urls"]["get"]
|
||||||
|
|
||||||
|
max_retries = 5
|
||||||
|
retry_count = 0
|
||||||
|
while prediction["status"] not in ("succeeded", "failed", "canceled"):
|
||||||
|
time.sleep(1)
|
||||||
|
r = requests.get(get_url, headers=headers)
|
||||||
|
if r.status_code >= 500:
|
||||||
|
retry_count += 1
|
||||||
|
print(f"Replicate poll got {r.status_code} (retry {retry_count}/{max_retries}): {r.text}")
|
||||||
|
if retry_count > max_retries:
|
||||||
|
r.raise_for_status()
|
||||||
|
continue
|
||||||
|
r.raise_for_status()
|
||||||
|
prediction = r.json()
|
||||||
|
retry_count = 0
|
||||||
|
|
||||||
|
if prediction["status"] != "succeeded":
|
||||||
|
raise RuntimeError(f"Replicate prediction failed: {prediction['status']} - {prediction.get('error')}")
|
||||||
|
|
||||||
|
output = prediction["output"]
|
||||||
|
image_url = output[0] if isinstance(output, list) else output
|
||||||
|
return requests.get(image_url).content
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
custom_emotes_json = get_custom_emotes()
|
custom_emotes_json = get_custom_emotes()
|
||||||
|
|||||||
@@ -26,4 +26,6 @@ Adapt config.json.
|
|||||||
##### Make sure you are in the virtualenv! (`. venv/bin/activate`)
|
##### Make sure you are in the virtualenv! (`. venv/bin/activate`)
|
||||||
Run: `python main.py`
|
Run: `python main.py`
|
||||||
|
|
||||||
Glacius was here. xd
|
/home/gameservers/.config/yt-dlp/config:
|
||||||
|
--proxy "http://127.0.0.1:1080"
|
||||||
|
--js-runtimes deno:/home/gameservers/.deno/bin/deno
|
||||||
|
|||||||
@@ -1,443 +1,352 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import math
|
import io
|
||||||
import time
|
import math
|
||||||
from .FFmpegAudioPlayer import FFmpegAudioPlayerFactory
|
from .FFmpegAudioPlayer import FFmpegAudioPlayerFactory
|
||||||
|
|
||||||
def _progress_delta(old_position, new_position, elapsed = None, jitter = 0.25):
|
class AudioPlayerFactory():
|
||||||
try:
|
AUDIOPLAYER_FFMPEG = 1
|
||||||
delta = abs(float(new_position) - float(old_position))
|
|
||||||
except (TypeError, ValueError):
|
def __init__(self, master):
|
||||||
return 0.0
|
self.Logger = logging.getLogger(__class__.__name__)
|
||||||
if not math.isfinite(delta):
|
self.Master = master
|
||||||
return 0.0
|
self.Torchlight = self.Master.Torchlight
|
||||||
if elapsed is not None:
|
|
||||||
try:
|
self.FFmpegAudioPlayerFactory = FFmpegAudioPlayerFactory(self)
|
||||||
elapsed = float(elapsed)
|
|
||||||
except (TypeError, ValueError):
|
def __del__(self):
|
||||||
return 0.0
|
self.Logger.info("~AudioPlayerFactory()")
|
||||||
if not math.isfinite(elapsed) or elapsed < 0.0:
|
|
||||||
return 0.0
|
def NewPlayer(self, _type):
|
||||||
delta = min(delta, elapsed + jitter)
|
if _type == self.AUDIOPLAYER_FFMPEG:
|
||||||
return delta
|
return self.FFmpegAudioPlayerFactory.NewPlayer()
|
||||||
|
|
||||||
|
|
||||||
class AudioPlayerFactory():
|
class AntiSpam():
|
||||||
AUDIOPLAYER_FFMPEG = 1
|
def __init__(self, master):
|
||||||
|
self.Logger = logging.getLogger(__class__.__name__)
|
||||||
def __init__(self, master):
|
self.Master = master
|
||||||
self.Logger = logging.getLogger(__class__.__name__)
|
self.Torchlight = self.Master.Torchlight
|
||||||
self.Master = master
|
|
||||||
self.Torchlight = self.Master.Torchlight
|
self.LastClips = dict()
|
||||||
|
self.DisabledTime = None
|
||||||
self.FFmpegAudioPlayerFactory = FFmpegAudioPlayerFactory(self)
|
self.SaidHint = False
|
||||||
|
|
||||||
def __del__(self):
|
def CheckAntiSpam(self, player):
|
||||||
self.Logger.info("~AudioPlayerFactory()")
|
if self.DisabledTime and self.DisabledTime > self.Torchlight().Master.Loop.time() and \
|
||||||
|
not (player.Access and player.Access["level"] >= self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]):
|
||||||
def NewPlayer(self, _type):
|
|
||||||
if _type == self.AUDIOPLAYER_FFMPEG:
|
self.Torchlight().SayPrivate(player, "Torchlight is currently on cooldown! ({0} seconds left)".format(
|
||||||
return self.FFmpegAudioPlayerFactory.NewPlayer()
|
math.ceil(self.DisabledTime - self.Torchlight().Master.Loop.time())))
|
||||||
raise ValueError("Unsupported audio player type: {0}".format(_type))
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
class AntiSpam():
|
|
||||||
GLOBAL_PLAY_TIME_IMMUNITY_LEVEL = 10
|
def SpamCheck(self, Delta):
|
||||||
|
Now = self.Torchlight().Master.Loop.time()
|
||||||
def __init__(self, master):
|
Duration = 0.0
|
||||||
self.Logger = logging.getLogger(__class__.__name__)
|
|
||||||
self.Master = master
|
for Key, Clip in list(self.LastClips.items()):
|
||||||
self.Torchlight = self.Master.Torchlight
|
if not Clip["timestamp"]:
|
||||||
|
continue
|
||||||
self.LastClips = dict()
|
|
||||||
self.DisabledTime = None
|
if Clip["timestamp"] + Clip["duration"] + self.Torchlight().Config["AntiSpam"]["MaxUsageSpan"] < Now:
|
||||||
|
if not Clip["active"]:
|
||||||
def _counts_toward_global_play_time(self, level):
|
del self.LastClips[Key]
|
||||||
return level < self.GLOBAL_PLAY_TIME_IMMUNITY_LEVEL
|
continue
|
||||||
|
|
||||||
def CheckAntiSpam(self, player):
|
Duration += Clip["duration"]
|
||||||
if self.DisabledTime and self.DisabledTime > self.Torchlight().Master.Loop.time() and \
|
|
||||||
not (player.Access and player.Access["level"] >= self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]):
|
if Duration > self.Torchlight().Config["AntiSpam"]["MaxUsageTime"]:
|
||||||
|
self.DisabledTime = self.Torchlight().Master.Loop.time() + self.Torchlight().Config["AntiSpam"]["PunishDelay"]
|
||||||
self.Torchlight().SayPrivate(player, "Torchlight is currently on cooldown! ({0} seconds left)".format(
|
self.Torchlight().SayChat("Blocked voice commands for the next {0} seconds. Used {1} seconds within {2} seconds.".format(
|
||||||
math.ceil(self.DisabledTime - self.Torchlight().Master.Loop.time())))
|
self.Torchlight().Config["AntiSpam"]["PunishDelay"], self.Torchlight().Config["AntiSpam"]["MaxUsageTime"], self.Torchlight().Config["AntiSpam"]["MaxUsageSpan"]))
|
||||||
return False
|
|
||||||
|
# Make a copy of the list since AudioClip.Stop() will change the list
|
||||||
return True
|
for AudioClip in self.Master.AudioClips[:]:
|
||||||
|
if AudioClip.Level < self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]:
|
||||||
def SpamCheck(self):
|
AudioClip.Stop()
|
||||||
Now = self.Torchlight().Master.Loop.time()
|
|
||||||
Duration = 0.0
|
self.LastClips.clear()
|
||||||
config = self.Torchlight().Config["AntiSpam"]
|
|
||||||
|
def OnPlay(self, clip):
|
||||||
for Key, Clip in list(self.LastClips.items()):
|
Now = self.Torchlight().Master.Loop.time()
|
||||||
if not Clip["timestamp"]:
|
self.LastClips[hash(clip)] = dict({"timestamp": Now, "duration": 0.0, "dominant": False, "active": True})
|
||||||
continue
|
|
||||||
|
HasDominant = False
|
||||||
if Clip["timestamp"] + Clip["duration"] + config["MaxUsageSpan"] < Now:
|
for Key, Clip in self.LastClips.items():
|
||||||
if not Clip["active"]:
|
if Clip["dominant"]:
|
||||||
del self.LastClips[Key]
|
HasDominant = True
|
||||||
continue
|
break
|
||||||
|
|
||||||
Duration += Clip["duration"]
|
self.LastClips[hash(clip)]["dominant"] = not HasDominant
|
||||||
|
|
||||||
if Duration > config["MaxUsageTime"]:
|
def OnStop(self, clip):
|
||||||
self.DisabledTime = self.Torchlight().Master.Loop.time() + config["PunishDelay"]
|
if hash(clip) not in self.LastClips:
|
||||||
self.Torchlight().SayChat("Blocked voice commands for the next {0} seconds. Used {1} seconds within {2} seconds.".format(
|
return
|
||||||
config["PunishDelay"], config["MaxUsageTime"], config["MaxUsageSpan"]))
|
|
||||||
|
self.LastClips[hash(clip)]["active"] = False
|
||||||
# Make a copy of the list since AudioClip.Stop() will change the list
|
|
||||||
for AudioClip in self.Master.AudioClips[:]:
|
if self.LastClips[hash(clip)]["dominant"]:
|
||||||
if AudioClip.Level < config["ImmunityLevel"]:
|
for Key, Clip in self.LastClips.items():
|
||||||
AudioClip.Stop()
|
if Clip["active"]:
|
||||||
|
Clip["dominant"] = True
|
||||||
self.LastClips.clear()
|
break
|
||||||
|
|
||||||
def OnPlay(self, clip):
|
self.LastClips[hash(clip)]["dominant"] = False
|
||||||
if not self._counts_toward_global_play_time(clip.Level):
|
|
||||||
return
|
def OnUpdate(self, clip, old_position, new_position):
|
||||||
|
Delta = new_position - old_position
|
||||||
Now = self.Torchlight().Master.Loop.time()
|
Clip = self.LastClips[hash(clip)]
|
||||||
self.LastClips[clip] = dict({"timestamp": Now, "duration": 0.0, "dominant": False, "active": True, "last_update_realtime": time.time()})
|
|
||||||
|
if not Clip["dominant"]:
|
||||||
HasDominant = False
|
return
|
||||||
for Key, Clip in self.LastClips.items():
|
|
||||||
if Clip["dominant"]:
|
Clip["duration"] += Delta
|
||||||
HasDominant = True
|
self.SpamCheck(Delta)
|
||||||
break
|
|
||||||
|
|
||||||
self.LastClips[clip]["dominant"] = not HasDominant
|
class Advertiser():
|
||||||
|
def __init__(self, master):
|
||||||
def OnStop(self, clip):
|
self.Logger = logging.getLogger(__class__.__name__)
|
||||||
if not self._counts_toward_global_play_time(clip.Level):
|
self.Master = master
|
||||||
return
|
self.Torchlight = self.Master.Torchlight
|
||||||
|
|
||||||
if clip not in self.LastClips:
|
self.LastClips = dict()
|
||||||
return
|
self.AdStop = 0
|
||||||
|
self.NextAdStop = 0
|
||||||
self.LastClips[clip]["active"] = False
|
|
||||||
|
def Think(self, Delta):
|
||||||
if self.LastClips[clip]["dominant"]:
|
Now = self.Torchlight().Master.Loop.time()
|
||||||
for Key, Clip in self.LastClips.items():
|
Duration = 0.0
|
||||||
if Clip["active"]:
|
|
||||||
Clip["dominant"] = True
|
for Key, Clip in list(self.LastClips.items()):
|
||||||
break
|
if not Clip["timestamp"]:
|
||||||
|
continue
|
||||||
self.LastClips[clip]["dominant"] = False
|
|
||||||
|
if Clip["timestamp"] + Clip["duration"] + self.Torchlight().Config["Advertiser"]["MaxSpan"] < Now:
|
||||||
def OnUpdate(self, clip, old_position, new_position):
|
if not Clip["active"]:
|
||||||
if not self._counts_toward_global_play_time(clip.Level):
|
del self.LastClips[Key]
|
||||||
return
|
continue
|
||||||
|
|
||||||
if clip not in self.LastClips:
|
Duration += Clip["duration"]
|
||||||
if self.Logger.isEnabledFor(logging.DEBUG):
|
|
||||||
self.Logger.debug(
|
self.NextAdStop -= Delta
|
||||||
"OnUpdate called for unknown clip key %r; %d known keys",
|
CeilDur = math.ceil(Duration)
|
||||||
clip,
|
if CeilDur > self.AdStop and self.NextAdStop <= 0 and CeilDur % self.Torchlight().Config["Advertiser"]["AdStop"] == 0:
|
||||||
len(self.LastClips),
|
self.Torchlight().SayChat("Hint: Type \x07FF0000!stop(ze) !pls(mg)\x01 to stop all currently playing sounds.")
|
||||||
)
|
self.AdStop = CeilDur
|
||||||
return
|
self.NextAdStop = 0
|
||||||
Clip = self.LastClips[clip]
|
elif CeilDur < self.AdStop:
|
||||||
now = time.time()
|
self.AdStop = 0
|
||||||
elapsed = None
|
self.NextAdStop = self.Torchlight().Config["Advertiser"]["AdStop"] / 2
|
||||||
if Clip.get("last_update_realtime") is not None:
|
|
||||||
elapsed = now - Clip["last_update_realtime"]
|
def OnPlay(self, clip):
|
||||||
Clip["last_update_realtime"] = now
|
Now = self.Torchlight().Master.Loop.time()
|
||||||
Delta = _progress_delta(old_position, new_position, elapsed = elapsed)
|
self.LastClips[hash(clip)] = dict({"timestamp": Now, "duration": 0.0, "dominant": False, "active": True})
|
||||||
|
|
||||||
if Delta <= 0.0 or not Clip["dominant"]:
|
HasDominant = False
|
||||||
return
|
for Key, Clip in self.LastClips.items():
|
||||||
|
if Clip["dominant"]:
|
||||||
Clip["duration"] += Delta
|
HasDominant = True
|
||||||
self.SpamCheck()
|
break
|
||||||
|
|
||||||
|
self.LastClips[hash(clip)]["dominant"] = not HasDominant
|
||||||
class Advertiser():
|
|
||||||
def __init__(self, master):
|
def OnStop(self, clip):
|
||||||
self.Logger = logging.getLogger(__class__.__name__)
|
if hash(clip) not in self.LastClips:
|
||||||
self.Master = master
|
return
|
||||||
self.Torchlight = self.Master.Torchlight
|
|
||||||
|
self.LastClips[hash(clip)]["active"] = False
|
||||||
self.LastClips = dict()
|
|
||||||
|
if self.LastClips[hash(clip)]["dominant"]:
|
||||||
def Think(self, clip):
|
for Key, Clip in self.LastClips.items():
|
||||||
Now = self.Torchlight().Master.Loop.time()
|
if Clip["active"]:
|
||||||
config = self.Torchlight().Config["Advertiser"]
|
Clip["dominant"] = True
|
||||||
|
break
|
||||||
for Key, Clip in list(self.LastClips.items()):
|
|
||||||
if not Clip["timestamp"]:
|
self.LastClips[hash(clip)]["dominant"] = False
|
||||||
continue
|
|
||||||
|
def OnUpdate(self, clip, old_position, new_position):
|
||||||
if Clip["timestamp"] + Clip["duration"] + config["MaxSpan"] < Now:
|
Delta = new_position - old_position
|
||||||
if not Clip["active"]:
|
Clip = self.LastClips[hash(clip)]
|
||||||
del self.LastClips[Key]
|
|
||||||
continue
|
if not Clip["dominant"]:
|
||||||
|
return
|
||||||
|
|
||||||
Clip = self.LastClips.get(clip)
|
Clip["duration"] += Delta
|
||||||
if not Clip:
|
self.Think(Delta)
|
||||||
return
|
|
||||||
|
|
||||||
if not clip.StopHinted and not Clip.get("hinted") and Clip["duration"] >= config["AdStop"]:
|
class AudioManager():
|
||||||
self.Torchlight().SayChat("Hint: Type \x07FF0000!stop(ze) !pls(mg)\x01 to stop all currently playing sounds.")
|
def __init__(self, torchlight):
|
||||||
Clip["hinted"] = True
|
self.Logger = logging.getLogger(__class__.__name__)
|
||||||
clip.StopHinted = True
|
self.Torchlight = torchlight
|
||||||
|
self.AntiSpam = AntiSpam(self)
|
||||||
def OnPlay(self, clip):
|
self.Advertiser = Advertiser(self)
|
||||||
Now = self.Torchlight().Master.Loop.time()
|
self.AudioPlayerFactory = AudioPlayerFactory(self)
|
||||||
self.LastClips[clip] = dict({"timestamp": Now, "duration": 0.0, "dominant": False, "active": True, "hinted": False, "last_update_realtime": time.time()})
|
self.AudioClips = []
|
||||||
|
|
||||||
HasDominant = False
|
def __del__(self):
|
||||||
for Key, Clip in self.LastClips.items():
|
self.Logger.info("~AudioManager()")
|
||||||
if Clip["dominant"]:
|
|
||||||
HasDominant = True
|
def CheckLimits(self, player):
|
||||||
break
|
Level = 0
|
||||||
|
if player.Access:
|
||||||
self.LastClips[clip]["dominant"] = not HasDominant
|
Level = player.Access["level"]
|
||||||
|
|
||||||
def OnStop(self, clip):
|
if str(Level) in self.Torchlight().Config["AudioLimits"]:
|
||||||
if clip not in self.LastClips:
|
if self.Torchlight().Config["AudioLimits"][str(Level)]["Uses"] >= 0 and \
|
||||||
return
|
player.Storage["Audio"]["Uses"] >= self.Torchlight().Config["AudioLimits"][str(Level)]["Uses"]:
|
||||||
|
|
||||||
self.LastClips[clip]["active"] = False
|
self.Torchlight().SayPrivate(player, "You have used up all of your free uses! ({0} uses)".format(
|
||||||
|
self.Torchlight().Config["AudioLimits"][str(Level)]["Uses"]))
|
||||||
if self.LastClips[clip]["dominant"]:
|
return False
|
||||||
for Key, Clip in self.LastClips.items():
|
|
||||||
if Clip["active"]:
|
if player.Storage["Audio"]["TimeUsed"] >= self.Torchlight().Config["AudioLimits"][str(Level)]["TotalTime"]:
|
||||||
Clip["dominant"] = True
|
self.Torchlight().SayPrivate(player, "You have used up all of your free time! ({0} seconds)".format(
|
||||||
break
|
self.Torchlight().Config["AudioLimits"][str(Level)]["TotalTime"]))
|
||||||
|
return False
|
||||||
self.LastClips[clip]["dominant"] = False
|
|
||||||
|
TimeElapsed = self.Torchlight().Master.Loop.time() - player.Storage["Audio"]["LastUse"]
|
||||||
def OnUpdate(self, clip, old_position, new_position):
|
UseDelay = player.Storage["Audio"]["LastUseLength"] * self.Torchlight().Config["AudioLimits"][str(Level)]["DelayFactor"]
|
||||||
if clip not in self.LastClips:
|
|
||||||
return
|
if TimeElapsed < UseDelay:
|
||||||
Clip = self.LastClips[clip]
|
self.Torchlight().SayPrivate(player, "You are currently on cooldown! ({0} seconds left)".format(
|
||||||
now = time.time()
|
round(UseDelay - TimeElapsed)))
|
||||||
elapsed = None
|
return False
|
||||||
if Clip.get("last_update_realtime") is not None:
|
|
||||||
elapsed = now - Clip["last_update_realtime"]
|
return True
|
||||||
Clip["last_update_realtime"] = now
|
|
||||||
Delta = _progress_delta(old_position, new_position, elapsed = elapsed)
|
def Stop(self, player, extra):
|
||||||
|
Level = 0
|
||||||
if Delta <= 0.0 or not Clip["dominant"]:
|
if player.Access:
|
||||||
return
|
Level = player.Access["level"]
|
||||||
|
|
||||||
Clip["duration"] += Delta
|
for AudioClip in self.AudioClips[:]:
|
||||||
self.Think(clip)
|
if extra and not extra.lower() in AudioClip.Player.Name.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
class AudioManager():
|
if not Level or (Level < AudioClip.Level and Level < self.Torchlight().Config["AntiSpam"]["StopLevel"]):
|
||||||
def __init__(self, torchlight):
|
AudioClip.Stops.add(player.UserID)
|
||||||
self.Logger = logging.getLogger(__class__.__name__)
|
|
||||||
self.Torchlight = torchlight
|
if len(AudioClip.Stops) >= 3:
|
||||||
self.AntiSpam = AntiSpam(self)
|
AudioClip.Stop()
|
||||||
self.Advertiser = Advertiser(self)
|
self.Torchlight().SayPrivate(AudioClip.Player, "Your audio clip was stopped.")
|
||||||
self.AudioPlayerFactory = AudioPlayerFactory(self)
|
if player != AudioClip.Player:
|
||||||
self.AudioClips = []
|
self.Torchlight().SayPrivate(player, "Stopped \"{0}\"({1}) audio clip.".format(AudioClip.Player.Name, AudioClip.Player.UserID))
|
||||||
|
else:
|
||||||
def __del__(self):
|
self.Torchlight().SayPrivate(player, "This audio clip needs {0} more !stop's.".format(3 - len(AudioClip.Stops)))
|
||||||
self.Logger.info("~AudioManager()")
|
else:
|
||||||
|
AudioClip.Stop()
|
||||||
def CheckLimits(self, player):
|
self.Torchlight().SayPrivate(AudioClip.Player, "Your audio clip was stopped.")
|
||||||
Level = 0
|
if player != AudioClip.Player:
|
||||||
if player.Access:
|
self.Torchlight().SayPrivate(player, "Stopped \"{0}\"({1}) audio clip.".format(AudioClip.Player.Name, AudioClip.Player.UserID))
|
||||||
Level = player.Access["level"]
|
|
||||||
|
def AudioClip(self, player, uri, _type = AudioPlayerFactory.AUDIOPLAYER_FFMPEG):
|
||||||
config = self.Torchlight().Config["AudioLimits"]
|
Level = 0
|
||||||
if str(Level) in config:
|
if player.Access:
|
||||||
level_config = config[str(Level)]
|
Level = player.Access["level"]
|
||||||
if level_config["Uses"] >= 0 and \
|
|
||||||
player.Storage["Audio"]["Uses"] >= level_config["Uses"]:
|
if self.Torchlight().Disabled and self.Torchlight().Disabled > Level:
|
||||||
|
self.Torchlight().SayPrivate(player, "Torchlight is currently disabled!")
|
||||||
self.Torchlight().SayPrivate(player, "You have used up all of your free uses! ({0} uses)".format(
|
return None
|
||||||
level_config["Uses"]))
|
|
||||||
return False
|
if not self.AntiSpam.CheckAntiSpam(player):
|
||||||
|
return None
|
||||||
if player.Storage["Audio"]["TimeUsed"] >= level_config["TotalTime"]:
|
|
||||||
self.Torchlight().SayPrivate(player, "You have used up all of your free time! ({0} seconds)".format(
|
if not self.CheckLimits(player):
|
||||||
level_config["TotalTime"]))
|
return None
|
||||||
return False
|
|
||||||
|
Clip = AudioClip(self, player, uri, _type)
|
||||||
TimeElapsed = self.Torchlight().Master.Loop.time() - player.Storage["Audio"]["LastUse"]
|
self.AudioClips.append(Clip)
|
||||||
UseDelay = player.Storage["Audio"]["LastUseLength"] * level_config["DelayFactor"]
|
|
||||||
|
if not player.Access or player.Access["level"] < self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]:
|
||||||
if TimeElapsed < UseDelay:
|
Clip.AudioPlayer.AddCallback("Play", lambda *args: self.AntiSpam.OnPlay(Clip, *args))
|
||||||
self.Torchlight().SayPrivate(player, "You are currently on cooldown! ({0} seconds left)".format(
|
Clip.AudioPlayer.AddCallback("Stop", lambda *args: self.AntiSpam.OnStop(Clip, *args))
|
||||||
round(UseDelay - TimeElapsed)))
|
Clip.AudioPlayer.AddCallback("Update", lambda *args: self.AntiSpam.OnUpdate(Clip, *args))
|
||||||
return False
|
|
||||||
|
Clip.AudioPlayer.AddCallback("Play", lambda *args: self.Advertiser.OnPlay(Clip, *args))
|
||||||
return True
|
Clip.AudioPlayer.AddCallback("Stop", lambda *args: self.Advertiser.OnStop(Clip, *args))
|
||||||
|
Clip.AudioPlayer.AddCallback("Update", lambda *args: self.Advertiser.OnUpdate(Clip, *args))
|
||||||
def Stop(self, player, extra):
|
|
||||||
Result = {"active": 0, "matched": 0, "stopped": 0, "pending": 0}
|
return Clip
|
||||||
stopper_level = player.Access["level"] if player.Access else 0
|
|
||||||
|
def OnDisconnect(self, player):
|
||||||
for AudioClip in self.AudioClips[:]:
|
for AudioClip in self.AudioClips[:]:
|
||||||
Result["active"] += 1
|
if AudioClip.Player == player:
|
||||||
target_level = AudioClip.Player.Access["level"] if AudioClip.Player.Access else 0
|
AudioClip.Stop()
|
||||||
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
|
class AudioClip():
|
||||||
elif getattr(player, "UserID", None) is not None and getattr(AudioClip.Player, "UserID", None) is not None:
|
def __init__(self, master, player, uri, _type):
|
||||||
same_player = player.UserID == AudioClip.Player.UserID
|
self.Logger = logging.getLogger(__class__.__name__)
|
||||||
else:
|
self.Master = master
|
||||||
same_player = player == AudioClip.Player
|
self.Torchlight = self.Master.Torchlight
|
||||||
|
self.Player = player
|
||||||
if not same_player and target_level > stopper_level:
|
self.Type = _type
|
||||||
Result["pending"] += 1
|
self.URI = uri
|
||||||
continue
|
self.LastPosition = None
|
||||||
Result["matched"] += 1
|
self.Stops = set()
|
||||||
|
|
||||||
AudioClip.Stop()
|
self.Level = 0
|
||||||
Result["stopped"] += 1
|
if self.Player.Access:
|
||||||
self.Torchlight().SayPrivate(AudioClip.Player, "Your audio clip was stopped.")
|
self.Level = self.Player.Access["level"]
|
||||||
if not same_player:
|
|
||||||
self.Torchlight().SayPrivate(player, "Stopped \"{0}\"({1}) audio clip.".format(AudioClip.Player.Name, AudioClip.Player.UserID))
|
self.AudioPlayer = self.Master.AudioPlayerFactory.NewPlayer(self.Type)
|
||||||
|
self.AudioPlayer.AddCallback("Play", self.OnPlay)
|
||||||
return Result
|
self.AudioPlayer.AddCallback("Stop", self.OnStop)
|
||||||
|
self.AudioPlayer.AddCallback("Update", self.OnUpdate)
|
||||||
def CreateAudioClip(self, player, uri, _type = AudioPlayerFactory.AUDIOPLAYER_FFMPEG):
|
|
||||||
Level = 0
|
def __del__(self):
|
||||||
if player.Access:
|
self.Logger.info("~AudioClip()")
|
||||||
Level = player.Access["level"]
|
|
||||||
|
def Play(self, seconds = None, rubberband = None, dec_params = None, bitrate = None, backwards = None, *args):
|
||||||
if self.Torchlight().Disabled and self.Torchlight().Disabled > Level:
|
return self.AudioPlayer.PlayURI(self.URI, position = seconds, rubberband = rubberband, dec_params = dec_params, bitrate = bitrate,
|
||||||
self.Torchlight().SayPrivate(player, "Torchlight is currently disabled!")
|
backwards = backwards, *args)
|
||||||
return None
|
|
||||||
|
def Stop(self):
|
||||||
if not self.AntiSpam.CheckAntiSpam(player):
|
return self.AudioPlayer.Stop()
|
||||||
return None
|
|
||||||
|
def OnPlay(self):
|
||||||
if not self.CheckLimits(player):
|
self.Logger.debug(sys._getframe().f_code.co_name + ' ' + self.URI)
|
||||||
return None
|
|
||||||
|
self.Player.Storage["Audio"]["Uses"] += 1
|
||||||
try:
|
self.Player.Storage["Audio"]["LastUse"] = self.Torchlight().Master.Loop.time()
|
||||||
Clip = AudioClip(self, player, uri, _type)
|
self.Player.Storage["Audio"]["LastUseLength"] = 0.0
|
||||||
except ValueError as ex:
|
|
||||||
self.Logger.warning("Rejected unsupported audio player type %r: %s", _type, ex)
|
def OnStop(self):
|
||||||
self.Torchlight().SayPrivate(player, "Unsupported audio player type.")
|
self.Logger.debug(sys._getframe().f_code.co_name + ' ' + self.URI)
|
||||||
return None
|
self.Master.AudioClips.remove(self)
|
||||||
self.AudioClips.append(Clip)
|
|
||||||
|
if self.AudioPlayer.Playing:
|
||||||
anti_spam_track_ceiling = min(
|
Delta = self.AudioPlayer.Position - self.LastPosition
|
||||||
self.Torchlight().Config["AntiSpam"]["ImmunityLevel"],
|
self.Player.Storage["Audio"]["TimeUsed"] += Delta
|
||||||
self.AntiSpam.GLOBAL_PLAY_TIME_IMMUNITY_LEVEL,
|
self.Player.Storage["Audio"]["LastUseLength"] += Delta
|
||||||
)
|
|
||||||
if not player.Access or player.Access["level"] < anti_spam_track_ceiling:
|
if str(self.Level) in self.Torchlight().Config["AudioLimits"]:
|
||||||
Clip.AudioPlayer.AddCallback("Play", lambda *args: self.AntiSpam.OnPlay(Clip, *args))
|
if self.Player.Storage:
|
||||||
Clip.AudioPlayer.AddCallback("Stop", lambda *args: self.AntiSpam.OnStop(Clip, *args))
|
if self.Player.Storage["Audio"]["TimeUsed"] >= self.Torchlight().Config["AudioLimits"][str(self.Level)]["TotalTime"]:
|
||||||
Clip.AudioPlayer.AddCallback("Update", lambda *args: self.AntiSpam.OnUpdate(Clip, *args))
|
self.Torchlight().SayPrivate(self.Player, "You have used up all of your free time! ({0} seconds)".format(
|
||||||
|
self.Torchlight().Config["AudioLimits"][str(self.Level)]["TotalTime"]))
|
||||||
Clip.AudioPlayer.AddCallback("Play", lambda *args: self.Advertiser.OnPlay(Clip, *args))
|
elif self.Player.Storage["Audio"]["LastUseLength"] >= self.Torchlight().Config["AudioLimits"][str(self.Level)]["MaxLength"]:
|
||||||
Clip.AudioPlayer.AddCallback("Stop", lambda *args: self.Advertiser.OnStop(Clip, *args))
|
self.Torchlight().SayPrivate(self.Player, "Your audio clip exceeded the maximum length! ({0} seconds)".format(
|
||||||
Clip.AudioPlayer.AddCallback("Update", lambda *args: self.Advertiser.OnUpdate(Clip, *args))
|
self.Torchlight().Config["AudioLimits"][str(self.Level)]["MaxLength"]))
|
||||||
|
|
||||||
return Clip
|
del self.AudioPlayer
|
||||||
|
|
||||||
def AudioClip(self, player, uri, _type = AudioPlayerFactory.AUDIOPLAYER_FFMPEG):
|
def OnUpdate(self, old_position, new_position):
|
||||||
return self.CreateAudioClip(player, uri, _type)
|
Delta = new_position - old_position
|
||||||
|
self.LastPosition = new_position
|
||||||
def OnDisconnect(self, player):
|
|
||||||
for AudioClip in self.AudioClips[:]:
|
self.Player.Storage["Audio"]["TimeUsed"] += Delta
|
||||||
if AudioClip.Player == player:
|
self.Player.Storage["Audio"]["LastUseLength"] += Delta
|
||||||
AudioClip.Stop()
|
|
||||||
|
if not str(self.Level) in self.Torchlight().Config["AudioLimits"]:
|
||||||
|
return
|
||||||
class AudioClip():
|
|
||||||
def __init__(self, master, player, uri, _type):
|
if (self.Player.Storage["Audio"]["TimeUsed"] >= self.Torchlight().Config["AudioLimits"][str(self.Level)]["TotalTime"] or
|
||||||
self.Logger = logging.getLogger(__class__.__name__)
|
self.Player.Storage["Audio"]["LastUseLength"] >= self.Torchlight().Config["AudioLimits"][str(self.Level)]["MaxLength"]):
|
||||||
self.Master = master
|
self.Stop()
|
||||||
self.Torchlight = self.Master.Torchlight
|
|
||||||
self.Player = player
|
|
||||||
self.Type = _type
|
|
||||||
self.URI = uri
|
|
||||||
self.LastPosition = None
|
|
||||||
self.LastUpdateRealtime = None
|
|
||||||
self.StartedAt = None
|
|
||||||
self.StopHinted = False
|
|
||||||
self.Stops = set()
|
|
||||||
|
|
||||||
self.Level = 0
|
|
||||||
if self.Player.Access:
|
|
||||||
self.Level = self.Player.Access["level"]
|
|
||||||
|
|
||||||
self.AudioPlayer = self.Master.AudioPlayerFactory.NewPlayer(self.Type)
|
|
||||||
self.AudioPlayer.AddCallback("Play", self.OnPlay)
|
|
||||||
self.AudioPlayer.AddCallback("Stop", self.OnStop)
|
|
||||||
self.AudioPlayer.AddCallback("Update", self.OnUpdate)
|
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
self.Logger.info("~AudioClip()")
|
|
||||||
|
|
||||||
def Play(self, seconds = None, rubberband = None, dec_params = None, bitrate = None, backwards = None, *args):
|
|
||||||
return self.AudioPlayer.PlayURI(self.URI, position = seconds, rubberband = rubberband, dec_params = dec_params, bitrate = bitrate,
|
|
||||||
backwards = backwards, *args)
|
|
||||||
|
|
||||||
def Stop(self):
|
|
||||||
return self.AudioPlayer.Stop()
|
|
||||||
|
|
||||||
def OnPlay(self):
|
|
||||||
self.Logger.debug(sys._getframe().f_code.co_name + ' ' + self.URI)
|
|
||||||
|
|
||||||
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 str(self.Level) in self.Torchlight().Config["AudioLimits"]:
|
|
||||||
if self.Player.Storage:
|
|
||||||
if self.Player.Storage["Audio"]["TimeUsed"] >= self.Torchlight().Config["AudioLimits"][str(self.Level)]["TotalTime"]:
|
|
||||||
self.Torchlight().SayPrivate(self.Player, "You have used up all of your free time! ({0} seconds)".format(
|
|
||||||
self.Torchlight().Config["AudioLimits"][str(self.Level)]["TotalTime"]))
|
|
||||||
elif self.Player.Storage["Audio"]["LastUseLength"] >= self.Torchlight().Config["AudioLimits"][str(self.Level)]["MaxLength"]:
|
|
||||||
self.Torchlight().SayPrivate(self.Player, "Your audio clip exceeded the maximum length! ({0} seconds)".format(
|
|
||||||
self.Torchlight().Config["AudioLimits"][str(self.Level)]["MaxLength"]))
|
|
||||||
|
|
||||||
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)
|
|
||||||
self.LastPosition = new_position
|
|
||||||
|
|
||||||
if Delta <= 0.0:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.Player.Storage["Audio"]["TimeUsed"] += Delta
|
|
||||||
self.Player.Storage["Audio"]["LastUseLength"] += Delta
|
|
||||||
|
|
||||||
if not str(self.Level) in self.Torchlight().Config["AudioLimits"]:
|
|
||||||
return
|
|
||||||
|
|
||||||
if (self.Player.Storage["Audio"]["TimeUsed"] >= self.Torchlight().Config["AudioLimits"][str(self.Level)]["TotalTime"] or
|
|
||||||
self.Player.Storage["Audio"]["LastUseLength"] >= self.Torchlight().Config["AudioLimits"][str(self.Level)]["MaxLength"]):
|
|
||||||
self.Stop()
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class CommandHandler():
|
|||||||
Ret = await Command._rfunc(line, RMatch, player)
|
Ret = await Command._rfunc(line, RMatch, player)
|
||||||
else:
|
else:
|
||||||
#self.Logger.debug(f"_rfunc line: {line}")
|
#self.Logger.debug(f"_rfunc line: {line}")
|
||||||
if line.startswith("!yt"): #transfering the line if its !yt or !yts in case people want to include pitch or tempo.
|
if line.lower().startswith("!yt"): #transfering the line if its !yt or !yts in case people want to include pitch or tempo. (case-insensitive: trigger matching itself is case-insensitive, so this must be too)
|
||||||
Ret = await Command._func(Message, player, line)
|
Ret = await Command._func(Message, player, line)
|
||||||
else:
|
else:
|
||||||
Ret = await Command._func(Message, player)
|
Ret = await Command._func(Message, player)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,268 +1,334 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import time
|
import time
|
||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
SAMPLEBYTES = 2
|
|
||||||
|
SAMPLEBYTES = 2
|
||||||
class FFmpegAudioPlayerFactory():
|
|
||||||
VALID_CALLBACKS = ["Play", "Stop", "Update"]
|
# 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)
|
||||||
def __init__(self, master):
|
# is tied to the specific request that generated it, and a *separate* ffmpeg
|
||||||
self.Logger = logging.getLogger(__class__.__name__)
|
# process making its own connection -- even through the same proxy, even with
|
||||||
self.Master = master
|
# matching headers copied from the --dump-json output -- still got HTTP 403
|
||||||
self.Torchlight = self.Master.Torchlight
|
# from Google. Matching proxy + headers wasn't enough because the header set
|
||||||
|
# also drifts (yt-dlp's impersonated Chrome build number alone changed
|
||||||
def __del__(self):
|
# between two of our test runs a few minutes apart), so hardcoding it is a
|
||||||
self.Master.Logger.info("~FFmpegAudioPlayerFactory()")
|
# losing game.
|
||||||
self.Quit()
|
#
|
||||||
|
# The reliable fix is to stop trying to replay yt-dlp's signed URL from a
|
||||||
def NewPlayer(self):
|
# second process at all: let yt-dlp itself fetch the media (same process,
|
||||||
self.Logger.debug(sys._getframe().f_code.co_name)
|
# same session, same proxy/headers it used to resolve the URL) and stream its
|
||||||
Player = FFmpegAudioPlayer(self)
|
# output straight into ffmpeg's stdin via a pipe. URIs that should be played
|
||||||
return Player
|
# this way are prefixed with YTDLP_URI_PREFIX by Commands.py.
|
||||||
|
YTDLP_URI_PREFIX = "ytdlp:"
|
||||||
def Quit(self):
|
|
||||||
self.Master.Logger.info("FFmpegAudioPlayerFactory->Quit()")
|
class FFmpegAudioPlayerFactory():
|
||||||
|
VALID_CALLBACKS = ["Play", "Stop", "Update"]
|
||||||
|
|
||||||
class FFmpegAudioPlayer():
|
def __init__(self, master):
|
||||||
def __init__(self, master):
|
self.Logger = logging.getLogger(__class__.__name__)
|
||||||
self.Master = master
|
self.Master = master
|
||||||
self.Torchlight = self.Master.Torchlight
|
self.Torchlight = self.Master.Torchlight
|
||||||
self.Playing = False
|
|
||||||
|
def __del__(self):
|
||||||
self.Host = (
|
self.Master.Logger.info("~FFmpegAudioPlayerFactory()")
|
||||||
self.Torchlight().Config["VoiceServer"]["Host"],
|
self.Quit()
|
||||||
self.Torchlight().Config["VoiceServer"]["Port"]
|
|
||||||
)
|
def NewPlayer(self):
|
||||||
self.SampleRate = float(self.Torchlight().Config["VoiceServer"]["SampleRate"])
|
self.Logger.debug(sys._getframe().f_code.co_name)
|
||||||
self.Channels = int(self.Torchlight().Config["VoiceServer"].get("Channels", 2))
|
Player = FFmpegAudioPlayer(self)
|
||||||
|
return Player
|
||||||
self.StartedPlaying = None
|
|
||||||
self.StoppedPlaying = None
|
def Quit(self):
|
||||||
self.Seconds = 0.0
|
self.Master.Logger.info("FFmpegAudioPlayerFactory->Quit()")
|
||||||
|
|
||||||
self.Writer = None
|
|
||||||
self.Process = None
|
class FFmpegAudioPlayer():
|
||||||
|
def __init__(self, master):
|
||||||
self.Callbacks = []
|
self.Master = master
|
||||||
|
self.Torchlight = self.Master.Torchlight
|
||||||
def __del__(self):
|
self.Playing = False
|
||||||
self.Master.Logger.debug("~FFmpegAudioPlayer()")
|
|
||||||
self.Stop()
|
self.Host = (
|
||||||
|
self.Torchlight().Config["VoiceServer"]["Host"],
|
||||||
def PlayURI(self, uri, position, rubberband = None, dec_params = None, bitrate = None,
|
self.Torchlight().Config["VoiceServer"]["Port"]
|
||||||
backwards = None, *args):
|
)
|
||||||
if self.Playing:
|
self.SampleRate = float(self.Torchlight().Config["VoiceServer"]["SampleRate"])
|
||||||
self.Stop()
|
|
||||||
|
self.StartedPlaying = None
|
||||||
proxy_url = ""
|
self.StoppedPlaying = None
|
||||||
if uri.startswith("http://") or uri.startswith("https://"):
|
self.Seconds = 0.0
|
||||||
proxy_url = self.Torchlight().Config["Proxy"]
|
|
||||||
|
self.Writer = None
|
||||||
if position:
|
self.Process = None
|
||||||
PosStr = str(datetime.timedelta(seconds=position))
|
# Only set in ytdlp-pipe mode (see PlayURI): the upstream yt-dlp
|
||||||
Command = ["/usr/bin/ffmpeg"]
|
# process feeding ffmpeg's stdin. Needs to be killed alongside
|
||||||
if proxy_url:
|
# self.Process on Stop(), or it lingers as an orphaned process.
|
||||||
Command += ["-http_proxy", proxy_url]
|
self.SourceProcess = None
|
||||||
Command += ["-ss", PosStr, "-i", uri, "-acodec", "pcm_s16le", "-ac", str(self.Channels), "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn"]
|
|
||||||
else:
|
self.Callbacks = []
|
||||||
Command = ["/usr/bin/ffmpeg"]
|
|
||||||
if proxy_url:
|
def __del__(self):
|
||||||
Command += ["-http_proxy", proxy_url]
|
self.Master.Logger.debug("~FFmpegAudioPlayer()")
|
||||||
Command += ["-i", uri, "-acodec", "pcm_s16le", "-ac", str(self.Channels), "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn"]
|
self.Stop()
|
||||||
|
|
||||||
if args:
|
def PlayURI(self, uri, position, rubberband = None, dec_params = None, bitrate = None,
|
||||||
Command += list(args)
|
backwards = None, *args):
|
||||||
|
IsYtdlp = uri.startswith(YTDLP_URI_PREFIX)
|
||||||
# Reset per-play counters/state in case this instance is reused.
|
# Commands.py may append a "#t=..." fragment to the URI purely so a
|
||||||
self.Seconds = 0.0
|
# downstream handler can re-parse it into a start position (see
|
||||||
self.StartedPlaying = None
|
# URLFilter.URLInfo) -- strip it back off here since the actual seek
|
||||||
self.StoppedPlaying = None
|
# already happens via `position`/"-ss" below, and yt-dlp doesn't need
|
||||||
self._byte_remainder = 0
|
# (or want) an arbitrary fragment tacked onto the watch URL.
|
||||||
self.Playing = True
|
SourceUrl = uri[len(YTDLP_URI_PREFIX):].split("#")[0] if IsYtdlp else None
|
||||||
|
|
||||||
if dec_params:
|
if IsYtdlp:
|
||||||
Command += dec_params
|
# Input is a pipe (yt-dlp's stdout), which isn't seekable, so a
|
||||||
|
# requested start position has to be an *output*-side "-ss"
|
||||||
filter_chain = ["highpass=f=20"]
|
# (placed after "-i") rather than the normal fast input-side
|
||||||
if rubberband:
|
# seek. Slower (ffmpeg decodes and discards up to that point
|
||||||
# Treat rubberband as either a single pre-built filtergraph string
|
# instead of jumping straight there) but correct.
|
||||||
# or an iterable of simple, comma-safe filters. Validate to avoid
|
Command = ["/usr/bin/ffmpeg", "-i", "pipe:0"]
|
||||||
# generating invalid ffmpeg filter syntax.
|
if position:
|
||||||
rubberband_filters = []
|
Command += ["-ss", str(datetime.timedelta(seconds = position))]
|
||||||
if isinstance(rubberband, str):
|
Command += ["-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args]
|
||||||
rubberband_filters = [rubberband]
|
elif position:
|
||||||
else:
|
PosStr = str(datetime.timedelta(seconds = position))
|
||||||
try:
|
Command = ["/usr/bin/ffmpeg", "-ss", PosStr, "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args]
|
||||||
iterator = iter(rubberband)
|
else:
|
||||||
except TypeError:
|
Command = ["/usr/bin/ffmpeg", "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args]
|
||||||
# Not iterable: coerce to string and treat as single element.
|
|
||||||
rubberband_filters = [str(rubberband)]
|
self.Playing = True
|
||||||
else:
|
if dec_params:
|
||||||
for f in iterator:
|
Command += dec_params
|
||||||
if not isinstance(f, str):
|
|
||||||
f = str(f)
|
if rubberband and backwards:
|
||||||
# Reject potentially complex/unsafe filter fragments that
|
Command += ["-filter:a"]
|
||||||
# could break the filtergraph when joined with commas.
|
rubberCommand = ""
|
||||||
if any(ch in f for ch in [",", ";", "[", "]"]):
|
for rubber in rubberband:
|
||||||
self.Master.Logger.error(
|
rubberCommand += rubber + ", "
|
||||||
"Skipping unsafe rubberband filter fragment %r: "
|
rubberCommand = rubberCommand[:-2]
|
||||||
"contains ',', ';', '[' or ']'", f
|
Command += [rubberCommand + "[reversed];[reversed]areverse"] #[reversed] is intermediate stream label so reverse knows what stream label to reverse
|
||||||
)
|
else:
|
||||||
continue
|
if rubberband:
|
||||||
rubberband_filters.append(f)
|
Command += ["-filter:a"]
|
||||||
filter_chain.extend(rubberband_filters)
|
rubberCommand = ""
|
||||||
if backwards:
|
for rubber in rubberband:
|
||||||
filter_chain.append("areverse")
|
rubberCommand += rubber + ", "
|
||||||
Command += ["-filter:a", ",".join(filter_chain)]
|
rubberCommand = rubberCommand[:-2]
|
||||||
if bitrate:
|
Command += [rubberCommand]
|
||||||
Command += ["-b:a", f"{bitrate}k"]
|
if backwards:
|
||||||
self.Master.Logger.debug(f"command: {Command}")
|
Command += ["-af"]
|
||||||
Command += ["-"]
|
Command += ["areverse"]
|
||||||
#self.Master.Logger.debug(f"command: {Command}")
|
if bitrate:
|
||||||
asyncio.ensure_future(self._stream_subprocess(Command))
|
Command += ["-ab ", str(bitrate), "k"]
|
||||||
return True
|
self.Master.Logger.debug(f"command: {Command}")
|
||||||
|
Command += ["-"]
|
||||||
def Stop(self, force = True):
|
#self.Master.Logger.debug(f"command: {Command}")
|
||||||
if not self.Playing:
|
asyncio.ensure_future(self._stream_subprocess(Command, SourceUrl))
|
||||||
return False
|
return True
|
||||||
|
|
||||||
if self.Process:
|
def Stop(self, force = True):
|
||||||
try:
|
if not self.Playing:
|
||||||
self.Process.terminate()
|
return False
|
||||||
self.Process.kill()
|
|
||||||
self.Process = None
|
if self.Process:
|
||||||
except ProcessLookupError:
|
try:
|
||||||
pass
|
self.Process.terminate()
|
||||||
|
self.Process.kill()
|
||||||
if self.Writer:
|
self.Process = None
|
||||||
if force:
|
except ProcessLookupError:
|
||||||
Socket = self.Writer.transport.get_extra_info("socket")
|
pass
|
||||||
if Socket:
|
|
||||||
Socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
|
if self.SourceProcess:
|
||||||
struct.pack("ii", 1, 0))
|
try:
|
||||||
|
self.SourceProcess.terminate()
|
||||||
self.Writer.transport.abort()
|
self.SourceProcess.kill()
|
||||||
|
self.SourceProcess = None
|
||||||
self.Writer.close()
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
self.Playing = False
|
|
||||||
|
if self.Writer:
|
||||||
self.Callback("Stop")
|
if force:
|
||||||
self.Callbacks = []
|
Socket = self.Writer.transport.get_extra_info("socket")
|
||||||
|
if Socket:
|
||||||
return True
|
Socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
|
||||||
|
struct.pack("ii", 1, 0))
|
||||||
def AddCallback(self, cbtype, cbfunc):
|
|
||||||
if not cbtype in FFmpegAudioPlayerFactory.VALID_CALLBACKS:
|
self.Writer.transport.abort()
|
||||||
return False
|
|
||||||
|
self.Writer.close()
|
||||||
self.Callbacks.append((cbtype, cbfunc))
|
|
||||||
return True
|
self.Playing = False
|
||||||
|
|
||||||
def Callback(self, cbtype, *args, **kwargs):
|
self.Callback("Stop")
|
||||||
for Callback in self.Callbacks:
|
del self.Callbacks
|
||||||
if Callback[0] == cbtype:
|
|
||||||
try:
|
return True
|
||||||
Callback[1](*args, **kwargs)
|
|
||||||
except Exception as e:
|
def AddCallback(self, cbtype, cbfunc):
|
||||||
self.Master.Logger.error(traceback.format_exc())
|
if not cbtype in FFmpegAudioPlayerFactory.VALID_CALLBACKS:
|
||||||
|
return False
|
||||||
async def _updater(self):
|
|
||||||
LastSecondsElapsed = 0.0
|
self.Callbacks.append((cbtype, cbfunc))
|
||||||
|
return True
|
||||||
while self.Playing:
|
|
||||||
SecondsElapsed = time.time() - self.StartedPlaying
|
def Callback(self, cbtype, *args, **kwargs):
|
||||||
|
for Callback in self.Callbacks:
|
||||||
if SecondsElapsed > self.Seconds:
|
if Callback[0] == cbtype:
|
||||||
SecondsElapsed = self.Seconds
|
try:
|
||||||
|
Callback[1](*args, **kwargs)
|
||||||
self.Callback("Update", LastSecondsElapsed, SecondsElapsed)
|
except Exception as e:
|
||||||
LastSecondsElapsed = SecondsElapsed
|
self.Master.Logger.error(traceback.format_exc())
|
||||||
|
|
||||||
if SecondsElapsed >= self.Seconds:
|
async def _updater(self):
|
||||||
if not self.StoppedPlaying:
|
LastSecondsElapsed = 0.0
|
||||||
self.Master.Logger.warning("BUFFER UNDERRUN")
|
|
||||||
await asyncio.sleep(0.05)
|
while self.Playing:
|
||||||
continue
|
SecondsElapsed = time.time() - self.StartedPlaying
|
||||||
self.Stop(False)
|
|
||||||
return
|
if SecondsElapsed > self.Seconds:
|
||||||
|
SecondsElapsed = self.Seconds
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
self.Callback("Update", LastSecondsElapsed, SecondsElapsed)
|
||||||
async def _read_stream(self, stream, writer):
|
|
||||||
Started = False
|
if SecondsElapsed >= self.Seconds:
|
||||||
try:
|
if not self.StoppedPlaying:
|
||||||
while stream and self.Playing:
|
print("BUFFER UNDERRUN!")
|
||||||
Data = await stream.read(65536)
|
self.Stop(False)
|
||||||
|
return
|
||||||
if Data:
|
|
||||||
try:
|
LastSecondsElapsed = SecondsElapsed
|
||||||
writer.write(Data)
|
|
||||||
await writer.drain()
|
await asyncio.sleep(0.1)
|
||||||
except (ConnectionError, BrokenPipeError, OSError) as ex:
|
|
||||||
self.Master.Logger.warning("Voice socket write failed: %s", ex)
|
async def _read_stream(self, stream, writer):
|
||||||
break
|
Started = False
|
||||||
|
|
||||||
Bytes = len(Data)
|
while stream and self.Playing:
|
||||||
frame_size = SAMPLEBYTES * self.Channels
|
Data = await stream.read(65536)
|
||||||
|
|
||||||
# Guard against invalid configuration and use integral frame counting
|
if Data:
|
||||||
if frame_size <= 0 or self.SampleRate <= 0:
|
writer.write(Data)
|
||||||
self.Master.Logger.error(
|
await writer.drain()
|
||||||
"Invalid audio configuration: Channels=%r, SampleRate=%r",
|
|
||||||
self.Channels,
|
Bytes = len(Data)
|
||||||
self.SampleRate,
|
Samples = Bytes / SAMPLEBYTES
|
||||||
)
|
Seconds = Samples / self.SampleRate
|
||||||
else:
|
|
||||||
# Accumulate remainder bytes to avoid systematic undercounting
|
self.Seconds += Seconds
|
||||||
total_bytes = getattr(self, "_byte_remainder", 0) + Bytes
|
|
||||||
Frames = total_bytes // frame_size
|
if not Started:
|
||||||
self._byte_remainder = total_bytes % frame_size
|
Started = True
|
||||||
Seconds = Frames / self.SampleRate
|
self.Callback("Play")
|
||||||
self.Seconds += Seconds
|
self.StartedPlaying = time.time()
|
||||||
|
asyncio.ensure_future(self._updater())
|
||||||
if not Started:
|
else:
|
||||||
Started = True
|
self.Process = None
|
||||||
self.Callback("Play")
|
break
|
||||||
self.StartedPlaying = time.time()
|
|
||||||
asyncio.ensure_future(self._updater())
|
self.StoppedPlaying = time.time()
|
||||||
else:
|
|
||||||
self.Process = None
|
async def _stream_subprocess(self, cmd, source_url = None):
|
||||||
break
|
if not self.Playing:
|
||||||
finally:
|
return
|
||||||
self.StoppedPlaying = time.time()
|
|
||||||
|
_, self.Writer = await asyncio.open_connection(self.Host[0], self.Host[1])
|
||||||
async def _stream_subprocess(self, cmd):
|
|
||||||
if not self.Playing:
|
# The metadata fetch (talks to youtube.com) and the actual media
|
||||||
return
|
# fetch (talks to googlevideo.com) are separate TCP connections even
|
||||||
try:
|
# within one yt-dlp run, and WARP's proxy doesn't guarantee the same
|
||||||
_, self.Writer = await asyncio.open_connection(self.Host[0], self.Host[1])
|
# apparent exit IP across separate connections -- Google signs the
|
||||||
|
# media URL to whichever IP it saw on the first connection, and a
|
||||||
Process = await asyncio.create_subprocess_exec(*cmd,
|
# mismatched second connection gets 403'd. A retry opens a fresh
|
||||||
stdout = asyncio.subprocess.PIPE, stderr = asyncio.subprocess.DEVNULL)
|
# connection through WARP and has a decent chance of landing on a
|
||||||
self.Process = Process
|
# matching IP, so we give it a few tries before giving up.
|
||||||
|
MaxAttempts = 3 if source_url else 1
|
||||||
await self._read_stream(Process.stdout, self.Writer)
|
|
||||||
await Process.wait()
|
for Attempt in range(1, MaxAttempts + 1):
|
||||||
|
if not self.Playing:
|
||||||
if self.Playing:
|
return
|
||||||
# Keep the clip active until the updater reaches self.Seconds.
|
|
||||||
# This allows !stop to interrupt short clips that are already
|
SourceProcess = None
|
||||||
# buffered to the voice socket but still audible in-game.
|
SourceStdErrTask = None
|
||||||
if self.StartedPlaying is None:
|
StdinArg = None
|
||||||
self.Stop(False)
|
ReadFd = None
|
||||||
except asyncio.CancelledError:
|
if source_url:
|
||||||
raise
|
# asyncio.subprocess.PIPE gives back a StreamReader, which is
|
||||||
except Exception:
|
# NOT a real file descriptor and can't be handed to a second
|
||||||
self.Master.Logger.error(traceback.format_exc())
|
# subprocess as stdin -- so we make an actual OS pipe and pass
|
||||||
if self.Playing:
|
# the raw fds instead, exactly like a shell "|" would, letting
|
||||||
self.Stop()
|
# 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,
|
||||||
|
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))
|
||||||
|
|
||||||
|
self.Stop()
|
||||||
|
return
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ urllib3
|
|||||||
yarl
|
yarl
|
||||||
cython
|
cython
|
||||||
geoip2
|
geoip2
|
||||||
|
bgutil-ytdlp-pot-provider
|
||||||
yt-dlp @ git+https://github.com/yt-dlp/yt-dlp@master
|
yt-dlp @ git+https://github.com/yt-dlp/yt-dlp@master
|
||||||
|
|
||||||
|
|
||||||
python3 -m pip install -U https://github.com/coletdjnz/yt-dlp-youtube-oauth2/archive/refs/heads/master.zip
|
python3 -m pip install -U https://github.com/coletdjnz/yt-dlp-youtube-oauth2/archive/refs/heads/master.zip
|
||||||
|
|||||||
+30
@@ -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