diff --git a/.gitignore b/.gitignore index 7d6a9df..63ec578 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ latest version/config.py # song data latest version/database/songs/ +latest version/database/bundle/ + +!latest version/database/bundle/README.md + +# backup +latest version/database/backup/ diff --git a/README.md b/README.md index 22185ea..0c659b3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Arcaea-server -一个微型的Arcaea本地服务器 A small local server for Arcaea +一个微型的 Arcaea 本地服务器 A small local server for Arcaea ## 简介 Introduction -这是基于Python以及Flask的微型本地Arcaea服务器,可以模拟游戏的主要功能。这可能是我第一次写这种大程序,若有不妥之处,敬请谅解。 +这是基于 Python 以及 Flask 的微型本地 Arcaea 服务器,可以模拟游戏的主要功能。这可能是我第一次写这种大程序,若有不妥之处,敬请谅解。 本程序主要用于学习研究,不得用于任何商业行为,否则后果自负,这不是强制要求,只是一个提醒与警告。 @@ -26,16 +26,17 @@ This procedure is mainly used for study and research, and shall not be used for - 多设备自动封号 Auto-ban of multiple devices - :warning: 多设备登录 Multi device login - 登录频次限制 Login rate limit - - :x: 销号 Destroy account + - 注册频次限制 Register rate limit + - :warning: 销号 Destroy account - 成绩上传 Score upload - 成绩校验 Score check - 成绩排名 Score rank - 潜力值机制 Potential - Best 30 - :warning: Recent Top 10 -- :warning: 世界排名 Global rank +- 世界排名 Global rank - 段位系统 Course system -- :warning: Link Play +- :warning: Link Play 2.0 - 好友系统 Friends - :x: 好友位提升 Max friend number increase - 云端存档 Cloud save @@ -48,14 +49,17 @@ This procedure is mainly used for study and research, and shall not be used for - :x: 加密下载 Encrypted downloading - 下载校验 Download check - 下载频次限制 Download rate limit +- 内容捆绑包热更新 Content bundle hot update - 购买系统 Purchase system - 单曲和曲包 Single & Pack - - :x: 捆绑包 Bundle + - :x: 捆绑包 Pack bundle - 折扣 Discount - 五周年兑换券 5-th anniversary ticket + - 单曲兑换券 Pick ticket - :x: Extend 包自动降价 Extend pack automatic price reduction - 奖励系统 Present system - 兑换码系统 Redeem code system +- 新手任务 Missions - 角色系统 Character system - 数据记录 Data recording - 用户成绩 Users' scores @@ -74,7 +78,6 @@ It is just so interesting. What it can do is under exploration. [这里 Here](https://github.com/Lost-MSth/Arcaea-server/releases) [Arcaea-CN official](https://arcaea.lowiro.com/zh) -[Arcaea-Konmai Academy](https://616.sb) ## 更新日志 Update log @@ -83,62 +86,118 @@ It is just so interesting. What it can do is under exploration. > 提醒:更新时请注意保留原先的数据库,以防数据丢失。 > Tips: When updating, please keep the original database in case of data loss. > -> 其它小改动请参考各个 commit 信息 +> 其它小改动请参考各个 commit 信息。 > Please refer to the commit messages for other minor changes. -### Version 2.11.3 - -> v2.11.2.1 ~ v2.11.2.7 for Arcaea 4.5.0 ~ 5.2.0 - -- 适用于 Arcaea 5.2.0 版本 - For Arcaea 5.2.0 -- 新搭档 **Ilith & Ivy**、**Hikari & Vanessa**、**摩耶**、**露恩** 已解锁(注意“ 洞烛(至高:第八探索者)”因客户端因素不可选用) - Unlock the character **Ilith & Ivy**, **Hikari & Vanessa**, **Maya**, and **Luin**. (Note that "Insight(Ascendant - 8th Seeker)" is not available due to the client.) -- 为以上角色的技能提供服务端支持 - Provide server-side support for the skills of the above characters. -- 设置中新增可选选项 `DOWNLOAD_FORBID_WHEN_NO_ITEM` 使得当 `songlist` 文件存在时,没有购买的用户无法下载曲目文件(实验性) - An option `DOWNLOAD_FORBID_WHEN_NO_ITEM` has been added to the config file to make that users cannot download the songs' files if they has not bought them when the `songlist` file exists. (Experimental) -- 支持文件 `video_720.mp4` & `video_1080.mp4` 的下载 - Add support for downloading `video_720.mp4` & `video_1080.mp4`. -- 在存档全解锁和 `songlist` 解析器中支持更多东西,以适应游戏更新 - Support more things in full cloud save unlocking and `songlist` parser, to adapt to game updates. -- Link Play 拥有更详细的控制台日志了 - More detailed console log for Link Play. -- 修复一些搭档的技能在世界模式进度中显示不正确的问题 - Fix a bug that some characters' skill cannot display proper values in world mode progression. -- 修复技能 "skill_mithra" 导致了 `prog` 值增加而不是世界模式进度增加的问题 - Fix a bug that "skill_mithra" results in adding `prog` value instead of world mode progress. -- 重构 Link Play TCP 数据交换部分,以获得更好的安全性和扩展性 - Code refactor of Link Play TCP data transmission for better security and scalability. -- 新增一个 HTTP API 用来获取 Link Play 中当前的房间和玩家信息 - Add an HTTP API endpoint for getting the information of current rooms and players in Link Play. +### Version 2.12.0 + +> v2.11.3.1 ~ v2.11.3.20 for Arcaea 5.2.0 ~ 5.10.4 +> +> Here are not some bug fixes. +> +> 注意:Link Play 2.0 无法兼容旧版本客户端。 Note: Link Play 2.0 is not compatible with older client versions. + +- 适用于 Arcaea 5.10.4 版本 + For Arcaea 5.10.4 +- 添加一些新搭档和搭档的觉醒形态,并支持他们的技能 + Add some new partners, uncap some others, and add support for their skills. +- 支持 Link Play 2.0 的几乎所有功能 + Add almost whole support for Link Play 2.0. +- 支持新谱面难度 ETR + Adapt to the new difficulty ETR. +- 支持内容捆绑包(热更新),包含两种更新模式 + Add support for content bundles (hot update), including two update modes. +- 支持新手任务系统 + Add support for missions. +- 更新 Recent 30 机制,修改其表结构 + Update Recent 30 mechanism. Alter Recent 30 table structure. +- PTT 机制更新:添加了推分保护 + PTT mechanism: Change first play protection to new best protection. +- 调整世界排名机制使其更接近于官服 + Adjust world rank mechanism to be closer to the official one. +- 重构世界模式,并调整了一些搭档的技能效果和进度计算逻辑 + Code refactor for World Mode, and adjust some skills and the logic of progress calculation. +- 支持世界模式的陷落梯子 + Add support for Breached World Map. +- 添加了一个陷落梯子例子(#148) + Add an example breached map. (#148) +- 变更残片购买体力的恢复时间为 23 小时 + Change the recover time of using fragments buying stamina to 23 hours. +- 支持设置多个可使用的和旧的游戏 API 前缀,其中旧的前缀会通知用户更新客户端 + Add some endpoints for old API prefixes to notify users to update the client; add support for multiple game prefixes. +- 支持用户自销毁账号(默认不开启) + Add support for users destroy their own accounts. (default unable) +- 添加对“单曲兑换券”的不完整支持 + Incomplete support for "pick_ticket". +- 世界模式地图文件夹中可以包含子文件夹了 + Make the world maps' folder can have sub folders. +- 支持后台和 API 刷新 Recent 30 的定数评分 + Add support for refreshing ratings of Recent 30 via API and webpage. +- 添加对 IP 及设备的用户注册频率限制 + Add the IP and the device rate limiters for user register. +- 修复当用户再次通过已经通过的段位时无法正常上传分数的问题(by Guzi422) + Fix the bug that the player cannot upload the score when completing a course again. (by Guzi422) +- 修复段位模式最高分在用户未完整完成挑战时不更新的逻辑问题 + Fix a logical bug that the course's high score will not update if the user does not complete the whole course challenge. +- 修复 Link Play 相关 API 接口报错的问题 + Fix a bug that API for Link Play cannot work. +- 修复依赖问题:cryptography >= 35.0.0 + Fix requirements: cryptography >= 35.0.0 +- 修复 `songlist` 解析问题(#156) + Fix a `songlist` parser problem. (#156) +- 修复技能 skill_amane 在世界地图台阶类型为空时报错的问题 + Fix a bug that "skill_amane" may arise error when the step type of world map is null. +- 支持自动添加搭档“光 & 对立 (Reunion)”和“光 (Fatalis)”,以尝试解决最终章的解锁问题(#110 #164) + Add support for automatically adding partner "Hikari & Tairitsu (Reunion)" and "Hikari (Fatalis)", to try to unlock Finale stories correctly. (#110 #164) +- 修复 `songlist` 文件存在时视频文件无法下载的问题(#177) + Fix a bug that the video files cannot be downloaded when +the `songlist` file exists. (#177) +- 修复 Link Play 中玩家全部返回房间后上一首曲子成绩消失的问题 + Fix a bug that the last song's scores will disappear when all players return to room in Link Play. +- 工具 `update_song.py` 支持 ETR 难度 + Add support for ETR difficulties in the `update_song.py` tool. +- 添加发送错误信息的小工具测试服务端 + Add a small tool test server to send error message. ## 运行环境与依赖 Running environment and requirements - Windows / Linux / Mac OS / Android... - Python >= 3.6 - Flask >= 2.0 - - Cryptography >= 3.0.0 + - Cryptography >= 35.0.0 - limits >= 2.7.0 - Charles, IDA, proxy app... (optional) +## 子项目 Sub repositories + +[Arcaea-Server-Wiki](https://arcaea.lost-msth.cn/Arcaea-Server/) +: 项目文档 Project documentation + +[Arcaea-Bundler](https://github.com/Lost-MSth/Arcaea-Bundler) +: 用于生成和解包内容捆绑包 Used to pack or unpack content bundles + +[Arcaea-Server-Frontend](https://github.com/Lost-MSth/arcaea_server_frontend) +: In building + +## 旧的说明 Old wiki + -## 使用说明 Instruction for use +### 使用说明 Instruction for use [中文](https://github.com/Lost-MSth/Arcaea-server/wiki/%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E) [English](https://github.com/Lost-MSth/Arcaea-server/wiki/Instruction-for-use) -## 注意 Attentions +### 注意 Attentions [中文](https://github.com/Lost-MSth/Arcaea-server/wiki/%E6%B3%A8%E6%84%8F) [English](https://github.com/Lost-MSth/Arcaea-server/wiki/Attentions) -## Q&A +### Q&A [中文 / English](https://github.com/Lost-MSth/Arcaea-server/wiki/Q&A) @@ -147,6 +206,7 @@ It is just so interesting. What it can do is under exploration. 歌曲数据库来自 Using song database from ~~[BotArcAPI releases](https://github.com/TheSnowfield/BotArcAPI/releases)~~ [ArcaeaSongDatabase](https://github.com/Arcaea-Infinity/ArcaeaSongDatabase) +[ArcaeaSongDatabase Fork](https://github.com/CuSO4Deposit/ArcaeaSongDatabase) > 从v2.9开始不再提供歌曲数据 > Since v2.9, song data will not be provided. @@ -156,7 +216,7 @@ It is just so interesting. What it can do is under exploration. ## 联系方式 Contact 如有必要,可以联系本人 Contact me if necessary -邮箱 Email:th84292@foxmail.com +邮箱 Email:arcaea@lost-msth.cn ## 支持一下 Support me diff --git a/latest version/api/api_code.py b/latest version/api/api_code.py index bf7a807..2a59e37 100644 --- a/latest version/api/api_code.py +++ b/latest version/api/api_code.py @@ -32,6 +32,7 @@ -210: 'Username exists', -211: 'Email address exists', -212: 'User code exists', + -213: 'Too many register attempts', -999: 'Unknown error' } diff --git a/latest version/api/songs.py b/latest version/api/songs.py index 23b2856..cab1ec3 100644 --- a/latest version/api/songs.py +++ b/latest version/api/songs.py @@ -63,7 +63,7 @@ def songs_get(data, user): '''查询全歌曲信息''' A = ['song_id', 'name'] B = ['song_id', 'name', 'rating_pst', - 'rating_prs', 'rating_ftr', 'rating_byn'] + 'rating_prs', 'rating_ftr', 'rating_byn', 'rating_etr'] with Connect() as c: query = Query(A, A, B).from_dict(data) x = Sql(c).select('chart', query=query) @@ -97,8 +97,8 @@ def songs_post(data, user): @api_try def songs_song_difficulty_rank_get(data, user, song_id, difficulty): '''查询歌曲某个难度的成绩排行榜,和游戏内接口相似,只允许limit''' - if difficulty not in [0, 1, 2, 3]: - raise InputError('Difficulty must be 0, 1, 2 or 3') + if difficulty not in [0, 1, 2, 3, 4]: + raise InputError('Difficulty must be 0, 1, 2, 3 or 4') limit = data.get('limit', 20) if not isinstance(limit, int): raise InputError('Limit must be int') diff --git a/latest version/core/bundle.py b/latest version/core/bundle.py new file mode 100644 index 0000000..265c110 --- /dev/null +++ b/latest version/core/bundle.py @@ -0,0 +1,254 @@ +import json +import os +from functools import lru_cache +from time import time + +from flask import url_for + +from .config_manager import Config +from .constant import Constant +from .error import NoAccess, NoData, RateLimit +from .limiter import ArcLimiter + + +class ContentBundle: + + def __init__(self) -> None: + self.version: str = None + self.prev_version: str = None + self.app_version: str = None + self.uuid: str = None + + self.json_size: int = None + self.bundle_size: int = None + self.json_path: str = None # relative path + self.bundle_path: str = None # relative path + + self.json_url: str = None + self.bundle_url: str = None + + @staticmethod + def parse_version(version: str) -> tuple: + try: + r = tuple(map(int, version.split('.'))) + except AttributeError: + r = (0, 0, 0) + return r + + @property + def version_tuple(self) -> tuple: + return self.parse_version(self.version) + + @classmethod + def from_json(cls, json_data: dict) -> 'ContentBundle': + x = cls() + x.version = json_data['versionNumber'] + x.prev_version = json_data['previousVersionNumber'] + x.app_version = json_data['applicationVersionNumber'] + x.uuid = json_data['uuid'] + if x.prev_version is None: + x.prev_version = '0.0.0' + return x + + def to_dict(self) -> dict: + r = { + 'contentBundleVersion': self.version, + 'appVersion': self.app_version, + 'jsonSize': self.json_size, + 'bundleSize': self.bundle_size, + } + if self.json_url and self.bundle_url: + r['jsonUrl'] = self.json_url + r['bundleUrl'] = self.bundle_url + return r + + def calculate_size(self) -> None: + self.json_size = os.path.getsize(os.path.join( + Constant.CONTENT_BUNDLE_FOLDER_PATH, self.json_path)) + self.bundle_size = os.path.getsize(os.path.join( + Constant.CONTENT_BUNDLE_FOLDER_PATH, self.bundle_path)) + + +class BundleParser: + + # {app_version: [ List[ContentBundle] ]} + bundles: 'dict[str, list[ContentBundle]]' = {} + # {app_version: max bundle version} + max_bundle_version: 'dict[str, str]' = {} + + # {bundle version: [next versions]} 宽搜索引 + next_versions: 'dict[str, list[str]]' = {} + # {(bver, b prev version): ContentBundle} 正向索引 + version_tuple_bundles: 'dict[tuple[str, str], ContentBundle]' = {} + + def __init__(self) -> None: + if not self.bundles: + self.parse() + + def re_init(self) -> None: + self.bundles.clear() + self.max_bundle_version.clear() + self.next_versions.clear() + self.version_tuple_bundles.clear() + self.get_bundles.cache_clear() + self.parse() + + def parse(self) -> None: + for root, dirs, files in os.walk(Constant.CONTENT_BUNDLE_FOLDER_PATH): + for file in files: + if not file.endswith('.json'): + continue + + json_path = os.path.join(root, file) + bundle_path = os.path.join(root, f'{file[:-5]}.cb') + + with open(json_path, 'rb') as f: + data = json.load(f) + + x = ContentBundle.from_json(data) + + x.json_path = os.path.relpath( + json_path, Constant.CONTENT_BUNDLE_FOLDER_PATH) + x.bundle_path = os.path.relpath( + bundle_path, Constant.CONTENT_BUNDLE_FOLDER_PATH) + + x.json_path = x.json_path.replace('\\', '/') + x.bundle_path = x.bundle_path.replace('\\', '/') + + if not os.path.isfile(bundle_path): + raise FileNotFoundError( + f'Bundle file not found: {bundle_path}') + x.calculate_size() + + self.bundles.setdefault(x.app_version, []).append(x) + + self.version_tuple_bundles[(x.version, x.prev_version)] = x + self.next_versions.setdefault( + x.prev_version, []).append(x.version) + + # sort by version + for k, v in self.bundles.items(): + v.sort(key=lambda x: x.version_tuple) + self.max_bundle_version[k] = v[-1].version + + @staticmethod + @lru_cache(maxsize=128) + def get_bundles(app_ver: str, b_ver: str) -> 'list[ContentBundle]': + if Config.BUNDLE_STRICT_MODE: + return BundleParser.bundles.get(app_ver, []) + + k = b_ver if b_ver else '0.0.0' + + target_version = BundleParser.max_bundle_version.get(app_ver, '0.0.0') + if k == target_version: + return [] + + # BFS + q = [[k]] + ans = None + while True: + qq = [] + for x in q: + if x[-1] == target_version: + ans = x + break + for y in BundleParser.next_versions.get(x[-1], []): + if y in x: + continue + qq.append(x + [y]) + + if ans is not None or not qq: + break + q = qq + + if not ans: + raise NoData( + f'No bundles found for app version: {app_ver}, bundle version: {b_ver}', status=404) + + r = [] + for i in range(1, len(ans)): + r.append(BundleParser.version_tuple_bundles[(ans[i], ans[i-1])]) + + return r + + +class BundleDownload: + + limiter = ArcLimiter( + Constant.BUNDLE_DOWNLOAD_TIMES_LIMIT, 'bundle_download') + + def __init__(self, c_m=None): + self.c_m = c_m + + self.client_app_version = None + self.client_bundle_version = None + self.device_id = None + + def set_client_info(self, app_version: str, bundle_version: str, device_id: str = None) -> None: + self.client_app_version = app_version + self.client_bundle_version = bundle_version + self.device_id = device_id + + def get_bundle_list(self) -> list: + bundles: 'list[ContentBundle]' = BundleParser.get_bundles( + self.client_app_version, self.client_bundle_version) + + if not bundles: + return [] + + now = time() + + if Constant.BUNDLE_DOWNLOAD_LINK_PREFIX: + prefix = Constant.BUNDLE_DOWNLOAD_LINK_PREFIX + if prefix[-1] != '/': + prefix += '/' + + def url_func(x): return f'{prefix}{x}' + else: + def url_func(x): return url_for( + 'bundle_download', token=x, _external=True) + + sql_list = [] + r = [] + for x in bundles: + if x.version_tuple <= ContentBundle.parse_version(self.client_bundle_version): + continue + t1 = os.urandom(64).hex() + t2 = os.urandom(64).hex() + + x.json_url = url_func(t1) + x.bundle_url = url_func(t2) + + sql_list.append((t1, x.json_path, now, self.device_id)) + sql_list.append((t2, x.bundle_path, now, self.device_id)) + r.append(x.to_dict()) + + if not sql_list: + return [] + + self.clear_expired_token() + + self.c_m.executemany( + '''insert into bundle_download_token values (?, ?, ?, ?)''', sql_list) + + return r + + def get_path_by_token(self, token: str, ip: str) -> str: + r = self.c_m.execute( + '''select file_path, time, device_id from bundle_download_token where token = ?''', (token,)).fetchone() + if not r: + raise NoAccess('Invalid token.', status=403) + file_path, create_time, device_id = r + + if time() - create_time > Constant.BUNDLE_DOWNLOAD_TIME_GAP_LIMIT: + raise NoAccess('Expired token.', status=403) + + if file_path.endswith('.cb') and not self.limiter.hit(ip): + raise RateLimit( + f'Too many content bundle downloads, IP: {ip}, DeviceID: {device_id}', status=429) + + return file_path + + def clear_expired_token(self) -> None: + self.c_m.execute( + '''delete from bundle_download_token where time < ?''', (int(time() - Constant.BUNDLE_DOWNLOAD_TIME_GAP_LIMIT),)) diff --git a/latest version/core/character.py b/latest version/core/character.py index 72f3e22..66ca7d9 100644 --- a/latest version/core/character.py +++ b/latest version/core/character.py @@ -120,6 +120,18 @@ def __init__(self, c=None) -> None: self.uncap_cores: list = [] self.voice: list = None + @property + def frag_value(self) -> float: + return self.frag.get_value(self.level) + + @property + def prog_value(self) -> float: + return self.prog.get_value(self.level) + + @property + def overdrive_value(self) -> float: + return self.overdrive.get_value(self.level) + @property def skill_id_displayed(self) -> str: return None diff --git a/latest version/core/config_manager.py b/latest version/core/config_manager.py index 1e1dbb4..5b2aaab 100644 --- a/latest version/core/config_manager.py +++ b/latest version/core/config_manager.py @@ -12,10 +12,13 @@ class Config: SONG_FILE_HASH_PRE_CALCULATE = True - GAME_API_PREFIX = '/evolution/23' + GAME_API_PREFIX = '/autumnequinox/33' # str | list[str] + OLD_GAME_API_PREFIX = [] # str | list[str] ALLOW_APPVERSION = [] # list[str] + BUNDLE_STRICT_MODE = True + SET_LINKPLAY_SERVER_AS_SUB_PROCESS = True LINKPLAY_HOST = '0.0.0.0' @@ -41,16 +44,21 @@ class Config: API_TOKEN = '' - DOWNLOAD_LINK_PREFIX = '' + DOWNLOAD_LINK_PREFIX = '' # http(s)://host(:port)/download/ + BUNDLE_DOWNLOAD_LINK_PREFIX = '' # http(s)://host(:port)/bundle_download/ DOWNLOAD_USE_NGINX_X_ACCEL_REDIRECT = False NGINX_X_ACCEL_REDIRECT_PREFIX = '/nginx_download/' + BUNDLE_NGINX_X_ACCEL_REDIRECT_PREFIX = '/nginx_bundle_download/' DOWNLOAD_TIMES_LIMIT = 3000 DOWNLOAD_TIME_GAP_LIMIT = 1000 DOWNLOAD_FORBID_WHEN_NO_ITEM = False + BUNDLE_DOWNLOAD_TIMES_LIMIT = '100/60 minutes' + BUNDLE_DOWNLOAD_TIME_GAP_LIMIT = 3000 + LOGIN_DEVICE_NUMBER_LIMIT = 1 ALLOW_LOGIN_SAME_DEVICE = False ALLOW_BAN_MULTIDEVICE_USER_AUTO = True @@ -70,6 +78,8 @@ class Config: SAVE_FULL_UNLOCK = False + ALLOW_SELF_ACCOUNT_DELETE = False + # ------------------------------------------ # You can change this to make another PTT mechanism. @@ -81,13 +91,21 @@ class Config: WORLD_MAP_FOLDER_PATH = './database/map/' SONG_FILE_FOLDER_PATH = './database/songs/' SONGLIST_FILE_PATH = './database/songs/songlist' + CONTENT_BUNDLE_FOLDER_PATH = './database/bundle/' SQLITE_DATABASE_PATH = './database/arcaea_database.db' SQLITE_DATABASE_BACKUP_FOLDER_PATH = './database/backup/' DATABASE_INIT_PATH = './database/init/' SQLITE_LOG_DATABASE_PATH = './database/arcaea_log.db' + SQLITE_DATABASE_DELETED_PATH = './database/arcaea_database_deleted.db' GAME_LOGIN_RATE_LIMIT = '30/5 minutes' API_LOGIN_RATE_LIMIT = '10/5 minutes' + GAME_REGISTER_IP_RATE_LIMIT = '10/1 day' + GAME_REGISTER_DEVICE_RATE_LIMIT = '3/1 day' + + + NOTIFICATION_EXPIRE_TIME = 3 * 60 * 1000 + class ConfigManager: diff --git a/latest version/core/constant.py b/latest version/core/constant.py index 7726cc2..113ce59 100644 --- a/latest version/core/constant.py +++ b/latest version/core/constant.py @@ -1,6 +1,7 @@ from .config_manager import Config -ARCAEA_SERVER_VERSION = 'v2.11.2.7' +ARCAEA_SERVER_VERSION = 'v2.11.3.20' +ARCAEA_DATABASE_VERSION = 'v2.11.3.19' ARCAEA_LOG_DATBASE_VERSION = 'v1.1' @@ -11,6 +12,7 @@ class Constant: MAX_STAMINA = 12 STAMINA_RECOVER_TICK = 1800000 + FRAGSTAM_RECOVER_TICK = 23 * 3600 * 1000 COURSE_STAMINA_COST = 4 @@ -42,12 +44,17 @@ class Constant: WORLD_MAP_FOLDER_PATH = Config.WORLD_MAP_FOLDER_PATH SONG_FILE_FOLDER_PATH = Config.SONG_FILE_FOLDER_PATH SONGLIST_FILE_PATH = Config.SONGLIST_FILE_PATH + CONTENT_BUNDLE_FOLDER_PATH = Config.CONTENT_BUNDLE_FOLDER_PATH SQLITE_DATABASE_PATH = Config.SQLITE_DATABASE_PATH SQLITE_LOG_DATABASE_PATH = Config.SQLITE_LOG_DATABASE_PATH + SQLITE_DATABASE_DELETED_PATH = Config.SQLITE_DATABASE_DELETED_PATH DOWNLOAD_TIMES_LIMIT = Config.DOWNLOAD_TIMES_LIMIT DOWNLOAD_TIME_GAP_LIMIT = Config.DOWNLOAD_TIME_GAP_LIMIT DOWNLOAD_LINK_PREFIX = Config.DOWNLOAD_LINK_PREFIX + BUNDLE_DOWNLOAD_TIMES_LIMIT = Config.BUNDLE_DOWNLOAD_TIMES_LIMIT + BUNDLE_DOWNLOAD_TIME_GAP_LIMIT = Config.BUNDLE_DOWNLOAD_TIME_GAP_LIMIT + BUNDLE_DOWNLOAD_LINK_PREFIX = Config.BUNDLE_DOWNLOAD_LINK_PREFIX LINKPLAY_UNLOCK_LENGTH = 512 # Units: bytes LINKPLAY_TIMEOUT = 5 # Units: seconds @@ -59,6 +66,13 @@ class Constant: LINKPLAY_TCP_SECRET_KEY = Config.LINKPLAY_TCP_SECRET_KEY LINKPLAY_TCP_MAX_LENGTH = 0x0FFFFFFF + + LINKPLAY_MATCH_GET_ROOMS_INTERVAL = 4 # Units: seconds + LINKPLAY_MATCH_PTT_ABS = [5, 20, 50, 100, 200, 500, 1000, 2000] + LINKPLAY_MATCH_UNLOCK_MIN = [1000, 800, 500, 300, 200, 100, 50, 1] + LINKPLAY_MATCH_TIMEOUT = 15 # Units: seconds + LINKPLAY_MATCH_MEMORY_CLEAN_INTERVAL = 60 # Units: seconds + # Well, I can't say a word when I see this. FINALE_SWITCH = [ (0x0015F0, 0x00B032), (0x014C9A, 0x014408), (0x062585, 0x02783B), diff --git a/latest version/core/course.py b/latest version/core/course.py index cf6c30c..0ebd0e2 100644 --- a/latest version/core/course.py +++ b/latest version/core/course.py @@ -39,7 +39,7 @@ def __init__(self, c=None) -> None: self.requirements: list = [] self.charts: list = [None, None, None, None] - self.items: list = None + self.items: list = [] def to_dict(self) -> dict: if self.course_name is None: @@ -160,13 +160,17 @@ def __init__(self, c=None, user=None) -> None: super().__init__(c) self.user = user - self.is_completed: bool = False + # self.is_completed: bool = False self.high_score: int = None self.best_clear_type: int = None + @property + def is_completed(self) -> bool: + return self.best_clear_type != 0 + def to_dict(self) -> dict: r = super().to_dict() - if self.is_completed is None: + if self.best_clear_type is None: self.select_user_course() r.update({ 'is_completed': self.is_completed, @@ -182,22 +186,16 @@ def select_user_course(self, course_id: str = None) -> None: (self.user.user_id, self.course_id)) x = self.c.fetchone() if x is None: - self.is_completed = False self.high_score = 0 self.best_clear_type = 0 else: - self.is_completed = True self.high_score = x[2] self.best_clear_type = x[3] - def insert_user_course(self) -> None: - self.c.execute('''insert into user_course values (?,?,?,?)''', + def insert_or_update_user_course(self) -> None: + self.c.execute('''insert or replace into user_course values (?,?,?,?)''', (self.user.user_id, self.course_id, self.high_score, self.best_clear_type)) - def update_user_course(self) -> None: - self.c.execute('''update user_course set high_score = ?, best_clear_type = ? where user_id = ? and course_id = ?''', - (self.high_score, self.best_clear_type, self.user.user_id, self.course_id)) - class UserCourseList: ''' @@ -262,15 +260,27 @@ def to_dict(self) -> dict: def update(self) -> None: '''课题模式更新''' + self.select_user_course() + + self.score += self.user_play.score + + flag = False + if self.score > self.high_score: + self.high_score = self.score + flag = True + if self.user_play.health < 0: # 你挂了 self.user_play.course_play_state = 5 self.score = 0 self.clear_type = 0 self.user_play.update_play_state_for_course() + if flag: + self.insert_or_update_user_course() return None + self.user_play.course_play_state += 1 - self.score += self.user_play.score + from .score import Score if Score.get_song_state(self.clear_type) > Score.get_song_state(self.user_play.clear_type): self.clear_type = self.user_play.clear_type @@ -278,24 +288,14 @@ def update(self) -> None: if self.user_play.course_play_state == 4: self.user.select_user_about_stamina() - self.select_course_item() - for i in self.items: - i.user_claim_item(self.user) - - self.select_user_course() if not self.is_completed: - self.high_score = self.score - self.best_clear_type = self.clear_type - self.is_completed = True - self.insert_user_course() - return None + self.select_course_item() + for i in self.items: + i.user_claim_item(self.user) - flag = False - if self.score > self.high_score: - self.high_score = self.score - flag = True if Score.get_song_state(self.clear_type) > Score.get_song_state(self.best_clear_type): self.best_clear_type = self.clear_type flag = True - if flag: - self.update_user_course() + + if flag: + self.insert_or_update_user_course() diff --git a/latest version/core/download.py b/latest version/core/download.py index cc358d6..208d22a 100644 --- a/latest version/core/download.py +++ b/latest version/core/download.py @@ -24,20 +24,12 @@ def get_song_file_md5(song_id: str, file_name: str) -> str: class SonglistParser: '''songlist文件解析器''' - FILE_NAMES = ['0.aff', '1.aff', '2.aff', '3.aff', + FILE_NAMES = ['0.aff', '1.aff', '2.aff', '3.aff', '4.aff', 'base.ogg', '3.ogg', 'video.mp4', 'video_audio.ogg', 'video_720.mp4', 'video_1080.mp4'] has_songlist = False songs: dict = {} # {song_id: value, ...} - # value: bit 76543210 - # 7: video_audio.ogg - # 6: video.mp4 - # 5: 3.ogg - # 4: base.ogg - # 3: 3.aff - # 2: 2.aff - # 1: 1.aff - # 0: 0.aff + # value: bitmap pack_info: 'dict[str, set]' = {} # {pack_id: {song_id, ...}, ...} free_songs: set = set() # {song_id, ...} @@ -55,8 +47,8 @@ def is_available_file(song_id: str, file_name: str) -> bool: # songlist没有,则只限制文件名 return file_name in SonglistParser.FILE_NAMES rule = SonglistParser.songs[song_id] - for i in range(10): - if file_name == SonglistParser.FILE_NAMES[i] and rule & (1 << i) != 0: + for i, v in enumerate(SonglistParser.FILE_NAMES): + if file_name == v and rule & (1 << i) != 0: return True return False @@ -88,10 +80,10 @@ def parse_one(self, song: dict) -> dict: return {} r = 0 if 'remote_dl' in song and song['remote_dl']: - r |= 16 + r |= 32 for i in song.get('difficulties', []): if i['ratingClass'] == 3 and i.get('audioOverride', False): - r |= 32 + r |= 64 r |= 1 << i['ratingClass'] else: if any(i['ratingClass'] == 3 for i in song.get('difficulties', [])): @@ -99,14 +91,14 @@ def parse_one(self, song: dict) -> dict: for extra_file in song.get('additional_files', []): x = extra_file['file_name'] - if x == SonglistParser.FILE_NAMES[6]: - r |= 64 - elif x == SonglistParser.FILE_NAMES[7]: + if x == SonglistParser.FILE_NAMES[7]: r |= 128 elif x == SonglistParser.FILE_NAMES[8]: r |= 256 elif x == SonglistParser.FILE_NAMES[9]: r |= 512 + elif x == SonglistParser.FILE_NAMES[10]: + r |= 1024 return {song['id']: r} diff --git a/latest version/core/error.py b/latest version/core/error.py index 439cdec..40913d3 100644 --- a/latest version/core/error.py +++ b/latest version/core/error.py @@ -101,6 +101,13 @@ def __init__(self, message=None, error_code=108, api_error_code=-999, extra_data super().__init__(message, error_code, api_error_code, extra_data, status) +class LowVersion(ArcError): + '''版本过低''' + + def __init__(self, message=None, error_code=5, api_error_code=-999, extra_data=None, status=403) -> None: + super().__init__(message, error_code, api_error_code, extra_data, status) + + class Timeout(ArcError): '''超时''' pass diff --git a/latest version/core/init.py b/latest version/core/init.py index 9510803..affa6f1 100644 --- a/latest version/core/init.py +++ b/latest version/core/init.py @@ -6,8 +6,9 @@ from time import time from traceback import format_exc +from core.bundle import BundleParser from core.config_manager import Config -from core.constant import ARCAEA_LOG_DATBASE_VERSION, ARCAEA_SERVER_VERSION +from core.constant import ARCAEA_DATABASE_VERSION, ARCAEA_LOG_DATBASE_VERSION from core.course import Course from core.download import DownloadList from core.purchase import Purchase @@ -15,6 +16,7 @@ MemoryDatabase) from core.user import UserRegister from core.util import try_rename +from core.world import MapParser class DatabaseInit: @@ -45,7 +47,7 @@ def table_init(self) -> None: with open(self.sql_path, 'r', encoding='utf-8') as f: self.c.executescript(f.read()) self.c.execute('''insert into config values("version", :a);''', { - 'a': ARCAEA_SERVER_VERSION}) + 'a': ARCAEA_DATABASE_VERSION}) def character_init(self) -> None: '''初始化搭档信息''' @@ -92,6 +94,8 @@ def item_init(self) -> None: ('memory', 'memory', 1)) self.c.execute('''insert into item values(?,?,?)''', ('anni5tix', 'anni5tix', 1)) + self.c.execute('''insert into item values(?,?,?)''', + ('pick_ticket', 'pick_ticket', 1)) with open(self.pack_path, 'rb') as f: self.insert_purchase_item(load(f)) @@ -99,6 +103,9 @@ def item_init(self) -> None: with open(self.single_path, 'rb') as f: self.insert_purchase_item(load(f)) + self.c.execute('''insert into item values(?,?,?)''', # 新手任务奖励曲 + ('innocence', 'world_song', 1)) + def course_init(self) -> None: '''初始化课题信息''' courses = [] @@ -135,8 +142,7 @@ def admin_init(self) -> None: character_id, is_skill_sealed, is_char_uncapped, is_char_uncapped_override, is_hide_rating, favorite_character, max_stamina_notification_enabled, current_map, ticket, prog_boost, email) values(:user_id, :name, :password, :join_date, :user_code, 0, 0, 0, 0, 0, 0, -1, 0, '', :memories, 0, :email) ''', {'user_code': x.user_code, 'user_id': x.user_id, 'join_date': now, 'name': x.name, 'password': '41e5653fc7aeb894026d6bb7b2db7f65902b454945fa8fd65a6327047b5277fb', 'memories': 114514, 'email': x.email}) - self.c.execute('''insert into recent30(user_id) values(:user_id)''', { - 'user_id': x.user_id}) + self.c.execute( '''insert into user_role values(?, "admin")''', (x.user_id,)) @@ -169,7 +175,17 @@ def table_init(self) -> None: with open(self.sql_path, 'r') as f: self.c.executescript(f.read()) self.c.execute( - '''insert into cache values("version", :a, -1);''', {'a': ARCAEA_SERVER_VERSION}) + '''insert into cache values("version", :a, -1);''', {'a': ARCAEA_LOG_DATBASE_VERSION}) + + def init(self) -> None: + with Connect(self.db_path) as c: + self.c = c + self.table_init() + + +class DeletedDatabaseInit(DatabaseInit): + def __init__(self, db_path: str = Config.SQLITE_DATABASE_DELETED_PATH) -> None: + super().__init__(db_path) def init(self) -> None: with Connect(self.db_path) as c: @@ -195,7 +211,7 @@ def check_folder(self, folder_path: str) -> bool: self.logger.warning('Folder `%s` is missing.' % folder_path) return f - def check_update_database(self) -> bool: + def _check_update_database_log(self) -> bool: if not self.check_file(Config.SQLITE_LOG_DATABASE_PATH): # 新建日志数据库 try: @@ -232,66 +248,69 @@ def check_update_database(self) -> bool: f'Failed to update the file `{Config.SQLITE_LOG_DATABASE_PATH}`') return False - if not self.check_file(Config.SQLITE_DATABASE_PATH): + return True + + def _check_update_database_main(self, db_path=Config.SQLITE_DATABASE_PATH, init_class=DatabaseInit) -> bool: + if not self.check_file(db_path): # 新建数据库 try: - self.logger.info( - 'Try to new the file `%s`.' % Config.SQLITE_DATABASE_PATH) - DatabaseInit().init() - self.logger.info( - 'Success to new the file `%s`.' % Config.SQLITE_DATABASE_PATH) + self.logger.info(f'Try to new the file `{db_path}`.') + init_class().init() + self.logger.info(f'Success to new the file `{db_path}`.') except Exception as e: self.logger.error(format_exc()) - self.logger.warning( - 'Failed to new the file `%s`.' % Config.SQLITE_DATABASE_PATH) + self.logger.warning(f'Failed to new the file `{db_path}`.') return False else: # 检查更新 - with Connect() as c: + with Connect(db_path) as c: try: c.execute('''select value from config where id="version"''') x = c.fetchone() except: x = None # 数据库自动更新,不强求 - if not x or x[0] != ARCAEA_SERVER_VERSION: + if not x or x[0] != ARCAEA_DATABASE_VERSION: self.logger.warning( - 'Maybe the file `%s` is an old version.' % Config.SQLITE_DATABASE_PATH) + f'Maybe the file `{db_path}` is an old version. Version: {x[0] if x else "None"}') try: self.logger.info( - 'Try to update the file `%s`.' % Config.SQLITE_DATABASE_PATH) + f'Try to update the file `{db_path}` to version {ARCAEA_DATABASE_VERSION}.') if not os.path.isdir(Config.SQLITE_DATABASE_BACKUP_FOLDER_PATH): os.makedirs(Config.SQLITE_DATABASE_BACKUP_FOLDER_PATH) - backup_path = try_rename(Config.SQLITE_DATABASE_PATH, os.path.join( - Config.SQLITE_DATABASE_BACKUP_FOLDER_PATH, os.path.split(Config.SQLITE_DATABASE_PATH)[-1] + '.bak')) + backup_path = try_rename(db_path, os.path.join( + Config.SQLITE_DATABASE_BACKUP_FOLDER_PATH, os.path.split(db_path)[-1] + '.bak')) try: - copy2(backup_path, Config.SQLITE_DATABASE_PATH) + copy2(backup_path, db_path) except: - copy(backup_path, Config.SQLITE_DATABASE_PATH) + copy(backup_path, db_path) temp_path = os.path.join( - *os.path.split(Config.SQLITE_DATABASE_PATH)[:-1], 'old_arcaea_database.db') + *os.path.split(db_path)[:-1], 'old_arcaea_database.db') if os.path.isfile(temp_path): os.remove(temp_path) - try_rename(Config.SQLITE_DATABASE_PATH, temp_path) + try_rename(db_path, temp_path) - DatabaseInit().init() - self.update_database(temp_path) + init_class().init() + self.update_database(temp_path, db_path) self.logger.info( - 'Success to update the file `%s`.' % Config.SQLITE_DATABASE_PATH) + f'Success to update the file `{db_path}`.') except Exception as e: self.logger.error(format_exc()) self.logger.warning( - 'Fail to update the file `%s`.' % Config.SQLITE_DATABASE_PATH) + f'Fail to update the file `{db_path}`.') return True + def check_update_database(self) -> bool: + return self._check_update_database_main() and self._check_update_database_log() and self._check_update_database_main(Config.SQLITE_DATABASE_DELETED_PATH, DeletedDatabaseInit) + @staticmethod def update_database(old_path: str, new_path: str = Config.SQLITE_DATABASE_PATH) -> None: '''更新数据库,并删除旧文件''' @@ -308,19 +327,42 @@ def update_log_database(old_path: str = Config.SQLITE_LOG_DATABASE_PATH) -> None def check_song_file(self) -> bool: '''检查song有关文件并初始化缓存''' f = self.check_folder(Config.SONG_FILE_FOLDER_PATH) - self.logger.info("Start to initialize song data...") + self.logger.info("Initialize song data...") try: DownloadList.initialize_cache() if not Config.SONG_FILE_HASH_PRE_CALCULATE: self.logger.info('Song file hash pre-calculate is disabled.') - self.logger.info('Complete!') except Exception as e: self.logger.error(format_exc()) - self.logger.warning('Initialization error!') + self.logger.warning('Song data initialization error!') + f = False + return f + + def check_content_bundle(self) -> bool: + '''检查 content bundle 有关文件并初始化缓存''' + f = self.check_folder(Config.CONTENT_BUNDLE_FOLDER_PATH) + self.logger.info("Initialize content bundle data...") + try: + BundleParser() + except Exception as e: + self.logger.error(format_exc()) + self.logger.warning('Content bundle data initialization error!') + f = False + return f + + def check_world_map(self) -> bool: + '''检查 world map 有关文件并初始化缓存''' + f = self.check_folder(Config.WORLD_MAP_FOLDER_PATH) + self.logger.info("Initialize world map data...") + try: + MapParser() + except Exception as e: + self.logger.error(format_exc()) + self.logger.warning('World map data initialization error!') f = False return f def check_before_run(self) -> bool: '''运行前检查,返回布尔值''' MemoryDatabase() # 初始化内存数据库 - return self.check_song_file() & self.check_update_database() + return self.check_song_file() and self.check_content_bundle() and self.check_update_database() and self.check_world_map() diff --git a/latest version/core/item.py b/latest version/core/item.py index 2b83413..24278f4 100644 --- a/latest version/core/item.py +++ b/latest version/core/item.py @@ -1,5 +1,6 @@ from .config_manager import Config -from .error import DataExist, InputError, ItemNotEnough, ItemUnavailable, NoData +from .error import (DataExist, InputError, ItemNotEnough, ItemUnavailable, + NoData) class Item: @@ -89,7 +90,7 @@ def select_user_item(self, user=None): class NormalItem(UserItem): - def __init__(self, c) -> None: + def __init__(self, c=None) -> None: super().__init__() self.c = c @@ -115,7 +116,7 @@ def user_claim_item(self, user): class PositiveItem(UserItem): - def __init__(self, c) -> None: + def __init__(self, c=None) -> None: super().__init__() self.c = c @@ -142,7 +143,7 @@ def user_claim_item(self, user): class ItemCore(PositiveItem): item_type = 'core' - def __init__(self, c, core_type: str = '', amount: int = 0) -> None: + def __init__(self, c=None, core_type: str = '', amount: int = 0) -> None: super().__init__(c) self.is_available = True self.item_id = core_type @@ -220,10 +221,12 @@ def user_claim_item(self, user): class Fragment(UserItem): item_type = 'fragment' - def __init__(self, c) -> None: + def __init__(self, c=None, amount=0) -> None: super().__init__() self.c = c self.is_available = True + self.item_id = self.item_type + self.amount = amount def user_claim_item(self, user): pass @@ -238,12 +241,24 @@ class Anni5tix(PositiveItem): def __init__(self, c) -> None: super().__init__(c) self.is_available = True + self.item_id = self.item_type + self.amount = 1 + + +class PickTicket(PositiveItem): + item_type = 'pick_ticket' + + def __init__(self, c=None) -> None: + super().__init__(c) + self.is_available = True + self.item_id = self.item_type + self.amount = 1 class WorldSong(NormalItem): item_type = 'world_song' - def __init__(self, c) -> None: + def __init__(self, c=None) -> None: super().__init__(c) self.is_available = True @@ -293,8 +308,10 @@ def user_claim_item(self, user): class Stamina6(UserItem): item_type = 'stamina6' - def __init__(self, c) -> None: + def __init__(self, c=None) -> None: super().__init__(c) + self.item_id = 'stamina6' + self.amount = 1 def user_claim_item(self, user): ''' @@ -307,6 +324,23 @@ def user_claim_item(self, user): user.update_user_one_column('world_mode_locked_end_ts', -1) +class ItemStamina(UserItem): + item_type = 'stamina' + + def __init__(self, c=None, amount=1) -> None: + super().__init__(c) + self.item_id = 'stamina' + self.amount = amount + + def user_claim_item(self, user): + ''' + 新手任务奖励体力 + ''' + user.select_user_about_stamina() + user.stamina.stamina += self.amount + user.stamina.update() + + class ItemFactory: def __init__(self, c=None) -> None: self.c = c @@ -324,6 +358,8 @@ def get_item(self, item_type: str): return Memory(self.c) elif item_type == 'anni5tix': return Anni5tix(self.c) + elif item_type == 'pick_ticket': + return PickTicket(self.c) elif item_type == 'world_song': return WorldSong(self.c) elif item_type == 'world_unlock': diff --git a/latest version/core/linkplay.py b/latest version/core/linkplay.py index 29bccf2..9cc8896 100644 --- a/latest version/core/linkplay.py +++ b/latest version/core/linkplay.py @@ -1,6 +1,8 @@ import socket from base64 import b64decode, b64encode from json import dumps, loads +from threading import RLock +from time import time from core.error import ArcError, Timeout @@ -11,33 +13,17 @@ socket.setdefaulttimeout(Constant.LINKPLAY_TIMEOUT) -def get_song_unlock(client_song_map: dict) -> bytes: +def get_song_unlock(client_song_map: 'dict[str, list]') -> bytes: '''处理可用歌曲bit,返回bytes''' user_song_unlock = [0] * Constant.LINKPLAY_UNLOCK_LENGTH - for i in range(0, Constant.LINKPLAY_UNLOCK_LENGTH*2, 2): - x = 0 - y = 0 - if str(i) in client_song_map: - if client_song_map[str(i)][0]: - x += 1 - if client_song_map[str(i)][1]: - x += 2 - if client_song_map[str(i)][2]: - x += 4 - if client_song_map[str(i)][3]: - x += 8 - if str(i+1) in client_song_map: - if client_song_map[str(i+1)][0]: - y += 1 - if client_song_map[str(i+1)][1]: - y += 2 - if client_song_map[str(i+1)][2]: - y += 4 - if client_song_map[str(i+1)][3]: - y += 8 - - user_song_unlock[i // 2] = y*16 + x + + for k, v in client_song_map.items(): + for i in range(5): + if not v[i]: + continue + index = int(k) * 5 + i + user_song_unlock[index // 8] |= 1 << (index % 8) return bytes(user_song_unlock) @@ -52,6 +38,10 @@ def __init__(self, c=None, user_id=None) -> None: self.__song_unlock: bytes = None self.client_song_map: dict = None + self.last_match_timestamp: int = 0 + self.match_times: int = None # 已匹配次数,减 1 后乘 5 就大致是匹配时间 + self.match_room: Room = None # 匹配到的房间,这个仅用来在两个人同时匹配时使用,一人建房,通知另一个人加入 + def to_dict(self) -> dict: return { 'userId': self.user_id, @@ -71,6 +61,16 @@ def get_song_unlock(self, client_song_map: dict = None) -> bytes: self.client_song_map = client_song_map self.__song_unlock = get_song_unlock(self.client_song_map) + def calc_available_chart_num(self, song_unlock: bytes) -> int: + '''计算交叠后可用谱面数量''' + new_unlock = [i & j for i, j in zip(self.song_unlock, song_unlock)] + s = 0 + for i in range(len(new_unlock)): + for j in range(8): + if new_unlock[i] & (1 << j): + s += 1 + return s + class Room: def __init__(self) -> None: @@ -79,11 +79,14 @@ def __init__(self) -> None: self.song_unlock: bytes = None + self.share_token: str = 'abcde12345' + def to_dict(self) -> dict: return { 'roomId': str(self.room_id), 'roomCode': self.room_code, - 'orderedAllowedSongs': (b64encode(self.song_unlock)).decode() + 'orderedAllowedSongs': (b64encode(self.song_unlock)).decode(), + 'shareToken': self.share_token } @@ -113,7 +116,7 @@ def tcp(data: bytes) -> bytes: raise ArcError( 'Too long body from link play server', status=400) iv = sock.recv(12) - tag = sock.recv(12) + tag = sock.recv(16) ciphertext = sock.recv(cipher_len) received = aes_gcm_128_decrypt( RemoteMultiPlayer.TCP_AES_KEY, b'', iv, ciphertext, tag) @@ -128,7 +131,7 @@ def data_swap(self, data: dict) -> dict: iv, ciphertext, tag = aes_gcm_128_encrypt( self.TCP_AES_KEY, dumps(data).encode('utf-8'), b'') send_data = Constant.LINKPLAY_AUTHENTICATION.encode( - 'utf-8') + len(ciphertext).to_bytes(8, byteorder='little') + iv + tag[:12] + ciphertext + 'utf-8') + len(ciphertext).to_bytes(8, byteorder='little') + iv + tag + ciphertext recv_data = self.tcp(send_data) self.data_recv = loads(recv_data) @@ -142,12 +145,15 @@ def create_room(self, user: 'Player' = None) -> None: '''创建房间''' if user is not None: self.user = user - user.select_user_one_column('name') + user.select_user_about_link_play() self.data_swap({ 'endpoint': 'create_room', 'data': { 'name': self.user.name, - 'song_unlock': b64encode(self.user.song_unlock).decode('utf-8') + 'song_unlock': b64encode(self.user.song_unlock).decode('utf-8'), + 'rating_ptt': self.user.rating_ptt, + 'is_hide_rating': self.user.is_hide_rating, + 'match_times': self.user.match_times } }) @@ -167,13 +173,16 @@ def join_room(self, room: 'Room' = None, user: 'Player' = None) -> None: if room is not None: self.room = room - self.user.select_user_one_column('name') + self.user.select_user_about_link_play() self.data_swap({ 'endpoint': 'join_room', 'data': { 'name': self.user.name, 'song_unlock': b64encode(self.user.song_unlock).decode('utf-8'), - 'room_code': self.room.room_code + 'room_code': self.room.room_code, + 'rating_ptt': self.user.rating_ptt, + 'is_hide_rating': self.user.is_hide_rating, + 'match_times': self.user.match_times } }) x = self.data_recv['data'] @@ -188,10 +197,14 @@ def update_room(self, user: 'Player' = None) -> None: '''更新房间''' if user is not None: self.user = user + + self.user.select_user_about_link_play() self.data_swap({ 'endpoint': 'update_room', 'data': { - 'token': self.user.token + 'token': self.user.token, + 'rating_ptt': self.user.rating_ptt, + 'is_hide_rating': self.user.is_hide_rating } }) @@ -214,3 +227,133 @@ def get_rooms(self, offset=0, limit=50) -> dict: }) return self.data_recv['data'] + + def select_room(self, room_code: str = None, share_token: str = None) -> dict: + self.data_swap({ + 'endpoint': 'select_room', + 'data': { + 'room_code': room_code, + 'share_token': share_token + } + }) + + return self.data_recv['data'] + + def get_match_rooms(self) -> dict: + '''获取一定数量的公共房间列表''' + self.data_swap({ + 'endpoint': 'get_match_rooms', + 'data': { + 'limit': 100 + } + }) + + return self.data_recv['data'] + + +class MatchStore: + + last_get_rooms_timestamp = 0 + room_cache: 'list[Room]' = [] + + player_queue: 'dict[int, Player]' = {} + + lock = RLock() + + last_memory_clean_timestamp = 0 + + def __init__(self, c=None) -> None: + self.c = c + self.remote = RemoteMultiPlayer() + + def refresh_rooms(self): + now = time() + if now - self.last_get_rooms_timestamp < Constant.LINKPLAY_MATCH_GET_ROOMS_INTERVAL: + return + MatchStore.room_cache = self.remote.get_match_rooms()['rooms'] + MatchStore.last_get_rooms_timestamp = now + + def init_player(self, user: 'Player'): + user.match_times = 0 + MatchStore.player_queue[user.user_id] = user + user.last_match_timestamp = time() + user.c = self.c + user.select_user_about_link_play() + user.c = None + + def clear_player(self, user_id: int): + MatchStore.player_queue.pop(user_id, None) + + def clean_room_cache(self): + MatchStore.room_cache = [] + MatchStore.last_get_rooms_timestamp = 0 + + def memory_clean(self): + now = time() + if now - self.last_memory_clean_timestamp < Constant.LINKPLAY_MEMORY_CLEAN_INTERVAL: + return + with self.lock: + for i in MatchStore.player_queue: + if now - i.last_match_timestamp > Constant.LINKPLAY_MATCH_TIMEOUT: + self.clear_player(i) + + def match(self, user_id: int): + user = MatchStore.player_queue.get(user_id) + if user is None: + raise ArcError( + f'User `{user_id}` not found in match queue.', code=999) + + if user.match_room is not None: + # 二人开新房,第二人加入 + user.c = self.c + self.remote.join_room(user.match_room, user) + self.clear_player(user_id) + return self.remote.to_dict() + + self.refresh_rooms() + + rule = min(user.match_times, len(Constant.LINKPLAY_MATCH_PTT_ABS) - + 1, len(Constant.LINKPLAY_MATCH_UNLOCK_MIN) - 1) + ptt_abs = Constant.LINKPLAY_MATCH_PTT_ABS[rule] + unlock_min = Constant.LINKPLAY_MATCH_UNLOCK_MIN[rule] + + # 加入已有房间 + for i in MatchStore.room_cache: + f = True + num = 0 + for j in i['players']: + if j['player_id'] != 0: + num += 1 + if abs(user.rating_ptt - j['rating_ptt']) >= ptt_abs: + f = False + break + + # 有玩家非正常退房时,next_state_timestamp 不为 0,有概率新玩家进不来,所以使用 num 统计玩家数量 + + if f and user.calc_available_chart_num(b64decode(i['song_unlock'])) >= unlock_min and ((time() + 2) * 1000000 < i['next_state_timestamp'] or i['next_state_timestamp'] <= 0 or num == 1): + room = Room() + room.room_code = i['room_code'] + user.c = self.c + self.remote.join_room(room, user) + self.clean_room_cache() + self.clear_player(user_id) + return self.remote.to_dict() + + now = time() + + # 二人开新房,第一人开房 + for p in MatchStore.player_queue.values(): + if p.user_id == user_id or now - p.last_match_timestamp > Constant.LINKPLAY_MATCH_TIMEOUT: + continue + new_rule = min(rule, p.match_times) + if abs(user.rating_ptt - p.rating_ptt) < Constant.LINKPLAY_MATCH_PTT_ABS[new_rule] and user.calc_available_chart_num(p.song_unlock) >= Constant.LINKPLAY_MATCH_UNLOCK_MIN[new_rule]: + user.c = self.c + self.remote.create_room(user) + self.clear_player(user_id) + p.match_room = self.remote.room + return self.remote.to_dict() + + user.match_times += 1 + user.last_match_timestamp = now + + return None diff --git a/latest version/core/mission.py b/latest version/core/mission.py new file mode 100644 index 0000000..726ca0f --- /dev/null +++ b/latest version/core/mission.py @@ -0,0 +1,240 @@ +from .item import Fragment, ItemCore, ItemStamina, PickTicket, WorldSong + + +class Mission: + mission_id: str = None + items: list = [] + + def __init__(self, c=None): + self.c = c + self.user = None + self._status: int = None + + if self.c is not None: + for i in self.items: + i.c = self.c + + def to_dict(self, has_items=False) -> dict: + r = { + 'mission_id': self.mission_id, + 'status': self.status, + } + if has_items: + r['items'] = [x.to_dict() for x in self.items] + return r + + @property + def status(self) -> str: + if self._status == 1: + return 'inprogress' + elif self._status == 2: + return 'cleared' + elif self._status == 3: + return 'prevclaimedfragmission' + elif self._status == 4: + return 'claimed' + + return 'locked' + + def user_claim_mission(self, user): + # param: user - User 类或子类的实例 + if user is not None: + self.user = user + + self.c.execute('''insert or replace into user_mission (user_id, mission_id, status) values (?, ?, 4)''', + (self.user.user_id, self.mission_id)) + for i in self.items: + i.user_claim_item(self.user) + self._status = 4 + + def user_clear_mission(self, user): + # param: user - User 类或子类的实例 + if user is not None: + self.user = user + + self.c.execute('''insert or replace into user_mission (user_id, mission_id, status) values (?, ?, 2)''', + (self.user.user_id, self.mission_id)) + self._status = 2 + + def select_user_mission(self, user): + # param: user - User 类或子类的实例 + if user is not None: + self.user = user + + self._status = 0 + self.c.execute('''select status from user_mission where user_id=? and mission_id=?''', + (self.user.user_id, self.mission_id)) + x = self.c.fetchone() + + if x and x[0]: + self._status = x[0] + + +class M11(Mission): + mission_id = 'mission_1_1_tutorial' + items = [Fragment(amount=10)] + + +class M12(Mission): + mission_id = 'mission_1_2_clearsong' + items = [Fragment(amount=10)] + + +class M13(Mission): + mission_id = 'mission_1_3_settings' + items = [Fragment(amount=10)] + + +class M14(Mission): + mission_id = 'mission_1_4_allsongsview' + items = [Fragment(amount=10)] + + +class M15(Mission): + mission_id = 'mission_1_5_fragunlock' + items = [ItemCore(core_type='core_generic', amount=1)] + + +class M1E(Mission): + mission_id = 'mission_1_end' + items = [Fragment(amount=100)] + + +class M21(Mission): + mission_id = 'mission_2_1_account' + items = [Fragment(amount=20)] + + +class M22(Mission): + mission_id = 'mission_2_2_profile' + items = [Fragment(amount=20)] + + +class M23(Mission): + mission_id = 'mission_2_3_partner' + items = [Fragment(amount=20)] + + +class M24(Mission): + mission_id = 'mission_2_4_usestamina' + items = [ItemCore(core_type='core_generic', amount=1)] + + +class M25(Mission): + mission_id = 'mission_2_5_prologuestart' + items = [ItemCore(core_type='core_generic', amount=1)] + + +class M2E(Mission): + mission_id = 'mission_2_end' + items = [ItemCore(core_type='core_generic', amount=3)] + + +class M31(Mission): + mission_id = 'mission_3_1_prsclear' + items = [Fragment(amount=50)] + + +class M32(Mission): + mission_id = 'mission_3_2_etherdrop' + items = [ItemStamina(amount=2)] + + +class M33(Mission): + mission_id = 'mission_3_3_step50' + items = [Fragment(amount=50)] + + +class M34(Mission): + mission_id = 'mission_3_4_frag60' + items = [ItemStamina(amount=2)] + + +class M3E(Mission): + mission_id = 'mission_3_end' + items = [ItemStamina(amount=6)] + + +class M41(Mission): + mission_id = 'mission_4_1_exgrade' + items = [Fragment(amount=100)] + + +class M42(Mission): + mission_id = 'mission_4_2_potential350' + items = [ItemStamina(amount=2)] + + +class M43(Mission): + mission_id = 'mission_4_3_twomaps' + items = [Fragment(amount=100)] + + +class M44(Mission): + mission_id = 'mission_4_4_worldsongunlock' + items = [ItemCore(core_type='core_generic', amount=3)] + + +class M45(Mission): + mission_id = 'mission_4_5_prologuefinish' + items = [ItemStamina(amount=2)] + + +_innocence = WorldSong() +_innocence.amount = 1 +_innocence.item_id = 'innocence' + + +class M4E(Mission): + mission_id = 'mission_4_end' + items = [_innocence] + + +class M51(Mission): + mission_id = 'mission_5_1_songgrouping' + items = [Fragment(amount=50)] + + +class M52(Mission): + mission_id = 'mission_5_2_partnerlv12' + items = [Fragment(amount=250)] + + +class M53(Mission): + mission_id = 'mission_5_3_cores' + items = [ItemCore(core_type='core_generic', amount=3)] + + +class M54(Mission): + mission_id = 'mission_5_4_courseclear' + items = [ItemCore(core_type='core_generic', amount=3)] + + +class M5E(Mission): + mission_id = 'mission_5_end' + items = [PickTicket()] + + +MISSION_DICT = {i.mission_id: i for i in Mission.__subclasses__()} + + +class UserMissionList: + def __init__(self, c=None, user=None): + self.c = c + self.user = user + + self.missions: list = [] + + def select_all(self): + self.missions = [] + self.c.execute('''select mission_id, status from user_mission where user_id=?''', + (self.user.user_id,)) + for i in self.c.fetchall(): + x = MISSION_DICT[i[0]]() + x._status = i[1] + self.missions.append(x) + + return self + + def to_dict_list(self) -> list: + return [i.to_dict() for i in self.missions] diff --git a/latest version/core/notification.py b/latest version/core/notification.py new file mode 100644 index 0000000..df6c2d0 --- /dev/null +++ b/latest version/core/notification.py @@ -0,0 +1,101 @@ +from .config_manager import Config +from .user import User +from .sql import Connect + +from time import time + + +class BaseNotification: + + notification_type = None + + def __init__(self, c_m=None) -> None: + self.receiver = None + self.sender = None + self.timestamp = None + self.content = None + + self.c_m = c_m + + @property + def is_expired(self) -> bool: + now = round(time() * 1000) + return now - self.timestamp > Config.NOTIFICATION_EXPIRE_TIME + + def to_dict(self) -> dict: + raise NotImplementedError() + + def insert(self): + self.receiver.select_user_one_column('mp_notification_enabled', True, bool) + if not self.receiver.mp_notification_enabled: + return + + self.c_m.execute( + '''select max(id) from notification where user_id = ?''', (self.receiver.user_id,)) + x = self.c_m.fetchone() + if x is None or x[0] is None: + x = 0 + else: + x = x[0] + 1 + + self.c_m.execute( + '''insert into notification values (?, ?, ?, ?, ?, ?, ?)''', + (self.receiver.user_id, x, self.notification_type, self.content, + self.sender.user_id, self.sender.name, self.timestamp) + ) + + +class RoomInviteNotification(BaseNotification): + + notification_type = 'room_inv' + + @classmethod + def from_list(cls, l: list, user: User = None) -> 'RoomInviteNotification': + x = cls() + x.sender = User() + x.sender.user_id = l[2] + x.sender.name = l[3] + x.content = l[1] + x.timestamp = l[4] + x.receiver = user + return x + + @classmethod + def from_sender(cls, sender: User, receiver: User, share_token: str, c_m) -> 'RoomInviteNotification': + x = cls() + x.c_m = c_m + x.sender = sender + x.receiver = receiver + x.content = share_token + x.timestamp = round(time() * 1000) + return x + + def to_dict(self) -> dict: + return { + 'sender': self.sender.name, + 'type': self.notification_type, + 'shareToken': self.content, + 'sendTs': self.timestamp + } + + +class NotificationFactory: + def __init__(self, c_m: Connect, user=None): + self.c_m = c_m + self.user = user + + def get_notification(self) -> 'list[BaseNotification]': + r = [] + + self.c_m.execute('''select type, content, sender_user_id, sender_name, timestamp from notification where user_id = ?''', + (self.user.user_id,)) + for i in self.c_m.fetchall(): + x = None + if i[0] == 'room_inv': + x = RoomInviteNotification.from_list(i, self.user) + + if x is not None and not x.is_expired: + r.append(x) + self.c_m.execute( + '''delete from notification where user_id = ?''', (self.user.user_id,)) + return r diff --git a/latest version/core/operation.py b/latest version/core/operation.py index 830a4ef..586188a 100644 --- a/latest version/core/operation.py +++ b/latest version/core/operation.py @@ -1,9 +1,12 @@ +from .bundle import BundleParser +from .constant import Constant from .download import DownloadList from .error import NoData from .save import SaveData from .score import Score from .sql import Connect, Sql from .user import User +from .world import MapParser class BaseOperation: @@ -25,6 +28,7 @@ def run(self, *args, **kwargs) -> None: class RefreshAllScoreRating(BaseOperation): ''' 刷新所有成绩的评分 + 包括 score_v2 ''' _name = 'refresh_all_score_rating' @@ -33,7 +37,7 @@ def run(self): # 但其实还是很慢 with Connect() as c: c.execute( - '''select song_id, rating_pst, rating_prs, rating_ftr, rating_byn from chart''') + '''select song_id, rating_pst, rating_prs, rating_ftr, rating_byn, rating_etr from chart''') x = c.fetchall() songs = [i[0] for i in x] @@ -41,12 +45,12 @@ def run(self): f'''update best_score set rating=0 where song_id not in ({','.join(['?']*len(songs))})''', songs) for i in x: - for j in range(0, 4): - defnum = -10 # 没在库里的全部当做定数-10 + for j in range(0, 5): + defnum = -10 # 没在库里的全部当做定数 -10 if i[j+1] is not None and i[j+1] > 0: defnum = float(i[j+1]) / 10 - c.execute('''select user_id, score from best_score where song_id=:a and difficulty=:b''', { + c.execute('''select user_id, score, shiny_perfect_count, perfect_count, near_count, miss_count from best_score where song_id=:a and difficulty=:b''', { 'a': i[0], 'b': j}) y = c.fetchall() values = [] @@ -54,12 +58,46 @@ def run(self): for k in y: ptt = Score.calculate_rating(defnum, k[1]) ptt = max(ptt, 0) - values.append((ptt,)) + score_v2 = Score.calculate_score_v2( + defnum, k[2], k[3], k[4], k[5]) + values.append((ptt, score_v2,)) where_values.append((k[0], i[0], j)) if values: - Sql(c).update_many('best_score', ['rating'], values, [ + Sql(c).update_many('best_score', ['rating', 'score_v2'], values, [ 'user_id', 'song_id', 'difficulty'], where_values) + # 更新 recent30 + song_defum: 'dict[str, list[int]]' = {} + for i in x: + song_defum[i[0]] = [] + for j in range(0, 5): + defnum = -10 + if i[j+1] is not None and i[j+1] > 0: + defnum = float(i[j+1]) / 10 + song_defum[i[0]].append(defnum) + + users = c.execute('''select user_id from user''').fetchall() + for i in users: + values = [] + where_values = [] + user_id = i[0] + c.execute( + '''select r_index, song_id, difficulty, score from recent30 where user_id = ?''', (user_id,)) + for j in c.fetchall(): + if j[1] in song_defum: + defnum = song_defum[j[1]][j[2]] + else: + defnum = -10 + ptt = Score.calculate_rating(defnum, j[3]) + ptt = max(ptt, 0) + + values.append((ptt,)) + where_values.append((user_id, j[0])) + + if values: + Sql(c).update_many('recent30', ['rating'], values, [ + 'user_id', 'r_index'], where_values) + class RefreshSongFileCache(BaseOperation): ''' @@ -73,6 +111,26 @@ def run(self): DownloadList.initialize_cache() +class RefreshBundleCache(BaseOperation): + ''' + 刷新 bundle 缓存 + ''' + _name = 'refresh_content_bundle_cache' + + def run(self): + BundleParser().re_init() + + +class RefreshWorldMapCache(BaseOperation): + ''' + 刷新 map 缓存 + ''' + _name = 'refresh_world_map_cache' + + def run(self): + MapParser().re_init() + + class SaveUpdateScore(BaseOperation): ''' 云存档更新成绩,是覆盖式更新 @@ -121,11 +179,16 @@ def _one_user_update(self): new_scores = [] for i in save.scores_data: rating = 0 + score_v2 = 0 if i['song_id'] in song_chart_const: - rating = Score.calculate_rating( - song_chart_const[i['song_id']][i['difficulty']] / 10, i['score']) + defnum = song_chart_const[i['song_id'] + ][i['difficulty']] / 10 + rating = Score.calculate_rating(defnum, i['score']) rating = max(rating, 0) + score_v2 = Score.calculate_score_v2( + defnum, i['shiny_perfect_count'], i['perfect_count'], i['near_count'], i['miss_count']) + y = f'{i["song_id"]}{i["difficulty"]}' if y in clear_state: clear_type = clear_state[y] @@ -133,10 +196,10 @@ def _one_user_update(self): clear_type = 0 new_scores.append((self.user.user_id, i['song_id'], i['difficulty'], i['score'], i['shiny_perfect_count'], i['perfect_count'], - i['near_count'], i['miss_count'], i['health'], i['modifier'], i['time_played'], clear_type, clear_type, rating)) + i['near_count'], i['miss_count'], i['health'], i['modifier'], i['time_played'], clear_type, clear_type, rating, score_v2)) c.executemany( - '''insert or replace into best_score values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', new_scores) + '''insert or replace into best_score values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', new_scores) def _all_update(self): with Connect() as c: @@ -157,11 +220,16 @@ def _all_update(self): new_scores = [] for i in save.scores_data: rating = 0 + score_v2 = 0 if i['song_id'] in song_chart_const: - rating = Score.calculate_rating( - song_chart_const[i['song_id']][i['difficulty']] / 10, i['score']) + defnum = song_chart_const[i['song_id'] + ][i['difficulty']] / 10 + rating = Score.calculate_rating(defnum, i['score']) rating = max(rating, 0) + score_v2 = Score.calculate_score_v2( + defnum, i['shiny_perfect_count'], i['perfect_count'], i['near_count'], i['miss_count']) + y = f'{i["song_id"]}{i["difficulty"]}' if y in clear_state: clear_type = clear_state[y] @@ -169,10 +237,10 @@ def _all_update(self): clear_type = 0 new_scores.append((user.user_id, i['song_id'], i['difficulty'], i['score'], i['shiny_perfect_count'], i['perfect_count'], - i['near_count'], i['miss_count'], i['health'], i['modifier'], i['time_played'], clear_type, clear_type, rating)) + i['near_count'], i['miss_count'], i['health'], i['modifier'], i['time_played'], clear_type, clear_type, rating, score_v2)) c.executemany( - '''insert or replace into best_score values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', new_scores) + '''insert or replace into best_score values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', new_scores) class UnlockUserItem(BaseOperation): @@ -245,3 +313,75 @@ def _all_delete(self): with Connect() as c: c.execute( f'''delete from user_item where type in ({','.join(['?'] * len(self.item_types))})''', self.item_types) + + +def _delete_one_table(c, table_name, user_id): + c.execute( + f'''insert into db_deleted.{table_name} select * from {table_name} where user_id = ?''', (user_id,)) + c.execute(f'''delete from {table_name} where user_id = ?''', (user_id,)) + + +class DeleteUserScore(BaseOperation): + ''' + 删除单用户成绩,不包含 recent 数据 + ''' + _name = 'delete_user_score' + + def __init__(self, user=None): + self.user = user + + def set_params(self, user_id: int = None, *args, **kwargs): + if user_id is not None: + self.user = User() + self.user.user_id = int(user_id) + return self + + def run(self): + assert self.user is not None + with Connect() as c: + c.execute('''attach database ? as db_deleted''', + (Constant.SQLITE_DATABASE_DELETED_PATH,)) + _delete_one_table(c, 'best_score', self.user.user_id) + _delete_one_table(c, 'recent30', self.user.user_id) + + +class DeleteOneUser(BaseOperation): + ''' + 删除单用户 + ''' + _name = 'delete_one_user' + + TABLES = ['best_score', 'recent30', 'user_char', 'user_course', 'user_item', + 'user_present', 'user_redeem', 'user_role', 'user_save', 'user_world', 'user'] + + def __init__(self, user=None): + self.user = user + + def set_params(self, user_id: int = None, *args, **kwargs): + if user_id is not None: + self.user = User() + self.user.user_id = int(user_id) + return self + + def run(self): + assert self.user is not None + with Connect() as c: + c.execute('''attach database ? as db_deleted''', + (Constant.SQLITE_DATABASE_DELETED_PATH,)) + + self._clear_login(c) + self._data_save(c) + + def _data_save(self, c): + c.execute( + f'''insert into db_deleted.friend select * from friend where user_id_me = ? or user_id_other = ?''', (self.user.user_id, self.user.user_id)) + c.execute(f'''delete from friend where user_id_me = ? or user_id_other = ?''', + (self.user.user_id, self.user.user_id)) + + [_delete_one_table(c, x, self.user.user_id) for x in self.TABLES] + + def _clear_login(self, c): + c.execute('''delete from login where user_id = ?''', + (self.user.user_id,)) + c.execute('''delete from api_login where user_id = ?''', + (self.user.user_id,)) diff --git a/latest version/core/purchase.py b/latest version/core/purchase.py index a3e5075..bad66bb 100644 --- a/latest version/core/purchase.py +++ b/latest version/core/purchase.py @@ -29,7 +29,7 @@ def __init__(self, c=None, user=None): self.items: list = [] - # TODO: "discount_reason": "extend" + # TODO: "discount_reason": extend, sale @property def price_displayed(self) -> int: @@ -44,6 +44,12 @@ def price_displayed(self) -> int: x.select_user_item(self.user) if x.amount >= 1: return 0 + elif self.discount_reason == 'pick_ticket': + x = ItemFactory(self.c).get_item('pick_ticket') + x.item_id = 'pick_ticket' + x.select_user_item(self.user) + if x.amount >= 1: + return 0 return self.price return self.orig_price @@ -60,7 +66,7 @@ def to_dict(self, has_items: bool = True, show_real_price: bool = True) -> dict: if self.discount_from > 0 and self.discount_to > 0: r['discount_from'] = self.discount_from r['discount_to'] = self.discount_to - if not show_real_price or (self.discount_reason == 'anni5tix' and price == 0): + if not show_real_price or (self.discount_reason in ('anni5tix', 'pick_ticket') and price == 0): r['discount_reason'] = self.discount_reason return r @@ -186,10 +192,10 @@ def buy(self) -> None: raise TicketNotEnough( 'The user does not have enough memories.', -6) - if not(self.orig_price == 0 or self.price == 0 and self.discount_from <= int(time() * 1000) <= self.discount_to): + if not (self.orig_price == 0 or self.price == 0 and self.discount_from <= int(time() * 1000) <= self.discount_to): if price_used == 0: - x = ItemFactory(self.c).get_item('anni5tix') - x.item_id = 'anni5tix' + x = ItemFactory(self.c).get_item(self.discount_reason) + x.item_id = self.discount_reason x.amount = -1 x.user_claim_item(self.user) else: diff --git a/latest version/core/save.py b/latest version/core/save.py index 640ff82..4360c28 100644 --- a/latest version/core/save.py +++ b/latest version/core/save.py @@ -107,6 +107,8 @@ def select_all(self, user) -> None: i['complete'] = 3 elif x[-5:-2] == '109': i['complete'] = 3 + elif x.endswith('112'): + i['complete'] = 999 else: i['complete'] = 1 diff --git a/latest version/core/score.py b/latest version/core/score.py index 5174b62..5b030c8 100644 --- a/latest version/core/score.py +++ b/latest version/core/score.py @@ -11,7 +11,7 @@ from .song import Chart from .sql import Connect, Query, Sql from .util import get_today_timestamp, md5 -from .world import WorldPlay +from .world import BeyondWorldPlay, BreachedWorldPlay, WorldPlay class Score: @@ -30,6 +30,7 @@ def __init__(self) -> None: self.best_clear_type: int = None self.clear_type: int = None self.rating: float = None + self.score_v2: float = None # for `world_rank_score` of global rank def set_score(self, score: int, shiny_perfect_count: int, perfect_count: int, near_count: int, miss_count: int, health: int, modifier: int, time_played: int, clear_type: int): self.score = int(score) if score is not None else 0 @@ -93,7 +94,7 @@ def is_valid(self) -> bool: '''分数有效性检查''' if self.shiny_perfect_count < 0 or self.perfect_count < 0 or self.near_count < 0 or self.miss_count < 0 or self.score < 0 or self.time_played <= 0: return False - if self.song.difficulty not in (0, 1, 2, 3): + if self.song.difficulty not in (0, 1, 2, 3, 4): return False all_note = self.all_note_count @@ -124,12 +125,31 @@ def calculate_rating(defnum: float, score: int) -> float: return ptt + @staticmethod + def calculate_score_v2(defnum: float, shiny_perfect_count: int, perfect_count: int, near_count: int, miss_count: int) -> float: + # 计算score_v2 refer: https://www.bilibili.com/video/BV1ys421u7BY + # 谱面定数小于等于 0 视为 unranked,返回值会为 0 + if not defnum or defnum <= 0: + return 0 + + all_note = perfect_count + near_count + miss_count + if all_note == 0: + return 0 + shiny_ratio = shiny_perfect_count / all_note + score_ratio = (perfect_count + near_count/2) / \ + all_note + shiny_perfect_count / 10000000 + acc_rating = max(0, min(shiny_ratio - 0.9, 0.095)) / 9.5 * 25 + score_rating = max(0, min(score_ratio - 0.99, 0.01)) * 75 + return defnum * (acc_rating + score_rating) + def get_rating_by_calc(self) -> float: - # 通过计算得到本成绩的rating + # 通过计算得到本成绩的 rating & score_v2 if not self.song.defnum: self.song.c = self.c self.song.select() self.rating = self.calculate_rating(self.song.chart_const, self.score) + self.score_v2 = self.calculate_score_v2( + self.song.chart_const, self.shiny_perfect_count, self.perfect_count, self.near_count, self.miss_count) return self.rating def to_dict(self) -> dict: @@ -181,6 +201,7 @@ def from_list(self, x: list) -> 'UserScore': self.set_score(x[3], x[4], x[5], x[6], x[7], x[8], x[9], x[10], x[12]) self.best_clear_type = int(x[11]) self.rating = float(x[13]) + self.score_v2 = float(x[14]) return self @@ -207,7 +228,7 @@ def __init__(self, c=None, user=None) -> None: self.submission_hash: str = None self.beyond_gauge: int = None self.unrank_flag: bool = None - self.first_protect_flag: bool = None + self.new_best_protect_flag: bool = None self.ptt: 'Potential' = None self.is_world_mode: bool = None @@ -245,7 +266,7 @@ def to_dict(self) -> dict: @property def is_protected(self) -> bool: - return self.health == -1 or int(self.score) >= 9800000 or self.first_protect_flag + return self.health == -1 or int(self.score) >= 9800000 or self.new_best_protect_flag @property def is_valid(self) -> bool: @@ -260,7 +281,8 @@ def is_valid(self) -> bool: if songfile_hash and songfile_hash != self.song_hash: return False - x = f'''{self.song_token}{self.song_hash}{self.song.song_id}{self.song.difficulty}{self.score}{self.shiny_perfect_count}{self.perfect_count}{self.near_count}{self.miss_count}{self.health}{self.modifier}{self.clear_type}''' + x = f'''{self.song_token}{self.song_hash}{self.song.song_id}{self.song.difficulty}{self.score}{self.shiny_perfect_count}{ + self.perfect_count}{self.near_count}{self.miss_count}{self.health}{self.modifier}{self.clear_type}''' if self.combo_interval_bonus is not None: if self.combo_interval_bonus < 0 or self.combo_interval_bonus > self.all_note_count / 150: return False @@ -385,53 +407,9 @@ def update_play_state_for_course(self) -> None: (self.course_play_state, self.course_play.score, self.course_play.clear_type, self.song_token)) def clear_play_state(self) -> None: - self.c.execute('''delete from songplay_token where user_id=:a ''', { + self.c.execute('''delete from songplay_token where user_id=:a''', { 'a': self.user.user_id}) - def update_recent30(self) -> None: - '''更新此分数对应用户的recent30''' - old_recent_10 = self.ptt.recent_10 - if self.is_protected: - old_r30 = self.ptt.r30.copy() - old_s30 = self.ptt.s30.copy() - - # 寻找pop位置 - songs = list(set(self.ptt.s30)) - if '' in self.ptt.s30: - r30_id = 29 - else: - n = len(songs) - if n >= 11: - r30_id = 29 - elif self.song.song_id_difficulty not in songs and n == 10: - r30_id = 29 - elif self.song.song_id_difficulty in songs and n == 10: - i = 29 - while self.ptt.s30[i] == self.song.song_id_difficulty and i > 0: - i -= 1 - r30_id = i - elif self.song.song_id_difficulty not in songs and n == 9: - i = 29 - while self.ptt.s30.count(self.ptt.s30[i]) == 1 and i > 0: - i -= 1 - r30_id = i - else: - r30_id = 29 - - self.ptt.recent_30_update( - r30_id, self.rating, self.song.song_id_difficulty) - if self.is_protected and old_recent_10 > self.ptt.recent_10: - if self.song.song_id_difficulty in old_s30: - # 发现重复歌曲,更新到最高rating - index = old_s30.index(self.song.song_id_difficulty) - if old_r30[index] < self.rating: - old_r30[index] = self.rating - - self.ptt.r30 = old_r30 - self.ptt.s30 = old_s30 - - self.ptt.insert_recent_30() - def record_score(self) -> None: '''向log数据库记录分数,请注意列名不同''' logdb_execute('''insert into user_score values(?,?,?,?,?,?,?,?,?,?,?,?,?)''', (self.user.user_id, self.song.song_id, self.song.difficulty, self.time_played, @@ -473,23 +451,25 @@ def upload_score(self) -> None: 'a': self.user.user_id, 'b': self.song.song_id, 'c': self.song.difficulty}) x = self.c.fetchone() if not x: - self.first_protect_flag = True # 初见保护 - self.c.execute('''insert into best_score values(:a,:b,:c,:d,:e,:f,:g,:h,:i,:j,:k,:l,:m,:n)''', { - 'a': self.user.user_id, 'b': self.song.song_id, 'c': self.song.difficulty, 'd': self.score, 'e': self.shiny_perfect_count, 'f': self.perfect_count, 'g': self.near_count, 'h': self.miss_count, 'i': self.health, 'j': self.modifier, 'k': self.time_played, 'l': self.clear_type, 'm': self.clear_type, 'n': self.rating}) + self.new_best_protect_flag = True # 初见保护 + self.c.execute('''insert into best_score values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', + (self.user.user_id, self.song.song_id, self.song.difficulty, self.score, self.shiny_perfect_count, self.perfect_count, self.near_count, self.miss_count, + self.health, self.modifier, self.time_played, self.clear_type, self.clear_type, self.rating, self.score_v2)) self.user.update_global_rank() else: - self.first_protect_flag = False + self.new_best_protect_flag = False if self.song_state > self.get_song_state(int(x[1])): # best状态更新 self.c.execute('''update best_score set best_clear_type = :a where user_id = :b and song_id = :c and difficulty = :d''', { 'a': self.clear_type, 'b': self.user.user_id, 'c': self.song.song_id, 'd': self.song.difficulty}) if self.score >= int(x[0]): # best成绩更新 - self.c.execute('''update best_score set score = :d, shiny_perfect_count = :e, perfect_count = :f, near_count = :g, miss_count = :h, health = :i, modifier = :j, clear_type = :k, rating = :l, time_played = :m where user_id = :a and song_id = :b and difficulty = :c ''', { - 'a': self.user.user_id, 'b': self.song.song_id, 'c': self.song.difficulty, 'd': self.score, 'e': self.shiny_perfect_count, 'f': self.perfect_count, 'g': self.near_count, 'h': self.miss_count, 'i': self.health, 'j': self.modifier, 'k': self.clear_type, 'l': self.rating, 'm': self.time_played}) + self.new_best_protect_flag = True + self.c.execute('''update best_score set score = :d, shiny_perfect_count = :e, perfect_count = :f, near_count = :g, miss_count = :h, health = :i, modifier = :j, clear_type = :k, rating = :l, time_played = :m, score_v2 = :n where user_id = :a and song_id = :b and difficulty = :c ''', { + 'a': self.user.user_id, 'b': self.song.song_id, 'c': self.song.difficulty, 'd': self.score, 'e': self.shiny_perfect_count, 'f': self.perfect_count, 'g': self.near_count, 'h': self.miss_count, 'i': self.health, 'j': self.modifier, 'k': self.clear_type, 'l': self.rating, 'm': self.time_played, 'n': self.score_v2}) self.user.update_global_rank() self.ptt = Potential(self.c, self.user) if not self.unrank_flag: - self.update_recent30() + self.ptt.r30_push_score(self) # 总PTT更新 user_rating_ptt = self.ptt.value @@ -500,7 +480,14 @@ def upload_score(self) -> None: # 世界模式判断 if self.is_world_mode: - self.world_play = WorldPlay(self.c, self.user, self) + self.user.select_user_about_world_play() + self.user.current_map.select_map_info() + if self.user.current_map.is_breached: + self.world_play = BreachedWorldPlay(self.c, self.user, self) + elif self.user.current_map.is_beyond: + self.world_play = BeyondWorldPlay(self.c, self.user, self) + else: + self.world_play = WorldPlay(self.c, self.user, self) self.world_play.update() # 课题模式判断 @@ -519,9 +506,8 @@ def __init__(self, c=None, user=None): self.c = c self.user = user - self.r30: 'list[float]' = None - self.s30: 'list[str]' = None - self.songs_selected: list = None + self.r30_tuples: 'list[tuple[int, str, int, float]]' = None + self.r30: 'list[Score]' = None self.b30: list = None @@ -537,75 +523,122 @@ def best_30(self) -> float: 'a': self.user.user_id}) return sum(x[0] for x in self.c.fetchall()) - def select_recent_30(self) -> None: + def select_recent_30_tuple(self) -> None: '''获取用户recent30数据''' self.c.execute( - '''select * from recent30 where user_id = :a''', {'a': self.user.user_id}) - x = self.c.fetchone() - if not x: - raise NoData( - f'No recent30 data for user `{self.user.user_id}`', api_error_code=-3) + '''select r_index, song_id, difficulty, rating from recent30 where user_id = ? order by time_played DESC''', (self.user.user_id,)) + + self.r30_tuples = [x for x in self.c.fetchall() if x[1] != ''] + + def select_recent_30(self) -> None: + self.c.execute( + '''select song_id, difficulty, score, shiny_perfect_count, perfect_count, near_count, miss_count, health, modifier, time_played, clear_type, rating from recent30 where user_id = ? order by time_played DESC''', (self.user.user_id,)) + self.r30 = [] - self.s30 = [] - if not x: - return None - for i in range(1, 61, 2): - if x[i] is not None: - self.r30.append(float(x[i])) - self.s30.append(x[i+1]) - else: - self.r30.append(0) - self.s30.append('') + for x in self.c.fetchall(): + if x[0] == '': + continue + s = Score() + s.song.set_chart(x[0], x[1]) + s.set_score(*x[2:-1]) + s.rating = x[-1] + self.r30.append(s) @property def recent_10(self) -> float: '''获取用户recent10的总潜力值''' - if self.r30 is None: - self.select_recent_30() + if self.r30_tuples is None: + self.select_recent_30_tuple() - rating_sum = 0 - r30, s30 = (list(t) for t in zip( - *sorted(zip(self.r30, self.s30), reverse=True))) + max_dict = {} + for x in self.r30_tuples: + if (x[1], x[2]) not in max_dict or max_dict[(x[1], x[2])] < x[3]: + max_dict[(x[1], x[2])] = x[3] - self.songs_selected = [] - i = 0 - while len(self.songs_selected) < 10 and i <= 29 and s30[i] != '' and s30[i] is not None: - if s30[i] not in self.songs_selected: - rating_sum += r30[i] - self.songs_selected.append(s30[i]) - i += 1 - return rating_sum + top_10_rating = sorted(max_dict.values(), reverse=True)[:10] + return sum(top_10_rating) def recent_30_to_dict_list(self) -> list: if self.r30 is None: self.select_recent_30() - r = [] - for x, y in zip(self.s30, self.r30): - if x: - r.append({ - 'song_id': x[:-1], - 'difficulty': int(x[-1]), - 'rating': y - }) - return r - def recent_30_update(self, pop_index: int, rating: float, song_id_difficulty: str) -> None: - self.r30.pop(pop_index) - self.s30.pop(pop_index) - self.r30.insert(0, rating) - self.s30.insert(0, song_id_difficulty) + return [x.to_dict() for x in self.r30] + + def update_one_r30(self, r_index: int, user_score: 'UserPlay | UserScore') -> None: + '''更新数据表中的一条数据''' + self.c.execute('''insert or replace into recent30 values(?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', + (self.user.user_id, r_index, user_score.time_played, user_score.song.song_id, user_score.song.difficulty, + user_score.score, user_score.shiny_perfect_count, user_score.perfect_count, user_score.near_count, user_score.miss_count, user_score.health, user_score.modifier, user_score.clear_type, user_score.rating)) + + # 更新内存中的数据 + x = (r_index, user_score.song.song_id, + user_score.song.difficulty, user_score.rating) + if len(self.r30_tuples) < 30: + self.r30_tuples.append(x) + return - def insert_recent_30(self) -> None: - '''更新r30表数据''' - sql = '''update recent30 set r0=?,song_id0=?,r1=?,song_id1=?,r2=?,song_id2=?,r3=?,song_id3=?,r4=?,song_id4=?,r5=?,song_id5=?,r6=?,song_id6=?,r7=?,song_id7=?,r8=?,song_id8=?,r9=?,song_id9=?,r10=?,song_id10=?,r11=?,song_id11=?,r12=?,song_id12=?,r13=?,song_id13=?,r14=?,song_id14=?,r15=?,song_id15=?,r16=?,song_id16=?,r17=?,song_id17=?,r18=?,song_id18=?,r19=?,song_id19=?,r20=?,song_id20=?,r21=?,song_id21=?,r22=?,song_id22=?,r23=?,song_id23=?,r24=?,song_id24=?,r25=?,song_id25=?,r26=?,song_id26=?,r27=?,song_id27=?,r28=?,song_id28=?,r29=?,song_id29=? where user_id=?''' - sql_list = [] for i in range(30): - sql_list.append(self.r30[i]) - sql_list.append(self.s30[i]) + if self.r30_tuples[i][0] == r_index: + self.r30_tuples[i] = x + break - sql_list.append(self.user.user_id) + def r30_push_score(self, user_score: 'UserPlay | UserScore') -> None: + '''根据新成绩调整 r30''' + if self.r30_tuples is None: + self.select_recent_30_tuple() + + if len(self.r30_tuples) < 30: + self.update_one_r30(len(self.r30_tuples), user_score) + return None - self.c.execute(sql, sql_list) + if user_score.is_protected: + # 保护,替换最低的最旧的成绩 + f_tuples = list( + filter(lambda x: x[-1] <= user_score.rating, self.r30_tuples)) + f_tuples.reverse() # 从旧到新 + f_tuples = sorted(f_tuples, key=lambda x: x[-1]) + if not f_tuples: + # 找不到更低的成绩,不更新 + return None + + unique_songs: 'dict[tuple[str, int], list[tuple[int, int, float]]]' = {} + for i, x in enumerate(self.r30_tuples): + unique_songs.setdefault((x[1], x[2]), []).append((i, x[0], x[3])) + + new_song = user_score.song.to_tuple() + + if len(unique_songs) >= 11 or (len(unique_songs) == 10 and new_song not in unique_songs): + if user_score.is_protected: + # 保护,替换最低的最旧的成绩 + self.update_one_r30(f_tuples[0][0], user_score) + else: + self.update_one_r30(self.r30_tuples[-1][0], user_score) + return None + + filtered_songs = dict(filter(lambda x: len( + x[1]) > 1, unique_songs.items())) # 过滤掉只有单个成绩的 + + if new_song in unique_songs and new_song not in filtered_songs: + # 如果新成绩有相同谱面的唯一成绩在 r30 中,则它也应该有可能被替换 + filtered_songs[new_song] = unique_songs[new_song] + + if user_score.is_protected: + # 保护,替换最低的最旧的成绩,此时需在 filtered_songs 中 + for x in f_tuples: + if (x[1], x[2]) in filtered_songs: + self.update_one_r30(x[0], user_score) + return None + else: + # 找到符合条件的最旧成绩 + max_idx = -1 + max_r_index = -1 + for x in filtered_songs.values(): + for y in x: + if y[0] > max_idx: + max_idx = y[0] + max_r_index = y[1] + + self.update_one_r30(max_r_index, user_score) class UserScoreList: diff --git a/latest version/core/song.py b/latest version/core/song.py index 2b391ea..8571a45 100644 --- a/latest version/core/song.py +++ b/latest version/core/song.py @@ -18,6 +18,9 @@ def to_dict(self) -> dict: 'difficulty': self.difficulty, 'chart_const': self.chart_const } + + def to_tuple(self) -> tuple: + return (self.song_id, self.difficulty) @property def chart_const(self) -> float: @@ -33,7 +36,7 @@ def set_chart(self, song_id: str = None, difficulty: int = None) -> None: def select(self) -> None: self.c.execute( - '''select rating_pst, rating_prs, rating_ftr, rating_byn from chart where song_id=:a''', {'a': self.song_id}) + '''select rating_pst, rating_prs, rating_ftr, rating_byn, rating_etr from chart where song_id=:a''', {'a': self.song_id}) x = self.c.fetchone() if x is None: if Config.ALLOW_SCORE_WITH_NO_SONG: @@ -63,19 +66,20 @@ def from_list(self, x: list) -> 'Song': self.song_id = x[0] self.name = x[1] self.charts = [Chart(self.c, self.song_id, 0), Chart(self.c, self.song_id, 1), Chart( - self.c, self.song_id, 2), Chart(self.c, self.song_id, 3)] + self.c, self.song_id, 2), Chart(self.c, self.song_id, 3), Chart(self.c, self.song_id, 4)] self.charts[0].defnum = x[2] self.charts[1].defnum = x[3] self.charts[2].defnum = x[4] self.charts[3].defnum = x[5] + self.charts[4].defnum = x[6] return self def from_dict(self, d: dict) -> 'Song': self.song_id = d['song_id'] self.name = d.get('name', '') self.charts = [Chart(self.c, self.song_id, 0), Chart(self.c, self.song_id, 1), Chart( - self.c, self.song_id, 2), Chart(self.c, self.song_id, 3)] - for i in range(4): + self.c, self.song_id, 2), Chart(self.c, self.song_id, 3), Chart(self.c, self.song_id, 4)] + for i in range(5): self.charts[i].defnum = -10 for chart in d['charts']: self.charts[chart['difficulty']].defnum = round( @@ -89,11 +93,11 @@ def delete(self) -> None: def update(self) -> None: '''全部更新''' self.c.execute( - '''update chart set name=?, rating_pst=?, rating_prs=?, rating_ftr=?, rating_byn=? where song_id=?''', (self.name, self.charts[0].defnum, self.charts[1].defnum, self.charts[2].defnum, self.charts[3].defnum, self.song_id)) + '''update chart set name=?, rating_pst=?, rating_prs=?, rating_ftr=?, rating_byn=?, rating_etr=? where song_id=?''', (self.name, self.charts[0].defnum, self.charts[1].defnum, self.charts[2].defnum, self.charts[3].defnum, self.charts[4].defnum, self.song_id)) def insert(self) -> None: self.c.execute( - '''insert into chart values (?,?,?,?,?,?)''', (self.song_id, self.name, self.charts[0].defnum, self.charts[1].defnum, self.charts[2].defnum, self.charts[3].defnum)) + '''insert into chart values (?,?,?,?,?,?,?)''', (self.song_id, self.name, self.charts[0].defnum, self.charts[1].defnum, self.charts[2].defnum, self.charts[3].defnum, self.charts[4].defnum)) def select_exists(self, song_id: str = None) -> bool: if song_id is not None: diff --git a/latest version/core/sql.py b/latest version/core/sql.py index a19088b..6852427 100644 --- a/latest version/core/sql.py +++ b/latest version/core/sql.py @@ -6,6 +6,7 @@ from .config_manager import Config from .constant import ARCAEA_LOG_DATBASE_VERSION, Constant from .error import ArcError, InputError +from .util import parse_version class Connect: @@ -349,10 +350,20 @@ def get_table_info(self, table_name: str): class DatabaseMigrator: + SPECIAL_UPDATE_VERSION = { + '2.11.3.11': '_version_2_11_3_11', + '2.11.3.13': '_version_2_11_3_13' + } + def __init__(self, c1_path: str, c2_path: str) -> None: self.c1_path = c1_path self.c2_path = c2_path + self.c1 = None + self.c2 = None + + self.tables = Constant.DATABASE_MIGRATE_TABLES + @staticmethod def update_one_table(c1, c2, table_name: str) -> bool: '''从c1向c2更新数据表,c1中存在的信息不变,即c2中的冲突信息会被覆盖''' @@ -397,7 +408,11 @@ def update_database(self) -> None: ''' with Connect(self.c2_path) as c2: with Connect(self.c1_path) as c1: - for i in Constant.DATABASE_MIGRATE_TABLES: + self.c1 = c1 + self.c2 = c2 + self.special_update() + + for i in self.tables: self.update_one_table(c1, c2, i) if not Constant.UPDATE_WITH_NEW_CHARACTER_DATA: @@ -405,6 +420,57 @@ def update_database(self) -> None: self.update_user_char_full(c2) # 更新user_char_full + def special_update(self): + old_version = self.c1.execute( + '''select value from config where id = "version"''').fetchone() + new_version = self.c2.execute( + '''select value from config where id = "version"''').fetchone() + old_version = old_version[0] if old_version else '0.0.0' + new_version = new_version[0] if new_version else '0.0.0' + old_version = parse_version(old_version) + new_version = parse_version(new_version) + + for k, v in self.SPECIAL_UPDATE_VERSION.items(): + if old_version < parse_version(k) <= new_version: + getattr(self, v)() + + def _version_2_11_3_11(self): + ''' + 2.11.3.11 版本特殊更新,调整 recent30 表结构 + recent30 表从 (user_id: int PK, rating: real, song_id: text, ...) \ + 更改为 (user_id: int PK, r_index: int PK, time_played: int, song_id: text, difficulty: int, score: int, sp, p, n, m, hp, mod, clear_type, rating: real) + ''' + + self.tables = [x for x in self.tables if x != 'recent30'] + + x = self.c1.execute('''select * from recent30''') + sql_list = [] + for i in x: + user_id = int(i[0]) + for j in range(30): + rating = i[1 + j * 2] + rating = float(rating) if rating else 0 + song_id_difficulty: str = i[2 + j * 2] + if song_id_difficulty: + song_id = song_id_difficulty[:-1] + difficulty = song_id_difficulty[-1] + difficulty = int(difficulty) if difficulty.isdigit() else 0 + else: + song_id = '' + difficulty = 0 + + sql_list.append( + (user_id, j, 100-j, song_id, difficulty, rating)) + + self.c2.executemany( + '''insert into recent30(user_id, r_index, time_played, song_id, difficulty, rating) values(?,?,?,?,?,?)''', sql_list) + + def _version_2_11_3_13(self): + ''' + 2.11.3.13 版本特殊更新,world_rank_score 机制调整,需清空用户分数 + ''' + self.c1.execute('''update user set world_rank_score = 0''') + class LogDatabaseMigrator: @@ -440,8 +506,19 @@ def __init__(self): self.c.execute('''PRAGMA synchronous = 0''') self.c.execute('''create table if not exists download_token(user_id int, song_id text,file_name text,token text,time int,primary key(user_id, song_id, file_name));''') + self.c.execute('''create table if not exists bundle_download_token(token text primary key, + file_path text, time int, device_id text);''') self.c.execute( '''create index if not exists download_token_1 on download_token (song_id, file_name);''') + self.c.execute(''' + create table if not exists notification( + user_id int, id int, + type text, content text, + sender_user_id int, sender_name text, + timestamp int, + primary key(user_id, id) + ) + ''') self.conn.commit() diff --git a/latest version/core/user.py b/latest version/core/user.py index dc135e3..ae168a0 100644 --- a/latest version/core/user.py +++ b/latest version/core/user.py @@ -11,6 +11,7 @@ NoData, RateLimit, UserBan) from .item import UserItemList from .limiter import ArcLimiter +from .mission import UserMissionList from .score import Score from .sql import Query, Sql from .world import Map, UserMap, UserStamina @@ -53,6 +54,12 @@ def hash_pwd(self) -> str: class UserRegister(User): + + limiter_ip = ArcLimiter( + Config.GAME_REGISTER_IP_RATE_LIMIT, 'game_register_ip') + limiter_device = ArcLimiter( + Config.GAME_REGISTER_DEVICE_RATE_LIMIT, 'game_register_device') + def __init__(self, c) -> None: super().__init__() self.c = c @@ -137,10 +144,16 @@ def _insert_user_char(self): if x: for i in x: exp = 25000 if i[1] == 30 else 10000 - self.c.execute('''insert into user_char_full values(?,?,?,?,?,?,0)''', + self.c.execute('''insert or replace into user_char_full values(?,?,?,?,?,?,0)''', (self.user_id, i[0], i[1], exp, i[2], 0)) - def register(self): + def register(self, device_id: str = None, ip: str = None): + if device_id is not None and not self.limiter_device.hit(device_id): + raise RateLimit(f'''Too many register attempts of device `{ + device_id}`''', 124, -213) + if ip is not None and ip != '127.0.0.1' and not self.limiter_ip.hit(ip): + raise RateLimit(f'''Too many register attempts of ip `{ + ip}`''', 124, -213) now = int(time.time() * 1000) if self.user_code is None: self._build_user_code() @@ -152,8 +165,6 @@ def register(self): character_id, is_skill_sealed, is_char_uncapped, is_char_uncapped_override, is_hide_rating, favorite_character, max_stamina_notification_enabled, current_map, ticket, prog_boost, email) values(:user_id, :name, :password, :join_date, :user_code, 0, 0, 0, 0, 0, 0, -1, 0, '', :memories, 0, :email) ''', {'user_code': self.user_code, 'user_id': self.user_id, 'join_date': now, 'name': self.name, 'password': self.hash_pwd, 'memories': Config.DEFAULT_MEMORIES, 'email': self.email}) - self.c.execute('''insert into recent30(user_id) values(:user_id)''', { - 'user_id': self.user_id}) class UserLogin(User): @@ -306,8 +317,10 @@ def __init__(self, c, user_id=None) -> None: self.recent_score = Score() self.favorite_character = None self.max_stamina_notification_enabled = False + self.mp_notification_enabled = True self.prog_boost: int = 0 self.beyond_boost_gauge: float = 0 + self.kanae_stored_prog: float = 0 self.next_fragstam_ts: int = None self.world_mode_locked_end_ts: int = None self.current_map: 'Map' = None @@ -348,6 +361,13 @@ def packs(self) -> list: return self.__packs + @property + def pick_ticket(self) -> int: + x = UserItemList(self.c, self).select_from_type('pick_ticket') + if not x.items: + return 0 + return x.items[0].amount + @property def world_unlocks(self) -> list: if self.__world_unlocks is None: @@ -488,7 +508,8 @@ def to_dict(self) -> dict: "settings": { "favorite_character": favorite_character_id, "is_hide_rating": self.is_hide_rating, - "max_stamina_notification_enabled": self.max_stamina_notification_enabled + "max_stamina_notification_enabled": self.max_stamina_notification_enabled, + "mp_notification_enabled": self.mp_notification_enabled, }, "user_id": self.user_id, "name": self.name, @@ -501,6 +522,7 @@ def to_dict(self) -> dict: "current_map": self.current_map.map_id, "prog_boost": self.prog_boost, "beyond_boost_gauge": self.beyond_boost_gauge, + "kanae_stored_prog": self.kanae_stored_prog, "next_fragstam_ts": self.next_fragstam_ts, "max_stamina_ts": self.stamina.max_stamina_ts, "stamina": self.stamina.stamina, @@ -518,7 +540,13 @@ def to_dict(self) -> dict: 'country': '', 'course_banners': self.course_banners, 'world_mode_locked_end_ts': self.world_mode_locked_end_ts, - 'locked_char_ids': [] # [1] + 'locked_char_ids': [], # [1] + 'user_missions': UserMissionList(self.c, self).select_all().to_dict_list(), + 'pick_ticket': self.pick_ticket, + + # 'custom_banner': 'online_banner_2024_06', + # 'subscription_multiplier': 114, + # 'memory_boost_ticket': 5, } def from_list(self, x: list) -> 'UserInfo': @@ -559,6 +587,9 @@ def from_list(self, x: list) -> 'UserInfo': self.stamina.set_value(x[32], x[33]) self.world_mode_locked_end_ts = x[34] if x[34] else -1 self.beyond_boost_gauge = x[35] if x[35] else 0 + self.kanae_stored_prog = x[36] if x[36] else 0 + + self.mp_notification_enabled = x[37] == 1 return self @@ -616,7 +647,7 @@ def select_user_about_world_play(self) -> None: 查询user表有关世界模式打歌的信息 ''' self.c.execute( - '''select character_id, max_stamina_ts, stamina, is_skill_sealed, is_char_uncapped, is_char_uncapped_override, current_map, world_mode_locked_end_ts, beyond_boost_gauge from user where user_id=?''', (self.user_id,)) + '''select character_id, max_stamina_ts, stamina, is_skill_sealed, is_char_uncapped, is_char_uncapped_override, current_map, world_mode_locked_end_ts, beyond_boost_gauge, kanae_stored_prog from user where user_id=?''', (self.user_id,)) x = self.c.fetchone() if not x: raise NoData('No user.', 108, -3) @@ -630,14 +661,29 @@ def select_user_about_world_play(self) -> None: self.current_map = UserMap(self.c, x[6], self) self.world_mode_locked_end_ts = x[7] if x[7] else -1 self.beyond_boost_gauge = x[8] if x[8] else 0 + self.kanae_stored_prog = x[9] if x[9] else 0 + + def select_user_about_link_play(self) -> None: + ''' + 查询 user 表有关 link play 的信息 + ''' + self.c.execute( + '''select name, rating_ptt, is_hide_rating from user where user_id=?''', (self.user_id,)) + x = self.c.fetchone() + if not x: + raise NoData('No user.', 108, -3) + + self.name = x[0] + self.rating_ptt = x[1] + self.is_hide_rating = x[2] == 1 @property def global_rank(self) -> int: '''用户世界排名,如果超过设定最大值,返回0''' if self.world_rank_score is None: self.select_user_one_column('world_rank_score', 0) - if self.world_rank_score is None: - return 0 + if not self.world_rank_score: + return 0 self.c.execute( '''select count(*) from user where world_rank_score > ?''', (self.world_rank_score,)) @@ -650,40 +696,30 @@ def global_rank(self) -> int: def update_global_rank(self) -> None: '''用户世界排名计算,有新增成绩则要更新''' - self.c.execute('''select song_id, rating_ftr, rating_byn from chart''') - x = self.c.fetchall() - - song_list_ftr = [self.user_id] - song_list_byn = [self.user_id] - for i in x: - if i[1] > 0: - song_list_ftr.append(i[0]) - if i[2] > 0: - song_list_byn.append(i[0]) - - score_sum = 0 - if len(song_list_ftr) >= 2: - self.c.execute( - f'''select sum(score) from best_score where user_id=? and difficulty=2 and song_id in ({','.join(['?']*(len(song_list_ftr)-1))})''', tuple(song_list_ftr)) - - x = self.c.fetchone() - if x[0] is not None: - score_sum += x[0] - - if len(song_list_byn) >= 2: - self.c.execute( - f'''select sum(score) from best_score where user_id=? and difficulty=3 and song_id in ({','.join(['?']*(len(song_list_byn)-1))})''', tuple(song_list_byn)) - - x = self.c.fetchone() - if x[0] is not None: - score_sum += x[0] - - self.c.execute('''update user set world_rank_score = :b where user_id = :a''', { - 'a': self.user_id, 'b': score_sum}) + self.c.execute( + ''' + with user_scores as ( + select song_id, difficulty, score_v2 from best_score where user_id = ? and difficulty in (2, 3, 4) + ) + select sum(a) from( + select sum(score_v2) as a from user_scores where difficulty = 2 and song_id in (select song_id from chart where rating_ftr > 0) + union + select sum(score_v2) as a from user_scores where difficulty = 3 and song_id in (select song_id from chart where rating_byn > 0) + union + select sum(score_v2) as a from user_scores where difficulty = 4 and song_id in (select song_id from chart where rating_etr > 0) + ) + ''', + (self.user_id,) + ) + x = self.c.fetchone() + if x[0] is None: + return - self.world_rank_score = score_sum + self.c.execute( + '''update user set world_rank_score = ? where user_id = ?''', (x[0], self.user_id)) + self.world_rank_score = x[0] - def select_user_one_column(self, column_name: str, default_value=None) -> None: + def select_user_one_column(self, column_name: str, default_value=None, data_type=None) -> None: ''' 查询user表的某个属性 请注意必须是一个普通属性,不能是一个类的实例 @@ -696,7 +732,12 @@ def select_user_one_column(self, column_name: str, default_value=None) -> None: if not x: raise NoData('No user.', 108, -3) - self.__dict__[column_name] = x[0] if x[0] else default_value + data = x[0] if x[0] is not None else default_value + + if data_type is not None: + data = data_type(data) + + self.__dict__[column_name] = data def update_user_one_column(self, column_name: str, value=None) -> None: ''' diff --git a/latest version/core/util.py b/latest version/core/util.py index 24c0021..1b0aec5 100644 --- a/latest version/core/util.py +++ b/latest version/core/util.py @@ -1,15 +1,16 @@ import hashlib import os -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from datetime import date from time import mktime +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + def aes_gcm_128_encrypt(key, plaintext, associated_data): iv = os.urandom(12) encryptor = Cipher( algorithms.AES(key), - modes.GCM(iv, min_tag_length=12), + modes.GCM(iv, min_tag_length=16), ).encryptor() encryptor.authenticate_additional_data(associated_data) ciphertext = encryptor.update(plaintext) + encryptor.finalize() @@ -19,7 +20,7 @@ def aes_gcm_128_encrypt(key, plaintext, associated_data): def aes_gcm_128_decrypt(key, associated_data, iv, ciphertext, tag): decryptor = Cipher( algorithms.AES(key), - modes.GCM(iv, tag, min_tag_length=12), + modes.GCM(iv, tag, min_tag_length=16), ).decryptor() decryptor.authenticate_additional_data(associated_data) return decryptor.update(ciphertext) + decryptor.finalize() @@ -65,3 +66,9 @@ def try_rename(path: str, new_path: str) -> str: def get_today_timestamp(): '''相对于本机本地时间的今天0点的时间戳''' return int(mktime(date.today().timetuple())) + + +def parse_version(s: str) -> 'list[int]': + '''解析版本号''' + s_number = "".join(x for x in s if x.isdigit() or x == '.') + return list(map(int, [x for x in s_number.split('.') if x != ''])) diff --git a/latest version/core/world.py b/latest version/core/world.py index 4110116..76774c4 100644 --- a/latest version/core/world.py +++ b/latest version/core/world.py @@ -1,7 +1,7 @@ import os from functools import lru_cache from json import load -from random import random +from random import randint from time import time from .character import Character @@ -10,34 +10,46 @@ from .item import ItemFactory -@lru_cache(maxsize=128) -def get_world_name(file_dir: str = Constant.WORLD_MAP_FOLDER_PATH) -> list: - '''获取所有地图名称,返回列表''' - file_list = [] - for root, dirs, files in os.walk(file_dir): - for file in files: - if os.path.splitext(file)[1] == '.json': - file_list.append(os.path.splitext(file)[0]) - return file_list +class MapParser: + map_id_path: 'dict[str, str]' = {} -@lru_cache(maxsize=128) -def get_world_info(map_id: str) -> dict: - '''读取json文件内容,返回字典''' - world_info = {} - with open(os.path.join(Constant.WORLD_MAP_FOLDER_PATH, f'{map_id}.json'), 'rb') as f: - world_info = load(f) - - return world_info - - -def get_world_all(c, user) -> list: - ''' - 读取所有地图信息,返回列表 - parameter: `user` - `User`类或子类的实例 - ''' - worlds = get_world_name() - return [UserMap(c, map_id, user) for map_id in worlds] + def __init__(self) -> None: + if not self.map_id_path: + self.parse() + + def parse(self) -> None: + for root, dirs, files in os.walk(Constant.WORLD_MAP_FOLDER_PATH): + for file in files: + if not file.endswith('.json'): + continue + + path = os.path.join(root, file) + self.map_id_path[file[:-5]] = path + + def re_init(self) -> None: + self.map_id_path.clear() + self.get_world_info.cache_clear() + self.parse() + + @staticmethod + @lru_cache(maxsize=128) + def get_world_info(map_id: str) -> dict: + '''读取json文件内容,返回字典''' + world_info = {} + with open(MapParser.map_id_path[map_id], 'rb') as f: + world_info = load(f) + + return world_info + + @staticmethod + def get_world_all(c, user) -> list: + ''' + 读取所有地图信息,返回列表 + parameter: `user` - `User` 类或子类的实例 + `c` - 数据库连接 + ''' + return [UserMap(c, map_id, user) for map_id in MapParser.map_id_path.keys()] class Step: @@ -86,7 +98,7 @@ def from_dict(self, d: dict) -> 'Step': self.restrict_ids = d.get('restrict_ids') self.restrict_type = d.get('restrict_type') self.restrict_difficulty = d.get('restrict_difficulty') - self.step_type = d.get('step_type') + self.step_type = d.get('step_type', []) self.speed_limit_value = d.get('speed_limit_value') self.plus_stamina_value = d.get('plus_stamina_value') if 'items' in d: @@ -99,6 +111,7 @@ def __init__(self, map_id: str = None) -> None: self.map_id: str = map_id self.is_legacy: bool = None self.is_beyond: bool = None + self.is_breached: bool = None self.beyond_health: int = None self.character_affinity: list = [] self.affinity_multiplier: list = [] @@ -119,6 +132,11 @@ def __init__(self, map_id: str = None) -> None: self.require_localunlock_challengeid: str = None self.chain_info: dict = None + # self.requires: list[dict] = None + + self.disable_over: bool = None + self.new_law: str = None + @property def rewards(self) -> list: if self.__rewards is None: @@ -145,6 +163,7 @@ def to_dict(self) -> dict: 'map_id': self.map_id, 'is_legacy': self.is_legacy, 'is_beyond': self.is_beyond, + 'is_breached': self.is_breached, 'beyond_health': self.beyond_health, 'character_affinity': self.character_affinity, 'affinity_multiplier': self.affinity_multiplier, @@ -165,11 +184,16 @@ def to_dict(self) -> dict: } if self.chain_info is not None: r['chain_info'] = self.chain_info + if self.disable_over: + r['disable_over'] = self.disable_over + if self.new_law is not None and self.new_law != '': + r['new_law'] = self.new_law return r def from_dict(self, raw_dict: dict) -> 'Map': - self.is_legacy = raw_dict.get('is_legacy') - self.is_beyond = raw_dict.get('is_beyond') + self.is_legacy = raw_dict.get('is_legacy', False) + self.is_beyond = raw_dict.get('is_beyond', False) + self.is_breached = raw_dict.get('is_breached', False) self.beyond_health = raw_dict.get('beyond_health') self.character_affinity = raw_dict.get('character_affinity', []) self.affinity_multiplier = raw_dict.get('affinity_multiplier', []) @@ -189,11 +213,14 @@ def from_dict(self, raw_dict: dict) -> 'Map': 'require_localunlock_challengeid', '') self.chain_info = raw_dict.get('chain_info') self.steps = [Step().from_dict(s) for s in raw_dict.get('steps')] + + self.disable_over = raw_dict.get('disable_over') + self.new_law = raw_dict.get('new_law') return self def select_map_info(self): '''获取地图信息''' - self.from_dict(get_world_info(self.map_id)) + self.from_dict(MapParser.get_world_info(self.map_id)) class UserMap(Map): @@ -250,6 +277,7 @@ def to_dict(self, has_map_info: bool = False, has_steps: bool = False, has_rewar r['curr_capture'] = self.curr_capture r['is_locked'] = self.is_locked r['user_id'] = self.user.user_id + # memory_boost_ticket if not has_steps: del r['steps'] if has_rewards: @@ -438,7 +466,184 @@ def update(self): 'a': self.stamina, 'b': self.max_stamina_ts, 'c': self.user.user_id}) -class WorldPlay: +class WorldSkillMixin: + def before_calculate(self) -> None: + factory_dict = { + 'skill_vita': self._skill_vita, + 'skill_mika': self._skill_mika, + 'skill_ilith_ivy': self._skill_ilith_ivy, + 'ilith_awakened_skill': self._ilith_awakened_skill, + 'skill_hikari_vanessa': self._skill_hikari_vanessa, + 'skill_mithra': self._skill_mithra + } + if self.user_play.beyond_gauge == 0 and self.character_used.character_id == 35 and self.character_used.skill_id_displayed: + self._special_tempest() + + if self.character_used.skill_id_displayed in factory_dict: + factory_dict[self.character_used.skill_id_displayed]() + + def after_climb(self) -> None: + factory_dict = { + 'eto_uncap': self._eto_uncap, + 'ayu_uncap': self._ayu_uncap, + 'skill_fatalis': self._skill_fatalis, + 'skill_amane': self._skill_amane, + 'skill_maya': self._skill_maya, + 'luna_uncap': self._luna_uncap, + 'skill_kanae_uncap': self._skill_kanae_uncap, + 'skill_eto_hoppe': self._skill_eto_hoppe, + } + if self.character_used.skill_id_displayed in factory_dict: + factory_dict[self.character_used.skill_id_displayed]() + + def _special_tempest(self) -> None: + '''风暴对立技能,prog随全角色等级提升''' + if self.character_used.database_table_name == 'user_char_full': + self.prog_tempest = 60 + else: + self.c.execute( + '''select sum(level) from user_char where user_id=?''', (self.user.user_id,)) + x = self.c.fetchone() + self.prog_tempest = int(x[0]) / 10 if x else 0 + if self.prog_tempest > 60: + self.prog_tempest = 60 + elif self.prog_tempest < 0: + self.prog_tempest = 0 + + def _skill_vita(self) -> None: + ''' + vita技能,overdrive随回忆率提升,提升量最多为10 + 此处采用线性函数 + ''' + self.over_skill_increase = 0 + if 0 < self.user_play.health <= 100: + self.over_skill_increase = self.user_play.health / 10 + + def _eto_uncap(self) -> None: + '''eto觉醒技能,获得残片奖励时世界模式进度加7''' + fragment_flag = False + + for i in self.user.current_map.rewards_for_climbing: + for j in i['items']: + if j.item_type == 'fragment': + fragment_flag = True + break + if fragment_flag: + break + + if fragment_flag: + self.character_bonus_progress_normalized = Constant.ETO_UNCAP_BONUS_PROGRESS + + self.user.current_map.reclimb(self.final_progress) + + def _luna_uncap(self) -> None: + '''luna觉醒技能,限制格开始时世界模式进度加 7,偷懒重爬(因为 map 信息还未获取)''' + x: 'Step' = self.user.current_map.steps_for_climbing[0] + if x.restrict_id and x.restrict_type: + self.self.character_bonus_progress_normalized = Constant.LUNA_UNCAP_BONUS_PROGRESS + self.user.current_map.reclimb(self.final_progress) + + def _ayu_uncap(self) -> None: + '''ayu 觉醒技能,世界模式进度随机变动 [-5, -5],但不会小于 0''' + + self.character_bonus_progress_normalized = randint( + -Constant.AYU_UNCAP_BONUS_PROGRESS, Constant.AYU_UNCAP_BONUS_PROGRESS) + + if self.progress_normalized + self.character_bonus_progress_normalized < 0: + self.character_bonus_progress_normalized = -self.progress_normalized + + self.user.current_map.reclimb(self.final_progress) + + def _skill_fatalis(self) -> None: + '''hikari fatalis技能,世界模式超载,打完休息60分钟''' + + self.user.world_mode_locked_end_ts = int( + time()*1000) + Constant.SKILL_FATALIS_WORLD_LOCKED_TIME + self.user.update_user_one_column('world_mode_locked_end_ts') + + def _skill_amane(self) -> None: + ''' + amane技能,起始格为限速或随机,成绩小于EX时,世界模式进度减半 + ''' + x: 'Step' = self.user.current_map.steps_for_climbing[0] + if ('randomsong' in x.step_type or 'speedlimit' in x.step_type) and self.user_play.song_grade < 5: + self.character_bonus_progress_normalized = -self.progress_normalized / 2 + self.user.current_map.reclimb(self.final_progress) + + def _ilith_awakened_skill(self) -> None: + ''' + ilith 觉醒技能,曲目通关时步数+6,wiki 说是 prog 值+6 + ''' + if self.user_play.health > 0: + self.prog_skill_increase = 6 + + def _skill_mika(self) -> None: + ''' + mika 技能,通关特定曲目能力值翻倍 + ''' + if self.user_play.song.song_id in Constant.SKILL_MIKA_SONGS and self.user_play.clear_type != 0: + self.over_skill_increase = self.character_used.overdrive.get_value( + self.character_used.level) + self.prog_skill_increase = self.character_used.prog.get_value( + self.character_used.level) + + def _skill_mithra(self) -> None: + ''' + mithra 技能,每 150 combo 增加世界模式进度+1 + ''' + if self.user_play.combo_interval_bonus: + self.character_bonus_progress_normalized = self.user_play.combo_interval_bonus + + def _skill_ilith_ivy(self) -> None: + ''' + ilith & ivy 技能,根据 skill_cytusii_flag 来增加三个数值,最高生命每过 20 就对应数值 +10 + ''' + if not self.user_play.skill_cytusii_flag: + return + x = self.user_play.skill_cytusii_flag[: + self.user_play.highest_health // 20] + self.over_skill_increase = x.count('2') * 10 + self.prog_skill_increase = x.count('1') * 10 + + def _skill_hikari_vanessa(self) -> None: + ''' + hikari & vanessa 技能,根据 skill_cytusii_flag 来减少三个数值,最高生命每过 20 就对应数值 -10 + ''' + if not self.user_play.skill_cytusii_flag: + return + x = self.user_play.skill_cytusii_flag[:5 - + self.user_play.lowest_health // 20] + self.over_skill_increase = -x.count('2') * 10 + self.prog_skill_increase = -x.count('1') * 10 + + def _skill_maya(self) -> None: + ''' + maya 技能,skill_flag 为 1 时,世界模式进度翻倍 + ''' + if self.character_used.skill_flag: + self.character_bonus_progress_normalized = self.progress_normalized + self.user.current_map.reclimb(self.final_progress) + self.character_used.change_skill_state() + + def _skill_kanae_uncap(self) -> None: + ''' + kanae 觉醒技能,保存世界模式 progress 并在下次结算 + 直接加减在 progress 最后 + 技能存储 base_progress * PROG / 50,下一次消耗全部存储值(无视技能和搭档,但需要非技能隐藏状态) + ''' + self.kanae_stored_progress = self.progress_normalized + self.user.current_map.reclimb(self.final_progress) + + def _skill_eto_hoppe(self) -> None: + ''' + eto_hoppe 技能,体力大于等于 6 格时,世界进度翻倍 + ''' + if self.user.stamina.stamina >= 6: + self.character_bonus_progress_normalized = self.progress_normalized + self.user.current_map.reclimb(self.final_progress) + + +class BaseWorldPlay(WorldSkillMixin): ''' 世界模式打歌类,处理特殊角色技能,联动UserMap和UserPlay @@ -452,13 +657,9 @@ def __init__(self, c=None, user=None, user_play=None) -> None: self.user_play = user_play self.character_used = None - self.base_step_value: float = None - self.step_value: float = None + self.character_bonus_progress_normalized: float = None - self.prog_tempest: float = None - self.character_bonus_progress: float = None - self.prog_skill_increase: float = None - self.over_skill_increase: float = None + # wpaid: str def to_dict(self) -> dict: arcmap: 'UserMap' = self.user.current_map @@ -466,8 +667,8 @@ def to_dict(self) -> dict: "rewards": arcmap.rewards_for_climbing_to_dict(), "exp": self.character_used.level.exp, "level": self.character_used.level.level, - "base_progress": self.base_step_value, - "progress": self.step_value, + "base_progress": self.base_progress, + "progress": self.final_progress, "user_map": { "user_id": self.user.user_id, "curr_position": arcmap.curr_position, @@ -480,47 +681,29 @@ def to_dict(self) -> dict: }, "char_stats": { "character_id": self.character_used.character_id, - "frag": self.character_used.frag.get_value(self.character_used.level), - "prog": self.character_used.prog.get_value(self.character_used.level), - "overdrive": self.character_used.overdrive.get_value(self.character_used.level) + "frag": self.character_used.frag_value, + "prog": self.character_used.prog_value, + "overdrive": self.character_used.overdrive_value }, "current_stamina": self.user.stamina.stamina, "max_stamina_ts": self.user.stamina.max_stamina_ts, 'world_mode_locked_end_ts': self.user.world_mode_locked_end_ts, - 'beyond_boost_gauge': self.user.beyond_boost_gauge + 'beyond_boost_gauge': self.user.beyond_boost_gauge, + # 'wpaid': 'helloworld', # world play id ??? + # progress_before_sub_boost + # progress_sub_boost_amount + # subscription_multiply } - if self.over_skill_increase is not None: - r['char_stats']['over_skill_increase'] = self.over_skill_increase - if self.prog_skill_increase is not None: - r['char_stats']['prog_skill_increase'] = self.prog_skill_increase - - if self.prog_tempest is not None: - r['char_stats']['prog'] += self.prog_tempest # 没试过要不要这样 - r['char_stats']['prog_tempest'] = self.prog_tempest - - if self.character_bonus_progress is not None: - # 猜的,为了让客户端正确显示 - r['progress'] -= self.character_bonus_progress - r['character_bonus_progress'] = self.character_bonus_progress - if self.character_used.skill_id_displayed == 'skill_maya': r['char_stats']['skill_state'] = self.character_used.skill_state - if self.user_play.beyond_gauge == 0: - r["user_map"]["steps"] = [ - x.to_dict() for x in arcmap.steps_for_climbing] - else: - r["user_map"]["steps"] = len(arcmap.steps_for_climbing) - if self.user_play.stamina_multiply != 1: r['stamina_multiply'] = self.user_play.stamina_multiply if self.user_play.fragment_multiply != 100: r['fragment_multiply'] = self.user_play.fragment_multiply - if self.user_play.prog_boost_multiply != 0: + if self.user_play.prog_boost_multiply != 0: # 源韵强化 r['prog_boost_multiply'] = self.user_play.prog_boost_multiply - if self.user_play.beyond_boost_gauge_usage != 0: - r['beyond_boost_gauge_usage'] = self.user_play.beyond_boost_gauge_usage return r @@ -531,94 +714,43 @@ def beyond_boost_gauge_addition(self) -> float: @property def step_times(self) -> float: - prog_boost_multiply = self.user_play.prog_boost_multiply + 100 - beyond_boost_times = 1 - - if self.user_play.beyond_gauge == 1: - if prog_boost_multiply > 100: - prog_boost_multiply -= 100 - if self.user_play.beyond_boost_gauge_usage == 100: - beyond_boost_times = 2 - elif self.user_play.beyond_boost_gauge_usage == 200: - beyond_boost_times = 3 - - return self.user_play.stamina_multiply * self.user_play.fragment_multiply / 100 * prog_boost_multiply / 100 * beyond_boost_times + raise NotImplementedError @property def exp_times(self) -> float: - prog_boost_multiply = self.user_play.prog_boost_multiply + 100 - beyond_boost_times = 1 - - if self.user_play.beyond_gauge == 1: - if prog_boost_multiply > 100: - prog_boost_multiply -= 100 - if self.user_play.beyond_boost_gauge_usage == 100: - beyond_boost_times = 2 - elif self.user_play.beyond_boost_gauge_usage == 200: - beyond_boost_times = 3 - - return self.user_play.stamina_multiply * prog_boost_multiply / 100 * beyond_boost_times - - def get_step(self) -> None: - if self.user_play.beyond_gauge == 0: - self.base_step_value = 2.5 + 2.45 * self.user_play.rating**0.5 - prog = self.character_used.prog.get_value( - self.character_used.level) - if self.prog_tempest: - prog += self.prog_tempest - if self.prog_skill_increase: - prog += self.prog_skill_increase + return self.user_play.stamina_multiply * (self.user_play.prog_boost_multiply / 100 + 1) - self.step_value = self.base_step_value * prog / 50 * self.step_times - else: - if self.user_play.clear_type == 0: - self.base_step_value = 25/28 + \ - (self.user_play.rating)**0.5 * 0.43 - else: - self.base_step_value = 75/28 + \ - (self.user_play.rating)**0.5 * 0.43 + @property + def character_bonus_progress(self) -> float: + return self.character_bonus_progress_normalized * self.step_times - if self.character_used.character_id in self.user.current_map.character_affinity: - affinity_multiplier = self.user.current_map.affinity_multiplier[self.user.current_map.character_affinity.index( - self.character_used.character_id)] - else: - affinity_multiplier = 1 + @property + def base_progress(self) -> float: + raise NotImplementedError - overdrive = self.character_used.overdrive.get_value( - self.character_used.level) - if self.over_skill_increase: - overdrive += self.over_skill_increase + @property + def progress_normalized(self) -> float: + raise NotImplementedError - self.step_value = self.base_step_value * overdrive / \ - 50 * self.step_times * affinity_multiplier + @property + def final_progress(self) -> float: + raise NotImplementedError - def update(self) -> None: - '''世界模式更新''' + def before_update(self) -> None: if self.user_play.prog_boost_multiply != 0: self.user.update_user_one_column('prog_boost', 0) self.user_play.clear_play_state() - self.user.select_user_about_world_play() - - if self.user_play.beyond_gauge == 0: - # 更新byd大招蓄力条 - self.user.beyond_boost_gauge += self.beyond_boost_gauge_addition - self.user.beyond_boost_gauge = min( - self.user.beyond_boost_gauge, 200) - self.user.update_user_one_column( - 'beyond_boost_gauge', self.user.beyond_boost_gauge) - elif self.user_play.beyond_boost_gauge_usage != 0 and self.user_play.beyond_boost_gauge_usage <= self.user.beyond_boost_gauge: - self.user.beyond_boost_gauge -= self.user_play.beyond_boost_gauge_usage - if abs(self.user.beyond_boost_gauge) <= 1e-5: - self.user.beyond_boost_gauge = 0 - self.user.update_user_one_column( - 'beyond_boost_gauge', self.user.beyond_boost_gauge) + # self.user.select_user_about_world_play() self.character_used = Character() self.user.character.select_character_info() if not self.user.is_skill_sealed: self.character_used = self.user.character + if self.user_play.beyond_gauge == 0 and self.user.kanae_stored_prog > 0: + # 实在不想拆开了,在这里判断一下,注意这段不会在 BeyondWorldPlay 中执行 + self.kanae_added_progress = self.user.kanae_stored_prog else: self.character_used.character_id = self.user.character.character_id self.character_used.level.level = self.user.character.level.level @@ -627,11 +759,9 @@ def update(self) -> None: self.character_used.prog.set_parameter(50, 50, 50) self.character_used.overdrive.set_parameter(50, 50, 50) - self.user.current_map.select_map_info() - self.before_calculate() - self.get_step() - self.user.current_map.climb(self.step_value) - self.after_climb() + # self.user.current_map.select_map_info() + + def after_update(self) -> None: for i in self.user.current_map.rewards_for_climbing: # 物品分发 for j in i['items']: @@ -655,156 +785,238 @@ def update(self) -> None: self.user.current_map.update() - def before_calculate(self) -> None: - factory_dict = {'skill_vita': self._skill_vita, 'skill_mika': self._skill_mika, 'skill_ilith_ivy': self._skill_ilith_ivy, - 'ilith_awakened_skill': self._ilith_awakened_skill, 'skill_hikari_vanessa': self._skill_hikari_vanessa} - if self.user_play.beyond_gauge == 0 and self.character_used.character_id == 35 and self.character_used.skill_id_displayed: - self._special_tempest() + def update(self) -> None: + '''世界模式更新''' + self.before_update() + self.before_calculate() + self.user.current_map.climb(self.final_progress) + self.after_climb() + self.after_update() - if self.character_used.skill_id_displayed in factory_dict: - factory_dict[self.character_used.skill_id_displayed]() - def after_climb(self) -> None: - factory_dict = {'eto_uncap': self._eto_uncap, 'ayu_uncap': self._ayu_uncap, - 'luna_uncap': self._luna_uncap, 'skill_fatalis': self._skill_fatalis, 'skill_amane': self._skill_amane, 'skill_maya': self._skill_maya, 'skill_mithra': self._skill_mithra} - if self.character_used.skill_id_displayed in factory_dict: - factory_dict[self.character_used.skill_id_displayed]() +class WorldPlay(BaseWorldPlay): + def __init__(self, c=None, user=None, user_play=None) -> None: + super().__init__(c, user, user_play) - def _special_tempest(self) -> None: - '''风暴对立技能,prog随全角色等级提升''' - if self.character_used.database_table_name == 'user_char_full': - self.prog_tempest = 60 - else: - self.c.execute( - '''select sum(level) from user_char where user_id=?''', (self.user.user_id,)) - x = self.c.fetchone() - self.prog_tempest = int(x[0]) / 10 if x else 0 - if self.prog_tempest > 60: - self.prog_tempest = 60 - elif self.prog_tempest < 0: - self.prog_tempest = 0 + self.prog_tempest: float = None + self.prog_skill_increase: float = None - def _skill_vita(self) -> None: - ''' - vita技能,overdrive随回忆率提升,提升量最多为10 - 此处采用线性函数 - ''' - self.over_skill_increase = 0 - if 0 < self.user_play.health <= 100: - self.over_skill_increase = self.user_play.health / 10 + self.kanae_added_progress: float = None # 群愿往外拿 + self.kanae_stored_progress: float = None # 往群愿里塞 + # self.user.kanae_stored_prog: float 群愿有的 - def _eto_uncap(self) -> None: - '''eto觉醒技能,获得残片奖励时世界模式进度加7''' - fragment_flag = False + def to_dict(self) -> dict: + r = super().to_dict() - for i in self.user.current_map.rewards_for_climbing: - for j in i['items']: - if j.item_type == 'fragment': - fragment_flag = True - break - if fragment_flag: - break + # 基础进度加上搭档倍数 不带 character_bonus_progress 但是带 kanae 技能 + r['progress_partial_after_stat'] = self.progress_normalized - if fragment_flag: - self.character_bonus_progress = Constant.ETO_UNCAP_BONUS_PROGRESS - self.step_value += self.character_bonus_progress + if self.character_bonus_progress_normalized is not None: + r['character_bonus_progress'] = self.character_bonus_progress_normalized + # 不懂为什么两个玩意一样 + r['character_bonus_progress_normalized'] = self.character_bonus_progress_normalized - self.user.current_map.reclimb(self.step_value) + if self.prog_skill_increase is not None: + r['char_stats']['prog_skill_increase'] = self.prog_skill_increase - def _luna_uncap(self) -> None: - '''luna觉醒技能,限制格开始时世界模式进度加7''' - x: 'Step' = self.user.current_map.steps_for_climbing[0] - if x.restrict_id and x.restrict_type: - self.character_bonus_progress = Constant.LUNA_UNCAP_BONUS_PROGRESS - self.step_value += self.character_bonus_progress + if self.prog_tempest is not None: + r['char_stats']['prog'] += self.prog_tempest # 没试过要不要这样 + r['char_stats']['prog_tempest'] = self.prog_tempest - self.user.current_map.reclimb(self.step_value) + if self.kanae_added_progress is not None: + r['kanae_added_progress'] = self.kanae_added_progress - def _ayu_uncap(self) -> None: - '''ayu觉醒技能,世界模式进度+5或-5,但不会小于0''' + if self.kanae_stored_progress is not None: + r['kanae_stored_progress'] = self.kanae_stored_progress - self.character_bonus_progress = Constant.AYU_UNCAP_BONUS_PROGRESS if random( - ) >= 0.5 else -Constant.AYU_UNCAP_BONUS_PROGRESS + r['partner_adjusted_prog'] = self.partner_adjusted_prog - self.step_value += self.character_bonus_progress - if self.step_value < 0: - self.character_bonus_progress += self.step_value - self.step_value = 0 + r["user_map"]["steps"] = [x.to_dict() + for x in self.user.current_map.steps_for_climbing] + return r - self.user.current_map.reclimb(self.step_value) + @property + def step_times(self) -> float: + return self.user_play.stamina_multiply * self.user_play.fragment_multiply / 100 * (self.user_play.prog_boost_multiply / 100 + 1) - def _skill_fatalis(self) -> None: - '''hikari fatalis技能,世界模式超载,打完休息60分钟''' + @property + def character_bonus_progress(self) -> float: + return self.character_bonus_progress_normalized * self.step_times - self.user.world_mode_locked_end_ts = int( - time()*1000) + Constant.SKILL_FATALIS_WORLD_LOCKED_TIME - self.user.update_user_one_column('world_mode_locked_end_ts') + @property + def base_progress(self) -> float: + return 2.5 + 2.45 * self.user_play.rating**0.5 - def _skill_amane(self) -> None: - ''' - amane技能,起始格为限速或随机,成绩小于EX时,世界模式进度减半 - 偷懒在after_climb里面,需要重爬一次 - ''' - x: 'Step' = self.user.current_map.steps_for_climbing[0] - if ('randomsong' in x.step_type or 'speedlimit' in x.step_type) and self.user_play.song_grade < 5: - self.character_bonus_progress = -self.step_value / 2 - self.step_value = self.step_value / 2 - self.user.current_map.reclimb(self.step_value) + @property + def final_progress(self) -> float: + return (self.progress_normalized + (self.character_bonus_progress_normalized or 0)) * self.step_times + (self.kanae_added_progress or 0) - (self.kanae_stored_progress or 0) - def _ilith_awakened_skill(self) -> None: - ''' - ilith 觉醒技能,曲目通关时步数+6,wiki 说是 prog 值+6 - ''' - if self.user_play.health > 0: - self.prog_skill_increase = 6 + @property + def partner_adjusted_prog(self) -> float: + prog = self.character_used.prog.get_value( + self.character_used.level) + if self.prog_tempest: + prog += self.prog_tempest + if self.prog_skill_increase: + prog += self.prog_skill_increase + return prog - def _skill_mika(self) -> None: - ''' - mika 技能,通关特定曲目能力值翻倍 - ''' - if self.user_play.song.song_id in Constant.SKILL_MIKA_SONGS and self.user_play.clear_type != 0: - self.over_skill_increase = self.character_used.overdrive.get_value( - self.character_used.level) - self.prog_skill_increase = self.character_used.prog.get_value( - self.character_used.level) + @property + def progress_normalized(self) -> float: + return self.base_progress * (self.partner_adjusted_prog / 50) - def _skill_mithra(self) -> None: - ''' - mithra 技能,每 150 combo 增加世界模式进度+1 - ''' - if self.user_play.combo_interval_bonus: - self.character_bonus_progress = self.user_play.combo_interval_bonus - self.step_value += self.character_bonus_progress - self.user.current_map.reclimb(self.step_value) + def after_update(self) -> None: + '''世界模式更新''' + super().after_update() - def _skill_ilith_ivy(self) -> None: - ''' - ilith & ivy 技能,根据 skill_cytusii_flag 来增加三个数值,最高生命每过 20 就对应数值 +10 - ''' - if not self.user_play.skill_cytusii_flag: - return - x = self.user_play.skill_cytusii_flag[: - self.user_play.highest_health // 20] - self.over_skill_increase = x.count('2') * 10 - self.prog_skill_increase = x.count('1') * 10 + # 更新byd大招蓄力条 + self.user.beyond_boost_gauge += self.beyond_boost_gauge_addition + self.user.beyond_boost_gauge = min(self.user.beyond_boost_gauge, 200) + self.user.update_user_one_column( + 'beyond_boost_gauge', self.user.beyond_boost_gauge) - def _skill_hikari_vanessa(self) -> None: - ''' - hikari & vanessa 技能,根据 skill_cytusii_flag 来减少三个数值,最高生命每过 20 就对应数值 -10 - ''' - if not self.user_play.skill_cytusii_flag: + # 更新kanae存储进度 + if self.kanae_stored_progress is not None: + self.user.kanae_stored_prog = self.kanae_stored_progress + self.user.update_user_one_column( + 'kanae_stored_prog', self.user.kanae_stored_prog) return - x = self.user_play.skill_cytusii_flag[:5 - - self.user_play.lowest_health // 20] - self.over_skill_increase = -x.count('2') * 10 - self.prog_skill_increase = -x.count('1') * 10 + if self.kanae_added_progress is None: + return + self.kanae_stored_progress = 0 + self.user.update_user_one_column('kanae_stored_prog', 0) - def _skill_maya(self) -> None: - ''' - maya 技能,skill_flag 为 1 时,世界模式进度翻倍 - ''' - if self.character_used.skill_flag: - self.character_bonus_progress = self.step_value - self.step_value += self.character_bonus_progress - self.user.current_map.reclimb(self.step_value) - self.character_used.change_skill_state() + +class BeyondWorldPlay(BaseWorldPlay): + + def __init__(self, c=None, user=None, user_play=None) -> None: + super().__init__(c, user, user_play) + self.over_skill_increase: float = None + + @property + def step_times(self) -> float: + return self.user_play.stamina_multiply * self.user_play.fragment_multiply / 100 * (1 + self.user_play.prog_boost_multiply / 100 + self.user_play.beyond_boost_gauge_usage / 100) + + @property + def affinity_multiplier(self) -> float: + if self.user.current_map.character_affinity is not None and self.character_used.character_id is not None and self.character_used.character_id in self.user.current_map.character_affinity: + return self.user.current_map.affinity_multiplier[self.user.current_map.character_affinity.index(self.character_used.character_id)] + return 1 + + @property + def base_progress(self) -> float: + return self.user_play.rating**0.5 * 0.43 + (25/28 if self.user_play.clear_type == 0 else 75/28) + + @property + def final_progress(self) -> float: + return self.progress_normalized * self.step_times + + @property + def progress_normalized(self) -> float: + overdrive = self.character_used.overdrive_value + if self.over_skill_increase: + overdrive += self.over_skill_increase + + return self.base_progress * (overdrive / 50) * self.affinity_multiplier + + def to_dict(self) -> dict: + r = super().to_dict() + + # byd 进度 没有加上源韵强化 和 boost 的数值 + r['pre_boost_progress'] = self.progress_normalized * \ + self.user_play.fragment_multiply / 100 + + # r['partner_multiply'] = self.affinity_multiplier # ? + + if self.over_skill_increase is not None: + r['char_stats']['over_skill_increase'] = self.over_skill_increase + + r["user_map"]["steps"] = len(self.user.current_map.steps_for_climbing) + r['affinity_multiply'] = self.affinity_multiplier + if self.user_play.beyond_boost_gauge_usage != 0: + r['beyond_boost_gauge_usage'] = self.user_play.beyond_boost_gauge_usage + + return r + + def after_update(self) -> None: + super().after_update() + if self.user_play.beyond_boost_gauge_usage != 0 and self.user_play.beyond_boost_gauge_usage <= self.user.beyond_boost_gauge: + self.user.beyond_boost_gauge -= self.user_play.beyond_boost_gauge_usage + if abs(self.user.beyond_boost_gauge) <= 1e-5: + self.user.beyond_boost_gauge = 0 + self.user.update_user_one_column( + 'beyond_boost_gauge', self.user.beyond_boost_gauge) + + +class WorldLawMixin: + def breached_before_calculate(self) -> None: + factory_dict = { + 'over100_step50': self._over100_step50, + 'frag50': self._frag50, + 'lowlevel': self._lowlevel, + 'antiheroism': self._antiheroism + } + if self.user.current_map.new_law in factory_dict: + factory_dict[self.user.current_map.new_law]() + + def _over100_step50(self) -> None: + '''PROG = OVER + STEP / 2''' + over = self.character_used.overdrive_value + self.over_skill_increase + prog = self.character_used.prog_value + self.prog_skill_increase + self.new_law_prog = over + prog / 2 + + def _frag50(self) -> None: + '''PROG x= FRAG''' + self.new_law_prog = self.character_used.frag_value + + def _lowlevel(self) -> None: + '''PROG x= max(1.0, 2.0 - 0.1 x LEVEL)''' + self.new_law_prog = 50 * \ + max(1, 2 - 0.1 * self.character_used.level.level) + + def _antiheroism(self) -> None: + '''PROG = OVER - ||OVER-FRAG|-|OVER-STEP||''' + over = self.character_used.overdrive_value + self.over_skill_increase + prog = self.character_used.prog_value + self.prog_skill_increase + x = abs(over - self.character_used.frag_value) + y = abs(over - prog) + self.new_law_prog = over - abs(x - y) + + +class BreachedWorldPlay(BeyondWorldPlay, WorldLawMixin): + def __init__(self, c=None, user=None, user_play=None) -> None: + super().__init__(c, user, user_play) + self.new_law_prog: float = None + + @property + def new_law_multiply(self) -> float: + if self.new_law_prog is None: + return 1 + return self.new_law_prog / 50 + + @property + def affinity_multiplier(self) -> float: + return 1 + + @property + def progress_normalized(self) -> float: + if self.user.current_map.disable_over: + return self.base_progress * self.new_law_multiply + + overdrive = self.character_used.overdrive_value + if self.over_skill_increase: + overdrive += self.over_skill_increase + return self.base_progress * (overdrive / 50) * self.new_law_multiply + + def to_dict(self) -> dict: + r = super().to_dict() + r['new_law_multiply'] = self.new_law_multiply + return r + + def update(self) -> None: + self.before_update() + self.before_calculate() + self.breached_before_calculate() + self.user.current_map.climb(self.final_progress) + self.after_climb() + self.after_update() diff --git a/latest version/database/bundle/README.md b/latest version/database/bundle/README.md new file mode 100644 index 0000000..aee86b1 --- /dev/null +++ b/latest version/database/bundle/README.md @@ -0,0 +1,5 @@ +## Arcaea Bundle Folder + +[Arcaea-Bundler](https://github.com/Lost-MSth/Arcaea-Bundler) + +There must be pairs of bundle file and JSON metadata file with the same name. The bundle file's suffix is `.cb` and the metadata file's suffix is `.json`. diff --git a/latest version/database/init/arc_data.py b/latest version/database/init/arc_data.py index d2300ac..8fb0dd0 100644 --- a/latest version/database/init/arc_data.py +++ b/latest version/database/init/arc_data.py @@ -1,45 +1,45 @@ class InitData: char = ['hikari', 'tairitsu', 'kou', 'sapphire', 'lethe', 'hikari&tairitsu(reunion)', 'Tairitsu(Axium)', 'Tairitsu(Grievous Lady)', 'stella', 'Hikari & Fisica', 'ilith', 'eto', 'luna', 'shirabe', 'Hikari(Zero)', 'Hikari(Fracture)', 'Hikari(Summer)', 'Tairitsu(Summer)', 'Tairitsu & Trin', - 'ayu', 'Eto & Luna', 'yume', 'Seine & Hikari', 'saya', 'Tairitsu & Chuni Penguin', 'Chuni Penguin', 'haruna', 'nono', 'MTA-XXX', 'MDA-21', 'kanae', 'Hikari(Fantasia)', 'Tairitsu(Sonata)', 'sia', 'DORO*C', 'Tairitsu(Tempest)', 'brillante', 'Ilith(Summer)', 'etude', 'Alice & Tenniel', 'Luna & Mia', 'areus', 'seele', 'isabelle', 'mir', 'lagrange', 'linka', 'nami', 'Saya & Elizabeth', 'lily', 'kanae(midsummer)', 'alice&tenniel(minuet)', 'tairitsu(elegy)', 'marija', 'vita', 'hikari(fatalis)', 'saki', 'setsuna', 'amane', 'kou(winter)', 'lagrange(aria)', 'lethe(apophenia)', 'shama(UNiVERSE)', 'milk(UNiVERSE)', 'shikoku', 'mika yurisaki', 'Mithra Tercera', 'Toa Kozukata', 'Nami(Twilight)', 'Ilith & Ivy', 'Hikari & Vanessa', 'Maya', 'Insight(Ascendant - 8th Seeker)', 'Luin'] + 'ayu', 'Eto & Luna', 'yume', 'Seine & Hikari', 'saya', 'Tairitsu & Chuni Penguin', 'Chuni Penguin', 'haruna', 'nono', 'MTA-XXX', 'MDA-21', 'kanae', 'Hikari(Fantasia)', 'Tairitsu(Sonata)', 'sia', 'DORO*C', 'Tairitsu(Tempest)', 'brillante', 'Ilith(Summer)', 'etude', 'Alice & Tenniel', 'Luna & Mia', 'areus', 'seele', 'isabelle', 'mir', 'lagrange', 'linka', 'nami', 'Saya & Elizabeth', 'lily', 'kanae(midsummer)', 'alice&tenniel(minuet)', 'tairitsu(elegy)', 'marija', 'vita', 'hikari(fatalis)', 'saki', 'setsuna', 'amane', 'kou(winter)', 'lagrange(aria)', 'lethe(apophenia)', 'shama(UNiVERSE)', 'milk(UNiVERSE)', 'shikoku', 'mika yurisaki', 'Mithra Tercera', 'Toa Kozukata', 'Nami(Twilight)', 'Ilith & Ivy', 'Hikari & Vanessa', 'Maya', 'Insight(Ascendant - 8th Seeker)', 'Luin', 'Vita(Cadenza)', 'Ai-chan', 'Luna & Ilot', 'Eto & Hoppe'] skill_id = ['gauge_easy', '', '', '', 'note_mirror', 'skill_reunion', '', 'gauge_hard', 'frag_plus_10_pack_stellights', 'gauge_easy|frag_plus_15_pst&prs', 'gauge_hard|fail_frag_minus_100', 'frag_plus_5_side_light', 'visual_hide_hp', 'frag_plus_5_side_conflict', 'challenge_fullcombo_0gauge', 'gauge_overflow', 'gauge_easy|note_mirror', 'note_mirror', 'visual_tomato_pack_tonesphere', - 'frag_rng_ayu', 'gaugestart_30|gaugegain_70', 'combo_100-frag_1', 'audio_gcemptyhit_pack_groovecoaster', 'gauge_saya', 'gauge_chuni', 'kantandeshou', 'gauge_haruna', 'frags_nono', 'gauge_pandora', 'gauge_regulus', 'omatsuri_daynight', '', '', 'sometimes(note_mirror|frag_plus_5)', 'scoreclear_aa|visual_scoregauge', 'gauge_tempest', 'gauge_hard', 'gauge_ilith_summer', '', 'note_mirror|visual_hide_far', 'frags_ongeki', 'gauge_areus', 'gauge_seele', 'gauge_isabelle', 'gauge_exhaustion', 'skill_lagrange', 'gauge_safe_10', 'frags_nami', 'skill_elizabeth', 'skill_lily', 'skill_kanae_midsummer', '', '', 'visual_ghost_skynotes', 'skill_vita', 'skill_fatalis', 'frags_ongeki_slash', 'frags_ongeki_hard', 'skill_amane', 'skill_kou_winter', '', 'gauge_hard|note_mirror', 'skill_shama', 'skill_milk', 'skill_shikoku', 'skill_mika', 'skill_mithra', 'skill_toa', 'skill_nami_twilight', 'skill_ilith_ivy', 'skill_hikari_vanessa', 'skill_maya', 'skill_intruder', 'skill_luin'] + 'frag_rng_ayu', 'gaugestart_30|gaugegain_70', 'combo_100-frag_1', 'audio_gcemptyhit_pack_groovecoaster', 'gauge_saya', 'gauge_chuni', 'kantandeshou', 'gauge_haruna', 'frags_nono', 'gauge_pandora', 'gauge_regulus', 'omatsuri_daynight', '', '', 'sometimes(note_mirror|frag_plus_5)', 'scoreclear_aa|visual_scoregauge', 'gauge_tempest', 'gauge_hard', 'gauge_ilith_summer', '', 'note_mirror|visual_hide_far', 'frags_ongeki', 'gauge_areus', 'gauge_seele', 'gauge_isabelle', 'gauge_exhaustion', 'skill_lagrange', 'gauge_safe_10', 'frags_nami', 'skill_elizabeth', 'skill_lily', 'skill_kanae_midsummer', '', '', 'visual_ghost_skynotes', 'skill_vita', 'skill_fatalis', 'frags_ongeki_slash', 'frags_ongeki_hard', 'skill_amane', 'skill_kou_winter', '', 'gauge_hard|note_mirror', 'skill_shama', 'skill_milk', 'skill_shikoku', 'skill_mika', 'skill_mithra', 'skill_toa', 'skill_nami_twilight', 'skill_ilith_ivy', 'skill_hikari_vanessa', 'skill_maya', 'skill_intruder', 'skill_luin', '', 'skill_aichan', 'skill_luna_ilot', 'skill_eto_hoppe'] skill_id_uncap = ['', '', 'frags_kou', '', 'visual_ink', '', '', '', '', '', 'ilith_awakened_skill', 'eto_uncap', 'luna_uncap', 'shirabe_entry_fee', - '', '', '', '', '', 'ayu_uncap', '', 'frags_yume', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', 'skill_luin_uncap'] + '', '', '', '', '', 'ayu_uncap', '', 'frags_yume', '', 'skill_saya_uncap', '', '', '', '', '', '', 'skill_kanae_uncap', '', '', '', 'skill_doroc_uncap', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', 'skill_luin_uncap', '', '', '', ''] skill_unlock_level = [0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 0, 0, 0, 0, 0, - 0, 0, 0, 8, 0, 14, 0, 0, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + 0, 0, 0, 8, 0, 14, 0, 0, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] frag1 = [55, 55, 60, 50, 47, 79, 47, 57, 41, 22, 50, 54, 60, 56, 78, 42, 41, 61, 52, 50, 52, 32, - 42, 55, 45, 58, 43, 0.5, 68, 50, 62, 45, 45, 52, 44, 27, 59, 0, 45, 50, 50, 47, 47, 61, 43, 42, 38, 25, 58, 50, 61, 45, 45, 38, 34, 27, 18, 56, 47, 30, 45, 57, 56, 47, 33, 26, 29, 66, 40, 33, 51, 27, 50, 60] + 42, 55, 45, 58, 43, 0.5, 68, 50, 62, 45, 45, 52, 44, 27, 59, 0, 45, 50, 50, 47, 47, 61, 43, 42, 38, 25, 58, 50, 61, 45, 45, 38, 34, 27, 18, 56, 47, 30, 45, 57, 56, 47, 33, 26, 29, 66, 40, 33, 51, 27, 50, 60, 45, 50, 38, 22] prog1 = [35, 55, 47, 50, 60, 70, 60, 70, 58, 45, 70, 45, 42, 46, 61, 67, 49, 44, 28, 45, 24, 46, 52, - 59, 62, 33, 58, 25, 63, 69, 50, 45, 45, 51, 34, 70, 62, 70, 45, 32, 32, 61, 47, 47, 37, 42, 50, 50, 45, 41, 61, 45, 45, 58, 50, 130, 18, 57, 55, 50, 45, 70, 37.5, 29, 44, 26, 26, 35, 40, 33, 58, 31, 50, 50] + 59, 62, 33, 58, 25, 63, 69, 50, 45, 45, 51, 34, 70, 62, 70, 45, 32, 32, 61, 47, 47, 37, 42, 50, 50, 45, 41, 61, 45, 45, 58, 50, 130, 18, 57, 55, 50, 45, 70, 37.5, 29, 44, 26, 26, 35, 40, 33, 58, 31, 50, 50, 45, 41, 12, 31] overdrive1 = [35, 55, 25, 50, 47, 70, 72, 57, 41, 7, 10, 32, 65, 31, 61, 53, 31, 47, 38, 12, 39, 18, - 48, 65, 45, 55, 44, 25, 46, 44, 33, 45, 45, 37, 25, 27, 50, 20, 45, 63, 21, 47, 61, 47, 65, 80, 38, 30, 49, 15, 34, 45, 45, 38, 67, 120, 44, 33, 55, 50, 45, 57, 31, 29, 65, 26, 29, 42.5, 40, 33, 58, 31, 50, 34] + 48, 65, 45, 55, 44, 25, 46, 44, 33, 45, 45, 37, 25, 27, 50, 20, 45, 63, 21, 47, 61, 47, 65, 80, 38, 30, 49, 15, 34, 45, 45, 38, 67, 120, 44, 33, 55, 50, 45, 57, 31, 29, 65, 26, 29, 42.5, 40, 33, 58, 31, 50, 34, 45, 41, 12, 19] frag20 = [78, 80, 90, 75, 70, 79, 70, 79, 65, 40, 50, 80, 90, 82, 0, 61, 67, 92, 85, 50, 86, 52, - 65, 85, 67, 88, 64, 0.5, 95, 70, 95, 50, 80, 87, 71, 50, 85, 0, 80, 75, 50, 70, 70, 90, 65, 80, 61, 50, 68, 60, 90, 67, 50, 60, 51, 50, 35, 85, 47, 50, 75, 80, 90, 80, 50, 51, 54, 100, 50, 58, 51, 40, 50, 70] + 65, 85, 67, 88, 64, 0.5, 95, 70, 95, 50, 80, 87, 71, 50, 85, 0, 80, 75, 50, 70, 70, 90, 65, 80, 61, 50, 68, 60, 90, 67, 50, 60, 51, 50, 35, 85, 47, 50, 75, 80, 90, 80, 50, 51, 54, 100, 50, 58, 51, 40, 50, 70, 50, 61.6, 48, 37] prog20 = [61, 80, 70, 75, 90, 70, 90, 102, 84, 78, 105, 67, 63, 68, 0, 99, 80, 66, 46, 83, 40, 73, - 80, 90, 93, 50, 86, 78, 89, 98, 75, 80, 50, 64, 55, 100, 90, 110, 80, 50, 74, 90, 70, 70, 56, 80, 79, 55, 65, 59, 90, 50, 90, 90, 75, 210, 35, 86, 92, 80, 75, 100, 60, 50, 68, 51, 50, 53, 85, 58, 96, 47, 50, 80] + 80, 90, 93, 50, 86, 78, 89, 98, 75, 80, 50, 64, 55, 100, 90, 110, 80, 50, 74, 90, 70, 70, 56, 80, 79, 55, 65, 59, 90, 50, 90, 90, 75, 210, 35, 86, 92, 80, 75, 100, 60, 50, 68, 51, 50, 53, 85, 58, 96, 47, 50, 80, 67, 41, 55, 50] overdrive20 = [61, 80, 47, 75, 70, 70, 95, 79, 65, 31, 50, 59, 90, 58, 0, 78, 50, 70, 62, 49, 64, - 46, 73, 95, 67, 84, 70, 78, 69, 70, 50, 80, 80, 63, 25, 50, 72, 55, 50, 95, 55, 70, 90, 70, 99, 80, 61, 40, 69, 62, 51, 90, 67, 60, 100, 200, 85, 50, 92, 50, 75, 80, 49.5, 50, 100, 51, 54, 65.5, 59.5, 58, 96, 47, 50, 54] + 46, 73, 95, 67, 84, 70, 78, 69, 70, 50, 80, 80, 63, 25, 50, 72, 55, 50, 95, 55, 70, 90, 70, 99, 80, 61, 40, 69, 62, 51, 90, 67, 60, 100, 200, 85, 50, 92, 50, 75, 80, 49.5, 50, 100, 51, 54, 65.5, 59.5, 58, 96, 47, 50, 54, 90, 41, 34, 30] frag30 = [88, 90, 100, 75, 80, 89, 70, 79, 65, 40, 50, 90, 100, 92, 0, 61, 67, 92, 85, 50, 86, 62, - 65, 85, 67, 88, 74, 0.5, 105, 80, 95, 50, 80, 87, 71, 50, 95, 0, 80, 75, 50, 70, 80, 100, 65, 80, 61, 50, 68, 60, 90, 67, 50, 60, 51, 50, 35, 85, 47, 50, 75, 80, 90, 80, 50, 51, 64, 100, 50, 58, 51, 40, 50, 80] + 65, 95, 67, 88, 74, 0.5, 105, 80, 105, 50, 80, 87, 81, 50, 95, 0, 80, 75, 50, 70, 80, 100, 65, 80, 61, 50, 68, 60, 90, 67, 50, 60, 51, 50, 35, 85, 47, 50, 75, 80, 90, 80, 50, 51, 64, 100, 50, 58, 51, 40, 50, 80, 50, 61.6, 48, 37] prog30 = [71, 90, 80, 75, 100, 80, 90, 102, 84, 78, 110, 77, 73, 78, 0, 99, 80, 66, 46, 93, 40, 83, - 80, 90, 93, 50, 96, 88, 99, 108, 75, 80, 50, 64, 55, 100, 100, 110, 80, 50, 74, 90, 80, 80, 56, 80, 79, 55, 65, 59, 90, 50, 90, 90, 75, 210, 35, 86, 92, 80, 75, 100, 60, 50, 68, 51, 60, 53, 85, 58, 96, 47, 50, 90] + 80, 100, 93, 50, 96, 88, 99, 108, 85, 80, 50, 64, 65, 100, 100, 110, 80, 50, 74, 90, 80, 80, 56, 80, 79, 55, 65, 59, 90, 50, 90, 90, 75, 210, 35, 86, 92, 80, 75, 100, 60, 50, 68, 51, 60, 53, 85, 58, 96, 47, 50, 90, 67, 41, 55, 50] overdrive30 = [71, 90, 57, 75, 80, 80, 95, 79, 65, 31, 50, 69, 100, 68, 0, 78, 50, 70, 62, 59, 64, - 56, 73, 95, 67, 84, 80, 88, 79, 80, 50, 80, 80, 63, 25, 50, 82, 55, 50, 95, 55, 70, 100, 80, 99, 80, 61, 40, 69, 62, 51, 90, 67, 60, 100, 200, 85, 50, 92, 50, 75, 80, 49.5, 50, 100, 51, 64, 65.5, 59.5, 58, 96, 47, 50, 64] + 56, 73, 105, 67, 84, 80, 88, 79, 80, 60, 80, 80, 63, 35, 50, 82, 55, 50, 95, 55, 70, 100, 80, 99, 80, 61, 40, 69, 62, 51, 90, 67, 60, 100, 200, 85, 50, 92, 50, 75, 80, 49.5, 50, 100, 51, 64, 65.5, 59.5, 58, 96, 47, 50, 64, 90, 41, 34, 30] char_type = [1, 0, 0, 0, 0, 0, 0, 2, 0, 1, 2, 0, 0, 0, 2, 3, 1, 0, 0, 0, 1, - 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 2, 2, 2, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2, 3, 0, 2, 2, 0, 0, 2, 0, 0, 2, 0, 2, 2, 1, 0, 2, 0, 0, 2] + 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 2, 2, 2, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2, 3, 0, 2, 2, 0, 0, 2, 0, 0, 2, 0, 2, 2, 1, 0, 2, 0, 0, 2, 0, 0, 0, 0] char_core = { 0: [{'core_id': 'core_hollow', 'amount': 25}, {'core_id': 'core_desolate', 'amount': 5}], @@ -61,17 +61,20 @@ class InitData: 10: [{'core_id': 'core_umbral', 'amount': 30}], 66: [{'core_id': 'core_chunithm', 'amount': 15}], 5: [{'core_id': 'core_hollow', 'amount': 0}], - 73: [{'core_id': 'core_wacca', 'amount': 15}] + 73: [{'core_id': 'core_wacca', 'amount': 15}], + 30: [{'core_id': 'core_hollow', 'amount': 5}, {'core_id': 'core_sunset', 'amount': 25}], + 34: [{'core_id': 'core_tanoc', 'amount': 15}], + 23: [{'core_id': 'core_desolate', 'amount': 5}, {'core_id': 'core_serene', 'amount': 25}], } cores = ['core_hollow', 'core_desolate', 'core_chunithm', 'core_crimson', - 'core_ambivalent', 'core_scarlet', 'core_groove', 'core_generic', 'core_binary', 'core_colorful', 'core_course_skip_purchase', 'core_umbral', 'core_wacca'] + 'core_ambivalent', 'core_scarlet', 'core_groove', 'core_generic', 'core_binary', 'core_colorful', 'core_course_skip_purchase', 'core_umbral', 'core_wacca', 'core_sunset', 'core_tanoc', 'core_serene'] world_songs = ["babaroque", "shadesoflight", "kanagawa", "lucifer", "anokumene", "ignotus", "rabbitintheblackroom", "qualia", "redandblue", "bookmaker", "darakunosono", "espebranch", "blacklotus", "givemeanightmare", "vividtheory", "onefr", "gekka", "vexaria3", "infinityheaven3", "fairytale3", "goodtek3", "suomi", "rugie", "faintlight", "harutopia", "goodtek", "dreaminattraction", "syro", "diode", "freefall", "grimheart", "blaster", - "cyberneciacatharsis", "monochromeprincess", "revixy", "vector", "supernova", "nhelv", "purgatorium3", "dement3", "crossover", "guardina", "axiumcrisis", "worldvanquisher", "sheriruth", "pragmatism", "gloryroad", "etherstrike", "corpssansorganes", "lostdesire", "blrink", "essenceoftwilight", "lapis", "solitarydream", "lumia3", "purpleverse", "moonheart3", "glow", "enchantedlove", "take", "lifeispiano", "vandalism", "nexttoyou3", "lostcivilization3", "turbocharger", "bookmaker3", "laqryma3", "kyogenkigo", "hivemind", "seclusion", "quonwacca3", "bluecomet", "energysynergymatrix", "gengaozo", "lastendconductor3", "antithese3", "qualia3", "kanagawa3", "heavensdoor3", "pragmatism3", "nulctrl", "avril", "ddd", "merlin3", "omakeno3", "nekonote", "sanskia", 'altair', 'mukishitsu', 'trapcrow', 'redandblue3', 'ignotus3', 'singularity3', 'dropdead3', 'arcahv', 'freefall3', 'partyvinyl3', 'tsukinimurakumo', 'mantis', 'worldfragments', 'astrawalkthrough', 'chronicle', 'trappola3', 'letsrock', 'shadesoflight3', 'teriqma3', 'impact3', 'lostemotion', 'gimmick', 'lawlesspoint', 'hybris', 'ultimatetaste', 'rgb', 'matenrou', 'dynitikos', 'amekagura', 'fantasy', 'aloneandlorn'] + "cyberneciacatharsis", "monochromeprincess", "revixy", "vector", "supernova", "nhelv", "purgatorium3", "dement3", "crossover", "guardina", "axiumcrisis", "worldvanquisher", "sheriruth", "pragmatism", "gloryroad", "etherstrike", "corpssansorganes", "lostdesire", "blrink", "essenceoftwilight", "lapis", "solitarydream", "lumia3", "purpleverse", "moonheart3", "glow", "enchantedlove", "take", "lifeispiano", "vandalism", "nexttoyou3", "lostcivilization3", "turbocharger", "bookmaker3", "laqryma3", "kyogenkigo", "hivemind", "seclusion", "quonwacca3", "bluecomet", "energysynergymatrix", "gengaozo", "lastendconductor3", "antithese3", "qualia3", "kanagawa3", "heavensdoor3", "pragmatism3", "nulctrl", "avril", "ddd", "merlin3", "omakeno3", "nekonote", "sanskia", 'altair', 'mukishitsu', 'trapcrow', 'redandblue3', 'ignotus3', 'singularity3', 'dropdead3', 'arcahv', 'freefall3', 'partyvinyl3', 'tsukinimurakumo', 'mantis', 'worldfragments', 'astrawalkthrough', 'chronicle', 'trappola3', 'letsrock', 'shadesoflight3', 'teriqma3', 'impact3', 'lostemotion', 'gimmick', 'lawlesspoint', 'hybris', 'ultimatetaste', 'rgb', 'matenrou', 'dynitikos', 'amekagura', 'fantasy', 'aloneandlorn', 'felys', 'onandon', 'hotarubinoyuki', 'oblivia3', 'libertas3', 'einherjar3', 'purpleverse3', 'viciousheroism3', 'inkarusi3', 'cyberneciacatharsis3', 'alephzero', 'hellohell', 'ichirin', 'awakeninruins', 'morningloom', 'lethalvoltage', 'leaveallbehind', 'desive', 'oldschoolsalvage', 'distortionhuman', 'epitaxy', 'hailstone', 'furetemitai'] world_unlocks = ["scenery_chap1", "scenery_chap2", - "scenery_chap3", "scenery_chap4", "scenery_chap5", "scenery_chap6", "scenery_chap7"] + "scenery_chap3", "scenery_chap4", "scenery_chap5", "scenery_chap6", "scenery_chap7", "scenery_beyond"] course_banners = ['course_banner_' + str(i) for i in range(1, 12)] diff --git a/latest version/database/init/packs.json b/latest version/database/init/packs.json index c5bf078..791f749 100644 --- a/latest version/database/init/packs.json +++ b/latest version/database/init/packs.json @@ -790,5 +790,77 @@ ], "orig_price": 500, "price": 500 + }, + { + "name": "extend_3", + "items": [ + { + "type": "pack", + "id": "extend_3", + "is_available": true + }, + { + "type": "core", + "amount": 7, + "id": "core_generic", + "is_available": true + } + ], + "price": 700, + "orig_price": 700 + }, + { + "name": "groovecoaster_append_1", + "items": [ + { + "type": "pack", + "id": "groovecoaster_append_1", + "is_available": true + }, + { + "type": "core", + "amount": 3, + "id": "core_generic", + "is_available": true + } + ], + "price": 300, + "orig_price": 300 + }, + { + "name": "nihil", + "items": [ + { + "type": "pack", + "id": "nihil", + "is_available": true + }, + { + "type": "core", + "amount": 5, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 500, + "price": 500 + }, + { + "name": "rotaeno", + "items": [ + { + "type": "pack", + "id": "rotaeno", + "is_available": true + }, + { + "type": "core", + "amount": 5, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 500, + "price": 500 } ] \ No newline at end of file diff --git a/latest version/database/init/singles.json b/latest version/database/init/singles.json index 0f9a18b..0a032af 100644 --- a/latest version/database/init/singles.json +++ b/latest version/database/init/singles.json @@ -1558,5 +1558,365 @@ ], "orig_price": 100, "price": 100 + }, + { + "name": "overdrive", + "items": [ + { + "type": "single", + "id": "overdrive", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "lightmyway", + "items": [ + { + "type": "single", + "id": "lightmyway", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "lunarossa", + "items": [ + { + "type": "single", + "id": "lunarossa", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "ionostream", + "items": [ + { + "type": "single", + "id": "ionostream", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "masqueradelegion", + "items": [ + { + "type": "single", + "id": "masqueradelegion", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "kyorenromance", + "items": [ + { + "type": "single", + "id": "kyorenromance", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "qovat", + "items": [ + { + "type": "single", + "id": "qovat", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "mirinae", + "items": [ + { + "type": "single", + "id": "mirinae", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "ultradiaxon", + "items": [ + { + "type": "single", + "id": "ultradiaxon", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "distortedfate", + "items": [ + { + "type": "single", + "id": "distortedfate", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "floatingworld", + "items": [ + { + "type": "single", + "id": "floatingworld", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "chromafill", + "items": [ + { + "type": "single", + "id": "chromafill", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "backtobasics", + "items": [ + { + "type": "single", + "id": "backtobasics", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "tasogare", + "items": [ + { + "type": "single", + "id": "tasogare", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "beautifuldreamer", + "items": [ + { + "type": "single", + "id": "beautifuldreamer", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "hypervision", + "items": [ + { + "type": "single", + "id": "hypervision", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "stargateextreme", + "items": [ + { + "type": "single", + "id": "stargateextreme", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "shrink", + "items": [ + { + "type": "single", + "id": "shrink", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "rainofconflict", + "items": [ + { + "type": "single", + "id": "rainofconflict", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 + }, + { + "name": "gensounosatellite", + "items": [ + { + "type": "single", + "id": "gensounosatellite", + "is_available": true + }, + { + "type": "core", + "amount": 1, + "id": "core_generic", + "is_available": true + } + ], + "orig_price": 100, + "price": 100 } ] \ No newline at end of file diff --git a/latest version/database/init/tables.sql b/latest version/database/init/tables.sql index 5e8d8f2..2e79e47 100644 --- a/latest version/database/init/tables.sql +++ b/latest version/database/init/tables.sql @@ -34,7 +34,9 @@ next_fragstam_ts int, max_stamina_ts int, stamina int, world_mode_locked_end_ts int, -beyond_boost_gauge real default 0 +beyond_boost_gauge real default 0, +kanae_stored_prog real default 0, +mp_notification_enabled int default 1 ); create table if not exists login(access_token text, user_id int, @@ -60,7 +62,8 @@ modifier int, time_played int, best_clear_type int, clear_type int, -rating real, +rating real default 0, +score_v2 real default 0, primary key(user_id, song_id, difficulty) ); create table if not exists user_char(user_id int, @@ -106,67 +109,22 @@ type text, amount int, primary key(character_id, item_id, type) ); -create table if not exists recent30(user_id int primary key, -r0 real, -song_id0 text, -r1 real, -song_id1 text, -r2 real, -song_id2 text, -r3 real, -song_id3 text, -r4 real, -song_id4 text, -r5 real, -song_id5 text, -r6 real, -song_id6 text, -r7 real, -song_id7 text, -r8 real, -song_id8 text, -r9 real, -song_id9 text, -r10 real, -song_id10 text, -r11 real, -song_id11 text, -r12 real, -song_id12 text, -r13 real, -song_id13 text, -r14 real, -song_id14 text, -r15 real, -song_id15 text, -r16 real, -song_id16 text, -r17 real, -song_id17 text, -r18 real, -song_id18 text, -r19 real, -song_id19 text, -r20 real, -song_id20 text, -r21 real, -song_id21 text, -r22 real, -song_id22 text, -r23 real, -song_id23 text, -r24 real, -song_id24 text, -r25 real, -song_id25 text, -r26 real, -song_id26 text, -r27 real, -song_id27 text, -r28 real, -song_id28 text, -r29 real, -song_id29 text +create table if not exists recent30( +user_id int, +r_index int, +time_played int, +song_id text, +difficulty int, +score int default 0, +shiny_perfect_count int default 0, +perfect_count int default 0, +near_count int default 0, +miss_count int default 0, +health int default 0, +modifier int default 0, +clear_type int default 0, +rating real default 0, +primary key(user_id, r_index) ); create table if not exists user_world(user_id int, map_id text, @@ -240,10 +198,11 @@ primary key(present_id, item_id, type) ); create table if not exists chart(song_id text primary key, name text, -rating_pst int, -rating_prs int, -rating_ftr int, -rating_byn int +rating_pst int default -1, +rating_prs int default -1, +rating_ftr int default -1, +rating_byn int default -1, +rating_etr int default -1 ); create table if not exists redeem(code text primary key, type int @@ -310,6 +269,12 @@ type text, amount int, primary key(course_id, item_id, type) ); +create table if not exists user_mission( +user_id int, +mission_id text, +status int, +primary key(user_id, mission_id) +); create index if not exists best_score_1 on best_score (song_id, difficulty); diff --git a/latest version/database/map/byd_inkarusi.json b/latest version/database/map/byd_inkarusi.json new file mode 100644 index 0000000..21574f2 --- /dev/null +++ b/latest version/database/map/byd_inkarusi.json @@ -0,0 +1,80 @@ +{ + "map_id": "byd_inkarusi", + "is_legacy": false, + "character_affinity": [], + "affinity_multiplier": [], + "chapter": 2001, + "available_from": -1, + "available_to": 9999999999999, + "is_repeatable": false, + "require_id": "inkarusi2", + "require_type": "chart_unlock", + "is_breached": true, + "stamina_cost": 3, + "new_law": "over100_step50", + "disable_over": true, + "coordinate": "500,0", + "beyond_health": 200, + "step_count": 5, + "custom_bg": "", + "is_beyond": true, + "requires": [ + { + "type": "chart_unlock", + "id": "inkarusi2" + } + ], + "steps": [ + { + "map_id": "byd_inkarusi", + "position": 0, + "capture": 50 + }, + { + "map_id": "byd_inkarusi", + "position": 1, + "capture": 50, + "items": [ + { + "type": "core", + "id": "core_generic", + "amount": 2 + } + ] + }, + { + "map_id": "byd_inkarusi", + "position": 2, + "capture": 50, + "items": [ + { + "type": "fragment", + "amount": 500 + } + ] + }, + { + "map_id": "byd_inkarusi", + "position": 3, + "capture": 50, + "items": [ + { + "type": "core", + "id": "core_generic", + "amount": 2 + } + ] + }, + { + "map_id": "byd_inkarusi", + "position": 4, + "capture": 0, + "items": [ + { + "id": "inkarusi3", + "type": "world_song" + } + ] + } + ] +} \ No newline at end of file diff --git a/latest version/linkplay_server/aes.py b/latest version/linkplay_server/aes.py index 7248382..c167af3 100644 --- a/latest version/linkplay_server/aes.py +++ b/latest version/linkplay_server/aes.py @@ -8,7 +8,7 @@ def encrypt(key, plaintext, associated_data): iv = urandom(12) encryptor = Cipher( algorithms.AES(key), - modes.GCM(iv, min_tag_length=12), + modes.GCM(iv, min_tag_length=16), ).encryptor() encryptor.authenticate_additional_data(associated_data) ciphertext = encryptor.update(plaintext) + encryptor.finalize() @@ -18,7 +18,7 @@ def encrypt(key, plaintext, associated_data): def decrypt(key, associated_data, iv, ciphertext, tag): decryptor = Cipher( algorithms.AES(key), - modes.GCM(iv, tag, min_tag_length=12), + modes.GCM(iv, tag, min_tag_length=16), ).decryptor() decryptor.authenticate_additional_data(associated_data) return decryptor.update(ciphertext) + decryptor.finalize() diff --git a/latest version/linkplay_server/config.py b/latest version/linkplay_server/config.py index de43994..25fd797 100644 --- a/latest version/linkplay_server/config.py +++ b/latest version/linkplay_server/config.py @@ -24,9 +24,16 @@ class Config: COMMAND_INTERVAL = 1000000 - COUNTDOWM_TIME = 3999 - PLAYER_PRE_TIMEOUT = 3000000 - PLAYER_TIMEOUT = 20000000 + PLAYER_TIMEOUT = 15000000 LINK_PLAY_UNLOCK_LENGTH = 512 + + COUNTDOWN_SONG_READY = 4 * 1000000 + COUNTDOWN_SONG_START = 6 * 1000000 + + # 计时模式 + COUNTDOWN_MATCHING = 15 * 1000000 + COUNTDOWN_SELECT_SONG = 45 * 1000000 + COUNTDOWN_SELECT_DIFFICULTY = 45 * 1000000 + COUNTDOWN_RESULT = 60 * 1000000 diff --git a/latest version/linkplay_server/main.py b/latest version/linkplay_server/main.py index 1963f05..292e9f9 100644 --- a/latest version/linkplay_server/main.py +++ b/latest version/linkplay_server/main.py @@ -1,4 +1,4 @@ -# import binascii +import binascii import logging import socketserver import threading @@ -21,11 +21,12 @@ def handle(self): try: token = client_msg[:8] iv = client_msg[8:20] - tag = client_msg[20:32] - ciphertext = client_msg[32:] - if bi(token) not in Store.link_play_data: + tag = client_msg[20:36] + ciphertext = client_msg[36:] + + user = Store.link_play_data.get(bi(token)) + if user is None: return None - user = Store.link_play_data[bi(token)] plaintext = decrypt(user['key'], b'', iv, ciphertext, tag) except Exception as e: @@ -52,8 +53,7 @@ def handle(self): # logging.info( # f'UDP-To-{self.client_address[0]}-{binascii.b2a_hex(i)}') - server.sendto(token + iv + tag[:12] + - ciphertext, self.client_address) + server.sendto(token + iv + tag + ciphertext, self.client_address) AUTH_LEN = len(Config.AUTHENTICATION) @@ -77,7 +77,7 @@ def handle(self): return None iv = self.rfile.read(12) - tag = self.rfile.read(12) + tag = self.rfile.read(16) ciphertext = self.rfile.read(cipher_len) self.data = decrypt(TCP_AES_KEY, b'', iv, ciphertext, tag) @@ -96,8 +96,8 @@ def handle(self): if Config.DEBUG: logging.info(f'TCP-To-{self.client_address[0]}-{r}') iv, ciphertext, tag = encrypt(TCP_AES_KEY, r.encode('utf-8'), b'') - r = len(ciphertext).to_bytes(8, byteorder='little') + \ - iv + tag[:12] + ciphertext + r = len(ciphertext).to_bytes( + 8, byteorder='little') + iv + tag + ciphertext except Exception as e: logging.error(e) return None diff --git a/latest version/linkplay_server/store.py b/latest version/linkplay_server/store.py index fdb7b71..7120a44 100644 --- a/latest version/linkplay_server/store.py +++ b/latest version/linkplay_server/store.py @@ -7,15 +7,18 @@ from .config import Config from .udp_class import Player, Room, bi +from .udp_sender import CommandSender class Store: # token: {'key': key, 'room': Room, 'player_index': player_index, 'player_id': player_id} link_play_data = {} - room_id_dict = {} # 'room_id': Room + room_id_dict: "dict[int, Room]" = {} # 'room_id': Room room_code_dict = {} # 'room_code': Room player_dict = {} # 'player_id' : Player + share_token_dict = {} # 'share_token': Room + lock = RLock() @@ -28,6 +31,14 @@ def random_room_code(): return re +def random_share_token(): + CHARSET = 'abcdefghijklmnopqrstuvwxyz0123456789' + re = '' + for _ in range(10): + re += CHARSET[randint(0, 35)] + return re + + def unique_random(dataset, length=8, random_func=None): '''无重复随机,且默认非0,没处理可能的死循环''' if random_func is None: @@ -45,18 +56,27 @@ def clear_player(token): # 清除玩家信息和token player_id = Store.link_play_data[token]['player_id'] logging.info(f'Clean player `{Store.player_dict[player_id].name}`') - del Store.player_dict[player_id] - del Store.link_play_data[token] + with Store.lock: + if player_id in Store.player_dict: + del Store.player_dict[player_id] + if token in Store.link_play_data: + del Store.link_play_data[token] def clear_room(room): # 清除房间信息 room_id = room.room_id room_code = room.room_code + share_token = room.share_token logging.info(f'Clean room `{room_code}`') - del Store.room_id_dict[room_id] - del Store.room_code_dict[room_code] - del room + with Store.lock: + if room_id in Store.room_id_dict: + del Store.room_id_dict[room_id] + if room_code in Store.room_code_dict: + del Store.room_code_dict[room_code] + if share_token in Store.share_token_dict: + del Store.share_token_dict[share_token] + del room def memory_clean(now): @@ -92,6 +112,8 @@ class TCPRouter: 'join_room', 'update_room', 'get_rooms', + 'select_room', + 'get_match_rooms' } def __init__(self, raw_data: 'dict | list'): @@ -115,12 +137,12 @@ def clean_check(): def handle(self) -> dict: self.clean_check() if self.endpoint not in self.router: - return None + return {'code': 999} try: r = getattr(self, self.endpoint)() except Exception as e: logging.error(e) - return 999 + return {'code': 999} if isinstance(r, int): return {'code': r} return { @@ -144,7 +166,7 @@ def generate_room() -> Room: room_id = unique_random(Store.room_id_dict) room = Room() room.room_id = room_id - room.timestamp = round(time() * 1000) + room.timestamp = round(time() * 1000000) Store.room_id_dict[room_id] = room room_code = unique_random( @@ -152,6 +174,11 @@ def generate_room() -> Room: room.room_code = room_code Store.room_code_dict[room_code] = room + share_token = unique_random( + Store.share_token_dict, random_func=random_share_token) + room.share_token = share_token + Store.share_token_dict[share_token] = room + return room def create_room(self) -> dict: @@ -160,6 +187,9 @@ def create_room(self) -> dict: # song_unlock: base64 str name = self.data['name'] song_unlock = b64decode(self.data['song_unlock']) + rating_ptt = self.data.get('rating_ptt', 0) + is_hide_rating = self.data.get('is_hide_rating', False) + match_times = self.data.get('match_times', None) key = urandom(16) with Store.lock: @@ -167,6 +197,9 @@ def create_room(self) -> dict: player = self.generate_player(name) player.song_unlock = song_unlock + player.rating_ptt = rating_ptt + player.is_hide_rating = is_hide_rating + player.player_index = 0 room.song_unlock = song_unlock room.host_id = player.player_id room.players[0] = player @@ -174,6 +207,12 @@ def create_room(self) -> dict: token = room.room_id player.token = token + # 匹配模式追加 + if match_times is not None: + room.is_public = 1 + room.round_mode = 3 + room.timed_mode = 1 + Store.link_play_data[token] = { 'key': key, 'room': room, @@ -198,6 +237,9 @@ def join_room(self) -> 'dict | int': key = urandom(16) name = self.data['name'] song_unlock = b64decode(self.data['song_unlock']) + rating_ptt = self.data.get('rating_ptt', 0) + is_hide_rating = self.data.get('is_hide_rating', False) + match_times = self.data.get('match_times', None) with Store.lock: if room_code not in Store.room_code_dict: @@ -212,7 +254,7 @@ def join_room(self) -> 'dict | int': if player_num == 0: # 房间不存在 return 1202 - if room.state != 2: + if room.state not in (0, 1, 2) or (room.is_public and match_times is None): # 无法加入 return 1205 @@ -221,16 +263,18 @@ def join_room(self) -> 'dict | int': player = self.generate_player(name) player.token = token player.song_unlock = song_unlock + player.rating_ptt = rating_ptt + player.is_hide_rating = is_hide_rating room.update_song_unlock() for i in range(4): if room.players[i].player_id == 0: room.players[i] = player - player_index = i + player.player_index = i break Store.link_play_data[token] = { 'key': key, 'room': room, - 'player_index': player_index, + 'player_index': player.player_index, 'player_id': player.player_id } @@ -248,11 +292,23 @@ def update_room(self) -> dict: # 房间信息更新 # data = ['3', token] token = int(self.data['token']) + rating_ptt = self.data.get('rating_ptt', 0) + is_hide_rating = self.data.get('is_hide_rating', False) + with Store.lock: if token not in Store.link_play_data: return 108 r = Store.link_play_data[token] room = r['room'] + + # 更新玩家信息 + player_index = r['player_index'] + player = room.players[player_index] + player.rating_ptt = rating_ptt + player.is_hide_rating = is_hide_rating + cs = CommandSender(room) + room.command_queue.append(cs.command_12(player_index)) + logging.info(f'TCP-Room `{room.room_code}` info update') return { 'room_code': room.room_code, @@ -300,3 +356,55 @@ def get_rooms(self) -> dict: 'has_more': f2, 'rooms': rooms } + + def select_room(self) -> dict: + # 查询房间信息 + + room_code = self.data.get('room_code', None) + share_token = self.data.get('share_token', None) + + if room_code is not None: + room = Store.room_code_dict.get(room_code, None) + elif share_token is not None: + room = Store.share_token_dict.get(share_token, None) + if room is None: + return 108 + + return { + 'room_id': room.room_id, + 'room_code': room.room_code, + 'share_token': room.share_token, + 'is_enterable': room.is_enterable, + 'is_matchable': room.is_matchable, + 'is_playing': room.is_playing, + 'is_public': room.is_public == 1, + 'timed_mode': room.timed_mode == 1, + } + + def get_match_rooms(self): + n = 0 + rooms = [] + + for room in Store.room_id_dict.values(): + if not room.is_matchable: + continue + + rooms.append({ + 'room_id': room.room_id, + 'room_code': room.room_code, + 'share_token': room.share_token, + 'is_matchable': room.is_matchable, + 'next_state_timestamp': room.next_state_timestamp, + 'song_unlock': b64encode(room.song_unlock).decode('utf-8'), + 'players': [{ + 'player_id': i.player_id, + 'name': i.name, + 'rating_ptt': i.rating_ptt + } for i in room.players] + }) + if n >= 100: + break + return { + 'amount': n, + 'rooms': rooms + } diff --git a/latest version/linkplay_server/udp_class.py b/latest version/linkplay_server/udp_class.py index 617906b..c6266d8 100644 --- a/latest version/linkplay_server/udp_class.py +++ b/latest version/linkplay_server/udp_class.py @@ -1,5 +1,6 @@ import logging from time import time +from random import randint from .config import Config @@ -12,26 +13,73 @@ def bi(value): return int.from_bytes(value, byteorder='little') -class Player: +class Score: def __init__(self) -> None: - self.player_id = 0 - self.player_name = b'\x45\x6d\x70\x74\x79\x50\x6c\x61\x79\x65\x72\x00\x00\x00\x00\x00' - self.token = 0 + self.difficulty = 0xff - self.character_id = 0xff - self.last_character_id = 0xff - self.is_uncapped = 0 + self.score = 0 + self.cleartype = 0 + self.timer = 0 + self.best_score_flag = 0 # personal best + self.best_player_flag = 0 # high score + + # 5.10 新增 + self.shiny_perfect_count = 0 # 2 bytes + self.perfect_count = 0 # 2 bytes + self.near_count = 0 # 2 bytes + self.miss_count = 0 # 2 bytes + self.early_count = 0 # 2 bytes + self.late_count = 0 # 2 bytes + + self.healthy = 0 # 4 bytes signed? 不确定,但似乎没影响 + + def copy(self, x: 'Score'): + self.difficulty = x.difficulty + self.score = x.score + self.cleartype = x.cleartype + self.timer = x.timer + self.best_score_flag = x.best_score_flag + self.best_player_flag = x.best_player_flag + self.shiny_perfect_count = x.shiny_perfect_count + self.perfect_count = x.perfect_count + self.near_count = x.near_count + self.miss_count = x.miss_count + self.early_count = x.early_count + self.late_count = x.late_count + self.healthy = x.healthy + + def clear(self): self.difficulty = 0xff - self.last_difficulty = 0xff self.score = 0 - self.last_score = 0 - self.timer = 0 - self.last_timer = 0 self.cleartype = 0 - self.last_cleartype = 0 + self.timer = 0 self.best_score_flag = 0 self.best_player_flag = 0 + self.shiny_perfect_count = 0 + self.perfect_count = 0 + self.near_count = 0 + self.miss_count = 0 + self.early_count = 0 + self.late_count = 0 + self.healthy = 0 + + def __str__(self): + return f'Score: {self.score}, Cleartype: {self.cleartype}, Difficulty: {self.difficulty}, Timer: {self.timer}, Best Score Flag: {self.best_score_flag}, Best Player Flag: {self.best_player_flag}, Shiny Perfect: {self.shiny_perfect_count}, Perfect: {self.perfect_count}, Near: {self.near_count}, Miss: {self.miss_count}, Early: {self.early_count}, Late: {self.late_count}, Healthy: {self.healthy}' + + +class Player: + def __init__(self, player_index: int = 0) -> None: + self.player_id = 0 + self.player_name = b'\x45\x6d\x70\x74\x79\x50\x6c\x61\x79\x65\x72\x00\x00\x00\x00\x00' + self.token = 0 + + self.character_id = 0xff + self.is_uncapped = 0 + + self.score = Score() + self.last_score = Score() + self.finish_flag = 0 self.player_state = 1 @@ -45,6 +93,16 @@ def __init__(self) -> None: self.start_command_num = 0 + # 5.10 新增 + + self.voting: int = 0x8000 # 2 bytes, song_idx, 0xffff 为不选择,0x8000 为默认值 + self.player_index: int = player_index # 1 byte 不确定对不对 + self.switch_2: int = 0 # 1 byte + + self.rating_ptt: int = 0 # 2 bytes + self.is_hide_rating: int = 0 # 1 byte + self.is_staff: int = 0 # 1 byte + @property def name(self) -> str: return self.player_name.decode('ascii').rstrip('\x00') @@ -56,15 +114,23 @@ def to_dict(self) -> dict: 'is_online': self.online == 1, 'character_id': self.character_id, 'is_uncapped': self.is_uncapped == 1, + 'rating_ptt': self.rating_ptt, + 'is_hide_rating': self.is_hide_rating == 1, 'last_song': { - 'difficulty': self.last_difficulty, - 'score': self.last_score, - 'cleartype': self.last_cleartype, + 'difficulty': self.last_score.difficulty, + 'score': self.last_score.score, + 'cleartype': self.last_score.cleartype, + 'shine_perfect': self.last_score.shiny_perfect_count, + 'perfect': self.last_score.perfect_count, + 'near': self.last_score.near_count, + 'miss': self.last_score.miss_count, + 'early': self.last_score.early_count, + 'late': self.last_score.late_count, }, 'song': { - 'difficulty': self.difficulty, - 'score': self.score, - 'cleartype': self.cleartype, + 'difficulty': self.score.difficulty, + 'score': self.score.score, + 'cleartype': self.score.cleartype, }, 'player_state': self.player_state, 'last_timestamp': self.last_timestamp, @@ -77,52 +143,140 @@ def set_player_name(self, player_name: str): else: self.player_name += b'\x00' * (16 - len(self.player_name)) + @property + def info(self) -> bytes: + re = bytearray() + re.extend(b(self.player_id, 8)) + re.append(self.character_id) + re.append(self.is_uncapped) + re.append(self.score.difficulty) + re.extend(b(self.score.score, 4)) + re.extend(b(self.score.timer, 4)) + re.append(self.score.cleartype) + re.append(self.player_state) + re.append(self.download_percent) + re.append(self.online) + + re.extend(b(self.voting, 2)) + re.append(self.player_index) + re.append(self.switch_2) + re.extend(b(self.rating_ptt, 2)) + re.append(self.is_hide_rating) + re.append(self.is_staff) + + return bytes(re) + + @property + def last_score_info(self) -> bytes: + if self.player_id == 0: + return b'\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + x = self.last_score + re = bytearray() + re.append(self.character_id) + re.append(x.difficulty) + re.extend(b(x.score, 4)) + re.append(x.cleartype) + re.append(x.best_score_flag) + re.append(x.best_player_flag) + re.extend(b(x.shiny_perfect_count, 2)) + re.extend(b(x.perfect_count, 2)) + re.extend(b(x.near_count, 2)) + re.extend(b(x.miss_count, 2)) + re.extend(b(x.early_count, 2)) + re.extend(b(x.late_count, 2)) + re.extend(b(x.healthy, 4)) + + return bytes(re) + class Room: + def __init__(self) -> None: self.room_id = 0 self.room_code = 'AAAA00' + self.share_token = 'abcde12345' # 5.10 新增 self.countdown = 0xffffffff self.timestamp = 0 - self.state = 0 - self.song_idx = 0xffff - self.last_song_idx = 0xffff + self._state = 0 + self.song_idx = 0xffff # 疑似 idx * 5 + self.last_song_idx = 0xffff # 疑似 idx * 5 self.song_unlock = b'\xFF' * Config.LINK_PLAY_UNLOCK_LENGTH self.host_id = 0 - self.players = [Player(), Player(), Player(), Player()] + self.players = [Player(0), Player(1), Player(2), Player(3)] self.interval = 1000 - self.times = 100 + self.times = 100 # ??? - self.round_switch = 0 + self.round_mode: int = 1 # 5.10 从 bool 修改为 int 1~3 + self.is_public = 0 # 5.10 新增 + self.timed_mode = 0 # 5.10 新增 + + self.selected_voter_player_id: int = 0 # 5.10 新增 self.command_queue = [] + self.next_state_timestamp = 0 # 计时模式下一个状态时间 + + @property + def state(self) -> int: + return self._state + + @state.setter + def state(self, value: int): + self._state = value + self.countdown = 0xffffffff + def to_dict(self) -> dict: p = [i.to_dict() for i in self.players if i.player_id != 0] for i in p: - i['is_host'] = i['player_id'] == self.host_id + i['is_host'] = i['multiplay_player_id'] == self.host_id return { 'room_id': self.room_id, 'room_code': self.room_code, + 'share_token': self.share_token, 'state': self.state, 'song_idx': self.song_idx, 'last_song_idx': self.last_song_idx if not self.is_playing else 0xffff, 'host_id': self.host_id, 'players': p, - 'round_switch': self.round_switch == 1, + 'round_mode': self.round_mode, 'last_timestamp': self.timestamp, 'is_enterable': self.is_enterable, + 'is_matchable': self.is_matchable, 'is_playing': self.is_playing, + 'is_public': self.is_public == 1, + 'timed_mode': self.timed_mode == 1, } + @property + def room_info(self) -> bytes: + re = bytearray() + re.extend(b(self.host_id, 8)) + re.append(self.state) + re.extend(b(self.countdown, 4)) + re.extend(b(self.timestamp, 8)) + re.extend(b(self.song_idx, 2)) + re.extend(b(self.interval, 2)) + re.extend(b(self.times, 7)) + re.extend(self.get_player_last_score()) + re.extend(b(self.last_song_idx, 2)) + re.append(self.round_mode) + re.append(self.is_public) + re.append(self.timed_mode) + re.extend(b(self.selected_voter_player_id, 8)) + return bytes(re) + @property def is_enterable(self) -> bool: return 0 < self.player_num < 4 and self.state == 2 + @property + def is_matchable(self) -> bool: + return self.is_public and 0 < self.player_num < 4 and self.state == 1 + @property def is_playing(self) -> bool: return self.state in (4, 5, 6, 7) @@ -133,7 +287,9 @@ def command_queue_length(self) -> int: @property def player_num(self) -> int: - self.check_player_online() + now = round(time() * 1000000) + if now - self.timestamp >= 1000000: + self.check_player_online(now) return sum(i.player_id != 0 for i in self.players) def check_player_online(self, now: int = None): @@ -156,29 +312,18 @@ def check_player_online(self, now: int = None): def get_players_info(self): # 获取所有玩家信息 - re = b'' + re = bytearray() for i in self.players: - re += b(i.player_id, 8) + b(i.character_id) + b(i.is_uncapped) + b(i.difficulty) + b(i.score, 4) + \ - b(i.timer, 4) + b(i.cleartype) + b(i.player_state) + \ - b(i.download_percent) + b(i.online) + b'\x00' + i.player_name - return re + re.extend(i.info) + re.append(0) + re.extend(i.player_name) + return bytes(re) def get_player_last_score(self): # 获取上次曲目玩家分数,返回bytes if self.last_song_idx == 0xffff: - return b'\xff\xff\x00\x00\x00\x00\x00\x00\x00' * 4 - re = b'' - - for i in range(4): - player = self.players[i] - - if player.player_id != 0: - re += b(player.last_character_id) + b(player.last_difficulty) + b(player.last_score, 4) + b( - player.last_cleartype) + b(player.best_score_flag) + b(player.best_player_flag) - else: - re += b'\xff\xff\x00\x00\x00\x00\x00\x00\x00' - - return re + return b'\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' * 4 + return b''.join(i.last_score_info for i in self.players) def make_round(self): # 轮换房主 @@ -203,9 +348,18 @@ def delete_player(self, player_index: int): f'Player `{player.name}` leaves room `{self.room_code}`') self.players[player_index].online = 0 - self.players[player_index] = Player() + self.players[player_index] = Player(player_index) self.update_song_unlock() + if self.state in (2, 3): + self.state = 1 + self.song_idx = 0xffff + self.voting_clear() + + if self.state in (1, 2) and self.timed_mode and self.player_num <= 1: + self.next_state_timestamp = 0 + self.countdown = 0xffffffff + def update_song_unlock(self): # 更新房间可用歌曲 r = bi(b'\xff' * Config.LINK_PLAY_UNLOCK_LENGTH) @@ -227,7 +381,7 @@ def is_ready(self, old_state: int, player_state: int): def is_finish(self): # 是否全部进入结算 - if self.state == 8: + if self.state != 7: return False for i in self.players: @@ -245,27 +399,110 @@ def make_finish(self): max_score_i = [] for i in range(4): player = self.players[i] - if player.player_id != 0: - player.finish_flag = 0 - player.last_timer = player.timer - player.last_score = player.score - player.last_cleartype = player.cleartype - player.last_character_id = player.character_id - player.last_difficulty = player.difficulty - player.best_player_flag = 0 - - if player.last_score > max_score: - max_score = player.last_score - max_score_i = [i] - elif player.last_score == max_score: - max_score_i.append(i) + if player.player_id == 0: + continue + player.finish_flag = 0 + player.last_score.copy(player.score) + player.last_score.best_player_flag = 0 + + if player.last_score.score > max_score: + max_score = player.last_score.score + max_score_i = [i] + elif player.last_score.score == max_score: + max_score_i.append(i) for i in max_score_i: - self.players[i].best_player_flag = 1 + self.players[i].last_score.best_player_flag = 1 + + self.voting_clear() + for i in self.players: + i.score.clear() logging.info( f'Room `{self.room_code}` finishes song `{self.song_idx}`') for i in self.players: if i.player_id != 0: - logging.info( - f'- Player `{i.name}` - Score: {i.last_score} Cleartype: {i.last_cleartype} Difficulty: {i.last_difficulty}') + logging.info(f'- Player `{i.name}` - {i.last_score}') + + @property + def is_all_player_voted(self) -> bool: + # 是否所有玩家都投票 + if self.state != 2: + return False + + for i in self.players: + if i.player_id != 0 and i.voting == 0x8000: + return False + + return True + + def random_song(self): + random_list = [] + for i in range(Config.LINK_PLAY_UNLOCK_LENGTH): + for j in range(8): + if self.song_unlock[i] & (1 << j): + random_list.append(i * 8 + j) + + if not random_list: + self.song_idx = 0 + else: + self.song_idx = random_list[randint(0, len(random_list) - 1)] + + def make_voting(self): + # 投票 + self.state = 3 + self.selected_voter_player_id = 0 + + random_list = [] + random_list_player_id = [] + for i in self.players: + if i.player_id == 0 or i.voting == 0xffff or i.voting == 0x8000: + continue + random_list.append(i.voting) + random_list_player_id.append(i.player_id) + + if random_list: + idx = randint(0, len(random_list) - 1) + self.song_idx = random_list[idx] * 5 + self.selected_voter_player_id = random_list_player_id[idx] + else: + self.random_song() + + logging.info( + f'Room `{self.room_code}` votes song `{self.song_idx}`') + + def voting_clear(self): + # 清除投票 + self.selected_voter_player_id = 0 + for i in self.players: + i.voting = 0x8000 + + @property + def should_next_state(self) -> bool: + if not self.timed_mode and self.state not in (4, 5, 6): + self.countdown = 0xffffffff + return False + now = round(time() * 1000000) + if self.countdown == 0xffffffff: + # 还没开始计时 + if self.is_public and self.state == 1: + self.next_state_timestamp = now + Config.COUNTDOWN_MATCHING + elif self.state == 2: + self.next_state_timestamp = now + Config.COUNTDOWN_SELECT_SONG + elif self.state == 3: + self.next_state_timestamp = now + Config.COUNTDOWN_SELECT_DIFFICULTY + elif self.state == 4: + self.next_state_timestamp = now + Config.COUNTDOWN_SONG_READY + elif self.state == 5 or self.state == 6: + self.next_state_timestamp = now + Config.COUNTDOWN_SONG_START + elif self.state == 8: + self.next_state_timestamp = now + Config.COUNTDOWN_RESULT + else: + return False + + # 不是哥们,616 你脑子怎么长的,上个版本是毫秒时间戳,新版本变成了微秒???那你这倒计时怎么还是毫秒啊!!! + self.countdown = (self.next_state_timestamp - now) // 1000 + if self.countdown <= 0: + self.countdown = 0 + return True + return False diff --git a/latest version/linkplay_server/udp_parser.py b/latest version/linkplay_server/udp_parser.py index 18f9b21..8b8e81b 100644 --- a/latest version/linkplay_server/udp_parser.py +++ b/latest version/linkplay_server/udp_parser.py @@ -1,14 +1,27 @@ import logging -import time -from .udp_class import Room, bi from .config import Config +from .udp_class import Room, bi from .udp_sender import CommandSender class CommandParser: - route = [None, 'command_01', 'command_02', 'command_03', 'command_04', 'command_05', - 'command_06', 'command_07', 'command_08', 'command_09', 'command_0a', 'command_0b'] + + route = { + 0x01: 'command_01', + 0x02: 'command_02', + 0x03: 'command_03', + 0x04: 'command_04', + 0x06: 'command_06', + 0x07: 'command_07', + 0x08: 'command_08', + 0x09: 'command_09', + 0x0a: 'command_0a', + 0x0b: 'command_0b', + 0x20: 'command_20', + 0x22: 'command_22', + 0x23: 'command_23', + } def __init__(self, room: Room, player_index: int = 0) -> None: self.room = room @@ -31,7 +44,7 @@ def get_commands(self, command: bytes): re.append(self.room.command_queue[i]) if self.room.players[self.player_index].extra_command_queue: - re += self.room.players[self.player_index].extra_command_queue + re += self.room.players[self.player_index].extra_command_queue[-12:] self.room.players[self.player_index].extra_command_queue = [] if r: @@ -52,10 +65,14 @@ def command_01(self): self.room.command_queue.append(self.s.command_10()) def command_02(self): + # 房主选歌 + if self.room.round_mode == 3: + logging.warning('Error: round_mode == 3 in command 02') + return None self.s.random_code = self.command[16:24] song_idx = bi(self.command[24:26]) - flag = 2 + flag = 5 if self.room.state == 2: flag = 0 self.room.state = 3 @@ -69,10 +86,17 @@ def command_03(self): # 尝试进入结算 self.s.random_code = self.command[16:24] player = self.room.players[self.player_index] - player.score = bi(self.command[24:28]) - player.cleartype = self.command[28] - player.difficulty = self.command[29] - player.best_score_flag = self.command[30] + player.score.score = bi(self.command[24:28]) + player.score.cleartype = self.command[28] + player.score.difficulty = self.command[29] + player.score.best_score_flag = self.command[30] + player.score.shiny_perfect_count = bi(self.command[31:33]) + player.score.perfect_count = bi(self.command[33:35]) + player.score.near_count = bi(self.command[35:37]) + player.score.miss_count = bi(self.command[37:39]) + player.score.early_count = bi(self.command[39:41]) + player.score.late_count = bi(self.command[41:43]) + player.score.healthy = bi(self.command[43:47]) player.finish_flag = 1 player.last_timestamp -= Config.COMMAND_INTERVAL self.room.last_song_idx = self.room.song_idx @@ -94,19 +118,16 @@ def command_04(self): flag = 1 self.room.delete_player(i) self.room.command_queue.append(self.s.command_12(i)) - self.room.update_song_unlock() self.room.command_queue.append(self.s.command_14()) break return [self.s.command_0d(flag)] - def command_05(self): - pass - def command_06(self): self.s.random_code = self.command[16:24] self.room.state = 1 self.room.song_idx = 0xffff + self.room.voting_clear() self.room.command_queue.append(self.s.command_13()) @@ -117,13 +138,17 @@ def command_07(self): self.room.command_queue.append(self.s.command_14()) + # 07 可能需要一个 0d 响应,code = 0x0b + def command_08(self): - self.room.round_switch = bi(self.command[24:25]) - self.s.random_code = self.command[16:24] - self.room.command_queue.append(self.s.command_13()) + # 可能弃用 + logging.warning('Command 08 is outdated') + pass + # self.room.round_mode = bi(self.command[24:25]) + # self.s.random_code = self.command[16:24] + # self.room.command_queue.append(self.s.command_13()) def command_09(self): - re = [] self.s.random_code = self.command[16:24] player = self.room.players[self.player_index] @@ -133,133 +158,167 @@ def command_09(self): self.room.update_song_unlock() player.start_command_num = self.room.command_queue_length self.room.command_queue.append(self.s.command_15()) - else: - if self.s.timestamp - player.last_timestamp >= Config.COMMAND_INTERVAL: - re.append(self.s.command_0c()) - player.last_timestamp = self.s.timestamp + return None - # 离线判断 - flag_13, player_index_list = self.room.check_player_online( - self.s.timestamp) - for i in player_index_list: - self.room.command_queue.append(self.s.command_12(i)) + flag_0c = False - flag_11 = False - flag_12 = False + if self.s.timestamp - player.last_timestamp >= Config.COMMAND_INTERVAL: + flag_0c = True + player.last_timestamp = self.s.timestamp - if player.online == 0: - flag_12 = True - player.online = 1 + # 离线判断 + flag_13, player_index_list = self.room.check_player_online( + self.s.timestamp) + for i in player_index_list: + self.room.command_queue.append(self.s.command_12(i)) - if self.room.is_ready(1, 1): - flag_13 = True - self.room.state = 2 - - if player.player_state != self.command[32]: - flag_12 = True - player.player_state = self.command[32] - - if player.difficulty != self.command[33] and player.player_state != 5 and player.player_state != 6 and player.player_state != 7 and player.player_state != 8: - flag_12 = True - player.difficulty = self.command[33] + flag_11 = False + flag_12 = False - if player.cleartype != self.command[34] and player.player_state != 7 and player.player_state != 8: - flag_12 = True - player.cleartype = self.command[34] - - if player.download_percent != self.command[35]: - flag_12 = True - player.download_percent = self.command[35] + if player.online == 0: + flag_12 = True + player.online = 1 - if player.character_id != self.command[36]: - flag_12 = True - player.character_id = self.command[36] + if self.room.timed_mode and self.room.state in (1, 2) and player.player_state == 8: + # 还在结算给踢了 + # 冗余,为了保险 + self.room.delete_player(self.player_index) + self.room.command_queue.append( + self.s.command_12(self.player_index)) + self.room.command_queue.append(self.s.command_14()) + + if self.room.is_ready(1, 1) and ((self.room.player_num > 1 and not self.room.is_public) or (self.room.is_public and self.room.player_num == 4)): + flag_13 = True + self.room.state = 2 + + if self.room.state == 1 and self.room.is_public and self.room.player_num > 1 and self.room.should_next_state: + flag_0c = True + flag_13 = True + self.room.state = 2 + + if self.room.state in (2, 3) and self.room.player_num < 2: + flag_13 = True + self.room.state = 1 - if player.is_uncapped != self.command[37]: - flag_12 = True - player.is_uncapped = self.command[37] + if self.room.state == 2 and self.room.should_next_state: + flag_0c = True + self.room.state = 3 + flag_13 = True + if self.room.round_mode == 3: + self.room.make_voting() + else: + self.room.random_song() + + if player.player_state != self.command[32]: + flag_12 = True + player.player_state = self.command[32] + + if player.score.difficulty != self.command[33] and player.player_state not in (5, 6, 7, 8): + flag_12 = True + player.score.difficulty = self.command[33] + + if player.score.cleartype != self.command[34] and player.player_state != 7 and player.player_state != 8: + flag_12 = True + player.score.cleartype = self.command[34] + + if player.download_percent != self.command[35]: + flag_12 = True + player.download_percent = self.command[35] + + if player.character_id != self.command[36]: + flag_12 = True + player.character_id = self.command[36] + + if player.is_uncapped != self.command[37]: + flag_12 = True + player.is_uncapped = self.command[37] + + if self.room.state == 3 and player.score.score != bi(self.command[24:28]): + flag_12 = True + player.score.score = bi(self.command[24:28]) + + if self.room.is_ready(3, 4) or (self.room.state == 3 and self.room.should_next_state): + flag_13 = True + flag_0c = True + self.room.state = 4 + + if self.room.round_mode == 2: + # 将换房主时间提前到此刻 + self.room.make_round() + logging.info(f'Room `{self.room.room_code}` starts playing') + + if self.room.state == 4: + # 这好像会误判 + # if player.download_percent < 99: + # # 有人没下载完把他踢了! + # self.room.delete_player(self.player_index) + # self.room.command_queue.append( + # self.s.command_12(self.player_index)) + # self.room.command_queue.append(self.s.command_14()) + + if self.room.should_next_state: + self.room.state = 5 + flag_11 = True + flag_13 = True - if self.room.state == 3 and player.score != bi(self.command[24:28]): - flag_12 = True - player.score = bi(self.command[24:28]) + if self.room.state == 5: + flag_13 = True + if self.room.is_ready(5, 6): + self.room.state = 6 + if self.room.is_ready(5, 7): + self.room.state = 7 + + if self.room.state in (5, 6) and self.room.should_next_state: + # 此处不清楚 + self.room.state = 7 + flag_13 = True + + if self.room.state in (7, 8): + player_now_timer = bi(self.command[28:32]) + if player.score.timer < player_now_timer or player_now_timer == 0 and player.score.timer != 0: + player.last_score.timer = player.score.timer + player.last_score.score = player.score.score + player.score.timer = player_now_timer + player.score.score = bi(self.command[24:28]) + + if player.score.timer != 0 or self.room.state != 8: + for i in self.room.players: + i.extra_command_queue.append( + self.s.command_0e(self.player_index)) + + if self.room.is_ready(8, 1): + flag_13 = True + self.room.state = 1 + self.room.song_idx = 0xffff - if self.room.is_ready(3, 4): + if self.room.state == 8 and self.room.should_next_state: + flag_0c = True flag_13 = True - self.room.countdown = Config.COUNTDOWM_TIME - self.room.timestamp = round(time.time() * 1000) - self.room.state = 4 - if self.room.round_switch == 1: - # 将换房主时间提前到此刻 - self.room.make_round() - - logging.info(f'Room `{self.room.room_code}` starts playing') - - if self.room.state in (4, 5, 6): - timestamp = round(time.time() * 1000) - self.room.countdown -= timestamp - self.room.timestamp - self.room.timestamp = timestamp - if self.room.state == 4 and self.room.countdown <= 0: - # 此处不清楚 - self.room.state = 5 - self.room.countdown = 5999 - flag_11 = True - flag_13 = True - - if self.room.state == 5 and self.room.is_ready(5, 6): - self.room.state = 6 - flag_13 = True - - if self.room.state == 5 and self.room.is_ready(5, 7): - self.room.state = 7 - self.room.countdown = 0xffffffff - flag_13 = True - - if self.room.state == 5 and self.room.countdown <= 0: - print('我怎么知道这是啥') - - if self.room.state == 6 and self.room.countdown <= 0: - # 此处不清楚 - self.room.state = 7 - self.room.countdown = 0xffffffff - flag_13 = True - - self.room.countdown = self.room.countdown if self.room.countdown > 0 else 0 - - if self.room.state in (7, 8): - if player.timer < bi(self.command[28:32]) or bi(self.command[28:32]) == 0 and player.timer != 0: - player.last_timer = player.timer - player.last_score = player.score - player.timer = bi(self.command[28:32]) - player.score = bi(self.command[24:28]) - - if player.timer != 0 or self.room.state != 8: - for i in self.room.players: - i.extra_command_queue.append( - self.s.command_0e(self.player_index)) - - if self.room.is_ready(8, 1): - flag_13 = True - self.room.state = 1 - self.room.song_idx = 0xffff - - for i in self.room.players: - i.timer = 0 - i.score = 0 - - if self.room.is_finish(): - # 有人退房导致的结算 - self.room.make_finish() - flag_13 = True - - if flag_11: - self.room.command_queue.append(self.s.command_11()) - if flag_12: + self.room.state = 1 + self.room.song_idx = 0xffff + + if self.room.timed_mode and self.room.state in (1, 2) and player.player_state == 8: + # 还在结算给踢了 + self.room.delete_player(self.player_index) self.room.command_queue.append( self.s.command_12(self.player_index)) - if flag_13: - self.room.command_queue.append(self.s.command_13()) + self.room.command_queue.append(self.s.command_14()) - return re + if self.room.is_finish(): + # 有人退房导致的结算 + self.room.make_finish() + flag_13 = True + + if flag_11: + self.room.command_queue.append(self.s.command_11()) + if flag_12: + self.room.command_queue.append( + self.s.command_12(self.player_index)) + if flag_13: + self.room.command_queue.append(self.s.command_13()) + + if flag_0c: + return [self.s.command_0c()] def command_0a(self): # 退出房间 @@ -267,9 +326,6 @@ def command_0a(self): self.room.command_queue.append(self.s.command_12(self.player_index)) - if self.room.state in (2, 3): - self.room.state = 1 - self.room.song_idx = 0xffff # self.room.command_queue.append(self.s.command_11()) self.room.command_queue.append(self.s.command_13()) self.room.command_queue.append(self.s.command_14()) @@ -281,3 +337,45 @@ def command_0b(self): if self.player_index != i and self.room.players[i].online == 1: self.room.players[i].extra_command_queue.append( self.s.command_0f(self.player_index, song_idx)) + + def command_20(self): + # 表情 + sticker_id = bi(self.command[16:18]) + for i in range(4): + if self.player_index != i and self.room.players[i].online == 1: + self.room.players[i].extra_command_queue.append( + self.s.command_21(self.player_index, sticker_id)) + + def command_22(self): + # 房间设置,懒得判断房主 + self.s.random_code = self.command[16:24] + self.room.is_public = self.command[25] + if self.room.is_public == 0: + self.room.round_mode = self.command[24] + self.room.timed_mode = self.command[26] + else: + self.room.round_mode = 3 + self.room.timed_mode = 1 + self.room.state = 1 + self.room.command_queue.append(self.s.command_11()) + self.room.command_queue.append(self.s.command_13()) + return [self.s.command_0d(1)] + + def command_23(self): + # 歌曲投票 + self.s.random_code = self.command[16:24] + if self.room.player_num < 2: + return [self.s.command_0d(6)] + if self.room.state != 2: + return [self.s.command_0d(5)] + player = self.room.players[self.player_index] + player.voting = bi(self.command[24:26]) + logging.info( + f'Player `{player.name}` votes for song `{player.voting}`') + self.room.command_queue.append(self.s.command_12(self.player_index)) + + if self.room.is_all_player_voted: + self.room.make_voting() + self.room.command_queue.append(self.s.command_13()) + + return [self.s.command_0d(1)] diff --git a/latest version/linkplay_server/udp_sender.py b/latest version/linkplay_server/udp_sender.py index 6290eb3..e389d61 100644 --- a/latest version/linkplay_server/udp_sender.py +++ b/latest version/linkplay_server/udp_sender.py @@ -1,28 +1,43 @@ +from os import urandom from time import time from .udp_class import Room, b +PADDING = [b(i) * i for i in range(16)] + [b''] + + class CommandSender: PROTOCOL_NAME = b'\x06\x16' - PROTOCOL_VERSION = b'\x09' + PROTOCOL_VERSION = b'\x0D' def __init__(self, room: Room = None) -> None: self.room = room self.timestamp = round(time() * 1000000) + self.room.timestamp = self.timestamp + 1 + + self._random_code = None + + @property + def random_code(self): + if self._random_code is None: + self._random_code = urandom(4) + b'\x00\x00\x00\x00' + return self._random_code - self.random_code = b'\x11\x11\x11\x11\x00\x00\x00\x00' + @random_code.setter + def random_code(self, value): + self._random_code = value @staticmethod def command_encode(t: tuple): r = b''.join(t) x = 16 - len(r) % 16 - return r + b(x) * x + return r + PADDING[x] def command_prefix(self, command: bytes): length = self.room.command_queue_length - if command >= b'\x10': + if b'\x10' <= command <= b'\x1f': length += 1 return (self.PROTOCOL_NAME, command, self.PROTOCOL_VERSION, b(self.room.room_id, 8), b(length, 4)) @@ -31,12 +46,18 @@ def command_0c(self): return self.command_encode((*self.command_prefix(b'\x0c'), self.random_code, b(self.room.state), b(self.room.countdown, 4), b(self.timestamp, 8))) def command_0d(self, code: int): + # 3 你不是房主 + # 5 有玩家目前无法开始 + # 6 需要更多玩家以开始 + # 7 有玩家无法游玩这首歌 + return self.command_encode((*self.command_prefix(b'\x0d'), self.random_code, b(code))) def command_0e(self, player_index: int): # 分数广播 + # 我猜,616 写错了,首先 4 个 00 大概是分数使用了 8 bytes 转换,其次上一个分数根本就不需要哈哈哈哈哈哈! player = self.room.players[player_index] - return self.command_encode((*self.command_prefix(b'\x0e'), b(player.player_id, 8), b(player.character_id), b(player.is_uncapped), b(player.difficulty), b(player.score, 4), b(player.timer, 4), b(player.cleartype), b(player.player_state), b(player.download_percent), b'\x01', b(player.last_score, 4), b(player.last_timer, 4), b(player.online))) + return self.command_encode((*self.command_prefix(b'\x0e'), player.info, b(player.last_score.score, 4), b'\x00' * 4, b(player.last_score.timer, 4), b'\x00' * 4)) def command_0f(self, player_index: int, song_idx: int): # 歌曲推荐 @@ -52,13 +73,17 @@ def command_11(self): def command_12(self, player_index: int): player = self.room.players[player_index] - return self.command_encode((*self.command_prefix(b'\x12'), self.random_code, b(player_index), b(player.player_id, 8), b(player.character_id), b(player.is_uncapped), b(player.difficulty), b(player.score, 4), b(player.timer, 4), b(player.cleartype), b(player.player_state), b(player.download_percent), b(player.online))) + return self.command_encode((*self.command_prefix(b'\x12'), self.random_code, b(player_index), player.info)) def command_13(self): - return self.command_encode((*self.command_prefix(b'\x13'), self.random_code, b(self.room.host_id, 8), b(self.room.state), b(self.room.countdown, 4), b(self.timestamp, 8), b(self.room.song_idx, 2), b(self.room.interval, 2), b(self.room.times, 7), self.room.get_player_last_score(), b(self.room.last_song_idx, 2), b(self.room.round_switch, 1))) + return self.command_encode((*self.command_prefix(b'\x13'), self.random_code, self.room.room_info)) def command_14(self): return self.command_encode((*self.command_prefix(b'\x14'), self.random_code, self.room.song_unlock)) def command_15(self): - return self.command_encode((*self.command_prefix(b'\x15'), self.room.get_players_info(), self.room.song_unlock, b(self.room.host_id, 8), b(self.room.state), b(self.room.countdown, 4), b(self.timestamp, 8), b(self.room.song_idx, 2), b(self.room.interval, 2), b(self.room.times, 7), self.room.get_player_last_score(), b(self.room.last_song_idx, 2), b(self.room.round_switch, 1))) + return self.command_encode((*self.command_prefix(b'\x15'), self.room.get_players_info(), self.room.song_unlock, self.room.room_info)) + + def command_21(self, player_index: int, sticker_id: int): + player = self.room.players[player_index] + return self.command_encode((*self.command_prefix(b'\x21'), b(player.player_id, 8), b(sticker_id, 2))) diff --git a/latest version/main.py b/latest version/main.py index bea401a..ac78ec5 100644 --- a/latest version/main.py +++ b/latest version/main.py @@ -27,6 +27,7 @@ import web.index import web.login # import webapi +from core.bundle import BundleDownload from core.constant import Constant from core.download import UserDownload from core.error import ArcError, NoAccess, RateLimit @@ -54,7 +55,7 @@ app.register_blueprint(web.login.bp) app.register_blueprint(web.index.bp) app.register_blueprint(api.bp) -app.register_blueprint(server.bp) +list(map(app.register_blueprint, server.get_bps())) # app.register_blueprint(webapi.bp) @@ -81,7 +82,8 @@ def download(file_path): x.song_id, x.file_name = file_path.split('/', 1) x.select_for_check() if x.is_limited: - raise RateLimit('You have reached the download limit.', 903) + raise RateLimit( + f'User `{x.user.user_id}` has reached the download limit.', 903) if not x.is_valid: raise NoAccess('Expired token.') x.download_hit() @@ -99,6 +101,26 @@ def download(file_path): return error_return() +@app.route('/bundle_download/', methods=['GET']) # 热更新下载 +def bundle_download(token: str): + with Connect(in_memory=True) as c_m: + try: + file_path = BundleDownload(c_m).get_path_by_token( + token, request.remote_addr) + if Config.DOWNLOAD_USE_NGINX_X_ACCEL_REDIRECT: + # nginx X-Accel-Redirect + response = make_response() + response.headers['Content-Type'] = 'application/octet-stream' + response.headers['X-Accel-Redirect'] = Config.BUNDLE_NGINX_X_ACCEL_REDIRECT_PREFIX + file_path + return response + return send_from_directory(Constant.CONTENT_BUNDLE_FOLDER_PATH, file_path, as_attachment=True, conditional=True) + except ArcError as e: + if Config.ALLOW_WARNING_LOG: + app.logger.warning(format_exc()) + return error_return(e) + return error_return() + + if Config.DEPLOY_MODE == 'waitress': # 给waitress加个日志 @app.after_request @@ -124,7 +146,7 @@ def tcp_server_run(): elif Config.DEPLOY_MODE == 'waitress': # waitress WSGI server import logging - from waitress import serve + from waitress import serve # type: ignore logger = logging.getLogger('waitress') logger.setLevel(logging.INFO) serve(app, host=Config.HOST, port=Config.PORT) diff --git a/latest version/requirements.txt b/latest version/requirements.txt index 4e18428..fb79834 100644 --- a/latest version/requirements.txt +++ b/latest version/requirements.txt @@ -1,3 +1,3 @@ flask>=2.0.2 -cryptography>=3.0.0 +cryptography>=35.0.0 limits>=2.7.0 diff --git a/latest version/server/__init__.py b/latest version/server/__init__.py index a6d5e70..28acf22 100644 --- a/latest version/server/__init__.py +++ b/latest version/server/__init__.py @@ -1,18 +1,39 @@ -from flask import Blueprint +from flask import Blueprint, jsonify from core.config_manager import Config from . import (auth, course, friend, multiplayer, others, present, purchase, - score, user, world) - -bp = Blueprint('server', __name__, url_prefix=Config.GAME_API_PREFIX) -bp.register_blueprint(user.bp) -bp.register_blueprint(auth.bp) -bp.register_blueprint(friend.bp) -bp.register_blueprint(score.bp) -bp.register_blueprint(world.bp) -bp.register_blueprint(purchase.bp) -bp.register_blueprint(present.bp) -bp.register_blueprint(others.bp) -bp.register_blueprint(multiplayer.bp) -bp.register_blueprint(course.bp) + score, user, world, mission) + + +__bp_old = Blueprint('old_server', __name__) + + +@__bp_old.route('/', methods=['GET', 'POST']) # 旧版 API 提示 +def server_hello(any): + return jsonify({"success": False, "error_code": 5}) + + +def get_bps(): + def string_to_list(s): + if isinstance(s, str): + s = [s] + elif not isinstance(s, list): + s = [] + return s + + bp = Blueprint('server', __name__) + list(map(bp.register_blueprint, [user.bp, auth.bp, friend.bp, score.bp, + world.bp, purchase.bp, present.bp, others.bp, multiplayer.bp, course.bp, mission.bp])) + + bps = [Blueprint(x, __name__, url_prefix=x) + for x in string_to_list(Config.GAME_API_PREFIX)] + for x in bps: + x.register_blueprint(bp) + + old_bps = [Blueprint(x, __name__, url_prefix=x) + for x in string_to_list(Config.OLD_GAME_API_PREFIX)] + for x in old_bps: + x.register_blueprint(__bp_old) + + return bps + old_bps diff --git a/latest version/server/func.py b/latest version/server/func.py index c2d96ef..65be2b0 100644 --- a/latest version/server/func.py +++ b/latest version/server/func.py @@ -3,8 +3,9 @@ from flask import current_app, g, jsonify +from core.bundle import BundleParser from core.config_manager import Config -from core.error import ArcError, NoAccess +from core.error import ArcError, LowVersion, NoAccess has_arc_hash = False try: @@ -22,7 +23,9 @@ def error_return(e: ArcError = default_error): # 错误返回 # -4 您的账号已在别处登录 # -3 无法连接至服务器 # 2 Arcaea服务器正在维护 + # 5 请更新 Arcaea 到最新版本 # 9 新版本请等待几分钟 + # 11 有游戏内容需要更新,即将返回主界面 # 100 无法在此ip地址下登录游戏 # 101 用户名占用 # 102 电子邮箱已注册 @@ -41,7 +44,7 @@ def error_return(e: ArcError = default_error): # 错误返回 # 150 非常抱歉您已被限制使用此功能 # 151 目前无法使用此功能 # 160 账户未邮箱认证,请检查邮箱 - # 161 账户认证过期,请重新注册 + # 161 账户认证过期,请重新注册 # 401 用户不存在 # 403 无法连接至服务器 # 501 502 -6 此物品目前无法获取 @@ -65,7 +68,7 @@ def error_return(e: ArcError = default_error): # 错误返回 # 9803 下载已取消 # 9905 没有在云端发现任何数据 # 9907 更新数据时发生了问题 - # 9908 服务器只支持最新的版本,请更新Arcaea + # 9908 服务器只支持最新的版本,请更新 Arcaea # 其它 发生未知错误 r = {"success": False, "error_code": e.error_code} if e.extra_data: @@ -106,7 +109,9 @@ def header_check(request) -> ArcError: headers = request.headers if Config.ALLOW_APPVERSION: # 版本检查 if 'AppVersion' not in headers or headers['AppVersion'] not in Config.ALLOW_APPVERSION: - return NoAccess('Invalid app version', 1203) + return LowVersion('Invalid app version', 5) + if request.method == 'GET' and 'ContentBundle' in headers and headers['ContentBundle'] != BundleParser.max_bundle_version.get(headers.get('AppVersion', ''), '0.0.0'): + return LowVersion('Invalid content bundle version', 11) if has_arc_hash and not ArcHashChecker(request).check(): return NoAccess('Invalid request') diff --git a/latest version/server/mission.py b/latest version/server/mission.py new file mode 100644 index 0000000..da5e8dc --- /dev/null +++ b/latest version/server/mission.py @@ -0,0 +1,68 @@ +from flask import Blueprint, request + +from core.error import NoData +from core.mission import MISSION_DICT +from core.sql import Connect +from core.user import UserOnline + +from .auth import auth_required +from .func import arc_try, success_return + +bp = Blueprint('mission', __name__, url_prefix='/mission') + + +def parse_mission_form(multidict) -> list: + r = [] + + x = multidict.get('mission_1') + i = 1 + while x: + r.append(x) + x = multidict.get(f'mission_{i + 1}') + i += 1 + return r + + +@bp.route('/me/clear', methods=['POST']) # 新手任务确认完成 +@auth_required(request) +@arc_try +def mission_clear(user_id): + m = parse_mission_form(request.form) + r = [] + for i, mission_id in enumerate(m): + if mission_id not in MISSION_DICT: + return NoData(f'Mission `{mission_id}` not found') + with Connect() as c: + x = MISSION_DICT[mission_id](c) + x.user_clear_mission(UserOnline(c, user_id)) + d = x.to_dict() + d['request_id'] = i + 1 + r.append(d) + + return success_return({'missions': r}) + + +@bp.route('/me/claim', methods=['POST']) # 领取新手任务奖励 +@auth_required(request) +@arc_try +def mission_claim(user_id): + m = parse_mission_form(request.form) + r = [] + + with Connect() as c: + user = UserOnline(c, user_id) + + for i, mission_id in enumerate(m): + if mission_id not in MISSION_DICT: + return NoData(f'Mission `{mission_id}` not found') + + x = MISSION_DICT[mission_id](c) + x.user_claim_mission(user) + d = x.to_dict(has_items=True) + d['request_id'] = i + 1 + r.append(d) + + return success_return({ + 'missions': r, + 'user': user.to_dict() + }) diff --git a/latest version/server/multiplayer.py b/latest version/server/multiplayer.py index 7a1e6b9..58b6261 100644 --- a/latest version/server/multiplayer.py +++ b/latest version/server/multiplayer.py @@ -2,7 +2,8 @@ from core.config_manager import Config from core.error import ArcError -from core.linkplay import Player, RemoteMultiPlayer, Room +from core.linkplay import MatchStore, Player, RemoteMultiPlayer, Room +from core.notification import RoomInviteNotification from core.sql import Connect from .auth import auth_required @@ -68,3 +69,104 @@ def multiplayer_update(user_id): ':')[0] if Config.LINKPLAY_DISPLAY_HOST == '' else Config.LINKPLAY_DISPLAY_HOST r['port'] = int(Config.LINKPLAY_UDP_PORT) return success_return(r) + + +@bp.route('/me/room//invite', methods=['POST']) # 邀请 +@auth_required(request) +@arc_try +def room_invite(user_id, room_code): + if not Config.LINKPLAY_HOST: + raise ArcError('The link play server is unavailable.', 151, status=404) + + other_user_id = request.form.get('to', type=int) + + x = RemoteMultiPlayer() + share_token = x.select_room(room_code=room_code)['share_token'] + + with Connect(in_memory=True) as c_m: + with Connect() as c: + sender = Player(c, user_id) + sender.select_user_about_link_play() + n = RoomInviteNotification.from_sender( + sender, Player(c, other_user_id), share_token, c_m) + n.insert() + + return success_return({}) # 无返回 + + +@bp.route('/me/room/status', methods=['POST']) # 房间号码获取 +@auth_required(request) +@arc_try +def room_status(user_id): + if not Config.LINKPLAY_HOST: + raise ArcError('The link play server is unavailable.', 151, status=404) + + share_token = request.form.get('shareToken', type=str) + + x = RemoteMultiPlayer() + room_code = x.select_room(share_token=share_token)['room_code'] + + return success_return({ + 'roomId': room_code, + }) + + +@bp.route('/me/matchmaking/join/', methods=['POST']) # 匹配 +@auth_required(request) +@arc_try +def matchmaking_join(user_id): + if not Config.LINKPLAY_HOST: + raise ArcError('The link play server is unavailable.', 151, status=404) + + with Connect() as c: + user = Player(None, user_id) + user.get_song_unlock(request.json['clientSongMap']) + + x = MatchStore(c) + x.init_player(user) + r = x.match(user_id) + + if r is None: + return success_return({ + 'userId': user_id, + 'status': 2, + }) + + r['endPoint'] = request.host.split( + ':')[0] if Config.LINKPLAY_DISPLAY_HOST == '' else Config.LINKPLAY_DISPLAY_HOST + r['port'] = int(Config.LINKPLAY_UDP_PORT) + return success_return(r) + + +@bp.route('/me/matchmaking/status/', methods=['POST']) # 匹配状态,5s 一次 +@auth_required(request) +@arc_try +def matchmaking_status(user_id): + if not Config.LINKPLAY_HOST: + raise ArcError('The link play server is unavailable.', 151, status=404) + + with Connect() as c: + + r = MatchStore(c).match(user_id) + if r is None: + return success_return({ + 'userId': user_id, + 'status': 0, + }) + + r['endPoint'] = request.host.split( + ':')[0] if Config.LINKPLAY_DISPLAY_HOST == '' else Config.LINKPLAY_DISPLAY_HOST + r['port'] = int(Config.LINKPLAY_UDP_PORT) + return success_return(r) + + +@bp.route('/me/matchmaking/leave/', methods=['POST']) # 退出匹配 +@auth_required(request) +@arc_try +def matchmaking_leave(user_id): + if not Config.LINKPLAY_HOST: + raise ArcError('The link play server is unavailable.', 151, status=404) + + MatchStore().clear_player(user_id) + + return success_return({}) diff --git a/latest version/server/others.py b/latest version/server/others.py index 9063770..66379d7 100644 --- a/latest version/server/others.py +++ b/latest version/server/others.py @@ -4,8 +4,11 @@ from flask import Blueprint, jsonify, request from werkzeug.datastructures import ImmutableMultiDict +from core.bundle import BundleDownload from core.download import DownloadList from core.error import RateLimit +from core.item import ItemCharacter +from core.notification import NotificationFactory from core.sql import Connect from core.system import GameInfo from core.user import UserOnline @@ -13,7 +16,7 @@ from .auth import auth_required from .func import arc_try, error_return, success_return from .present import present_info -from .purchase import bundle_bundle, bundle_pack +from .purchase import bundle_bundle, bundle_pack, get_single from .score import song_score_friend from .user import user_me from .world import world_all @@ -26,6 +29,30 @@ def game_info(): return success_return(GameInfo().to_dict()) +@bp.route('/notification/me', methods=['GET']) # 通知 +@auth_required(request) +@arc_try +def notification_me(user_id): + with Connect(in_memory=True) as c_m: + x = NotificationFactory(c_m, UserOnline(c_m, user_id)) + return success_return([i.to_dict() for i in x.get_notification()]) + + +@bp.route('/game/content_bundle', methods=['GET']) # 热更新 +@arc_try +def game_content_bundle(): + # error code 5, 9 work + app_version = request.headers.get('AppVersion') + bundle_version = request.headers.get('ContentBundle') + device_id = request.headers.get('DeviceId') + with Connect(in_memory=True) as c_m: + x = BundleDownload(c_m) + x.set_client_info(app_version, bundle_version, device_id) + return success_return({ + 'orderedResults': x.get_bundle_list() + }) + + @bp.route('/serve/download/me/song', methods=['GET']) # 歌曲下载 @auth_required(request) @arc_try @@ -49,34 +76,57 @@ def finale_progress(): @bp.route('/finale/finale_start', methods=['POST']) -def finale_start(): +@auth_required(request) +@arc_try +def finale_start(user_id): # testify开始,对立再见 - # 没数据 - return success_return({}) + # 但是对立不再见 + + with Connect() as c: + item = ItemCharacter(c) + item.set_id('55') # Hikari (Fatalis) + item.user_claim_item(UserOnline(c, user_id)) + return success_return({}) @bp.route('/finale/finale_end', methods=['POST']) -def finale_end(): +@auth_required(request) +@arc_try +def finale_end(user_id): + + with Connect() as c: + item = ItemCharacter(c) + item.set_id('5') # Hikari & Tairitsu (Reunion) + item.user_claim_item(UserOnline(c, user_id)) + return success_return({}) + + +@bp.route('/applog/me/log', methods=['POST']) +def applog_me(): + # 异常日志,不处理 return success_return({}) -map_dict = {'/user/me': user_me, - '/purchase/bundle/pack': bundle_pack, - '/serve/download/me/song': download_song, - '/game/info': game_info, - '/present/me': present_info, - '/world/map/me': world_all, - '/score/song/friend': song_score_friend, - '/purchase/bundle/bundle': bundle_bundle, - '/finale/progress': finale_progress} +map_dict = { + '/user/me': user_me, + '/purchase/bundle/pack': bundle_pack, + '/serve/download/me/song': download_song, + '/game/info': game_info, + '/present/me': present_info, + '/world/map/me': world_all, + '/score/song/friend': song_score_friend, + '/purchase/bundle/bundle': bundle_bundle, + '/finale/progress': finale_progress, + '/purchase/bundle/single': get_single +} @bp.route('/compose/aggregate', methods=['GET']) # 集成式请求 def aggregate(): try: - #global request + # global request finally_response = {'success': True, 'value': []} - #request_ = request + # request_ = request get_list = json.loads(request.args.get('calls')) if len(get_list) > 10: # 请求太多驳回 @@ -101,13 +151,13 @@ def aggregate(): 'error_code'), 'id': i['id']} if "extra" in resp: finally_response['extra'] = resp['extra'] - #request = request_ + # request = request_ return jsonify(finally_response) finally_response['value'].append( {'id': i.get('id'), 'value': resp['value'] if hasattr(resp, 'get') else resp}) - #request = request_ + # request = request_ return jsonify(finally_response) except KeyError: return error_return() diff --git a/latest version/server/purchase.py b/latest version/server/purchase.py index b45b315..1e80da1 100644 --- a/latest version/server/purchase.py +++ b/latest version/server/purchase.py @@ -2,6 +2,7 @@ from flask import Blueprint, request +from core.constant import Constant from core.error import InputError, ItemUnavailable, PostError from core.item import ItemFactory, Stamina6 from core.purchase import Purchase, PurchaseList @@ -105,6 +106,8 @@ def buy_special(user_id): x.discount_from = -1 x.discount_to = -1 x.items = [ItemFactory(c).get_item(item_id)] + # request.form['ticket_used'] == 'true' + # memory_boost_ticket: x-1 x.buy() r = {'user_id': x.user.user_id, 'ticket': x.user.ticket} @@ -130,7 +133,7 @@ def purchase_stamina(user_id, buy_stamina_type): return ItemUnavailable('Buying stamina by fragment is not available yet.', 905) user.update_user_one_column( - 'next_fragstam_ts', now + 24 * 3600 * 1000) + 'next_fragstam_ts', now + Constant.FRAGSTAM_RECOVER_TICK) s = Stamina6(c) s.user_claim_item(user) return success_return({ diff --git a/latest version/server/user.py b/latest version/server/user.py index 05ffa21..9a08fcc 100644 --- a/latest version/server/user.py +++ b/latest version/server/user.py @@ -1,8 +1,10 @@ from flask import Blueprint, current_app, request from core.character import UserCharacter +from core.config_manager import Config from core.error import ArcError from core.item import ItemCore +from core.operation import DeleteOneUser from core.save import SaveData from core.sql import Connect from core.user import User, UserLogin, UserOnline, UserRegister @@ -30,12 +32,12 @@ def register(): else: device_id = 'low_version' - new_user.register() + ip = request.remote_addr + new_user.register(device_id, ip) # 注册后自动登录 user = UserLogin(c) - user.login(new_user.name, new_user.password, - device_id, request.remote_addr) + user.login(new_user.name, new_user.password, device_id, ip) current_app.logger.info(f'New user `{user.user_id}` registered') return success_return({'user_id': user.user_id, 'access_token': user.token}) @@ -155,7 +157,7 @@ def sys_set(user_id, set_arg): user.change_favorite_character(int(value)) else: value = 'true' == value - if set_arg in ('is_hide_rating', 'max_stamina_notification_enabled'): + if set_arg in ('is_hide_rating', 'max_stamina_notification_enabled', 'mp_notification_enabled'): user.update_user_one_column(set_arg, value) return success_return(user.to_dict()) @@ -164,7 +166,10 @@ def sys_set(user_id, set_arg): @auth_required(request) @arc_try def user_delete(user_id): - raise ArcError('Cannot delete the account.', 151, status=404) + if not Config.ALLOW_SELF_ACCOUNT_DELETE: + raise ArcError('Cannot delete the account.', 151, status=404) + DeleteOneUser().set_params(user_id).run() + return success_return({'user_id': user_id}) @bp.route('/email/resend_verify', methods=['POST']) # 邮箱验证重发 diff --git a/latest version/server/world.py b/latest version/server/world.py index 8567ea0..4fb1edc 100644 --- a/latest version/server/world.py +++ b/latest version/server/world.py @@ -2,7 +2,7 @@ from core.sql import Connect from core.user import UserOnline -from core.world import UserMap, get_world_all +from core.world import MapParser, UserMap from .auth import auth_required from .func import arc_try, success_return @@ -20,7 +20,7 @@ def world_all(user_id): return success_return({ "current_map": user.current_map.map_id, "user_id": user_id, - "maps": [x.to_dict(has_map_info=True, has_rewards=True) for x in get_world_all(c, user)] + "maps": [x.to_dict(has_map_info=True, has_rewards=True) for x in MapParser.get_world_all(c, user)] }) diff --git a/latest version/static/style.css b/latest version/static/style.css index 0aec47b..6678106 100644 --- a/latest version/static/style.css +++ b/latest version/static/style.css @@ -173,6 +173,12 @@ input[type=submit] { color: white; } +.difficulty_etr { + font-size: 0.9em; + background-color: rgb(161, 132, 181); + color: white; +} + .rank { font-size: 0.8em; margin-left: 4px; diff --git a/latest version/templates/web/allsong.html b/latest version/templates/web/allsong.html index 654a66f..0bb6bfe 100644 --- a/latest version/templates/web/allsong.html +++ b/latest version/templates/web/allsong.html @@ -34,6 +34,11 @@

