forked from Dniel97/artemis
Merge branch 'develop' into idac
This commit is contained in:
commit
8b3bb08c91
@ -44,11 +44,12 @@ class ShopOwner():
|
|||||||
self.permissions = perms
|
self.permissions = perms
|
||||||
|
|
||||||
class UserSession():
|
class UserSession():
|
||||||
def __init__(self, usr_id: int = 0, ip: str = "", perms: int = 0, ongeki_ver: int = 7):
|
def __init__(self, usr_id: int = 0, ip: str = "", perms: int = 0, ongeki_ver: int = 7, chunithm_ver: int = -1):
|
||||||
self.user_id = usr_id
|
self.user_id = usr_id
|
||||||
self.current_ip = ip
|
self.current_ip = ip
|
||||||
self.permissions = perms
|
self.permissions = perms
|
||||||
self.ongeki_version = ongeki_ver
|
self.ongeki_version = ongeki_ver
|
||||||
|
self.chunithm_version = chunithm_ver
|
||||||
|
|
||||||
class FrontendServlet():
|
class FrontendServlet():
|
||||||
def __init__(self, cfg: CoreConfig, config_dir: str) -> None:
|
def __init__(self, cfg: CoreConfig, config_dir: str) -> None:
|
||||||
@ -213,7 +214,8 @@ class FE_Base():
|
|||||||
sesh.user_id = tk['user_id']
|
sesh.user_id = tk['user_id']
|
||||||
sesh.current_ip = tk['current_ip']
|
sesh.current_ip = tk['current_ip']
|
||||||
sesh.permissions = tk['permissions']
|
sesh.permissions = tk['permissions']
|
||||||
|
sesh.chunithm_version = tk['chunithm_version']
|
||||||
|
|
||||||
if sesh.user_id <= 0:
|
if sesh.user_id <= 0:
|
||||||
self.logger.error("User session failed to validate due to an invalid ID!")
|
self.logger.error("User session failed to validate due to an invalid ID!")
|
||||||
return UserSession()
|
return UserSession()
|
||||||
@ -252,12 +254,12 @@ class FE_Base():
|
|||||||
if usr_sesh.permissions <= 0 or usr_sesh.permissions > 255:
|
if usr_sesh.permissions <= 0 or usr_sesh.permissions > 255:
|
||||||
self.logger.error(f"User session failed to validate due to an invalid permission value! {usr_sesh.permissions}")
|
self.logger.error(f"User session failed to validate due to an invalid permission value! {usr_sesh.permissions}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return usr_sesh
|
return usr_sesh
|
||||||
|
|
||||||
def encode_session(self, sesh: UserSession, exp_seconds: int = 86400) -> str:
|
def encode_session(self, sesh: UserSession, exp_seconds: int = 86400) -> str:
|
||||||
try:
|
try:
|
||||||
return jwt.encode({ "user_id": sesh.user_id, "current_ip": sesh.current_ip, "permissions": sesh.permissions, "ongeki_version": sesh.ongeki_version, "exp": int(datetime.now(tz=timezone.utc).timestamp()) + exp_seconds }, b64decode(self.core_config.frontend.secret), algorithm="HS256")
|
return jwt.encode({ "user_id": sesh.user_id, "current_ip": sesh.current_ip, "permissions": sesh.permissions, "ongeki_version": sesh.ongeki_version, "chunithm_version": sesh.chunithm_version, "exp": int(datetime.now(tz=timezone.utc).timestamp()) + exp_seconds }, b64decode(self.core_config.frontend.secret), algorithm="HS256")
|
||||||
except jwt.InvalidKeyError:
|
except jwt.InvalidKeyError:
|
||||||
self.logger.error("Failed to encode User session because the secret is invalid!")
|
self.logger.error("Failed to encode User session because the secret is invalid!")
|
||||||
return ""
|
return ""
|
||||||
|
@ -12,3 +12,6 @@ uploads:
|
|||||||
photos_dir: ""
|
photos_dir: ""
|
||||||
movies: False
|
movies: False
|
||||||
movies_dir: ""
|
movies_dir: ""
|
||||||
|
|
||||||
|
crypto:
|
||||||
|
encrypted_only: False
|
@ -811,6 +811,12 @@ class ChuniBase:
|
|||||||
upsert = data["upsertUserAll"]
|
upsert = data["upsertUserAll"]
|
||||||
user_id = data["userId"]
|
user_id = data["userId"]
|
||||||
|
|
||||||
|
if int(user_id) & 0x1000000000001 == 0x1000000000001:
|
||||||
|
place_id = int(user_id) & 0xFFFC00000000
|
||||||
|
|
||||||
|
self.logger.info("Guest play from place ID %d, ignoring.", place_id)
|
||||||
|
return {"returnCode": "1"}
|
||||||
|
|
||||||
if "userData" in upsert:
|
if "userData" in upsert:
|
||||||
try:
|
try:
|
||||||
upsert["userData"][0]["userName"] = self.read_wtf8(
|
upsert["userData"][0]["userName"] = self.read_wtf8(
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
from typing import List
|
from typing import List
|
||||||
from starlette.routing import Route
|
from starlette.routing import Route, Mount
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import Response, RedirectResponse
|
from starlette.responses import Response, RedirectResponse
|
||||||
from os import path
|
from os import path
|
||||||
@ -29,7 +29,13 @@ class ChuniFrontend(FE_Base):
|
|||||||
def get_routes(self) -> List[Route]:
|
def get_routes(self) -> List[Route]:
|
||||||
return [
|
return [
|
||||||
Route("/", self.render_GET, methods=['GET']),
|
Route("/", self.render_GET, methods=['GET']),
|
||||||
|
Route("/rating", self.render_GET_rating, methods=['GET']),
|
||||||
|
Mount("/playlog", routes=[
|
||||||
|
Route("/", self.render_GET_playlog, methods=['GET']),
|
||||||
|
Route("/{index}", self.render_GET_playlog, methods=['GET']),
|
||||||
|
]),
|
||||||
Route("/update.name", self.update_name, methods=['POST']),
|
Route("/update.name", self.update_name, methods=['POST']),
|
||||||
|
Route("/version.change", self.version_change, methods=['POST']),
|
||||||
]
|
]
|
||||||
|
|
||||||
async def render_GET(self, request: Request) -> bytes:
|
async def render_GET(self, request: Request) -> bytes:
|
||||||
@ -39,27 +45,165 @@ class ChuniFrontend(FE_Base):
|
|||||||
usr_sesh = self.validate_session(request)
|
usr_sesh = self.validate_session(request)
|
||||||
if not usr_sesh:
|
if not usr_sesh:
|
||||||
usr_sesh = UserSession()
|
usr_sesh = UserSession()
|
||||||
|
|
||||||
return Response(template.render(
|
if usr_sesh.user_id > 0:
|
||||||
title=f"{self.core_config.server.name} | {self.nav_name}",
|
versions = await self.data.profile.get_all_profile_versions(usr_sesh.user_id)
|
||||||
game_list=self.environment.globals["game_list"],
|
profile = []
|
||||||
sesh=vars(usr_sesh)
|
if versions:
|
||||||
), media_type="text/html; charset=utf-8")
|
# chunithm_version is -1 means it is not initialized yet, select a default version from existing.
|
||||||
|
if usr_sesh.chunithm_version < 0:
|
||||||
|
usr_sesh.chunithm_version = versions[0]
|
||||||
|
profile = await self.data.profile.get_profile_data(usr_sesh.user_id, usr_sesh.chunithm_version)
|
||||||
|
|
||||||
|
resp = Response(template.render(
|
||||||
|
title=f"{self.core_config.server.name} | {self.nav_name}",
|
||||||
|
game_list=self.environment.globals["game_list"],
|
||||||
|
sesh=vars(usr_sesh),
|
||||||
|
user_id=usr_sesh.user_id,
|
||||||
|
profile=profile,
|
||||||
|
version_list=ChuniConstants.VERSION_NAMES,
|
||||||
|
versions=versions,
|
||||||
|
cur_version=usr_sesh.chunithm_version
|
||||||
|
), media_type="text/html; charset=utf-8")
|
||||||
|
|
||||||
|
if usr_sesh.chunithm_version >= 0:
|
||||||
|
encoded_sesh = self.encode_session(usr_sesh)
|
||||||
|
resp.set_cookie("DIANA_SESH", encoded_sesh)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
else:
|
||||||
|
return RedirectResponse("/gate/", 303)
|
||||||
|
|
||||||
|
async def render_GET_rating(self, request: Request) -> bytes:
|
||||||
|
template = self.environment.get_template(
|
||||||
|
"titles/chuni/templates/chuni_rating.jinja"
|
||||||
|
)
|
||||||
|
usr_sesh = self.validate_session(request)
|
||||||
|
if not usr_sesh:
|
||||||
|
usr_sesh = UserSession()
|
||||||
|
|
||||||
|
if usr_sesh.user_id > 0:
|
||||||
|
if usr_sesh.chunithm_version < 0:
|
||||||
|
return RedirectResponse("/game/chuni/", 303)
|
||||||
|
profile = await self.data.profile.get_profile_data(usr_sesh.user_id, usr_sesh.chunithm_version)
|
||||||
|
rating = await self.data.profile.get_profile_rating(usr_sesh.user_id, usr_sesh.chunithm_version)
|
||||||
|
hot_list=[]
|
||||||
|
base_list=[]
|
||||||
|
if profile and rating:
|
||||||
|
song_records = []
|
||||||
|
for song in rating:
|
||||||
|
music_chart = await self.data.static.get_music_chart(usr_sesh.chunithm_version, song.musicId, song.difficultId)
|
||||||
|
if music_chart:
|
||||||
|
if (song.score < 800000):
|
||||||
|
song_rating = 0
|
||||||
|
elif (song.score >= 800000 and song.score < 900000):
|
||||||
|
song_rating = music_chart.level / 2 - 5
|
||||||
|
elif (song.score >= 900000 and song.score < 925000):
|
||||||
|
song_rating = music_chart.level - 5
|
||||||
|
elif (song.score >= 925000 and song.score < 975000):
|
||||||
|
song_rating = music_chart.level - 3
|
||||||
|
elif (song.score >= 975000 and song.score < 1000000):
|
||||||
|
song_rating = (song.score - 975000) / 2500 * 0.1 + music_chart.level
|
||||||
|
elif (song.score >= 1000000 and song.score < 1005000):
|
||||||
|
song_rating = (song.score - 1000000) / 1000 * 0.1 + 1 + music_chart.level
|
||||||
|
elif (song.score >= 1005000 and song.score < 1007500):
|
||||||
|
song_rating = (song.score - 1005000) / 500 * 0.1 + 1.5 + music_chart.level
|
||||||
|
elif (song.score >= 1007500 and song.score < 1009000):
|
||||||
|
song_rating = (song.score - 1007500) / 100 * 0.01 + 2 + music_chart.level
|
||||||
|
elif (song.score >= 1009000):
|
||||||
|
song_rating = 2.15 + music_chart.level
|
||||||
|
song_rating = int(song_rating * 10 ** 2) / 10 ** 2
|
||||||
|
song_records.append({
|
||||||
|
"difficultId": song.difficultId,
|
||||||
|
"musicId": song.musicId,
|
||||||
|
"title": music_chart.title,
|
||||||
|
"level": music_chart.level,
|
||||||
|
"score": song.score,
|
||||||
|
"type": song.type,
|
||||||
|
"song_rating": song_rating,
|
||||||
|
})
|
||||||
|
hot_list = [obj for obj in song_records if obj["type"] == "userRatingBaseHotList"]
|
||||||
|
base_list = [obj for obj in song_records if obj["type"] == "userRatingBaseList"]
|
||||||
|
return Response(template.render(
|
||||||
|
title=f"{self.core_config.server.name} | {self.nav_name}",
|
||||||
|
game_list=self.environment.globals["game_list"],
|
||||||
|
sesh=vars(usr_sesh),
|
||||||
|
profile=profile,
|
||||||
|
hot_list=hot_list,
|
||||||
|
base_list=base_list,
|
||||||
|
), media_type="text/html; charset=utf-8")
|
||||||
|
else:
|
||||||
|
return RedirectResponse("/gate/", 303)
|
||||||
|
|
||||||
|
async def render_GET_playlog(self, request: Request) -> bytes:
|
||||||
|
template = self.environment.get_template(
|
||||||
|
"titles/chuni/templates/chuni_playlog.jinja"
|
||||||
|
)
|
||||||
|
usr_sesh = self.validate_session(request)
|
||||||
|
if not usr_sesh:
|
||||||
|
usr_sesh = UserSession()
|
||||||
|
|
||||||
|
if usr_sesh.user_id > 0:
|
||||||
|
if usr_sesh.chunithm_version < 0:
|
||||||
|
return RedirectResponse("/game/chuni/", 303)
|
||||||
|
path_index = request.path_params.get('index')
|
||||||
|
if not path_index or int(path_index) < 1:
|
||||||
|
index = 0
|
||||||
|
else:
|
||||||
|
index = int(path_index) - 1 # 0 and 1 are 1st page
|
||||||
|
user_id = usr_sesh.user_id
|
||||||
|
playlog_count = await self.data.score.get_user_playlogs_count(user_id)
|
||||||
|
if playlog_count < index * 20 :
|
||||||
|
return Response(template.render(
|
||||||
|
title=f"{self.core_config.server.name} | {self.nav_name}",
|
||||||
|
game_list=self.environment.globals["game_list"],
|
||||||
|
sesh=vars(usr_sesh),
|
||||||
|
playlog_count=0
|
||||||
|
), media_type="text/html; charset=utf-8")
|
||||||
|
playlog = await self.data.score.get_playlogs_limited(user_id, index, 20)
|
||||||
|
playlog_with_title = []
|
||||||
|
for record in playlog:
|
||||||
|
music_chart = await self.data.static.get_music_chart(usr_sesh.chunithm_version, record.musicId, record.level)
|
||||||
|
if music_chart:
|
||||||
|
difficultyNum=music_chart.level
|
||||||
|
artist=music_chart.artist
|
||||||
|
title=music_chart.title
|
||||||
|
else:
|
||||||
|
difficultyNum=0
|
||||||
|
artist="unknown"
|
||||||
|
title="musicid: " + str(record.musicId)
|
||||||
|
playlog_with_title.append({
|
||||||
|
"raw": record,
|
||||||
|
"title": title,
|
||||||
|
"difficultyNum": difficultyNum,
|
||||||
|
"artist": artist,
|
||||||
|
})
|
||||||
|
return Response(template.render(
|
||||||
|
title=f"{self.core_config.server.name} | {self.nav_name}",
|
||||||
|
game_list=self.environment.globals["game_list"],
|
||||||
|
sesh=vars(usr_sesh),
|
||||||
|
user_id=usr_sesh.user_id,
|
||||||
|
playlog=playlog_with_title,
|
||||||
|
playlog_count=playlog_count
|
||||||
|
), media_type="text/html; charset=utf-8")
|
||||||
|
else:
|
||||||
|
return RedirectResponse("/gate/", 303)
|
||||||
|
|
||||||
async def update_name(self, request: Request) -> bytes:
|
async def update_name(self, request: Request) -> bytes:
|
||||||
usr_sesh = self.validate_session(request)
|
usr_sesh = self.validate_session(request)
|
||||||
if not usr_sesh:
|
if not usr_sesh:
|
||||||
return RedirectResponse("/gate/", 303)
|
return RedirectResponse("/gate/", 303)
|
||||||
|
|
||||||
new_name: str = request.query_params.get('new_name', '')
|
form_data = await request.form()
|
||||||
|
new_name: str = form_data.get("new_name")
|
||||||
new_name_full = ""
|
new_name_full = ""
|
||||||
|
|
||||||
if not new_name:
|
if not new_name:
|
||||||
return RedirectResponse("/gate/?e=4", 303)
|
return RedirectResponse("/gate/?e=4", 303)
|
||||||
|
|
||||||
if len(new_name) > 8:
|
if len(new_name) > 8:
|
||||||
return RedirectResponse("/gate/?e=8", 303)
|
return RedirectResponse("/gate/?e=8", 303)
|
||||||
|
|
||||||
for x in new_name: # FIXME: This will let some invalid characters through atm
|
for x in new_name: # FIXME: This will let some invalid characters through atm
|
||||||
o = ord(x)
|
o = ord(x)
|
||||||
try:
|
try:
|
||||||
@ -72,12 +216,31 @@ class ChuniFrontend(FE_Base):
|
|||||||
return RedirectResponse("/gate/?e=4", 303)
|
return RedirectResponse("/gate/?e=4", 303)
|
||||||
else:
|
else:
|
||||||
new_name_full += x
|
new_name_full += x
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Something went wrong parsing character {o:04X} - {e}")
|
self.logger.error(f"Something went wrong parsing character {o:04X} - {e}")
|
||||||
return RedirectResponse("/gate/?e=4", 303)
|
return RedirectResponse("/gate/?e=4", 303)
|
||||||
|
|
||||||
if not await self.data.profile.update_name(usr_sesh, new_name_full):
|
if not await self.data.profile.update_name(usr_sesh.user_id, new_name_full):
|
||||||
return RedirectResponse("/gate/?e=999", 303)
|
return RedirectResponse("/gate/?e=999", 303)
|
||||||
|
|
||||||
return RedirectResponse("/gate/?s=1", 303)
|
return RedirectResponse("/game/chuni/?s=1", 303)
|
||||||
|
|
||||||
|
async def version_change(self, request: Request):
|
||||||
|
usr_sesh = self.validate_session(request)
|
||||||
|
if not usr_sesh:
|
||||||
|
usr_sesh = UserSession()
|
||||||
|
|
||||||
|
if usr_sesh.user_id > 0:
|
||||||
|
form_data = await request.form()
|
||||||
|
chunithm_version = form_data.get("version")
|
||||||
|
self.logger.info(f"version change to: {chunithm_version}")
|
||||||
|
if(chunithm_version.isdigit()):
|
||||||
|
usr_sesh.chunithm_version=int(chunithm_version)
|
||||||
|
encoded_sesh = self.encode_session(usr_sesh)
|
||||||
|
self.logger.info(f"Created session with JWT {encoded_sesh}")
|
||||||
|
resp = RedirectResponse("/game/chuni/", 303)
|
||||||
|
resp.set_cookie("DIANA_SESH", encoded_sesh)
|
||||||
|
return resp
|
||||||
|
else:
|
||||||
|
return RedirectResponse("/gate/", 303)
|
@ -757,3 +757,26 @@ class ChuniProfileData(BaseData):
|
|||||||
return
|
return
|
||||||
|
|
||||||
return result.lastrowid
|
return result.lastrowid
|
||||||
|
|
||||||
|
async def get_profile_rating(self, aime_id: int, version: int) -> Optional[List[Row]]:
|
||||||
|
sql = select(rating).where(and_(
|
||||||
|
rating.c.user == aime_id,
|
||||||
|
rating.c.version <= version,
|
||||||
|
))
|
||||||
|
|
||||||
|
result = await self.execute(sql)
|
||||||
|
if result is None:
|
||||||
|
self.logger.warning(f"Rating of user {aime_id}, version {version} was None")
|
||||||
|
return None
|
||||||
|
return result.fetchall()
|
||||||
|
|
||||||
|
async def get_all_profile_versions(self, aime_id: int) -> Optional[List[Row]]:
|
||||||
|
sql = select([profile.c.version]).where(profile.c.user == aime_id)
|
||||||
|
result = await self.execute(sql)
|
||||||
|
if result is None:
|
||||||
|
self.logger.warning(f"user {aime_id}, has no profile")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
versions_raw = result.fetchall()
|
||||||
|
versions = [row[0] for row in versions_raw]
|
||||||
|
return sorted(versions, reverse=True)
|
@ -190,6 +190,23 @@ class ChuniScoreData(BaseData):
|
|||||||
return None
|
return None
|
||||||
return result.fetchall()
|
return result.fetchall()
|
||||||
|
|
||||||
|
async def get_playlogs_limited(self, aime_id: int, index: int, count: int) -> Optional[Row]:
|
||||||
|
sql = select(playlog).where(playlog.c.user == aime_id).order_by(playlog.c.id.desc()).limit(count).offset(index * count)
|
||||||
|
|
||||||
|
result = await self.execute(sql)
|
||||||
|
if result is None:
|
||||||
|
self.logger.warning(f" aime_id {aime_id} has no playlog ")
|
||||||
|
return None
|
||||||
|
return result.fetchall()
|
||||||
|
|
||||||
|
async def get_user_playlogs_count(self, aime_id: int) -> Optional[Row]:
|
||||||
|
sql = select(func.count()).where(playlog.c.user == aime_id)
|
||||||
|
result = await self.execute(sql)
|
||||||
|
if result is None:
|
||||||
|
self.logger.warning(f" aime_id {aime_id} has no playlog ")
|
||||||
|
return None
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
async def put_playlog(self, aime_id: int, playlog_data: Dict, version: int) -> Optional[int]:
|
async def put_playlog(self, aime_id: int, playlog_data: Dict, version: int) -> Optional[int]:
|
||||||
# Calculate the ROM version that should be inserted into the DB, based on the version of the ggame being inserted
|
# Calculate the ROM version that should be inserted into the DB, based on the version of the ggame being inserted
|
||||||
# We only need from Version 10 (Plost) and back, as newer versions include romVersion in their upsert
|
# We only need from Version 10 (Plost) and back, as newer versions include romVersion in their upsert
|
||||||
|
24
titles/chuni/templates/chuni_header.jinja
Normal file
24
titles/chuni/templates/chuni_header.jinja
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<div class="chuni-header">
|
||||||
|
<h1>Chunithm</h1>
|
||||||
|
<ul class="chuni-navi">
|
||||||
|
<li><a class="nav-link" href="/game/chuni">PROFILE</a></li>
|
||||||
|
<li><a class="nav-link" href="/game/chuni/rating">RATING</a></li>
|
||||||
|
<li><a class="nav-link" href="/game/chuni/playlog">RECORD</a></li>
|
||||||
|
<li><a class="nav-link" href="/game/chuni/musics">MUSICS</a></li>
|
||||||
|
<li><a class="nav-link" href="/game/chuni/userbox">USER BOX</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
$(document).ready(function () {
|
||||||
|
var currentPath = window.location.pathname;
|
||||||
|
if (currentPath === '/game/chuni/') {
|
||||||
|
$('.nav-link[href="/game/chuni"]').addClass('active');
|
||||||
|
} else if (currentPath.startsWith('/game/chuni/playlog')) {
|
||||||
|
$('.nav-link[href="/game/chuni/playlog"]').addClass('active');
|
||||||
|
} else if (currentPath.startsWith('/game/chuni/rating')) {
|
||||||
|
$('.nav-link[href="/game/chuni/rating"]').addClass('active');
|
||||||
|
} else if (currentPath.startsWith('/game/chuni/musics')) {
|
||||||
|
$('.nav-link[href="/game/chuni/musics"]').addClass('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
@ -1,43 +1,150 @@
|
|||||||
{% extends "core/templates/index.jinja" %}
|
{% extends "core/templates/index.jinja" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Chunithm</h1>
|
<style>
|
||||||
{% if profile is defined and profile is not none and profile.id > 0 %}
|
{% include 'titles/chuni/templates/css/chuni_style.css' %}
|
||||||
<script type="text/javascript">
|
</style>
|
||||||
function toggle_new_name_form() {
|
<div class="container">
|
||||||
let frm = document.getElementById("new_name_form");
|
{% include 'titles/chuni/templates/chuni_header.jinja' %}
|
||||||
let btn = document.getElementById("btn_toggle_form");
|
{% if profile is defined and profile is not none and profile.id > 0 %}
|
||||||
|
<div class="row">
|
||||||
if (frm.style['display'] != "") {
|
<div class="col-lg-8 m-auto mt-3">
|
||||||
frm.style['display'] = "";
|
<div class="card bg-card rounded">
|
||||||
frm.style['max-height'] = "";
|
<table class="table-large table-rowdistinct">
|
||||||
btn.innerText = "Cancel";
|
<caption align="top">OVERVIEW</caption>
|
||||||
} else {
|
<tr>
|
||||||
frm.style['display'] = "none";
|
<th>{{ profile.userName }}</th>
|
||||||
frm.style['max-height'] = "0px";
|
<th>
|
||||||
btn.innerText = "Edit";
|
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#name_change">Edit</button>
|
||||||
}
|
</th>
|
||||||
}
|
</tr>
|
||||||
</script>
|
<tr>
|
||||||
<h3>Profile for {{ profile.userName }} <button onclick="toggle_new_name_form()" class="btn btn-secondary" id="btn_toggle_form">Edit</button></h3>
|
<td>version:</td>
|
||||||
{% if error is defined %}
|
<td>
|
||||||
{% include "core/templates/widgets/err_banner.jinja" %}
|
<select name="version" id="version" onChange="changeVersion(this)">
|
||||||
{% endif %}
|
{% for ver in versions %}
|
||||||
{% if success is defined and success == 1 %}
|
{% if ver == cur_version %}
|
||||||
<div style="background-color: #00AA00; padding: 20px; margin-bottom: 10px; width: 15%;">
|
<option value="{{ ver }}" selected>{{ version_list[ver] }}</option>
|
||||||
Update successful
|
{% else %}
|
||||||
</div>
|
<option value="{{ ver }}">{{ version_list[ver] }}</option>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form style="max-width: 33%; display: none; max-height: 0px;" action="/game/chuni/update.name" method="post" id="new_name_form">
|
{% endfor %}
|
||||||
<div class="mb-3">
|
</select>
|
||||||
<label for="new_name" class="form-label">New Trainer Name</label>
|
{% if versions | length > 1 %}
|
||||||
<input type="text" class="form-control" id="new_name" name="new_name" aria-describedby="new_name_help" maxlength="14">
|
<p style="margin-block-end: 0;">You have {{ versions | length }} versions.</p>
|
||||||
<div id="new_name_help" class="form-text">Must be 14 characters or less</div>
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Level:</td>
|
||||||
|
<td>{{ profile.level }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Rating:</td>
|
||||||
|
<td>
|
||||||
|
<span class="{% if profile.playerRating >= 1600 %}rainbow{% elif profile.playerRating < 1600 and profile.playerRating >= 1525 %}platinum{% elif profile.playerRating < 1525 and profile.playerRating >=1500 %}platinum{% endif %}">
|
||||||
|
{{ profile.playerRating|float/100 }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
(highest: {{ profile.highestRating|float/100 }})
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Over Power:</td>
|
||||||
|
<td>{{ profile.overPowerPoint|float/100 }}({{ profile.overPowerRate|float/100 }})</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Current Point:</td>
|
||||||
|
<td>{{ profile.point }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Total Point:</td>
|
||||||
|
<td>{{ profile.totalPoint }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Play Counts:</td>
|
||||||
|
<td>{{ profile.playCount }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Last Play Date:</td>
|
||||||
|
<td>{{ profile.lastPlayDate }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-8 m-auto mt-3">
|
||||||
|
<div class="card bg-card rounded">
|
||||||
|
<table class="table-large table-rowdistinct">
|
||||||
|
<caption align="top">SCORE</caption>
|
||||||
|
<tr>
|
||||||
|
<td>Total High Score:</td>
|
||||||
|
<td>{{ profile.totalHiScore }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Total Basic High Score:</td>
|
||||||
|
<td>{{ profile.totalBasicHighScore }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Total Advanced High Score:</td>
|
||||||
|
<td>{{ profile.totalAdvancedHighScore }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Total Expert High Score:</td>
|
||||||
|
<td>{{ profile.totalExpertHighScore }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Total Master High Score:</td>
|
||||||
|
<td>{{ profile.totalMasterHighScore }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Total Ultima High Score :</td>
|
||||||
|
<td>{{ profile.totalUltimaHighScore }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Submit</button>
|
{% if error is defined %}
|
||||||
</form>
|
{% include "core/templates/widgets/err_banner.jinja" %}
|
||||||
{% elif sesh is defined and sesh is not none and sesh.user_id > 0 %}
|
{% endif %}
|
||||||
No profile information found for this account.
|
{% elif sesh is defined and sesh is not none and sesh.user_id > 0 %}
|
||||||
{% else %}
|
No profile information found for this account.
|
||||||
Login to view profile information.
|
{% else %}
|
||||||
{% endif %}
|
Login to view profile information.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="modal fade" id="name_change" tabindex="-1" aria-labelledby="name_change_label" data-bs-theme="dark"
|
||||||
|
aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Name change</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="new_name_form" action="/game/chuni/update.name" method="post" style="outline: 0;">
|
||||||
|
<label class="form-label" for="new_name">new name:</label>
|
||||||
|
<input class="form-control" aria-describedby="newNameHelp" form="new_name_form" id="new_name"
|
||||||
|
name="new_name" maxlength="14" type="text" required>
|
||||||
|
<div id="newNameHelp" class="form-text">name must be full-width character string.
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<input type=submit class="btn btn-primary" type="button" form="new_name_form">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function changeVersion(sel) {
|
||||||
|
$.post("/game/chuni/version.change", { version: sel.value })
|
||||||
|
.done(function (data) {
|
||||||
|
location.reload();
|
||||||
|
})
|
||||||
|
.fail(function () {
|
||||||
|
alert("Failed to update version.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
184
titles/chuni/templates/chuni_playlog.jinja
Normal file
184
titles/chuni/templates/chuni_playlog.jinja
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
{% extends "core/templates/index.jinja" %}
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
{% include 'titles/chuni/templates/css/chuni_style.css' %}
|
||||||
|
</style>
|
||||||
|
<div class="container">
|
||||||
|
{% include 'titles/chuni/templates/chuni_header.jinja' %}
|
||||||
|
{% if playlog is defined and playlog is not none %}
|
||||||
|
<div class="row">
|
||||||
|
<h4 style="text-align: center;">Playlog counts: {{ playlog_count }}</h4>
|
||||||
|
{% set rankName = ['D', 'C', 'B', 'BB', 'BBB', 'A', 'AA', 'AAA', 'S', 'S+', 'SS', 'SS+', 'SSS', 'SSS+'] %}
|
||||||
|
{% set difficultyName = ['normal', 'hard', 'expert', 'master', 'ultimate'] %}
|
||||||
|
{% for record in playlog %}
|
||||||
|
<div class="col-lg-6 mt-3">
|
||||||
|
<div class="card bg-card rounded card-hover">
|
||||||
|
<div class="card-header row">
|
||||||
|
<div class="col-8 scrolling-text">
|
||||||
|
<h5 class="card-text"> {{ record.title }} </h5>
|
||||||
|
<br>
|
||||||
|
<h6 class="card-text"> {{ record.artist }} </h6>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<h6 class="card-text">{{ record.raw.userPlayDate }}</h6>
|
||||||
|
<h6 class="card-text">TRACK {{ record.raw.track }}</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body row">
|
||||||
|
<div class="col-3" style="text-align: center;">
|
||||||
|
<h4 class="card-text">{{ record.raw.score }}</h4>
|
||||||
|
<h2>{{ rankName[record.raw.rank] }}</h2>
|
||||||
|
<h6
|
||||||
|
class="{% if record.raw.level == 0 %}normal{% elif record.raw.level == 1 %}advanced{% elif record.raw.level == 2 %}expert{% elif record.raw.level == 3 %}master{% elif record.raw.level == 4 %}ultima{% endif %}">
|
||||||
|
{{ difficultyName[record.raw.level] }}  {{ record.difficultyNum }}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="col-6" style="text-align: center;">
|
||||||
|
<table class="table-small table-rowdistinc">
|
||||||
|
<tr>
|
||||||
|
<td>JUSTICE CRITIAL</td>
|
||||||
|
<td>
|
||||||
|
{{ record.raw.judgeCritical + record.raw.judgeHeaven }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>JUSTICE</td>
|
||||||
|
<td>
|
||||||
|
{{ record.raw.judgeJustice }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>ATTACK</td>
|
||||||
|
<td>
|
||||||
|
{{ record.raw.judgeAttack }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>MISS</td>
|
||||||
|
<td>
|
||||||
|
{{ record.raw.judgeGuilty }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-3" style="text-align: center;">
|
||||||
|
{%if record.raw.isFullCombo == 1 %}
|
||||||
|
<h6>FULL COMBO</h6>
|
||||||
|
{% endif %}
|
||||||
|
{%if record.raw.isAllJustice == 1 %}
|
||||||
|
<h6>ALL JUSTICE</h6>
|
||||||
|
{% endif %}
|
||||||
|
{%if record.raw.isNewRecord == 1 %}
|
||||||
|
<h6>NEW RECORD</h6>
|
||||||
|
{% endif %}
|
||||||
|
{%if record.raw.fullChainKind > 0 %}
|
||||||
|
<h6>FULL CHAIN</h6>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% set playlog_pages = playlog_count // 20 + 1 %}
|
||||||
|
{% elif sesh is defined and sesh is not none and sesh.user_id > 0 %}
|
||||||
|
No Playlog information found for this account.
|
||||||
|
{% else %}
|
||||||
|
Login to view profile information.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<footer class="navbar-fixed-bottom">
|
||||||
|
<nav aria-label="Playlog page navigation">
|
||||||
|
<ul class="pagination justify-content-center mt-3">
|
||||||
|
<li class="page-item"><a id="prev_page" class="page-link" href="#">Previous</a></li>
|
||||||
|
<li class="page-item"><a id="first_page" class="page-link" href="/game/chuni/playlog/">1</a></li>
|
||||||
|
<li class="page-item"><a id="prev_3_page" class="page-link" href="">...</a></li>
|
||||||
|
<li class="page-item"><a id="front_page" class="page-link" href="">2</a></li>
|
||||||
|
<li class="page-item"><a id="cur_page" class="page-link active" href="">3</a></li>
|
||||||
|
<li class="page-item"><a id="back_page" class="page-link" href="">4</a></li>
|
||||||
|
<li class="page-item"><a id="next_3_page" class="page-link" href="">...</a></li>
|
||||||
|
<li class="page-item"><a id="last_page" class="page-link" href="/game/chuni/playlog/{{ playlog_pages }}">{{
|
||||||
|
playlog_pages }}</a></li>
|
||||||
|
<li class="page-item"><a id="next_page" class="page-link" href="#">Next</a></li>
|
||||||
|
 
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-5"></div>
|
||||||
|
<div class="col-2">
|
||||||
|
<div class="input-group rounded">
|
||||||
|
<input id="page_input" type="text" class="form-control" placeholder="go to page">
|
||||||
|
<span class="input-group-btn">
|
||||||
|
<button id="go_button" class="btn btn-light" type="button">
|
||||||
|
Go!
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-5"></div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
$(document).ready(function () {
|
||||||
|
$('.scrolling-text p, .scrolling-text h1, .scrolling-text h2, .scrolling-text h3, .scrolling-text h4, .scrolling-text h5, .scrolling-text h6').each(function () {
|
||||||
|
var parentWidth = $(this).parent().width();
|
||||||
|
var elementWidth = $(this).outerWidth();
|
||||||
|
var elementWidthWithPadding = $(this).outerWidth(true);
|
||||||
|
|
||||||
|
if (elementWidthWithPadding > parentWidth) {
|
||||||
|
$(this).addClass('scrolling');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var currentUrl = window.location.pathname;
|
||||||
|
var currentPage = parseInt(currentUrl.split('/').pop());
|
||||||
|
var rootUrl = '/game/chuni/playlog/';
|
||||||
|
var playlogPages = {{ playlog_pages }};
|
||||||
|
if (Number.isNaN(currentPage)) {
|
||||||
|
currentPage = 1;
|
||||||
|
}
|
||||||
|
$('#cur_page').text(currentPage);
|
||||||
|
$('#prev_page').attr('href', rootUrl + (currentPage - 1))
|
||||||
|
$('#next_page').attr('href', rootUrl + (currentPage + 1))
|
||||||
|
$('#front_page').attr('href', rootUrl + (currentPage - 1))
|
||||||
|
$('#front_page').text(currentPage - 1);
|
||||||
|
$('#back_page').attr('href', rootUrl + (currentPage + 1))
|
||||||
|
$('#back_page').text(currentPage + 1);
|
||||||
|
$('#prev_3_page').attr('href', rootUrl + (currentPage - 3))
|
||||||
|
$('#next_3_page').attr('href', rootUrl + (currentPage + 3))
|
||||||
|
if ((currentPage - 1) < 3) {
|
||||||
|
$('#prev_3_page').hide();
|
||||||
|
if ((currentPage - 1) < 2) {
|
||||||
|
$('#front_page').hide();
|
||||||
|
if (currentPage === 1) {
|
||||||
|
$('#first_page').hide();
|
||||||
|
$('#prev_page').addClass('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((playlogPages - currentPage) < 3) {
|
||||||
|
$('#next_3_page').hide();
|
||||||
|
if ((playlogPages - currentPage) < 2) {
|
||||||
|
$('#back_page').hide();
|
||||||
|
if (currentPage === playlogPages) {
|
||||||
|
$('#last_page').hide();
|
||||||
|
$('#next_page').addClass('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#go_button').click(function () {
|
||||||
|
var pageNumber = parseInt($('#page_input').val());
|
||||||
|
|
||||||
|
if (!Number.isNaN(pageNumber) && pageNumber <= playlogPages && pageNumber >= 0) {
|
||||||
|
var url = '/game/chuni/playlog/' + pageNumber;
|
||||||
|
window.location.href = url;
|
||||||
|
} else {
|
||||||
|
$('#page_input').val('');
|
||||||
|
$('#page_input').attr('placeholder', 'invalid input!');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock content %}
|
79
titles/chuni/templates/chuni_rating.jinja
Normal file
79
titles/chuni/templates/chuni_rating.jinja
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
{% extends "core/templates/index.jinja" %}
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
{% include 'titles/chuni/templates/css/chuni_style.css' %}
|
||||||
|
</style>
|
||||||
|
<div class="container">
|
||||||
|
{% include 'titles/chuni/templates/chuni_header.jinja' %}
|
||||||
|
{% if profile is defined and profile is not none and profile.id > 0 %}
|
||||||
|
<h4 style="text-align: center;">Rating: {{ profile.playerRating|float/100 }}    Player Counts: {{
|
||||||
|
profile.playCount }}</h4>
|
||||||
|
<div class="row">
|
||||||
|
{% if hot_list %}
|
||||||
|
<div class="col-lg-6 mt-3">
|
||||||
|
<div class="card bg-card rounded">
|
||||||
|
<table class="table-large table-rowdistinct">
|
||||||
|
<caption align="top">Recent 10</caption>
|
||||||
|
<tr>
|
||||||
|
<th>Music</th>
|
||||||
|
<th>Difficulty</th>
|
||||||
|
<th>Score</th>
|
||||||
|
<th>Rating</th>
|
||||||
|
</tr>
|
||||||
|
{% for row in hot_list %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.title }}</td>
|
||||||
|
<td
|
||||||
|
class="{% if row.difficultId == 0 %}basic{% elif row.difficultId == 1 %}{% elif row.difficultId == 2 %}expert{% elif row.difficultId == 3 %}master{% else %}{% endif %}">
|
||||||
|
{{ row.level }}
|
||||||
|
</td>
|
||||||
|
<td>{{ row.score }}</td>
|
||||||
|
<td class="{% if row.song_rating >= 16 %}rainbow{% endif %}">
|
||||||
|
{{ row.song_rating }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>No r10 found</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if base_list %}
|
||||||
|
<div class="col-lg-6 mt-3">
|
||||||
|
<div class="card bg-card rounded">
|
||||||
|
<table class="table-large table-rowdistinct">
|
||||||
|
<caption align="top">Best 30</caption>
|
||||||
|
<tr>
|
||||||
|
<th>Music</th>
|
||||||
|
<th>Difficulty</th>
|
||||||
|
<th>Score</th>
|
||||||
|
<th>Rating</th>
|
||||||
|
</tr>
|
||||||
|
{% for row in base_list %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.title }}</td>
|
||||||
|
<td
|
||||||
|
class="{% if row.difficultId == 0 %}normal{% elif row.difficultId == 1 %}hard{% elif row.difficultId == 2 %}expert{% elif row.difficultId == 3 %}master{% else %}{% endif %}">
|
||||||
|
{{ row.level }}
|
||||||
|
</td>
|
||||||
|
<td>{{ row.score }}</td>
|
||||||
|
<td class="{% if row.song_rating >= 16 %}rainbow{% endif %}">
|
||||||
|
{{ row.song_rating }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>No b30 found</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif sesh is defined and sesh is not none and sesh.user_id > 0 %}
|
||||||
|
No profile information found for this account.
|
||||||
|
{% else %}
|
||||||
|
Login to view profile information.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
195
titles/chuni/templates/css/chuni_style.css
Normal file
195
titles/chuni/templates/css/chuni_style.css
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
.chuni-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.chuni-navi {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #333;
|
||||||
|
text-align: center;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.chuni-navi li {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.chuni-navi li a {
|
||||||
|
display: block;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.chuni-navi li a:hover:not(.active) {
|
||||||
|
background-color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.chuni-navi li a.active {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.chuni-navi li.right {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
|
||||||
|
ul.chuni-navi li.right,
|
||||||
|
ul.chuni-navi li {
|
||||||
|
float: none;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
|
border-collapse: separate;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #555555;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-rowdistinct tr:nth-child(even) {
|
||||||
|
background-color: #303030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-rowdistinct tr:nth-child(odd) {
|
||||||
|
background-color: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
caption {
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-large {
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-large th,
|
||||||
|
.table-large td {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-small {
|
||||||
|
width: 100%;
|
||||||
|
margin: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-small th,
|
||||||
|
.table-small td {
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-card {
|
||||||
|
background-color: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.normal {
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hard {
|
||||||
|
color: #ffc107;
|
||||||
|
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expert {
|
||||||
|
color: #dc3545;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master {
|
||||||
|
color: #dd09e8;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultimate {
|
||||||
|
color: #000000;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rainbow {
|
||||||
|
background: linear-gradient(to right, red, yellow, lime, aqua, blue, fuchsia) 0 / 5em;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platinum {
|
||||||
|
color: #FFFF00;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold {
|
||||||
|
color: #FFFF00;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrolling-text {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrolling-text p {
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrolling-text h6 {
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrolling-text h5 {
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrolling {
|
||||||
|
animation: scroll 10s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scroll {
|
||||||
|
0% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
@ -238,8 +238,10 @@ class Mai2Base:
|
|||||||
user_id = data["userId"]
|
user_id = data["userId"]
|
||||||
upsert = data["upsertUserAll"]
|
upsert = data["upsertUserAll"]
|
||||||
|
|
||||||
if int(user_id) & 1000000000001 == 1000000000001:
|
if int(user_id) & 0x1000000000001 == 0x1000000000001:
|
||||||
self.logger.info("Guest play, ignoring.")
|
place_id = int(user_id) & 0xFFFC00000000
|
||||||
|
|
||||||
|
self.logger.info("Guest play from place ID %d, ignoring.", place_id)
|
||||||
return {"returnCode": 1, "apiName": "UpsertUserAllApi"}
|
return {"returnCode": 1, "apiName": "UpsertUserAllApi"}
|
||||||
|
|
||||||
if "userData" in upsert and len(upsert["userData"]) > 0:
|
if "userData" in upsert and len(upsert["userData"]) > 0:
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from typing import Dict
|
||||||
|
|
||||||
from core.config import CoreConfig
|
from core.config import CoreConfig
|
||||||
|
|
||||||
|
|
||||||
@ -70,8 +72,32 @@ class Mai2UploadsConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Mai2CryptoConfig:
|
||||||
|
def __init__(self, parent_config: "Mai2Config") -> None:
|
||||||
|
self.__config = parent_config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def keys(self) -> Dict[int, list[str]]:
|
||||||
|
"""
|
||||||
|
in the form of:
|
||||||
|
internal_version: [key, iv, salt]
|
||||||
|
key and iv are hex strings
|
||||||
|
salt is a normal UTF-8 string
|
||||||
|
"""
|
||||||
|
return CoreConfig.get_config_field(
|
||||||
|
self.__config, "mai2", "crypto", "keys", default={}
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def encrypted_only(self) -> bool:
|
||||||
|
return CoreConfig.get_config_field(
|
||||||
|
self.__config, "mai2", "crypto", "encrypted_only", default=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Mai2Config(dict):
|
class Mai2Config(dict):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.server = Mai2ServerConfig(self)
|
self.server = Mai2ServerConfig(self)
|
||||||
self.deliver = Mai2DeliverConfig(self)
|
self.deliver = Mai2DeliverConfig(self)
|
||||||
self.uploads = Mai2UploadsConfig(self)
|
self.uploads = Mai2UploadsConfig(self)
|
||||||
|
self.crypto = Mai2CryptoConfig(self)
|
@ -121,8 +121,10 @@ class Mai2DX(Mai2Base):
|
|||||||
user_id = data["userId"]
|
user_id = data["userId"]
|
||||||
upsert = data["upsertUserAll"]
|
upsert = data["upsertUserAll"]
|
||||||
|
|
||||||
if int(user_id) & 1000000000001 == 1000000000001:
|
if int(user_id) & 0x1000000000001 == 0x1000000000001:
|
||||||
self.logger.info("Guest play, ignoring.")
|
place_id = int(user_id) & 0xFFFC00000000
|
||||||
|
|
||||||
|
self.logger.info("Guest play from place ID %d, ignoring.", place_id)
|
||||||
return {"returnCode": 1, "apiName": "UpsertUserAllApi"}
|
return {"returnCode": 1, "apiName": "UpsertUserAllApi"}
|
||||||
|
|
||||||
if "userData" in upsert and len(upsert["userData"]) > 0:
|
if "userData" in upsert and len(upsert["userData"]) > 0:
|
||||||
|
@ -6,9 +6,13 @@ import inflection
|
|||||||
import yaml
|
import yaml
|
||||||
import logging, coloredlogs
|
import logging, coloredlogs
|
||||||
import zlib
|
import zlib
|
||||||
|
import string
|
||||||
from logging.handlers import TimedRotatingFileHandler
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
from os import path, mkdir
|
from os import path, mkdir
|
||||||
from typing import Tuple, List, Dict
|
from typing import Tuple, List, Dict
|
||||||
|
from Crypto.Hash import MD5
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
from Crypto.Util.Padding import pad
|
||||||
|
|
||||||
from core.config import CoreConfig
|
from core.config import CoreConfig
|
||||||
from core.utils import Utils
|
from core.utils import Utils
|
||||||
@ -32,6 +36,7 @@ class Mai2Servlet(BaseServlet):
|
|||||||
def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None:
|
def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None:
|
||||||
super().__init__(core_cfg, cfg_dir)
|
super().__init__(core_cfg, cfg_dir)
|
||||||
self.game_cfg = Mai2Config()
|
self.game_cfg = Mai2Config()
|
||||||
|
self.hash_table: Dict[int, Dict[str, str]] = {}
|
||||||
if path.exists(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}"):
|
if path.exists(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}"):
|
||||||
self.game_cfg.update(
|
self.game_cfg.update(
|
||||||
yaml.safe_load(open(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}"))
|
yaml.safe_load(open(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}"))
|
||||||
@ -86,6 +91,37 @@ class Mai2Servlet(BaseServlet):
|
|||||||
level=self.game_cfg.server.loglevel, logger=self.logger, fmt=log_fmt_str
|
level=self.game_cfg.server.loglevel, logger=self.logger, fmt=log_fmt_str
|
||||||
)
|
)
|
||||||
self.logger.initted = True
|
self.logger.initted = True
|
||||||
|
|
||||||
|
for version, keys in self.game_cfg.crypto.keys.items():
|
||||||
|
if version < Mai2Constants.VER_MAIMAI_DX:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(keys) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.hash_table[version] = {}
|
||||||
|
method_list = [
|
||||||
|
method
|
||||||
|
for method in dir(self.versions[version])
|
||||||
|
if not method.startswith("__")
|
||||||
|
]
|
||||||
|
|
||||||
|
for method in method_list:
|
||||||
|
# handle_method_api_request -> HandleMethodApiRequest
|
||||||
|
# remove the first 6 chars and the final 7 chars to get the canonical
|
||||||
|
# endpoint name.
|
||||||
|
method_fixed = inflection.camelize(method)[6:-7]
|
||||||
|
hash = MD5.new((method_fixed + keys[2]).encode())
|
||||||
|
|
||||||
|
# truncate unused bytes like the game does
|
||||||
|
hashed_name = hash.hexdigest()
|
||||||
|
self.hash_table[version][hashed_name] = method_fixed
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
"Hashed v%s method %s with %s to get %s",
|
||||||
|
version, method_fixed, keys[2], hashed_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_game_enabled(
|
def is_game_enabled(
|
||||||
@ -234,7 +270,7 @@ class Mai2Servlet(BaseServlet):
|
|||||||
self.logger.error(f"Error handling v{version} method {endpoint} - {e}")
|
self.logger.error(f"Error handling v{version} method {endpoint} - {e}")
|
||||||
return Response(zlib.compress(b'{"returnCode": "0"}'))
|
return Response(zlib.compress(b'{"returnCode": "0"}'))
|
||||||
|
|
||||||
if resp == None:
|
if resp is None:
|
||||||
resp = {"returnCode": 1}
|
resp = {"returnCode": 1}
|
||||||
|
|
||||||
self.logger.debug(f"Response {resp}")
|
self.logger.debug(f"Response {resp}")
|
||||||
@ -252,6 +288,8 @@ class Mai2Servlet(BaseServlet):
|
|||||||
req_raw = await request.body()
|
req_raw = await request.body()
|
||||||
internal_ver = 0
|
internal_ver = 0
|
||||||
client_ip = Utils.get_ip_addr(request)
|
client_ip = Utils.get_ip_addr(request)
|
||||||
|
encrypted = False
|
||||||
|
|
||||||
if version < 105: # 1.0
|
if version < 105: # 1.0
|
||||||
internal_ver = Mai2Constants.VER_MAIMAI_DX
|
internal_ver = Mai2Constants.VER_MAIMAI_DX
|
||||||
elif version >= 105 and version < 110: # PLUS
|
elif version >= 105 and version < 110: # PLUS
|
||||||
@ -271,19 +309,54 @@ class Mai2Servlet(BaseServlet):
|
|||||||
elif version >= 140: # BUDDiES
|
elif version >= 140: # BUDDiES
|
||||||
internal_ver = Mai2Constants.VER_MAIMAI_DX_BUDDIES
|
internal_ver = Mai2Constants.VER_MAIMAI_DX_BUDDIES
|
||||||
|
|
||||||
|
if all(c in string.hexdigits for c in endpoint) and len(endpoint) == 32:
|
||||||
|
# If we get a 32 character long hex string, it's a hash and we're
|
||||||
|
# dealing with an encrypted request. False positives shouldn't happen
|
||||||
|
# as long as requests are suffixed with `Api`.
|
||||||
|
if internal_ver not in self.hash_table:
|
||||||
|
self.logger.error(
|
||||||
|
"v%s does not support encryption or no keys entered",
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
return Response(zlib.compress(b'{"stat": "0"}'))
|
||||||
|
elif endpoint.lower() not in self.hash_table[internal_ver]:
|
||||||
|
self.logger.error(
|
||||||
|
"No hash found for v%s endpoint %s",
|
||||||
|
version, endpoint
|
||||||
|
)
|
||||||
|
return Response(zlib.compress(b'{"stat": "0"}'))
|
||||||
|
|
||||||
|
endpoint = self.hash_table[internal_ver][endpoint.lower()]
|
||||||
|
|
||||||
|
try:
|
||||||
|
crypt = AES.new(
|
||||||
|
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][0]),
|
||||||
|
AES.MODE_CBC,
|
||||||
|
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
req_raw = crypt.decrypt(req_raw)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
"Failed to decrypt v%s request to %s",
|
||||||
|
version, endpoint,
|
||||||
|
exc_info=e,
|
||||||
|
)
|
||||||
|
return Response(zlib.compress(b'{"stat": "0"}'))
|
||||||
|
|
||||||
|
encrypted = True
|
||||||
|
|
||||||
if (
|
if (
|
||||||
request.headers.get("Mai-Encoding") is not None
|
not encrypted
|
||||||
or request.headers.get("X-Mai-Encoding") is not None
|
and self.game_cfg.crypto.encrypted_only
|
||||||
|
and version >= 110
|
||||||
):
|
):
|
||||||
# The has is some flavor of MD5 of the endpoint with a constant bolted onto the end of it.
|
self.logger.error(
|
||||||
# See cake.dll's Obfuscator function for details. Hopefully most DLL edits will remove
|
"Unencrypted v%s %s request, but config is set to encrypted only: %r",
|
||||||
# these two(?) headers to not cause issues, but given the general quality of SEGA data...
|
version, endpoint, req_raw
|
||||||
enc_ver = request.headers.get("Mai-Encoding")
|
|
||||||
if enc_ver is None:
|
|
||||||
enc_ver = request.headers.get("X-Mai-Encoding")
|
|
||||||
self.logger.debug(
|
|
||||||
f"Encryption v{enc_ver} - User-Agent: {request.headers.get('User-Agent')}"
|
|
||||||
)
|
)
|
||||||
|
return Response(zlib.compress(b'{"stat": "0"}'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
unzip = zlib.decompress(req_raw)
|
unzip = zlib.decompress(req_raw)
|
||||||
@ -320,12 +393,26 @@ class Mai2Servlet(BaseServlet):
|
|||||||
self.logger.error(f"Error handling v{version} method {endpoint} - {e}")
|
self.logger.error(f"Error handling v{version} method {endpoint} - {e}")
|
||||||
return Response(zlib.compress(b'{"stat": "0"}'))
|
return Response(zlib.compress(b'{"stat": "0"}'))
|
||||||
|
|
||||||
if resp == None:
|
if resp is None:
|
||||||
resp = {"returnCode": 1}
|
resp = {"returnCode": 1}
|
||||||
|
|
||||||
self.logger.debug(f"Response {resp}")
|
self.logger.debug(f"Response {resp}")
|
||||||
|
|
||||||
return Response(zlib.compress(json.dumps(resp, ensure_ascii=False).encode("utf-8")))
|
zipped = zlib.compress(json.dumps(resp, ensure_ascii=False).encode("utf-8"))
|
||||||
|
|
||||||
|
if not encrypted or version < 110:
|
||||||
|
return Response(zipped)
|
||||||
|
|
||||||
|
padded = pad(zipped, 16)
|
||||||
|
|
||||||
|
crypt = AES.new(
|
||||||
|
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][0]),
|
||||||
|
AES.MODE_CBC,
|
||||||
|
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(crypt.encrypt(padded))
|
||||||
|
|
||||||
|
|
||||||
async def handle_old_srv(self, request: Request) -> bytes:
|
async def handle_old_srv(self, request: Request) -> bytes:
|
||||||
endpoint = request.path_params.get('endpoint')
|
endpoint = request.path_params.get('endpoint')
|
||||||
|
@ -293,7 +293,9 @@ class OngekiBase:
|
|||||||
|
|
||||||
async def handle_upsert_user_gplog_api_request(self, data: Dict) -> Dict:
|
async def handle_upsert_user_gplog_api_request(self, data: Dict) -> Dict:
|
||||||
user = data["userId"]
|
user = data["userId"]
|
||||||
if user >= 200000000000000: # Account for guest play
|
|
||||||
|
# If playing as guest, the user ID is or(0x1000000000001, (placeId & 65535) << 32)
|
||||||
|
if user & 0x1000000000001 == 0x1000000000001:
|
||||||
user = None
|
user = None
|
||||||
|
|
||||||
await self.data.log.put_gp_log(
|
await self.data.log.put_gp_log(
|
||||||
@ -426,9 +428,10 @@ class OngekiBase:
|
|||||||
userTechCountList = []
|
userTechCountList = []
|
||||||
|
|
||||||
for tc in utcl:
|
for tc in utcl:
|
||||||
tc.pop("id")
|
tmp = tc._asdict()
|
||||||
tc.pop("user")
|
tmp.pop("id")
|
||||||
userTechCountList.append(tc)
|
tmp.pop("user")
|
||||||
|
userTechCountList.append(tmp)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"userId": data["userId"],
|
"userId": data["userId"],
|
||||||
@ -944,6 +947,12 @@ class OngekiBase:
|
|||||||
upsert = data["upsertUserAll"]
|
upsert = data["upsertUserAll"]
|
||||||
user_id = data["userId"]
|
user_id = data["userId"]
|
||||||
|
|
||||||
|
if user_id & 0x1000000000001 == 0x1000000000001:
|
||||||
|
place_id = int(user_id) & 0xFFFC00000000
|
||||||
|
|
||||||
|
self.logger.info("Guest play from place ID %d, ignoring.", place_id)
|
||||||
|
return {"returnCode": 1, "apiName": "UpsertUserAllApi"}
|
||||||
|
|
||||||
# The isNew fields are new as of Red and up. We just won't use them for now.
|
# The isNew fields are new as of Red and up. We just won't use them for now.
|
||||||
|
|
||||||
if "userData" in upsert and len(upsert["userData"]) > 0:
|
if "userData" in upsert and len(upsert["userData"]) > 0:
|
||||||
|
@ -129,7 +129,13 @@ tech_count = Table(
|
|||||||
|
|
||||||
class OngekiScoreData(BaseData):
|
class OngekiScoreData(BaseData):
|
||||||
async def get_tech_count(self, aime_id: int) -> Optional[List[Dict]]:
|
async def get_tech_count(self, aime_id: int) -> Optional[List[Dict]]:
|
||||||
return []
|
sql = select(tech_count).where(tech_count.c.user == aime_id)
|
||||||
|
|
||||||
|
result = await self.execute(sql)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
return result.fetchall()
|
||||||
|
|
||||||
async def put_tech_count(self, aime_id: int, tech_count_data: Dict) -> Optional[int]:
|
async def put_tech_count(self, aime_id: int, tech_count_data: Dict) -> Optional[int]:
|
||||||
tech_count_data["user"] = aime_id
|
tech_count_data["user"] = aime_id
|
||||||
|
Loading…
Reference in New Issue
Block a user