Merge pull request 'develop' (#2) from Hay1tsme/artemis:develop into develop

Reviewed-on: ThatzOkay/artemis#2
This commit is contained in:
ThatzOkay 2024-06-10 22:11:14 +00:00
commit 5ba041b32b
29 changed files with 1357 additions and 309 deletions

View File

@ -1,7 +1,12 @@
# Changelog # Changelog
Documenting updates to ARTEMiS, to be updated every time the master branch is pushed to. Documenting updates to ARTEMiS, to be updated every time the master branch is pushed to.
## 20240530
### DIVA
+ Fix reader for when dificulty is not a int
## 20240526 ## 20240526
### DIVA
+ Fixed missing awaits causing coroutine error + Fixed missing awaits causing coroutine error
## 20240524 ## 20240524

View File

@ -960,7 +960,7 @@ class DLReport:
return True return True
cfg_dir = environ.get("DIANA_CFG_DIR", "config") cfg_dir = environ.get("ARTEMIS_CFG_DIR", "config")
cfg: CoreConfig = CoreConfig() cfg: CoreConfig = CoreConfig()
if path.exists(f"{cfg_dir}/core.yaml"): if path.exists(f"{cfg_dir}/core.yaml"):
cfg.update(yaml.safe_load(open(f"{cfg_dir}/core.yaml"))) cfg.update(yaml.safe_load(open(f"{cfg_dir}/core.yaml")))

View File

@ -0,0 +1,48 @@
"""mai2_add_favs_rivals
Revision ID: 4a02e623e5e6
Revises: 8ad40a6e7be2
Create Date: 2024-06-08 19:02:43.856395
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '4a02e623e5e6'
down_revision = '8ad40a6e7be2'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mai2_item_favorite_music',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user', sa.Integer(), nullable=False),
sa.Column('musicId', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user'], ['aime_user.id'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user', 'musicId', name='mai2_item_favorite_music_uk'),
mysql_charset='utf8mb4'
)
op.create_table('mai2_user_rival',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user', sa.Integer(), nullable=False),
sa.Column('rival', sa.Integer(), nullable=False),
sa.Column('show', sa.Boolean(), server_default='0', nullable=False),
sa.ForeignKeyConstraint(['rival'], ['aime_user.id'], onupdate='cascade', ondelete='cascade'),
sa.ForeignKeyConstraint(['user'], ['aime_user.id'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user', 'rival', name='mai2_user_rival_uk'),
mysql_charset='utf8mb4'
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('mai2_user_rival')
op.drop_table('mai2_item_favorite_music')
# ### end Alembic commands ###

View File

@ -1,7 +1,7 @@
"""mai2_buddies_support """mai2_buddies_support
Revision ID: 81e44dd6047a Revision ID: 81e44dd6047a
Revises: d8950c7ce2fc Revises: 6a7e8277763b
Create Date: 2024-03-12 19:10:37.063907 Create Date: 2024-03-12 19:10:37.063907
""" """

View File

@ -0,0 +1,30 @@
"""ongeki: fix clearStatus
Revision ID: 8ad40a6e7be2
Revises: 7dc13e364e53
Create Date: 2024-05-29 19:03:30.062157
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '8ad40a6e7be2'
down_revision = '7dc13e364e53'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column('ongeki_score_best', 'clearStatus',
existing_type=mysql.TINYINT(display_width=1),
type_=sa.Integer(),
existing_nullable=False)
def downgrade():
op.alter_column('ongeki_score_best', 'clearStatus',
existing_type=sa.Integer(),
type_=mysql.TINYINT(display_width=1),
existing_nullable=False)

View File

@ -120,3 +120,7 @@ class UserData(BaseData):
result = await self.execute(sql) result = await self.execute(sql)
return result is not None return result is not None
async def get_user_by_username(self, username: str) -> Optional[Row]:
result = await self.execute(aime_user.select(aime_user.c.username == username))
if result: return result.fetchone()

View File

@ -44,12 +44,13 @@ 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, chunithm_ver: int = -1): def __init__(self, usr_id: int = 0, ip: str = "", perms: int = 0, ongeki_ver: int = 7, chunithm_ver: int = -1, maimai_version: 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 self.chunithm_version = chunithm_ver
self.maimai_version = maimai_version
class FrontendServlet(): class FrontendServlet():
def __init__(self, cfg: CoreConfig, config_dir: str) -> None: def __init__(self, cfg: CoreConfig, config_dir: str) -> None:
@ -192,7 +193,7 @@ class FE_Base():
), media_type="text/html; charset=utf-8") ), media_type="text/html; charset=utf-8")
if sesh is None: if sesh is None:
resp.delete_cookie("DIANA_SESH") resp.delete_cookie("ARTEMIS_SESH")
return resp return resp
def get_routes(self) -> List[Route]: def get_routes(self) -> List[Route]:
@ -216,6 +217,8 @@ class FE_Base():
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'] sesh.chunithm_version = tk['chunithm_version']
sesh.maimai_version = tk['maimai_version']
sesh.ongeki_version = tk['ongeki_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!")
@ -241,7 +244,7 @@ class FE_Base():
return UserSession() return UserSession()
def validate_session(self, request: Request) -> Optional[UserSession]: def validate_session(self, request: Request) -> Optional[UserSession]:
sesh = request.cookies.get('DIANA_SESH', "") sesh = request.cookies.get('ARTEMIS_SESH', "")
if not sesh: if not sesh:
return None return None
@ -260,7 +263,17 @@ class FE_Base():
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, "chunithm_version": sesh.chunithm_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,
"maimai_version": sesh.maimai_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 ""
@ -292,7 +305,7 @@ class FE_Gate(FE_Base):
error=err, error=err,
sesh=vars(UserSession()), sesh=vars(UserSession()),
), media_type="text/html; charset=utf-8") ), media_type="text/html; charset=utf-8")
resp.delete_cookie("DIANA_SESH") resp.delete_cookie("ARTEMIS_SESH")
return resp return resp
async def render_login(self, request: Request): async def render_login(self, request: Request):
@ -308,8 +321,12 @@ class FE_Gate(FE_Base):
uid = await self.data.card.get_user_id_from_card(access_code) uid = await self.data.card.get_user_id_from_card(access_code)
if uid is None: if uid is None:
self.logger.debug(f"Failed to find user for card {access_code}") user = await self.data.user.get_user_by_username(access_code) # Lookup as username
return RedirectResponse("/gate/?e=1", 303) if not user:
self.logger.debug(f"Failed to find user for card/username {access_code}")
return RedirectResponse("/gate/?e=1", 303)
uid = user['id']
user = await self.data.user.get_user(uid) user = await self.data.user.get_user(uid)
if user is None: if user is None:
@ -338,7 +355,7 @@ class FE_Gate(FE_Base):
usr_sesh = self.encode_session(sesh) usr_sesh = self.encode_session(sesh)
self.logger.debug(f"Created session with JWT {usr_sesh}") self.logger.debug(f"Created session with JWT {usr_sesh}")
resp = RedirectResponse("/user/", 303) resp = RedirectResponse("/user/", 303)
resp.set_cookie("DIANA_SESH", usr_sesh) resp.set_cookie("ARTEMIS_SESH", usr_sesh)
return resp return resp
@ -377,7 +394,7 @@ class FE_Gate(FE_Base):
usr_sesh = self.encode_session(sesh) usr_sesh = self.encode_session(sesh)
self.logger.debug(f"Created session with JWT {usr_sesh}") self.logger.debug(f"Created session with JWT {usr_sesh}")
resp = RedirectResponse("/user/", 303) resp = RedirectResponse("/user/", 303)
resp.set_cookie("DIANA_SESH", usr_sesh) resp.set_cookie("ARTEMIS_SESH", usr_sesh)
return resp return resp
@ -495,7 +512,7 @@ class FE_User(FE_Base):
async def render_logout(self, request: Request): async def render_logout(self, request: Request):
resp = RedirectResponse("/gate/", 303) resp = RedirectResponse("/gate/", 303)
resp.delete_cookie("DIANA_SESH") resp.delete_cookie("ARTEMIS_SESH")
return resp return resp
async def edit_card(self, request: Request) -> RedirectResponse: async def edit_card(self, request: Request) -> RedirectResponse:
@ -879,7 +896,7 @@ class FE_Machine(FE_Base):
arcade={} arcade={}
), media_type="text/html; charset=utf-8") ), media_type="text/html; charset=utf-8")
cfg_dir = environ.get("DIANA_CFG_DIR", "config") cfg_dir = environ.get("ARTEMIS_CFG_DIR", "config")
cfg: CoreConfig = CoreConfig() cfg: CoreConfig = CoreConfig()
if path.exists(f"{cfg_dir}/core.yaml"): if path.exists(f"{cfg_dir}/core.yaml"):
cfg.update(yaml.safe_load(open(f"{cfg_dir}/core.yaml"))) cfg.update(yaml.safe_load(open(f"{cfg_dir}/core.yaml")))