{% block title %}All songs{% endblock %}

BYD {{song['rating_byn']}} {% endif %} +
+ {% if song['rating_etr'] %} + ETR + {{song['rating_etr']}} + {% endif %} diff --git a/latest version/templates/web/changepresent.html b/latest version/templates/web/changepresent.html index b6aac74..740dc6d 100644 --- a/latest version/templates/web/changepresent.html +++ b/latest version/templates/web/changepresent.html @@ -28,6 +28,7 @@

{% block title %}Change the presents{% endblock %}

+ diff --git a/latest version/templates/web/changeredeem.html b/latest version/templates/web/changeredeem.html index b646312..19f5583 100644 --- a/latest version/templates/web/changeredeem.html +++ b/latest version/templates/web/changeredeem.html @@ -39,6 +39,7 @@

{% block title %}Change the redeem codes{% endblock %}

+ diff --git a/latest version/templates/web/changesong.html b/latest version/templates/web/changesong.html index cd06a7f..09893a3 100644 --- a/latest version/templates/web/changesong.html +++ b/latest version/templates/web/changesong.html @@ -19,6 +19,8 @@

{% block title %}Change the songs{% endblock %}

+ +
如果没有某个谱面,应该填入-1。
If there is no some chart, fill in -1 please.
diff --git a/latest version/templates/web/updatedatabase.html b/latest version/templates/web/updatedatabase.html index ddc48dd..9b1764a 100644 --- a/latest version/templates/web/updatedatabase.html +++ b/latest version/templates/web/updatedatabase.html @@ -13,7 +13,8 @@

