Merge branch 'develop' into idac

This commit is contained in:
Dniel97 2024-06-23 22:12:32 +02:00
commit 66114238a5
Signed by untrusted user: Dniel97
GPG Key ID: 6180B3C768FB2E08
30 changed files with 1492 additions and 62 deletions

View File

@ -1,6 +1,14 @@
# 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.
## 20240620
### CHUNITHM
+ CHUNITHM LUMINOUS support
## 20240616
### DIVA
+ Working frontend with name and level strings edit and playlog
## 20240530 ## 20240530
### DIVA ### DIVA
+ Fix reader for when dificulty is not a int + Fix reader for when dificulty is not a int

View File

@ -10,13 +10,14 @@ class ADBFelicaLookupRequest(ADBBaseRequest):
self.pmm = hex(pmm)[2:].upper() self.pmm = hex(pmm)[2:].upper()
class ADBFelicaLookupResponse(ADBBaseResponse): class ADBFelicaLookupResponse(ADBBaseResponse):
def __init__(self, access_code: str = None, game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", code: int = 0x03, length: int = 0x30, status: int = 1) -> None: def __init__(self, access_code: str = None, idx: int = 0, game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", code: int = 0x03, length: int = 0x30, status: int = 1) -> None:
super().__init__(code, length, status, game_id, store_id, keychip_id) super().__init__(code, length, status, game_id, store_id, keychip_id)
self.access_code = access_code if access_code is not None else "00000000000000000000" self.access_code = access_code if access_code is not None else "00000000000000000000"
self.idx = idx
@classmethod @classmethod
def from_req(cls, req: ADBHeader, access_code: str = None) -> "ADBFelicaLookupResponse": def from_req(cls, req: ADBHeader, access_code: str = None, idx: int = 0) -> "ADBFelicaLookupResponse":
c = cls(access_code, req.game_id, req.store_id, req.keychip_id) c = cls(access_code, idx, req.game_id, req.store_id, req.keychip_id)
c.head.protocol_ver = req.protocol_ver c.head.protocol_ver = req.protocol_ver
return c return c
@ -26,7 +27,7 @@ class ADBFelicaLookupResponse(ADBBaseResponse):
"access_code" / Int8ub[10], "access_code" / Int8ub[10],
Padding(2) Padding(2)
).build(dict( ).build(dict(
felica_idx = 0, felica_idx = self.idx,
access_code = bytes.fromhex(self.access_code) access_code = bytes.fromhex(self.access_code)
)) ))

View File

@ -194,6 +194,9 @@ class AimedbServlette():
if user_id and user_id > 0: if user_id and user_id > 0:
await self.data.card.update_card_last_login(req.access_code) await self.data.card.update_card_last_login(req.access_code)
if req.access_code.startswith("010") or req.access_code.startswith("3"):
await self.data.card.set_chip_id_by_access_code(req.access_code, req.serial_number)
self.logger.info(f"Attempt to set chip id to {req.serial_number} for access code {req.access_code}")
return ret return ret
async def handle_lookup_ex(self, data: bytes, resp_code: int) -> ADBBaseResponse: async def handle_lookup_ex(self, data: bytes, resp_code: int) -> ADBBaseResponse:
@ -229,15 +232,24 @@ class AimedbServlette():
async def handle_felica_lookup(self, data: bytes, resp_code: int) -> bytes: async def handle_felica_lookup(self, data: bytes, resp_code: int) -> bytes:
""" """
On official, I think a card has to be registered for this to actually work, but On official, the IDm is used as a key to look up the stored access code in a large
I'm making the executive decision to not implement that and just kick back our database. We do not have access to that database so we have to make due with what we got.
faux generated access code. The real felica IDm -> access code conversion is done Interestingly, namco games are able to read S_PAD0 and send the server the correct access
on the ADB server, which we do not and will not ever have access to. Because we can code, but aimedb doesn't. Until somebody either enters the correct code manually, or scans
assure that all IDms will be unique, this basic 0-padded hex -> int conversion will on a game that reads it correctly from the card, this will have to do. It's the same conversion
be fine. used on the big boy networks.
""" """
req = ADBFelicaLookupRequest(data) req = ADBFelicaLookupRequest(data)
ac = self.data.card.to_access_code(req.idm) card = await self.data.card.get_card_by_idm(req.idm)
if not card:
ac = self.data.card.to_access_code(req.idm)
test = await self.data.card.get_card_by_access_code(ac)
if test:
await self.data.card.set_idm_by_access_code(ac, req.idm)
else:
ac = card['access_code']
self.logger.info( self.logger.info(
f"idm {req.idm} ipm {req.pmm} -> access_code {ac}" f"idm {req.idm} ipm {req.pmm} -> access_code {ac}"
) )
@ -245,7 +257,8 @@ class AimedbServlette():
async def handle_felica_register(self, data: bytes, resp_code: int) -> bytes: async def handle_felica_register(self, data: bytes, resp_code: int) -> bytes:
""" """
I've never seen this used. Used to register felica moble access codes. Will never be used on our network
because we don't implement felica_lookup properly.
""" """
req = ADBFelicaLookupRequest(data) req = ADBFelicaLookupRequest(data)
ac = self.data.card.to_access_code(req.idm) ac = self.data.card.to_access_code(req.idm)
@ -279,8 +292,18 @@ class AimedbServlette():
async def handle_felica_lookup_ex(self, data: bytes, resp_code: int) -> bytes: async def handle_felica_lookup_ex(self, data: bytes, resp_code: int) -> bytes:
req = ADBFelicaLookup2Request(data) req = ADBFelicaLookup2Request(data)
access_code = self.data.card.to_access_code(req.idm) user_id = None
user_id = await self.data.card.get_user_id_from_card(access_code=access_code) card = await self.data.card.get_card_by_idm(req.idm)
if not card:
access_code = self.data.card.to_access_code(req.idm)
card = await self.data.card.get_card_by_access_code(access_code)
if card:
user_id = card['user']
await self.data.card.set_idm_by_access_code(access_code, req.idm)
else:
user_id = card['user']
access_code = card['access_code']
if user_id is None: if user_id is None:
user_id = -1 user_id = -1
@ -290,6 +313,14 @@ class AimedbServlette():
) )
resp = ADBFelicaLookup2Response.from_req(req.head, user_id, access_code) resp = ADBFelicaLookup2Response.from_req(req.head, user_id, access_code)
if user_id > 0:
if card['is_banned'] and card['is_locked']:
resp.head.status = ADBStatus.BAN_SYS_USER
elif card['is_banned']:
resp.head.status = ADBStatus.BAN_SYS
elif card['is_locked']:
resp.head.status = ADBStatus.LOCK_USER
if user_id and user_id > 0 and self.config.aimedb.id_secret: if user_id and user_id > 0 and self.config.aimedb.id_secret:
auth_key = create_sega_auth_key(user_id, req.head.game_id, req.head.store_id, req.head.keychip_id, self.config.aimedb.id_secret, self.config.aimedb.id_lifetime_seconds) auth_key = create_sega_auth_key(user_id, req.head.game_id, req.head.store_id, req.head.keychip_id, self.config.aimedb.id_secret, self.config.aimedb.id_lifetime_seconds)
@ -337,6 +368,16 @@ class AimedbServlette():
self.logger.info( self.logger.info(
f"Registration blocked!: access code {req.access_code}" f"Registration blocked!: access code {req.access_code}"
) )
if user_id > 0:
if req.access_code.startswith("010") or req.access_code.startswith("3"):
await self.data.card.set_chip_id_by_access_code(req.access_code, req.serial_number)
self.logger.info(f"Attempt to set chip id to {req.serial_number} for access code {req.access_code}")
elif req.access_code.startswith("0008"):
idm = self.data.card.to_idm(req.access_code)
await self.data.card.set_idm_by_access_code(req.access_code, idm)
self.logger.info(f"Attempt to set IDm to {idm} for access code {req.access_code}")
resp = ADBLookupResponse.from_req(req.head, user_id) resp = ADBLookupResponse.from_req(req.head, user_id)
if resp.user_id <= 0: if resp.user_id <= 0:

View File

@ -0,0 +1,27 @@
"""chuni_add_net_battle_uk
Revision ID: 1e150d16ab6b
Revises: b23f985100ba
Create Date: 2024-06-21 22:57:18.418488
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '1e150d16ab6b'
down_revision = 'b23f985100ba'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint(None, 'chuni_profile_net_battle', ['user'])
# ### end Alembic commands ###
def downgrade():
op.drop_constraint(None, 'chuni_profile_net_battle', type_='unique')
# ### end Alembic commands ###

View File

@ -0,0 +1,50 @@
"""card_add_idm_chip_id
Revision ID: 48f4acc43a7e
Revises: 1e150d16ab6b
Create Date: 2024-06-21 23:53:34.369134
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '48f4acc43a7e'
down_revision = '1e150d16ab6b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('aime_card', sa.Column('idm', sa.String(length=16), nullable=True))
op.add_column('aime_card', sa.Column('chip_id', sa.BIGINT(), nullable=True))
op.alter_column('aime_card', 'access_code',
existing_type=mysql.VARCHAR(length=20),
nullable=False)
op.alter_column('aime_card', 'created_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('now()'),
existing_nullable=True)
op.create_unique_constraint(None, 'aime_card', ['chip_id'])
op.create_unique_constraint(None, 'aime_card', ['idm'])
op.create_unique_constraint(None, 'aime_card', ['access_code'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'aime_card', type_='unique')
op.drop_constraint(None, 'aime_card', type_='unique')
op.drop_constraint(None, 'aime_card', type_='unique')
op.alter_column('aime_card', 'created_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('CURRENT_TIMESTAMP'),
existing_nullable=True)
op.alter_column('aime_card', 'access_code',
existing_type=mysql.VARCHAR(length=20),
nullable=True)
op.drop_column('aime_card', 'chip_id')
op.drop_column('aime_card', 'idm')
# ### end Alembic commands ###

View File

@ -0,0 +1,87 @@
"""CHUNITHM LUMINOUS
Revision ID: b23f985100ba
Revises: 3657efefc5a4
Create Date: 2024-06-20 08:08:08.759261
"""
from alembic import op
from sqlalchemy import Column, Integer, Boolean, UniqueConstraint
# revision identifiers, used by Alembic.
revision = 'b23f985100ba'
down_revision = '3657efefc5a4'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"chuni_profile_net_battle",
Column("id", Integer, primary_key=True, nullable=False),
Column("user", Integer, nullable=False),
Column("isRankUpChallengeFailed", Boolean),
Column("highestBattleRankId", Integer),
Column("battleIconId", Integer),
Column("battleIconNum", Integer),
Column("avatarEffectPoint", Integer),
mysql_charset="utf8mb4",
)
op.create_foreign_key(
None,
"chuni_profile_net_battle",
"aime_user",
["user"],
["id"],
ondelete="cascade",
onupdate="cascade",
)
op.create_table(
"chuni_item_cmission",
Column("id", Integer, primary_key=True, nullable=False),
Column("user", Integer, nullable=False),
Column("missionId", Integer, nullable=False),
Column("point", Integer),
UniqueConstraint("user", "missionId", name="chuni_item_cmission_uk"),
mysql_charset="utf8mb4",
)
op.create_foreign_key(
None,
"chuni_item_cmission",
"aime_user",
["user"],
["id"],
ondelete="cascade",
onupdate="cascade",
)
op.create_table(
"chuni_item_cmission_progress",
Column("id", Integer, primary_key=True, nullable=False),
Column("user", Integer, nullable=False),
Column("missionId", Integer, nullable=False),
Column("order", Integer),
Column("stage", Integer),
Column("progress", Integer),
UniqueConstraint(
"user", "missionId", "order", name="chuni_item_cmission_progress_uk"
),
mysql_charset="utf8mb4",
)
op.create_foreign_key(
None,
"chuni_item_cmission_progress",
"aime_user",
["user"],
["id"],
ondelete="cascade",
onupdate="cascade",
)
def downgrade():
op.drop_table("chuni_profile_net_battle")
op.drop_table("chuni_item_cmission")
op.drop_table("chuni_item_cmission_progress")

View File

@ -1,6 +1,6 @@
from typing import Dict, List, Optional from typing import Dict, List, Optional
from sqlalchemy import Table, Column, UniqueConstraint from sqlalchemy import Table, Column, UniqueConstraint
from sqlalchemy.types import Integer, String, Boolean, TIMESTAMP from sqlalchemy.types import Integer, String, Boolean, TIMESTAMP, BIGINT
from sqlalchemy.sql.schema import ForeignKey from sqlalchemy.sql.schema import ForeignKey
from sqlalchemy.sql import func from sqlalchemy.sql import func
from sqlalchemy.engine import Row from sqlalchemy.engine import Row
@ -11,12 +11,10 @@ aime_card = Table(
"aime_card", "aime_card",
metadata, metadata,
Column("id", Integer, primary_key=True, nullable=False), Column("id", Integer, primary_key=True, nullable=False),
Column( Column("user", ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False),
"user", Column("access_code", String(20), nullable=False, unique=True),
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), Column("idm", String(16), unique=True),
nullable=False, Column("chip_id", BIGINT, unique=True),
),
Column("access_code", String(20)),
Column("created_date", TIMESTAMP, server_default=func.now()), Column("created_date", TIMESTAMP, server_default=func.now()),
Column("last_login_date", TIMESTAMP, onupdate=func.now()), Column("last_login_date", TIMESTAMP, onupdate=func.now()),
Column("is_locked", Boolean, server_default="0"), Column("is_locked", Boolean, server_default="0"),
@ -122,6 +120,26 @@ class CardData(BaseData):
if result is None: if result is None:
self.logger.warn(f"Failed to update last login time for {access_code}") self.logger.warn(f"Failed to update last login time for {access_code}")
async def get_card_by_idm(self, idm: str) -> Optional[Row]:
result = await self.execute(aime_card.select(aime_card.c.idm == idm))
if result:
return result.fetchone()
async def get_card_by_chip_id(self, chip_id: int) -> Optional[Row]:
result = await self.execute(aime_card.select(aime_card.c.chip_id == chip_id))
if result:
return result.fetchone()
async def set_chip_id_by_access_code(self, access_code: str, chip_id: int) -> Optional[Row]:
result = await self.execute(aime_card.update(aime_card.c.access_code == access_code).values(chip_id=chip_id))
if not result:
self.logger.error(f"Failed to update chip ID to {chip_id} for {access_code}")
async def set_idm_by_access_code(self, access_code: str, idm: str) -> Optional[Row]:
result = await self.execute(aime_card.update(aime_card.c.access_code == access_code).values(idm=idm))
if not result:
self.logger.error(f"Failed to update IDm to {idm} for {access_code}")
def to_access_code(self, luid: str) -> str: def to_access_code(self, luid: str) -> str:
""" """
Given a felica cards internal 16 hex character luid, convert it to a 0-padded 20 digit access code as a string Given a felica cards internal 16 hex character luid, convert it to a 0-padded 20 digit access code as a string
@ -132,4 +150,4 @@ class CardData(BaseData):
""" """
Given a 20 digit access code as a string, return the 16 hex character luid Given a 20 digit access code as a string, return the 16 hex character luid
""" """
return f"{int(access_code):0{16}x}" return f"{int(access_code):0{16}X}"

