2 Commits

Author SHA1 Message Date
ecebd5d774 Deduplicate rating list functions 2024-03-14 09:59:00 +07:00
5c48edc86b CHUNITHM & O.N.G.E.K.I.: Handle userRatingBase*List 2024-03-13 15:51:01 +07:00
336 changed files with 30227 additions and 112428 deletions

1
.gitignore vendored
View File

@ -145,7 +145,6 @@ dmypy.json
cython_debug/
.vscode/*
.vs/*
# Local History for Visual Studio Code
.history/

View File

@ -1,92 +1,6 @@
# Changelog
Documenting updates to ARTEMiS, to be updated every time the master branch is pushed to.
## 20240811
### System
+ Change backend from Twisted to Starlette
+ Implement async handlers
+ Reboot times for multiple games have been fixed (thanks zaphkito!)
### Frontend
+ Edit button changed to View on the user page, and is where you can edit the card memo
+ Add card now works as it should
+ Add event log viewer in the `sys` page for sysadmins
+ Add pages for Pokken, SAO, and maimai
### AimeDB
+ Now rejects all-zero access codes
+ Stores card IDm (for AmusementIC) and MiFare ID (for old aime/banapass)
+ ...unless that MiFare ID is 0x01020304 (the default for segatools)
### maimai
+ Add support for BUDDiES
+ Rivals and Favorite Music support
### Wacca
+ Add option to block unregistered serials from accessing the title server
### DIVA
+ Fix for reading modded content (Thanks ThatzOkay!)
### CHUNITHM
+ Save net battle info
## 20240630
### DIVA
+ Added configurable festa options'
## 20240629
### CHUNITHM
+ Add team points
## 20240628
### maimai
+ Add present support
## 20240627
### SAO
+ Fix ghost items, character and player XP, EX Bonuses, unlocks, and much much more
## 20240620
### CHUNITHM
+ CHUNITHM LUMINOUS support
## 20240616
### CHUNITHM
+ Support network encryption for Export/International versions
### DIVA
+ Working frontend with name and level strings edit and playlog
## 20240530
### DIVA
+ Fix reader for when dificulty is not a int
## 20240526
### DIVA
+ Fixed missing awaits causing coroutine error
## 20240524
### DIVA
+ Fixed new profile start request causing coroutine error
## 20240523
### DIVA
+ Fixed binary handler & render_POST errors
## 20240408
### System
+ Modified the game specific documentation
## 20240407
### Maimai
+ Support maimai DX International [#118](https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/118) (Thanks beerpsi!)
+ Fixed the maimai DX reboot time from config [#120](https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/120) (Thanks topty!)
## 20240318
### CXB
+ Fixing handle_data_shop_list_detail_request for Sunrise S1
## 20240302
### SAO
+ Fixing new profile creation with right heroes and start VP

View File

@ -1,182 +1,8 @@
# Contributing to ARTEMiS
If you would like to contribute to artemis, either by adding features, games, or fixing bugs, you can do so by forking the repo and submitting a pull request [here](https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls). This guide assume you're familiar with both git, python, and the libraries that artemis uses.
This document is a work in progress. If you have any questions or notice any errors, please report it to the discord.
If you would like to contribute to artemis, either by adding features, games, or fixing bugs, you can do so by forking the repo and submitting a pull request [here](https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls). Please make sure, if you're submitting a PR for a game or game version, that you're following the n-0/y-1 guidelines, or it will be rejected.
## Adding games
### Step 0
+ Follow the "n-1" rule of thumb. PRs for game versions that are currently active in arcades will be deleted. If you're unsure, ask!
+ Always PR against the `develop` branch.
+ Check to see if somebody else is already PRing the features/games you want to add. If they are, consider contributing to them rather then making an entirely new PR.
+ We don't technically have a written code style guide (TODO) but try to keep your code consistant with code that's already there where possible.
### Step 1 (Setup)
1) Fork the gitea repo, clone your fork, and checkout the develop branch.
2) Make a new folder in the `titles` folder, name it some recogniseable shorthand for your game (Chunithm becomes chuni, maimai dx is mai2, etc)
3) In this new folder, create a file named `__init__.py`. This is the first thing that will load when your title module is loaded by the core system, and it acts as sort of a directory for where everything lives in your module. This file will contain the following required items:
+ `index`: must point to a subclass of `BaseServlet` that will handle setup and dispatching of your game.
+ `game_codes`: must be a list of 4 letter SEGA game codes as strings.
It can also contain the following optional fields:
+ `database`: points to a subclass of `Data` that contains one or more subclasses of `BaseData` that act as database transaction handlers. Required for the class to store and retrieve data from the database.
+ `reader`: points to a subclass of `BaseReader` that handles importing static data from game files into the database.
+ `frontend`: points to a subclass of `FE_Base` that handles frontend routes for your game.
The next step will focus on `index`
### Step 2 (Index)
1) Create another file in your game's folder. By convention, it should be called `index.py`.
2) Inside `index.py`, add the following code, replacing {Game name here} with the name of your game, without spaces or special characters. Look at other titles for examples.
```py
from core.title import BaseServlet
from core import CoreConfig
class {Game name here}Servlet(BaseServlet):
def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None:
pass
```
3) The `__init__` function should acomplish the following:
+ Reading your game's config
+ Setting up your games logger
+ Instancing your games versions
It's usually safe to copy and paste the `__init__` functions from other games, just make sure you change everything that needs to be changed!
4) Go back to the `__init__.py` that you created and add the following:
```py
from .index import {Game name here}Servlet
index = {Game name here}Servlet
```
5) Going back to `index.py`, within the Servlet class, define the following functions from `BaseServlet` as needed (see function documentation):
+ `is_game_enabled`: Returns true if the game is enabled and should be served, false otherwise. Returns false by default, so override this to allow your game to be served.
+ `get_routes`: Returns a list of Starlette routes that your game will serve.
+ `get_allnet_info`: Returns a tuple of strings where the first is the allnet uri and the second is the allnet host. The function takes the game ID, version and keychip ID as parameters, so you can send different responses if need be.
+ `get_mucha_info`: Only used by games that use Mucha as authentication. Returns a tuple where the first is a bool that is weather or not the game is enabled, the 2nd is a list of game CDs as strings that this servlet should handle, and the 3rd is a list of netID prefixes that each game CD should use. If your game does not use mucha, do not define this function.
+ `setup`: Preforms any setup your servlet requires, such as spinning up matching servers. It is run once when the server starts. If you don't need any setup, do not define.
6) Make sure any functions you specify to handle routes in `get_routes` are defined as async, as follows: `async def handle_thing(self, request: Request) -> Response:` where Response is whatever kind of Response class you'll be returning. Make sure all paths in this function return some subclass of Response, otherwise you'll get an error when serving.
### Step 3 (Constants)
1) In your game's folder, create a file to store static values for your game. By convention, we call this `const.py`
2) Inside, create a class called `{Game name here}Constants`. Do not define an `__init__` function.
3) Put constants related to your game here. A good example of something to put here is game codes.
```py
class {Game name here}Constants:
GAME_CODE = "SBXX"
CONFIG_NAME = "{game name}.yaml"
```
4) If you choose to put game codes in here, add this to your `__init__.py` file:
```py
from .const import {Game name here}Constants
...
game_codes = [{Game name here}Constants.GAME_CODE]
```
### Step 4 (Config)
1) Make a file to store your game's config. By convention, it should be called `config.py`
2) Inside that file, add the following:
```py
from core.config import CoreConfig
class {game name}ServerConfig:
def __init__(self, parent_config: "{game name}Config") -> None:
self.__config = parent_config
@property
def enable(self) -> bool:
return CoreConfig.get_config_field(
self.__config, "{game name}", "server", "enable", default=True
)
@property
def loglevel(self) -> int:
return CoreConfig.str_to_loglevel(
CoreConfig.get_config_field(
self.__config, "{game name}", "server", "loglevel", default="info"
)
)
class {game name}Config(dict):
def __init__(self) -> None:
self.server = {game name}ServerConfig(self)
```
3) In the `example_config` folder, create a yaml file for your game. By convention, it should be called `{game folder name}.ymal`. Add the following:
```yaml
server:
enable: True
loglevel: "info"
```
4) Add any additional config options that you feel the game needs. Look to other games for config examples.
5) In `index.py` import your config and instance it in `__init__` with:
```py
self.game_cfg = {game folder name}Config()
if path.exists(f"{cfg_dir}/{game folder name}Constants.CONFIG_NAME}"):
self.game_cfg.update(
yaml.safe_load(open(f"{cfg_dir}/{game folder name}Constants.CONFIG_NAME}"))
)
```
This will attempt to load the config file you specified in your constants, and if not, go with the defaults specified in `config.py`. This game_cfg object can then be passed down to your handlers when you create them.
At this stage your game should be loaded by allnet, and serve whatever routes you put in `get_routes`. See the next section about adding versions and handlers.
### Step 5 (Database)
TODO
### Step 6 (Frontend)
TODO
### Step 7 (Reader)
TODO
Guide WIP
## Adding game versions
See the above section about code expectations and how to PR.
1) In the game's folder, create a python file to contain the version handlers. By convention, the first version is version 0, and is stored in `base.py`. Versions following that increment the version number, and are stored in `{short version name}.py`. See Wacca's folder for an example of how to name versions.
2) Internal version numbers should be defined in `const.py`. The version should change any time the game gets a major update (i.e. a new version or plus version.)
```py
# in const.py
VERSION_{game name} = 0
VERSION_{game name}_PLUS = 1
```
3) Inside `base.py` (or whatever your version is named) add the following:
```py
class {game name}Base:
def __init__(self, cfg: CoreConfig, game_cfg: {game name}Config) -> None:
self.game_config = game_cfg
self.core_config = cfg
self.version = {game name}Constants.VERSION_{game name}
self.data = {game name}Data(cfg)
# Any other initialization stuff
```
4) Define your handlers. This will vary wildly by game, but best practice is to keep the naming consistant, so that the main dispatch function in `index.py` can use `getattr` to get the handler, rather then having a static list of what endpoint or request type goes to which handler. See Wacca's `index.py` and `base.py` for examples of how to do this.
5) If your version is not the base version, make sure it inherits from the base version:
```py
class {game name}Plus({game name}Base):
def __init__(self, cfg: CoreConfig, game_cfg: {game name}Config) -> None:
super().__init__(cfg, game_cfg)
self.version = {game name}Constants.VERSION_{game name}_PLUS
```
6) Back in `index.py` make sure to import your new class, and add it to `__init__`. Some games may opt to just a single list called `self.versions` that contains all the version classes at their internal version's index. Others may simply define them as seperate members. See Wacca for an example of `self.versions`
7) Add your version to your game's dispatching logic.
8) Test to make sure your game is being handled properly.
9) Submit a PR.
## Adding/improving core services
If you intend to submit improvements or additions to core services (allnet, mucha, billing, aimedb, database, etc) please get in touch with a maintainer.
Guide WIP

View File

@ -1,5 +1,7 @@
from core.config import CoreConfig
from core.allnet import AllnetServlet, BillingServlet
from core.aimedb import AimedbServlette
from core.title import TitleServlet
from core.utils import Utils
from core.mucha import MuchaServlet
from core.frontend import FrontendServlet

View File

@ -2,5 +2,5 @@ from .base import ADBBaseRequest, ADBBaseResponse, ADBHeader, ADBHeaderException
from .base import CompanyCodes, ReaderFwVer, CMD_CODE_GOODBYE, HEADER_SIZE
from .lookup import ADBLookupRequest, ADBLookupResponse, ADBLookupExResponse
from .campaign import ADBCampaignClearRequest, ADBCampaignClearResponse, ADBCampaignResponse, ADBOldCampaignRequest, ADBOldCampaignResponse
from .felica import ADBFelicaLookupRequest, ADBFelicaLookupResponse, ADBFelicaLookupExRequest, ADBFelicaLookupExResponse
from .felica import ADBFelicaLookupRequest, ADBFelicaLookupResponse, ADBFelicaLookup2Request, ADBFelicaLookup2Response
from .log import ADBLogExRequest, ADBLogRequest, ADBStatusLogRequest, ADBLogExResponse

View File

@ -120,7 +120,7 @@ class ADBHeader:
if self.store_id == 0:
raise ADBHeaderException(f"Store ID cannot be 0!")
if re.fullmatch(r"^A[0-9]{2}[A-Z][0-9]{2}[A-HJ-NP-Z][0-9]{4}$", self.keychip_id) is None:
if re.fullmatch(r"^A[0-9]{2}[E|X][0-9]{2}[A-HJ-NP-Z][0-9]{4}$", self.keychip_id) is None:
raise ADBHeaderException(f"Keychip ID {self.keychip_id} is invalid!")
return True

View File

@ -10,14 +10,13 @@ class ADBFelicaLookupRequest(ADBBaseRequest):
self.pmm = hex(pmm)[2:].upper()
class ADBFelicaLookupResponse(ADBBaseResponse):
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:
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:
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.idx = idx
@classmethod
def from_req(cls, req: ADBHeader, access_code: str = None, idx: int = 0) -> "ADBFelicaLookupResponse":
c = cls(access_code, idx, req.game_id, req.store_id, req.keychip_id)
def from_req(cls, req: ADBHeader, access_code: str = None) -> "ADBFelicaLookupResponse":
c = cls(access_code, req.game_id, req.store_id, req.keychip_id)
c.head.protocol_ver = req.protocol_ver
return c
@ -27,7 +26,7 @@ class ADBFelicaLookupResponse(ADBBaseResponse):
"access_code" / Int8ub[10],
Padding(2)
).build(dict(
felica_idx = self.idx,
felica_idx = 0,
access_code = bytes.fromhex(self.access_code)
))
@ -35,7 +34,7 @@ class ADBFelicaLookupResponse(ADBBaseResponse):
return self.head.make() + resp_struct
class ADBFelicaLookupExRequest(ADBBaseRequest):
class ADBFelicaLookup2Request(ADBBaseRequest):
def __init__(self, data: bytes) -> None:
super().__init__(data)
self.random = struct.unpack_from("<16s", data, 0x20)[0]
@ -46,7 +45,7 @@ class ADBFelicaLookupExRequest(ADBBaseRequest):
self.company = CompanyCodes(int.from_bytes(company, 'little'))
self.fw_ver = ReaderFwVer.from_byte(fw_ver)
class ADBFelicaLookupExResponse(ADBBaseResponse):
class ADBFelicaLookup2Response(ADBBaseResponse):
def __init__(self, user_id: Union[int, None] = None, access_code: Union[str, None] = None, game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", code: int = 0x12, length: int = 0x130, status: int = 1) -> None:
super().__init__(code, length, status, game_id, store_id, keychip_id)
self.user_id = user_id if user_id is not None else -1
@ -56,7 +55,7 @@ class ADBFelicaLookupExResponse(ADBBaseResponse):
self.auth_key = [0] * 256
@classmethod
def from_req(cls, req: ADBHeader, user_id: Union[int, None] = None, access_code: Union[str, None] = None) -> "ADBFelicaLookupExResponse":
def from_req(cls, req: ADBHeader, user_id: Union[int, None] = None, access_code: Union[str, None] = None) -> "ADBFelicaLookup2Response":
c = cls(user_id, access_code, req.game_id, req.store_id, req.keychip_id)
c.head.protocol_ver = req.protocol_ver
return c

View File

@ -176,12 +176,6 @@ class AimedbServlette():
async def handle_lookup(self, data: bytes, resp_code: int) -> ADBBaseResponse:
req = ADBLookupRequest(data)
if req.access_code == "00000000000000000000":
self.logger.warn(f"All-zero access code from {req.head.keychip_id}")
ret = ADBLookupResponse.from_req(req.head, -1)
ret.head.status = ADBStatus.BAN_SYS
return ret
user_id = await self.data.card.get_user_id_from_card(req.access_code)
is_banned = await self.data.card.get_card_banned(req.access_code)
is_locked = await self.data.card.get_card_locked(req.access_code)
@ -200,19 +194,10 @@ class AimedbServlette():
if user_id and user_id > 0:
await self.data.card.update_card_last_login(req.access_code)
if (req.access_code.startswith("010") or req.access_code.startswith("3")) and req.serial_number != 0x04030201: # Default segatools sn
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:08X} for access code {req.access_code}")
return ret
async def handle_lookup_ex(self, data: bytes, resp_code: int) -> ADBBaseResponse:
req = ADBLookupRequest(data)
if req.access_code == "00000000000000000000":
self.logger.warn(f"All-zero access code from {req.head.keychip_id}")
ret = ADBLookupExResponse.from_req(req.head, -1)
ret.head.status = ADBStatus.BAN_SYS
return ret
user_id = await self.data.card.get_user_id_from_card(req.access_code)
is_banned = await self.data.card.get_card_banned(req.access_code)
@ -244,50 +229,25 @@ class AimedbServlette():
async def handle_felica_lookup(self, data: bytes, resp_code: int) -> bytes:
"""
On official, the IDm is used as a key to look up the stored access code in a large
database. We do not have access to that database so we have to make due with what we got.
Interestingly, namco games are able to read S_PAD0 and send the server the correct access
code, but aimedb doesn't. Until somebody either enters the correct code manually, or scans
on a game that reads it correctly from the card, this will have to do. It's the same conversion
used on the big boy networks.
On official, I think a card has to be registered for this to actually work, but
I'm making the executive decision to not implement that and just kick back our
faux generated access code. The real felica IDm -> access code conversion is done
on the ADB server, which we do not and will not ever have access to. Because we can
assure that all IDms will be unique, this basic 0-padded hex -> int conversion will
be fine.
"""
req = ADBFelicaLookupRequest(data)
idm = req.idm.zfill(16)
if idm == "0000000000000000":
self.logger.warn(f"All-zero IDm from {req.head.keychip_id}")
ret = ADBFelicaLookupResponse.from_req(req.head, "00000000000000000000")
ret.head.status = ADBStatus.BAN_SYS
return ret
card = await self.data.card.get_card_by_idm(idm)
if not card:
ac = self.data.card.to_access_code(idm)
test = await self.data.card.get_card_by_access_code(ac)
if test:
await self.data.card.set_idm_by_access_code(ac, idm)
else:
ac = card['access_code']
ac = self.data.card.to_access_code(req.idm)
self.logger.info(
f"idm {idm} ipm {req.pmm.zfill(16)} -> access_code {ac}"
f"idm {req.idm} ipm {req.pmm} -> access_code {ac}"
)
return ADBFelicaLookupResponse.from_req(req.head, ac)
async def handle_felica_register(self, data: bytes, resp_code: int) -> bytes:
"""
Used to register felica moble access codes. Will never be used on our network
because we don't implement felica_lookup properly.
I've never seen this used.
"""
req = ADBFelicaLookupRequest(data)
idm = req.idm.zfill(16)
if idm == "0000000000000000":
self.logger.warn(f"All-zero IDm from {req.head.keychip_id}")
ret = ADBFelicaLookupResponse.from_req(req.head, "00000000000000000000")
ret.head.status = ADBStatus.BAN_SYS
return ret
ac = self.data.card.to_access_code(req.idm)
if self.config.server.allow_user_registration:
@ -318,44 +278,18 @@ class AimedbServlette():
return ADBFelicaLookupResponse.from_req(req.head, ac)
async def handle_felica_lookup_ex(self, data: bytes, resp_code: int) -> bytes:
req = ADBFelicaLookupExRequest(data)
user_id = None
idm = req.idm.zfill(16)
if idm == "0000000000000000":
self.logger.warn(f"All-zero IDm from {req.head.keychip_id}")
ret = ADBFelicaLookupExResponse.from_req(req.head, -1, "00000000000000000000")
ret.head.status = ADBStatus.BAN_SYS
return ret
card = await self.data.card.get_card_by_idm(idm)
if not card:
access_code = self.data.card.to_access_code(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, idm)
else:
user_id = card['user']
access_code = card['access_code']
req = ADBFelicaLookup2Request(data)
access_code = self.data.card.to_access_code(req.idm)
user_id = await self.data.card.get_user_id_from_card(access_code=access_code)
if user_id is None:
user_id = -1
self.logger.info(
f"idm {idm} ipm {req.pmm} -> access_code {access_code} user_id {user_id}"
f"idm {req.idm} ipm {req.pmm} -> access_code {access_code} user_id {user_id}"
)
resp = ADBFelicaLookupExResponse.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
resp = ADBFelicaLookup2Response.from_req(req.head, user_id, access_code)
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)
@ -380,12 +314,6 @@ class AimedbServlette():
async def handle_register(self, data: bytes, resp_code: int) -> bytes:
req = ADBLookupRequest(data)
user_id = -1
if req.access_code == "00000000000000000000":
self.logger.warn(f"All-zero access code from {req.head.keychip_id}")
ret = ADBLookupResponse.from_req(req.head, -1)
ret.head.status = ADBStatus.BAN_SYS
return ret
if self.config.server.allow_user_registration:
user_id = await self.data.user.create_user()
@ -409,16 +337,6 @@ class AimedbServlette():
self.logger.info(
f"Registration blocked!: access code {req.access_code}"
)
if user_id > 0:
if (req.access_code.startswith("010") or req.access_code.startswith("3")) and req.serial_number != 0x04030201: # Default segatools sn:
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)
if resp.user_id <= 0:

View File

@ -132,7 +132,7 @@ class AllnetServlet:
async def handle_poweron(self, request: Request):
request_ip = Utils.get_ip_addr(request)
pragma_header = request.headers.get('Pragma', "")
is_dfi = pragma_header == "DFI"
is_dfi = pragma_header is not None and pragma_header == "DFI"
data = await request.body()
try:
@ -171,7 +171,7 @@ class AllnetServlet:
if machine is None and not self.config.server.allow_unregistered_serials:
msg = f"Unrecognised serial {req.serial} attempted allnet auth from {request_ip}."
await self.data.base.log_event(
"allnet", "ALLNET_AUTH_UNKNOWN_SERIAL", logging.WARN, msg, {"serial": req.serial}, None, None, None, request_ip, req.game_id, req.ver
"allnet", "ALLNET_AUTH_UNKNOWN_SERIAL", logging.WARN, msg
)
self.logger.warning(msg)
@ -183,9 +183,9 @@ class AllnetServlet:
arcade = await self.data.arcade.get_arcade(machine["arcade"])
if self.config.server.check_arcade_ip:
if arcade["ip"] and arcade["ip"] is not None and arcade["ip"] != req.ip:
msg = f"{req.serial} attempted allnet auth from bad IP {req.ip} (expected {arcade['ip']})."
msg = f"Serial {req.serial} attempted allnet auth from bad IP {req.ip} (expected {arcade['ip']})."
await self.data.base.log_event(
"allnet", "ALLNET_AUTH_BAD_IP", logging.ERROR, msg, {}, None, arcade['id'], machine['id'], request_ip, req.game_id, req.ver
"allnet", "ALLNET_AUTH_BAD_IP", logging.ERROR, msg
)
self.logger.warning(msg)
@ -194,9 +194,9 @@ class AllnetServlet:
return PlainTextResponse(urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n")
elif (not arcade["ip"] or arcade["ip"] is None) and self.config.server.strict_ip_checking:
msg = f"{req.serial} attempted allnet auth from bad IP {req.ip}, but arcade {arcade['id']} has no IP set! (strict checking enabled)."
msg = f"Serial {req.serial} attempted allnet auth from bad IP {req.ip}, but arcade {arcade['id']} has no IP set! (strict checking enabled)."
await self.data.base.log_event(
"allnet", "ALLNET_AUTH_NO_SHOP_IP", logging.ERROR, msg, {}, None, arcade['id'], machine['id'], request_ip, req.game_id, req.ver
"allnet", "ALLNET_AUTH_NO_SHOP_IP", logging.ERROR, msg
)
self.logger.warning(msg)
@ -204,17 +204,7 @@ class AllnetServlet:
resp_dict = {k: v for k, v in vars(resp).items() if v is not None}
return PlainTextResponse(urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n")
if machine['game'] and machine['game'] != req.game_id:
msg = f"{req.serial} attempted allnet auth with bad game ID {req.game_id} (expected {machine['game']})."
await self.data.base.log_event(
"allnet", "ALLNET_AUTH_BAD_GAME", logging.ERROR, msg, {}, None, arcade['id'], machine['id'], request_ip, req.game_id, req.ver
)
self.logger.warning(msg)
resp.stat = ALLNET_STAT.bad_game.value
resp_dict = {k: v for k, v in vars(resp).items() if v is not None}
return PlainTextResponse(urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n")
country = (
arcade["country"] if machine["country"] is None else machine["country"]
)
@ -246,14 +236,11 @@ class AllnetServlet:
arcade["timezone"] if arcade["timezone"] is not None else "+0900" if req.format_ver == 3 else "+09:00"
)
else:
arcade = None
if req.game_id not in TitleServlet.title_registry:
if not self.config.server.is_develop:
msg = f"Unrecognised game {req.game_id} attempted allnet auth from {request_ip}."
await self.data.base.log_event(
"allnet", "ALLNET_AUTH_UNKNOWN_GAME", logging.WARN, msg, {}, None, arcade['id'] if arcade else None, machine['id'] if machine else None, request_ip, req.game_id, req.ver
"allnet", "ALLNET_AUTH_UNKNOWN_GAME", logging.WARN, msg
)
self.logger.warning(msg)
@ -284,37 +271,25 @@ class AllnetServlet:
resp_dict = {k: v for k, v in vars(resp).items() if v is not None}
return PlainTextResponse(urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n")
if machine and arcade:
msg = f"{req.serial} authenticated from {request_ip}: {req.game_id} v{req.ver}"
await self.data.base.log_event(
"allnet", "ALLNET_AUTH_SUCCESS", logging.INFO, msg, {}, None, arcade['id'], machine['id'], request_ip, req.game_id, req.ver
)
else:
msg = f"Allow unregistered serial {req.serial} to authenticate from {request_ip}: {req.game_id} v{req.ver}"
await self.data.base.log_event(
"allnet", "ALLNET_AUTH_SUCCESS_UNREG", logging.INFO, msg, {"serial": req.serial}, None, None, None, request_ip, req.game_id, req.ver
)
msg = f"{req.serial} authenticated from {request_ip}: {req.game_id} v{req.ver}"
await self.data.base.log_event("allnet", "ALLNET_AUTH_SUCCESS", logging.INFO, msg)
self.logger.info(msg)
resp_dict = {k: v for k, v in vars(resp).items() if v is not None}
resp_str = urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n"
resp_str = urllib.parse.unquote(urllib.parse.urlencode(resp_dict))
self.logger.debug(f"Allnet response: {resp_dict}")
resp_str += "\n"
if is_dfi:
return PlainTextResponse(
content=self.to_dfi(resp_str) + b"\r\n",
headers={
"Pragma": "DFI",
},
)
"""if is_dfi:
request.responseHeaders.addRawHeader('Pragma', 'DFI')
return self.to_dfi(resp_str)"""
return PlainTextResponse(resp_str)
async def handle_dlorder(self, request: Request):
request_ip = Utils.get_ip_addr(request)
pragma_header = request.headers.get('Pragma', "")
is_dfi = pragma_header == "DFI"
is_dfi = pragma_header is not None and pragma_header == "DFI"
data = await request.body()
try:
@ -351,11 +326,7 @@ class AllnetServlet:
):
return PlainTextResponse(urllib.parse.unquote(urllib.parse.urlencode(vars(resp))) + "\n")
else:
machine = await self.data.arcade.get_machine(req.serial)
if not machine or not machine['ota_enable'] or not machine['is_cab'] or machine['is_blacklisted']:
return PlainTextResponse(urllib.parse.unquote(urllib.parse.urlencode(vars(resp))) + "\n")
else: # TODO: Keychip check
if path.exists(
f"{self.config.allnet.update_cfg_folder}/{req.game_id}-{req.ver.replace('.', '')}-app.ini"
):
@ -366,38 +337,25 @@ class AllnetServlet:
):
resp.uri += f"|http://{self.config.server.hostname}:{self.config.server.port}/dl/ini/{req.game_id}-{req.ver.replace('.', '')}-opt.ini"
if resp.uri:
self.logger.info(f"Sending download uri {resp.uri}")
await self.data.base.log_event(
"allnet", "DLORDER_REQ_SUCCESS", logging.INFO, f"Send download URI to {req.serial} for {req.game_id} v{req.ver} from {Utils.get_ip_addr(request)}", {"uri": resp.uri}, None,
machine['arcade'], machine['id'], request_ip, req.game_id, req.ver
)
# Maybe add a log event for checkin but no url sent?
self.logger.debug(f"Sending download uri {resp.uri}")
await self.data.base.log_event("allnet", "DLORDER_REQ_SUCCESS", logging.INFO, f"{Utils.get_ip_addr(request)} requested DL Order for {req.serial} {req.game_id} v{req.ver}")
res_str = urllib.parse.unquote(urllib.parse.urlencode(vars(resp))) + "\n"
if is_dfi:
return PlainTextResponse(
content=self.to_dfi(res_str) + b"\r\n",
headers={
"Pragma": "DFI",
},
)
"""if is_dfi:
request.responseHeaders.addRawHeader('Pragma', 'DFI')
return self.to_dfi(res_str)"""
return PlainTextResponse(res_str)
async def handle_dlorder_ini(self, request: Request) -> bytes:
req_file = request.path_params.get("file", "").replace("%0A", "").replace("\n", "")
request_ip = Utils.get_ip_addr(request)
if not req_file:
return PlainTextResponse(status_code=404)
if path.exists(f"{self.config.allnet.update_cfg_folder}/{req_file}"):
self.logger.info(f"Request for DL INI file {req_file} from {request_ip} successful")
await self.data.base.log_event(
"allnet", "DLORDER_INI_SENT", logging.INFO, f"{request_ip} successfully recieved {req_file}", {"file": req_file}, ip=request_ip
)
self.logger.info(f"Request for DL INI file {req_file} from {Utils.get_ip_addr(request)} successful")
await self.data.base.log_event("allnet", "DLORDER_INI_SENT", logging.INFO, f"{Utils.get_ip_addr(request)} successfully recieved {req_file}")
return PlainTextResponse(open(
f"{self.config.allnet.update_cfg_folder}/{req_file}", "r", encoding="utf-8"
@ -435,13 +393,7 @@ class AllnetServlet:
msg = f"{rep.serial} @ {client_ip} reported {rep.rep_type.name} download state {rep.rf_state.name} for {rep.gd} v{rep.dav}:"\
f" {rep.tdsc}/{rep.tsc} segments downloaded for working files {rep.wfl} with {rep.dfl if rep.dfl else 'none'} complete."
machine = await self.data.arcade.get_machine(rep.serial)
if machine:
await self.data.base.log_event("allnet", "DL_REPORT", logging.INFO, msg, dl_data, None, machine['arcade'], machine['id'], client_ip, rep.gd, rep.dav)
else:
msg = "Unknown serial " + msg
await self.data.base.log_event("allnet", "DL_REPORT_UNREG", logging.INFO, msg, dl_data, None, None, None, client_ip, rep.gd, rep.dav)
await self.data.base.log_event("allnet", "DL_REPORT", logging.INFO, msg, dl_data)
self.logger.info(msg)
return PlainTextResponse("OK")
@ -461,24 +413,14 @@ class AllnetServlet:
if serial is None or num_files_dld is None or num_files_to_dl is None or dl_state is None:
return PlainTextResponse("NG")
msg = f"LoaderStateRecorder Request from {ip} {serial}: {num_files_dld}/{num_files_to_dl} Files download (State: {dl_state})"
machine = await self.data.arcade.get_machine(serial)
if machine:
await self.data.base.log_event("allnet", "LSR_REPORT", logging.INFO, msg, req_dict, None, machine['arcade'], machine['id'], ip)
else:
msg = "Unregistered " + msg
await self.data.base.log_event("allnet", "LSR_REPORT_UNREG", logging.INFO, msg, req_dict, None, None, None, ip)
self.logger.info(msg)
self.logger.info(f"LoaderStateRecorder Request from {ip} {serial}: {num_files_dld}/{num_files_to_dl} Files download (State: {dl_state})")
return PlainTextResponse("OK")
async def handle_alive(self, request: Request) -> bytes:
return PlainTextResponse("OK")
async def handle_naomitest(self, request: Request) -> bytes:
# This could be spam-able, removing
#self.logger.info(f"Ping from {Utils.get_ip_addr(request)}")
self.logger.info(f"Ping from {Utils.get_ip_addr(request)}")
return PlainTextResponse("naomi ok")
def allnet_req_to_dict(self, data: str) -> Optional[List[Dict[str, Any]]]:
@ -608,35 +550,18 @@ class BillingServlet:
if machine is None and not self.config.server.allow_unregistered_serials:
msg = f"Unrecognised serial {req.keychipid} attempted billing checkin from {request_ip} for {req.gameid} v{req.gamever}."
await self.data.base.log_event(
"allnet", "BILLING_CHECKIN_NG_SERIAL", logging.WARN, msg, ip=request_ip, game=req.gameid, version=req.gamever
"allnet", "BILLING_CHECKIN_NG_SERIAL", logging.WARN, msg
)
self.logger.warning(msg)
return PlainTextResponse(f"result=1&requestno={req.requestno}&message=Keychip Serial bad\r\n")
log_details = {
"playcount": req.playcnt,
"billing_type": req.billingtype.name,
"nearfull": req.nearfull,
"playlimit": req.playlimit,
}
if machine is not None:
await self.data.base.log_event("billing", "BILLING_CHECKIN_OK", logging.INFO, "", log_details, None, machine['arcade'], machine['id'], request_ip, req.gameid, req.gamever)
self.logger.info(
f"Unregistered Billing checkin from {request_ip}: game {req.gameid} ver {req.gamever} keychip {req.keychipid} playcount "
f"{req.playcnt} billing_type {req.billingtype.name} nearfull {req.nearfull} playlimit {req.playlimit}"
)
else:
log_details['serial'] = req.keychipid
await self.data.base.log_event("billing", "BILLING_CHECKIN_OK_UNREG", logging.INFO, "", log_details, None, None, None, request_ip, req.gameid, req.gamever)
self.logger.info(
f"Unregistered Billing checkin from {request_ip}: game {req.gameid} ver {req.gamever} keychip {req.keychipid} playcount "
f"{req.playcnt} billing_type {req.billingtype.name} nearfull {req.nearfull} playlimit {req.playlimit}"
)
msg = (
f"Billing checkin from {request_ip}: game {req.gameid} ver {req.gamever} keychip {req.keychipid} playcount "
f"{req.playcnt} billing_type {req.billingtype.name} nearfull {req.nearfull} playlimit {req.playlimit}"
)
self.logger.info(msg)
await self.data.base.log_event("billing", "BILLING_CHECKIN_OK", logging.INFO, msg)
if req.traceleft > 0:
self.logger.warn(f"{req.traceleft} unsent tracelogs")
kc_playlimit = req.playlimit
@ -960,7 +885,7 @@ class DLReport:
return True
cfg_dir = environ.get("ARTEMIS_CFG_DIR", "config")
cfg_dir = environ.get("DIANA_CFG_DIR", "config")
cfg: CoreConfig = CoreConfig()
if path.exists(f"{cfg_dir}/core.yaml"):
cfg.update(yaml.safe_load(open(f"{cfg_dir}/core.yaml")))

View File

@ -9,8 +9,7 @@ from starlette.responses import PlainTextResponse
from os import environ, path, mkdir, W_OK, access
from typing import List
from core import CoreConfig, TitleServlet, MuchaServlet
from core.allnet import AllnetServlet, BillingServlet
from core import CoreConfig, TitleServlet, MuchaServlet, AllnetServlet, BillingServlet, AimedbServlette
from core.frontend import FrontendServlet
async def dummy_rt(request: Request):

View File

@ -1,9 +1,5 @@
import logging
import os
import ssl
from typing import Any, Union
from typing_extensions import Optional
import logging, os
from typing import Any
class ServerConfig:
def __init__(self, parent_config: "CoreConfig") -> None:
@ -180,60 +176,6 @@ class DatabaseConfig:
self.__config, "core", "database", "protocol", default="mysql"
)
@property
def ssl_enabled(self) -> bool:
return CoreConfig.get_config_field(
self.__config, "core", "database", "ssl_enabled", default=False
)
@property
def ssl_cafile(self) -> Optional[str]:
return CoreConfig.get_config_field(
self.__config, "core", "database", "ssl_cafile", default=None
)
@property
def ssl_capath(self) -> Optional[str]:
return CoreConfig.get_config_field(
self.__config, "core", "database", "ssl_capath", default=None
)
@property
def ssl_cert(self) -> Optional[str]:
return CoreConfig.get_config_field(
self.__config, "core", "database", "ssl_cert", default=None
)
@property
def ssl_key(self) -> Optional[str]:
return CoreConfig.get_config_field(
self.__config, "core", "database", "ssl_key", default=None
)
@property
def ssl_key_password(self) -> Optional[str]:
return CoreConfig.get_config_field(
self.__config, "core", "database", "ssl_key_password", default=None
)
@property
def ssl_verify_identity(self) -> bool:
return CoreConfig.get_config_field(
self.__config, "core", "database", "ssl_verify_identity", default=True
)
@property
def ssl_verify_cert(self) -> Optional[Union[str, bool]]:
return CoreConfig.get_config_field(
self.__config, "core", "database", "ssl_verify_cert", default=None
)
@property
def ssl_ciphers(self) -> Optional[str]:
return CoreConfig.get_config_field(
self.__config, "core", "database", "ssl_ciphers", default=None
)
@property
def sha2_password(self) -> bool:
return CoreConfig.get_config_field(
@ -260,53 +202,6 @@ class DatabaseConfig:
self.__config, "core", "database", "memcached_host", default="localhost"
)
def create_ssl_context_if_enabled(self):
if not self.ssl_enabled:
return
no_ca = (
self.ssl_cafile is None
and self.ssl_capath is None
)
ctx = ssl.create_default_context(
cafile=self.ssl_cafile,
capath=self.ssl_capath,
)
ctx.check_hostname = not no_ca and self.ssl_verify_identity
if self.ssl_verify_cert is None:
ctx.verify_mode = ssl.CERT_NONE if no_ca else ssl.CERT_REQUIRED
elif isinstance(self.ssl_verify_cert, bool):
ctx.verify_mode = (
ssl.CERT_REQUIRED
if self.ssl_verify_cert
else ssl.CERT_NONE
)
elif isinstance(self.ssl_verify_cert, str):
value = self.ssl_verify_cert.lower()
if value in ("none", "0", "false", "no"):
ctx.verify_mode = ssl.CERT_NONE
elif value == "optional":
ctx.verify_mode = ssl.CERT_OPTIONAL
elif value in ("required", "1", "true", "yes"):
ctx.verify_mode = ssl.CERT_REQUIRED
else:
ctx.verify_mode = ssl.CERT_NONE if no_ca else ssl.CERT_REQUIRED
if self.ssl_cert:
ctx.load_cert_chain(
self.ssl_cert,
self.ssl_key,
self.ssl_key_password,
)
if self.ssl_ciphers:
ctx.set_ciphers(self.ssl_ciphers)
return ctx
class FrontendConfig:
def __init__(self, parent_config: "CoreConfig") -> None:
self.__config = parent_config

View File

@ -1,18 +1,16 @@
from enum import Enum
class MainboardPlatformCodes(Enum):
RINGEDGE = "AAL"
RINGEDGE2 = "AAS"
RINGWIDE = "AAM"
NU = "AAV"
NUSX = "AAW"
ALLS = "ACA"
#ALLS_UX = "ACAE"
#ALLS_HX = "ACAX"
class MainboardPlatformCodes:
RINGEDGE = "AALE"
RINGWIDE = "AAML"
NU = "AAVE"
NUSX = "AAWE"
ALLS_UX = "ACAE"
ALLS_HX = "ACAX"
class MainboardRevisions(Enum):
class MainboardRevisions:
RINGEDGE = 1
RINGEDGE2 = 2
@ -31,10 +29,11 @@ class MainboardRevisions(Enum):
ALLS_HX2 = 12
class KeychipPlatformsCodes(Enum):
RING = "72"
NU = ("60", "61", "69")
ALLS = "63"
class KeychipPlatformsCodes:
RING = "A72E"
NU = ("A60E", "A60E", "A60E")
NUSX = ("A61X", "A69X")
ALLS = "A63E"
class AllnetCountryCode(Enum):

View File

@ -1,18 +1,8 @@
from __future__ import with_statement
import asyncio
import os
from pathlib import Path
import threading
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
import yaml
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from core.config import CoreConfig
from core.data.schema.base import metadata
# this is the Alembic Config object, which provides
@ -47,29 +37,20 @@ def run_migrations_offline():
script output.
"""
raise Exception("Not implemented or configured!")
raise Exception('Not implemented or configured!')
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
@ -78,42 +59,21 @@ async def run_async_migrations() -> None:
for override in overrides:
ini_section[override] = overrides[override]
core_config = CoreConfig()
with (Path("../../..") / os.environ["ARTEMIS_CFG_DIR"] / "core.yaml").open(encoding="utf-8") as f:
core_config.update(yaml.safe_load(f))
connectable = async_engine_from_config(
connectable = engine_from_config(
ini_section,
poolclass=pool.NullPool,
connect_args={
"charset": "utf8mb4",
"ssl": core_config.database.create_ssl_context_if_enabled(),
}
)
prefix='sqlalchemy.',
poolclass=pool.NullPool)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online():
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# there's no event loop
asyncio.run(run_async_migrations())
else:
# there's currently an event loop and trying to wait for a coroutine
# to finish without using `await` is pretty wormy. nested event loops
# are explicitly forbidden by asyncio.
#
# take the easy way out, spawn it in another thread.
thread = threading.Thread(target=asyncio.run, args=(run_async_migrations(),))
thread.start()
thread.join()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()

View File

@ -1,27 +0,0 @@
"""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

@ -1,28 +0,0 @@
"""mai2_buddies_plus
Revision ID: 28443e2da5b8
Revises: 5ea73f89d982
Create Date: 2024-09-15 20:44:02.351819
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '28443e2da5b8'
down_revision = '5ea73f89d982'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('mai2_profile_detail', sa.Column('point', sa.Integer()))
op.add_column('mai2_profile_detail', sa.Column('totalPoint', sa.Integer()))
op.add_column('mai2_profile_detail', sa.Column('friendRegistSkip', sa.SmallInteger()))
def downgrade():
op.drop_column('mai2_profile_detail', 'point')
op.drop_column('mai2_profile_detail', 'totalPoint')
op.drop_column('mai2_profile_detail', 'friendRegistSkip')

View File

@ -1,48 +0,0 @@
"""add_event_log_info
Revision ID: 2bf9f38d9444
Revises: 81e44dd6047a
Create Date: 2024-05-21 23:00:17.468407
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '2bf9f38d9444'
down_revision = '81e44dd6047a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('event_log', sa.Column('user', sa.INTEGER(), nullable=True))
op.add_column('event_log', sa.Column('arcade', sa.INTEGER(), nullable=True))
op.add_column('event_log', sa.Column('machine', sa.INTEGER(), nullable=True))
op.add_column('event_log', sa.Column('ip', sa.TEXT(length=39), nullable=True))
op.alter_column('event_log', 'when_logged',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('now()'),
existing_nullable=False)
op.create_foreign_key(None, 'event_log', 'machine', ['machine'], ['id'], onupdate='cascade', ondelete='cascade')
op.create_foreign_key(None, 'event_log', 'arcade', ['arcade'], ['id'], onupdate='cascade', ondelete='cascade')
op.create_foreign_key(None, 'event_log', 'aime_user', ['user'], ['id'], onupdate='cascade', ondelete='cascade')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'event_log', type_='foreignkey')
op.drop_constraint(None, 'event_log', type_='foreignkey')
op.drop_constraint(None, 'event_log', type_='foreignkey')
op.alter_column('event_log', 'when_logged',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('current_timestamp()'),
existing_nullable=False)
op.drop_column('event_log', 'ip')
op.drop_column('event_log', 'machine')
op.drop_column('event_log', 'arcade')
op.drop_column('event_log', 'user')
# ### end Alembic commands ###

View File

@ -1,46 +0,0 @@
"""add_event_log_game_version
Revision ID: 2d024cf145a1
Revises: 2bf9f38d9444
Create Date: 2024-05-21 23:41:31.445331
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '2d024cf145a1'
down_revision = '2bf9f38d9444'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('event_log', sa.Column('game', sa.TEXT(length=4), nullable=True))
op.add_column('event_log', sa.Column('version', sa.TEXT(length=24), nullable=True))
op.alter_column('event_log', 'ip',
existing_type=mysql.TINYTEXT(),
type_=sa.TEXT(length=39),
existing_nullable=True)
op.alter_column('event_log', 'when_logged',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('now()'),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('event_log', 'when_logged',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('current_timestamp()'),
existing_nullable=False)
op.alter_column('event_log', 'ip',
existing_type=sa.TEXT(length=39),
type_=mysql.TINYTEXT(),
existing_nullable=True)
op.drop_column('event_log', 'version')
op.drop_column('event_log', 'game')
# ### end Alembic commands ###

View File

@ -1,54 +0,0 @@
"""pokken_fix_pokemon_uk
Revision ID: 3657efefc5a4
Revises: 4a02e623e5e6
Create Date: 2024-06-13 23:50:57.611998
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '3657efefc5a4'
down_revision = '4a02e623e5e6'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('pokken_pokemon_data', 'char_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pokken_pokemon_data', 'illustration_book_no',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.drop_constraint('pokken_pokemon_data_ibfk_1', table_name='pokken_pokemon_data', type_='foreignkey')
op.drop_index('pokken_pokemon_data_uk', table_name='pokken_pokemon_data')
op.create_unique_constraint('pokken_pokemon_uk', 'pokken_pokemon_data', ['user', 'illustration_book_no'])
op.create_foreign_key("pokken_pokemon_data_ibfk_1", "pokken_pokemon_data", "aime_user", ['user'], ['id'])
op.alter_column('pokken_profile', 'trainer_name',
existing_type=mysql.VARCHAR(length=16),
type_=sa.String(length=14),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('pokken_profile', 'trainer_name',
existing_type=sa.String(length=14),
type_=mysql.VARCHAR(length=16),
existing_nullable=True)
op.drop_constraint('pokken_pokemon_data_ibfk_1', table_name='pokken_pokemon_data', type_='foreignkey')
op.drop_constraint('pokken_pokemon_uk', 'pokken_pokemon_data', type_='unique')
op.create_index('pokken_pokemon_data_uk', 'pokken_pokemon_data', ['user', 'char_id'], unique=True)
op.create_foreign_key("pokken_pokemon_data_ibfk_1", "pokken_pokemon_data", "aime_user", ['user'], ['id'])
op.alter_column('pokken_pokemon_data', 'illustration_book_no',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pokken_pokemon_data', 'char_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
# ### end Alembic commands ###

View File

@ -1,122 +0,0 @@
"""chuni_ui_overhaul
Revision ID: 41f77ef50588
Revises: d8cd1fa04c2a
Create Date: 2024-11-02 13:27:45.839787
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '41f77ef50588'
down_revision = 'd8cd1fa04c2a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('chuni_static_avatar', sa.Column('sortName', mysql.VARCHAR(length=255), nullable=True))
op.add_column('chuni_static_avatar', sa.Column('isEnabled', mysql.TINYINT(display_width=1), server_default=sa.text('1'), autoincrement=False, nullable=True))
op.add_column('chuni_static_avatar', sa.Column('defaultHave', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=True))
op.create_table('chuni_static_character',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('version', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('characterId', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('name', mysql.VARCHAR(length=255), nullable=True),
sa.Column('sortName', mysql.VARCHAR(length=255), nullable=True),
sa.Column('worksName', mysql.VARCHAR(length=255), nullable=True),
sa.Column('rareType', mysql.INTEGER(display_width=11), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('imagePath1', mysql.VARCHAR(length=255), nullable=True),
sa.Column('imagePath2', mysql.VARCHAR(length=255), nullable=True),
sa.Column('imagePath3', mysql.VARCHAR(length=255), nullable=True),
sa.Column('isEnabled', mysql.TINYINT(display_width=1), server_default=sa.text('1'), autoincrement=False, nullable=True),
sa.Column('defaultHave', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id'),
mysql_collate='utf8mb4_general_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
op.create_index('chuni_static_character_uk', 'chuni_static_character', ['version', 'characterId'], unique=True)
op.create_table('chuni_static_map_icon',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('version', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('mapIconId', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('name', mysql.VARCHAR(length=255), nullable=True),
sa.Column('sortName', mysql.VARCHAR(length=255), nullable=True),
sa.Column('iconPath', mysql.VARCHAR(length=255), nullable=True),
sa.Column('isEnabled', mysql.TINYINT(display_width=1), server_default=sa.text('1'), autoincrement=False, nullable=True),
sa.Column('defaultHave', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id'),
mysql_collate='utf8mb4_general_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
op.create_index('chuni_static_mapicon_uk', 'chuni_static_map_icon', ['version', 'mapIconId'], unique=True)
op.create_table('chuni_static_nameplate',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('version', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('nameplateId', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('name', mysql.VARCHAR(length=255), nullable=True),
sa.Column('texturePath', mysql.VARCHAR(length=255), nullable=True),
sa.Column('isEnabled', mysql.TINYINT(display_width=1), server_default=sa.text('1'), autoincrement=False, nullable=True),
sa.Column('defaultHave', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('sortName', mysql.VARCHAR(length=255), nullable=True),
sa.PrimaryKeyConstraint('id'),
mysql_collate='utf8mb4_general_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
op.create_index('chuni_static_nameplate_uk', 'chuni_static_nameplate', ['version', 'nameplateId'], unique=True)
op.create_table('chuni_static_trophy',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('version', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('trophyId', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('name', mysql.VARCHAR(length=255), nullable=True),
sa.Column('rareType', mysql.TINYINT(display_width=11), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('isEnabled', mysql.TINYINT(display_width=1), server_default=sa.text('1'), autoincrement=False, nullable=True),
sa.Column('defaultHave', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id'),
mysql_collate='utf8mb4_general_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
op.create_index('chuni_static_trophy_uk', 'chuni_static_trophy', ['version', 'trophyId'], unique=True)
op.create_table('chuni_static_system_voice',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('version', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('voiceId', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('name', mysql.VARCHAR(length=255), nullable=True),
sa.Column('sortName', mysql.VARCHAR(length=255), nullable=True),
sa.Column('imagePath', mysql.VARCHAR(length=255), nullable=True),
sa.Column('isEnabled', mysql.TINYINT(display_width=1), server_default=sa.text('1'), autoincrement=False, nullable=True),
sa.Column('defaultHave', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id'),
mysql_collate='utf8mb4_general_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
op.create_index('chuni_static_systemvoice_uk', 'chuni_static_system_voice', ['version', 'voiceId'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('chuni_static_systemvoice_uk', table_name='chuni_static_system_voice')
op.drop_table('chuni_static_system_voice')
op.drop_index('chuni_static_trophy_uk', table_name='chuni_static_trophy')
op.drop_table('chuni_static_trophy')
op.drop_index('chuni_static_nameplate_uk', table_name='chuni_static_nameplate')
op.drop_table('chuni_static_nameplate')
op.drop_index('chuni_static_mapicon_uk', table_name='chuni_static_map_icon')
op.drop_table('chuni_static_map_icon')
op.drop_index('chuni_static_character_uk', table_name='chuni_static_character')
op.drop_table('chuni_static_character')
op.drop_column('chuni_static_avatar', 'defaultHave')
op.drop_column('chuni_static_avatar', 'isEnabled')
op.drop_column('chuni_static_avatar', 'sortName')
# ### end Alembic commands ###

View File

@ -1,50 +0,0 @@
"""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("chip_id", 'aime_card', type_='unique')
op.drop_constraint("idm", 'aime_card', type_='unique')
op.drop_constraint("access_code", '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

@ -1,48 +0,0 @@
"""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,43 +0,0 @@
"""mai2_intimacy
Revision ID: 54a84103b84e
Revises: bc91c1206dca
Create Date: 2024-09-16 17:47:49.164546
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import Column, Integer, UniqueConstraint
# revision identifiers, used by Alembic.
revision = '54a84103b84e'
down_revision = 'bc91c1206dca'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"mai2_user_intimate",
Column("id", Integer, primary_key=True, nullable=False),
Column("user", Integer, nullable=False),
Column("partnerId", Integer, nullable=False),
Column("intimateLevel", Integer, nullable=False),
Column("intimateCountRewarded", Integer, nullable=False),
UniqueConstraint("user", "partnerId", name="mai2_user_intimate_uk"),
mysql_charset="utf8mb4",
)
op.create_foreign_key(
None,
"mai2_user_intimate",
"aime_user",
["user"],
["id"],
ondelete="cascade",
onupdate="cascade",
)
def downgrade():
op.drop_table("mai2_user_intimate")

View File

@ -1,41 +0,0 @@
"""mai2_presents
Revision ID: 5ea363686347
Revises: 680789dabab3
Create Date: 2024-06-28 14:49:07.666879
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '5ea363686347'
down_revision = '680789dabab3'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mai2_item_present',
sa.Column('id', sa.BIGINT(), nullable=False),
sa.Column('version', sa.INTEGER(), nullable=True),
sa.Column('user', sa.Integer(), nullable=True),
sa.Column('itemKind', sa.INTEGER(), nullable=False),
sa.Column('itemId', sa.INTEGER(), nullable=False),
sa.Column('stock', sa.INTEGER(), server_default='1', nullable=False),
sa.Column('startDate', sa.TIMESTAMP(), nullable=True),
sa.Column('endDate', sa.TIMESTAMP(), nullable=True),
sa.ForeignKeyConstraint(['user'], ['aime_user.id'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('version', 'user', 'itemKind', 'itemId', name='mai2_item_present_uk'),
mysql_charset='utf8mb4'
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('mai2_item_present')
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""card_add_memo
Revision ID: 5ea73f89d982
Revises: 745448d83696
Create Date: 2024-07-06 22:46:56.992152
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '5ea73f89d982'
down_revision = '745448d83696'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('aime_card', sa.Column('memo', sa.VARCHAR(length=16), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('aime_card', 'memo')
# ### end Alembic commands ###

View File

@ -1,295 +0,0 @@
"""sao_player_changes
Revision ID: 680789dabab3
Revises: a616fd164e40
Create Date: 2024-06-26 23:19:16.863778
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '680789dabab3'
down_revision = 'a616fd164e40'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('sao_equipment_data', sa.Column('is_shop_purchase', sa.BOOLEAN(), server_default='0', nullable=False))
op.add_column('sao_equipment_data', sa.Column('is_protect', sa.BOOLEAN(), server_default='0', nullable=False))
op.add_column('sao_equipment_data', sa.Column('property1_property_id', sa.BIGINT(), server_default='2', nullable=False))
op.add_column('sao_equipment_data', sa.Column('property1_value1', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_equipment_data', sa.Column('property1_value2', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_equipment_data', sa.Column('property2_property_id', sa.BIGINT(), server_default='2', nullable=False))
op.add_column('sao_equipment_data', sa.Column('property2_value1', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_equipment_data', sa.Column('property2_value2', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_equipment_data', sa.Column('property3_property_id', sa.BIGINT(), server_default='2', nullable=False))
op.add_column('sao_equipment_data', sa.Column('property3_value1', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_equipment_data', sa.Column('property3_value2', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_equipment_data', sa.Column('property4_property_id', sa.BIGINT(), server_default='2', nullable=False))
op.add_column('sao_equipment_data', sa.Column('property4_value1', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_equipment_data', sa.Column('property4_value2', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_equipment_data', sa.Column('converted_card_num', sa.INTEGER(), server_default='0', nullable=False))
op.alter_column('sao_equipment_data', 'equipment_id',
existing_type=mysql.INTEGER(),
type_=sa.BIGINT(),
existing_nullable=False)
op.alter_column('sao_equipment_data', 'get_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('now()'),
existing_nullable=False)
op.create_foreign_key(None, 'sao_equipment_data', 'sao_static_property', ['property2_property_id'], ['PropertyId'], onupdate='cascade', ondelete='cascade')
op.create_foreign_key(None, 'sao_equipment_data', 'sao_static_property', ['property4_property_id'], ['PropertyId'], onupdate='cascade', ondelete='cascade')
op.create_foreign_key(None, 'sao_equipment_data', 'sao_static_property', ['property3_property_id'], ['PropertyId'], onupdate='cascade', ondelete='cascade')
op.create_foreign_key(None, 'sao_equipment_data', 'sao_static_property', ['property1_property_id'], ['PropertyId'], onupdate='cascade', ondelete='cascade')
op.create_foreign_key(None, 'sao_equipment_data', 'sao_static_equipment_list', ['equipment_id'], ['EquipmentId'], onupdate='cascade', ondelete='cascade')
op.add_column('sao_hero_log_data', sa.Column('max_level_extend_num', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('is_awakenable', sa.BOOLEAN(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('awakening_stage', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('awakening_exp', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('is_shop_purchase', sa.BOOLEAN(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('is_protect', sa.BOOLEAN(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('property1_property_id', sa.BIGINT(), server_default='2', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('property1_value1', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('property1_value2', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('property2_property_id', sa.BIGINT(), server_default='2', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('property2_value1', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('property2_value2', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('property3_property_id', sa.BIGINT(), server_default='2', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('property3_value1', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('property3_value2', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('property4_property_id', sa.BIGINT(), server_default='2', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('property4_value1', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('property4_value2', sa.INTEGER(), server_default='0', nullable=False))
op.add_column('sao_hero_log_data', sa.Column('converted_card_num', sa.INTEGER(), server_default='0', nullable=False))
op.alter_column('sao_hero_log_data', 'main_weapon',
existing_type=mysql.INTEGER(),
nullable=True)
op.alter_column('sao_hero_log_data', 'sub_equipment',
existing_type=mysql.INTEGER(),
nullable=True)
op.alter_column('sao_hero_log_data', 'skill_slot1_skill_id',
existing_type=mysql.INTEGER(),
type_=sa.BIGINT(),
nullable=True)
op.alter_column('sao_hero_log_data', 'skill_slot2_skill_id',
existing_type=mysql.INTEGER(),
type_=sa.BIGINT(),
nullable=True)
op.alter_column('sao_hero_log_data', 'skill_slot3_skill_id',
existing_type=mysql.INTEGER(),
type_=sa.BIGINT(),
nullable=True)
op.alter_column('sao_hero_log_data', 'skill_slot4_skill_id',
existing_type=mysql.INTEGER(),
type_=sa.BIGINT(),
nullable=True)
op.alter_column('sao_hero_log_data', 'skill_slot5_skill_id',
existing_type=mysql.INTEGER(),
type_=sa.BIGINT(),
nullable=True)
op.alter_column('sao_hero_log_data', 'get_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('now()'),
existing_nullable=False)
op.alter_column("sao_hero_log_data", "user_hero_log_id",
existing_type=sa.Integer(),
new_column_name="hero_log_id",
type_=sa.BIGINT(),
nullable=False)
op.execute(sa.text("UPDATE sao_hero_log_data SET skill_slot1_skill_id = NULL WHERE skill_slot1_skill_id = 0;"))
op.execute(sa.text("UPDATE sao_hero_log_data SET skill_slot2_skill_id = NULL WHERE skill_slot2_skill_id = 0;"))
op.execute(sa.text("UPDATE sao_hero_log_data SET skill_slot3_skill_id = NULL WHERE skill_slot3_skill_id = 0;"))
op.execute(sa.text("UPDATE sao_hero_log_data SET skill_slot4_skill_id = NULL WHERE skill_slot4_skill_id = 0;"))
op.execute(sa.text("UPDATE sao_hero_log_data SET skill_slot5_skill_id = NULL WHERE skill_slot5_skill_id = 0;"))
op.execute(sa.text("UPDATE sao_hero_log_data SET main_weapon = NULL WHERE main_weapon = 0;"))
op.execute(sa.text("UPDATE sao_hero_log_data SET sub_equipment = NULL WHERE sub_equipment = 0;"))
op.execute(sa.text("UPDATE sao_hero_party SET user_hero_log_id_1 = NULL WHERE user_hero_log_id_1 = 0;"))
op.execute(sa.text("UPDATE sao_hero_party SET user_hero_log_id_2 = NULL WHERE user_hero_log_id_2 = 0;"))
op.execute(sa.text("UPDATE sao_hero_party SET user_hero_log_id_3 = NULL WHERE user_hero_log_id_3 = 0;"))
op.execute(sa.text("UPDATE sao_hero_log_data INNER JOIN sao_equipment_data ON sao_hero_log_data.main_weapon = sao_equipment_data.equipment_id SET sao_hero_log_data.main_weapon = sao_equipment_data.id;"))
op.execute(sa.text("UPDATE sao_hero_log_data INNER JOIN sao_equipment_data ON sao_hero_log_data.sub_equipment = sao_equipment_data.equipment_id SET sao_hero_log_data.sub_equipment = sao_equipment_data.id;"))
op.execute(sa.text("UPDATE sao_hero_party INNER JOIN sao_hero_log_data ON sao_hero_party.user_hero_log_id_1 = sao_hero_log_data.hero_log_id SET sao_hero_party.user_hero_log_id_1 = sao_hero_log_data.id;"))
op.execute(sa.text("UPDATE sao_hero_party INNER JOIN sao_hero_log_data ON sao_hero_party.user_hero_log_id_2 = sao_hero_log_data.hero_log_id SET sao_hero_party.user_hero_log_id_2 = sao_hero_log_data.id;"))
op.execute(sa.text("UPDATE sao_hero_party INNER JOIN sao_hero_log_data ON sao_hero_party.user_hero_log_id_3 = sao_hero_log_data.hero_log_id SET sao_hero_party.user_hero_log_id_3 = sao_hero_log_data.id;"))
op.create_foreign_key(None, 'sao_hero_log_data', 'sao_static_property', ['property4_property_id'], ['PropertyId'], onupdate='cascade', ondelete='cascade')
op.create_foreign_key(None, 'sao_hero_log_data', 'sao_static_skill', ['skill_slot1_skill_id'], ['SkillId'], onupdate='set null', ondelete='set null')
op.create_foreign_key(None, 'sao_hero_log_data', 'sao_static_skill', ['skill_slot5_skill_id'], ['SkillId'], onupdate='set null', ondelete='set null')
op.create_foreign_key(None, 'sao_hero_log_data', 'sao_static_skill', ['skill_slot2_skill_id'], ['SkillId'], onupdate='set null', ondelete='set null')
op.create_foreign_key(None, 'sao_hero_log_data', 'sao_static_skill', ['skill_slot3_skill_id'], ['SkillId'], onupdate='set null', ondelete='set null')
op.create_foreign_key(None, 'sao_hero_log_data', 'sao_equipment_data', ['main_weapon'], ['id'], onupdate='set null', ondelete='set null')
op.create_foreign_key(None, 'sao_hero_log_data', 'sao_static_property', ['property3_property_id'], ['PropertyId'], onupdate='cascade', ondelete='cascade')
op.create_foreign_key(None, 'sao_hero_log_data', 'sao_static_skill', ['skill_slot4_skill_id'], ['SkillId'], onupdate='set null', ondelete='set null')
op.create_foreign_key(None, 'sao_hero_log_data', 'sao_equipment_data', ['sub_equipment'], ['id'], onupdate='set null', ondelete='set null')
op.create_foreign_key(None, 'sao_hero_log_data', 'sao_static_property', ['property1_property_id'], ['PropertyId'], onupdate='cascade', ondelete='cascade')
op.create_foreign_key(None, 'sao_hero_log_data', 'sao_static_hero_list', ['hero_log_id'], ['HeroLogId'], onupdate='cascade', ondelete='cascade')
op.create_foreign_key(None, 'sao_hero_log_data', 'sao_static_property', ['property2_property_id'], ['PropertyId'], onupdate='cascade', ondelete='cascade')
op.create_foreign_key(None, 'sao_hero_party', 'sao_hero_log_data', ['user_hero_log_id_3'], ['id'], onupdate='cascade', ondelete='cascade')
op.create_foreign_key(None, 'sao_hero_party', 'sao_hero_log_data', ['user_hero_log_id_1'], ['id'], onupdate='cascade', ondelete='cascade')
op.create_foreign_key(None, 'sao_hero_party', 'sao_hero_log_data', ['user_hero_log_id_2'], ['id'], onupdate='cascade', ondelete='cascade')
op.alter_column('sao_item_data', 'get_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('now()'),
existing_nullable=False)
op.alter_column('sao_play_sessions', 'play_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('now()'),
existing_nullable=False)
op.add_column('sao_player_quest', sa.Column('quest_type', sa.INTEGER(), server_default='1', nullable=False))
op.alter_column('sao_player_quest', 'play_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('now()'),
existing_nullable=False)
op.alter_column('sao_player_quest', 'episode_id',
existing_type=mysql.INTEGER(),
new_column_name="quest_scene_id",
type_=sa.BIGINT(),
nullable=False)
op.create_foreign_key(None, 'sao_player_quest', 'sao_static_quest', ['quest_scene_id'], ['QuestSceneId'], onupdate='cascade', ondelete='cascade')
op.add_column('sao_profile', sa.Column('my_shop', sa.INTEGER(), nullable=True))
op.add_column('sao_profile', sa.Column('fav_hero', sa.INTEGER(), nullable=True))
op.add_column('sao_profile', sa.Column('when_register', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=True))
op.add_column('sao_profile', sa.Column('last_login_date', sa.TIMESTAMP(), nullable=True))
op.add_column('sao_profile', sa.Column('last_yui_medal_date', sa.TIMESTAMP(), nullable=True))
op.add_column('sao_profile', sa.Column('last_bonus_yui_medal_date', sa.TIMESTAMP(), nullable=True))
op.add_column('sao_profile', sa.Column('last_comeback_date', sa.TIMESTAMP(), nullable=True))
op.add_column('sao_profile', sa.Column('last_login_bonus_date', sa.TIMESTAMP(), nullable=True))
op.add_column('sao_profile', sa.Column('ad_confirm_date', sa.TIMESTAMP(), nullable=True))
op.add_column('sao_profile', sa.Column('login_ct', sa.INTEGER(), server_default='0', nullable=True))
op.create_foreign_key(None, 'sao_profile', 'sao_hero_log_data', ['fav_hero'], ['id'], onupdate='cascade', ondelete='set null')
def downgrade():
op.drop_constraint("sao_profile_ibfk_2", 'sao_profile', type_='foreignkey')
op.drop_column('sao_profile', 'login_ct')
op.drop_column('sao_profile', 'ad_confirm_date')
op.drop_column('sao_profile', 'last_login_bonus_date')
op.drop_column('sao_profile', 'last_comeback_date')
op.drop_column('sao_profile', 'last_bonus_yui_medal_date')
op.drop_column('sao_profile', 'last_yui_medal_date')
op.drop_column('sao_profile', 'last_login_date')
op.drop_column('sao_profile', 'when_register')
op.drop_column('sao_profile', 'fav_hero')
op.drop_column('sao_profile', 'my_shop')
op.alter_column('sao_player_quest', 'quest_scene_id',
existing_type=mysql.BIGINT(),
new_column_name="episode_id",
type_=sa.INTEGER(),
nullable=False)
op.drop_constraint("sao_player_quest_ibfk_2", 'sao_player_quest', type_='foreignkey')
op.alter_column('sao_player_quest', 'play_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('CURRENT_TIMESTAMP'),
existing_nullable=False)
op.drop_column('sao_player_quest', 'quest_scene_id')
op.drop_column('sao_player_quest', 'quest_type')
op.alter_column('sao_play_sessions', 'play_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('CURRENT_TIMESTAMP'),
existing_nullable=False)
op.alter_column('sao_item_data', 'get_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('CURRENT_TIMESTAMP'),
existing_nullable=False)
op.drop_constraint("sao_hero_party_ibfk_2", 'sao_hero_party', type_='foreignkey')
op.drop_constraint("sao_hero_party_ibfk_3", 'sao_hero_party', type_='foreignkey')
op.drop_constraint("sao_hero_party_ibfk_4", 'sao_hero_party', type_='foreignkey')
op.alter_column("sao_hero_log_data", "hero_log_id",
existing_type=sa.BIGINT(),
new_column_name="user_hero_log_id",
type_=sa.Integer(),
nullable=False)
op.drop_constraint("sao_hero_log_data_ibfk_2", 'sao_hero_log_data', type_='foreignkey')
op.drop_constraint("sao_hero_log_data_ibfk_3", 'sao_hero_log_data', type_='foreignkey')
op.drop_constraint("sao_hero_log_data_ibfk_4", 'sao_hero_log_data', type_='foreignkey')
op.drop_constraint("sao_hero_log_data_ibfk_5", 'sao_hero_log_data', type_='foreignkey')
op.drop_constraint("sao_hero_log_data_ibfk_6", 'sao_hero_log_data', type_='foreignkey')
op.drop_constraint("sao_hero_log_data_ibfk_7", 'sao_hero_log_data', type_='foreignkey')
op.drop_constraint("sao_hero_log_data_ibfk_8", 'sao_hero_log_data', type_='foreignkey')
op.drop_constraint("sao_hero_log_data_ibfk_9", 'sao_hero_log_data', type_='foreignkey')
op.drop_constraint("sao_hero_log_data_ibfk_10", 'sao_hero_log_data', type_='foreignkey')
op.drop_constraint("sao_hero_log_data_ibfk_11", 'sao_hero_log_data', type_='foreignkey')
op.drop_constraint("sao_hero_log_data_ibfk_12", 'sao_hero_log_data', type_='foreignkey')
op.drop_constraint("sao_hero_log_data_ibfk_13", 'sao_hero_log_data', type_='foreignkey')
op.alter_column('sao_hero_log_data', 'get_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('CURRENT_TIMESTAMP'),
existing_nullable=False)
op.alter_column('sao_hero_log_data', 'skill_slot5_skill_id',
existing_type=sa.BIGINT(),
type_=mysql.INTEGER(),
nullable=False)
op.alter_column('sao_hero_log_data', 'skill_slot4_skill_id',
existing_type=sa.BIGINT(),
type_=mysql.INTEGER(),
nullable=False)
op.alter_column('sao_hero_log_data', 'skill_slot3_skill_id',
existing_type=sa.BIGINT(),
type_=mysql.INTEGER(),
nullable=False)
op.alter_column('sao_hero_log_data', 'skill_slot2_skill_id',
existing_type=sa.BIGINT(),
type_=mysql.INTEGER(),
nullable=False)
op.alter_column('sao_hero_log_data', 'skill_slot1_skill_id',
existing_type=sa.BIGINT(),
type_=mysql.INTEGER(),
nullable=False)
op.alter_column('sao_hero_log_data', 'sub_equipment',
existing_type=mysql.INTEGER(),
nullable=False)
op.alter_column('sao_hero_log_data', 'main_weapon',
existing_type=mysql.INTEGER(),
nullable=False)
op.drop_column('sao_hero_log_data', 'converted_card_num')
op.drop_column('sao_hero_log_data', 'property4_value2')
op.drop_column('sao_hero_log_data', 'property4_value1')
op.drop_column('sao_hero_log_data', 'property4_property_id')
op.drop_column('sao_hero_log_data', 'property3_value2')
op.drop_column('sao_hero_log_data', 'property3_value1')
op.drop_column('sao_hero_log_data', 'property3_property_id')
op.drop_column('sao_hero_log_data', 'property2_value2')
op.drop_column('sao_hero_log_data', 'property2_value1')
op.drop_column('sao_hero_log_data', 'property2_property_id')
op.drop_column('sao_hero_log_data', 'property1_value2')
op.drop_column('sao_hero_log_data', 'property1_value1')
op.drop_column('sao_hero_log_data', 'property1_property_id')
op.drop_column('sao_hero_log_data', 'is_protect')
op.drop_column('sao_hero_log_data', 'is_shop_purchase')
op.drop_column('sao_hero_log_data', 'awakening_exp')
op.drop_column('sao_hero_log_data', 'awakening_stage')
op.drop_column('sao_hero_log_data', 'is_awakenable')
op.drop_column('sao_hero_log_data', 'max_level_extend_num')
op.drop_constraint("sao_equipment_data_ibfk_2", 'sao_equipment_data', type_='foreignkey')
op.drop_constraint("sao_equipment_data_ibfk_3", 'sao_equipment_data', type_='foreignkey')
op.drop_constraint("sao_equipment_data_ibfk_4", 'sao_equipment_data', type_='foreignkey')
op.drop_constraint("sao_equipment_data_ibfk_5", 'sao_equipment_data', type_='foreignkey')
op.drop_constraint("sao_equipment_data_ibfk_6", 'sao_equipment_data', type_='foreignkey')
op.alter_column('sao_equipment_data', 'get_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('CURRENT_TIMESTAMP'),
existing_nullable=False)
op.alter_column('sao_equipment_data', 'equipment_id',
existing_type=sa.BIGINT(),
type_=mysql.INTEGER(),
existing_nullable=False)
op.drop_column('sao_equipment_data', 'converted_card_num')
op.drop_column('sao_equipment_data', 'property4_value2')
op.drop_column('sao_equipment_data', 'property4_value1')
op.drop_column('sao_equipment_data', 'property4_property_id')
op.drop_column('sao_equipment_data', 'property3_value2')
op.drop_column('sao_equipment_data', 'property3_value1')
op.drop_column('sao_equipment_data', 'property3_property_id')
op.drop_column('sao_equipment_data', 'property2_value2')
op.drop_column('sao_equipment_data', 'property2_value1')
op.drop_column('sao_equipment_data', 'property2_property_id')
op.drop_column('sao_equipment_data', 'property1_value2')
op.drop_column('sao_equipment_data', 'property1_value1')
op.drop_column('sao_equipment_data', 'property1_property_id')
op.drop_column('sao_equipment_data', 'is_protect')
op.drop_column('sao_equipment_data', 'is_shop_purchase')

View File

@ -1,28 +0,0 @@
"""chuni_team_points
Revision ID: 745448d83696
Revises: 5ea363686347
Create Date: 2024-06-29 00:05:22.479187
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '745448d83696'
down_revision = '5ea363686347'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('chuni_profile_team', sa.Column('userTeamPoint', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('chuni_profile_team', 'userTeamPoint')
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""cxb_add_playlog_grade
Revision ID: 7dc13e364e53
Revises: 2d024cf145a1
Create Date: 2024-05-28 22:31:22.264926
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '7dc13e364e53'
down_revision = '2d024cf145a1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('cxb_playlog', sa.Column('grade', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('cxb_playlog', 'grade')
# ### end Alembic commands ###

View File

@ -1,68 +0,0 @@
"""mai2_buddies_support
Revision ID: 81e44dd6047a
Revises: 6a7e8277763b
Create Date: 2024-03-12 19:10:37.063907
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = "81e44dd6047a"
down_revision = "6a7e8277763b"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"mai2_playlog_2p",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user", sa.Integer(), nullable=False),
sa.Column("userId1", sa.Integer(), nullable=True),
sa.Column("userId2", sa.Integer(), nullable=True),
sa.Column("userName1", sa.String(length=25), nullable=True),
sa.Column("userName2", sa.String(length=25), nullable=True),
sa.Column("regionId", sa.Integer(), nullable=True),
sa.Column("placeId", sa.Integer(), nullable=True),
sa.Column("user2pPlaylogDetailList", sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(
["user"], ["aime_user.id"], onupdate="cascade", ondelete="cascade"
),
sa.PrimaryKeyConstraint("id"),
mysql_charset="utf8mb4",
)
op.add_column(
"mai2_playlog",
sa.Column(
"extBool1", sa.Boolean(), nullable=True, server_default=sa.text("NULL")
),
)
op.add_column(
"mai2_profile_detail",
sa.Column(
"renameCredit", sa.Integer(), nullable=True, server_default=sa.text("NULL")
),
)
op.add_column(
"mai2_profile_detail",
sa.Column(
"currentPlayCount",
sa.Integer(),
nullable=True,
server_default=sa.text("NULL"),
),
)
def downgrade():
op.drop_table("mai2_playlog_2p")
op.drop_column("mai2_playlog", "extBool1")
op.drop_column("mai2_profile_detail", "renameCredit")
op.drop_column("mai2_profile_detail", "currentPlayCount")

View File

@ -1,30 +0,0 @@
"""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

@ -1,437 +0,0 @@
"""sao_backport
Revision ID: a616fd164e40
Revises: 48f4acc43a7e
Create Date: 2024-06-24 20:28:34.471282
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'a616fd164e40'
down_revision = '48f4acc43a7e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('sao_static_quest')
op.create_table('sao_static_quest',
sa.Column('QuestSceneId', sa.BIGINT(), nullable=False),
sa.Column('SortNo', sa.INTEGER(), nullable=False),
sa.Column('Tutorial', sa.BOOLEAN(), nullable=False),
sa.Column('ColRate', sa.DECIMAL(), nullable=False),
sa.Column('LimitDefault', sa.INTEGER(), nullable=False),
sa.Column('LimitResurrection', sa.INTEGER(), nullable=False),
sa.Column('RewardTableSubId', sa.INTEGER(), nullable=False),
sa.Column('PlayerTraceTableSubId', sa.INTEGER(), nullable=False),
sa.Column('SuccessPlayerExp', sa.INTEGER(), nullable=False),
sa.Column('FailedPlayerExp', sa.INTEGER(), nullable=False),
sa.Column('PairExpRate', sa.INTEGER(), nullable=False),
sa.Column('TrioExpRate', sa.INTEGER(), nullable=False),
sa.Column('SingleRewardVp', sa.INTEGER(), nullable=False),
sa.Column('PairRewardVp', sa.INTEGER(), nullable=False),
sa.Column('TrioRewardVp', sa.INTEGER(), nullable=False),
sa.PrimaryKeyConstraint('QuestSceneId'),
mysql_charset='utf8mb4'
)
op.create_table('sao_static_property',
sa.Column('PropertyId', sa.BIGINT(), nullable=False),
sa.Column('PropertyTargetType', sa.INTEGER(), nullable=False),
sa.Column('PropertyName', sa.VARCHAR(length=255), nullable=False),
sa.Column('PropertyName_en', sa.VARCHAR(length=255), nullable=True),
sa.Column('PropertyNameFormat', sa.VARCHAR(length=255), nullable=False),
sa.Column('PropertyNameFormat_en', sa.VARCHAR(length=255), nullable=True),
sa.Column('PropertyTypeId', sa.INTEGER(), nullable=False),
sa.Column('Value1Min', sa.INTEGER(), nullable=False),
sa.Column('Value1Max', sa.INTEGER(), nullable=False),
sa.Column('Value2Min', sa.INTEGER(), nullable=False),
sa.Column('Value2Max', sa.INTEGER(), nullable=False),
sa.PrimaryKeyConstraint('PropertyId'),
mysql_charset='utf8mb4'
)
op.create_table('sao_static_reward',
sa.Column('RewardTableId', sa.BIGINT(), nullable=False),
sa.Column('RewardTableSubId', sa.INTEGER(), nullable=False),
sa.Column('UnanalyzedLogGradeId', sa.INTEGER(), nullable=False),
sa.Column('CommonRewardType', sa.INTEGER(), nullable=False),
sa.Column('CommonRewardId', sa.INTEGER(), nullable=False),
sa.Column('CommonRewardNum', sa.INTEGER(), nullable=False),
sa.Column('StrengthMin', sa.INTEGER(), nullable=False),
sa.Column('StrengthMax', sa.INTEGER(), nullable=False),
sa.Column('PropertyTableSubId', sa.INTEGER(), nullable=False),
sa.Column('QuestInfoDisplayFlag', sa.BOOLEAN(), nullable=False),
sa.Column('Rate', sa.INTEGER(), nullable=False),
sa.PrimaryKeyConstraint('RewardTableId'),
mysql_charset='utf8mb4'
)
op.create_table('sao_static_skill',
sa.Column('SkillId', sa.BIGINT(), nullable=False),
sa.Column('WeaponTypeId', sa.INTEGER(), nullable=False),
sa.Column('Name', sa.VARCHAR(length=255), nullable=False),
sa.Column('Name_en', sa.VARCHAR(length=255), nullable=True),
sa.Column('Attack', sa.BOOLEAN(), nullable=False),
sa.Column('Passive', sa.BOOLEAN(), nullable=False),
sa.Column('Pet', sa.BOOLEAN(), nullable=False),
sa.Column('Level', sa.INTEGER(), nullable=False),
sa.Column('SkillCondition', sa.INTEGER(), nullable=False),
sa.Column('CoolTime', sa.INTEGER(), nullable=False),
sa.Column('SkillIcon', sa.VARCHAR(length=255), nullable=False),
sa.Column('FriendSkillIcon', sa.VARCHAR(length=255), nullable=False),
sa.Column('InfoText', sa.VARCHAR(length=255), nullable=False),
sa.Column('InfoText_en', sa.VARCHAR(length=255), nullable=True),
sa.PrimaryKeyConstraint('SkillId'),
mysql_charset='utf8mb4'
)
op.create_table('sao_static_trace_table',
sa.Column('PlayerTraceTableId', sa.BIGINT(), nullable=False),
sa.Column('PlayerTraceTableSubId', sa.INTEGER(), nullable=False),
sa.Column('CommonRewardType', sa.INTEGER(), nullable=False),
sa.Column('CommonRewardId', sa.INTEGER(), nullable=False),
sa.Column('CommonRewardNum', sa.INTEGER(), nullable=False),
sa.Column('Rate', sa.INTEGER(), nullable=False),
sa.PrimaryKeyConstraint('PlayerTraceTableId'),
mysql_charset='utf8mb4'
)
op.create_table('sao_player_beginner_mission',
sa.Column('id', sa.BIGINT(), nullable=False),
sa.Column('user', sa.INTEGER(), nullable=False),
sa.Column('beginner_mission_id', sa.INTEGER(), nullable=False),
sa.Column('condition_id', sa.INTEGER(), nullable=False),
sa.Column('is_seat', sa.BOOLEAN(), server_default='0', nullable=False),
sa.Column('achievement_num', sa.INTEGER(), nullable=False),
sa.Column('complete_flag', sa.BOOLEAN(), server_default='0', nullable=False),
sa.Column('complete_date', sa.TIMESTAMP(), nullable=True),
sa.Column('reward_received_flag', sa.BOOLEAN(), server_default='0', nullable=False),
sa.Column('reward_received_date', sa.TIMESTAMP(), nullable=True),
sa.ForeignKeyConstraint(['user'], ['aime_user.id'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user'),
sa.UniqueConstraint('user', 'condition_id', name='sao_player_beginner_mission_uk'),
mysql_charset='utf8mb4'
)
op.create_table('sao_player_resource_card',
sa.Column('id', sa.BIGINT(), nullable=False),
sa.Column('user', sa.INTEGER(), nullable=False),
sa.Column('common_reward_type', sa.INTEGER(), nullable=False),
sa.Column('common_reward_id', sa.INTEGER(), nullable=False),
sa.Column('holographic_flag', sa.BOOLEAN(), server_default='0', nullable=False),
sa.Column('serial', sa.VARCHAR(length=20), nullable=True),
sa.ForeignKeyConstraint(['user'], ['aime_user.id'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('serial'),
mysql_charset='utf8mb4'
)
op.create_table('sao_player_tutorial',
sa.Column('id', sa.BIGINT(), nullable=False),
sa.Column('user', sa.INTEGER(), nullable=False),
sa.Column('tutorial_byte', sa.INTEGER(), nullable=False),
sa.ForeignKeyConstraint(['user'], ['aime_user.id'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user', 'tutorial_byte', name='sao_player_tutorial_uk'),
mysql_charset='utf8mb4'
)
op.create_table('sao_static_episode',
sa.Column('EpisodeId', sa.BIGINT(), nullable=False),
sa.Column('EpisodeChapterId', sa.INTEGER(), nullable=False),
sa.Column('ReleaseEpisodeId', sa.INTEGER(), nullable=False),
sa.Column('Title', sa.VARCHAR(length=255), nullable=False),
sa.Column('CommentSummary', sa.VARCHAR(length=255), nullable=False),
sa.Column('ExBonusTableSubId', sa.INTEGER(), nullable=False),
sa.Column('QuestSceneId', sa.BIGINT(), nullable=True),
sa.ForeignKeyConstraint(['QuestSceneId'], ['sao_static_quest.QuestSceneId'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('EpisodeId'),
mysql_charset='utf8mb4'
)
op.create_table('sao_static_ex_bonus',
sa.Column('ExBonusTableId', sa.BIGINT(), nullable=False),
sa.Column('ExBonusTableSubId', sa.INTEGER(), nullable=False),
sa.Column('ExBonusConditionId', sa.INTEGER(), nullable=False),
sa.Column('ConditionValue1', sa.INTEGER(), nullable=False),
sa.Column('ConditionValue2', sa.INTEGER(), nullable=False),
sa.Column('CommonRewardType', sa.INTEGER(), nullable=False),
sa.Column('CommonRewardId', sa.INTEGER(), nullable=False),
sa.Column('CommonRewardNum', sa.INTEGER(), nullable=False),
sa.Column('Strength', sa.INTEGER(), nullable=False),
sa.Column('Property1PropertyId', sa.BIGINT(), nullable=False),
sa.Column('Property1Value1', sa.INTEGER(), nullable=False),
sa.Column('Property1Value2', sa.INTEGER(), nullable=False),
sa.Column('Property2PropertyId', sa.BIGINT(), nullable=False),
sa.Column('Property2Value1', sa.INTEGER(), nullable=False),
sa.Column('Property2Value2', sa.INTEGER(), nullable=False),
sa.Column('Property3PropertyId', sa.BIGINT(), nullable=False),
sa.Column('Property3Value1', sa.INTEGER(), nullable=False),
sa.Column('Property3Value2', sa.INTEGER(), nullable=False),
sa.Column('Property4PropertyId', sa.BIGINT(), nullable=False),
sa.Column('Property4Value1', sa.INTEGER(), nullable=False),
sa.Column('Property4Value2', sa.INTEGER(), nullable=False),
sa.ForeignKeyConstraint(['Property1PropertyId'], ['sao_static_property.PropertyId'], onupdate='cascade', ondelete='cascade'),
sa.ForeignKeyConstraint(['Property2PropertyId'], ['sao_static_property.PropertyId'], onupdate='cascade', ondelete='cascade'),
sa.ForeignKeyConstraint(['Property3PropertyId'], ['sao_static_property.PropertyId'], onupdate='cascade', ondelete='cascade'),
sa.ForeignKeyConstraint(['Property4PropertyId'], ['sao_static_property.PropertyId'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('ExBonusTableId'),
mysql_charset='utf8mb4'
)
op.create_table('sao_static_ex_tower',
sa.Column('ExTowerQuestId', sa.BIGINT(), nullable=False),
sa.Column('ExTowerId', sa.INTEGER(), nullable=False),
sa.Column('ReleaseExTowerQuestId', sa.INTEGER(), nullable=False),
sa.Column('Title', sa.VARCHAR(length=255), nullable=False),
sa.Column('Title_en', sa.VARCHAR(length=255), nullable=True),
sa.Column('ExBonusTableSubId', sa.INTEGER(), nullable=False),
sa.Column('QuestSceneId', sa.BIGINT(), nullable=False),
sa.ForeignKeyConstraint(['QuestSceneId'], ['sao_static_quest.QuestSceneId'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('ExTowerQuestId'),
mysql_charset='utf8mb4'
)
op.create_table('sao_static_side_quest',
sa.Column('SideQuestId', sa.BIGINT(), nullable=False),
sa.Column('DisplayName', sa.VARCHAR(length=255), nullable=False),
sa.Column('DisplayName_en', sa.VARCHAR(length=255), nullable=True),
sa.Column('EpisodeNum', sa.INTEGER(), nullable=False),
sa.Column('ExBonusTableSubId', sa.INTEGER(), nullable=False),
sa.Column('QuestSceneId', sa.BIGINT(), nullable=False),
sa.ForeignKeyConstraint(['QuestSceneId'], ['sao_static_quest.QuestSceneId'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('SideQuestId'),
sa.UniqueConstraint('SideQuestId'),
mysql_charset='utf8mb4'
)
op.create_table('sao_static_skill_table',
sa.Column('SkillTableId', sa.BIGINT(), nullable=False),
sa.Column('SkillId', sa.BIGINT(), nullable=False),
sa.Column('SkillTableSubId', sa.INTEGER(), nullable=False),
sa.Column('LevelObtained', sa.INTEGER(), nullable=False),
sa.Column('AwakeningId', sa.INTEGER(), nullable=False),
sa.ForeignKeyConstraint(['SkillId'], ['sao_static_skill.SkillId'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('SkillTableId'),
mysql_charset='utf8mb4'
)
op.create_table('sao_static_tower',
sa.Column('TowerId', sa.BIGINT(), nullable=False),
sa.Column('ReleaseTowerId', sa.INTEGER(), nullable=False),
sa.Column('ExBonusTableSubId', sa.INTEGER(), nullable=False),
sa.Column('QuestSceneId', sa.BIGINT(), nullable=False),
sa.ForeignKeyConstraint(['QuestSceneId'], ['sao_static_quest.QuestSceneId'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('TowerId'),
mysql_charset='utf8mb4'
)
op.create_table('sao_player_ex_bonus',
sa.Column('id', sa.BIGINT(), nullable=False),
sa.Column('user', sa.INTEGER(), nullable=False),
sa.Column('quest_scene_id', sa.BIGINT(), nullable=False),
sa.Column('ex_bonus_table_id', sa.BIGINT(), nullable=False),
sa.Column('quest_clear_flag', sa.BOOLEAN(), server_default='0', nullable=False),
sa.ForeignKeyConstraint(['ex_bonus_table_id'], ['sao_static_ex_bonus.ExBonusTableId'], onupdate='cascade', ondelete='cascade'),
sa.ForeignKeyConstraint(['quest_scene_id'], ['sao_static_quest.QuestSceneId'], onupdate='cascade', ondelete='cascade'),
sa.ForeignKeyConstraint(['user'], ['aime_user.id'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user', 'quest_scene_id', 'ex_bonus_table_id', name='sao_player_ex_bonus_uk'),
mysql_charset='utf8mb4'
)
op.create_table('sao_player_hero_card',
sa.Column('id', sa.BIGINT(), nullable=False),
sa.Column('user', sa.INTEGER(), nullable=False),
sa.Column('user_hero_id', sa.INTEGER(), nullable=False),
sa.Column('holographic_flag', sa.BOOLEAN(), server_default='0', nullable=False),
sa.Column('serial', sa.VARCHAR(length=20), nullable=True),
sa.ForeignKeyConstraint(['user'], ['aime_user.id'], onupdate='cascade', ondelete='cascade'),
sa.ForeignKeyConstraint(['user_hero_id'], ['sao_hero_log_data.id'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('serial'),
mysql_charset='utf8mb4'
)
op.alter_column('sao_end_sessions', 'play_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('now()'),
existing_nullable=False)
op.drop_table('sao_static_equipment_list')
op.create_table("sao_static_equipment_list",
sa.Column("EquipmentId", sa.BIGINT, primary_key=True, nullable=False),
sa.Column("EquipmentType", sa.INTEGER, nullable=False),
sa.Column("WeaponTypeId", sa.INTEGER, nullable=False),
sa.Column("Name", sa.VARCHAR(255), nullable=False),
sa.Column("Name_en", sa.VARCHAR(255)),
sa.Column("Rarity", sa.INTEGER, nullable=False),
sa.Column("Power", sa.INTEGER, nullable=False),
sa.Column("StrengthIncrement", sa.INTEGER, nullable=False),
sa.Column("SkillCondition", sa.INTEGER, nullable=False),
sa.Column("Property1PropertyId", sa.BIGINT, sa.ForeignKey("sao_static_property.PropertyId", ondelete="cascade", onupdate="cascade"), nullable=False),
sa.Column("Property1Value1", sa.INTEGER, nullable=False),
sa.Column("Property1Value2", sa.INTEGER, nullable=False),
sa.Column("Property2PropertyId", sa.BIGINT, sa.ForeignKey("sao_static_property.PropertyId", ondelete="cascade", onupdate="cascade"), nullable=False),
sa.Column("Property2Value1", sa.INTEGER, nullable=False),
sa.Column("Property2Value2", sa.INTEGER, nullable=False),
sa.Column("Property3PropertyId", sa.BIGINT, sa.ForeignKey("sao_static_property.PropertyId", ondelete="cascade", onupdate="cascade"), nullable=False),
sa.Column("Property3Value1", sa.INTEGER, nullable=False),
sa.Column("Property3Value2", sa.INTEGER, nullable=False),
sa.Column("Property4PropertyId", sa.BIGINT, sa.ForeignKey("sao_static_property.PropertyId", ondelete="cascade", onupdate="cascade"), nullable=False),
sa.Column("Property4Value1", sa.INTEGER, nullable=False),
sa.Column("Property4Value2", sa.INTEGER, nullable=False),
sa.Column("SalePrice", sa.INTEGER, nullable=False),
sa.Column("CompositionExp", sa.INTEGER, nullable=False),
sa.Column("AwakeningExp", sa.INTEGER, nullable=False),
sa.Column("FlavorText", sa.VARCHAR(255), nullable=False),
sa.Column("FlavorText_en", sa.VARCHAR(255)),
mysql_charset="utf8mb4"
)
op.drop_table('sao_static_hero_list')
op.create_table("sao_static_hero_list",
sa.Column("HeroLogId", sa.BIGINT, primary_key=True, nullable=False),
sa.Column("CharaId", sa.INTEGER, nullable=False),
sa.Column("Name", sa.VARCHAR(255), nullable=False),
sa.Column("Nickname", sa.VARCHAR(255), nullable=False),
sa.Column("Name_en", sa.VARCHAR(255)),
sa.Column("Nickname_en", sa.VARCHAR(255)),
sa.Column("Rarity", sa.INTEGER, nullable=False),
sa.Column("WeaponTypeId", sa.INTEGER, nullable=False),
sa.Column("HeroLogRoleId", sa.INTEGER, nullable=False),
sa.Column("CostumeTypeId", sa.INTEGER, nullable=False),
sa.Column("UnitId", sa.INTEGER, nullable=False),
sa.Column("DefaultEquipmentId1", sa.BIGINT, sa.ForeignKey("sao_static_equipment_list.EquipmentId", ondelete="cascade", onupdate="cascade")),
sa.Column("DefaultEquipmentId2", sa.BIGINT, sa.ForeignKey("sao_static_equipment_list.EquipmentId", ondelete="cascade", onupdate="cascade")),
sa.Column("SkillTableSubId", sa.INTEGER, nullable=False),
sa.Column("HpMin", sa.INTEGER, nullable=False),
sa.Column("HpMax", sa.INTEGER, nullable=False),
sa.Column("StrMin", sa.INTEGER, nullable=False),
sa.Column("StrMax", sa.INTEGER, nullable=False),
sa.Column("VitMin", sa.INTEGER, nullable=False),
sa.Column("VitMax", sa.INTEGER, nullable=False),
sa.Column("IntMin", sa.INTEGER, nullable=False),
sa.Column("IntMax", sa.INTEGER, nullable=False),
sa.Column("Property1PropertyId", sa.BIGINT, sa.ForeignKey("sao_static_property.PropertyId", ondelete="cascade", onupdate="cascade"), nullable=False),
sa.Column("Property1Value1", sa.INTEGER, nullable=False),
sa.Column("Property1Value2", sa.INTEGER, nullable=False),
sa.Column("Property2PropertyId", sa.BIGINT, sa.ForeignKey("sao_static_property.PropertyId", ondelete="cascade", onupdate="cascade"), nullable=False),
sa.Column("Property2Value1", sa.INTEGER, nullable=False),
sa.Column("Property2Value2", sa.INTEGER, nullable=False),
sa.Column("Property3PropertyId", sa.BIGINT, sa.ForeignKey("sao_static_property.PropertyId", ondelete="cascade", onupdate="cascade"), nullable=False),
sa.Column("Property3Value1", sa.INTEGER, nullable=False),
sa.Column("Property3Value2", sa.INTEGER, nullable=False),
sa.Column("Property4PropertyId", sa.BIGINT, sa.ForeignKey("sao_static_property.PropertyId", ondelete="cascade", onupdate="cascade"), nullable=False),
sa.Column("Property4Value1", sa.INTEGER, nullable=False),
sa.Column("Property4Value2", sa.INTEGER, nullable=False),
sa.Column("FlavorText", sa.VARCHAR(255), nullable=False),
sa.Column("FlavorText_en", sa.VARCHAR(255)),
sa.Column("SalePrice", sa.INTEGER, nullable=False),
sa.Column("CompositionExp", sa.INTEGER, nullable=False),
sa.Column("AwakeningExp", sa.INTEGER, nullable=False),
sa.Column("Slot4UnlockLevel", sa.INTEGER, nullable=False),
sa.Column("Slot5UnlockLevel", sa.INTEGER, nullable=False),
sa.Column("CollectionEmptyFrameDisplayFlag", sa.BOOLEAN, nullable=False),
mysql_charset="utf8mb4"
)
op.drop_table('sao_static_item_list')
op.create_table("sao_static_item_list",
sa.Column("ItemId", sa.INTEGER, nullable=False, primary_key=True),
sa.Column("ItemTypeId", sa.INTEGER, nullable=False),
sa.Column("Name", sa.VARCHAR(255), nullable=False),
sa.Column("Name_en", sa.VARCHAR(255)),
sa.Column("Rarity", sa.INTEGER, nullable=False),
sa.Column("Value", sa.INTEGER, nullable=False),
sa.Column("PropertyId", sa.BIGINT, sa.ForeignKey("sao_static_property.PropertyId", ondelete="cascade", onupdate="cascade"), nullable=False),
sa.Column("PropertyValue1Min", sa.INTEGER, nullable=False),
sa.Column("PropertyValue1Max", sa.INTEGER, nullable=False),
sa.Column("PropertyValue2Min", sa.INTEGER, nullable=False),
sa.Column("PropertyValue2Max", sa.INTEGER, nullable=False),
sa.Column("FlavorText", sa.VARCHAR(255), nullable=False),
sa.Column("FlavorText_en", sa.VARCHAR(255)),
sa.Column("SalePrice", sa.INTEGER, nullable=False),
sa.Column("ItemIcon", sa.VARCHAR(255), nullable=False),
mysql_charset="utf8mb4"
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('sao_static_item_list')
op.create_table("sao_static_item_list",
sa.Column("id", sa.Integer, primary_key=True, nullable=False),
sa.Column("version", sa.Integer),
sa.Column("itemId", sa.Integer),
sa.Column("itemTypeId", sa.Integer),
sa.Column("name", sa.String(255)),
sa.Column("rarity", sa.Integer),
sa.Column("flavorText", sa.String(255)),
sa.Column("enabled", sa.Boolean),
sa.UniqueConstraint(
"version", "itemId", name="sao_static_item_list_uk"
),
mysql_charset="utf8mb4"
)
op.drop_table('sao_static_hero_list')
op.create_table("sao_static_hero_list",
sa.Column("id", sa.Integer, primary_key=True, nullable=False),
sa.Column("version", sa.Integer),
sa.Column("heroLogId", sa.Integer),
sa.Column("name", sa.String(255)),
sa.Column("nickname", sa.String(255)),
sa.Column("rarity", sa.Integer),
sa.Column("skillTableSubId", sa.Integer),
sa.Column("awakeningExp", sa.Integer),
sa.Column("flavorText", sa.String(255)),
sa.Column("enabled", sa.Boolean),
sa.UniqueConstraint(
"version", "heroLogId", name="sao_static_hero_list_uk"
),
mysql_charset="utf8mb4",
)
op.drop_table('sao_static_equipment_list')
op.create_table("sao_static_equipment_list",
sa.Column("id", sa.Integer, primary_key=True, nullable=False),
sa.Column("version", sa.Integer),
sa.Column("equipmentId", sa.Integer),
sa.Column("equipmentType", sa.Integer),
sa.Column("weaponTypeId", sa.Integer),
sa.Column("name", sa.String(255)),
sa.Column("rarity", sa.Integer),
sa.Column("flavorText", sa.String(255)),
sa.Column("enabled", sa.Boolean),
sa.UniqueConstraint(
"version", "equipmentId", name="sao_static_equipment_list_uk"
),
mysql_charset="utf8mb4"
)
op.alter_column('sao_end_sessions', 'play_date',
existing_type=mysql.TIMESTAMP(),
server_default=sa.text('CURRENT_TIMESTAMP'),
existing_nullable=False)
op.drop_table('sao_player_hero_card')
op.drop_table('sao_player_ex_bonus')
op.drop_table('sao_static_tower')
op.drop_table('sao_static_skill_table')
op.drop_table('sao_static_side_quest')
op.drop_table('sao_static_ex_tower')
op.drop_table('sao_static_ex_bonus')
op.drop_table('sao_static_episode')
op.drop_table('sao_player_tutorial')
op.drop_table('sao_player_resource_card')
op.drop_table('sao_player_beginner_mission')
op.drop_table('sao_static_trace_table')
op.drop_table('sao_static_skill')
op.drop_table('sao_static_reward')
op.drop_table('sao_static_property')
op.drop_table('sao_static_quest')
op.create_table('sao_static_quest',
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
sa.Column('enabled', mysql.TINYINT(display_width=1), autoincrement=False, nullable=True),
sa.Column('version', mysql.INTEGER(), autoincrement=False, nullable=True),
sa.Column('questSceneId', mysql.INTEGER(), autoincrement=False, nullable=True),
sa.Column('sortNo', mysql.INTEGER(), autoincrement=False, nullable=True),
sa.Column('name', mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_general_ci', length=255), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint("version", "questSceneId", name="sao_static_quest_uk"),
mysql_charset='utf8mb4'
)
# ### end Alembic commands ###

View File

@ -1,87 +0,0 @@
"""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,24 +0,0 @@
"""mai2_favorite_song_ordering
Revision ID: bc91c1206dca
Revises: 28443e2da5b8
Create Date: 2024-09-16 14:24:56.714066
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bc91c1206dca'
down_revision = '28443e2da5b8'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('mai2_item_favorite_music', sa.Column('orderId', sa.Integer(), nullable=True))
def downgrade():
op.drop_column('mai2_item_favorite_music', 'orderId')

View File

@ -1,38 +0,0 @@
"""mai2_add_photos
Revision ID: d8cd1fa04c2a
Revises: 54a84103b84e
Create Date: 2024-10-06 03:09:15.959817
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'd8cd1fa04c2a'
down_revision = '54a84103b84e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mai2_user_photo',
sa.Column('id', sa.VARCHAR(length=36), nullable=False),
sa.Column('user', sa.Integer(), nullable=False),
sa.Column('playlog_num', sa.INTEGER(), nullable=False),
sa.Column('track_num', sa.INTEGER(), nullable=False),
sa.Column('when_upload', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user'], ['aime_user.id'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user', 'playlog_num', 'track_num', name='mai2_user_photo_uk'),
mysql_charset='utf8mb4'
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('mai2_user_photo')
# ### end Alembic commands ###

View File

@ -1,70 +1,54 @@
import logging
import os
import secrets
import ssl
import string
import warnings
from hashlib import sha256
import logging, coloredlogs
from typing import Optional
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy import create_engine
from logging.handlers import TimedRotatingFileHandler
from typing import Any, ClassVar, Optional
import alembic.config
import os
import secrets, string
import bcrypt
import coloredlogs
import pymysql.err
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
create_async_engine,
)
from hashlib import sha256
import alembic.config
import glob
from core.config import CoreConfig
from core.data.schema import ArcadeData, BaseData, CardData, UserData, metadata
from core.utils import MISSING, Utils
from core.data.schema import *
from core.utils import Utils
class Data:
engine: ClassVar[AsyncEngine] = MISSING
session: ClassVar[AsyncSession] = MISSING
user: ClassVar[UserData] = MISSING
arcade: ClassVar[ArcadeData] = MISSING
card: ClassVar[CardData] = MISSING
base: ClassVar[BaseData] = MISSING
engine = None
session = None
user = None
arcade = None
card = None
base = None
def __init__(self, cfg: CoreConfig) -> None:
self.config = cfg
if self.config.database.sha2_password:
passwd = sha256(self.config.database.password.encode()).digest()
self.__url = f"{self.config.database.protocol}+aiomysql://{self.config.database.username}:{passwd.hex()}@{self.config.database.host}:{self.config.database.port}/{self.config.database.name}"
self.__url = f"{self.config.database.protocol}://{self.config.database.username}:{passwd.hex()}@{self.config.database.host}:{self.config.database.port}/{self.config.database.name}?charset=utf8mb4"
else:
self.__url = f"{self.config.database.protocol}+aiomysql://{self.config.database.username}:{self.config.database.password}@{self.config.database.host}:{self.config.database.port}/{self.config.database.name}"
self.__url = f"{self.config.database.protocol}://{self.config.database.username}:{self.config.database.password}@{self.config.database.host}:{self.config.database.port}/{self.config.database.name}?charset=utf8mb4"
if Data.engine is MISSING:
Data.engine = create_async_engine(
self.__url,
pool_recycle=3600,
isolation_level="AUTOCOMMIT",
connect_args={
"charset": "utf8mb4",
"ssl": self.config.database.create_ssl_context_if_enabled(),
},
)
if Data.engine is None:
Data.engine = create_engine(self.__url, pool_recycle=3600)
self.__engine = Data.engine
if Data.session is MISSING:
Data.session = AsyncSession(Data.engine, expire_on_commit=False)
if Data.session is None:
s = sessionmaker(bind=Data.engine, autoflush=True, autocommit=True)
Data.session = scoped_session(s)
if Data.user is MISSING:
if Data.user is None:
Data.user = UserData(self.config, self.session)
if Data.arcade is MISSING:
if Data.arcade is None:
Data.arcade = ArcadeData(self.config, self.session)
if Data.card is MISSING:
if Data.card is None:
Data.card = CardData(self.config, self.session)
if Data.base is MISSING:
if Data.base is None:
Data.base = BaseData(self.config, self.session)
self.logger = logging.getLogger("database")
@ -110,73 +94,40 @@ class Data:
alembic.config.main(argv=alembicArgs)
os.chdir(old_dir)
async def create_database(self):
def create_database(self):
self.logger.info("Creating databases...")
metadata.create_all(
self.engine,
checkfirst=True,
)
with warnings.catch_warnings():
# SQLAlchemy will generate a nice primary key constraint name, but in
# MySQL/MariaDB the constraint name is always PRIMARY. Every time a
# custom primary key name is generated, a warning is emitted from pymysql,
# which we don't care about. Other warnings may be helpful though, don't
# suppress everything.
warnings.filterwarnings(
action="ignore",
message=r"Name '(.+)' ignored for PRIMARY key\.",
category=pymysql.err.Warning,
)
for _, mod in Utils.get_all_titles().items():
if hasattr(mod, "database"):
mod.database(self.config)
metadata.create_all(
self.engine,
checkfirst=True,
)
async with self.engine.begin() as conn:
await conn.run_sync(metadata.create_all, checkfirst=True)
# Stamp the end revision as if alembic had created it, so it can take off after this.
self.__alembic_cmd(
"stamp",
"head",
)
for _, mod in Utils.get_all_titles().items():
if hasattr(mod, "database"):
mod.database(self.config)
await conn.run_sync(metadata.create_all, checkfirst=True)
# Stamp the end revision as if alembic had created it, so it can take off after this.
self.__alembic_cmd(
"stamp",
"head",
)
def schema_upgrade(self, ver: Optional[str] = None):
with warnings.catch_warnings():
# SQLAlchemy will generate a nice primary key constraint name, but in
# MySQL/MariaDB the constraint name is always PRIMARY. Every time a
# custom primary key name is generated, a warning is emitted from pymysql,
# which we don't care about. Other warnings may be helpful though, don't
# suppress everything.
warnings.filterwarnings(
action="ignore",
message=r"Name '(.+)' ignored for PRIMARY key\.",
category=pymysql.err.Warning,
)
self.__alembic_cmd(
"upgrade",
"head" if not ver else ver,
)
def schema_upgrade(self, ver: str = None):
self.__alembic_cmd(
"upgrade",
"head" if not ver else ver,
)
def schema_downgrade(self, ver: str):
with warnings.catch_warnings():
# SQLAlchemy will generate a nice primary key constraint name, but in
# MySQL/MariaDB the constraint name is always PRIMARY. Every time a
# custom primary key name is generated, a warning is emitted from pymysql,
# which we don't care about. Other warnings may be helpful though, don't
# suppress everything.
warnings.filterwarnings(
action="ignore",
message=r"Name '(.+)' ignored for PRIMARY key\.",
category=pymysql.err.Warning,
)
self.__alembic_cmd(
"downgrade",
ver,
)
self.__alembic_cmd(
"downgrade",
ver,
)
async def create_owner(self, email: Optional[str] = None, code: str = "00000000000000000000") -> None:
async def create_owner(self, email: Optional[str] = None, code: Optional[str] = "00000000000000000000") -> None:
pw = "".join(
secrets.choice(string.ascii_letters + string.digits) for i in range(20)
)
@ -199,12 +150,12 @@ class Data:
async def migrate(self) -> None:
exist = await self.base.execute("SELECT * FROM alembic_version")
if exist is not None:
self.logger.warning("No need to migrate as you have already migrated to alembic. If you are trying to upgrade the schema, use `upgrade` instead!")
self.logger.warn("No need to migrate as you have already migrated to alembic. If you are trying to upgrade the schema, use `upgrade` instead!")
return
self.logger.info("Upgrading to latest with legacy system")
if not await self.legacy_upgrade():
self.logger.warning("No need to migrate as you have already deleted the old schema_versions system. If you are trying to upgrade the schema, use `upgrade` instead!")
self.logger.warn("No need to migrate as you have already deleted the old schema_versions system. If you are trying to upgrade the schema, use `upgrade` instead!")
return
self.logger.info("Done")
@ -290,10 +241,6 @@ class Data:
self.logger.info("Message is required for create-revision")
return
for _, mod in Utils.get_all_titles().items():
if hasattr(mod, "database"):
mod.database(self.config)
self.__alembic_cmd(
"revision",
"--autogenerate",

View File

@ -1,16 +1,16 @@
import re
from typing import List, Optional
from sqlalchemy import Column, Table, and_, or_
from typing import Optional, Dict, List
from sqlalchemy import Table, Column, and_, or_
from sqlalchemy.sql.schema import ForeignKey, PrimaryKeyConstraint
from sqlalchemy.types import Integer, String, Boolean, JSON
from sqlalchemy.sql import func, select
from sqlalchemy.dialects.mysql import insert
from sqlalchemy.engine import Row
from sqlalchemy.sql import func, select
from sqlalchemy.sql.schema import ForeignKey, PrimaryKeyConstraint
from sqlalchemy.types import JSON, Boolean, Integer, String
import re
from core.data.schema.base import BaseData, metadata
from core.const import *
arcade: Table = Table(
arcade = Table(
"arcade",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
@ -26,7 +26,7 @@ arcade: Table = Table(
mysql_charset="utf8mb4",
)
machine: Table = Table(
machine = Table(
"machine",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
@ -47,7 +47,7 @@ machine: Table = Table(
mysql_charset="utf8mb4",
)
arcade_owner: Table = Table(
arcade_owner = Table(
"arcade_owner",
metadata,
Column(
@ -69,7 +69,7 @@ arcade_owner: Table = Table(
class ArcadeData(BaseData):
async def get_machine(self, serial: Optional[str] = None, id: Optional[int] = None) -> Optional[Row]:
async def get_machine(self, serial: str = None, id: int = None) -> Optional[Row]:
if serial is not None:
serial = serial.replace("-", "")
if len(serial) == 11:
@ -94,20 +94,20 @@ class ArcadeData(BaseData):
return None
return result.fetchone()
async def create_machine(
async def put_machine(
self,
arcade_id: int,
serial: str = "",
board: Optional[str] = None,
game: Optional[str] = None,
board: str = None,
game: str = None,
is_cab: bool = False,
) -> Optional[int]:
if not arcade_id:
if arcade_id:
self.logger.error(f"{__name__ }: Need arcade id!")
return None
sql = machine.insert().values(
arcade=arcade_id, serial=serial, board=board, game=game, is_cab=is_cab
arcade=arcade_id, keychip=serial, board=board, game=game, is_cab=is_cab
)
result = await self.execute(sql)
@ -148,15 +148,15 @@ class ArcadeData(BaseData):
return None
return result.fetchall()
async def create_arcade(
async def put_arcade(
self,
name: Optional[str] = None,
nickname: Optional[str] = None,
name: str,
nickname: str = None,
country: str = "JPN",
country_id: int = 1,
state: str = "",
city: str = "",
region_id: int = 1,
regional_id: int = 1,
) -> Optional[int]:
if nickname is None:
nickname = name
@ -168,7 +168,7 @@ class ArcadeData(BaseData):
country_id=country_id,
state=state,
city=city,
region_id=region_id,
regional_id=regional_id,
)
result = await self.execute(sql)
@ -206,6 +206,17 @@ class ArcadeData(BaseData):
return None
return result.lastrowid
async def format_serial(
self, platform_code: str, platform_rev: int, serial_num: int, append: int = 4152
) -> str:
return f"{platform_code}{platform_rev:02d}A{serial_num:04d}{append:04d}" # 0x41 = A, 0x52 = R
def validate_keychip_format(self, serial: str) -> bool:
if re.fullmatch(r"^A[0-9]{2}[E|X][-]?[0-9]{2}[A-HJ-NP-Z][0-9]{4}([0-9]{4})?$", serial) is None:
return False
return True
async def get_arcade_by_name(self, name: str) -> Optional[List[Row]]:
sql = arcade.select(or_(arcade.c.name.like(f"%{name}%"), arcade.c.nickname.like(f"%{name}%")))
result = await self.execute(sql)
@ -219,53 +230,3 @@ class ArcadeData(BaseData):
if result is None:
return None
return result.fetchall()
async def get_num_generated_keychips(self) -> Optional[int]:
result = await self.execute(select(func.count("serial LIKE 'A69A%'")).select_from(machine))
if result:
return result.fetchone()['count_1']
self.logger.error("Failed to count machine serials that start with A69A!")
def format_serial(
self, platform_code: str, platform_rev: int, serial_letter: str, serial_num: int, append: int, dash: bool = False
) -> str:
return f"{platform_code}{'-' if dash else ''}{platform_rev:02d}{serial_letter}{serial_num:04d}{append:04d}"
def validate_keychip_format(self, serial: str) -> bool:
# For the 2nd letter, E and X are the only "real" values that have been observed (A is used for generated keychips)
if re.fullmatch(r"^A[0-9]{2}[A-Z][-]?[0-9]{2}[A-HJ-NP-Z][0-9]{4}([0-9]{4})?$", serial) is None:
return False
return True
# Thanks bottersnike!
def get_keychip_suffix(self, year: int, month: int) -> str:
assert year > 1957
assert 1 <= month <= 12
year -= 1957
# Jan/Feb/Mar are from the previous tax year
if month < 4:
year -= 1
assert year >= 1 and year <= 99
month = ((month - 1) + 9) % 12 # Offset so April=0
return f"{year:02}{month // 6:01}{month % 6 + 1:01}"
def parse_keychip_suffix(self, suffix: str) -> tuple[int, int]:
year = int(suffix[0:2])
half = int(suffix[2])
assert half in (0, 1)
period = int(suffix[3])
assert period in (1, 2, 3, 4, 5, 6)
month = half * 6 + (period - 1)
month = ((month + 3) % 12) + 1 # Offset so Jan=1
# Jan/Feb/Mar are from the previous tax year
if month < 4:
year += 1
year += 1957
return (year, month)

View File

@ -1,35 +1,27 @@
import asyncio
import json
import logging
from random import randrange
from typing import Any, Dict, List, Optional
from sqlalchemy import Column, MetaData, Table
from typing import Any, Optional, Dict, List
from sqlalchemy.engine import Row
from sqlalchemy.engine.cursor import CursorResult
from sqlalchemy.engine.base import Connection
from sqlalchemy.sql import text, func, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.schema import ForeignKey
from sqlalchemy.sql import func, text
from sqlalchemy.types import INTEGER, JSON, TEXT, TIMESTAMP, Integer, String
from sqlalchemy import MetaData, Table, Column
from sqlalchemy.types import Integer, String, TIMESTAMP, JSON
from sqlalchemy.dialects.mysql import insert
from core.config import CoreConfig
metadata = MetaData()
event_log: Table = Table(
event_log = Table(
"event_log",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
Column("system", String(255), nullable=False),
Column("type", String(255), nullable=False),
Column("severity", Integer, nullable=False),
Column("user", INTEGER, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade")),
Column("arcade", INTEGER, ForeignKey("arcade.id", ondelete="cascade", onupdate="cascade")),
Column("machine", INTEGER, ForeignKey("machine.id", ondelete="cascade", onupdate="cascade")),
Column("ip", TEXT(39)),
Column("game", TEXT(4)),
Column("version", TEXT(24)),
Column("message", String(1000), nullable=False),
Column("details", JSON, nullable=False),
Column("when_logged", TIMESTAMP, nullable=False, server_default=func.now()),
@ -38,7 +30,7 @@ event_log: Table = Table(
class BaseData:
def __init__(self, cfg: CoreConfig, conn: AsyncSession) -> None:
def __init__(self, cfg: CoreConfig, conn: Connection) -> None:
self.config = cfg
self.conn = conn
self.logger = logging.getLogger("database")
@ -48,7 +40,7 @@ class BaseData:
try:
self.logger.debug(f"SQL Execute: {''.join(str(sql).splitlines())}")
res = await self.conn.execute(text(sql), opts)
res = self.conn.execute(text(sql), opts)
except SQLAlchemyError as e:
self.logger.error(f"SQLAlchemy error {e}")
@ -60,7 +52,7 @@ class BaseData:
except Exception:
try:
res = await self.conn.execute(sql, opts)
res = self.conn.execute(sql, opts)
except SQLAlchemyError as e:
self.logger.error(f"SQLAlchemy error {e}")
@ -83,19 +75,12 @@ class BaseData:
return randrange(10000, 9999999)
async def log_event(
self, system: str, type: str, severity: int, message: str, details: Dict = {}, user: int = None,
arcade: int = None, machine: int = None, ip: Optional[str] = None, game: Optional[str] = None, version: Optional[str] = None
self, system: str, type: str, severity: int, message: str, details: Dict = {}
) -> Optional[int]:
sql = event_log.insert().values(
system=system,
type=type,
severity=severity,
user=user,
arcade=arcade,
machine=machine,
ip=ip,
game=game,
version=version,
message=message,
details=json.dumps(details),
)
@ -109,8 +94,8 @@ class BaseData:
return result.lastrowid
async def get_event_log(self, entries: int = 100) -> Optional[List[Row]]:
sql = event_log.select().order_by(event_log.c.id.desc()).limit(entries)
async def get_event_log(self, entries: int = 100) -> Optional[List[Dict]]:
sql = event_log.select().limit(entries).all()
result = await self.execute(sql)
if result is None:

View File

@ -1,35 +1,32 @@
from typing import Dict, List, Optional
from sqlalchemy import Column, Table, UniqueConstraint
from sqlalchemy.engine import Row
from sqlalchemy.sql import func
from sqlalchemy import Table, Column, UniqueConstraint
from sqlalchemy.types import Integer, String, Boolean, TIMESTAMP
from sqlalchemy.sql.schema import ForeignKey
from sqlalchemy.types import BIGINT, TIMESTAMP, VARCHAR, Boolean, Integer, String
from sqlalchemy.sql import func
from sqlalchemy.engine import Row
from core.data.schema.base import BaseData, metadata
aime_card: Table = Table(
aime_card = Table(
"aime_card",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
Column("user", ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False),
Column("access_code", String(20), nullable=False, unique=True),
Column("idm", String(16), unique=True),
Column("chip_id", BIGINT, unique=True),
Column(
"user",
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
Column("access_code", String(20)),
Column("created_date", TIMESTAMP, server_default=func.now()),
Column("last_login_date", TIMESTAMP, onupdate=func.now()),
Column("is_locked", Boolean, server_default="0"),
Column("is_banned", Boolean, server_default="0"),
Column("memo", VARCHAR(16)),
UniqueConstraint("user", "access_code", name="aime_card_uk"),
mysql_charset="utf8mb4",
)
class CardData(BaseData):
moble_os_codes = set([0x06, 0x07, 0x10, 0x12, 0x13, 0x14, 0x15, 0x17, 0x18])
card_os_codes = set([0x20, 0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7])
async def get_card_by_access_code(self, access_code: str) -> Optional[Row]:
sql = aime_card.select(aime_card.c.access_code == access_code)
@ -124,37 +121,7 @@ class CardData(BaseData):
result = await self.execute(sql)
if result is None:
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}")
async def set_access_code_by_access_code(self, old_ac: str, new_ac: str) -> None:
result = await self.execute(aime_card.update(aime_card.c.access_code == old_ac).values(access_code=new_ac))
if not result:
self.logger.error(f"Failed to change card access code from {old_ac} to {new_ac}")
async def set_memo_by_access_code(self, access_code: str, memo: str) -> None:
result = await self.execute(aime_card.update(aime_card.c.access_code == access_code).values(memo=memo))
if not result:
self.logger.error(f"Failed to add memo to card {access_code}")
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
@ -165,4 +132,4 @@ class CardData(BaseData):
"""
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

@ -1,15 +1,15 @@
from typing import List, Optional
import bcrypt
from sqlalchemy import Column, Table
from typing import Optional, List
from sqlalchemy import Table, Column
from sqlalchemy.types import Integer, String, TIMESTAMP
from sqlalchemy.sql import func
from sqlalchemy.dialects.mysql import insert
from sqlalchemy.engine import Row
from sqlalchemy.sql import func, select
from sqlalchemy.types import TIMESTAMP, Integer, String
from sqlalchemy.engine import Row
import bcrypt
from core.data.schema.base import BaseData, metadata
aime_user: Table = Table(
aime_user = Table(
"aime_user",
metadata,
Column("id", Integer, nullable=False, primary_key=True, autoincrement=True),
@ -26,10 +26,10 @@ aime_user: Table = Table(
class UserData(BaseData):
async def create_user(
self,
id: Optional[int] = None,
username: Optional[str] = None,
email: Optional[str] = None,
password: Optional[str] = None,
id: int = None,
username: str = None,
email: str = None,
password: str = None,
permission: int = 1,
) -> Optional[int]:
if id is None:
@ -120,7 +120,3 @@ class UserData(BaseData):
result = await self.execute(sql)
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

@ -10,9 +10,6 @@ import bcrypt
import re
import jwt
import yaml
import secrets
import string
import random
from base64 import b64decode
from enum import Enum
from datetime import datetime, timezone
@ -44,13 +41,11 @@ class ShopOwner():
self.permissions = perms
class UserSession():
def __init__(self, usr_id: int = 0, ip: str = "", perms: int = 0, ongeki_ver: int = 7, chunithm_ver: int = -1, maimai_version: int = -1):
def __init__(self, usr_id: int = 0, ip: str = "", perms: int = 0, ongeki_ver: int = 7):
self.user_id = usr_id
self.current_ip = ip
self.permissions = perms
self.ongeki_version = ongeki_ver
self.chunithm_version = chunithm_ver
self.maimai_version = maimai_version
class FrontendServlet():
def __init__(self, cfg: CoreConfig, config_dir: str) -> None:
@ -134,13 +129,8 @@ class FrontendServlet():
]),
Mount("/sys", routes=[
Route("/", self.system.render_GET, methods=['GET']),
Route("/logs", self.system.render_logs, methods=['GET']),
Route("/lookup.user", self.system.lookup_user, methods=['GET']),
Route("/lookup.shop", self.system.lookup_shop, methods=['GET']),
Route("/add.user", self.system.add_user, methods=['POST']),
Route("/add.card", self.system.add_card, methods=['POST']),
Route("/add.shop", self.system.add_shop, methods=['POST']),
Route("/add.cab", self.system.add_cab, methods=['POST']),
]),
Mount("/shop", routes=[
Route("/", self.arcade.render_GET, methods=['GET']),
@ -193,7 +183,7 @@ class FE_Base():
), media_type="text/html; charset=utf-8")
if sesh is None:
resp.delete_cookie("ARTEMIS_SESH")
resp.delete_cookie("DIANA_SESH")
return resp
def get_routes(self) -> List[Route]:
@ -216,10 +206,7 @@ class FE_Base():
sesh.user_id = tk['user_id']
sesh.current_ip = tk['current_ip']
sesh.permissions = tk['permissions']
sesh.chunithm_version = tk['chunithm_version']
sesh.maimai_version = tk['maimai_version']
sesh.ongeki_version = tk['ongeki_version']
if sesh.user_id <= 0:
self.logger.error("User session failed to validate due to an invalid ID!")
return UserSession()
@ -244,7 +231,7 @@ class FE_Base():
return UserSession()
def validate_session(self, request: Request) -> Optional[UserSession]:
sesh = request.cookies.get('ARTEMIS_SESH', "")
sesh = request.cookies.get('DIANA_SESH', "")
if not sesh:
return None
@ -258,22 +245,12 @@ class FE_Base():
if usr_sesh.permissions <= 0 or usr_sesh.permissions > 255:
self.logger.error(f"User session failed to validate due to an invalid permission value! {usr_sesh.permissions}")
return None
return usr_sesh
def encode_session(self, sesh: UserSession, exp_seconds: int = 86400) -> str:
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,
"maimai_version": sesh.maimai_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, "exp": int(datetime.now(tz=timezone.utc).timestamp()) + exp_seconds }, b64decode(self.core_config.frontend.secret), algorithm="HS256")
except jwt.InvalidKeyError:
self.logger.error("Failed to encode User session because the secret is invalid!")
return ""
@ -305,7 +282,7 @@ class FE_Gate(FE_Base):
error=err,
sesh=vars(UserSession()),
), media_type="text/html; charset=utf-8")
resp.delete_cookie("ARTEMIS_SESH")
resp.delete_cookie("DIANA_SESH")
return resp
async def render_login(self, request: Request):
@ -321,12 +298,8 @@ class FE_Gate(FE_Base):
uid = await self.data.card.get_user_id_from_card(access_code)
if uid is None:
user = await self.data.user.get_user_by_username(access_code) # Lookup as username
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']
self.logger.debug(f"Failed to find user for card {access_code}")
return RedirectResponse("/gate/?e=1", 303)
user = await self.data.user.get_user(uid)
if user is None:
@ -355,7 +328,7 @@ class FE_Gate(FE_Base):
usr_sesh = self.encode_session(sesh)
self.logger.debug(f"Created session with JWT {usr_sesh}")
resp = RedirectResponse("/user/", 303)
resp.set_cookie("ARTEMIS_SESH", usr_sesh)
resp.set_cookie("DIANA_SESH", usr_sesh)
return resp
@ -394,7 +367,7 @@ class FE_Gate(FE_Base):
usr_sesh = self.encode_session(sesh)
self.logger.debug(f"Created session with JWT {usr_sesh}")
resp = RedirectResponse("/user/", 303)
resp.set_cookie("ARTEMIS_SESH", usr_sesh)
resp.set_cookie("DIANA_SESH", usr_sesh)
return resp
@ -476,11 +449,10 @@ class FE_User(FE_Base):
card_data.append({
'access_code': ac,
'status': status,
'chip_id': c['chip_id'],
'idm': c['idm'],
'chip_id': "", #None if c['chip_id'] is None else f"{c['chip_id']:X}",
'idm': "",
'type': c_type,
"memo": c['memo'],
"id": c['id'],
"memo": ""
})
if "e" in request.query_params:
@ -513,110 +485,14 @@ class FE_User(FE_Base):
async def render_logout(self, request: Request):
resp = RedirectResponse("/gate/", 303)
resp.delete_cookie("ARTEMIS_SESH")
resp.delete_cookie("DIANA_SESH")
return resp
async def edit_card(self, request: Request) -> RedirectResponse:
frm = await request.form()
usr_sesh = self.validate_session(request)
if not usr_sesh or not self.test_perm(usr_sesh.permissions, PermissionOffset.USERMOD):
return RedirectResponse("/gate/", 303)
frm = await request.form()
cid = frm.get("card_edit_frm_card_id", None)
if not cid:
return RedirectResponse("/user/?e=999", 303)
ac = frm.get("card_edit_frm_access_code", None)
if not ac:
return RedirectResponse("/user/?e=999", 303)
card = await self.data.card.get_card_by_id(cid)
if not card:
return RedirectResponse("/user/?e=2", 303)
if card['user'] != usr_sesh.user_id and not self.test_perm_minimum(usr_sesh.permissions, PermissionOffset.USERMOD):
return RedirectResponse("/user/?e=11", 303)
if frm.get("add_memo", None) or frm.get("add_memo", None) == "":
memo = frm.get("add_memo")
if len(memo) > 16:
return RedirectResponse("/user/?e=4", 303)
await self.data.card.set_memo_by_access_code(ac, memo)
if False: # Saving this in case I want to allow editing idm/chip ID down the line
if frm.get("add_felica_idm", None):
idm = frm.get('add_felica_idm')
if not all(c in string.hexdigits for c in idm):
return RedirectResponse("/user/?e=4", 303)
await self.data.card.set_idm_by_access_code(ac, idm)
if frm.get("add_mifare_chip_id", None):
chip_id: str = frm.get('add_mifare_chip_id')
if not all(c in string.hexdigits for c in idm):
return RedirectResponse("/user/?e=4", 303)
await self.data.card.set_chip_id_by_access_code(ac, int(chip_id, 16))
return RedirectResponse("/user/?s=4", 303)
return RedirectResponse("/user/", 303)
async def add_card(self, request: Request) -> RedirectResponse:
frm = await request.form()
card_type = frm.get("card_add_frm_type", None)
access_code = frm.get("add_access_code", None)
idm = frm.get("add_idm", None)
idm_caps = None
usr_sesh = self.validate_session(request)
if not usr_sesh or not self.test_perm(usr_sesh.permissions, PermissionOffset.USERMOD):
return RedirectResponse("/gate/", 303)
if not len(access_code) == 20 or (not access_code.startswith("5") and not access_code.startswith("3") \
and not access_code.startswith("010") and not access_code.startswith("0008")):
return RedirectResponse("/user/?e=4", 303)
if card_type == "0" and access_code.startswith("5") and len(idm) == 16:
idm_caps = idm.upper()
if not all([x in string.hexdigits for x in idm_caps]):
return RedirectResponse("/user/?e=4", 303)
if access_code.startswith("5") and not idm_caps:
return RedirectResponse("/user/?e=13", 303)
test = await self.data.card.get_card_by_access_code(access_code)
if test:
return RedirectResponse("/user/?e=12", 303)
if idm_caps:
test = await self.data.card.get_card_by_idm(idm_caps)
if test and test['user'] != usr_sesh.user_id:
return RedirectResponse("/user/?e=12", 303)
test = await self.data.card.get_card_by_access_code(self.data.card.to_access_code(idm_caps))
if test:
if test['user'] != usr_sesh.user_id:
return RedirectResponse("/user/?e=12", 303)
await self.data.card.set_access_code_by_access_code(test['access_code'], access_code)
self.logger.info(f"Update card {test['id']} from {test['access_code']} to {access_code} for user {usr_sesh.user_id}")
await self.data.card.set_idm_by_access_code(access_code, idm_caps)
self.logger.info(f"Set IDm for card {access_code} to {idm_caps}")
return RedirectResponse("/user/?s=1", 303)
if card_type == "0" and access_code.startswith("0008"):
test = await self.data.card.get_card_by_idm(self.data.card.to_idm(access_code))
if test:
return RedirectResponse("/user/?e=12", 303)
new_card = await self.data.card.create_card(usr_sesh.user_id, access_code)
self.logger.info(f"Created new card {new_card} with access code {access_code} for user {usr_sesh.user_id}")
if idm_caps:
await self.data.card.set_idm_by_access_code(access_code, idm_caps)
self.logger.info(f"Set IDm for card {access_code} to {idm_caps}")
return RedirectResponse("/user/?s=1", 303)
return RedirectResponse("/user/", 303)
async def render_POST(self, request: Request):
frm = await request.form()
@ -675,16 +551,10 @@ class FE_System(FE_Base):
if not usr_sesh or not self.test_perm_minimum(usr_sesh.permissions, PermissionOffset.USERMOD):
return RedirectResponse("/gate/", 303)
if request.query_params.get("e", None):
err = int(request.query_params.get("e"))
else:
err = 0
return Response(template.render(
title=f"{self.core_config.server.name} | System",
sesh=vars(usr_sesh),
usrlist=[],
error = err
), media_type="text/html; charset=utf-8")
async def lookup_user(self, request: Request):
@ -791,147 +661,6 @@ class FE_System(FE_Base):
shoplist=shoplist,
), media_type="text/html; charset=utf-8")
async def add_user(self, request: Request):
template = self.environment.get_template("core/templates/sys/index.jinja")
usr_sesh = self.validate_session(request)
if not usr_sesh or not self.test_perm(usr_sesh.permissions, PermissionOffset.ACMOD):
return RedirectResponse("/gate/", 303)
frm = await request.form()
username = frm.get("userName", None)
email = frm.get("userEmail", None)
perm = frm.get("usrPerm", "1")
passwd = "".join(
secrets.choice(string.ascii_letters + string.digits) for i in range(20)
)
hash = bcrypt.hashpw(passwd.encode(), bcrypt.gensalt())
if not email:
return RedirectResponse("/sys/?e=4", 303)
uid = await self.data.user.create_user(username=username if username else None, email=email, password=hash.decode(), permission=int(perm))
return Response(template.render(
title=f"{self.core_config.server.name} | System",
sesh=vars(usr_sesh),
usradd={"id": uid, "username": username, "password": passwd},
), media_type="text/html; charset=utf-8")
async def add_card(self, request: Request):
template = self.environment.get_template("core/templates/sys/index.jinja")
usr_sesh = self.validate_session(request)
if not usr_sesh or not self.test_perm(usr_sesh.permissions, PermissionOffset.ACMOD):
return RedirectResponse("/gate/", 303)
frm = await request.form()
userid = frm.get("cardUsr", None)
access_code = frm.get("cardAc", None)
idm = frm.get("cardIdm", None)
if userid is None or access_code is None or not userid.isdigit() or not len(access_code) == 20 or not access_code.isdigit:
return RedirectResponse("/sys/?e=4", 303)
cardid = await self.data.card.create_card(int(userid), access_code)
if not cardid:
return RedirectResponse("/sys/?e=99", 303)
if idm is not None:
# TODO: save IDM
pass
return Response(template.render(
title=f"{self.core_config.server.name} | System",
sesh=vars(usr_sesh),
cardadd={"id": cardid, "user": userid, "access_code": access_code},
), media_type="text/html; charset=utf-8")
async def add_shop(self, request: Request):
template = self.environment.get_template("core/templates/sys/index.jinja")
usr_sesh = self.validate_session(request)
if not usr_sesh or not self.test_perm(usr_sesh.permissions, PermissionOffset.ACMOD):
return RedirectResponse("/gate/", 303)
frm = await request.form()
name = frm.get("shopName", None)
country = frm.get("shopCountry", "JPN")
ip = frm.get("shopIp", None)
acid = await self.data.arcade.create_arcade(name if name else None, name if name else None, country)
if not acid:
return RedirectResponse("/sys/?e=99", 303)
if ip:
# TODO: set IP
pass
return Response(template.render(
title=f"{self.core_config.server.name} | System",
sesh=vars(usr_sesh),
shopadd={"id": acid},
), media_type="text/html; charset=utf-8")
async def add_cab(self, request: Request):
template = self.environment.get_template("core/templates/sys/index.jinja")
usr_sesh = self.validate_session(request)
if not usr_sesh or not self.test_perm(usr_sesh.permissions, PermissionOffset.ACMOD):
return RedirectResponse("/gate/", 303)
frm = await request.form()
shopid = frm.get("cabShop", None)
serial = frm.get("cabSerial", None)
game_code = frm.get("cabGame", None)
if not shopid or not shopid.isdigit():
return RedirectResponse("/sys/?e=4", 303)
if not serial:
append = self.data.arcade.get_keychip_suffix(datetime.now().year, datetime.now().month)
generated = await self.data.arcade.get_num_generated_keychips()
if not generated:
generated = 0
serial = self.data.arcade.format_serial("A69A", 1, "A", generated + 1, int(append))
serial_dash = self.data.arcade.format_serial("A69A", 1, "A", generated + 1, int(append), True)
cab_id = await self.data.arcade.create_machine(int(shopid), serial, None, game_code if game_code else None)
return Response(template.render(
title=f"{self.core_config.server.name} | System",
sesh=vars(usr_sesh),
cabadd={"id": cab_id, "serial": serial_dash},
), media_type="text/html; charset=utf-8")
async def render_logs(self, request: Request):
template = self.environment.get_template("core/templates/sys/logs.jinja")
events = []
usr_sesh = self.validate_session(request)
if not usr_sesh or not self.test_perm(usr_sesh.permissions, PermissionOffset.SYSADMIN):
return RedirectResponse("/sys/?e=11", 303)
logs = await self.data.base.get_event_log()
if not logs:
logs = []
for log in logs:
evt = log._asdict()
if not evt['user']: evt["user"] = "NONE"
if not evt['arcade']: evt["arcade"] = "NONE"
if not evt['machine']: evt["machine"] = "NONE"
if not evt['ip']: evt["ip"] = "NONE"
if not evt['game']: evt["game"] = "NONE"
if not evt['version']: evt["version"] = "NONE"
evt['when_logged'] = evt['when_logged'].strftime("%x %X")
events.append(evt)
return Response(template.render(
title=f"{self.core_config.server.name} | Event Logs",
sesh=vars(usr_sesh),
events=events
), media_type="text/html; charset=utf-8")
class FE_Arcade(FE_Base):
async def render_GET(self, request: Request):
template = self.environment.get_template("core/templates/arcade/index.jinja")
@ -998,7 +727,7 @@ class FE_Machine(FE_Base):
arcade={}
), media_type="text/html; charset=utf-8")
cfg_dir = environ.get("ARTEMIS_CFG_DIR", "config")
cfg_dir = environ.get("DIANA_CFG_DIR", "config")
cfg: CoreConfig = CoreConfig()
if path.exists(f"{cfg_dir}/core.yaml"):
cfg.update(yaml.safe_load(open(f"{cfg_dir}/core.yaml")))

View File

@ -10,7 +10,7 @@ Cab added successfully
{% endif %}
<ul style="font-size: 20px;">
{% for c in arcade.cabs %}
<li><a href="/cab/{{ c.id }}">{{ c.serial }}</a> ({{ c.game if c.game else "Any" }})&nbsp;<button class="btn btn-secondary" onclick="prep_edit_form()">Edit</button>&nbsp;<button class="btn-danger btn">Delete</button></li>
<li><a href="/cab/{{ c.id }}">{{ c.serial }} ({{ c.game }})</a>&nbsp;<button class="btn btn-secondary" onclick="prep_edit_form()">Edit</button>&nbsp;<button class="btn-danger btn">Delete</button></li>
{% endfor %}
</ul>
{% else %}

View File

@ -15,18 +15,18 @@
-moz-appearance: textfield;
}
</style>
<form id="login" style="max-width: 240px; min-width: 15%;" action="/gate/gate.login" method="post">
<form id="login" style="max-width: 240px; min-width: 10%;" action="/gate/gate.login" method="post">
<div class="form-group row">
<label for="access_code">Access Code or Username</label><br>
<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>
<label for="access_code">Card Access Code</label><br>
<input form="login" class="form-control" name="access_code" id="access_code" type="number" placeholder="00000000000000000000" maxlength="20" required>
</div>
<div class="form-group row">
<label for="passwd">Password</label><br>
<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>
<input id="passwd" class="form-control" name="passwd" type="password" placeholder="password">
</div>
<p></p>
<input id="submit" class="btn btn-primary" style="display: block; margin: 0 auto;" form="login" type="submit" value="Login">
</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 %}

View File

@ -4,7 +4,6 @@
{% if error is defined %}
{% include "core/templates/widgets/err_banner.jinja" %}
{% endif %}
<h2>Search</h2>
<div class="row" id="rowForm">
{% if "{:08b}".format(sesh.permissions)[6] == "1" %}
<div class="col-sm-6" style="max-width: 25%;">
@ -22,12 +21,7 @@
OR
<div class="form-group">
<label for="usrEmail">Email address</label>
<input type="email" class="form-control" id="usrEmail" name="usrEmail">
</div>
OR
<div class="form-group">
<label for="usrAc">Access Code</label>
<input type="text" class="form-control" id="usrAc" name="usrAc" maxlength="20" placeholder="00000000000000000000">
<input type="email" class="form-control" id="usrEmail" name="usrEmail" aria-describedby="emailHelp">
</div>
<br />
<button type="submit" class="btn btn-primary">Search</button>
@ -51,9 +45,6 @@
<button type="submit" class="btn btn-primary">Search</button>
</form>
</div>
<div class="col-sm-6" style="max-width: 25%;">
<a href="/sys/logs"><button class="btn btn-primary">Event Logs</button></a>
</div>
{% endif %}
</div>
<div class="row" id="rowResult" style="margin: 10px;">
@ -72,121 +63,7 @@
</div>
{% endif %}
</div>
<h2>Add</h2>
<div class="row" id="rowAdd">
{% if "{:08b}".format(sesh.permissions)[6] == "1" %}
<div class="col-sm-6" style="max-width: 25%;">
<form id="usrAdd" name="usrAdd" action="/sys/add.user" class="form-inline" method="POST">
<h3>Add User</h3>
<div class="form-group">
<label for="usrName">Username</label>
<input type="text" class="form-control" id="usrName" name="usrName">
</div>
<br>
<div class="form-group">
<label for="usrEmail">Email address</label>
<input type="email" class="form-control" id="usrEmail" name="usrEmail" required>
</div>
<br>
<div class="form-group">
<label for="usrPerm">Permission Level</label>
<input type="number" class="form-control" id="usrPerm" name="usrPerm" value="1">
</div>
<br />
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>
<div class="col-sm-6" style="max-width: 25%;">
<form id="cardAdd" name="cardAdd" action="/sys/add.card" class="form-inline" method="POST">
<h3>Add Card</h3>
<div class="form-group">
<label for="cardUsr">User ID</label>
<input type="number" class="form-control" id="cardUsr" name="cardUsr" required>
</div>
<br>
<div class="form-group">
<label for="cardAc">Access Code</label>
<input type="text" class="form-control" id="cardAc" name="cardAc" maxlength="20" placeholder="00000000000000000000" required>
</div>
<br>
<div class="form-group">
<label for="cardIdm">IDm/Chip ID</label>
<input type="text" class="form-control" id="cardIdm" name="cardIdm" disabled>
</div>
<br />
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>
{% endif %}
{% if "{:08b}".format(sesh.permissions)[5] == "1" %}
<div class="col-sm-6" style="max-width: 25%;">
<form id="shopAdd" name="shopAdd" action="/sys/add.shop" class="form-inline" method="POST">
<h3>Add Shop</h3>
<div class="form-group">
<label for="shopName">Name</label>
<input type="text" class="form-control" id="shopName" name="shopName">
</div>
<br>
<div class="form-group">
<label for="shopCountry">Country Code</label>
<input type="text" class="form-control" id="shopCountry" name="shopCountry" maxlength="3" placeholder="JPN">
</div>
<br />
<div class="form-group">
<label for="shopIp">VPN IP</label>
<input type="text" class="form-control" id="shopIp" name="shopIp">
</div>
<br />
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>
<div class="col-sm-6" style="max-width: 25%;">
<form id="cabAdd" name="cabAdd" action="/sys/add.cab" class="form-inline" method="POST">
<h3>Add Machine</h3>
<div class="form-group">
<label for="cabShop">Shop ID</label>
<input type="number" class="form-control" id="cabShop" name="cabShop" required>
</div>
<br>
<div class="form-group">
<label for="cabSerial">Serial</label>
<input type="text" class="form-control" id="cabSerial" name="cabSerial">
</div>
<br />
<div class="form-group">
<label for="cabGame">Game Code</label>
<input type="text" class="form-control" id="cabGame" name="cabGame" maxlength="4" placeholder="SXXX">
</div>
<br />
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>
{% endif %}
</div>
<div class="row" id="rowAddResult" style="margin: 10px;">
{% if "{:08b}".format(sesh.permissions)[6] == "1" %}
<div id="userAddResult" class="col-sm-6" style="max-width: 25%;">
{% if usradd is defined %}
<pre>Added user {{ usradd.username if usradd.username is not none else "with no name"}} with id {{usradd.id}} and password {{ usradd.password }}</pre>
{% endif %}
</div>
<div id="cardAddResult" class="col-sm-6" style="max-width: 25%;">
{% if cardadd is defined %}
<pre>Added {{ cardadd.access_code }} with id {{cardadd.id}} to user {{ cardadd.user }}</pre>
{% endif %}
</div>
{% endif %}
{% if "{:08b}".format(sesh.permissions)[5] == "1" %}
<div id="shopAddResult" class="col-sm-6" style="max-width: 25%;">
{% if shopadd is defined %}
<pre>Added Shop {{ shopadd.id }}</pre></a>
{% endif %}
</div>
<div id="cabAddResult" class="col-sm-6" style="max-width: 25%;">
{% if cabadd is defined %}
<pre>Added Machine {{ cabadd.id }} with serial {{ cabadd.serial }}</pre></a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock content %}

View File

@ -1,202 +0,0 @@
{% extends "core/templates/index.jinja" %}
{% block content %}
<h1>Event Logs</h1>
<table class="table table-dark table-striped-columns" id="tbl_events">
<caption>Viewing last 100 logs</caption>
<thead>
<tr>
<th>Severity</th>
<th>Timestamp</th>
<th>System</th>
<th>Name</th>
<th>User</th>
<th>Arcade</th>
<th>Machine</th>
<th>Game</th>
<th>Version</th>
<th>Message</th>
<th>Params</th>
</tr>
</thead>
{% if events is not defined or events|length == 0 %}
<tr>
<td colspan="11" style="text-align:center"><i>No Events</i></td>
</tr>
{% endif %}
</table>
<div id="div_tbl_ctrl">
<select id="sel_per_page" onchange="update_tbl()">
<option value="10" selected>10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
&nbsp;
<button class="btn btn-primary" id="btn_prev" disabled onclick="chg_page(-1)"><<</button>
<button class="btn btn-primary" id="btn_next" onclick="chg_page(1)">>></button>
</div>
<script type="text/javascript">
{% if events is defined %}
const TBL_DATA = {{events}};
{% else %}
const TBL_DATA = [];
{% endif %}
var per_page = 0;
var page = 0;
function update_tbl() {
if (TBL_DATA.length == 0) {
document.getElementById("btn_next").disabled = true;
document.getElementById("btn_prev").disabled = true;
return;
}
var tbl = document.getElementById("tbl_events");
for (var i = 0; i < per_page; i++) {
try{
tbl.deleteRow(1);
} catch {
break;
}
}
per_page = document.getElementById("sel_per_page").value;
if (per_page >= TBL_DATA.length) {
page = 0;
document.getElementById("btn_next").disabled = true;
document.getElementById("btn_prev").disabled = true;
}
for (var i = 0; i < per_page; i++) {
let off = (page * per_page) + i;
if (off >= TBL_DATA.length) {
if (page != 0) {
document.getElementById("btn_next").disabled = true;
document.getElementById("btn_prev").disabled = false;
}
break;
}
var data = TBL_DATA[off];
var row = tbl.insertRow(i + 1);
var cell_severity = row.insertCell(0);
switch (data.severity) {
case 10:
cell_severity.innerHTML = "DEBUG";
row.classList.add("table-success");
break;
case 20:
cell_severity.innerHTML = "INFO";
row.classList.add("table-info");
break;
case 30:
cell_severity.innerHTML = "WARN";
row.classList.add("table-warning");
break;
case 40:
cell_severity.innerHTML = "ERROR";
row.classList.add("table-danger");
break;
case 50:
cell_severity.innerHTML = "CRITICAL";
row.classList.add("table-danger");
break;
default:
cell_severity.innerHTML = "---";
row.classList.add("table-primary");
break;
}
var cell_ts = row.insertCell(1);
cell_ts.innerHTML = data.when_logged;
var cell_mod = row.insertCell(2);
cell_mod.innerHTML = data.system;
var cell_name = row.insertCell(3);
cell_name.innerHTML = data.type;
var cell_usr = row.insertCell(4);
if (data.user == 'NONE') {
cell_usr.innerHTML = "---";
} else {
cell_usr.innerHTML = "<a href=\"/user/" + data.user + "\">" + data.user + "</a>";
}
var cell_arcade = row.insertCell(5);
if (data.arcade == 'NONE') {
cell_arcade.innerHTML = "---";
} else {
cell_arcade.innerHTML = "<a href=\"/shop/" + data.arcade + "\">" + data.arcade + "</a>";
}
var cell_machine = row.insertCell(6);
if (data.arcade == 'NONE') {
cell_machine.innerHTML = "---";
} else {
cell_machine.innerHTML = "<a href=\"/cab/" + data.machine + "\">" + data.machine + "</a>";
}
var cell_game = row.insertCell(7);
if (data.game == 'NONE') {
cell_game.innerHTML = "---";
} else {
cell_game.innerHTML = data.game;
}
var cell_version = row.insertCell(8);
if (data.version == 'NONE') {
cell_version.innerHTML = "---";
} else {
cell_version.innerHTML = data.version;
}
var cell_msg = row.insertCell(9);
if (data.message == '') {
cell_msg.innerHTML = "---";
} else {
cell_msg.innerHTML = data.message;
}
var cell_deets = row.insertCell(10);
if (data.details == '{}') {
cell_deets.innerHTML = "---";
} else {
cell_deets.innerHTML = data.details;
}
}
}
function chg_page(num) {
var max_page = TBL_DATA.length / per_page;
console.log(max_page);
page = page + num;
if (page > max_page && max_page >= 1) {
page = max_page;
document.getElementById("btn_next").disabled = true;
document.getElementById("btn_prev").disabled = false;
return;
} else if (page < 0) {
page = 0;
document.getElementById("btn_next").disabled = false;
document.getElementById("btn_prev").disabled = true;
return;
} else if (page == 0) {
document.getElementById("btn_next").disabled = TBL_DATA.length == 0;
document.getElementById("btn_prev").disabled = true;
} else {
document.getElementById("btn_next").disabled = false;
document.getElementById("btn_prev").disabled = false;
}
update_tbl();
}
update_tbl();
</script>
{% endblock content %}

View File

@ -28,28 +28,12 @@ function toggle_add_card_form() {
}
}
function toggle_idm_disabled(is_disabled) {
document.getElementById("btn_add_card");
let dv = document.getElementById("add_card_container")
if (dv.style['display'] != "") {
btn.innerText = "Cancel";
dv.style['display'] = "";
} else {
btn.innerText = "Add";
dv.style['display'] = "none";
}
}
function prep_edit_form(access_code, chip_id, idm, card_type, u_memo, card_id) {
function prep_edit_form(access_code, chip_id, idm, card_type, u_memo) {
ac = document.getElementById("card_edit_frm_access_code");
cid = document.getElementById("card_edit_frm_chip_id");
fidm = document.getElementById("card_edit_frm_idm");
memo = document.getElementById("card_edit_frm_memo");
document.getElementById("card_edit_frm_card_id").value = card_id;
if (chip_id == "None" || chip_id == undefined) {
chip_id = ""
}
@ -65,14 +49,11 @@ function prep_edit_form(access_code, chip_id, idm, card_type, u_memo, card_id) {
fidm.value = idm;
memo.value = u_memo;
if (access_code.startsWith("3") || access_code.startsWith("010")) {
cid.disabled = false;
fidm.disabled = true;
} else if (access_code.startsWith("5") || access_code.startsWith("0008")) {
if (card_type == "AmusementIC") {
cid.disabled = true;
fidm.disabled = false;
} else {
cid.disabled = true;
cid.disabled = false;
fidm.disabled = true;
}
}
@ -103,35 +84,16 @@ Card added successfully
{% endif %}
<div id="add_card_container" style="display: none; max-width: 33%;">
<form action="/user/add.card" method="post", id="frm_add_card">
<div class="form-check">
<input type="radio" id="card_add_frm_type_aicc" value="0" name="card_add_frm_type" aria-describedby="aicc_help" onclick="document.getElementById('card_add_frm_idm').disabled = false;">
<label class="form-label" for="card_add_frm_type_aicc">AmusementIC</label>
<div id="aicc_help" class="form-text">Starts with 5. If you don't have the IDm, use the 0008 access code shown in-game</div>
<br>
<input type="radio" id="card_add_frm_type_old" value="1" name="card_add_frm_type" aria-describedby="old_help" onclick="document.getElementById('card_add_frm_idm').disabled = true;">
<label class="form-label" for="card_add_frm_type_old">Old Aime/Banapass</label>
<div id="old_help" class="form-text">Starts with 010 (aime) or 3 (banapass)</div>
</div>
<label class="form-label" for="card_add_frm_access_code">Access Code:</label>
<input class="form-control" name="add_access_code" id="card_add_frm_access_code" maxlength="20" type="text" required aria-describedby="ac_help">
<div id="ac_help" class="form-text">20 digit code on the back of the card.</div>
<label class="form-label" for="card_add_frm_access_code">IDm:</label>
<input class="form-control" name="add_idm" id="card_add_frm_idm" maxlength="16" type="text" aria-describedby="idm_help">
<div id="idm_help" class="form-text">AmusementIC cards only! 16 hexidecimal digits, sometimes called the serial number, gotten by scanning the card with a reader.</div>
<br>
<button type="submit" class="btn btn-primary">Add</button>
</form>
<br>
</div>
{% if success is defined and success == 4 %}
<div style="background-color: #00AA00; padding: 20px; margin-bottom: 10px; width: 15%;">
Update successful
</div>
{% endif %}
<ul style="font-size: 20px;">
{% for c in cards %}
<li>{{ c.access_code }} ({{ c.type if c.memo is none or not c.memo else c.memo }}): {{ c.status }}&nbsp;<button onclick="prep_edit_form('{{ c.access_code }}', '{{ c.chip_id}}', '{{ c.idm }}', '{{ c.type }}', '{{ c.memo }}', '{{ c.id }}')" data-bs-toggle="modal" data-bs-target="#card_edit" class="btn btn-secondary" id="btn_edit_card_{{ c.access_code }}">View</button>&nbsp;{% if c.status == 'Active'%}<button class="btn-warning btn">Lock</button>{% elif c.status == 'Locked' %}<button class="btn-warning btn">Unlock</button>{% endif %}&nbsp;<button class="btn-danger btn" {{ "disabled" if cards|length == 1 else ""}}>Delete</button></li>
<li>{{ c.access_code }} ({{ c.type}}): {{ c.status }}&nbsp;<button onclick="prep_edit_form('{{ c.access_code }}', '{{ c.chip_id}}', '{{ c.idm }}', '{{ c.type }}', '{{ c.memo }}')" data-bs-toggle="modal" data-bs-target="#card_edit" class="btn btn-secondary" id="btn_edit_card_{{ c.access_code }}">Edit</button>&nbsp;{% if c.status == 'Active'%}<button class="btn-warning btn">Lock</button>{% elif c.status == 'Locked' %}<button class="btn-warning btn">Unlock</button>{% endif %}&nbsp;<button class="btn-danger btn">Delete</button></li>
{% endfor %}
</ul>
@ -180,31 +142,31 @@ Update successful
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="card_edit_label">Card Information</h1>
<h1 class="modal-title fs-5" id="card_edit_label">Edit Card</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form action="/user/edit.card" method="post" id="frm_edit_card">
<input type="hidden" readonly name="card_edit_frm_card_id" id="card_edit_frm_card_id">
<label class="form-label" for="card_edit_frm_access_code">Access Code:</label>
<input class="form-control-plaintext" readonly name="card_edit_frm_access_code" id="card_edit_frm_access_code" maxlength="20" type="text" required aria-describedby="ac_help">
<input class="form-control" readonly name="add_access_code" id="card_edit_frm_access_code" maxlength="20" type="text" required aria-describedby="ac_help">
<div id="ac_help" class="form-text">20 digit code on the back of the card. If this is incorrect, contact a sysadmin.</div>
<label class="form-label" for="card_edit_frm_idm" id="card_edit_frm_idm_lbl">FeliCa IDm:</label>
<input class="form-control-plaintext" aria-describedby="idm_help" name="add_felica_idm" id="card_edit_frm_idm" maxlength="16" type="text" readonly>
<div id="idm_help" class="form-text">8 bytes that uniquly idenfites a FeliCa card. Obtained by reading the card with an NFC reader.</div>
<label class="form-label" for="card_edit_frm_chip_id" id="card_edit_frm_chip_id_lbl">Mifare UID:</label>
<input class="form-control-plaintext" aria-describedby="chip_id_help" name="add_mifare_chip_id" id="card_edit_frm_chip_id" maxlength="8" type="text" readonly>
<div id="chip_id_help" class="form-text">4 byte integer that uniquly identifies a Mifare card. Obtained by reading the card with an NFC reader.</div>
<label class="form-label" for="card_edit_frm_memo" id="card_edit_frm_memo_lbl">Memo:</label>
<input class="form-control" aria-describedby="memo_help" name="add_memo" id="card_edit_frm_memo" maxlength="16" type="text">
<div id="memo_help" class="form-text">Must be 16 characters or less.</div>
<label class="form-label" for="card_edit_frm_idm" id="card_edit_frm_idm_lbl">FeliCa IDm:</label>
<input class="form-control" aria-describedby="idm_help" name="add_felica_idm" id="card_edit_frm_idm" maxlength="16" type="text">
<div id="idm_help" class="form-text">8 bytes that uniquly idenfites a FeliCa card. Obtained by reading the card with an NFC reader.</div>
<label class="form-label" for="card_edit_frm_chip_id" id="card_edit_frm_chip_id_lbl">Mifare UID:</label>
<input class="form-control" aria-describedby="chip_id_help" name="add_mifare_chip_id" id="card_edit_frm_chip_id" maxlength="8" type="text">
<div id="chip_id_help" class="form-text">4 byte integer that uniquly identifies a Mifare card. Obtained by reading the card with an NFC reader.</div>
</form>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Update Memo</button>
</form>
<button type="submit" class="btn btn-primary" form="frm_edit_card">Edit</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>

View File

@ -21,12 +21,6 @@ New Nickname too long
You must be logged in to preform this action
{% elif error == 10 %}
Invalid serial number
{% elif error == 11 %}
Access Denied
{% elif error == 12 %}
Card already registered
{% elif error == 13 %}
AmusementIC Access Codes beginning with 5 must have IDm
{% else %}
An unknown error occoured
{% endif %}

View File

@ -80,8 +80,7 @@ class BaseServlet:
cfg_dir (str): Config directory
Returns:
Tuple[bool, List[str], List[str]]: Tuple where offset 0 is true if the game is enabled, false otherwise, and offset 1 is the game CDs handled
by this servlette, and offset 2 is mucha netID prefixes that should be used for each game CD.
Tuple[bool, str]: Tuple where offset 0 is true if the game is enabled, false otherwise, and offset 1 is the game CD
"""
return (False, [], [])

View File

@ -1,47 +1,18 @@
import importlib
from typing import Dict, Any, Optional
from types import ModuleType
from starlette.requests import Request
import logging
import importlib
from os import walk
import jwt
from base64 import b64decode
from datetime import datetime, timezone
from os import walk
from types import ModuleType
from typing import Any, Dict, Optional
import jwt
from starlette.requests import Request
from .config import CoreConfig
class _MissingSentinel:
__slots__: tuple[str, ...] = ()
def __eq__(self, other) -> bool:
return False
def __bool__(self) -> bool:
return False
def __hash__(self) -> int:
return 0
def __repr__(self):
return "..."
MISSING: Any = _MissingSentinel()
"""This is different from `None` in that its type is `Any`, and so it can be used
as a placeholder for values that are *definitely* going to be initialized,
so they don't have to be typed as `T | None`, which makes type checkers
angry when an attribute is accessed.
This can also be used for when `None` has actual meaning as a value, and so a
separate value is needed to mean "unset"."""
class Utils:
real_title_port = None
real_title_port_ssl = None
@classmethod
def get_all_titles(cls) -> Dict[str, ModuleType]:
ret: Dict[str, Any] = {}
@ -63,58 +34,28 @@ class Utils:
@classmethod
def get_ip_addr(cls, req: Request) -> str:
ip = req.headers.get("x-forwarded-for", req.client.host)
return ip.split(", ")[0]
return req.headers.get("x-forwarded-for", req.client.host)
@classmethod
def get_title_port(cls, cfg: CoreConfig):
if cls.real_title_port is not None:
return cls.real_title_port
cls.real_title_port = (
cfg.server.proxy_port
if cfg.server.is_using_proxy and cfg.server.proxy_port
else cfg.server.port
)
if cls.real_title_port is not None: return cls.real_title_port
cls.real_title_port = cfg.server.proxy_port if cfg.server.is_using_proxy and cfg.server.proxy_port else cfg.server.port
return cls.real_title_port
@classmethod
def get_title_port_ssl(cls, cfg: CoreConfig):
if cls.real_title_port_ssl is not None:
return cls.real_title_port_ssl
cls.real_title_port_ssl = (
cfg.server.proxy_port_ssl
if cfg.server.is_using_proxy and cfg.server.proxy_port_ssl
else 443
)
if cls.real_title_port_ssl is not None: return cls.real_title_port_ssl
cls.real_title_port_ssl = cfg.server.proxy_port_ssl if cfg.server.is_using_proxy and cfg.server.proxy_port_ssl else Utils.get_title_port(cfg)
return cls.real_title_port_ssl
def create_sega_auth_key(
aime_id: int,
game: str,
place_id: int,
keychip_id: str,
b64_secret: str,
exp_seconds: int = 86400,
err_logger: str = "aimedb",
) -> Optional[str]:
def create_sega_auth_key(aime_id: int, game: str, place_id: int, keychip_id: str, b64_secret: str, exp_seconds: int = 86400, err_logger: str = 'aimedb') -> Optional[str]:
logger = logging.getLogger(err_logger)
try:
return jwt.encode(
{
"aime_id": aime_id,
"game": game,
"place_id": place_id,
"keychip_id": keychip_id,
"exp": int(datetime.now(tz=timezone.utc).timestamp()) + exp_seconds,
},
b64decode(b64_secret),
algorithm="HS256",
)
return jwt.encode({ "aime_id": aime_id, "game": game, "place_id": place_id, "keychip_id": keychip_id, "exp": int(datetime.now(tz=timezone.utc).timestamp()) + exp_seconds }, b64decode(b64_secret), algorithm="HS256")
except jwt.InvalidKeyError:
logger.error("Failed to encode Sega Auth Key because the secret is invalid!")
return None
@ -122,19 +63,10 @@ def create_sega_auth_key(
logger.error(f"Unknown exception occoured when encoding Sega Auth Key! {e}")
return None
def decode_sega_auth_key(
token: str, b64_secret: str, err_logger: str = "aimedb"
) -> Optional[Dict]:
def decode_sega_auth_key(token: str, b64_secret: str, err_logger: str = 'aimedb') -> Optional[Dict]:
logger = logging.getLogger(err_logger)
try:
return jwt.decode(
token,
"secret",
b64decode(b64_secret),
algorithms=["HS256"],
options={"verify_signature": True},
)
return jwt.decode(token, "secret", b64decode(b64_secret), algorithms=["HS256"], options={"verify_signature": True})
except jwt.ExpiredSignatureError:
logger.error("Sega Auth Key failed to validate due to an expired signature!")
return None
@ -150,3 +82,4 @@ def decode_sega_auth_key(
except Exception as e:
logger.error(f"Unknown exception occoured when decoding Sega Auth Key! {e}")
return None

View File

@ -1,15 +1,14 @@
#!/usr/bin/env python3
import argparse
import asyncio
import logging
from os import W_OK, access, environ, mkdir, path
from os import mkdir, path, access, W_OK
import yaml
import asyncio
from core.config import CoreConfig
from core.data import Data
from core.config import CoreConfig
async def main():
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Database utilities")
parser.add_argument(
"--config", "-c", type=str, help="Config folder to use", default="config"
@ -26,11 +25,10 @@ async def main():
parser.add_argument("action", type=str, help="create, upgrade, downgrade, create-owner, migrate, create-revision, create-autorevision")
args = parser.parse_args()
environ["ARTEMIS_CFG_DIR"] = args.config
cfg = CoreConfig()
if path.exists(f"{args.config}/core.yaml"):
cfg_dict = yaml.safe_load(open(f"{args.config}/core.yaml"))
cfg_dict.get("database", {})["loglevel"] = "info"
cfg.update(cfg_dict)
if not path.exists(cfg.server.log_dir):
@ -45,7 +43,7 @@ async def main():
data = Data(cfg)
if args.action == "create":
await data.create_database()
data.create_database()
elif args.action == "upgrade":
data.schema_upgrade(args.version)
@ -57,20 +55,20 @@ async def main():
data.schema_downgrade(args.version)
elif args.action == "create-owner":
await data.create_owner(args.email, args.access_code)
loop = asyncio.get_event_loop()
loop.run_until_complete(data.create_owner(args.email, args.access_code))
elif args.action == "migrate":
await data.migrate()
loop = asyncio.get_event_loop()
loop.run_until_complete(data.migrate())
elif args.action == "create-revision":
await data.create_revision(args.message)
loop = asyncio.get_event_loop()
loop.run_until_complete(data.create_revision(args.message))
elif args.action == "create-autorevision":
await data.create_revision_auto(args.message)
loop = asyncio.get_event_loop()
loop.run_until_complete(data.create_revision_auto(args.message))
else:
logging.getLogger("database").info(f"Unknown action {args.action}")
if __name__ == "__main__":
asyncio.run(main())

129
docs/INSTALL_UBUNTU_old.md Normal file
View File

@ -0,0 +1,129 @@
# ARTEMiS - Ubuntu 20.04 LTS Guide
This step-by-step guide assumes that you are using a fresh install of Ubuntu 20.04 LTS, some of the steps can be skipped if you already have an installation with MySQL 5.7 or even some of the modules already present on your environment
# Setup
## Install memcached module
1. sudo apt-get install memcached
2. Under the file /etc/memcached.conf, please make sure the following parameters are set:
```
# Start with a cap of 64 megs of memory. It's reasonable, and the daemon default
# Note that the daemon will grow to this size, but does not start out holding this much
# memory
-I 128m
-m 1024
```
** This is mandatory to avoid memcached overload caused by Crossbeats or by massive profiles
3. Restart memcached using: sudo systemctl restart memcached
## Install MySQL 5.7
```
sudo apt update
sudo apt install wget -y
wget https://dev.mysql.com/get/mysql-apt-config_0.8.12-1_all.deb
sudo dpkg -i mysql-apt-config_0.8.12-1_all.deb
```
1. During the first prompt, select Ubuntu Bionic
2. Select the default option
3. Select MySQL 5.7
4. Select the last option
```
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 467B942D3A79BD29
sudo apt-get update
sudo apt-cache policy mysql-server
sudo apt install -f mysql-client=5.7* mysql-community-server=5.7* mysql-server=5.7*
```
## Default Configuration for MySQL Server
1. sudo mysql_secure_installation
> Make sure to follow the steps that will be prompted such as changing the mysql root password and such
2. Test your MySQL Server login by doing the following command :
> mysql -u root -p
## Create the default ARTEMiS database and user
1. mysql -u root -p
2. Please change the password indicated in the next line for a custom secure one and continue with the next commands
```
CREATE USER 'aime'@'localhost' IDENTIFIED BY 'MyStrongPass.';
CREATE DATABASE aime;
GRANT Alter,Create,Delete,Drop,Index,Insert,References,Select,Update ON aime.* TO 'aime'@'localhost';
FLUSH PRIVILEGES;
exit;
```
3. sudo systemctl restart mysql
## Install Python modules
```
sudo apt-get install python3-dev default-libmysqlclient-dev build-essential mysql-client libmysqlclient-dev libmemcached-dev
sudo apt install libpython3.8-dev
sudo apt-get install python3-software-properties
sudo apt install python3-pip
sudo pip3 install --upgrade pip testresources
sudo pip3 install --upgrade pip setuptools
sudo apt-get install python3-tk
```
7. Change your work path to the ARTEMiS root folder using 'cd' and install the requirements:
> sudo python3 -m pip install -r requirements.txt
## Copy/Rename the folder example_config to config
## Adjust /config/core.yaml
1. Make sure to change the server listen_address to be set to your local machine IP (ex.: 192.168.1.xxx)
2. Adjust the proper MySQL information you created earlier
3. Add the AimeDB key at the bottom of the file
## Create the database tables for ARTEMiS
1. sudo python3 dbutils.py create
2. If you get "No module named Crypto", run the following command:
```
sudo pip uninstall crypto
sudo pip uninstall pycrypto
sudo pip install pycrypto
```
## Firewall Adjustements
```
sudo ufw allow 80
sudo ufw allow 443
sudo ufw allow 8443
sudo ufw allow 22345
sudo ufw allow 8090
sudo ufw allow 8444
sudo ufw allow 8080
```
## Running the ARTEMiS instance
1. sudo python3 index.py
# Troubleshooting
## Game does not connect to ARTEMiS Allnet server
1. Double-check your core.yaml, the listen_address is most likely either not binded to the proper IP or the port is not opened
## Game does not connect to Title Server
1. Verify that your core.yaml is setup properly for both the server listen_address and title hostname
2. Boot your game and verify that an AllNet response does show and if it does, attempt to open the URI that is shown under a browser such as Edge, Chrome & Firefox.
3. If a page is shown, the server is working properly and if it doesn't, double check your port forwarding and also that you have entered the proper local IP under the Title hostname in core.yaml.
## Unhandled command under AimeDB
1. Double check your AimeDB key under core.yaml, it is incorrect.
## Memcache failed, error 3
1. Make sure memcached is properly installed and running. You can check the status of the service using the following command:
> sudo systemctl status memcached
2. If it is failing, double check the /etc/memcached.conf file, it may have duplicated arguments like the -I and -m
3. If it is still not working afterward, you can proceed with a workaround by manually editing the /core/data/cache.py file.
```
# Make memcache optional
try:
has_mc = False
except ModuleNotFoundError:
has_mc = False
```

View File

@ -59,7 +59,7 @@ GRANT Alter,Create,Delete,Drop,Index,Insert,References,Select,Update ON aime.* T
- Put the password you created for the aime user into the `database` section.
- Put in the aimedb key (YOU DO NOT GENERATE THIS KEY, FIND IT SOMEWHERE).
- Set your hostname to be whatever hostname or IP address games can reach your server at (many games reject localhost and 127.0.0.1).
- Optional: generate base64-encoded secrets for aimedb and frontend using something like `openssl rand -base64 64`. It is advised to make all secrets different.
- Optional: generate base64-encoded secrets for aimedb and frontend.
- See [config.md](docs/config.md) for a full list of options.
- edit `idz.yaml`
- If you don't plan on anyone using your server to play Initial D Zero, it is best to disable it to cut down on console spam on boot.

102
docs/INSTALL_WINDOWS_old.md Normal file
View File

@ -0,0 +1,102 @@
# ARTEMiS - Windows 10/11 Guide
This step-by-step guide assumes that you are using a fresh install of Windows 10/11 without MySQL installed, some of the steps can be skipped if you already have an installation with MySQL 8.0 or even some of the modules already present on your environment
# Setup
## Install Python Python 3.9 (recommended) or 3.10
1. Download Python 3.9 : [Link](https://www.python.org/ftp/python/3.9.13/python-3.9.13-amd64.exe)
2. Install python-3.9.13-amd64.exe
1. Select Customize installation
2. Make sure that pip, tcl/tk, and the for all users are checked and hit Next
3. Make sure that you enable "Create shortcuts for installed applications" and "Add Python to environment variables" and hit Install
## Install MySQL 8.0
1. Download MySQL 8.0 Server : [Link](https://dev.mysql.com/get/Downloads/MySQLInstaller/mysql-installer-community-8.0.34.0.msi)
2. Install mysql-installer-web-community-8.0.34.0.msi
1. Click on "Add ..." on the side
2. Click on the "+" next to MySQL Servers
3. Make sure MySQL Server 8.0.34 - X64 is under the products to be installed.
4. Hit Next and Next once installed
5. Select the configuration type "Development Computer"
6. Hit Next
7. Select "Use Legacy Authentication Method (Retain MySQL 5.x compatibility)" and hit Next
8. Enter a root password and then hit Next >
9. Leave everything under Windows Service as default and hit Next >
10. Click on Execute and for it to finish and hit Next> and then Finish
3. Open MySQL 8.0 Command Line Client and login as your root user
4. Change `<Enter Password Here>` to a new password for the user aime, type those commands to create your user and the database
```sql
CREATE USER 'aime'@'localhost' IDENTIFIED BY '<Enter Password Here>';
CREATE DATABASE aime;
GRANT Alter,Create,Delete,Drop,Index,Insert,References,Select,Update ON aime.* TO 'aime'@'localhost';
FLUSH PRIVILEGES;
exit;
```
## Install Python modules
1. Change your work path to the artemis-master folder using 'cd' and install the requirements:
```shell
pip install -r requirements.txt
```
## Copy/Rename the folder `example_config` to `config`
## Adjust `config/core.yaml`
1. Make sure to change the server `hostname` to be set to your local machine IP (ex.: 192.168.xxx.xxx)
- In case you want to run this only locally, set the following values:
```yaml
server:
listen_address: 0.0.0.0
title:
hostname: 192.168.xxx.xxx
```
1. Adjust the proper MySQL information you created earlier
```yaml
database:
host: "localhost"
username: "aime"
password: "<Enter Password Here>"
name: "aime"
```
3. Add the AimeDB key at the bottom of the file
4. If the webui is needed, change the flag from False to True
## Create the database tables for ARTEMiS
```shell
python dbutils.py create
```
## Firewall Adjustements
Make sure the following ports are open both on your router and local Windows firewall in case you want to use this for public use (NOT recommended):
> Port 80 (TCP), 443 (TCP), 8443 (TCP), 22345 (TCP), 8080 (TCP), 8090 (TCP) **webui, 8444 (TCP) **mucha
## Running the ARTEMiS instance
```shell
python index.py
```
# Troubleshooting
## Game does not connect to ARTEMiS Allnet server
1. Double-check your core.yaml, the listen_address is most likely either not binded to the proper IP or the port is not opened
## Game does not connect to Title Server
1. Verify that your core.yaml is setup properly for both the server listen_address and title hostname
2. Boot your game and verify that an AllNet response does show and if it does, attempt to open the URI that is shown under a browser such as Edge, Chrome & Firefox.
3. If a page is shown, the server is working properly and if it doesn't, double check your port forwarding and also that you have entered the proper local IP under the Title hostname in core.yaml.
## Unhandled command under AimeDB
1. Double check your AimeDB key under core.yaml, it is incorrect.
## AttributeError: module 'collections' has no attribute 'Hashable'
1. This means the pyYAML module is obsolete, simply rerun pip with the -U (force update) flag, as shown below.
- Change your work path to the artemis-master (or artemis-develop) folder using 'cd' and run the following commands:
```shell
pip install -r requirements.txt -U
```

View File

@ -17,8 +17,8 @@
- `strict_ip_checking`: Rejects clients if there is no IP in the `arcade` table for the respective arcade. Default `False`
## Title
- `loglevel`: Logging level for the title server. Default `info`
- `reboot_start_time`: 24 hour JST time that clients will see as the start of maintenance period, ex `04:00`. A few games or early version will report errors if it is empty, ex maimai DX 1.00
- `reboot_end_time`: 24 hour JST time that clients will see as the end of maintenance period, ex `07:00`. this must be set to 7:00 am for some game, please do not change it.
- `reboot_start_time`: 24 hour JST time that clients will see as the start of maintenance period, ex `04:00`. Leave blank for no maintenance time. Default: `""`
- `reboot_end_time`: 24 hour JST time that clients will see as the end of maintenance period, ex `05:00`. Leave blank for no maintenance time. Default: `""`
## Database
- `host`: Host of the database. Default `localhost`
- `username`: Username of the account the server should connect to the database with. Default `aime`
@ -26,7 +26,6 @@
- `name`: Name of the database the server should expect. Default `aime`
- `port`: Port the database server is listening on. Default `3306`
- `protocol`: Protocol used in the connection string, e.i `mysql` would result in `mysql://...`. Default `mysql`
- `ssl_enabled`: Enforce SSL to be used in the connection string. Default `False`
- `sha2_password`: Whether or not the password in the connection string should be hashed via SHA2. Default `False`
- `loglevel`: Logging level for the database. Default `info`
- `memcached_host`: Host of the memcached server. Default `localhost`

View File

@ -9,15 +9,7 @@ using the megaime database. Clean installations always create the latest databas
To upgrade the core database and the database for every game, execute:
```shell
python dbutils.py upgrade
```
If you are using the old master branch that was not setup with alembic, make sure to do the following steps in order:
- Pull down latest master/develop
- Update core.yaml
- Back up your existing database
```shell
python dbutils.py migrate
python dbutils.py autoupgrade
```
# Table of content
@ -26,14 +18,11 @@ python dbutils.py migrate
- [CHUNITHM](#chunithm)
- [crossbeats REV.](#crossbeats-rev)
- [maimai DX](#maimai-dx)
- [Project Diva](#hatsune-miku-project-diva)
- [O.N.G.E.K.I.](#o-n-g-e-k-i)
- [Card Maker](#card-maker)
- [WACCA](#wacca)
- [Sword Art Online Arcade](#sao)
- [Initial D Zero](#initial-d-zero)
- [Initial D THE ARCADE](#initial-d-the-arcade)
- [Pokken Tournament](#pokken)
- [Initial D THE ARCADE](#initial-d-the-arcade)
# Supported Games
@ -66,7 +55,6 @@ Games listed below have been tested and confirmed working.
| 12 | CHUNITHM NEW PLUS!! |
| 13 | CHUNITHM SUN |
| 14 | CHUNITHM SUN PLUS |
| 15 | CHUNITHM LUMINOUS |
### Importer
@ -77,36 +65,27 @@ In order to use the importer locate your game installation folder and execute:
python read.py --game SDBT --version <version ID> --binfolder /path/to/game/folder --optfolder /path/to/game/option/folder
```
The importer for Chunithm will import: Events, Music, Charge Items, Avatar Accesories, Nameplates, Characters, Trophies, Map Icons, and System Voices.
The importer for Chunithm will import: Events, Music, Charge Items and Avatar Accesories.
### Config
Config file is located in `config/chuni.yaml`.
| Option | Info |
|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------|
| `news_msg` | If this is set, the news at the top of the main screen will be displayed (up to Chunithm Paradise Lost) |
| `name` | If this is set, all players that are not on a team will use this one by default. |
| `use_login_bonus` | This is used to enable the login bonuses |
| `stock_tickets` | If this is set, specifies tickets to auto-stock at login. Format is a comma-delimited list of IDs. Defaults to None |
| `stock_count` | Ignored if stock_tickets is not specified. Number to stock of each ticket. Defaults to 99 |
| `forced_item_unlocks` | Frontend UI customization overrides that allow all items of given types to be used (instead of just those unlocked/purchased by the user) |
| `crypto` | This option is used to enable the TLS Encryption |
| Option | Info |
|------------------|----------------------------------------------------------------------------------------------------------------|
| `news_msg` | If this is set, the news at the top of the main screen will be displayed (up to Chunithm Paradise Lost) |
| `name` | If this is set, all players that are not on a team will use this one by default. |
| `use_login_bonus`| This is used to enable the login bonuses |
| `crypto` | This option is used to enable the TLS Encryption |
If you would like to use network encryption, add the keys to the `keys` section under `crypto`, where the key
is the version ID for Japanese (SDHD) versions and `"{versionID}_int"` for Export (SDGS) versions, and the value
is an array containing `[key, iv, salt, iter_count]` in order.
`iter_count` is optional for all Japanese (SDHD) versions but may be required for some Export (SDGS) versions.
You will receive an error in the logs if it needs to be specified.
**If you would like to use network encryption, the following will be required underneath but key, iv and hash are required:**
```yaml
crypto:
encrypted_only: False
keys:
13: ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000"]
"13_int": ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000", 42]
```
### Database upgrade
@ -114,7 +93,7 @@ crypto:
Always make sure your database (tables) are up-to-date:
```shell
python dbutils.py upgrade
python dbutils.py --game SDBT upgrade
```
### Online Battle
@ -154,15 +133,12 @@ INSERT INTO aime.chuni_profile_team (teamName) VALUES (<teamName>);
Team names can be regular ASCII, and they will be displayed ingame.
### Favorite songs
Favorites can be set through the Frontend Web UI for songs previously played. Alternatively, you can set the songs that will be in a user's Favorite Songs category using the following SQL entries:
You can set the songs that will be in a user's Favorite Songs category using the following SQL entries:
```sql
INSERT INTO aime.chuni_item_favorite (user, version, favId, favKind) VALUES (<user>, <version>, <songId>, 1);
```
The songId is based on the actual ID within your version of Chunithm.
### Profile Customization
The Frontend Web UI supports configuration of the userbox, avatar (NEW!! and newer), map icon (AMAZON and newer), and system voice (AMAZON and newer).
## crossbeats REV.
@ -191,14 +167,6 @@ Config file is located in `config/cxb.yaml`.
## maimai DX
### Presents
Presents are items given to the user when they login, with a little animation (for example, the KOP song was given to the finalists as a present). To add a present, you must insert it into the `mai2_item_present` table. In that table, a NULL version means any version, a NULL user means any user, a NULL start date means always open, and a NULL end date means it never expires. Below is a list of presents one might wish to add:
| Game Version | Item ID | Item Kind | Item Description | Present Description |
|--------------|---------|-----------|-------------------------------------------------|------------------------------------------------|
| BUDDiES (21) | 409505 | Icon (3) | 旅行スタンプ(月面基地) (Travel Stamp - Moon Base) | Officially obtained on the webui with a serial |
| | | | | number, for project raputa |
### Versions
| Game Code | Version ID | Version Name |
@ -224,8 +192,6 @@ Presents are items given to the user when they login, with a little animation (f
| SDEZ | 18 | maimai DX UNiVERSE PLUS |
| SDEZ | 19 | maimai DX FESTiVAL |
| SDEZ | 20 | maimai DX FESTiVAL PLUS |
| SDEZ | 21 | maimai DX BUDDiES |
| SDEZ | 22 | maimai DX BUDDiES PLUS |
### Importer
@ -249,7 +215,7 @@ The importer for maimai Pre-DX will import Events and Music. Not all games will
Always make sure your database (tables) are up-to-date:
```shell
python dbutils.py upgrade
python dbutils.py --game SDEZ upgrade
```
Pre-Dx uses the same database as DX, so only upgrade using the SDEZ game code!
@ -279,14 +245,10 @@ the Shop, Modules and Customizations.
Config file is located in `config/diva.yaml`.
| Option | Info |
| -------------------- | ------------------------------------------------------------------------------------------------ |
| `festa_enable` | Enable or disable the ingame festa |
| `festa_add_VP` | Set the extra VP you get when clearing a song, if festa is not enabled no extra VP will be given |
| `festa_multiply_VP` | Multiplier for festa add VP |
| `festa_end_time` | Set the date time for when festa will end and not show up in game anymore |
| `unlock_all_modules` | Unlocks all modules (costumes) by default, if set to `False` all modules need to be purchased |
| `unlock_all_items` | Unlocks all items (customizations) by default, if set to `False` all items need to be purchased |
| Option | Info |
| -------------------- | ----------------------------------------------------------------------------------------------- |
| `unlock_all_modules` | Unlocks all modules (costumes) by default, if set to `False` all modules need to be purchased |
| `unlock_all_items` | Unlocks all items (customizations) by default, if set to `False` all items need to be purchased |
### Custom PV Lists (databanks)
@ -297,24 +259,7 @@ In order to use custom PV Lists, simply drop in your .dat files inside of /title
Always make sure your database (tables) are up-to-date:
```shell
python dbutils.py upgrade
```
### Using NGINX
Diva's netcode does not send a `Host` header with it's network requests. This renders it incompatable with NGINX as configured in the example config, because nginx relies on the header to determine how to proxy the request. If you'd still like to use NGINX with diva, please see the sample config below.
```conf
server {
listen 80 default_server;
server_name _;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_request_headers on;
proxy_pass http://127.0.0.1:8080/;
}
}
python dbutils.py --game SBZV upgrade
```
## O.N.G.E.K.I.
@ -370,7 +315,7 @@ crypto:
Always make sure your database (tables) are up-to-date:
```shell
python dbutils.py upgrade
python dbutils.py --game SDDT upgrade
```
### Controlling Events (Ranking Event, Technical Challenge Event, Mission Event)
@ -461,7 +406,6 @@ After that, on next login the present should be received (or whenever it suppose
* UNiVERSE PLUS: Yes
* FESTiVAL: Yes (added in A031)
* FESTiVAL PLUS: Yes (added in A035)
* BUDDiES: Yes (added in A039)
* O.N.G.E.K.I. bright MEMORY: Yes
@ -598,7 +542,7 @@ Config file is located in `config/wacca.yaml`.
Always make sure your database (tables) are up-to-date:
```shell
python dbutils.py upgrade
python dbutils.py --game SDFE upgrade
```
### VIP Rewards
@ -671,36 +615,25 @@ Config file is located in `config/sao.yaml`.
Always make sure your database (tables) are up-to-date:
```shell
python dbutils.py upgrade
python dbutils.py --game SDEW upgrade
```
### Notes
- Defrag Match and online coop requires a cloud instance of Photon and a working application ID
- Defrag Match will crash at loading
- Co-Op Online is not supported
- Shop is displayed but cannot purchase heroes or items
- Player title is currently static and cannot be changed in-game
- QR Card Scanning of existing cards requires them to be registered on the webui
- QR Card Scanning currently only load a static hero
- Ex-quests progression not supported yet
- Daily Missions not implemented
- Terminal functionality is almost entirely untested
- EX TOWER 1,2 & 3 are not yet supported
- Daily Yui coin not yet fixed
### Credits for SAO support:
- Midorica - Network Support
- Dniel97 - Helping with network base
- tungnotpunk - Source
- Hay1tsme - fixing many issues with the original implemetation
## Initial D Zero
### SDDF
| Version ID | Version Name |
| ---------- | -------------------- |
| 0 | Initial D Zero v1.10 |
| 1 | Initial D Zero v1.30 |
| 2 | Initial D Zero v2.10 |
| 3 | Initial D Zero v2.30 |
### Info
TODO, probably just leave disabled unless you're doing development things for it.
## Initial D THE ARCADE
@ -747,7 +680,7 @@ Config file is located in `config/idac.yaml`.
Always make sure your database (tables) are up-to-date:
```shell
python dbutils.py upgrade
python dbutils.py --game SDGT upgrade
```
### Notes
@ -832,82 +765,3 @@ python dbutils.py upgrade
A huge thanks to all people who helped shaping this project to what it is now and don't want to be mentioned here.
## Pokken
### SDAK
| Version ID | Version Name |
| ---------- | ------------ |
| 0 | Pokken |
### Config
Config file is `pokken.yaml`
#### server
| Option | Info | Default |
| ------ | ---- | ------- |
| `hostname` | Hostname override for allnet to tell the game where to connect. Useful for local setups that need to use a different hostname for pokken's proxy. Otherwise, it should match `server`->`hostname` in `core.yaml`. | `localhost` |
| `enabled` | `True` if the pokken service should be enabled. `False` otherwise. | `True` |
| `loglevel` | String indicating how verbose pokken logs should be. Acceptable values are `debug`, `info`, `warn`, and `error`. | `info` |
| `auto_register` | For games that don't use aimedb, this controls weather connecting cards that aren't registered should automatically be registered when making a profile. Set to `False` to require cards be already registered before being usable with Pokken. | `True` |
| `enable_matching` | If `True`, allow non-local matching. This doesn't currently work because BIWA, the matching protocol the game uses, is not understood, so this should be set to `False`. | `False` |
| `stun_server_host` | Hostname of the STUN server the game will use for matching. | `stunserver.stunprotocol.org` (might not work anymore? recomend changing) |
| `stun_server_port` | Port for the external STUN server. Will probably be moved to the `ports` section in the future. | `3478` |
#### ports
| Option | Info | Default |
| ------ | ---- | ------- |
| `game` | Override for the title server port sent by allnet. Useful for local setups utalizing NGINX. | `9000` |
| `admission` | Port for the admission server used in global matching. May be obsolited later. | `9001` |
### Connecting to Artemis
Pokken is a bit tricky to get working due to it having a hard requirement of the connection being HTTPS. This is simplified somewhat by Pokken simply not validating the certificate in any way, shape or form (it can be self-signed, expired, for a different domain, etc.) but it does have to be there. The work-around is to spin up a local NGINX (or other proxy) instance and point traffic back to artemis. See below for a sample nginx config:
`nginx.conf`
```conf
# This example assumes your artemis instance is configured to listed on port 8080, and your certs exists at /path/to/cert and are called title.crt and title.key.
server {
listen 443 ssl;
server_name your.hostname.here;
ssl_certificate /path/to/cert/title.crt;
ssl_certificate_key /path/to/cert/title.key;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
ssl_session_tickets off;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
ssl_ciphers "ALL:@SECLEVEL=0";
ssl_prefer_server_ciphers off;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_request_headers on;
proxy_pass http://127.0.0.1:8080/;
}
}
```
`pokken.yaml`
```yaml
server:
hostname: "your.hostname.here"
enable: True
loglevel: "info"
auto_register: True
enable_matching: False
stun_server_host: "stunserver.stunprotocol.org"
stun_server_port: 3478
ports:
game: 443
admission: 9001
```
### Info
The arcade release is missing a few fighters and supports compared to the switch version. It may be possible to mod these in in the future, but not much headway has been made on this as far as I know. Mercifully, the game uses the pokedex number (illustration_book_no) wherever possible when referingto both fighters and supports. Customization is entirely done on the webui. Artemis currently only supports changing your name, gender, and supporrt teams, but more is planned for the future.
### Credits
Special thanks to Pocky for pointing me in the right direction in terms of getting this game to function at all, and Lightning and other pokken cab owners for doing testing and reporting bugs/issues.

View File

@ -1,34 +0,0 @@
# Migrating from an older build of ARTEMiS
If you haven't updated artemis in a while, you may find that configuration options have moved, been renamed, or no longer exist. This document exists to help migrate from legacy versions of artemis to newer builds.
## Dependancies
Make sure your dependiences are up to date with what's required to run artemis. A simple `pip install -r requirements.txt` will get you up to date.
## Database
Database migration is required if you are using a version of artemis that still uses the old custom-rolled database versioning system (raw SQL scripts). Artemis now uses alembic to manage database versioning, and you will need to move to this new system.
**BEFORE DOING ANY DATABASE WORK, ALWAYS MAKE SURE YOU HAVE FUNCTIONAL, UP-TO-DATE BACKUPS!!**
For almost all situations, simply running `python dbutils.py migrate` will do the job. This will upgrade you to the latest version of the old system, move you over to alembic, then upgrade you to the newest alembic version. If you encounter any errors or data loss, you should report this as a bug to our issue tracker.
## Configuration
Configuration management is the sewage cleaning of the sysadmin world. It sucks and nobody likes to do it, but it needs to be done or everyone ends up in deep shit. This section will walk through what configuration options have changed, and how to set them properly.
### core.yaml
`title`->`hostname` is now `server`->`hostname`. This hostname is what gets sent to clients in response to auth requests, so it should be both accessable from whereever the client is, and point properly to the title server.
With the move to starlette and uvicorn, different services now run as seperate USGI applications. `billing`->`standalone` and `allnet`->`standalone` are flags that determine weather the service runs as a stand-alone service, on it's own seperate port, or as a part of the whole application. For example, setting `billing`->`standalone` to `True` will cause a seperate instance of the billing server to spin up listening on 8443 with SSL using the certs listed in the config file. Setting it to `False` will just allow the main server to also serve `/request/` and assumes that something is standing in front of it proxying 8443 SSL to whatever `server`->`port` is set to.
Beforehand, if `server`->`is_develop` was `False`, the server assumed that there was a proxy standing in front of it, proxying requests to proper channels. This was, in hindsight, a very dumb assumption. Now, `server`->`is_using_proxy` is what flags the server as having nginx or another proxy in front of it. The effects of setting this to true are somewhat game-dependant, but generally artemis will use the port listed in `server`->`proxy_port` (and `server`->`proxy_port_ssl` for SSL connections, as defined by the games) instead of `server`->`port`. If set to 0, `server`->`proxy_port` will default to what `server`->`port` (and `server`->`proxy_port_ssl` will default to 443) make sure to set them accordingly. Note that some title servers have their own needs and specify their own specific ports. Refer to [game_specific_info.md](docs/game_specific_info.md) for more infomation. (For example, pokken requires SSL using an older, weaker certificate, and always requires the port to be sent even if it's port 443)
`index.py`'s args have changed. You can now override what port the title server listens on with `-p` and tell the server to use ssl with `-s`.
Rather then having a `standalone` config variable, the frontend is a seperate wsgi app entirely. Having `enable` be `True` will launch it on the port specified in the config file. Otherwise, the fontend will not run.
`title`->`reboot_start_time`/`reboot_end_time` allow you to specify when the games should be told network maintanence is happening. It's exact implementation depends on the game. Do note that many games will beave unexpectly if `reboot_end_time` is not `07:00`.
If you wish to make use of aimedb's SegaAuthId system to better protect the few title servers that actually use it, set `aimedb`->`id_secret` to base64-encoded random bytes (32 is a good length) using something like `openssl rand -base64 64`. If you intend to use the frontend, the same thing must be done for `frontend`->`secret` or you won't be able to log in.
`mucha`'s only option is now just log level.
`aimedb` now has it's own `listen_address` field, in case you want to proxy everything but aimedb, so it can still listen on `0.0.0.0` instead of `127.0.0.1`.

View File

@ -8,22 +8,7 @@ team:
mods:
use_login_bonus: True
# stock_tickets allows specified ticket IDs to be auto-stocked at login. Format is a comma-delimited string of ticket IDs
# note: quanity is not refreshed on "continue" after set - only on subsequent login
stock_tickets:
stock_count: 99
# Allow use of all available customization items in frontend web ui
# note: This effectively makes every available item appear to be in the user's inventory. It does _not_ override the "disableFlag" setting on individual items
# warning: This can result in pushing a lot of data, especially the userbox items. Recommended for local network use only.
forced_item_unlocks:
map_icons: False
system_voices: False
avatar_accessories: False
nameplates: False
trophies: False
character_icons: False
version:
11:
rom: 2.00.00
@ -37,9 +22,6 @@ version:
14:
rom: 2.15.00
data: 2.15.00
15:
rom: 2.20.00
data: 2.20.00
crypto:
encrypted_only: False

View File

@ -18,7 +18,7 @@ server:
title:
loglevel: "info"
reboot_start_time: "04:00"
reboot_end_time: "07:00" # this must be set to 7:00 am for some game, please do not change it
reboot_end_time: "05:00"
database:
host: "localhost"
@ -27,7 +27,6 @@ database:
name: "aime"
port: 3306
protocol: "mysql"
ssl_enabled: False
sha2_password: False
loglevel: "info"
enable_memcached: True

View File

@ -1,4 +1,4 @@
server:
enable: True
loglevel: "info"
use_https: True
use:https: True

View File

@ -1,10 +1,6 @@
server:
enable: True
loglevel: "info"
festa_enable: True
festa_add_VP: "20,5"
festa_multiply_VP: "1,2"
festa_end_time: "2029-01-01 00:00:00.0"
mods:
unlock_all_modules: True

View File

@ -1,5 +1,5 @@
server:
enable: False
enable: True
loglevel: "info"
hostname: ""
news: ""

View File

@ -12,6 +12,3 @@ uploads:
photos_dir: ""
movies: False
movies_dir: ""
crypto:
encrypted_only: False

View File

@ -2,19 +2,12 @@ server:
enable: True
loglevel: "info"
auto_register: True
photon_app_id: "7df3a2f6-d69d-4073-aafe-810ee61e1cea"
data_version: 1
game_version: 33
crypt:
enable: False
key: ""
iv: ""
hash:
verify_hash: False
hash_base: ""
card:
enable: True
crypt_password: ""
crypt_salt: ""
hash_base: ""

View File

@ -6,8 +6,7 @@ import uvicorn
import logging
import asyncio
from core.config import CoreConfig
from core.aimedb import AimedbServlette
from core import CoreConfig, AimedbServlette
async def launch_main(cfg: CoreConfig, ssl: bool) -> None:
if ssl:

26
read.py
View File

@ -1,15 +1,15 @@
#!/usr/bin/env python3
import argparse
import asyncio
import logging
import os
import re
from logging.handlers import TimedRotatingFileHandler
from os import path
from typing import List, Optional
import coloredlogs
import os
import yaml
from os import path
import logging
import coloredlogs
import asyncio
from logging.handlers import TimedRotatingFileHandler
from typing import List, Optional
from core import CoreConfig, Utils
@ -44,7 +44,7 @@ class BaseReader:
pass
async def main():
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Import Game Information")
parser.add_argument(
"--game",
@ -140,12 +140,8 @@ async def main():
for dir, mod in titles.items():
if args.game in mod.game_codes:
handler = mod.reader(config, args.version, bin_arg, opt_arg, args.extra)
await handler.read()
loop = asyncio.get_event_loop()
loop.run_until_complete(handler.read())
logger.info("Done")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -10,7 +10,6 @@ Games listed below have been tested and confirmed working. Only game versions ol
+ CHUNITHM INTL
+ SUPERSTAR
+ SUPERSTAR PLUS
+ NEW
+ NEW PLUS
+ SUN
@ -29,7 +28,6 @@ Games listed below have been tested and confirmed working. Only game versions ol
+ NEW PLUS
+ SUN
+ SUN PLUS
+ LUMINOUS
+ crossbeats REV.
+ Crossbeats REV.
@ -49,8 +47,6 @@ Games listed below have been tested and confirmed working. Only game versions ol
+ UNiVERSE PLUS
+ FESTiVAL
+ FESTiVAL PLUS
+ BUDDiES
+ BUDDiES PLUS
+ O.N.G.E.K.I.
+ SUMMER

View File

@ -1,24 +1,23 @@
mypy
wheel
pytz
pyyaml
sqlalchemy==1.4.46
aiomysql
pyopenssl
service_identity
PyCryptodome
inflection
coloredlogs
pylibmc; platform_system != "Windows"
wacky
bcrypt
jinja2
protobuf
pillow
pyjwt==2.8.0
websockets
starlette
asyncio
uvicorn
alembic
python-multipart
mypy
wheel
pytz
pyyaml
sqlalchemy==1.4.46
mysqlclient
pyopenssl
service_identity
PyCryptodome
inflection
coloredlogs
pylibmc; platform_system != "Windows"
wacky
bcrypt
jinja2
protobuf
pillow
pyjwt==2.8.0
websockets
starlette
asyncio
uvicorn
alembic

View File

@ -1,16 +1,16 @@
import itertools
import json
import logging
import json
from datetime import datetime, timedelta
from typing import Any, Dict, List
from time import strftime
import pytz
from typing import Dict, Any, List
from core.config import CoreConfig
from titles.chuni.config import ChuniConfig
from titles.chuni.const import ChuniConstants, ItemKind
from titles.chuni.const import ChuniConstants
from titles.chuni.database import ChuniData
from titles.chuni.config import ChuniConfig
SCORE_BUFFER = {}
class ChuniBase:
def __init__(self, core_cfg: CoreConfig, game_cfg: ChuniConfig) -> None:
@ -24,35 +24,20 @@ class ChuniBase:
async def handle_game_login_api_request(self, data: Dict) -> Dict:
"""
Handles the login bonus and ticket stock logic, required for the game
because getUserLoginBonus gets called after getUserItem; therefore the
Handles the login bonus logic, required for the game because
getUserLoginBonus gets called after getUserItem and therefore the
items needs to be inserted in the database before they get requested.
- Adds a stock for each specified ticket (itemKind 5)
- Adds a bonusCount after a user logged in after 24 hours, makes sure
loginBonus 30 gets looped, only show the login banner every 24 hours,
adds the bonus to items (itemKind 6)
Adds a bonusCount after a user logged in after 24 hours, makes sure
loginBonus 30 gets looped, only show the login banner every 24 hours,
adds the bonus to items (itemKind 6)
"""
user_id = data["userId"]
# If we want to make certain tickets always available, stock them now
if self.game_cfg.mods.stock_tickets:
for ticket in self.game_cfg.mods.stock_tickets.split(","):
await self.data.item.put_item(
user_id,
{
"itemId": ticket.strip(),
"itemKind": ItemKind.TICKET.value,
"stock": self.game_cfg.mods.stock_count,
"isValid": True,
},
)
# ignore the login bonus if disabled in config
if not self.game_cfg.mods.use_login_bonus:
return {"returnCode": 1}
user_id = data["userId"]
login_bonus_presets = await self.data.static.get_login_bonus_presets(self.version)
for preset in login_bonus_presets:
@ -116,7 +101,7 @@ class ChuniBase:
user_id,
{
"itemId": login_item["presentId"],
"itemKind": ItemKind.PRESENT.value,
"itemKind": 6,
"stock": login_item["itemNum"],
"isValid": True,
},
@ -277,39 +262,35 @@ class ChuniBase:
}
async def handle_get_user_character_api_request(self, data: Dict) -> Dict:
user_id = int(data["userId"])
next_idx = int(data["nextIndex"])
max_ct = int(data["maxCount"])
# add one to the limit so we know if there's a next page of items
rows = await self.data.item.get_characters(
user_id, limit=max_ct + 1, offset=next_idx
)
if rows is None or len(rows) == 0:
characters = await self.data.item.get_characters(data["userId"])
if characters is None:
return {
"userId": user_id,
"userId": data["userId"],
"length": 0,
"nextIndex": -1,
"userCharacterList": [],
}
character_list = []
next_idx = int(data["nextIndex"])
max_ct = int(data["maxCount"])
for row in rows[:max_ct]:
tmp = row._asdict()
tmp.pop("id")
for x in range(next_idx, len(characters)):
tmp = characters[x]._asdict()
tmp.pop("user")
tmp.pop("id")
character_list.append(tmp)
if len(rows) > max_ct:
if len(character_list) >= max_ct:
break
if len(characters) >= next_idx + max_ct:
next_idx += max_ct
else:
next_idx = -1
return {
"userId": user_id,
"userId": data["userId"],
"length": len(character_list),
"nextIndex": next_idx,
"userCharacterList": character_list,
@ -339,31 +320,29 @@ class ChuniBase:
}
async def handle_get_user_course_api_request(self, data: Dict) -> Dict:
user_id = int(data["userId"])
next_idx = int(data["nextIndex"])
max_ct = int(data["maxCount"])
rows = await self.data.score.get_courses(
user_id, limit=max_ct + 1, offset=next_idx
)
if rows is None or len(rows) == 0:
user_course_list = await self.data.score.get_courses(data["userId"])
if user_course_list is None:
return {
"userId": user_id,
"userId": data["userId"],
"length": 0,
"nextIndex": -1,
"userCourseList": [],
}
course_list = []
next_idx = int(data.get("nextIndex", 0))
max_ct = int(data.get("maxCount", 300))
for row in rows[:max_ct]:
tmp = row._asdict()
for x in range(next_idx, len(user_course_list)):
tmp = user_course_list[x]._asdict()
tmp.pop("user")
tmp.pop("id")
course_list.append(tmp)
if len(rows) > max_ct:
if len(user_course_list) >= max_ct:
break
if len(user_course_list) >= next_idx + max_ct:
next_idx += max_ct
else:
next_idx = -1
@ -431,94 +410,75 @@ class ChuniBase:
}
async def handle_get_user_rival_music_api_request(self, data: Dict) -> Dict:
user_id = int(data["userId"])
rival_id = int(data["rivalId"])
next_idx = int(data["nextIndex"])
max_ct = int(data["maxCount"])
rival_levels = [int(x["level"]) for x in data["userRivalMusicLevelList"]]
rival_id = data["rivalId"]
next_index = int(data["nextIndex"])
max_count = int(data["maxCount"])
user_rival_music_list = []
# Fetch all the rival music entries for the user
rows = await self.data.score.get_scores(
rival_id,
levels=rival_levels,
limit=max_ct + 1,
offset=next_idx,
)
all_entries = await self.data.score.get_rival_music(rival_id)
if rows is None or len(rows) == 0:
return {
"userId": user_id,
"rivalId": rival_id,
"nextIndex": -1,
"userRivalMusicList": [],
}
# Process the entries based on max_count and nextIndex
for music in all_entries:
music_id = music["musicId"]
level = music["level"]
score = music["scoreMax"]
rank = music["scoreRank"]
music_details = [x._asdict() for x in rows]
returned_music_details_count = 0
music_list = []
# Create a music entry for the current music_id if it's unique
music_entry = next((entry for entry in user_rival_music_list if entry["musicId"] == music_id), None)
if music_entry is None:
music_entry = {
"musicId": music_id,
"length": 0,
"userRivalMusicDetailList": []
}
user_rival_music_list.append(music_entry)
# note that itertools.groupby will only work on sorted keys, which is already sorted by
# the query in get_scores
for music_id, details_iter in itertools.groupby(music_details, key=lambda x: x["musicId"]):
details: list[dict[Any, Any]] = [
{"level": d["level"], "scoreMax": d["scoreMax"]}
for d in details_iter
]
# Create a level entry for the current level if it's unique or has a higher score
level_entry = next((entry for entry in music_entry["userRivalMusicDetailList"] if entry["level"] == level), None)
if level_entry is None:
level_entry = {
"level": level,
"scoreMax": score,
"scoreRank": rank
}
music_entry["userRivalMusicDetailList"].append(level_entry)
elif score > level_entry["scoreMax"]:
level_entry["scoreMax"] = score
level_entry["scoreRank"] = rank
music_list.append({"musicId": music_id, "length": len(details), "userMusicDetailList": details})
returned_music_details_count += len(details)
# Calculate the length for each "musicId" by counting the unique levels
for music_entry in user_rival_music_list:
music_entry["length"] = len(music_entry["userRivalMusicDetailList"])
if len(music_list) >= max_ct:
break
# if we returned fewer PBs than we originally asked for from the database, that means
# we queried for the PBs of max_ct + 1 songs.
if returned_music_details_count < len(rows):
next_idx += max_ct
else:
next_idx = -1
return {
"userId": user_id,
"rivalId": rival_id,
"length": len(music_list),
"nextIndex": next_idx,
"userRivalMusicList": music_list,
# Prepare the result dictionary with user rival music data
result = {
"userId": data["userId"],
"rivalId": data["rivalId"],
"nextIndex": str(next_index + len(user_rival_music_list[next_index: next_index + max_count]) if max_count <= len(user_rival_music_list[next_index: next_index + max_count]) else -1),
"userRivalMusicList": user_rival_music_list[next_index: next_index + max_count]
}
return result
async def handle_get_user_favorite_item_api_request(self, data: Dict) -> Dict:
user_id = int(data["userId"])
next_idx = int(data["nextIndex"])
max_ct = int(data["maxCount"])
kind = int(data["kind"])
is_all_favorite_item = str(data["isAllFavoriteItem"]) == "true"
user_fav_item_list = []
# still needs to be implemented on WebUI
# 1: Music, 2: User, 3: Character
rows = await self.data.item.get_all_favorites(
user_id,
self.version,
fav_kind=kind,
limit=max_ct + 1,
offset=next_idx,
fav_list = await self.data.item.get_all_favorites(
data["userId"], self.version, fav_kind=int(data["kind"])
)
if rows is not None:
for fav in rows[:max_ct]:
if fav_list is not None:
for fav in fav_list:
user_fav_item_list.append({"id": fav["favId"]})
if rows is None or len(rows) <= max_ct:
next_idx = -1
else:
next_idx += max_ct
return {
"userId": user_id,
"userId": data["userId"],
"length": len(user_fav_item_list),
"kind": kind,
"nextIndex": next_idx,
"kind": data["kind"],
"nextIndex": -1,
"userFavoriteItemList": user_fav_item_list,
}
@ -530,39 +490,36 @@ class ChuniBase:
return {"userId": data["userId"], "length": 0, "userFavoriteMusicList": []}
async def handle_get_user_item_api_request(self, data: Dict) -> Dict:
user_id = int(data["userId"])
next_idx = int(data["nextIndex"])
max_ct = int(data["maxCount"])
kind = int(int(data["nextIndex"]) / 10000000000)
next_idx = int(int(data["nextIndex"]) % 10000000000)
user_item_list = await self.data.item.get_items(data["userId"], kind)
kind = next_idx // 10000000000
next_idx = next_idx % 10000000000
rows = await self.data.item.get_items(
user_id, kind, limit=max_ct + 1, offset=next_idx
)
if rows is None or len(rows) == 0:
if user_item_list is None or len(user_item_list) == 0:
return {
"userId": user_id,
"userId": data["userId"],
"nextIndex": -1,
"itemKind": kind,
"userItemList": [],
}
items: List[Dict[str, Any]] = []
for row in rows[:max_ct]:
tmp = row._asdict()
for i in range(next_idx, len(user_item_list)):
tmp = user_item_list[i]._asdict()
tmp.pop("user")
tmp.pop("id")
items.append(tmp)
if len(items) >= int(data["maxCount"]):
break
if len(rows) > max_ct:
next_idx = kind * 10000000000 + next_idx + max_ct
xout = kind * 10000000000 + next_idx + len(items)
if len(items) < int(data["maxCount"]):
next_idx = 0
else:
next_idx = -1
next_idx = xout
return {
"userId": user_id,
"userId": data["userId"],
"nextIndex": next_idx,
"itemKind": kind,
"length": len(items),
@ -614,55 +571,62 @@ class ChuniBase:
}
async def handle_get_user_music_api_request(self, data: Dict) -> Dict:
user_id = int(data["userId"])
next_idx = int(data["nextIndex"])
max_ct = int(data["maxCount"])
rows = await self.data.score.get_scores(
user_id, limit=max_ct + 1, offset=next_idx
)
if rows is None or len(rows) == 0:
music_detail = await self.data.score.get_scores(data["userId"])
if music_detail is None:
return {
"userId": user_id,
"userId": data["userId"],
"length": 0,
"nextIndex": -1,
"userMusicList": [], # 240
}
music_details = [x._asdict() for x in rows]
returned_music_details_count = 0
music_list = []
song_list = []
next_idx = int(data["nextIndex"])
max_ct = int(data["maxCount"])
# note that itertools.groupby will only work on sorted keys, which is already sorted by
# the query in get_scores
for _music_id, details_iter in itertools.groupby(music_details, key=lambda x: x["musicId"]):
details: list[dict[Any, Any]] = []
for x in range(next_idx, len(music_detail)):
found = False
tmp = music_detail[x]._asdict()
tmp.pop("user")
tmp.pop("id")
for d in details_iter:
d.pop("id")
d.pop("user")
for song in song_list:
score_buf = SCORE_BUFFER.get(str(data["userId"])) or []
if song["userMusicDetailList"][0]["musicId"] == tmp["musicId"]:
found = True
song["userMusicDetailList"].append(tmp)
song["length"] = len(song["userMusicDetailList"])
score_buf.append(tmp["musicId"])
SCORE_BUFFER[str(data["userId"])] = score_buf
details.append(d)
score_buf = SCORE_BUFFER.get(str(data["userId"])) or []
if not found and tmp["musicId"] not in score_buf:
song_list.append({"length": 1, "userMusicDetailList": [tmp]})
score_buf.append(tmp["musicId"])
SCORE_BUFFER[str(data["userId"])] = score_buf
music_list.append({"length": len(details), "userMusicDetailList": details})
returned_music_details_count += len(details)
if len(music_list) >= max_ct:
if len(song_list) >= max_ct:
break
# if we returned fewer PBs than we originally asked for from the database, that means
# we queried for the PBs of max_ct + 1 songs.
if returned_music_details_count < len(rows):
next_idx += max_ct
for songIdx in range(len(song_list)):
for recordIdx in range(x+1, len(music_detail)):
if song_list[songIdx]["userMusicDetailList"][0]["musicId"] == music_detail[recordIdx]["musicId"]:
music = music_detail[recordIdx]._asdict()
music.pop("user")
music.pop("id")
song_list[songIdx]["userMusicDetailList"].append(music)
song_list[songIdx]["length"] += 1
if len(song_list) >= max_ct:
next_idx += len(song_list)
else:
next_idx = -1
SCORE_BUFFER[str(data["userId"])] = []
return {
"userId": user_id,
"length": len(music_list),
"userId": data["userId"],
"length": len(song_list),
"nextIndex": next_idx,
"userMusicList": music_list,
"userMusicList": song_list, # 240
}
async def handle_get_user_option_api_request(self, data: Dict) -> Dict:
@ -756,14 +720,9 @@ class ChuniBase:
team_id = 65535
team_name = self.game_cfg.team.team_name
team_rank = 0
team_user_point = 0
# Get user profile
profile = await self.data.profile.get_profile_data(data["userId"], self.version)
if profile is None:
return {"userId": data["userId"], "teamId": 0}
if profile and profile["teamId"]:
# Get team by id
team = await self.data.profile.get_team_by_id(profile["teamId"])
@ -772,12 +731,7 @@ class ChuniBase:
team_id = team["id"]
team_name = team["teamName"]
team_rank = await self.data.profile.get_team_rank(team["id"])
team_point = team["teamPoint"]
if team["userTeamPoint"] is not None and team["userTeamPoint"] != "":
user_team_point_data = json.loads(team["userTeamPoint"])
for user_point_data in user_team_point_data:
if user_point_data["user"] == data["userId"]:
team_user_point = int(user_point_data["userPoint"])
# Don't return anything if no team name has been defined for defaults and there is no team set for the player
if not profile["teamId"] and team_name == "":
return {"userId": data["userId"], "teamId": 0}
@ -787,12 +741,11 @@ class ChuniBase:
"teamId": team_id,
"teamRank": team_rank,
"teamName": team_name,
"assaultTimeRate": 1, # TODO: Figure out assaultTime, which might be team point boost?
"userTeamPoint": {
"userId": data["userId"],
"teamId": team_id,
"orderId": 0,
"teamPoint": team_user_point,
"orderId": 1,
"teamPoint": 1,
"aggrDate": data["playDate"],
},
}
@ -858,12 +811,6 @@ class ChuniBase:
upsert = data["upsertUserAll"]
user_id = data["userId"]
if int(user_id) & 0x1000000000001 == 0x1000000000001:
place_id = int(user_id) & 0xFFFC00000000
self.logger.info("Guest play from place ID %d, ignoring.", place_id)
return {"returnCode": "1"}
if "userData" in upsert:
try:
upsert["userData"][0]["userName"] = self.read_wtf8(
@ -988,31 +935,6 @@ class ChuniBase:
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"}
@ -1041,4 +963,4 @@ class ChuniBase:
return {
"userId": data["userId"],
"userNetBattleData": {"recentNBSelectMusicList": []},
}
}

View File

@ -53,29 +53,6 @@ class ChuniModsConfig:
self.__config, "chuni", "mods", "use_login_bonus", default=True
)
@property
def stock_tickets(self) -> str:
return CoreConfig.get_config_field(
self.__config, "chuni", "mods", "stock_tickets", default=None
)
@property
def stock_count(self) -> int:
return CoreConfig.get_config_field(
self.__config, "chuni", "mods", "stock_count", default=99
)
def forced_item_unlocks(self, item: str) -> bool:
forced_item_unlocks = CoreConfig.get_config_field(
self.__config, "chuni", "mods", "forced_item_unlocks", default={}
)
if item not in forced_item_unlocks.keys():
# default to no forced unlocks
return False
return forced_item_unlocks[item]
class ChuniVersionConfig:
def __init__(self, parent_config: "ChuniConfig") -> None:
@ -86,14 +63,9 @@ class ChuniVersionConfig:
in the form of:
11: {"rom": 2.00.00, "data": 2.00.00}
"""
versions = CoreConfig.get_config_field(
return CoreConfig.get_config_field(
self.__config, "chuni", "version", default={}
)
if version not in versions.keys():
return None
return versions[version]
)[version]
class ChuniCryptoConfig:

View File

@ -1,6 +1,3 @@
from enum import Enum, IntEnum
class ChuniConstants:
GAME_CODE = "SDBT"
GAME_CODE_NEW = "SDHD"
@ -19,13 +16,10 @@ class ChuniConstants:
VER_CHUNITHM_CRYSTAL = 8
VER_CHUNITHM_CRYSTAL_PLUS = 9
VER_CHUNITHM_PARADISE = 10
VER_CHUNITHM_NEW = 11
VER_CHUNITHM_NEW_PLUS = 12
VER_CHUNITHM_SUN = 13
VER_CHUNITHM_SUN_PLUS = 14
VER_CHUNITHM_LUMINOUS = 15
VERSION_NAMES = [
"CHUNITHM",
"CHUNITHM PLUS",
@ -41,116 +35,9 @@ class ChuniConstants:
"CHUNITHM NEW!!",
"CHUNITHM NEW PLUS!!",
"CHUNITHM SUN",
"CHUNITHM SUN PLUS",
"CHUNITHM LUMINOUS",
]
SCORE_RANK_INTERVALS_OLD = [
(1007500, "SSS"),
(1000000, "SS"),
( 975000, "S"),
( 950000, "AAA"),
( 925000, "AA"),
( 900000, "A"),
( 800000, "BBB"),
( 700000, "BB"),
( 600000, "B"),
( 500000, "C"),
( 0, "D"),
]
SCORE_RANK_INTERVALS_NEW = [
(1009000, "SSS+"), # New only
(1007500, "SSS"),
(1005000, "SS+"), # New only
(1000000, "SS"),
( 990000, "S+"), # New only
( 975000, "S"),
( 950000, "AAA"),
( 925000, "AA"),
( 900000, "A"),
( 800000, "BBB"),
( 700000, "BB"),
( 600000, "B"),
( 500000, "C"),
( 0, "D"),
"CHUNITHM SUN PLUS"
]
@classmethod
def game_ver_to_string(cls, ver: int):
return cls.VERSION_NAMES[ver]
class MapAreaConditionType(IntEnum):
"""Condition types for the GetGameMapAreaConditionApi endpoint. Incomplete.
For the MAP_CLEARED/MAP_AREA_CLEARED/TROPHY_OBTAINED conditions, the conditionId
is the map/map area/trophy.
For the RANK_*/ALL_JUSTICE conditions, the conditionId is songId * 100 + difficultyId.
For example, Halcyon [ULTIMA] would be 173 * 100 + 4 = 17304.
"""
ALWAYS_UNLOCKED = 0
MAP_CLEARED = 1
MAP_AREA_CLEARED = 2
TROPHY_OBTAINED = 3
RANK_SSS = 19
RANK_SSP = 20
RANK_SS = 21
RANK_SP = 22
RANK_S = 23
ALL_JUSTICE = 28
class MapAreaConditionLogicalOperator(Enum):
AND = 1
OR = 2
class AvatarCategory(Enum):
WEAR = 1
HEAD = 2
FACE = 3
SKIN = 4
ITEM = 5
FRONT = 6
BACK = 7
class ItemKind(IntEnum):
NAMEPLATE = 1
FRAME = 2
"""
"Frame" is the background for the gauge/score/max combo display
shown during gameplay. This item cannot be equipped (as of LUMINOUS)
and is hardcoded to the current game's version.
"""
TROPHY = 3
SKILL = 4
TICKET = 5
"""A statue is also a ticket."""
PRESENT = 6
MUSIC_UNLOCK = 7
MAP_ICON = 8
SYSTEM_VOICE = 9
SYMBOL_CHAT = 10
AVATAR_ACCESSORY = 11
ULTIMA_UNLOCK = 12
"""This only applies to ULTIMA difficulties that are *not* unlocked by
SS-ing EXPERT+MASTER.
"""
class FavoriteItemKind(IntEnum):
MUSIC = 1
RIVAL = 2
CHARACTER = 3
return cls.VERSION_NAMES[ver]

View File

@ -1,17 +1,13 @@
from core.data import Data
from core.config import CoreConfig
from titles.chuni.schema import *
from .config import ChuniConfig
class ChuniData(Data):
def __init__(self, cfg: CoreConfig, chuni_cfg: ChuniConfig = None) -> None:
def __init__(self, cfg: CoreConfig) -> None:
super().__init__(cfg)
self.item = ChuniItemData(cfg, self.session)
self.profile = ChuniProfileData(cfg, self.session)
self.score = ChuniScoreData(cfg, self.session)
self.static = ChuniStaticData(cfg, self.session)
# init rom versioning for use with score playlog data
if chuni_cfg:
ChuniRomVersion.init_versions(chuni_cfg)

View File

@ -1,9 +1,7 @@
from typing import List
from starlette.routing import Route, Mount
from starlette.routing import Route
from starlette.requests import Request
from starlette.responses import Response, RedirectResponse
from starlette.staticfiles import StaticFiles
from sqlalchemy.engine import Row
from os import path
import yaml
import jinja2
@ -12,70 +10,7 @@ from core.frontend import FE_Base, UserSession
from core.config import CoreConfig
from .database import ChuniData
from .config import ChuniConfig
from .const import ChuniConstants, AvatarCategory, ItemKind
def pairwise(iterable):
# https://docs.python.org/3/library/itertools.html#itertools.pairwise
# but for Python < 3.10. pairwise('ABCDEFG') → AB BC CD DE EF FG
iterator = iter(iterable)
a = next(iterator, None)
for b in iterator:
yield a, b
a = b
def calculate_song_rank(score: int, game_version: int) -> str:
if game_version >= ChuniConstants.VER_CHUNITHM_NEW:
intervals = ChuniConstants.SCORE_RANK_INTERVALS_NEW
else:
intervals = ChuniConstants.SCORE_RANK_INTERVALS_OLD
for (min_score, rank) in intervals:
if score >= min_score:
return rank
return "D"
def calculate_song_rating(score: int, chart_constant: float, game_version: int) -> float:
is_new = game_version >= ChuniConstants.VER_CHUNITHM_NEW
if is_new: # New and later
max_score = 1009000
max_rating_modifier = 2.15
else: # Up to Paradise Lost
max_score = 1007500
max_rating_modifier = 2.0
if (score < 500000):
return 0.0 # D
elif (score >= max_score):
return chart_constant + max_rating_modifier # SSS/SSS+
# Okay, we're doing this the hard way.
# Rating goes up linearly between breakpoints listed below.
# Pick the score interval in which we are in, then calculate
# the position between possible ratings.
score_intervals = [
( 500000, 0.0), # C
( 800000, max(0.0, (chart_constant - 5.0) / 2)), # BBB
( 900000, max(0.0, (chart_constant - 5.0))), # A
( 925000, max(0.0, (chart_constant - 3.0))), # AA
( 975000, chart_constant), # S
(1000000, chart_constant + 1.0), # SS
(1005000, chart_constant + 1.5), # SS+
(1007500, chart_constant + 2.0), # SSS
(1009000, chart_constant + max_rating_modifier), # SSS+!
]
for ((lo_score, lo_rating), (hi_score, hi_rating)) in pairwise(score_intervals):
if not (lo_score <= score < hi_score):
continue
interval_pos = (score - lo_score) / (hi_score - lo_score)
return lo_rating + ((hi_rating - lo_rating) * interval_pos)
from .const import ChuniConstants
class ChuniFrontend(FE_Base):
@ -83,34 +18,18 @@ class ChuniFrontend(FE_Base):
self, cfg: CoreConfig, environment: jinja2.Environment, cfg_dir: str
) -> None:
super().__init__(cfg, environment)
self.data = ChuniData(cfg)
self.game_cfg = ChuniConfig()
if path.exists(f"{cfg_dir}/{ChuniConstants.CONFIG_NAME}"):
self.game_cfg.update(
yaml.safe_load(open(f"{cfg_dir}/{ChuniConstants.CONFIG_NAME}"))
)
self.data = ChuniData(cfg, self.game_cfg)
self.nav_name = "Chunithm"
def get_routes(self) -> List[Route]:
return [
Route("/", self.render_GET, methods=['GET']),
Route("/rating", self.render_GET_rating, methods=['GET']),
Mount("/playlog", routes=[
Route("/", self.render_GET_playlog, methods=['GET']),
Route("/{index}", self.render_GET_playlog, methods=['GET']),
]),
Route("/favorites", self.render_GET_favorites, methods=['GET']),
Route("/userbox", self.render_GET_userbox, methods=['GET']),
Route("/avatar", self.render_GET_avatar, methods=['GET']),
Route("/update.map-icon", self.update_map_icon, methods=['POST']),
Route("/update.system-voice", self.update_system_voice, methods=['POST']),
Route("/update.userbox", self.update_userbox, methods=['POST']),
Route("/update.avatar", self.update_avatar, methods=['POST']),
Route("/update.name", self.update_name, methods=['POST']),
Route("/update.favorite_music_playlog", self.update_favorite_music_playlog, methods=['POST']),
Route("/update.favorite_music_favorites", self.update_favorite_music_favorites, methods=['POST']),
Route("/version.change", self.version_change, methods=['POST']),
Mount('/img', app=StaticFiles(directory='titles/chuni/img'), name="img")
]
async def render_GET(self, request: Request) -> bytes:
@ -120,593 +39,27 @@ class ChuniFrontend(FE_Base):
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:
# chunithm_version is -1 means it is not initialized yet, select a default version from existing.
if usr_sesh.chunithm_version < 0:
usr_sesh.chunithm_version = versions[0]
profile = await self.data.profile.get_profile_data(usr_sesh.user_id, usr_sesh.chunithm_version)
user_id = usr_sesh.user_id
version = usr_sesh.chunithm_version
# While map icons and system voices weren't present prior to AMAZON, we don't need to bother checking
# version here - it'll just end up being empty sets and the jinja will ignore the variables anyway.
map_icons, total_map_icons = await self.get_available_map_icons(version, profile)
system_voices, total_system_voices = await self.get_available_system_voices(version, profile)
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=user_id,
profile=profile,
version_list=ChuniConstants.VERSION_NAMES,
versions=versions,
cur_version=version,
cur_version_name=ChuniConstants.game_ver_to_string(version),
map_icons=map_icons,
system_voices=system_voices,
total_map_icons=total_map_icons,
total_system_voices=total_system_voices
), media_type="text/html; charset=utf-8")
if usr_sesh.chunithm_version >= 0:
encoded_sesh = self.encode_session(usr_sesh)
resp.set_cookie("ARTEMIS_SESH", encoded_sesh)
return resp
else:
return RedirectResponse("/gate/", 303)
async def render_GET_rating(self, request: Request) -> bytes:
template = self.environment.get_template(
"titles/chuni/templates/chuni_rating.jinja"
)
usr_sesh = self.validate_session(request)
if not usr_sesh:
usr_sesh = UserSession()
if usr_sesh.user_id > 0:
if usr_sesh.chunithm_version < 0:
return RedirectResponse("/game/chuni/", 303)
profile = await self.data.profile.get_profile_data(usr_sesh.user_id, usr_sesh.chunithm_version)
rating = await self.data.profile.get_profile_rating(usr_sesh.user_id, usr_sesh.chunithm_version)
hot_list=[]
base_list=[]
if profile and rating:
song_records = []
for song in rating:
music_chart = await self.data.static.get_music_chart(usr_sesh.chunithm_version, song.musicId, song.difficultId)
if not music_chart:
continue
rank = calculate_song_rank(song.score, profile.version)
rating = calculate_song_rating(song.score, music_chart.level, profile.version)
song_rating = int(rating * 10 ** 2) / 10 ** 2
song_records.append({
"difficultId": song.difficultId,
"musicId": song.musicId,
"title": music_chart.title,
"level": music_chart.level,
"score": song.score,
"type": song.type,
"rank": rank,
"song_rating": song_rating,
})
hot_list = [obj for obj in song_records if obj["type"] == "userRatingBaseHotList"]
base_list = [obj for obj in song_records if obj["type"] == "userRatingBaseList"]
return Response(template.render(
title=f"{self.core_config.server.name} | {self.nav_name}",
game_list=self.environment.globals["game_list"],
sesh=vars(usr_sesh),
profile=profile,
hot_list=hot_list,
base_list=base_list,
cur_version=usr_sesh.chunithm_version,
cur_version_name=ChuniConstants.game_ver_to_string(usr_sesh.chunithm_version)
), media_type="text/html; charset=utf-8")
else:
return RedirectResponse("/gate/", 303)
async def render_GET_playlog(self, request: Request) -> bytes:
template = self.environment.get_template(
"titles/chuni/templates/chuni_playlog.jinja"
)
usr_sesh = self.validate_session(request)
if not usr_sesh:
usr_sesh = UserSession()
if usr_sesh.user_id > 0:
if usr_sesh.chunithm_version < 0:
return RedirectResponse("/game/chuni/", 303)
path_index = request.path_params.get('index')
if not path_index or int(path_index) < 1:
index = 0
else:
index = int(path_index) - 1 # 0 and 1 are 1st page
user_id = usr_sesh.user_id
version = usr_sesh.chunithm_version
playlog_count = await self.data.score.get_user_playlogs_count(user_id, version)
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,
cur_version=version,
cur_version_name=ChuniConstants.game_ver_to_string(version)
), media_type="text/html; charset=utf-8")
playlog = await self.data.score.get_playlogs_limited(user_id, version, index, 20)
playlog_with_title = []
for idx,record in enumerate(playlog):
music_chart = await self.data.static.get_music_chart(version, record.musicId, record.level)
if music_chart:
difficultyNum=music_chart.level
artist=music_chart.artist
title=music_chart.title
(jacket, ext) = path.splitext(music_chart.jacketPath)
jacket += ".png"
else:
difficultyNum=0
artist="unknown"
title="musicid: " + str(record.musicId)
jacket = "unknown.png"
# Check if this song is a favorite so we can populate the add/remove button
is_favorite = await self.data.item.is_favorite(user_id, version, record.musicId)
playlog_with_title.append({
# Values for the actual readable results
"raw": record,
"title": title,
"difficultyNum": difficultyNum,
"artist": artist,
"jacket": jacket,
# Values used solely for favorite updates
"idx": idx,
"musicId": record.musicId,
"isFav": is_favorite
})
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=user_id,
playlog=playlog_with_title,
playlog_count=playlog_count,
cur_version=version,
cur_version_name=ChuniConstants.game_ver_to_string(version)
), media_type="text/html; charset=utf-8")
else:
return RedirectResponse("/gate/", 303)
async def render_GET_favorites(self, request: Request) -> bytes:
template = self.environment.get_template(
"titles/chuni/templates/chuni_favorites.jinja"
)
usr_sesh = self.validate_session(request)
if not usr_sesh:
usr_sesh = UserSession()
if usr_sesh.user_id > 0:
if usr_sesh.chunithm_version < 0:
return RedirectResponse("/game/chuni/", 303)
user_id = usr_sesh.user_id
version = usr_sesh.chunithm_version
favorites = await self.data.item.get_all_favorites(user_id, version, 1)
favorites_count = len(favorites)
favorites_with_title = []
favorites_by_genre = dict()
for idx,favorite in enumerate(favorites):
song = await self.data.static.get_song(favorite.favId)
if song:
# we likely got multiple results - one for each chart. Just use the first
artist=song.artist
title=song.title
genre=song.genre
(jacket, ext) = path.splitext(song.jacketPath)
jacket += ".png"
else:
artist="unknown"
title="musicid: " + str(favorite.favId)
genre="unknown"
jacket = "unknown.png"
# add a new collection for the genre if this is our first time seeing it
if genre not in favorites_by_genre:
favorites_by_genre[genre] = []
# add the song to the appropriate genre collection
favorites_by_genre[genre].append({
"idx": idx,
"title": title,
"artist": artist,
"jacket": jacket,
"favId": favorite.favId
})
# Sort favorites by title before rendering the page
for g in favorites_by_genre:
favorites_by_genre[g].sort(key=lambda x: x["title"].lower())
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=user_id,
favorites_by_genre=favorites_by_genre,
favorites_count=favorites_count,
cur_version=version,
cur_version_name=ChuniConstants.game_ver_to_string(version)
), media_type="text/html; charset=utf-8")
else:
return RedirectResponse("/gate/", 303)
async def get_available_map_icons(self, version: int, profile: Row) -> (List[dict], int):
items = dict()
rows = await self.data.static.get_map_icons(version)
if rows is None:
return (items, 0) # can only happen with old db
force_unlocked = self.game_cfg.mods.forced_item_unlocks("map_icons")
user_map_icons = []
if not force_unlocked:
user_map_icons = await self.data.item.get_items(profile.user, ItemKind.MAP_ICON.value)
user_map_icons = [icon["itemId"] for icon in user_map_icons] + [profile.mapIconId]
for row in rows:
if force_unlocked or row["defaultHave"] or row["mapIconId"] in user_map_icons:
item = dict()
item["id"] = row["mapIconId"]
item["name"] = row["name"]
item["iconPath"] = path.splitext(row["iconPath"])[0] + ".png"
items[row["mapIconId"]] = item
return (items, len(rows))
async def get_available_system_voices(self, version: int, profile: Row) -> (List[dict], int):
items = dict()
rows = await self.data.static.get_system_voices(version)
if rows is None:
return (items, 0) # can only happen with old db
force_unlocked = self.game_cfg.mods.forced_item_unlocks("system_voices")
user_system_voices = []
if not force_unlocked:
user_system_voices = await self.data.item.get_items(profile.user, ItemKind.SYSTEM_VOICE.value)
user_system_voices = [icon["itemId"] for icon in user_system_voices] + [profile.voiceId]
for row in rows:
if force_unlocked or row["defaultHave"] or row["voiceId"] in user_system_voices:
item = dict()
item["id"] = row["voiceId"]
item["name"] = row["name"]
item["imagePath"] = path.splitext(row["imagePath"])[0] + ".png"
items[row["voiceId"]] = item
return (items, len(rows))
async def get_available_nameplates(self, version: int, profile: Row) -> (List[dict], int):
items = dict()
rows = await self.data.static.get_nameplates(version)
if rows is None:
return (items, 0) # can only happen with old db
force_unlocked = self.game_cfg.mods.forced_item_unlocks("nameplates")
user_nameplates = []
if not force_unlocked:
user_nameplates = await self.data.item.get_items(profile.user, ItemKind.NAMEPLATE.value)
user_nameplates = [item["itemId"] for item in user_nameplates] + [profile.nameplateId]
for row in rows:
if force_unlocked or row["defaultHave"] or row["nameplateId"] in user_nameplates:
item = dict()
item["id"] = row["nameplateId"]
item["name"] = row["name"]
item["texturePath"] = path.splitext(row["texturePath"])[0] + ".png"
items[row["nameplateId"]] = item
return (items, len(rows))
async def get_available_trophies(self, version: int, profile: Row) -> (List[dict], int):
items = dict()
rows = await self.data.static.get_trophies(version)
if rows is None:
return (items, 0) # can only happen with old db
force_unlocked = self.game_cfg.mods.forced_item_unlocks("trophies")
user_trophies = []
if not force_unlocked:
user_trophies = await self.data.item.get_items(profile.user, ItemKind.TROPHY.value)
user_trophies = [item["itemId"] for item in user_trophies] + [profile.trophyId]
for row in rows:
if force_unlocked or row["defaultHave"] or row["trophyId"] in user_trophies:
item = dict()
item["id"] = row["trophyId"]
item["name"] = row["name"]
item["rarity"] = row["rareType"]
items[row["trophyId"]] = item
return (items, len(rows))
async def get_available_characters(self, version: int, profile: Row) -> (List[dict], int):
items = dict()
rows = await self.data.static.get_characters(version)
if rows is None:
return (items, 0) # can only happen with old db
force_unlocked = self.game_cfg.mods.forced_item_unlocks("character_icons")
user_characters = []
if not force_unlocked:
user_characters = await self.data.item.get_characters(profile.user)
user_characters = [chara["characterId"] for chara in user_characters] + [profile.characterId, profile.charaIllustId]
for row in rows:
if force_unlocked or row["defaultHave"] or row["characterId"] in user_characters:
item = dict()
item["id"] = row["characterId"]
item["name"] = row["name"]
item["iconPath"] = path.splitext(row["imagePath3"])[0] + ".png"
items[row["characterId"]] = item
return (items, len(rows))
async def get_available_avatar_items(self, version: int, category: AvatarCategory, user_unlocked_items: List[int]) -> (List[dict], int):
items = dict()
rows = await self.data.static.get_avatar_items(version, category.value)
if rows is None:
return (items, 0) # can only happen with old db
force_unlocked = self.game_cfg.mods.forced_item_unlocks("avatar_accessories")
for row in rows:
if force_unlocked or row["defaultHave"] or row["avatarAccessoryId"] in user_unlocked_items:
item = dict()
item["id"] = row["avatarAccessoryId"]
item["name"] = row["name"]
item["iconPath"] = path.splitext(row["iconPath"])[0] + ".png"
item["texturePath"] = path.splitext(row["texturePath"])[0] + ".png"
items[row["avatarAccessoryId"]] = item
return (items, len(rows))
async def render_GET_userbox(self, request: Request) -> bytes:
template = self.environment.get_template(
"titles/chuni/templates/chuni_userbox.jinja"
)
usr_sesh = self.validate_session(request)
if not usr_sesh:
usr_sesh = UserSession()
if usr_sesh.user_id > 0:
if usr_sesh.chunithm_version < 0:
return RedirectResponse("/game/chuni/", 303)
user_id = usr_sesh.user_id
version = usr_sesh.chunithm_version
# Get the user profile so we know how the userbox is currently configured
profile = await self.data.profile.get_profile_data(user_id, version)
# Build up lists of available userbox components
nameplates, total_nameplates = await self.get_available_nameplates(version, profile)
trophies, total_trophies = await self.get_available_trophies(version, profile)
characters, total_characters = await self.get_available_characters(version, profile)
# Get the user's team
team_name = "ARTEMiS"
if profile["teamId"]:
team = await self.data.profile.get_team_by_id(profile["teamId"])
team_name = team["teamName"]
# Figure out the rating color we should use (rank maps to the stylesheet)
rating = profile.playerRating / 100;
rating_rank = 0
if rating >= 16:
rating_rank = 8
elif rating >= 15.25:
rating_rank = 7
elif rating >= 14.5:
rating_rank = 6
elif rating >= 13.25:
rating_rank = 5
elif rating >= 12:
rating_rank = 4
elif rating >= 10:
rating_rank = 3
elif rating >= 7:
rating_rank = 2
elif rating >= 4:
rating_rank = 1
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=user_id,
cur_version=version,
cur_version_name=ChuniConstants.game_ver_to_string(version),
profile=profile,
team_name=team_name,
rating_rank=rating_rank,
nameplates=nameplates,
trophies=trophies,
characters=characters,
total_nameplates=total_nameplates,
total_trophies=total_trophies,
total_characters=total_characters
), media_type="text/html; charset=utf-8")
else:
return RedirectResponse("/gate/", 303)
async def render_GET_avatar(self, request: Request) -> bytes:
template = self.environment.get_template(
"titles/chuni/templates/chuni_avatar.jinja"
)
usr_sesh = self.validate_session(request)
if not usr_sesh:
usr_sesh = UserSession()
if usr_sesh.user_id > 0:
if usr_sesh.chunithm_version < 11:
# Avatar configuration only for NEW!! and newer
return RedirectResponse("/game/chuni/", 303)
user_id = usr_sesh.user_id
version = usr_sesh.chunithm_version
# Get the user profile so we know what avatar items are currently in use
profile = await self.data.profile.get_profile_data(user_id, version)
# Get all the user avatar accessories so we know what to populate
user_accessories = await self.data.item.get_items(user_id, ItemKind.AVATAR_ACCESSORY.value)
user_accessories = [item["itemId"] for item in user_accessories] + \
[profile.avatarBack, profile.avatarItem, profile.avatarWear, \
profile.avatarFront, profile.avatarSkin, profile.avatarHead, profile.avatarFace]
# Build up available list of items for each avatar category
wears, total_wears = await self.get_available_avatar_items(version, AvatarCategory.WEAR, user_accessories)
faces, total_faces = await self.get_available_avatar_items(version, AvatarCategory.FACE, user_accessories)
heads, total_heads = await self.get_available_avatar_items(version, AvatarCategory.HEAD, user_accessories)
skins, total_skins = await self.get_available_avatar_items(version, AvatarCategory.SKIN, user_accessories)
items, total_items = await self.get_available_avatar_items(version, AvatarCategory.ITEM, user_accessories)
fronts, total_fronts = await self.get_available_avatar_items(version, AvatarCategory.FRONT, user_accessories)
backs, total_backs = await self.get_available_avatar_items(version, AvatarCategory.BACK, user_accessories)
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=user_id,
cur_version=version,
cur_version_name=ChuniConstants.game_ver_to_string(version),
profile=profile,
wears=wears,
faces=faces,
heads=heads,
skins=skins,
items=items,
fronts=fronts,
backs=backs,
total_wears=total_wears,
total_faces=total_faces,
total_heads=total_heads,
total_skins=total_skins,
total_items=total_items,
total_fronts=total_fronts,
total_backs=total_backs
), media_type="text/html; charset=utf-8")
else:
return RedirectResponse("/gate/", 303)
async def update_map_icon(self, request: Request) -> bytes:
usr_sesh = self.validate_session(request)
if not usr_sesh:
return RedirectResponse("/gate/", 303)
form_data = await request.form()
new_map_icon: str = form_data.get("id")
if not new_map_icon:
return RedirectResponse("/gate/?e=4", 303)
if not await self.data.profile.update_map_icon(usr_sesh.user_id, usr_sesh.chunithm_version, new_map_icon):
return RedirectResponse("/gate/?e=999", 303)
return RedirectResponse("/game/chuni/", 303)
async def update_system_voice(self, request: Request) -> bytes:
usr_sesh = self.validate_session(request)
if not usr_sesh:
return RedirectResponse("/gate/", 303)
form_data = await request.form()
new_system_voice: str = form_data.get("id")
if not new_system_voice:
return RedirectResponse("/gate/?e=4", 303)
if not await self.data.profile.update_system_voice(usr_sesh.user_id, usr_sesh.chunithm_version, new_system_voice):
return RedirectResponse("/gate/?e=999", 303)
return RedirectResponse("/game/chuni/", 303)
async def update_userbox(self, request: Request) -> bytes:
usr_sesh = self.validate_session(request)
if not usr_sesh:
return RedirectResponse("/gate/", 303)
form_data = await request.form()
new_nameplate: str = form_data.get("nameplate")
new_trophy: str = form_data.get("trophy")
new_character: str = form_data.get("character")
if not new_nameplate or \
not new_trophy or \
not new_character:
return RedirectResponse("/game/chuni/userbox?e=4", 303)
if not await self.data.profile.update_userbox(usr_sesh.user_id, usr_sesh.chunithm_version, new_nameplate, new_trophy, new_character):
return RedirectResponse("/gate/?e=999", 303)
return RedirectResponse("/game/chuni/userbox", 303)
async def update_avatar(self, request: Request) -> bytes:
usr_sesh = self.validate_session(request)
if not usr_sesh:
return RedirectResponse("/gate/", 303)
form_data = await request.form()
new_wear: str = form_data.get("wear")
new_face: str = form_data.get("face")
new_head: str = form_data.get("head")
new_skin: str = form_data.get("skin")
new_item: str = form_data.get("item")
new_front: str = form_data.get("front")
new_back: str = form_data.get("back")
if not new_wear or \
not new_face or \
not new_head or \
not new_skin or \
not new_item or \
not new_front or \
not new_back:
return RedirectResponse("/game/chuni/avatar?e=4", 303)
if not await self.data.profile.update_avatar(usr_sesh.user_id, usr_sesh.chunithm_version, new_wear, new_face, new_head, new_skin, new_item, new_front, new_back):
return RedirectResponse("/gate/?e=999", 303)
return RedirectResponse("/game/chuni/avatar", 303)
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)
), media_type="text/html; charset=utf-8")
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: str = request.query_params.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:
@ -719,57 +72,12 @@ class ChuniFrontend(FE_Base):
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):
if not await self.data.profile.update_name(usr_sesh, new_name_full):
return RedirectResponse("/gate/?e=999", 303)
return RedirectResponse("/game/chuni/?s=1", 303)
async def update_favorite_music(self, request: Request, retPage: str):
usr_sesh = self.validate_session(request)
if not usr_sesh:
return RedirectResponse(retPage, 303)
user_id = usr_sesh.user_id
version = usr_sesh.chunithm_version
form_data = await request.form()
music_id: str = form_data.get("musicId")
isAdd: int = int(form_data.get("isAdd"))
if isAdd:
if await self.data.item.put_favorite_music(user_id, version, music_id) == None:
return RedirectResponse("/gate/?e=999", 303)
else:
if await self.data.item.delete_favorite_music(user_id, version, music_id) == None:
return RedirectResponse("/gate/?e=999", 303)
return RedirectResponse(retPage, 303)
async def update_favorite_music_playlog(self, request: Request):
return await self.update_favorite_music(request, "/game/chuni/playlog")
async def update_favorite_music_favorites(self, request: Request):
return await self.update_favorite_music(request, "/game/chuni/favorites")
async def version_change(self, request: Request):
usr_sesh = self.validate_session(request)
if not usr_sesh:
usr_sesh = UserSession()
if usr_sesh.user_id > 0:
form_data = await request.form()
chunithm_version = form_data.get("version")
self.logger.debug(f"version change to: {chunithm_version}")
if(chunithm_version.isdigit()):
usr_sesh.chunithm_version=int(chunithm_version)
encoded_sesh = self.encode_session(usr_sesh)
self.logger.debug(f"Created session with JWT {encoded_sesh}")
resp = RedirectResponse("/game/chuni/", 303)
resp.set_cookie("ARTEMIS_SESH", encoded_sesh)
return resp
else:
return RedirectResponse("/gate/", 303)
return RedirectResponse("/gate/?s=1", 303)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,4 +0,0 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View File

@ -1,4 +0,0 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

View File

@ -1,5 +0,0 @@
# Ignore everything in this directory
*
# Except this file and default unknown
!.gitignore
!unknown.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@ -1,4 +0,0 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

View File

@ -1,4 +0,0 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@ -1,4 +0,0 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

View File

@ -1,8 +1,7 @@
from starlette.requests import Request
from starlette.routing import Route
from starlette.responses import Response
import logging
import coloredlogs
import logging, coloredlogs
from logging.handlers import TimedRotatingFileHandler
import zlib
import yaml
@ -35,13 +34,12 @@ from .new import ChuniNew
from .newplus import ChuniNewPlus
from .sun import ChuniSun
from .sunplus import ChuniSunPlus
from .luminous import ChuniLuminous
class ChuniServlet(BaseServlet):
def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None:
super().__init__(core_cfg, cfg_dir)
self.game_cfg = ChuniConfig()
self.hash_table: Dict[str, Dict[str, str]] = {}
self.hash_table: Dict[Dict[str, str]] = {}
if path.exists(f"{cfg_dir}/{ChuniConstants.CONFIG_NAME}"):
self.game_cfg.update(
yaml.safe_load(open(f"{cfg_dir}/{ChuniConstants.CONFIG_NAME}"))
@ -63,7 +61,6 @@ class ChuniServlet(BaseServlet):
ChuniNewPlus,
ChuniSun,
ChuniSunPlus,
ChuniLuminous,
]
self.logger = logging.getLogger("chuni")
@ -92,65 +89,30 @@ class ChuniServlet(BaseServlet):
)
self.logger.inited = True
known_iter_counts = {
ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS: 67,
f"{ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS}_int": 25, # SUPERSTAR
ChuniConstants.VER_CHUNITHM_PARADISE: 44,
f"{ChuniConstants.VER_CHUNITHM_PARADISE}_int": 51, # SUPERSTAR PLUS
ChuniConstants.VER_CHUNITHM_NEW: 54,
f"{ChuniConstants.VER_CHUNITHM_NEW}_int": 49,
ChuniConstants.VER_CHUNITHM_NEW_PLUS: 25,
f"{ChuniConstants.VER_CHUNITHM_NEW_PLUS}_int": 31,
ChuniConstants.VER_CHUNITHM_SUN: 70,
f"{ChuniConstants.VER_CHUNITHM_SUN}_int": 35,
ChuniConstants.VER_CHUNITHM_SUN_PLUS: 36,
f"{ChuniConstants.VER_CHUNITHM_SUN_PLUS}_int": 36,
ChuniConstants.VER_CHUNITHM_LUMINOUS: 8,
f"{ChuniConstants.VER_CHUNITHM_LUMINOUS}_int": 8,
}
for version, keys in self.game_cfg.crypto.keys.items():
if len(keys) < 3:
continue
if isinstance(version, int):
version_idx = version
else:
version_idx = int(version.split("_")[0])
salt = bytes.fromhex(keys[2])
if len(keys) >= 4:
iter_count = keys[3]
elif (iter_count := known_iter_counts.get(version)) is None:
self.logger.error(
"Number of iteration rounds for version %s is not known, but it is not specified in the config",
version,
)
continue
self.hash_table[version] = {}
method_list = [
method
for method in dir(self.versions[version_idx])
for method in dir(self.versions[version])
if not method.startswith("__")
]
for method in method_list:
method_fixed = inflection.camelize(method)[6:-7]
# This only applies for CHUNITHM NEW International and later for some reason.
# CHUNITHM SUPERSTAR (PLUS) did not add "Exp" to the endpoint when hashing.
if (
isinstance(version, str)
and version.endswith("_int")
and version_idx >= ChuniConstants.VER_CHUNITHM_NEW
):
method_fixed += "C3Exp"
# number of iterations was changed to 70 in SUN and then to 36
if version == ChuniConstants.VER_CHUNITHM_SUN_PLUS:
iter_count = 36
elif version == ChuniConstants.VER_CHUNITHM_SUN:
iter_count = 70
else:
iter_count = 44
hash = PBKDF2(
method_fixed,
salt,
bytes.fromhex(keys[2]),
128,
count=iter_count,
hmac_hash_module=SHA1,
@ -160,7 +122,7 @@ class ChuniServlet(BaseServlet):
self.hash_table[version][hashed_name] = method_fixed
self.logger.debug(
f"Hashed v{version} method {method_fixed} with {salt} to get {hashed_name}"
f"Hashed v{version} method {method_fixed} with {bytes.fromhex(keys[2])} to get {hash.hex()}"
)
@classmethod
@ -233,61 +195,47 @@ class ChuniServlet(BaseServlet):
internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS
elif version >= 210 and version < 215: # SUN
internal_ver = ChuniConstants.VER_CHUNITHM_SUN
elif version >= 215 and version < 220: # SUN PLUS
elif version >= 215: # SUN
internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS
elif version >= 220: # LUMINOUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS
elif game_code == "SDGS": # Int
if version < 105: # SUPERSTAR
internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS
elif version >= 105 and version < 110: # SUPERSTAR PLUS *Cursed but needed due to different encryption key
internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE
if version < 110: # SUPERSTAR
internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE # FIXME: Not sure what was intended to go here? was just "PARADISE"
elif version >= 110 and version < 115: # NEW
internal_ver = ChuniConstants.VER_CHUNITHM_NEW
elif version >= 115 and version < 120: # NEW PLUS!!
internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS
elif version >= 120 and version < 125: # SUN
internal_ver = ChuniConstants.VER_CHUNITHM_SUN
elif version >= 125 and version < 130: # SUN PLUS
elif version >= 125: # 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 we get a 32 character long hex string, it's a hash and we're
# doing encrypted. The likelihood of false positives is low but
# doing encrypted. The likelyhood of false positives is low but
# technically not 0
if game_code == "SDGS":
crypto_cfg_key = f"{internal_ver}_int"
hash_table_key = f"{internal_ver}_int"
else:
crypto_cfg_key = internal_ver
hash_table_key = internal_ver
if internal_ver < ChuniConstants.VER_CHUNITHM_NEW:
endpoint = request.headers.get("User-Agent").split("#")[0]
else:
if hash_table_key not in self.hash_table:
if internal_ver not in self.hash_table:
self.logger.error(
f"v{version} does not support encryption or no keys entered"
)
return Response(zlib.compress(b'{"stat": "0"}'))
elif endpoint.lower() not in self.hash_table[hash_table_key]:
elif endpoint.lower() not in self.hash_table[internal_ver]:
self.logger.error(
f"No hash found for v{version} endpoint {endpoint}"
)
return Response(zlib.compress(b'{"stat": "0"}'))
endpoint = self.hash_table[hash_table_key][endpoint.lower()]
endpoint = self.hash_table[internal_ver][endpoint.lower()]
try:
crypt = AES.new(
bytes.fromhex(self.game_cfg.crypto.keys[crypto_cfg_key][0]),
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][0]),
AES.MODE_CBC,
bytes.fromhex(self.game_cfg.crypto.keys[crypto_cfg_key][1]),
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]),
)
req_raw = crypt.decrypt(req_raw)
@ -324,13 +272,7 @@ class ChuniServlet(BaseServlet):
self.logger.info(f"v{version} {endpoint} request from {client_ip}")
self.logger.debug(req_data)
if game_code == "SDGS" and version >= 110:
endpoint = endpoint.replace("C3Exp", "")
elif game_code == "SDGS" and version < 110:
endpoint = endpoint.replace("Exp", "")
else:
endpoint = endpoint
endpoint = endpoint.replace("C3Exp", "") if game_code == "SDGS" else endpoint
func_to_find = "handle_" + inflection.underscore(endpoint) + "_request"
handler_cls = self.versions[internal_ver](self.core_cfg, self.game_cfg)
@ -347,7 +289,7 @@ class ChuniServlet(BaseServlet):
self.logger.error(f"Error handling v{version} method {endpoint} - {e}")
return Response(zlib.compress(b'{"stat": "0"}'))
if resp is None:
if resp == None:
resp = {"returnCode": 1}
self.logger.debug(f"Response {resp}")
@ -360,9 +302,9 @@ class ChuniServlet(BaseServlet):
padded = pad(zipped, 16)
crypt = AES.new(
bytes.fromhex(self.game_cfg.crypto.keys[crypto_cfg_key][0]),
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][0]),
AES.MODE_CBC,
bytes.fromhex(self.game_cfg.crypto.keys[crypto_cfg_key][1]),
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]),
)
return Response(crypt.encrypt(padded))
return Response(crypt.encrypt(padded))

View File

@ -1,298 +0,0 @@
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

@ -4,14 +4,12 @@ from random import randint
from typing import Dict
import pytz
from core.config import CoreConfig
from core.utils import Utils
from titles.chuni.base import ChuniBase
from titles.chuni.config import ChuniConfig
from titles.chuni.const import ChuniConstants
from titles.chuni.database import ChuniData
from titles.chuni.base import ChuniBase
from titles.chuni.config import ChuniConfig
class ChuniNew(ChuniBase):
ITEM_TYPE = {"character": 20, "story": 21, "card": 22}
@ -34,8 +32,6 @@ class ChuniNew(ChuniBase):
return "210"
if self.version == ChuniConstants.VER_CHUNITHM_SUN_PLUS:
return "215"
if self.version == ChuniConstants.VER_CHUNITHM_LUMINOUS:
return "220"
async def handle_get_game_setting_api_request(self, data: Dict) -> Dict:
# use UTC time and convert it to JST time by adding +9
@ -106,8 +102,7 @@ class ChuniNew(ChuniBase):
return {"returnCode": "1"}
async def handle_get_user_map_area_api_request(self, data: Dict) -> Dict:
map_area_ids = [int(area["mapAreaId"]) for area in data["mapAreaIdList"]]
user_map_areas = await self.data.item.get_map_areas(data["userId"], map_area_ids)
user_map_areas = await self.data.item.get_map_areas(data["userId"])
map_areas = []
for map_area in user_map_areas:
@ -287,37 +282,35 @@ class ChuniNew(ChuniBase):
}
async def handle_get_user_printed_card_api_request(self, data: Dict) -> Dict:
user_id = int(data["userId"])
next_idx = int(data["nextIndex"])
max_ct = int(data["maxCount"])
rows = await self.data.item.get_user_print_states(
user_id,
has_completed=True,
limit=max_ct + 1,
offset=next_idx,
user_print_list = await self.data.item.get_user_print_states(
data["userId"], has_completed=True
)
if rows is None or len(rows) == 0:
if user_print_list is None:
return {
"userId": user_id,
"userId": data["userId"],
"length": 0,
"nextIndex": -1,
"userPrintedCardList": [],
}
print_list = []
next_idx = int(data["nextIndex"])
max_ct = int(data["maxCount"])
for row in rows[:max_ct]:
tmp = row._asdict()
for x in range(next_idx, len(user_print_list)):
tmp = user_print_list[x]._asdict()
print_list.append(tmp["cardId"])
if len(rows) > max_ct:
next_idx += max_ct
if len(print_list) >= max_ct:
break
if len(print_list) >= max_ct:
next_idx = next_idx + max_ct
else:
next_idx = -1
return {
"userId": user_id,
"userId": data["userId"],
"length": len(print_list),
"nextIndex": next_idx,
"userPrintedCardList": print_list,

View File

@ -2,12 +2,10 @@ from typing import Optional
from os import walk, path
import xml.etree.ElementTree as ET
from read import BaseReader
from PIL import Image
from core.config import CoreConfig
from titles.chuni.database import ChuniData
from titles.chuni.const import ChuniConstants
from titles.chuni.schema.static import music as MusicTable
class ChuniReader(BaseReader):
@ -37,43 +35,30 @@ class ChuniReader(BaseReader):
if self.opt_dir is not None:
data_dirs += self.get_data_directories(self.opt_dir)
we_diff = "4"
if self.version >= ChuniConstants.VER_CHUNITHM_NEW:
we_diff = "5"
# character images could be stored anywhere across all the data dirs. Map them first
self.logger.info(f"Mapping DDS image files...")
dds_images = dict()
for dir in data_dirs:
self.map_dds_images(dds_images, f"{dir}/ddsImage")
for dir in data_dirs:
self.logger.info(f"Read from {dir}")
await self.read_events(f"{dir}/event")
await self.read_music(f"{dir}/music", we_diff)
await self.read_music(f"{dir}/music")
await self.read_charges(f"{dir}/chargeItem")
await self.read_avatar(f"{dir}/avatarAccessory")
await self.read_login_bonus(f"{dir}/")
await self.read_nameplate(f"{dir}/namePlate")
await self.read_trophy(f"{dir}/trophy")
await self.read_character(f"{dir}/chara", dds_images)
await self.read_map_icon(f"{dir}/mapIcon")
await self.read_system_voice(f"{dir}/systemVoice")
async def read_login_bonus(self, root_dir: str) -> None:
for root, dirs, files in walk(f"{root_dir}loginBonusPreset"):
for dir in dirs:
if path.exists(f"{root}/{dir}/LoginBonusPreset.xml"):
with open(f"{root}/{dir}/LoginBonusPreset.xml", "r", encoding="utf-8") as fp:
strdata = fp.read()
with open(f"{root}/{dir}/LoginBonusPreset.xml", "rb") as fp:
bytedata = fp.read()
strdata = bytedata.decode("UTF-8")
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
id = name.find("id").text
name = name.find("str").text
disableFlag = xml_root.find("disableFlag") # may not exist in older data
is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False
is_enabled = (
True if xml_root.find("disableFlag").text == "false" else False
)
result = await self.data.static.put_login_bonus_preset(
self.version, id, name, is_enabled
@ -136,8 +121,9 @@ class ChuniReader(BaseReader):
for root, dirs, files in walk(evt_dir):
for dir in dirs:
if path.exists(f"{root}/{dir}/Event.xml"):
with open(f"{root}/{dir}/Event.xml", "r", encoding="utf-8") as fp:
strdata = fp.read()
with open(f"{root}/{dir}/Event.xml", "rb") as fp:
bytedata = fp.read()
strdata = bytedata.decode("UTF-8")
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
@ -154,29 +140,21 @@ class ChuniReader(BaseReader):
else:
self.logger.warning(f"Failed to insert event {id}")
async def read_music(self, music_dir: str, we_diff: str = "4") -> None:
max_title_len = MusicTable.columns["title"].type.length
max_artist_len = MusicTable.columns["artist"].type.length
async def read_music(self, music_dir: str) -> None:
for root, dirs, files in walk(music_dir):
for dir in dirs:
if path.exists(f"{root}/{dir}/Music.xml"):
with open(f"{root}/{dir}/Music.xml", "r", encoding='utf-8') as fp:
strdata = fp.read()
with open(f"{root}/{dir}/Music.xml", "rb") as fp:
bytedata = fp.read()
strdata = bytedata.decode("UTF-8")
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
song_id = name.find("id").text
title = name.find("str").text
if len(title) > max_title_len:
self.logger.warning(f"Truncating music {song_id} song title")
title = title[:max_title_len]
for artistName in xml_root.findall("artistName"):
artist = artistName.find("str").text
if len(artist) > max_artist_len:
self.logger.warning(f"Truncating music {song_id} artist name")
artist = artist[:max_artist_len]
for genreNames in xml_root.findall("genreNames"):
for list_ in genreNames.findall("list"):
@ -185,8 +163,6 @@ class ChuniReader(BaseReader):
for jaketFile in xml_root.findall("jaketFile"): # nice typo, SEGA
jacket_path = jaketFile.find("path").text
# Save off image for use in frontend
self.copy_image(jacket_path, f"{root}/{dir}", "titles/chuni/img/jacket/")
for fumens in xml_root.findall("fumens"):
for MusicFumenData in fumens.findall("MusicFumenData"):
@ -196,7 +172,7 @@ class ChuniReader(BaseReader):
chart_type = MusicFumenData.find("type")
chart_id = chart_type.find("id").text
chart_diff = chart_type.find("str").text
if chart_diff == "WorldsEnd" and chart_id == we_diff: # 4 in SDBT, 5 in SDHD
if chart_diff == "WorldsEnd" and (chart_id == "4" or chart_id == "5"): # 4 in SDBT, 5 in SDHD
level = float(xml_root.find("starDifType").text)
we_chara = (
xml_root.find("worldsEndTagName")
@ -234,8 +210,9 @@ class ChuniReader(BaseReader):
for root, dirs, files in walk(charge_dir):
for dir in dirs:
if path.exists(f"{root}/{dir}/ChargeItem.xml"):
with open(f"{root}/{dir}/ChargeItem.xml", "r", encoding='utf-8') as fp:
strdata = fp.read()
with open(f"{root}/{dir}/ChargeItem.xml", "rb") as fp:
bytedata = fp.read()
strdata = bytedata.decode("UTF-8")
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
@ -263,219 +240,25 @@ class ChuniReader(BaseReader):
for root, dirs, files in walk(avatar_dir):
for dir in dirs:
if path.exists(f"{root}/{dir}/AvatarAccessory.xml"):
with open(f"{root}/{dir}/AvatarAccessory.xml", "r", encoding='utf-8') as fp:
strdata = fp.read()
with open(f"{root}/{dir}/AvatarAccessory.xml", "rb") as fp:
bytedata = fp.read()
strdata = bytedata.decode("UTF-8")
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
id = name.find("id").text
name = name.find("str").text
sortName = xml_root.find("sortName").text
category = xml_root.find("category").text
defaultHave = xml_root.find("defaultHave").text == 'true'
disableFlag = xml_root.find("disableFlag") # may not exist in older data
is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False
for image in xml_root.findall("image"):
iconPath = image.find("path").text
self.copy_image(iconPath, f"{root}/{dir}", "titles/chuni/img/avatar/")
for texture in xml_root.findall("texture"):
texturePath = texture.find("path").text
self.copy_image(texturePath, f"{root}/{dir}", "titles/chuni/img/avatar/")
result = await self.data.static.put_avatar(
self.version, id, name, category, iconPath, texturePath, is_enabled, defaultHave, sortName
self.version, id, name, category, iconPath, texturePath
)
if result is not None:
self.logger.info(f"Inserted avatarAccessory {id}")
else:
self.logger.warning(f"Failed to insert avatarAccessory {id}")
async def read_nameplate(self, nameplate_dir: str) -> None:
for root, dirs, files in walk(nameplate_dir):
for dir in dirs:
if path.exists(f"{root}/{dir}/NamePlate.xml"):
with open(f"{root}/{dir}/NamePlate.xml", "r", encoding='utf-8') as fp:
strdata = fp.read()
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
id = name.find("id").text
name = name.find("str").text
sortName = xml_root.find("sortName").text
defaultHave = xml_root.find("defaultHave").text == 'true'
disableFlag = xml_root.find("disableFlag") # may not exist in older data
is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False
for image in xml_root.findall("image"):
texturePath = image.find("path").text
self.copy_image(texturePath, f"{root}/{dir}", "titles/chuni/img/nameplate/")
result = await self.data.static.put_nameplate(
self.version, id, name, texturePath, is_enabled, defaultHave, sortName
)
if result is not None:
self.logger.info(f"Inserted nameplate {id}")
else:
self.logger.warning(f"Failed to insert nameplate {id}")
async def read_trophy(self, trophy_dir: str) -> None:
for root, dirs, files in walk(trophy_dir):
for dir in dirs:
if path.exists(f"{root}/{dir}/Trophy.xml"):
with open(f"{root}/{dir}/Trophy.xml", "r", encoding='utf-8') as fp:
strdata = fp.read()
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
id = name.find("id").text
name = name.find("str").text
rareType = xml_root.find("rareType").text
disableFlag = xml_root.find("disableFlag") # may not exist in older data
is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False
defaultHave = xml_root.find("defaultHave").text == 'true'
result = await self.data.static.put_trophy(
self.version, id, name, rareType, is_enabled, defaultHave
)
if result is not None:
self.logger.info(f"Inserted trophy {id}")
else:
self.logger.warning(f"Failed to insert trophy {id}")
async def read_character(self, chara_dir: str, dds_images: dict) -> None:
for root, dirs, files in walk(chara_dir):
for dir in dirs:
if path.exists(f"{root}/{dir}/Chara.xml"):
with open(f"{root}/{dir}/Chara.xml", "r", encoding='utf-8') as fp:
strdata = fp.read()
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
id = name.find("id").text
name = name.find("str").text
sortName = xml_root.find("sortName").text
for work in xml_root.findall("works"):
worksName = work.find("str").text
rareType = xml_root.find("rareType").text
defaultHave = xml_root.find("defaultHave").text == 'true'
disableFlag = xml_root.find("disableFlag") # may not exist in older data
is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False
# character images are not stored alongside
for image in xml_root.findall("defaultImages"):
imageKey = image.find("str").text
if imageKey in dds_images.keys():
(imageDir, imagePaths) = dds_images[imageKey]
imagePath1 = imagePaths[0] if len(imagePaths) > 0 else ""
imagePath2 = imagePaths[1] if len(imagePaths) > 1 else ""
imagePath3 = imagePaths[2] if len(imagePaths) > 2 else ""
# @note the third image is the image needed for the user box ui
if imagePath3:
self.copy_image(imagePath3, imageDir, "titles/chuni/img/character/")
else:
self.logger.warning(f"Character {id} only has {len(imagePaths)} images. Expected 3")
else:
self.logger.warning(f"Unable to location character {id} images")
result = await self.data.static.put_character(
self.version, id, name, sortName, worksName, rareType, imagePath1, imagePath2, imagePath3, is_enabled, defaultHave
)
if result is not None:
self.logger.info(f"Inserted character {id}")
else:
self.logger.warning(f"Failed to insert character {id}")
async def read_map_icon(self, mapicon_dir: str) -> None:
for root, dirs, files in walk(mapicon_dir):
for dir in dirs:
if path.exists(f"{root}/{dir}/MapIcon.xml"):
with open(f"{root}/{dir}/MapIcon.xml", "r", encoding='utf-8') as fp:
strdata = fp.read()
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
id = name.find("id").text
name = name.find("str").text
sortName = xml_root.find("sortName").text
for image in xml_root.findall("image"):
iconPath = image.find("path").text
self.copy_image(iconPath, f"{root}/{dir}", "titles/chuni/img/mapIcon/")
defaultHave = xml_root.find("defaultHave").text == 'true'
disableFlag = xml_root.find("disableFlag") # may not exist in older data
is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False
result = await self.data.static.put_map_icon(
self.version, id, name, sortName, iconPath, is_enabled, defaultHave
)
if result is not None:
self.logger.info(f"Inserted map icon {id}")
else:
self.logger.warning(f"Failed to map icon {id}")
async def read_system_voice(self, voice_dir: str) -> None:
for root, dirs, files in walk(voice_dir):
for dir in dirs:
if path.exists(f"{root}/{dir}/SystemVoice.xml"):
with open(f"{root}/{dir}/SystemVoice.xml", "r", encoding='utf-8') as fp:
strdata = fp.read()
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
id = name.find("id").text
name = name.find("str").text
sortName = xml_root.find("sortName").text
for image in xml_root.findall("image"):
imagePath = image.find("path").text
self.copy_image(imagePath, f"{root}/{dir}", "titles/chuni/img/systemVoice/")
defaultHave = xml_root.find("defaultHave").text == 'true'
disableFlag = xml_root.find("disableFlag") # may not exist in older data
is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False
result = await self.data.static.put_system_voice(
self.version, id, name, sortName, imagePath, is_enabled, defaultHave
)
if result is not None:
self.logger.info(f"Inserted system voice {id}")
else:
self.logger.warning(f"Failed to system voice {id}")
def copy_image(self, filename: str, src_dir: str, dst_dir: str) -> None:
# Convert the image to png so we can easily display it in the frontend
file_src = path.join(src_dir, filename)
(basename, ext) = path.splitext(filename)
file_dst = path.join(dst_dir, basename) + ".png"
if path.exists(file_src) and not path.exists(file_dst):
try:
im = Image.open(file_src)
im.save(file_dst)
except Exception:
self.logger.warning(f"Failed to convert {filename} to png")
def map_dds_images(self, image_dict: dict, dds_dir: str) -> None:
for root, dirs, files in walk(dds_dir):
for dir in dirs:
directory = f"{root}/{dir}"
if path.exists(f"{directory}/DDSImage.xml"):
with open(f"{directory}/DDSImage.xml", "r", encoding='utf-8') as fp:
strdata = fp.read()
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
name = name.find("str").text
images = []
i = 0
while xml_root.findall(f"ddsFile{i}"):
for ddsFile in xml_root.findall(f"ddsFile{i}"):
images += [ddsFile.find("path").text]
i += 1
image_dict[name] = (directory, images)

View File

@ -1,6 +1,6 @@
from titles.chuni.schema.profile import ChuniProfileData
from titles.chuni.schema.score import ChuniScoreData, ChuniRomVersion
from titles.chuni.schema.score import ChuniScoreData
from titles.chuni.schema.item import ChuniItemData
from titles.chuni.schema.static import ChuniStaticData
__all__ = ["ChuniProfileData", "ChuniScoreData", "ChuniRomVersion", "ChuniItemData", "ChuniStaticData"]
__all__ = ["ChuniProfileData", "ChuniScoreData", "ChuniItemData", "ChuniStaticData"]

View File

@ -1,22 +1,22 @@
from typing import Dict, List, Optional
from sqlalchemy import (
Column,
PrimaryKeyConstraint,
Table,
Column,
UniqueConstraint,
PrimaryKeyConstraint,
and_,
delete,
)
from sqlalchemy.dialects.mysql import insert
from sqlalchemy.engine import Row
from sqlalchemy.types import Integer, String, TIMESTAMP, Boolean, JSON
from sqlalchemy.engine.base import Connection
from sqlalchemy.schema import ForeignKey
from sqlalchemy.sql import func, select
from sqlalchemy.types import JSON, TIMESTAMP, Boolean, Integer, String
from sqlalchemy.dialects.mysql import insert
from sqlalchemy.engine import Row
from core.data.schema import BaseData, metadata
character: Table = Table(
character = Table(
"chuni_item_character",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
@ -40,7 +40,7 @@ character: Table = Table(
mysql_charset="utf8mb4",
)
item: Table = Table(
item = Table(
"chuni_item_item",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
@ -141,7 +141,7 @@ gacha = Table(
mysql_charset="utf8mb4",
)
print_state: Table = Table(
print_state = Table(
"chuni_item_print_state",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
@ -210,7 +210,7 @@ login_bonus = Table(
mysql_charset="utf8mb4",
)
favorite: Table = Table(
favorite = Table(
"chuni_item_favorite",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
@ -243,36 +243,6 @@ matching = Table(
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):
async def get_oldest_free_matching(self, version: int) -> Optional[Row]:
@ -359,48 +329,17 @@ class ChuniItemData(BaseData):
return None
return result.lastrowid
async def is_favorite(
self, user_id: int, version: int, fav_id: int, fav_kind: int = 1
) -> bool:
async def get_all_favorites(
self, user_id: int, version: int, fav_kind: int = 1
) -> Optional[List[Row]]:
sql = favorite.select(
and_(
favorite.c.version == version,
favorite.c.user == user_id,
favorite.c.favId == fav_id,
favorite.c.favKind == fav_kind,
)
)
result = await self.execute(sql)
if result is None:
return False
return True if len(result.all()) else False
async def get_all_favorites(
self,
user_id: int,
version: int,
fav_kind: int = 1,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> Optional[List[Row]]:
sql = select(favorite).where(
and_(
favorite.c.version == version,
favorite.c.user == user_id,
favorite.c.favKind == fav_kind,
)
)
if limit is not None or offset is not None:
sql = sql.order_by(favorite.c.id)
if limit is not None:
sql = sql.limit(limit)
if offset is not None:
sql = sql.offset(offset)
result = await self.execute(sql)
if result is None:
return None
@ -452,31 +391,6 @@ class ChuniItemData(BaseData):
return None
return result.fetchone()
async def put_favorite_music(self, user_id: int, version: int, music_id: int) -> Optional[int]:
sql = insert(favorite).values(user=user_id, version=version, favId=music_id, favKind=1)
conflict = sql.on_duplicate_key_update(user=user_id, version=version, favId=music_id, favKind=1)
result = await self.execute(conflict)
if result is None:
return None
return result.lastrowid
async def delete_favorite_music(self, user_id: int, version: int, music_id: int) -> Optional[int]:
sql = delete(favorite).where(
and_(
favorite.c.user==user_id,
favorite.c.version==version,
favorite.c.favId==music_id,
favorite.c.favKind==1
)
)
result = await self.execute(sql)
if result is None:
return None
return result.lastrowid
async def put_character(self, user_id: int, character_data: Dict) -> Optional[int]:
character_data["user"] = user_id
@ -500,18 +414,9 @@ class ChuniItemData(BaseData):
return None
return result.fetchone()
async def get_characters(
self, user_id: int, limit: Optional[int] = None, offset: Optional[int] = None
) -> Optional[List[Row]]:
async def get_characters(self, user_id: int) -> Optional[List[Row]]:
sql = select(character).where(character.c.user == user_id)
if limit is not None or offset is not None:
sql = sql.order_by(character.c.id)
if limit is not None:
sql = sql.limit(limit)
if offset is not None:
sql = sql.offset(offset)
result = await self.execute(sql)
if result is None:
return None
@ -530,26 +435,13 @@ class ChuniItemData(BaseData):
return None
return result.lastrowid
async def get_items(
self,
user_id: int,
kind: Optional[int] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> Optional[List[Row]]:
cond = item.c.user == user_id
if kind is not None:
cond &= item.c.itemKind == kind
sql = select(item).where(cond)
if limit is not None or offset is not None:
sql = sql.order_by(item.c.id)
if limit is not None:
sql = sql.limit(limit)
if offset is not None:
sql = sql.offset(offset)
async def get_items(self, user_id: int, kind: int = None) -> Optional[List[Row]]:
if kind is None:
sql = select(item).where(item.c.user == user_id)
else:
sql = select(item).where(
and_(item.c.user == user_id, item.c.itemKind == kind)
)
result = await self.execute(sql)
if result is None:
@ -611,8 +503,8 @@ class ChuniItemData(BaseData):
return None
return result.lastrowid
async def get_map_areas(self, user_id: int, map_area_ids: List[int]) -> Optional[List[Row]]:
sql = select(map_area).where(map_area.c.user == user_id, map_area.c.mapAreaId.in_(map_area_ids))
async def get_map_areas(self, user_id: int) -> Optional[List[Row]]:
sql = select(map_area).where(map_area.c.user == user_id)
result = await self.execute(sql)
if result is None:
@ -643,26 +535,15 @@ class ChuniItemData(BaseData):
return result.lastrowid
async def get_user_print_states(
self,
aime_id: int,
has_completed: bool = False,
limit: Optional[int] = None,
offset: Optional[int] = None,
self, aime_id: int, has_completed: bool = False
) -> Optional[List[Row]]:
sql = select(print_state).where(
sql = print_state.select(
and_(
print_state.c.user == aime_id,
print_state.c.hasCompleted == has_completed,
)
)
if limit is not None or offset is not None:
sql = sql.order_by(print_state.c.id)
if limit is not None:
sql = sql.limit(limit)
if offset is not None:
sql = sql.offset(offset)
result = await self.execute(sql)
if result is None:
return None
@ -713,66 +594,3 @@ class ChuniItemData(BaseData):
)
return None
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()

Some files were not shown because too many files have changed in this diff Show More