{% block title %}Update databases{% endblock %}

这里可以将旧版本的数据库同步到新版本的数据库,并刷新用户拥有的全角色列表。
可上传文件: arcaea_database.db
新数据库不存在的数据会被添加,存在的重复数据也会被改变。

- Here you can synchronize the old version of the database to the new version of the database and refresh the list of full + Here you can synchronize the old version of the database to the new version of the database and refresh the list of + full characters owned by players.
Uploadable files: arcaea_database.db
Data that does not exist in the new database will be added and the existing duplicate data will also be changed. @@ -40,4 +41,16 @@

{% block title %}Update databases{% endblock %}

Here you can refresh the ratings of all scores in the database. The purpose is to deal with the updating of songs' chart consts.
+
+
+
+
Refresh content bundles
+
+ + +
这里可以刷新储存在内存中的 bundle 文件夹下所有热更新文件的信息。目的是应对热更新文件的修改。
+
Here you can refresh the information of all the files stored in the bundle folder in memory. + The purpose + is to deal with the updating of content bundles.
+
{% endblock %} \ No newline at end of file diff --git a/latest version/web/index.py b/latest version/web/index.py index a1ba21c..128a77a 100644 --- a/latest version/web/index.py +++ b/latest version/web/index.py @@ -1,16 +1,19 @@ import os import time -from core.init import FileChecker -from core.operation import RefreshAllScoreRating, RefreshSongFileCache, SaveUpdateScore, UnlockUserItem -from core.rank import RankList -from core.sql import Connect -from core.user import User from flask import Blueprint, flash, redirect, render_template, request, url_for from werkzeug.utils import secure_filename import web.system import web.webscore +from core.init import FileChecker +from core.operation import (DeleteUserScore, RefreshAllScoreRating, + RefreshBundleCache, RefreshSongFileCache, + SaveUpdateScore, UnlockUserItem) +from core.rank import RankList +from core.score import Potential +from core.sql import Connect +from core.user import User from web.login import login_required UPLOAD_FOLDER = 'database' @@ -97,8 +100,11 @@ def single_player_ptt(): user_id = user_id[0] user = web.webscore.get_user(c, user_id) posts = web.webscore.get_user_score(c, user_id, 30) - recent, recentptt = web.webscore.get_user_recent30( - c, user_id) + u = User() + u.user_id = user_id + p = Potential(c, u) + recentptt = p.recent_10 / 10 + recent = p.recent_30_to_dict_list() if not posts: error = '无成绩 No score.' else: @@ -191,13 +197,15 @@ def defnum(x): if x: posts = [] for i in x: - posts.append({'song_id': i[0], - 'name_en': i[1], - 'rating_pst': defnum(i[2]), - 'rating_prs': defnum(i[3]), - 'rating_ftr': defnum(i[4]), - 'rating_byn': defnum(i[5]) - }) + posts.append({ + 'song_id': i[0], + 'name_en': i[1], + 'rating_pst': defnum(i[2]), + 'rating_prs': defnum(i[3]), + 'rating_ftr': defnum(i[4]), + 'rating_byn': defnum(i[5]), + 'rating_etr': defnum(i[6]) + }) else: error = '没有谱面数据 No song data.' @@ -297,6 +305,18 @@ def update_song_hash(): return render_template('web/updatedatabase.html') +@bp.route('/updatedatabase/refreshsbundle', methods=['POST']) +@login_required +def update_content_bundle(): + # 更新 bundle + try: + RefreshBundleCache().run() + flash('数据刷新成功 Success refresh data.') + except: + flash('Something error!') + return render_template('web/updatedatabase.html') + + @bp.route('/updatedatabase/refreshsongrating', methods=['POST']) @login_required def update_song_rating(): @@ -335,6 +355,7 @@ def get_rating(x): rating_prs = get_rating(request.form['rating_prs']) rating_ftr = get_rating(request.form['rating_ftr']) rating_byd = get_rating(request.form['rating_byd']) + rating_etr = get_rating(request.form['rating_etr']) if len(song_id) >= 256: song_id = song_id[:200] if len(name_en) >= 256: @@ -344,8 +365,8 @@ def get_rating(x): c.execute( '''select exists(select * from chart where song_id=:a)''', {'a': song_id}) if c.fetchone() == (0,): - c.execute('''insert into chart values(:a,:b,:c,:d,:e,:f)''', { - 'a': song_id, 'b': name_en, 'c': rating_pst, 'd': rating_prs, 'e': rating_ftr, 'f': rating_byd}) + c.execute('''insert into chart values(:a,:b,:c,:d,:e,:f,:g)''', { + 'a': song_id, 'b': name_en, 'c': rating_pst, 'd': rating_prs, 'e': rating_ftr, 'f': rating_byd, 'g': rating_etr}) flash('歌曲添加成功 Successfully add the song.') else: error = '歌曲已存在 The song exists.' @@ -422,7 +443,7 @@ def all_character(): def change_character(): # 修改角色数据 skill_ids = ['No_skill', 'gauge_easy', 'note_mirror', 'gauge_hard', 'frag_plus_10_pack_stellights', 'gauge_easy|frag_plus_15_pst&prs', 'gauge_hard|fail_frag_minus_100', 'frag_plus_5_side_light', 'visual_hide_hp', 'frag_plus_5_side_conflict', 'challenge_fullcombo_0gauge', 'gauge_overflow', 'gauge_easy|note_mirror', 'note_mirror', 'visual_tomato_pack_tonesphere', - 'frag_rng_ayu', 'gaugestart_30|gaugegain_70', 'combo_100-frag_1', 'audio_gcemptyhit_pack_groovecoaster', 'gauge_saya', 'gauge_chuni', 'kantandeshou', 'gauge_haruna', 'frags_nono', 'gauge_pandora', 'gauge_regulus', 'omatsuri_daynight', 'sometimes(note_mirror|frag_plus_5)', 'scoreclear_aa|visual_scoregauge', 'gauge_tempest', 'gauge_hard', 'gauge_ilith_summer', 'frags_kou', 'visual_ink', 'shirabe_entry_fee', 'frags_yume', 'note_mirror|visual_hide_far', 'frags_ongeki', 'gauge_areus', 'gauge_seele', 'gauge_isabelle', 'gauge_exhaustion', 'skill_lagrange', 'gauge_safe_10', 'frags_nami', 'skill_elizabeth', 'skill_lily', 'skill_kanae_midsummer', 'eto_uncap', 'luna_uncap', 'frags_preferred_song', 'visual_ghost_skynotes', 'ayu_uncap', 'skill_vita', 'skill_fatalis', 'skill_reunion', 'frags_ongeki_slash', 'frags_ongeki_hard', 'skill_amane', 'skill_kou_winter', 'gauge_hard|note_mirror', 'skill_shama', 'skill_milk', 'skill_shikoku', 'skill_mika', 'ilith_awakened_skill', 'skill_mithra', 'skill_toa', 'skill_nami_twilight', 'skill_ilith_ivy', 'skill_hikari_vanessa', 'skill_maya', 'skill_luin', 'skill_luin_uncap'] + 'frag_rng_ayu', 'gaugestart_30|gaugegain_70', 'combo_100-frag_1', 'audio_gcemptyhit_pack_groovecoaster', 'gauge_saya', 'gauge_chuni', 'kantandeshou', 'gauge_haruna', 'frags_nono', 'gauge_pandora', 'gauge_regulus', 'omatsuri_daynight', 'sometimes(note_mirror|frag_plus_5)', 'scoreclear_aa|visual_scoregauge', 'gauge_tempest', 'gauge_hard', 'gauge_ilith_summer', 'frags_kou', 'visual_ink', 'shirabe_entry_fee', 'frags_yume', 'note_mirror|visual_hide_far', 'frags_ongeki', 'gauge_areus', 'gauge_seele', 'gauge_isabelle', 'gauge_exhaustion', 'skill_lagrange', 'gauge_safe_10', 'frags_nami', 'skill_elizabeth', 'skill_lily', 'skill_kanae_midsummer', 'eto_uncap', 'luna_uncap', 'frags_preferred_song', 'visual_ghost_skynotes', 'ayu_uncap', 'skill_vita', 'skill_fatalis', 'skill_reunion', 'frags_ongeki_slash', 'frags_ongeki_hard', 'skill_amane', 'skill_kou_winter', 'gauge_hard|note_mirror', 'skill_shama', 'skill_milk', 'skill_shikoku', 'skill_mika', 'ilith_awakened_skill', 'skill_mithra', 'skill_toa', 'skill_nami_twilight', 'skill_ilith_ivy', 'skill_hikari_vanessa', 'skill_maya', 'skill_luin', 'skill_luin_uncap', 'skill_kanae_uncap', 'skill_doroc_uncap', 'skill_saya_uncap', 'skill_luna_ilot', 'skill_eto_hoppe', 'skill_aichan'] return render_template('web/changechar.html', skill_ids=skill_ids) @@ -1366,7 +1387,10 @@ def delete_user_score(): user_id = c.fetchone() if user_id: user_id = user_id[0] - web.system.clear_user_score(c, user_id) + c.execute('''update user set rating_ptt=0, song_id='', difficulty=0, score=0, shiny_perfect_count=0, perfect_count=0, near_count=0, miss_count=0, health=0, time_played=0, rating=0, world_rank_score=0 where user_id=:a''', { + 'a': user_id}) + c.connection.commit() + DeleteUserScore().set_params(user_id=user_id).run() flash("用户成绩删除成功 Successfully delete the user's scores.") else: diff --git a/latest version/web/system.py b/latest version/web/system.py index 7fc6b3e..26e7e07 100644 --- a/latest version/web/system.py +++ b/latest version/web/system.py @@ -261,12 +261,3 @@ def ban_one_user(c, user_id): {'a': user_id}) c.execute('''delete from login where user_id=:a''', {'a': user_id}) return - - -def clear_user_score(c, user_id): - # 清除用户所有成绩,包括best_score和recent30,以及recent数据,但不包括云端存档 - c.execute('''update user set rating_ptt=0, song_id='', difficulty=0, score=0, shiny_perfect_count=0, perfect_count=0, near_count=0, miss_count=0, health=0, time_played=0, rating=0, world_rank_score=0 where user_id=:a''', - {'a': user_id}) - c.execute('''delete from best_score where user_id=:a''', {'a': user_id}) - c.execute('''delete from recent30 where user_id=:a''', {'a': user_id}) - return diff --git a/latest version/web/webscore.py b/latest version/web/webscore.py index 517a69c..485aad7 100644 --- a/latest version/web/webscore.py +++ b/latest version/web/webscore.py @@ -1,5 +1,7 @@ import time +from core.score import Potential + def get_user_score(c, user_id, limit=-1, offset=0): # 返回用户的所有歌曲数据,带排名,返回字典列表 @@ -74,37 +76,3 @@ def get_user(c, user_id): } return r - - -def get_user_recent30(c, user_id): - # 获取玩家recent30信息并计算recent10的ptt,返回字典列表和一个值 - c.execute('''select * from recent30 where user_id=:a''', {'a': user_id}) - sumr = 0 - x = c.fetchone() - r = [] - if x is not None: - r30 = [] - s30 = [] - for i in range(1, 61, 2): - if x[i] is not None: - r30.append(float(x[i])) - s30.append(x[i+1]) - else: - r30.append(0) - s30.append('') - r30, s30 = (list(t) for t in zip(*sorted(zip(r30, s30), reverse=True))) - songs = [] - i = 0 - while len(songs) < 10 and i <= 29 and s30[i] != '' and s30[i] is not None: - if s30[i] not in songs: - sumr += r30[i] - songs.append(s30[i]) - i += 1 - for i in range(0, 30): - if s30[i]: - r.append({ - 'song_id': s30[i][:-1], - 'difficulty': int(s30[i][-1]), - 'rating': r30[i] - }) - return r, sumr / 10 diff --git a/tools/server_maintenance.py b/tools/server_maintenance.py new file mode 100644 index 0000000..8101705 --- /dev/null +++ b/tools/server_maintenance.py @@ -0,0 +1,19 @@ +from flask import Flask, jsonify + +app = Flask(__name__) + +HOST = '0.0.0.0' +PORT = '80' + +@app.route('/favicon.ico', methods=['GET']) +def favicon(): + return '' + +@app.route('/') +def hello(p): + r = {"success": False, "error_code": 2} + return jsonify(r) + + +if __name__ == '__main__': + app.run(host=HOST, port=PORT) diff --git a/tools/update_song.py b/tools/update_song.py index 84f0abe..55de357 100644 --- a/tools/update_song.py +++ b/tools/update_song.py @@ -34,7 +34,7 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> bool: return True -def insert(cursor, song_id, name, a, b, c, d, update_type=0): +def insert(cursor, song_id, name, a, b, c, d, e, update_type=0): '''Insert a new song into database.''' if update_type == 0 or update_type == 1: cursor.execute( @@ -45,12 +45,12 @@ def insert(cursor, song_id, name, a, b, c, d, update_type=0): return elif update_type == 1: # 重复则更新,以`arcsong.db`数据为准 - cursor.execute('''update chart set name=?, rating_pst=?, rating_prs=?, rating_ftr=?, rating_byn=? where song_id=?''', - (name, a, b, c, d, song_id)) + cursor.execute('''update chart set name=?, rating_pst=?, rating_prs=?, rating_ftr=?, rating_byn=?, rating_etr=? where song_id=?''', + (name, a, b, c, d, e, song_id)) return cursor.execute( - '''insert into chart values (?,?,?,?,?,?)''', (song_id, name, a, b, c, d)) + '''insert into chart values (?,?,?,?,?,?,?)''', (song_id, name, a, b, c, d, e)) def from_song_datebase(): @@ -61,17 +61,19 @@ def from_song_datebase(): if 'songs' in tables: c.execute( '''select sid, name_en, rating_pst, rating_prs, rating_ftr, rating_byn from songs''') - data = c.fetchall() + data = [] + for x in c.fetchall(): + data.append((x[0], x[1], x[2], x[3], x[4], x[5], -1)) elif 'charts' in tables: c.execute( '''select song_id, rating_class, name_en, rating from charts''') songs = {} for song_id, rating_class, name_en, rating in c.fetchall(): if song_id not in songs: - songs[song_id] = [-1, -1, -1, -1, name_en] + songs[song_id] = [-1, -1, -1, -1, -1, name_en] songs[song_id][rating_class] = rating - data = [(x, y[-1], y[0], y[1], y[2], y[3]) + data = [(x, y[-1], y[0], y[1], y[2], y[3], y[4]) for x, y in songs.items()] else: print('Error: Cannot find table `songs` or `charts` in the database.') @@ -91,7 +93,7 @@ def from_song_datebase(): # 清空数据表后更新 c.execute('''delete from chart''') for x in data: - insert(c, x[0], x[1], x[2], x[3], x[4], x[5], update_type) + insert(c, x[0], x[1], x[2], x[3], x[4], x[5], x[6], update_type) print('Seems to be done.')