View File

@ -63,6 +63,7 @@ Games listed below have been tested and confirmed working.
| 12 | CHUNITHM NEW PLUS!! | | 12 | CHUNITHM NEW PLUS!! |
| 13 | CHUNITHM SUN | | 13 | CHUNITHM SUN |
| 14 | CHUNITHM SUN PLUS | | 14 | CHUNITHM SUN PLUS |
| 15 | CHUNITHM LUMINOUS |
### Importer ### Importer

View File

@ -22,6 +22,9 @@ version:
14: 14:
rom: 2.15.00 rom: 2.15.00
data: 2.15.00 data: 2.15.00
15:
rom: 2.20.00
data: 2.20.00
crypto: crypto:
encrypted_only: False encrypted_only: False

View File

@ -941,6 +941,31 @@ class ChuniBase:
rating_type, rating_type,
upsert[rating_type], upsert[rating_type],
) )
# added in LUMINOUS
if "userCMissionList" in upsert:
for cmission in upsert["userCMissionList"]:
mission_id = cmission["missionId"]
await self.data.item.put_cmission(
user_id,
{
"missionId": mission_id,
"point": cmission["point"],
},
)
for progress in cmission["userCMissionProgressList"]:
await self.data.item.put_cmission_progress(user_id, mission_id, progress)
if "userNetBattleData" in upsert:
net_battle = upsert["userNetBattleData"][0]
# fix the boolean
net_battle["isRankUpChallengeFailed"] = (
False if net_battle["isRankUpChallengeFailed"] == "false" else True
)
await self.data.profile.put_net_battle(user_id, net_battle)
return {"returnCode": "1"} return {"returnCode": "1"}
@ -969,4 +994,4 @@ class ChuniBase:
return { return {
"userId": data["userId"], "userId": data["userId"],
"userNetBattleData": {"recentNBSelectMusicList": []}, "userNetBattleData": {"recentNBSelectMusicList": []},
} }

View File

@ -1,3 +1,6 @@
from enum import Enum
class ChuniConstants: class ChuniConstants:
GAME_CODE = "SDBT" GAME_CODE = "SDBT"
GAME_CODE_NEW = "SDHD" GAME_CODE_NEW = "SDHD"
@ -20,6 +23,7 @@ class ChuniConstants:
VER_CHUNITHM_NEW_PLUS = 12 VER_CHUNITHM_NEW_PLUS = 12
VER_CHUNITHM_SUN = 13 VER_CHUNITHM_SUN = 13
VER_CHUNITHM_SUN_PLUS = 14 VER_CHUNITHM_SUN_PLUS = 14
VER_CHUNITHM_LUMINOUS = 15
VERSION_NAMES = [ VERSION_NAMES = [
"CHUNITHM", "CHUNITHM",
"CHUNITHM PLUS", "CHUNITHM PLUS",
@ -35,9 +39,22 @@ class ChuniConstants:
"CHUNITHM NEW!!", "CHUNITHM NEW!!",
"CHUNITHM NEW PLUS!!", "CHUNITHM NEW PLUS!!",
"CHUNITHM SUN", "CHUNITHM SUN",
"CHUNITHM SUN PLUS" "CHUNITHM SUN PLUS",
"CHUNITHM LUMINOUS",
] ]
@classmethod @classmethod
def game_ver_to_string(cls, ver: int): def game_ver_to_string(cls, ver: int):
return cls.VERSION_NAMES[ver] return cls.VERSION_NAMES[ver]
class MapAreaConditionType(Enum):
UNLOCKED = 0
MAP_CLEARED = 1
MAP_AREA_CLEARED = 2
TROPHY_OBTAINED = 3
class MapAreaConditionLogicalOperator(Enum):
AND = 1
OR = 2

View File

@ -1,7 +1,8 @@
from starlette.requests import Request from starlette.requests import Request
from starlette.routing import Route from starlette.routing import Route
from starlette.responses import Response from starlette.responses import Response
import logging, coloredlogs import logging
import coloredlogs
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
import zlib import zlib
import yaml import yaml
@ -34,6 +35,7 @@ from .new import ChuniNew
from .newplus import ChuniNewPlus from .newplus import ChuniNewPlus
from .sun import ChuniSun from .sun import ChuniSun
from .sunplus import ChuniSunPlus from .sunplus import ChuniSunPlus
from .luminous import ChuniLuminous
class ChuniServlet(BaseServlet): class ChuniServlet(BaseServlet):
def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None: def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None:
@ -61,6 +63,7 @@ class ChuniServlet(BaseServlet):
ChuniNewPlus, ChuniNewPlus,
ChuniSun, ChuniSun,
ChuniSunPlus, ChuniSunPlus,
ChuniLuminous,
] ]
self.logger = logging.getLogger("chuni") self.logger = logging.getLogger("chuni")
@ -103,7 +106,9 @@ class ChuniServlet(BaseServlet):
for method in method_list: for method in method_list:
method_fixed = inflection.camelize(method)[6:-7] method_fixed = inflection.camelize(method)[6:-7]
# number of iterations was changed to 70 in SUN and then to 36 # number of iterations was changed to 70 in SUN and then to 36
if version == ChuniConstants.VER_CHUNITHM_SUN_PLUS: if version == ChuniConstants.VER_CHUNITHM_LUMINOUS:
iter_count = 8
elif version == ChuniConstants.VER_CHUNITHM_SUN_PLUS:
iter_count = 36 iter_count = 36
elif version == ChuniConstants.VER_CHUNITHM_SUN: elif version == ChuniConstants.VER_CHUNITHM_SUN:
iter_count = 70 iter_count = 70
@ -195,8 +200,10 @@ class ChuniServlet(BaseServlet):
internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS
elif version >= 210 and version < 215: # SUN elif version >= 210 and version < 215: # SUN
internal_ver = ChuniConstants.VER_CHUNITHM_SUN internal_ver = ChuniConstants.VER_CHUNITHM_SUN
elif version >= 215: # SUN PLUS elif version >= 215 and version < 220: # SUN PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS
elif version >= 220: # LUMINOUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS
elif game_code == "SDGS": # Int elif game_code == "SDGS": # Int
if version < 110: # SUPERSTAR / SUPERSTAR PLUS if version < 110: # SUPERSTAR / SUPERSTAR PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE # SUPERSTAR / SUPERSTAR PLUS worked fine with it internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE # SUPERSTAR / SUPERSTAR PLUS worked fine with it
@ -206,8 +213,10 @@ class ChuniServlet(BaseServlet):
internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS
elif version >= 120 and version < 125: # SUN elif version >= 120 and version < 125: # SUN
internal_ver = ChuniConstants.VER_CHUNITHM_SUN internal_ver = ChuniConstants.VER_CHUNITHM_SUN
elif version >= 125: # SUN PLUS elif version >= 125 and version < 130: # SUN PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS
elif version >= 130: # LUMINOUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS
if all(c in string.hexdigits for c in endpoint) and len(endpoint) == 32: 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 # If we get a 32 character long hex string, it's a hash and we're
@ -295,7 +304,7 @@ class ChuniServlet(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}")
@ -313,4 +322,4 @@ class ChuniServlet(BaseServlet):
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]), bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]),
) )
return Response(crypt.encrypt(padded)) return Response(crypt.encrypt(padded))