View File

@ -15,18 +15,18 @@
-moz-appearance: textfield; -moz-appearance: textfield;
} }
</style> </style>
<form id="login" style="max-width: 240px; min-width: 10%;" action="/gate/gate.login" method="post"> <form id="login" style="max-width: 240px; min-width: 15%;" action="/gate/gate.login" method="post">
<div class="form-group row"> <div class="form-group row">
<label for="access_code">Card Access Code</label><br> <label for="access_code">Access Code or Username</label><br>
<input form="login" class="form-control" name="access_code" id="access_code" type="number" placeholder="00000000000000000000" maxlength="20" required> <input form="login" class="form-control" name="access_code" id="access_code" placeholder="00000000000000000000" maxlength="20" required aria-describedby="access_code_help">
<div id="access_code_help" class="form-text">20 Digit access code from a card registered to your account, or your account username. (NOT your username from a game!)</div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label for="passwd">Password</label><br> <label for="passwd">Password</label><br>
<input id="passwd" class="form-control" name="passwd" type="password" placeholder="password"> <input id="passwd" class="form-control" name="passwd" type="password" placeholder="password" aria-describedby="passwd_help">
<div id="passwd_help" class="form-text">Leave blank if registering for the webui. Your card must have been used on a game connected to this server to register.</div>
</div> </div>
<p></p> <p></p>
<input id="submit" class="btn btn-primary" style="display: block; margin: 0 auto;" form="login" type="submit" value="Login"> <input id="submit" class="btn btn-primary" style="display: block; margin: 0 auto;" form="login" type="submit" value="Login">
</form> </form>
<h6>*To register for the webui, type in the access code of your card, as shown in a game, and leave the password field blank.</h6>
<h6>*If you have not registered a card with this server, you cannot create a webui account.</h6>
{% endblock content %} {% endblock content %}

View File

@ -68,7 +68,7 @@ class ChuniFrontend(FE_Base):
if usr_sesh.chunithm_version >= 0: if usr_sesh.chunithm_version >= 0:
encoded_sesh = self.encode_session(usr_sesh) encoded_sesh = self.encode_session(usr_sesh)
resp.set_cookie("DIANA_SESH", encoded_sesh) resp.set_cookie("ARTEMIS_SESH", encoded_sesh)
return resp return resp
else: else:
@ -240,7 +240,7 @@ class ChuniFrontend(FE_Base):
encoded_sesh = self.encode_session(usr_sesh) encoded_sesh = self.encode_session(usr_sesh)
self.logger.info(f"Created session with JWT {encoded_sesh}") self.logger.info(f"Created session with JWT {encoded_sesh}")
resp = RedirectResponse("/game/chuni/", 303) resp = RedirectResponse("/game/chuni/", 303)
resp.set_cookie("DIANA_SESH", encoded_sesh) resp.set_cookie("ARTEMIS_SESH", encoded_sesh)
return resp return resp
else: else:
return RedirectResponse("/gate/", 303) return RedirectResponse("/gate/", 303)

View File

@ -5,7 +5,7 @@
</style> </style>
<div class="container"> <div class="container">
{% include 'titles/chuni/templates/chuni_header.jinja' %} {% include 'titles/chuni/templates/chuni_header.jinja' %}
{% if profile is defined and profile is not none and profile.id > 0 %} {% if profile is defined and profile is not none and profile|length > 0 %}
<div class="row"> <div class="row">
<div class="col-lg-8 m-auto mt-3"> <div class="col-lg-8 m-auto mt-3">
<div class="card bg-card rounded"> <div class="card bg-card rounded">

View File

@ -183,7 +183,11 @@ class DivaReader(BaseReader):
pv_list[pv_id] = self.add_branch(pv_list[pv_id], key_args, val) pv_list[pv_id] = self.add_branch(pv_list[pv_id], key_args, val)
for pv_id, pv_data in pv_list.items(): for pv_id, pv_data in pv_list.items():
song_id = int(pv_id.split("_")[1]) try:
song_id = int(pv_id.split("_")[1])
except ValueError:
self.logger.error(f"Invalid song ID format: {pv_id}")
continue
if "songinfo" not in pv_data: if "songinfo" not in pv_data:
continue continue
if "illustrator" not in pv_data["songinfo"]: if "illustrator" not in pv_data["songinfo"]:

View File