298
titles/chuni/luminous.py Normal file
View File

@ -0,0 +1,298 @@
from datetime import timedelta
from typing import Dict
from core.config import CoreConfig
from titles.chuni.sunplus import ChuniSunPlus
from titles.chuni.const import ChuniConstants, MapAreaConditionLogicalOperator, MapAreaConditionType
from titles.chuni.config import ChuniConfig
class ChuniLuminous(ChuniSunPlus):
def __init__(self, core_cfg: CoreConfig, game_cfg: ChuniConfig) -> None:
super().__init__(core_cfg, game_cfg)
self.version = ChuniConstants.VER_CHUNITHM_LUMINOUS
async def handle_cm_get_user_preview_api_request(self, data: Dict) -> Dict:
user_data = await super().handle_cm_get_user_preview_api_request(data)
# Does CARD MAKER 1.35 work this far up?
user_data["lastDataVersion"] = "2.20.00"
return user_data
async def handle_get_user_c_mission_api_request(self, data: Dict) -> Dict:
user_id = data["userId"]
mission_id = data["missionId"]
progress_list = []
point = 0
mission_data = await self.data.item.get_cmission(user_id, mission_id)
progress_data = await self.data.item.get_cmission_progress(user_id, mission_id)
if mission_data and progress_data:
point = mission_data["point"]
for progress in progress_data:
progress_list.append(
{
"order": progress["order"],
"stage": progress["stage"],
"progress": progress["progress"],
}
)
return {
"userId": user_id,
"missionId": mission_id,
"point": point,
"userCMissionProgressList": progress_list,
}
async def handle_get_user_net_battle_ranking_info_api_request(self, data: Dict) -> Dict:
user_id = data["userId"]
net_battle = {}
net_battle_data = await self.data.profile.get_net_battle(user_id)
if net_battle_data:
net_battle = {
"isRankUpChallengeFailed": net_battle_data["isRankUpChallengeFailed"],
"highestBattleRankId": net_battle_data["highestBattleRankId"],
"battleIconId": net_battle_data["battleIconId"],
"battleIconNum": net_battle_data["battleIconNum"],
"avatarEffectPoint": net_battle_data["avatarEffectPoint"],
}
return {
"userId": user_id,
"userNetBattleData": net_battle,
}
async def handle_get_game_map_area_condition_api_request(self, data: Dict) -> Dict:
# There is no game data for this, everything is server side.
# However, we can selectively show/hide events as data is imported into the server.
events = await self.data.static.get_enabled_events(self.version)
event_by_id = {evt["eventId"]: evt for evt in events}
conditions = []
# The Mystic Rainbow of LUMINOUS map unlocks when any mainline LUMINOUS area
# (ep. I, ep. II, ep. III) are completed.
mystic_area_1_conditions = {
"mapAreaId": 3229301, # Mystic Rainbow of LUMINOUS Area 1
"length": 0,
"mapAreaConditionList": [],
}
mystic_area_1_added = False
# Secret AREA: MUSIC GAME
if 14029 in event_by_id:
start_date = event_by_id[14029]["startDate"].strftime(self.date_time_format)
mission_in_progress_end_date = "2099-12-31 00:00:00.0"
# The "MISSION in progress" trophy required to trigger the secret area
# is only available in the first CHUNITHM mission. If the second mission
# (event ID 14214) was imported into ARTEMiS, we disable the requirement
# for this trophy.
if 14214 in event_by_id:
mission_in_progress_end_date = (event_by_id[14214]["startDate"] - timedelta(hours=2)).strftime(self.date_time_format)
conditions.extend([
{
"mapAreaId": 2206201, # BlythE ULTIMA
"length": 1,
# Obtain the trophy "MISSION in progress".
"mapAreaConditionList": [
{
"type": MapAreaConditionType.TROPHY_OBTAINED.value,
"conditionId": 6832,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": mission_in_progress_end_date,
}
],
},
{
"mapAreaId": 2206202, # PRIVATE SERVICE ULTIMA
"length": 1,
# Obtain the trophy "MISSION in progress".
"mapAreaConditionList": [
{
"type": MapAreaConditionType.TROPHY_OBTAINED.value,
"conditionId": 6832,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": mission_in_progress_end_date,
}
],
},
{
"mapAreaId": 2206203, # New York Back Raise
"length": 1,
# SS NightTheater's EXPERT chart and get the title
# "今宵、劇場に映し出される景色とは――――。"
"mapAreaConditionList": [
{
"type": MapAreaConditionType.TROPHY_OBTAINED.value,
"conditionId": 6833,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
},
],
},
{
"mapAreaId": 2206204, # Spasmodic
"length": 2,
# - Get 1 miss on Random (any difficulty) and get the title "当たり待ち"
# - Get 1 miss on 花たちに希望を (any difficulty) and get the title "花たちに希望を"
"mapAreaConditionList": [
{
"type": MapAreaConditionType.TROPHY_OBTAINED.value,
"conditionId": 6834,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
},
{
"type": MapAreaConditionType.TROPHY_OBTAINED.value,
"conditionId": 6835,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
},
],
},
{
"mapAreaId": 2206205, # ΩΩPARTS
"length": 2,
# - S Sage EXPERT to get the title "マターリ進行キボンヌ"
# - Equip this title and play cab-to-cab with another person with this title
# to get "マターリしようよ". Disabled because it is difficult to play cab2cab
# on data setups. A network operator may consider re-enabling it by uncommenting
# the second condition.
"mapAreaConditionList": [
{
"type": MapAreaConditionType.TROPHY_OBTAINED.value,
"conditionId": 6836,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
},
# {
# "type": MapAreaConditionType.TROPHY_OBTAINED.value,
# "conditionId": 6837,
# "logicalOpe": MapAreaConditionLogicalOperator.AND.value,
# "startDate": start_date,
# "endDate": "2099-12-31 00:00:00.0",
# },
],
},
{
"mapAreaId": 2206206, # Blow My Mind
"length": 1,
# SS on CHAOS EXPERT, Hydra EXPERT, Surive EXPERT and Jakarta PROGRESSION EXPERT
# to get the title "Can you hear me?"
"mapAreaConditionList": [
{
"type": MapAreaConditionType.TROPHY_OBTAINED.value,
"conditionId": 6838,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
},
],
},
{
"mapAreaId": 2206207, # VALLIS-NERIA
"length": 6,
# Finish the 6 other areas
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_AREA_CLEARED.value,
"conditionId": x,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
}
for x in range(2206201, 2206207)
],
},
])
# LUMINOUS ep. I
if 14005 in event_by_id:
start_date = event_by_id[14005]["startDate"].strftime(self.date_time_format)
if not mystic_area_1_added:
conditions.append(mystic_area_1_conditions)
mystic_area_1_added = True
mystic_area_1_conditions["length"] += 1
mystic_area_1_conditions["mapAreaConditionList"].append(
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020701,
"logicalOpe": MapAreaConditionLogicalOperator.OR.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
}
)
conditions.append(
{
"mapAreaId": 3229302, # Mystic Rainbow of LUMINOUS Area 2,
"length": 1,
# Unlocks when LUMINOUS ep. I is completed.
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020701,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
},
],
}
)
# LUMINOUS ep. II
if 14251 in event_by_id:
start_date = event_by_id[14251]["startDate"].strftime(self.date_time_format)
if not mystic_area_1_added:
conditions.append(mystic_area_1_conditions)
mystic_area_1_added = True
mystic_area_1_conditions["length"] += 1
mystic_area_1_conditions["mapAreaConditionList"].append(
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020702,
"logicalOpe": MapAreaConditionLogicalOperator.OR.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
}
)
conditions.append(
{
"mapAreaId": 3229303, # Mystic Rainbow of LUMINOUS Area 3,
"length": 1,
# Unlocks when LUMINOUS ep. II is completed.
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020702,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
},
],
}
)
return {
"length": len(conditions),
"gameMapAreaConditionList": conditions,
}

View File

@ -32,6 +32,8 @@ class ChuniNew(ChuniBase):
return "210" return "210"
if self.version == ChuniConstants.VER_CHUNITHM_SUN_PLUS: if self.version == ChuniConstants.VER_CHUNITHM_SUN_PLUS:
return "215" return "215"
if self.version == ChuniConstants.VER_CHUNITHM_LUMINOUS:
return "220"
async def handle_get_game_setting_api_request(self, data: Dict) -> Dict: async def handle_get_game_setting_api_request(self, data: Dict) -> Dict:
# use UTC time and convert it to JST time by adding +9 # use UTC time and convert it to JST time by adding +9

View File

@ -48,9 +48,8 @@ class ChuniReader(BaseReader):
for root, dirs, files in walk(f"{root_dir}loginBonusPreset"): for root, dirs, files in walk(f"{root_dir}loginBonusPreset"):
for dir in dirs: for dir in dirs:
if path.exists(f"{root}/{dir}/LoginBonusPreset.xml"): if path.exists(f"{root}/{dir}/LoginBonusPreset.xml"):
with open(f"{root}/{dir}/LoginBonusPreset.xml", "rb") as fp: with open(f"{root}/{dir}/LoginBonusPreset.xml", "r", encoding="utf-8") as fp:
bytedata = fp.read() strdata = fp.read()
strdata = bytedata.decode("UTF-8")
xml_root = ET.fromstring(strdata) xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"): for name in xml_root.findall("name"):
@ -121,9 +120,8 @@ class ChuniReader(BaseReader):
for root, dirs, files in walk(evt_dir): for root, dirs, files in walk(evt_dir):
for dir in dirs: for dir in dirs:
if path.exists(f"{root}/{dir}/Event.xml"): if path.exists(f"{root}/{dir}/Event.xml"):
with open(f"{root}/{dir}/Event.xml", "rb") as fp: with open(f"{root}/{dir}/Event.xml", "r", encoding="utf-8") as fp:
bytedata = fp.read() strdata = fp.read()
strdata = bytedata.decode("UTF-8")
xml_root = ET.fromstring(strdata) xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"): for name in xml_root.findall("name"):
@ -144,9 +142,8 @@ class ChuniReader(BaseReader):
for root, dirs, files in walk(music_dir): for root, dirs, files in walk(music_dir):
for dir in dirs: for dir in dirs:
if path.exists(f"{root}/{dir}/Music.xml"): if path.exists(f"{root}/{dir}/Music.xml"):
with open(f"{root}/{dir}/Music.xml", "rb") as fp: with open(f"{root}/{dir}/Music.xml", "r", encoding='utf-8') as fp:
bytedata = fp.read() strdata = fp.read()
strdata = bytedata.decode("UTF-8")
xml_root = ET.fromstring(strdata) xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"): for name in xml_root.findall("name"):
@ -210,9 +207,8 @@ class ChuniReader(BaseReader):
for root, dirs, files in walk(charge_dir): for root, dirs, files in walk(charge_dir):
for dir in dirs: for dir in dirs:
if path.exists(f"{root}/{dir}/ChargeItem.xml"): if path.exists(f"{root}/{dir}/ChargeItem.xml"):
with open(f"{root}/{dir}/ChargeItem.xml", "rb") as fp: with open(f"{root}/{dir}/ChargeItem.xml", "r", encoding='utf-8') as fp:
bytedata = fp.read() strdata = fp.read()
strdata = bytedata.decode("UTF-8")
xml_root = ET.fromstring(strdata) xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"): for name in xml_root.findall("name"):
@ -240,9 +236,8 @@ class ChuniReader(BaseReader):
for root, dirs, files in walk(avatar_dir): for root, dirs, files in walk(avatar_dir):
for dir in dirs: for dir in dirs:
if path.exists(f"{root}/{dir}/AvatarAccessory.xml"): if path.exists(f"{root}/{dir}/AvatarAccessory.xml"):
with open(f"{root}/{dir}/AvatarAccessory.xml", "rb") as fp: with open(f"{root}/{dir}/AvatarAccessory.xml", "r", encoding='utf-8') as fp:
bytedata = fp.read() strdata = fp.read()
strdata = bytedata.decode("UTF-8")
xml_root = ET.fromstring(strdata) xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"): for name in xml_root.findall("name"):

View File