@ -2,10 +2,12 @@ from titles.mai2.index import Mai2Servlet
from titles.mai2.const import Mai2Constants from titles.mai2.const import Mai2Constants
from titles.mai2.database import Mai2Data from titles.mai2.database import Mai2Data
from titles.mai2.read import Mai2Reader from titles.mai2.read import Mai2Reader
from .frontend import Mai2Frontend
index = Mai2Servlet index = Mai2Servlet
database = Mai2Data database = Mai2Data
reader = Mai2Reader reader = Mai2Reader
frontend = Mai2Frontend
game_codes = [ game_codes = [
Mai2Constants.GAME_CODE_DX, Mai2Constants.GAME_CODE_DX,
Mai2Constants.GAME_CODE_FINALE, Mai2Constants.GAME_CODE_FINALE,

View File

@ -4,6 +4,7 @@ import logging
from base64 import b64decode from base64 import b64decode
from os import path, stat, remove from os import path, stat, remove
from PIL import ImageFile from PIL import ImageFile
from random import randint
import pytz import pytz
from core.config import CoreConfig from core.config import CoreConfig
@ -886,3 +887,45 @@ class Mai2Base:
self.logger.error(f"Failed to delete {out_name}.bin, please remove it manually") self.logger.error(f"Failed to delete {out_name}.bin, please remove it manually")
return {'returnCode': ret_code, 'apiName': 'UploadUserPhotoApi'} return {'returnCode': ret_code, 'apiName': 'UploadUserPhotoApi'}
async def handle_get_user_favorite_item_api_request(self, data: Dict) -> Dict:
user_id = data.get("userId", 0)
kind = data.get("kind", 0) # 1 is fav music, 2 is rival user IDs
next_index = data.get("nextIndex", 0)
max_ct = data.get("maxCount", 100) # always 100
is_all = data.get("isAllFavoriteItem", False) # always false
id_list: List[Dict] = []
if user_id:
if kind == 1:
fav_music = await self.data.item.get_fav_music(user_id)
if fav_music:
for fav in fav_music:
id_list.append({"orderId": 0, "id": fav["musicId"]})
if len(id_list) >= 100: # Lazy but whatever
break
elif kind == 2:
rivals = await self.data.profile.get_rivals_game(user_id)
if rivals:
for rival in rivals:
id_list.append({"orderId": 0, "id": rival["rival"]})
return {
"userId": user_id,
"kind": kind,
"nextIndex": 0,
"userFavoriteItemList": id_list,
}
async def handle_get_user_recommend_rate_music_api_request(self, data: Dict) -> Dict:
"""
userRecommendRateMusicIdList: list[int]
"""
return {"userId": data["userId"], "userRecommendRateMusicIdList": []}
async def handle_get_user_recommend_select_music_api_request(self, data: Dict) -> Dict:
"""
userRecommendSelectionMusicIdList: list[int]
"""
return {"userId": data["userId"], "userRecommendSelectionMusicIdList": []}

View File

@ -17,16 +17,3 @@ class Mai2Buddies(Mai2FestivalPlus):
# hardcode lastDataVersion for CardMaker # hardcode lastDataVersion for CardMaker
user_data["lastDataVersion"] = "1.40.00" user_data["lastDataVersion"] = "1.40.00"
return user_data return user_data
async def handle_get_user_new_item_api_request(self, data: Dict) -> Dict:
# TODO: Added in 1.41, implement this?
user_id = data["userId"]
version = data.get("version", 1041000)
user_playlog_list = data.get("userPlaylogList", [])
return {
"userId": user_id,
"itemKind": -1,
"itemId": -1,
}

View File

@ -563,33 +563,76 @@ class Mai2DX(Mai2Base):
return {"userId": data["userId"], "length": 0, "userRegionList": []} return {"userId": data["userId"], "length": 0, "userRegionList": []}
async def handle_get_user_rival_data_api_request(self, data: Dict) -> Dict: async def handle_get_user_rival_data_api_request(self, data: Dict) -> Dict:
user_id = data["userId"] user_id = data.get("userId", 0)
rival_id = data["rivalId"] rival_id = data.get("rivalId", 0)
""" if not user_id or not rival_id: return {}
class UserRivalData:
rivalId: int rival_pf = await self.data.profile.get_profile_detail(rival_id, self.version)
rivalName: str if not rival_pf: return {}
"""
return {"userId": user_id, "userRivalData": {}} return {
"userId": user_id,
"userRivalData": {
"rivalId": rival_id,
"rivalName": rival_pf['userName']
}
}
async def handle_get_user_rival_music_api_request(self, data: Dict) -> Dict: async def handle_get_user_rival_music_api_request(self, data: Dict) -> Dict:
user_id = data.get("userId", 0)
rival_id = data.get("rivalId", 0)
next_index = data.get("nextIndex", 0)
max_ct = 100
upper_lim = next_index + max_ct
rival_music_list: Dict[int, List] = {}
songs = await self.data.score.get_best_scores(rival_id)
if songs is None:
self.logger.debug("handle_get_user_rival_music_api_request: get_best_scores returned None!")
return {
"userId": user_id,
"rivalId": rival_id,
"nextIndex": 0,
"userRivalMusicList": [] # musicId userRivalMusicDetailList -> level achievement deluxscoreMax
}
num_user_songs = len(songs)
for x in range(next_index, upper_lim):
if x >= num_user_songs:
break
tmp = songs[x]._asdict()
if tmp['musicId'] in rival_music_list:
rival_music_list[tmp['musicId']].append([{"level": tmp['level'], 'achievement': tmp['achievement'], 'deluxscoreMax': tmp['deluxscoreMax']}])
else:
if len(rival_music_list) >= max_ct:
break
rival_music_list[tmp['musicId']] = [{"level": tmp['level'], 'achievement': tmp['achievement'], 'deluxscoreMax': tmp['deluxscoreMax']}]
next_index = 0 if len(rival_music_list) < max_ct or num_user_songs == upper_lim else upper_lim
self.logger.info(f"Send rival {rival_id} songs {next_index}-{upper_lim} ({len(rival_music_list)}) out of {num_user_songs} for user {user_id} (next idx {next_index})")
return {
"userId": user_id,
"rivalId": rival_id,
"nextIndex": next_index,
"userRivalMusicList": [{"musicId": x, "userRivalMusicDetailList": y} for x, y in rival_music_list.items()]
}
async def handle_get_user_new_item_api_request(self, data: Dict) -> Dict:
# TODO: Added in 1.41, implement this?
user_id = data["userId"] user_id = data["userId"]
rival_id = data["rivalId"] version = data.get("version", 1041000)
next_idx = data["nextIndex"] user_playlog_list = data.get("userPlaylogList", [])
rival_music_levels = data["userRivalMusicLevelList"]
""" return {
class UserRivalMusicList: "userId": user_id,
class UserRivalMusicDetailList: "itemKind": -1,
level: int "itemId": -1,
achievement: int }
deluxscoreMax: int
musicId: int
userRivalMusicDetailList: list[UserRivalMusicDetailList]
"""
return {"userId": user_id, "nextIndex": 0, "userRivalMusicList": []}
async def handle_get_user_music_api_request(self, data: Dict) -> Dict: async def handle_get_user_music_api_request(self, data: Dict) -> Dict:
user_id = data.get("userId", 0) user_id = data.get("userId", 0)
@ -636,3 +679,208 @@ class Mai2DX(Mai2Base):
return ret return ret
ret['loginId'] = ret.get('loginCount', 0) ret['loginId'] = ret.get('loginCount', 0)
return ret return ret
# CardMaker support added in Universe
async def handle_cm_get_user_preview_api_request(self, data: Dict) -> Dict:
p = await self.data.profile.get_profile_detail(data["userId"], self.version)
if p is None:
return {}
return {
"userName": p["userName"],
"rating": p["playerRating"],
# hardcode lastDataVersion for CardMaker
"lastDataVersion": "1.20.00", # Future versiohs should replace this with the correct version
# checks if the user is still logged in
"isLogin": False,
"isExistSellingCard": True,
}
async def handle_cm_get_user_data_api_request(self, data: Dict) -> Dict:
# user already exists, because the preview checks that already
p = await self.data.profile.get_profile_detail(data["userId"], self.version)
cards = await self.data.card.get_user_cards(data["userId"])
if cards is None or len(cards) == 0:
# This should never happen
self.logger.error(
f"handle_get_user_data_api_request: Internal error - No cards found for user id {data['userId']}"
)
return {}
# get the dict representation of the row so we can modify values
user_data = p._asdict()
# remove the values the game doesn't want
user_data.pop("id")
user_data.pop("user")
user_data.pop("version")
return {"userId": data["userId"], "userData": user_data}
async def handle_cm_login_api_request(self, data: Dict) -> Dict:
return {"returnCode": 1}
async def handle_cm_logout_api_request(self, data: Dict) -> Dict:
return {"returnCode": 1}
async def handle_cm_get_selling_card_api_request(self, data: Dict) -> Dict:
selling_cards = await self.data.static.get_enabled_cards(self.version)
if selling_cards is None:
return {"length": 0, "sellingCardList": []}
selling_card_list = []
for card in selling_cards:
tmp = card._asdict()
tmp.pop("id")
tmp.pop("version")
tmp.pop("cardName")
tmp.pop("enabled")
tmp["startDate"] = datetime.strftime(
tmp["startDate"], Mai2Constants.DATE_TIME_FORMAT
)
tmp["endDate"] = datetime.strftime(
tmp["endDate"], Mai2Constants.DATE_TIME_FORMAT
)
tmp["noticeStartDate"] = datetime.strftime(
tmp["noticeStartDate"], Mai2Constants.DATE_TIME_FORMAT
)
tmp["noticeEndDate"] = datetime.strftime(
tmp["noticeEndDate"], Mai2Constants.DATE_TIME_FORMAT
)
selling_card_list.append(tmp)
return {"length": len(selling_card_list), "sellingCardList": selling_card_list}
async def handle_cm_get_user_card_api_request(self, data: Dict) -> Dict:
user_cards = await self.data.item.get_cards(data["userId"])
if user_cards is None:
return {"returnCode": 1, "length": 0, "nextIndex": 0, "userCardList": []}
max_ct = data["maxCount"]
next_idx = data["nextIndex"]
start_idx = next_idx
end_idx = max_ct + start_idx
if len(user_cards[start_idx:]) > max_ct:
next_idx += max_ct
else:
next_idx = 0
card_list = []
for card in user_cards:
tmp = card._asdict()
tmp.pop("id")
tmp.pop("user")
tmp["startDate"] = datetime.strftime(
tmp["startDate"], Mai2Constants.DATE_TIME_FORMAT
)
tmp["endDate"] = datetime.strftime(
tmp["endDate"], Mai2Constants.DATE_TIME_FORMAT
)
card_list.append(tmp)
return {
"returnCode": 1,
"length": len(card_list[start_idx:end_idx]),
"nextIndex": next_idx,
"userCardList": card_list[start_idx:end_idx],
}
async def handle_cm_get_user_item_api_request(self, data: Dict) -> Dict:
await self.handle_get_user_item_api_request(data)
async def handle_cm_get_user_character_api_request(self, data: Dict) -> Dict:
characters = await self.data.item.get_characters(data["userId"])
chara_list = []
for chara in characters:
chara_list.append(
{
"characterId": chara["characterId"],
# no clue why those values are even needed
"point": 0,
"count": 0,
"level": chara["level"],
"nextAwake": 0,
"nextAwakePercent": 0,
"favorite": False,
"awakening": chara["awakening"],
"useCount": chara["useCount"],
}
)
return {
"returnCode": 1,
"length": len(chara_list),
"userCharacterList": chara_list,
}
async def handle_cm_get_user_card_print_error_api_request(self, data: Dict) -> Dict:
return {"length": 0, "userPrintDetailList": []}
async def handle_cm_upsert_user_print_api_request(self, data: Dict) -> Dict:
user_id = data["userId"]
upsert = data["userPrintDetail"]
# set a random card serial number
serial_id = "".join([str(randint(0, 9)) for _ in range(20)])
# calculate start and end date of the card
start_date = datetime.utcnow()
end_date = datetime.utcnow() + timedelta(days=15)
user_card = upsert["userCard"]
await self.data.item.put_card(
user_id,
user_card["cardId"],
user_card["cardTypeId"],
user_card["charaId"],
user_card["mapId"],
# add the correct start date and also the end date in 15 days
start_date,
end_date,
)
# get the profile extend to save the new bought card
extend = await self.data.profile.get_profile_extend(user_id, self.version)
if extend:
extend = extend._asdict()
# parse the selectedCardList
# 6 = Freedom Pass, 4 = Gold Pass (cardTypeId)
selected_cards: List = extend["selectedCardList"]
# if no pass is already added, add the corresponding pass
if not user_card["cardTypeId"] in selected_cards:
selected_cards.insert(0, user_card["cardTypeId"])
extend["selectedCardList"] = selected_cards
await self.data.profile.put_profile_extend(user_id, self.version, extend)
# properly format userPrintDetail for the database
upsert.pop("userCard")
upsert.pop("serialId")
upsert["printDate"] = datetime.strptime(upsert["printDate"], "%Y-%m-%d")
await self.data.item.put_user_print_detail(user_id, serial_id, upsert)
return {
"returnCode": 1,
"orderId": 0,
"serialId": serial_id,
"startDate": datetime.strftime(start_date, Mai2Constants.DATE_TIME_FORMAT),
"endDate": datetime.strftime(end_date, Mai2Constants.DATE_TIME_FORMAT),
}
async def handle_cm_upsert_user_printlog_api_request(self, data: Dict) -> Dict:
return {
"returnCode": 1,
"orderId": 0,
"serialId": data["userPrintlog"]["serialId"],
}
async def handle_cm_upsert_buy_card_api_request(self, data: Dict) -> Dict:
return {"returnCode": 1}

View File

@ -20,18 +20,6 @@ class Mai2Festival(Mai2UniversePlus):
async def handle_user_login_api_request(self, data: Dict) -> Dict: async def handle_user_login_api_request(self, data: Dict) -> Dict:
user_login = await super().handle_user_login_api_request(data) user_login = await super().handle_user_login_api_request(data)
# useless? # TODO: Make use of this
user_login["Bearer"] = "ARTEMiSTOKEN" user_login["Bearer"] = "ARTEMiSTOKEN"
return user_login return user_login
async def handle_get_user_recommend_rate_music_api_request(self, data: Dict) -> Dict:
"""
userRecommendRateMusicIdList: list[int]
"""
return {"userId": data["userId"], "userRecommendRateMusicIdList": []}
async def handle_get_user_recommend_select_music_api_request(self, data: Dict) -> Dict:
"""
userRecommendSelectionMusicIdList: list[int]
"""
return {"userId": data["userId"], "userRecommendSelectionMusicIdList": []}

View File

@ -17,22 +17,3 @@ class Mai2FestivalPlus(Mai2Festival):
# hardcode lastDataVersion for CardMaker # hardcode lastDataVersion for CardMaker
user_data["lastDataVersion"] = "1.35.00" user_data["lastDataVersion"] = "1.35.00"
return user_data return user_data
async def handle_get_user_favorite_item_api_request(self, data: Dict) -> Dict:
user_id = data.get("userId", 0)
kind = data.get("kind", 2)
next_index = data.get("nextIndex", 0)
max_ct = data.get("maxCount", 100)
is_all = data.get("isAllFavoriteItem", False)
"""
class userFavoriteItemList:
orderId: int
id: int
"""
return {
"userId": user_id,
"kind": kind,
"nextIndex": 0,
"userFavoriteItemList": [],
}

190
titles/mai2/frontend.py Normal file
View File

@ -0,0 +1,190 @@
from typing import List
from starlette.routing import Route, Mount
from starlette.requests import Request
from starlette.responses import Response, RedirectResponse
from os import path
import yaml
import jinja2
from core.frontend import FE_Base, UserSession
from core.config import CoreConfig
from .database import Mai2Data
from .config import Mai2Config
from .const import Mai2Constants
class Mai2Frontend(FE_Base):
def __init__(
self, cfg: CoreConfig, environment: jinja2.Environment, cfg_dir: str
) -> None:
super().__init__(cfg, environment)
self.data = Mai2Data(cfg)
self.game_cfg = Mai2Config()
if path.exists(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}"):
self.game_cfg.update(
yaml.safe_load(open(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}"))
)
self.nav_name = "maimai"
def get_routes(self) -> List[Route]:
return [
Route("/", self.render_GET, 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("/version.change", self.version_change, methods=['POST']),
]
async def render_GET(self, request: Request) -> bytes:
template = self.environment.get_template(
"titles/mai2/templates/mai2_index.jinja"
)
usr_sesh = self.validate_session(request)
if not usr_sesh:
usr_sesh = UserSession()
if usr_sesh.user_id > 0:
versions = await self.data.profile.get_all_profile_versions(usr_sesh.user_id)
profile = []
if versions:
# maimai_version is -1 means it is not initialized yet, select a default version from existing.
if usr_sesh.maimai_version < 0:
usr_sesh.maimai_version = versions[0]['version']
profile = await self.data.profile.get_profile_detail(usr_sesh.user_id, usr_sesh.maimai_version)
versions = [x['version'] for x in versions]
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=Mai2Constants.VERSION_STRING,
versions=versions,
cur_version=usr_sesh.maimai_version
), media_type="text/html; charset=utf-8")
if usr_sesh.maimai_version >= 0:
encoded_sesh = self.encode_session(usr_sesh)
resp.delete_cookie("ARTEMIS_SESH")
resp.set_cookie("ARTEMIS_SESH", encoded_sesh)
return resp
else:
return RedirectResponse("/gate/", 303)
async def render_GET_playlog(self, request: Request) -> bytes:
template = self.environment.get_template(
"titles/mai2/templates/mai2_playlog.jinja"
)
usr_sesh = self.validate_session(request)
if not usr_sesh:
print("wtf")
usr_sesh = UserSession()
if usr_sesh.user_id > 0:
if usr_sesh.maimai_version < 0:
print(usr_sesh.maimai_version)
return RedirectResponse("/game/mai2/", 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(user_id, index, 20)
playlog_with_title = []
for record in playlog:
music_chart = await self.data.static.get_music_chart(usr_sesh.maimai_version, record.musicId, record.level)
if music_chart:
difficultyNum=music_chart.chartId
difficulty=music_chart.difficulty
artist=music_chart.artist
title=music_chart.title
else:
difficultyNum=0
difficulty=0
artist="unknown"
title="musicid: " + str(record.musicId)
playlog_with_title.append({
"raw": record,
"title": title,
"difficultyNum": difficultyNum,
"difficulty": difficulty,
"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:
usr_sesh = self.validate_session(request)
if not usr_sesh:
return RedirectResponse("/gate/", 303)
form_data = await request.form()
new_name: str = form_data.get("new_name")
new_name_full = ""
if not new_name:
return RedirectResponse("/gate/?e=4", 303)
if len(new_name) > 8:
return RedirectResponse("/gate/?e=8", 303)
for x in new_name: # FIXME: This will let some invalid characters through atm
o = ord(x)
try:
if o == 0x20:
new_name_full += chr(0x3000)
elif o < 0x7F and o > 0x20:
new_name_full += chr(o + 0xFEE0)
elif o <= 0x7F:
self.logger.warn(f"Invalid ascii character {o:02X}")
return RedirectResponse("/gate/?e=4", 303)
else:
new_name_full += x
except Exception as e:
self.logger.error(f"Something went wrong parsing character {o:04X} - {e}")
return RedirectResponse("/gate/?e=4", 303)
if not await self.data.profile.update_name(usr_sesh.user_id, new_name_full):
return RedirectResponse("/gate/?e=999", 303)
return RedirectResponse("/game/mai2/?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()
maimai_version = form_data.get("version")
self.logger.info(f"version change to: {maimai_version}")
if(maimai_version.isdigit()):
usr_sesh.maimai_version=int(maimai_version)
encoded_sesh = self.encode_session(usr_sesh)
self.logger.info(f"Created session with JWT {encoded_sesh}")
resp = RedirectResponse("/game/mai2/", 303)
resp.set_cookie("ARTEMIS_SESH", encoded_sesh)
return resp
else:
return RedirectResponse("/gate/", 303)

View File

@ -134,6 +134,20 @@ favorite = Table(
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
fav_music = Table(
"mai2_item_favorite_music",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
Column("musicId", Integer, nullable=False),
UniqueConstraint("user", "musicId", name="mai2_item_favorite_music_uk"),
mysql_charset="utf8mb4",
)
charge = Table( charge = Table(
"mai2_item_charge", "mai2_item_charge",
metadata, metadata,
@ -451,6 +465,30 @@ class Mai2ItemData(BaseData):
return None return None
return result.fetchall() return result.fetchall()
async def get_fav_music(self, user_id: int) -> Optional[List[Row]]:
result = await self.execute(fav_music.select(fav_music.c.user == user_id))
if result:
return result.fetchall()
async def add_fav_music(self, user_id: int, music_id: int) -> Optional[int]:
sql = insert(fav_music).values(
user = user_id,
musicId = music_id
)
conflict = sql.on_duplicate_key_do_nothing()
result = await self.execute(conflict)
if result:
return result.lastrowid
self.logger.error(f"Failed to add music {music_id} as favorite for user {user_id}!")
async def remove_fav_music(self, user_id: int, music_id: int) -> None:
result = await self.execute(fav_music.delete(and_(fav_music.c.user == user_id, fav_music.c.musicId == music_id)))
if not result:
self.logger.error(f"Failed to remove music {music_id} as favorite for user {user_id}!")
async def put_card( async def put_card(
self, self,
user_id: int, user_id: int,

View File

@ -491,8 +491,31 @@ consec_logins = Table(
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
rival = Table(
"mai2_user_rival",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
Column(
"rival",
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
Column("show", Boolean, nullable=False, server_default="0"),
UniqueConstraint("user", "rival", name="mai2_user_rival_uk"),
mysql_charset="utf8mb4",
)
class Mai2ProfileData(BaseData): class Mai2ProfileData(BaseData):
async def get_all_profile_versions(self, user_id: int) -> Optional[List[Row]]:
result = await self.execute(detail.select(detail.c.user == user_id))
if result:
return result.fetchall()
async def put_profile_detail( async def put_profile_detail(
self, user_id: int, version: int, detail_data: Dict, is_dx: bool = True self, user_id: int, version: int, detail_data: Dict, is_dx: bool = True
) -> Optional[Row]: ) -> Optional[Row]:
@ -843,3 +866,52 @@ class Mai2ProfileData(BaseData):
if result is None: if result is None:
return None return None
return result.fetchone() return result.fetchone()
async def get_rivals(self, user_id: int) -> Optional[List[Row]]:
result = await self.execute(rival.select(rival.c.user == user_id))
if result:
return result.fetchall()
async def get_rivals_game(self, user_id: int) -> Optional[List[Row]]:
result = await self.execute(rival.select(and_(rival.c.user == user_id, rival.c.show == True)).limit(3))
if result:
return result.fetchall()
async def set_rival_shown(self, user_id: int, rival_id: int, is_shown: bool) -> None:
sql = rival.update(and_(rival.c.user == user_id, rival.c.rival == rival_id)).values(
show = is_shown
)
result = await self.execute(sql)
if not result:
self.logger.error(f"Failed to set rival {rival_id} shown status to {is_shown} for user {user_id}")
async def add_rival(self, user_id: int, rival_id: int) -> Optional[int]:
sql = insert(rival).values(
user = user_id,
rival = rival_id
)
conflict = sql.on_duplicate_key_do_nothing()
result = await self.execute(conflict)
if result:
return result.lastrowid
self.logger.error(f"Failed to add music {rival_id} as favorite for user {user_id}!")
async def remove_rival(self, user_id: int, rival_id: int) -> None:
result = await self.execute(rival.delete(and_(rival.c.user == user_id, rival.c.rival == rival_id)))
if not result:
self.logger.error(f"Failed to remove rival {rival_id} for user {user_id}!")
async def update_name(self, user_id: int, new_name: str) -> bool:
sql = detail.update(detail.c.user == user_id).values(
userName=new_name
)
result = await self.execute(sql)
if result is None:
self.logger.warning(f"Failed to set user {user_id} name to {new_name}")
return False
return True

View File

@ -319,16 +319,16 @@ class Mai2ScoreData(BaseData):
sql = best_score.select( sql = best_score.select(
and_( and_(
best_score.c.user == user_id, best_score.c.user == user_id,
(best_score.c.song_id == song_id) if song_id is not None else True, (best_score.c.musicId == song_id) if song_id is not None else True,
) )
) ).order_by(best_score.c.musicId).order_by(best_score.c.level)
else: else:
sql = best_score_old.select( sql = best_score_old.select(
and_( and_(
best_score_old.c.user == user_id, best_score_old.c.user == user_id,
(best_score_old.c.song_id == song_id) if song_id is not None else True, (best_score_old.c.musicId == song_id) if song_id is not None else True,
) )
) ).order_by(best_score.c.musicId).order_by(best_score.c.level)
result = await self.execute(sql) result = await self.execute(sql)
if result is None: if result is None:
@ -398,3 +398,23 @@ class Mai2ScoreData(BaseData):
if result is None: if result is None:
return None return None
return result.fetchall() return result.fetchall()
async def get_playlogs(self, user_id: int, idx: int = 0, limit: int = 0) -> Optional[List[Row]]:
sql = playlog.select(playlog.c.user == user_id)
if limit:
sql = sql.limit(limit)
if idx:
sql = sql.offset(idx * limit)
result = await self.execute(sql)
if result:
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()

View File

@ -0,0 +1,195 @@
.mai2-header {
text-align: center;
}
ul.mai2-navi {
list-style-type: none;
padding: 0;
overflow: hidden;
background-color: #333;
text-align: center;
display: inline-block;
}
ul.mai2-navi li {
display: inline-block;
}
ul.mai2-navi li a {
display: block;
color: white;
text-align: center;
padding: 14px 16px;
text-decoration: none;
}
ul.mai2-navi li a:hover:not(.active) {
background-color: #111;
}
ul.mai2-navi li a.active {
background-color: #4CAF50;
}
ul.mai2-navi li.right {
float: right;
}
@media screen and (max-width: 600px) {
ul.mai2-navi li.right,
ul.mai2-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);
}
.basic {
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%);
}
}

View File

@ -0,0 +1,17 @@
<div class="mai2-header">
<h1>maimai</h1>
<ul class="mai2-navi">
<li><a class="nav-link" href="/game/mai2/">PROFILE</a></li>
<li><a class="nav-link" href="/game/mai2/playlog/">RECORD</a></li>
</ul>
</div>
<script>
$(document).ready(function () {
var currentPath = window.location.pathname;
if (currentPath === '/game/mai2/') {
$('.nav-link[href="/game/mai2/"]').addClass('active');
} else if (currentPath.startsWith('/game/mai2/playlog/')) {
$('.nav-link[href="/game/mai2/playlog/"]').addClass('active');
}
});
</script>

View File

@ -0,0 +1,134 @@
{% extends "core/templates/index.jinja" %}
{% block content %}
<style>
{% include 'titles/mai2/templates/css/mai2_style.css' %}
</style>
<div class="container">
{% include 'titles/mai2/templates/mai2_header.jinja' %}
{% if profile is defined and profile is not none and profile|length > 0 %}
<div class="row">
<div class="col-lg-8 m-auto mt-3">
<div class="card bg-card rounded">
<table class="table-large table-rowdistinct">
<caption align="top">OVERVIEW</caption>
<tr>
<th>{{ profile.userName }}</th>
<th>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#name_change">Edit</button>
</th>
</tr>
<tr>
<td>version:</td>
<td>
<select name="version" id="version" onChange="changeVersion(this)">
{% for ver in versions %}
{% if ver == cur_version %}
<option value="{{ ver }}" selected>{{ version_list[ver] }}</option>
{% else %}
<option value="{{ ver }}">{{ version_list[ver] }}</option>
{% endif %}
{% endfor %}
</select>
{% if versions | length > 1 %}
<p style="margin-block-end: 0;">You have {{ versions | length }} versions.</p>
{% endif %}
</td>
</tr>
<tr>
<td>Rating:</td>
<td>
<span class="{% if profile.playerRating >= 15000 %}rainbow{% elif profile.playerRating < 15000 and profile.playerRating >= 14500 %}platinum{% elif profile.playerRating < 14500 and profile.playerRating >=14000 %}platinum{% endif %}">
{{ profile.playerRating }}
</span>
<span>
(highest: {{ profile.highestRating }})
</span>
</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 Delux Score:</td>
<td>{{ profile.totalDeluxscore }}</td>
</tr>
<tr>
<td>Total Basic Delux Score:</td>
<td>{{ profile.totalBasicDeluxscore }}</td>
</tr>
<tr>
<td>Total Advanced Delux Score:</td>
<td>{{ profile.totalAdvancedDeluxscore }}</td>
</tr>
<tr>
<td>Total Expert Delux Score:</td>
<td>{{ profile.totalExpertDeluxscore }}</td>
</tr>
<tr>
<td>Total Master Delux Score:</td>
<td>{{ profile.totalMasterDeluxscore }}</td>
</tr>
<tr>
<td>Total ReMaster Delux Score:</td>
<td>{{ profile.totalReMasterDeluxscore }}</td>
</tr>
</table>
</div>
</div>
</div>
{% if error is defined %}
{% include "core/templates/widgets/err_banner.jinja" %}
{% endif %}
{% 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>
<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/mai2/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/mai2/version.change", { version: sel.value })
.done(function (data) {
location.reload();
})
.fail(function () {
alert("Failed to update version.");
});
}
</script>
{% endblock content %}

View File

@ -0,0 +1,225 @@
{% extends "core/templates/index.jinja" %}
{% block content %}
<style>
{% include 'titles/mai2/templates/css/mai2_style.css' %}
</style>
<div class="container">
{% include 'titles/mai2/templates/mai2_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 = ['basic', '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.trackNo }}</h6>
</div>
</div>
<div class="card-body row">
<div class="col-3" style="text-align: center;">
<h4 class="card-text">{{ record.raw.deluxscore }}</h4>
<h2>{{ rankName[record.raw.rank] }}</h2>
<h6
class="{% if record.raw.level == 0 %}basic{% elif record.raw.level == 1 %}advanced{% elif record.raw.level == 2 %}expert{% elif record.raw.level == 3 %}master{% elif record.raw.level == 4 %}remaster{% endif %}">
{{ difficultyName[record.raw.level] }}&nbsp&nbsp{{ record.difficulty }}
</h6>
</div>
<div class="col-6" style="text-align: center;">
<table class="table-small table-rowdistinc">
<tr>
<td>CRITICAL PERFECT</td>
<td>
Tap: {{ record.raw.tapCriticalPerfect }}<br>
Hold: {{ record.raw.holdCriticalPerfect }}<br>
Slide: {{ record.raw.slideCriticalPerfect }}<br>
Touch: {{ record.raw.touchCriticalPerfect }}<br>
Break: {{ record.raw.breakCriticalPerfect }}
</td>
</tr>
<tr>
<td>PERFECT</td>
<td>
Tap: {{ record.raw.tapPerfect }}<br>
Hold: {{ record.raw.holdPerfect }}<br>
Slide: {{ record.raw.slidePerfect }}<br>
Touch: {{ record.raw.touchPerfect }}<br>
Break: {{ record.raw.breakPerfect }}
</td>
</tr>
<tr>
<td>GREAT</td>
<td>
Tap: {{ record.raw.tapGreat }}<br>
Hold: {{ record.raw.holdGreat }}<br>
Slide: {{ record.raw.slideGreat }}<br>
Touch: {{ record.raw.touchGreat }}<br>
Break: {{ record.raw.breakGreat }}
</td>
</tr>
<tr>
<td>GOOD</td>
<td>
Tap: {{ record.raw.tapGood }}<br>
Hold: {{ record.raw.holdGood }}<br>
Slide: {{ record.raw.slideGood }}<br>
Touch: {{ record.raw.touchGood }}<br>
Break: {{ record.raw.breakGood }}
</td>
</tr>
<tr>
<td>MISS</td>
<td>
Tap: {{ record.raw.tapMiss }}<br>
Hold: {{ record.raw.holdMiss }}<br>
Slide: {{ record.raw.slideMiss }}<br>
Touch: {{ record.raw.touchMiss }}<br>
Break: {{ record.raw.breakMiss }}
</td>
</tr>
</table>
</div>
<div class="col-3" style="text-align: center;">
{%if record.raw.comboStatus == 1 %}
<h6>FULL COMBO</h6>
{% endif %}
{%if record.raw.comboStatus == 2 %}
<h6>FULL COMBO +</h6>
{% endif %}
{%if record.raw.comboStatus == 3 %}
<h6>ALL PERFECT</h6>
{% endif %}
{%if record.raw.comboStatus == 4 %}
<h6>ALL PERFECT +</h6>
{% endif %}
{%if record.raw.syncStatus == 1 %}
<h6>FULL SYNC</h6>
{% endif %}
{%if record.raw.syncStatus == 2 %}
<h6>FULL SYNC +</h6>
{% endif %}
{%if record.raw.syncStatus == 3 %}
<h6>FULL SYNC DX</h6>
{% endif %}
{%if record.raw.syncStatus == 4 %}
<h6>FULL SYNC DX +</h6>
{% endif %}
{%if record.raw.isAchieveNewRecord == 1 or record.raw.isDeluxscoreNewRecord == 1 %}
<h6>NEW RECORD</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/mai2/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/mai2/playlog/{{ playlog_pages }}">{{
playlog_pages }}</a></li>
<li class="page-item"><a id="next_page" class="page-link" href="#">Next</a></li>
&nbsp
</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/mai2/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/mai2/playlog/' + pageNumber;
window.location.href = url;
} else {
$('#page_input').val('');
$('#page_input').attr('placeholder', 'invalid input!');
}
});
});
</script>
{% endblock content %}

View File

@ -1,8 +1,6 @@
from typing import Any, List, Dict from typing import Any, List, Dict
from random import randint from random import randint
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytz
import json
from core.config import CoreConfig from core.config import CoreConfig
from titles.mai2.splashplus import Mai2SplashPlus from titles.mai2.splashplus import Mai2SplashPlus
@ -14,207 +12,3 @@ class Mai2Universe(Mai2SplashPlus):
def __init__(self, cfg: CoreConfig, game_cfg: Mai2Config) -> None: def __init__(self, cfg: CoreConfig, game_cfg: Mai2Config) -> None:
super().__init__(cfg, game_cfg) super().__init__(cfg, game_cfg)
self.version = Mai2Constants.VER_MAIMAI_DX_UNIVERSE self.version = Mai2Constants.VER_MAIMAI_DX_UNIVERSE
async def handle_cm_get_user_preview_api_request(self, data: Dict) -> Dict:
p = await self.data.profile.get_profile_detail(data["userId"], self.version)
if p is None:
return {}
return {
"userName": p["userName"],
"rating": p["playerRating"],
# hardcode lastDataVersion for CardMaker
"lastDataVersion": "1.20.00",
# checks if the user is still logged in
"isLogin": False,
"isExistSellingCard": True,
}
async def handle_cm_get_user_data_api_request(self, data: Dict) -> Dict:
# user already exists, because the preview checks that already
p = await self.data.profile.get_profile_detail(data["userId"], self.version)
cards = await self.data.card.get_user_cards(data["userId"])
if cards is None or len(cards) == 0:
# This should never happen
self.logger.error(
f"handle_get_user_data_api_request: Internal error - No cards found for user id {data['userId']}"
)
return {}
# get the dict representation of the row so we can modify values
user_data = p._asdict()
# remove the values the game doesn't want
user_data.pop("id")
user_data.pop("user")
user_data.pop("version")
return {"userId": data["userId"], "userData": user_data}
async def handle_cm_login_api_request(self, data: Dict) -> Dict:
return {"returnCode": 1}
async def handle_cm_logout_api_request(self, data: Dict) -> Dict:
return {"returnCode": 1}
async def handle_cm_get_selling_card_api_request(self, data: Dict) -> Dict:
selling_cards = await self.data.static.get_enabled_cards(self.version)
if selling_cards is None:
return {"length": 0, "sellingCardList": []}
selling_card_list = []
for card in selling_cards:
tmp = card._asdict()
tmp.pop("id")
tmp.pop("version")
tmp.pop("cardName")
tmp.pop("enabled")
tmp["startDate"] = datetime.strftime(
tmp["startDate"], Mai2Constants.DATE_TIME_FORMAT
)
tmp["endDate"] = datetime.strftime(
tmp["endDate"], Mai2Constants.DATE_TIME_FORMAT
)
tmp["noticeStartDate"] = datetime.strftime(
tmp["noticeStartDate"], Mai2Constants.DATE_TIME_FORMAT
)
tmp["noticeEndDate"] = datetime.strftime(
tmp["noticeEndDate"], Mai2Constants.DATE_TIME_FORMAT
)
selling_card_list.append(tmp)
return {"length": len(selling_card_list), "sellingCardList": selling_card_list}
async def handle_cm_get_user_card_api_request(self, data: Dict) -> Dict:
user_cards = await self.data.item.get_cards(data["userId"])
if user_cards is None:
return {"returnCode": 1, "length": 0, "nextIndex": 0, "userCardList": []}
max_ct = data["maxCount"]
next_idx = data["nextIndex"]
start_idx = next_idx
end_idx = max_ct + start_idx
if len(user_cards[start_idx:]) > max_ct:
next_idx += max_ct
else:
next_idx = 0
card_list = []
for card in user_cards:
tmp = card._asdict()
tmp.pop("id")
tmp.pop("user")
tmp["startDate"] = datetime.strftime(
tmp["startDate"], Mai2Constants.DATE_TIME_FORMAT
)
tmp["endDate"] = datetime.strftime(
tmp["endDate"], Mai2Constants.DATE_TIME_FORMAT
)
card_list.append(tmp)
return {
"returnCode": 1,
"length": len(card_list[start_idx:end_idx]),
"nextIndex": next_idx,
"userCardList": card_list[start_idx:end_idx],
}
async def handle_cm_get_user_item_api_request(self, data: Dict) -> Dict:
await super().handle_get_user_item_api_request(data)
async def handle_cm_get_user_character_api_request(self, data: Dict) -> Dict:
characters = await self.data.item.get_characters(data["userId"])
chara_list = []
for chara in characters:
chara_list.append(
{
"characterId": chara["characterId"],
# no clue why those values are even needed
"point": 0,
"count": 0,
"level": chara["level"],
"nextAwake": 0,
"nextAwakePercent": 0,
"favorite": False,
"awakening": chara["awakening"],
"useCount": chara["useCount"],
}
)
return {
"returnCode": 1,
"length": len(chara_list),
"userCharacterList": chara_list,
}
async def handle_cm_get_user_card_print_error_api_request(self, data: Dict) -> Dict:
return {"length": 0, "userPrintDetailList": []}
async def handle_cm_upsert_user_print_api_request(self, data: Dict) -> Dict:
user_id = data["userId"]
upsert = data["userPrintDetail"]
# set a random card serial number
serial_id = "".join([str(randint(0, 9)) for _ in range(20)])
# calculate start and end date of the card
start_date = datetime.utcnow()
end_date = datetime.utcnow() + timedelta(days=15)
user_card = upsert["userCard"]
await self.data.item.put_card(
user_id,
user_card["cardId"],
user_card["cardTypeId"],
user_card["charaId"],
user_card["mapId"],
# add the correct start date and also the end date in 15 days
start_date,
end_date,
)
# get the profile extend to save the new bought card
extend = await self.data.profile.get_profile_extend(user_id, self.version)
if extend:
extend = extend._asdict()
# parse the selectedCardList
# 6 = Freedom Pass, 4 = Gold Pass (cardTypeId)
selected_cards: List = extend["selectedCardList"]
# if no pass is already added, add the corresponding pass
if not user_card["cardTypeId"] in selected_cards:
selected_cards.insert(0, user_card["cardTypeId"])
extend["selectedCardList"] = selected_cards
await self.data.profile.put_profile_extend(user_id, self.version, extend)
# properly format userPrintDetail for the database
upsert.pop("userCard")
upsert.pop("serialId")
upsert["printDate"] = datetime.strptime(upsert["printDate"], "%Y-%m-%d")
await self.data.item.put_user_print_detail(user_id, serial_id, upsert)
return {
"returnCode": 1,
"orderId": 0,
"serialId": serial_id,
"startDate": datetime.strftime(start_date, Mai2Constants.DATE_TIME_FORMAT),
"endDate": datetime.strftime(end_date, Mai2Constants.DATE_TIME_FORMAT),
}
async def handle_cm_upsert_user_printlog_api_request(self, data: Dict) -> Dict:
return {
"returnCode": 1,
"orderId": 0,
"serialId": data["userPrintlog"]["serialId"],
}
async def handle_cm_upsert_buy_card_api_request(self, data: Dict) -> Dict:
return {"returnCode": 1}

View File

@ -31,7 +31,8 @@ class OngekiFrontend(FE_Base):
def get_routes(self) -> List[Route]: def get_routes(self) -> List[Route]:
return [ return [
Route("/", self.render_GET) Route("/", self.render_GET),
Route("/version.change", self.render_POST, methods=['POST'])
] ]
async def render_GET(self, request: Request) -> bytes: async def render_GET(self, request: Request) -> bytes:
@ -69,29 +70,34 @@ class OngekiFrontend(FE_Base):
return RedirectResponse("/gate/", 303) return RedirectResponse("/gate/", 303)
async def render_POST(self, request: Request): async def render_POST(self, request: Request):
uri = request.uri.decode() uri = request.url.path
frm = await request.form()
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()
if usr_sesh.user_id > 0: if usr_sesh.user_id > 0:
if uri == "/game/ongeki/rival.add": if uri == "/game/ongeki/rival.add":
rival_id = request.args[b"rivalUserId"][0].decode() rival_id = frm.get("rivalUserId")
await self.data.profile.put_rival(usr_sesh.user_id, rival_id) await self.data.profile.put_rival(usr_sesh.user_id, rival_id)
# self.logger.info(f"{usr_sesh.user_id} added a rival") # self.logger.info(f"{usr_sesh.user_id} added a rival")
return RedirectResponse(b"/game/ongeki/", 303) return RedirectResponse(b"/game/ongeki/", 303)
elif uri == "/game/ongeki/rival.delete": elif uri == "/game/ongeki/rival.delete":
rival_id = request.args[b"rivalUserId"][0].decode() rival_id = frm.get("rivalUserId")
await self.data.profile.delete_rival(usr_sesh.user_id, rival_id) await self.data.profile.delete_rival(usr_sesh.user_id, rival_id)
# self.logger.info(f"{response}") # self.logger.info(f"{response}")
return RedirectResponse(b"/game/ongeki/", 303) return RedirectResponse(b"/game/ongeki/", 303)
elif uri == "/game/ongeki/version.change": elif uri == "/game/ongeki/version.change":
ongeki_version=request.args[b"version"][0].decode() ongeki_version=frm.get("version")
if(ongeki_version.isdigit()): if(ongeki_version.isdigit()):
usr_sesh.ongeki_version=int(ongeki_version) usr_sesh.ongeki_version=int(ongeki_version)
return RedirectResponse("/game/ongeki/", 303) enc = self.encode_session(usr_sesh)
resp = RedirectResponse("/game/ongeki/", 303)
resp.delete_cookie('ARTEMIS_SESH')
resp.set_cookie('ARTEMIS_SESH', enc)
return resp
else: else:
Response("Something went wrong", status_code=500) Response("Something went wrong", status_code=500)

View File

@ -30,7 +30,7 @@ score_best = Table(
Column("isFullCombo", Boolean, nullable=False), Column("isFullCombo", Boolean, nullable=False),
Column("isAllBreake", Boolean, nullable=False), Column("isAllBreake", Boolean, nullable=False),
Column("isLock", Boolean, nullable=False), Column("isLock", Boolean, nullable=False),
Column("clearStatus", Boolean, nullable=False), Column("clearStatus", Integer, nullable=False),
Column("isStoryWatched", Boolean, nullable=False), Column("isStoryWatched", Boolean, nullable=False),
Column("platinumScoreMax", Integer), Column("platinumScoreMax", Integer),
UniqueConstraint("user", "musicId", "level", name="ongeki_best_score_uk"), UniqueConstraint("user", "musicId", "level", name="ongeki_best_score_uk"),