@ -243,6 +243,36 @@ matching = Table(
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
cmission = Table(
"chuni_item_cmission",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
Column("missionId", Integer, nullable=False),
Column("point", Integer),
UniqueConstraint("user", "missionId", name="chuni_item_cmission_uk"),
mysql_charset="utf8mb4",
)
cmission_progress = Table(
"chuni_item_cmission_progress",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
Column("user", ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False),
Column("missionId", Integer, nullable=False),
Column("order", Integer),
Column("stage", Integer),
Column("progress", Integer),
UniqueConstraint(
"user", "missionId", "order", name="chuni_item_cmission_progress_uk"
),
mysql_charset="utf8mb4",
)
class ChuniItemData(BaseData): class ChuniItemData(BaseData):
async def get_oldest_free_matching(self, version: int) -> Optional[Row]: async def get_oldest_free_matching(self, version: int) -> Optional[Row]:
@ -594,3 +624,66 @@ class ChuniItemData(BaseData):
) )
return None return None
return result.lastrowid return result.lastrowid
async def put_cmission_progress(
self, user_id: int, mission_id: int, progress_data: Dict
) -> Optional[int]:
progress_data["user"] = user_id
progress_data["missionId"] = mission_id
sql = insert(cmission_progress).values(**progress_data)
conflict = sql.on_duplicate_key_update(**progress_data)
result = await self.execute(conflict)
if result is None:
return None
return result.lastrowid
async def get_cmission_progress(
self, user_id: int, mission_id: int
) -> Optional[List[Row]]:
sql = cmission_progress.select(
and_(
cmission_progress.c.user == user_id,
cmission_progress.c.missionId == mission_id,
)
).order_by(cmission_progress.c.order.asc())
result = await self.execute(sql)
if result is None:
return None
return result.fetchall()
async def get_cmission(self, user_id: int, mission_id: int) -> Optional[Row]:
sql = cmission.select(
and_(cmission.c.user == user_id, cmission.c.missionId == mission_id)
)
result = await self.execute(sql)
if result is None:
return None
return result.fetchone()
async def put_cmission(self, user_id: int, mission_data: Dict) -> Optional[int]:
mission_data["user"] = user_id
sql = insert(cmission).values(**mission_data)
conflict = sql.on_duplicate_key_update(**mission_data)
result = await self.execute(conflict)
if result is None:
return None
return result.lastrowid
async def get_cmissions(self, user_id: int) -> Optional[List[Row]]:
sql = cmission.select(cmission.c.user == user_id)
result = await self.execute(sql)
if result is None:
return None
return result.fetchall()

View File

@ -412,6 +412,18 @@ rating = Table(
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
net_battle = Table(
"chuni_profile_net_battle",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
Column("user", Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, unique=True),
Column("isRankUpChallengeFailed", Boolean),
Column("highestBattleRankId", Integer),
Column("battleIconId", Integer),
Column("battleIconNum", Integer),
Column("avatarEffectPoint", Integer),
mysql_charset="utf8mb4",
)
class ChuniProfileData(BaseData): class ChuniProfileData(BaseData):
async def update_name(self, user_id: int, new_name: str) -> bool: async def update_name(self, user_id: int, new_name: str) -> bool:
@ -779,4 +791,32 @@ class ChuniProfileData(BaseData):
else: else:
versions_raw = result.fetchall() versions_raw = result.fetchall()
versions = [row[0] for row in versions_raw] versions = [row[0] for row in versions_raw]
return sorted(versions, reverse=True) return sorted(versions, reverse=True)
async def put_net_battle(self, user_id: int, net_battle_data: Dict) -> Optional[int]:
sql = insert(net_battle).values(
user=user_id,
isRankUpChallengeFailed=net_battle_data['isRankUpChallengeFailed'],
highestBattleRankId=net_battle_data['highestBattleRankId'],
battleIconId=net_battle_data['battleIconId'],
battleIconNum=net_battle_data['battleIconNum'],
avatarEffectPoint=net_battle_data['avatarEffectPoint'],
)
conflict = sql.on_duplicate_key_update(
isRankUpChallengeFailed=net_battle_data['isRankUpChallengeFailed'],
highestBattleRankId=net_battle_data['highestBattleRankId'],
battleIconId=net_battle_data['battleIconId'],
battleIconNum=net_battle_data['battleIconNum'],
avatarEffectPoint=net_battle_data['avatarEffectPoint'],
)
result = await self.execute(conflict)
if result:
return result.inserted_primary_key['id']
self.logger.error(f"Failed to put net battle data for user {user_id}")
async def get_net_battle(self, user_id: int) -> Optional[Row]:
result = await self.execute(net_battle.select(net_battle.c.user == user_id))
if result:
return result.fetchone()

View File

@ -242,6 +242,8 @@ class ChuniScoreData(BaseData):
# Calculates the ROM version that should be fetched for rankings, based on the game version being retrieved # Calculates the ROM version that should be fetched for rankings, based on the game version being retrieved
# This prevents tracks that are not accessible in your version from counting towards the 10 results # This prevents tracks that are not accessible in your version from counting towards the 10 results
romVer = { romVer = {
15: "2.20%",
14: "2.15%",
13: "2.10%", 13: "2.10%",
12: "2.05%", 12: "2.05%",
11: "2.00%", 11: "2.00%",

View File

@ -2,8 +2,10 @@ from titles.diva.index import DivaServlet
from titles.diva.const import DivaConstants from titles.diva.const import DivaConstants
from titles.diva.database import DivaData from titles.diva.database import DivaData
from titles.diva.read import DivaReader from titles.diva.read import DivaReader
from .frontend import DivaFrontend
index = DivaServlet index = DivaServlet
database = DivaData database = DivaData
reader = DivaReader reader = DivaReader
frontend = DivaFrontend
game_codes = [DivaConstants.GAME_CODE] game_codes = [DivaConstants.GAME_CODE]

182
titles/diva/frontend.py Normal file
View File

@ -0,0 +1,182 @@
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 DivaData
from .config import DivaConfig
from .const import DivaConstants
class DivaFrontend(FE_Base):
def __init__(
self, cfg: CoreConfig, environment: jinja2.Environment, cfg_dir: str
) -> None:
super().__init__(cfg, environment)
self.data = DivaData(cfg)
self.game_cfg = DivaConfig()
if path.exists(f"{cfg_dir}/{DivaConstants.CONFIG_NAME}"):
self.game_cfg.update(
yaml.safe_load(open(f"{cfg_dir}/{DivaConstants.CONFIG_NAME}"))
)
self.nav_name = "diva"
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("/update.lv", self.update_lv, methods=['POST']),
]
async def render_GET(self, request: Request) -> bytes:
template = self.environment.get_template(
"titles/diva/templates/diva_index.jinja"
)
usr_sesh = self.validate_session(request)
if not usr_sesh:
usr_sesh = UserSession()
if usr_sesh.user_id > 0:
profile = await self.data.profile.get_profile(usr_sesh.user_id, 1)
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
), media_type="text/html; charset=utf-8")
return resp
else:
return RedirectResponse("/gate")
async def render_GET_playlog(self, request: Request) -> bytes:
template = self.environment.get_template(
"titles/diva/templates/diva_playlog.jinja"
)
usr_sesh = self.validate_session(request)
if not usr_sesh:
usr_sesh = UserSession()
if usr_sesh.user_id > 0:
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),
score_count=0
), media_type="text/html; charset=utf-8")
playlog = await self.data.score.get_playlogs(user_id, index, 20) #Maybe change to the playlog instead of direct scores
playlog_with_title = []
for record in playlog:
song = await self.data.static.get_music_chart(record[2], record[3], record[4])
if song:
title = song.title
vocaloid_arranger = song.vocaloid_arranger
else:
title = "Unknown"
vocaloid_arranger = "Unknown"
playlog_with_title.append({
"raw": record,
"title": title,
"vocaloid_arranger": vocaloid_arranger
})
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/", 300)
async def update_name(self, request: Request) -> Response:
usr_sesh = self.validate_session(request)
if not usr_sesh:
return RedirectResponse("/gate")
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_profile(usr_sesh.user_id, player_name=new_name_full):
return RedirectResponse("/gate/?e=999", 303)
return RedirectResponse("/game/diva", 303)
async def update_lv(self, request: Request) -> Response:
usr_sesh = self.validate_session(request)
if not usr_sesh:
return RedirectResponse("/gate")
form_data = await request.form()
new_lv: str = form_data.get("new_lv")
new_lv_full = ""
if not new_lv:
return RedirectResponse("/gate/?e=4", 303)
if len(new_lv) > 8:
return RedirectResponse("/gate/?e=8", 303)
for x in new_lv: # FIXME: This will let some invalid characters through atm
o = ord(x)
try:
if o == 0x20:
new_lv_full += chr(0x3000)
elif o < 0x7F and o > 0x20:
new_lv_full += chr(o + 0xFEE0)
elif o <= 0x7F:
self.logger.warn(f"Invalid ascii character {o:02X}")
return RedirectResponse("/gate/?e=4", 303)
else:
new_lv_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_profile(usr_sesh.user_id, lv_str=new_lv_full):
return RedirectResponse("/gate/?e=999", 303)
return RedirectResponse("/game/diva", 303)

View File

@ -90,7 +90,7 @@ class DivaProfileData(BaseData):
return None return None
return result.lastrowid return result.lastrowid
async def update_profile(self, aime_id: int, **profile_args) -> None: async def update_profile(self, aime_id: int, **profile_args) -> bool:
""" """
Given an aime_id update the profile corresponding to the arguments Given an aime_id update the profile corresponding to the arguments
which are the diva_profile Columns which are the diva_profile Columns
@ -102,7 +102,9 @@ class DivaProfileData(BaseData):
self.logger.error( self.logger.error(
f"update_profile: failed to update profile! profile: {aime_id}" f"update_profile: failed to update profile! profile: {aime_id}"
) )
return None return False
return True
async def get_profile(self, aime_id: int, version: int) -> Optional[List[Dict]]: async def get_profile(self, aime_id: int, version: int) -> Optional[List[Dict]]:
""" """

View File

@ -239,3 +239,23 @@ class DivaScoreData(BaseData):
if result is None: if result is None:
return None return None
return result.fetchall() return result.fetchall()
async def get_playlogs(self, aime_id: int, idx: int = 0, limit: int = 0) -> Optional[Row]:
sql = select(playlog).where(playlog.c.user == aime_id).order_by(playlog.c.date_scored.desc())
if limit:
sql = sql.limit(limit)
if idx:
sql = sql.offset(idx)
result = await self.execute(sql)
if result:
return result.fetchall()
async def get_user_playlogs_count(self, aime_id: int) -> Optional[int]:
sql = select(func.count()).where(playlog.c.user == aime_id)
result = await self.execute(sql)
if result is None:
self.logger.warning(f"aimu_id {aime_id} has no scores ")
return None
return result.scalar()

View File

@ -0,0 +1,195 @@
.diva-header {
text-align: center;
}
ul.diva-navi {
list-style-type: none;
padding: 0;
overflow: hidden;
background-color: #333;
text-align: center;
display: inline-block;
}
ul.diva-navi li {
display: inline-block;
}
ul.diva-navi li a {
display: block;
color: white;
text-align: center;
padding: 14px 16px;
text-decoration: none;
}
ul.diva-navi li a:hover:not(.active) {
background-color: #111;
}
ul.diva-navi li a.active {
background-color: #4CAF50;
}
ul.diva-navi li.right {
float: right;
}
@media screen and (max-width: 600px) {
ul.diva-navi li.right,
ul.diva-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="diva-header">
<h1>diva</h1>
<ul class="diva-navi">
<li><a class="nav-link" href="/game/diva/">PROFILE</a></li>
<li><a class="nav-link" href="/game/diva/playlog/">RECORD</a></li>
</ul>
</div>
<script>
$(document).ready(function () {
var currentPath = window.location.pathname;
if (currentPath === '/game/diva/') {
$('.nav-link[href="/game/diva/"]').addClass('active');
} else if (currentPath.startsWith('/game/diva/playlog/')) {
$('.nav-link[href="/game/diva/playlog/"]').addClass('active');
}
});
</script>

View File

@ -0,0 +1,111 @@
{% extends "core/templates/index.jinja" %}
{% block content %}
<style>
{% include 'titles/diva/templates/css/diva_style.css' %}
</style>
<div class="container">
{% include 'titles/diva/templates/diva_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" class="text-center">OVERVIEW</caption>
<tr>
<th>Player name:</th>
<th>{{ profile[3] }}</th>
<th>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal"
data-bs-target="#name_change">Edit</button>
</th>
<th>Level string:</th>
<th>{{ profile[4] }}</th>
<th>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal"
data-bs-target="#lv_change">Edit</button>
</th>
</tr>
<tr>
<td>Lvl:</td>
<td>{{ profile[5] }}</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>Lvl points:</td>
<td>{{ profile[6] }}</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>Vocaloid points:</td>
<td>{{ profile[7] }}</td>
<td></td>
<td></td>
<td></td>
<td></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/diva/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>
<div class="modal fade" id="lv_change" tabindex="-1" aria-labelledby="lv_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">Level string change</h5>
</div>
<div class="modal-body">
<form id="new_lv_form" action="/game/diva/update.lv" method="post" style="outline: 0;">
<label class="form-label" for="new_lv">new level string:</label>
<input class="form-control" aria-describedby="newLvHelp" form="new_lv_form" id="new_lv" name="new_lv"
maxlength="14" type="text" required>
<div id="newLvHelp" class="form-text">level string must be full-width character string.
</div>
</form>
<div class="modal-footer">
<input type=submit class="btn btn-primary" type="button" form="new_lv_form">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,169 @@
{% extends "core/templates/index.jinja" %}
{% block content %}
<style>
{% include 'titles/diva/templates/css/diva_style.css' %}
</style>
<div class="container">
{% include 'titles/diva/templates/diva_header.jinja' %}
{% if playlog is defined and playlog is not none %}
<div class="row">
<h4 style="text-align: center;">Score counts: {{ playlog_count }}</h4>
{% set difficultyName = ['easy', 'normal', 'hard', 'extreme', 'extra extreme'] %}
{% set clearState = ['MISSxTAKE', 'STANDARD', 'GREAT', 'EXELLENT', 'PERFECT'] %}
{% for record in playlog %}
<div class="col-lg-6 mt-3">
<div class="card bg-card rounded card-hover">
<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.vocaloid_arranger }}</h6>
</div>
<div class="col-4">
<h6 class="card-text">{{record.raw.date_scored}}</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>{{ record.raw.atn_pnt / 100 }}%</h2>
<h6>{{ difficultyName[record.raw.difficulty] }}</h6>
</div>
<div class="col-6" style="text-align: center;">
<table class="table-small table-rowdistinc">
<tr>
<td>COOL</td>
<td>{{ record.raw.cool }}</td>
</tr>
<tr>
<td>FINE</td>
<td>{{ record.raw.fine }}</td>
</tr>
<tr>
<td>SAFE</td>
<td>{{ record.raw.safe }}</td>
</tr>
<tr>
<td>SAD</td>
<td>{{ record.raw.sad }}</td>
</tr>
<tr>
<td>WORST</td>
<td>{{ record.raw.worst }}</td>
</tr>
</table>
</div>
<div class="col-3" style="text-align: center;">
<h6>{{ record.raw.max_combo }}</h6>
{% if record.raw.clr_kind == -1 %}
<h6>{{ clearState[0] }}</h6>
{% elif record.raw.clr_kind == 2 %}
<h6>{{ clearState[1] }}</h6>
{% elif record.raw.clr_kind == 3 %}
<h6>{{ clearState[2] }}</h6>
{% elif record.raw.clr_kind == 4 %}
<h6>{{ clearState[3] }}</h6>
{% elif record.raw.clr_kind == 5 %}
<h6>{{ clearState[4] }}</h6>
{% endif %}
{% if record.raw.clr_kind == -1 %}
<h6>NOT CLEAR</h6>
{% else %}
<h6>CLEAR</h6>
{% endif %}
</div>
</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 Score information found for this account.
{% else %}
Login to view profile information.
{% endif %}
</div>
<footer class="navbar-fixed-bottom">
<nav aria-label="Score page navication">
<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/diva/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/diva/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>
</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/diva/playlog/';
var scorePages = {{ 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 ((scorePages - currentPage) < 3) {
$('#next_3_page').hide();
if ((scorePages - currentPage) < 2) {
$('#back_page').hide();
if (currentPage === scorePages) {
$('#last_page').hide();
$('#next_page').addClass('disabled');
}
}
}
$('#go_button').click(function () {
var pageNumber = parseInt($('#page_input').val());
if (!Number.isNaN(pageNumber) && pageNumber <= scorePages && pageNumber >= 0) {
var url = '/game/diva/playlog/' + pageNumber;
window.location.href = url;
} else {
$('#page_input').val('');
$('#page_input').attr('placeholder', 'invalid input!');
}
});
});
</script>
{% endblock content %}

View File

@ -216,16 +216,20 @@ class OngekiServlet(BaseServlet):
) )
return Response(zlib.compress(b'{"stat": "0"}')) return Response(zlib.compress(b'{"stat": "0"}'))
try: if version < 105:
unzip = zlib.decompress(req_raw) # O.N.G.E.K.I base don't use zlib
req_data = json.loads(req_raw)
else:
try:
unzip = zlib.decompress(req_raw)
except zlib.error as e:
self.logger.error(
f"Failed to decompress v{version} {endpoint} request -> {e}"
)
return Response(zlib.compress(b'{"stat": "0"}'))
except zlib.error as e: req_data = json.loads(unzip)
self.logger.error(
f"Failed to decompress v{version} {endpoint} request -> {e}"
)
return Response(zlib.compress(b'{"stat": "0"}'))
req_data = json.loads(unzip)
self.logger.info( self.logger.info(
f"v{version} {endpoint} request from {client_ip}" f"v{version} {endpoint} request from {client_ip}"
@ -251,9 +255,12 @@ class OngekiServlet(BaseServlet):
self.logger.debug(f"Response {resp}") self.logger.debug(f"Response {resp}")
zipped = zlib.compress(json.dumps(resp, ensure_ascii=False).encode("utf-8")) resp_raw = json.dumps(resp, ensure_ascii=False).encode("utf-8")
zipped = zlib.compress(resp_raw)
if not encrtped or version < 120: if not encrtped or version < 120:
if version < 105:
return Response(resp_raw)
return Response(zipped) return Response(zipped)
padded = pad(zipped, 16) padded = pad(zipped, 16)

View File

@ -165,6 +165,8 @@ class PokkenBase:
f"Register new card {access_code} (UserId {user_id}, CardId {card_id})" f"Register new card {access_code} (UserId {user_id}, CardId {card_id})"
) )
await self.data.card.set_chip_id_by_access_code(access_code, int(request.load_user.chip_id[:8], 16))
elif card is None: elif card is None:
self.logger.info(f"Registration of card {access_code} blocked!") self.logger.info(f"Registration of card {access_code} blocked!")
res.load_user.CopyFrom(load_usr) res.load_user.CopyFrom(load_usr)
@ -173,6 +175,8 @@ class PokkenBase:
else: else:
user_id = card['user'] user_id = card['user']
card_id = card['id'] card_id = card['id']
if not card['chip_id']:
await self.data.card.set_chip_id_by_access_code(access_code, int(request.load_user.chip_id[:8], 16))
""" """
TODO: Unlock all supports? Probably TODO: Unlock all supports? Probably

View File

@ -83,6 +83,10 @@ class SaoBase:
if not user_id: if not user_id:
user_id = await self.data.user.create_user() #works user_id = await self.data.user.create_user() #works
card_id = await self.data.card.create_card(user_id, req.access_code) card_id = await self.data.card.create_card(user_id, req.access_code)
if req.access_code.startswith("5"):
await self.data.card.set_idm_by_access_code(card_id, req.chip_id[:16])
elif req.access_code.startswith("010") or req.access_code.startswith("3"):
await self.data.card.set_chip_id_by_access_code(card_id, int(req.chip_id[:8], 16))
if card_id is None: if card_id is None:
user_id = -1 user_id = -1

View File

@ -833,14 +833,14 @@ class WaccaBase:
# TODO: Coop and vs data # TODO: Coop and vs data
async def handle_user_music_updateCoop_request(self, data: Dict) -> Dict: async def handle_user_music_updateCoop_request(self, data: Dict) -> Dict:
coop_info = data["params"][4] coop_info = data["params"][4]
return self.handle_user_music_update_request(data) return await self.handle_user_music_update_request(data)
async def handle_user_music_updateVersus_request(self, data: Dict) -> Dict: async def handle_user_music_updateVersus_request(self, data: Dict) -> Dict:
vs_info = data["params"][4] vs_info = data["params"][4]
return self.handle_user_music_update_request(data) return await self.handle_user_music_update_request(data)
async def handle_user_music_updateTrial_request(self, data: Dict) -> Dict: async def handle_user_music_updateTrial_request(self, data: Dict) -> Dict:
return self.handle_user_music_update_request(data) return await self.handle_user_music_update_request(data)
async def handle_user_mission_update_request(self, data: Dict) -> Dict: async def handle_user_mission_update_request(self, data: Dict) -> Dict:
req = UserMissionUpdateRequest(data) req = UserMissionUpdateRequest(data)