Merge pull request #183 from Lost-MSth/dev

Update to v2.12.0

close #110 #139 #166 #170
This commit is contained in:
Lost-MSth
2024-10-03 15:49:47 +08:00
committed by GitHub
59 changed files with 3767 additions and 1051 deletions

6
.gitignore vendored
View File

@@ -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/

136
README.md
View File

@@ -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
### Version 2.12.0
> v2.11.2.1 ~ v2.11.2.7 for Arcaea 4.5.0 ~ 5.2.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.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.
- 适用于 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
<!--
## 环境搭建 Environment construction
### 环境搭建 Environment construction
[中文](https://github.com/Lost-MSth/Arcaea-server/wiki/%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA)
[English](https://github.com/Lost-MSth/Arcaea-server/wiki/Environment-construction)
-->
## 使用说明 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
邮箱 Emailth84292@foxmail.com
邮箱 Emailarcaea@lost-msth.cn
## 支持一下 Support me

View File

@@ -32,6 +32,7 @@ CODE_MSG = {
-210: 'Username exists',
-211: 'Email address exists',
-212: 'User code exists',
-213: 'Too many register attempts',
-999: 'Unknown error'
}

View File

@@ -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')

View File

@@ -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),))

View File

@@ -120,6 +120,18 @@ class Character(CollectionItemMixin):
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

View File

@@ -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:

View File

@@ -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),

View File

@@ -39,7 +39,7 @@ class Course:
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 @@ class UserCourse(Course):
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 @@ class UserCourse(Course):
(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 @@ class CoursePlay(UserCourse):
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 @@ class CoursePlay(UserCourse):
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()

View File

@@ -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 @@ class SonglistParser:
# 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 @@ class SonglistParser:
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 @@ class SonglistParser:
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}

View File

@@ -101,6 +101,13 @@ class NoAccess(ArcError):
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

View File

@@ -6,8 +6,9 @@ from shutil import copy, copy2
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 @@ from core.sql import (Connect, DatabaseMigrator, LogDatabaseMigrator,
MemoryDatabase)
from core.user import UserRegister
from core.util import try_rename
from core.world import MapParser
class DatabaseInit:
@@ -45,7 +47,7 @@ class DatabaseInit:
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 @@ class DatabaseInit:
('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 @@ class DatabaseInit:
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 @@ class DatabaseInit:
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 @@ class LogDatabaseInit:
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 @@ class FileChecker:
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 @@ class FileChecker:
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 @@ class FileChecker:
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()

View File

@@ -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 @@ class UserItem(Item):
class NormalItem(UserItem):
def __init__(self, c) -> None:
def __init__(self, c=None) -> None:
super().__init__()
self.c = c
@@ -115,7 +116,7 @@ class NormalItem(UserItem):
class PositiveItem(UserItem):
def __init__(self, c) -> None:
def __init__(self, c=None) -> None:
super().__init__()
self.c = c
@@ -142,7 +143,7 @@ class PositiveItem(UserItem):
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 @@ class Memory(UserItem):
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 @@ class ProgBoost(UserItem):
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 @@ class Stamina6(UserItem):
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 @@ class ItemFactory:
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':

View File

@@ -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 @@ from .util import aes_gcm_128_decrypt, aes_gcm_128_encrypt
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 @@ class Player(UserInfo):
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 @@ class Player(UserInfo):
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 @@ class Room:
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 @@ class RemoteMultiPlayer:
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 @@ class RemoteMultiPlayer:
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 @@ class RemoteMultiPlayer:
'''创建房间'''
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 @@ class RemoteMultiPlayer:
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 @@ class RemoteMultiPlayer:
'''更新房间'''
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 @@ class RemoteMultiPlayer:
})
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

View File

@@ -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]

View File

@@ -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

View File

@@ -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 @@ class BaseOperation:
class RefreshAllScoreRating(BaseOperation):
'''
刷新所有成绩的评分
包括 score_v2
'''
_name = 'refresh_all_score_rating'
@@ -33,7 +37,7 @@ class RefreshAllScoreRating(BaseOperation):
# 但其实还是很慢
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 @@ class RefreshAllScoreRating(BaseOperation):
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 @@ class RefreshAllScoreRating(BaseOperation):
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 @@ class RefreshSongFileCache(BaseOperation):
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 @@ class SaveUpdateScore(BaseOperation):
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 @@ class SaveUpdateScore(BaseOperation):
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 @@ class SaveUpdateScore(BaseOperation):
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 @@ class SaveUpdateScore(BaseOperation):
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 @@ class UnlockUserItem(BaseOperation):
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,))

View File

@@ -29,7 +29,7 @@ class Purchase(CollectionItemMixin):
self.items: list = []
# TODO: "discount_reason": "extend"
# TODO: "discount_reason": extend, sale
@property
def price_displayed(self) -> int:
@@ -44,6 +44,12 @@ class Purchase(CollectionItemMixin):
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 @@ class Purchase(CollectionItemMixin):
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 @@ class Purchase(CollectionItemMixin):
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:

View File

@@ -107,6 +107,8 @@ class SaveData:
i['complete'] = 3
elif x[-5:-2] == '109':
i['complete'] = 3
elif x.endswith('112'):
i['complete'] = 999
else:
i['complete'] = 1

View File

@@ -11,7 +11,7 @@ from .item import ItemCore
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 @@ class Score:
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 @@ class Score:
'''分数有效性检查'''
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 @@ class Score:
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 @@ class UserScore(Score):
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 @@ class UserPlay(UserScore):
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 @@ class UserPlay(UserScore):
@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 @@ class UserPlay(UserScore):
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 @@ class UserPlay(UserScore):
(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 @@ class UserPlay(UserScore):
'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 @@ class UserPlay(UserScore):
# 世界模式判断
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 @@ class Potential:
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 @@ class Potential:
'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()
self.c.execute(sql, sql_list)
if len(self.r30_tuples) < 30:
self.update_one_r30(len(self.r30_tuples), user_score)
return None
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:

View File

@@ -19,6 +19,9 @@ class Chart:
'chart_const': self.chart_const
}
def to_tuple(self) -> tuple:
return (self.song_id, self.difficulty)
@property
def chart_const(self) -> float:
return self.defnum / 10 if self.defnum else -1
@@ -33,7 +36,7 @@ class Chart:
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 @@ class 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 @@ class Song:
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:

View File

@@ -6,6 +6,7 @@ from atexit import register
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 @@ class Sql:
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 @@ class DatabaseMigrator:
'''
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 @@ class DatabaseMigrator:
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<index>: real, song_id<index>: 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 @@ class MemoryDatabase:
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()

View File

@@ -11,6 +11,7 @@ from .error import (ArcError, DataExist, FriendError, InputError, NoAccess,
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 @@ class User:
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 @@ class UserRegister(User):
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 @@ class UserRegister(User):
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 @@ class UserInfo(User):
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 @@ class UserInfo(User):
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 @@ class UserInfo(User):
"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 @@ class UserInfo(User):
"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 @@ class UserInfo(User):
'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 @@ class UserInfo(User):
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 @@ class UserInfo(User):
查询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 @@ class UserInfo(User):
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 @@ class UserInfo(User):
def update_global_rank(self) -> None:
'''用户世界排名计算,有新增成绩则要更新'''
self.c.execute('''select song_id, rating_ftr, rating_byn from chart''')
x = self.c.fetchall()
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
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])
self.c.execute(
'''update user set world_rank_score = ? where user_id = ?''', (x[0], self.user_id))
self.world_rank_score = x[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.world_rank_score = score_sum
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 @@ class UserInfo(User):
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:
'''

View File

@@ -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 != '']))

View File

@@ -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 .error import InputError, MapLocked, NoData
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)
def __init__(self) -> None:
if not self.map_id_path:
self.parse()
return world_info
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 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 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 @@ class 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 @@ class Map:
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 @@ class Map:
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 @@ class Map:
'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 @@ class Map:
}
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 @@ class 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 @@ class UserMap(Map):
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,226 +466,16 @@ class UserStamina(Stamina):
'a': self.stamina, 'b': self.max_stamina_ts, 'c': self.user.user_id})
class WorldPlay:
'''
世界模式打歌类处理特殊角色技能联动UserMap和UserPlay
parameter: `user` - `UserOnline`类或子类的实例
'user_play` - `UserPlay`类的实例
'''
def __init__(self, c=None, user=None, user_play=None) -> None:
self.c = c
self.user = user
self.user_play = user_play
self.character_used = None
self.base_step_value: float = None
self.step_value: 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
def to_dict(self) -> dict:
arcmap: 'UserMap' = self.user.current_map
r = {
"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,
"user_map": {
"user_id": self.user.user_id,
"curr_position": arcmap.curr_position,
"curr_capture": arcmap.curr_capture,
"is_locked": arcmap.is_locked,
"map_id": arcmap.map_id,
"prev_capture": arcmap.prev_capture,
"prev_position": arcmap.prev_position,
"beyond_health": arcmap.beyond_health
},
"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)
},
"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
}
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:
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
@property
def beyond_boost_gauge_addition(self) -> float:
# guessed by Lost-MSth
return 2.45 * self.user_play.rating ** 0.5 + 27
@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
@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
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
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
overdrive = self.character_used.overdrive.get_value(
self.character_used.level)
if self.over_skill_increase:
overdrive += self.over_skill_increase
self.step_value = self.base_step_value * overdrive / \
50 * self.step_times * affinity_multiplier
def 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.character_used = Character()
self.user.character.select_character_info()
if not self.user.is_skill_sealed:
self.character_used = self.user.character
else:
self.character_used.character_id = self.user.character.character_id
self.character_used.level.level = self.user.character.level.level
self.character_used.level.exp = self.user.character.level.exp
self.character_used.frag.set_parameter(50, 50, 50)
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()
for i in self.user.current_map.rewards_for_climbing: # 物品分发
for j in i['items']:
j.c = self.c
j.user_claim_item(self.user)
x: 'Step' = self.user.current_map.steps_for_climbing[-1]
if x.step_type:
if 'plusstamina' in x.step_type and x.plus_stamina_value:
# 体力格子
self.user.stamina.stamina += x.plus_stamina_value
self.user.stamina.update()
# 角色升级
if self.character_used.database_table_name == 'user_char':
self.character_used.upgrade(
self.user, self.exp_times*self.user_play.rating*6)
if self.user.current_map.curr_position == self.user.current_map.step_count-1 and self.user.current_map.is_repeatable: # 循环图判断
self.user.current_map.curr_position = 0
self.user.current_map.update()
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}
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()
@@ -665,8 +483,16 @@ class WorldPlay:
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}
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]()
@@ -706,32 +532,27 @@ class WorldPlay:
break
if fragment_flag:
self.character_bonus_progress = Constant.ETO_UNCAP_BONUS_PROGRESS
self.step_value += self.character_bonus_progress
self.character_bonus_progress_normalized = Constant.ETO_UNCAP_BONUS_PROGRESS
self.user.current_map.reclimb(self.step_value)
self.user.current_map.reclimb(self.final_progress)
def _luna_uncap(self) -> None:
'''luna觉醒技能限制格开始时世界模式进度加7'''
'''luna觉醒技能限制格开始时世界模式进度加 7偷懒重爬因为 map 信息还未获取)'''
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
self.user.current_map.reclimb(self.step_value)
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'''
'''ayu 觉醒技能,世界模式进度随机变动 [-5, -5],但不会小于 0'''
self.character_bonus_progress = Constant.AYU_UNCAP_BONUS_PROGRESS if random(
) >= 0.5 else -Constant.AYU_UNCAP_BONUS_PROGRESS
self.character_bonus_progress_normalized = randint(
-Constant.AYU_UNCAP_BONUS_PROGRESS, Constant.AYU_UNCAP_BONUS_PROGRESS)
self.step_value += self.character_bonus_progress
if self.step_value < 0:
self.character_bonus_progress += self.step_value
self.step_value = 0
if self.progress_normalized + self.character_bonus_progress_normalized < 0:
self.character_bonus_progress_normalized = -self.progress_normalized
self.user.current_map.reclimb(self.step_value)
self.user.current_map.reclimb(self.final_progress)
def _skill_fatalis(self) -> None:
'''hikari fatalis技能世界模式超载打完休息60分钟'''
@@ -743,13 +564,11 @@ class WorldPlay:
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)
self.character_bonus_progress_normalized = -self.progress_normalized / 2
self.user.current_map.reclimb(self.final_progress)
def _ilith_awakened_skill(self) -> None:
'''
@@ -773,9 +592,7 @@ class WorldPlay:
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)
self.character_bonus_progress_normalized = self.user_play.combo_interval_bonus
def _skill_ilith_ivy(self) -> None:
'''
@@ -804,7 +621,402 @@ class WorldPlay:
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_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
parameter: `user` - `UserOnline`类或子类的实例
'user_play` - `UserPlay`类的实例
'''
def __init__(self, c=None, user=None, user_play=None) -> None:
self.c = c
self.user = user
self.user_play = user_play
self.character_used = None
self.character_bonus_progress_normalized: float = None
# wpaid: str
def to_dict(self) -> dict:
arcmap: 'UserMap' = self.user.current_map
r = {
"rewards": arcmap.rewards_for_climbing_to_dict(),
"exp": self.character_used.level.exp,
"level": self.character_used.level.level,
"base_progress": self.base_progress,
"progress": self.final_progress,
"user_map": {
"user_id": self.user.user_id,
"curr_position": arcmap.curr_position,
"curr_capture": arcmap.curr_capture,
"is_locked": arcmap.is_locked,
"map_id": arcmap.map_id,
"prev_capture": arcmap.prev_capture,
"prev_position": arcmap.prev_position,
"beyond_health": arcmap.beyond_health
},
"char_stats": {
"character_id": self.character_used.character_id,
"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,
# 'wpaid': 'helloworld', # world play id ???
# progress_before_sub_boost
# progress_sub_boost_amount
# subscription_multiply
}
if self.character_used.skill_id_displayed == 'skill_maya':
r['char_stats']['skill_state'] = self.character_used.skill_state
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: # 源韵强化
r['prog_boost_multiply'] = self.user_play.prog_boost_multiply
return r
@property
def beyond_boost_gauge_addition(self) -> float:
# guessed by Lost-MSth
return 2.45 * self.user_play.rating ** 0.5 + 27
@property
def step_times(self) -> float:
raise NotImplementedError
@property
def exp_times(self) -> float:
return self.user_play.stamina_multiply * (self.user_play.prog_boost_multiply / 100 + 1)
@property
def character_bonus_progress(self) -> float:
return self.character_bonus_progress_normalized * self.step_times
@property
def base_progress(self) -> float:
raise NotImplementedError
@property
def progress_normalized(self) -> float:
raise NotImplementedError
@property
def final_progress(self) -> float:
raise NotImplementedError
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()
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
self.character_used.level.exp = self.user.character.level.exp
self.character_used.frag.set_parameter(50, 50, 50)
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()
def after_update(self) -> None:
for i in self.user.current_map.rewards_for_climbing: # 物品分发
for j in i['items']:
j.c = self.c
j.user_claim_item(self.user)
x: 'Step' = self.user.current_map.steps_for_climbing[-1]
if x.step_type:
if 'plusstamina' in x.step_type and x.plus_stamina_value:
# 体力格子
self.user.stamina.stamina += x.plus_stamina_value
self.user.stamina.update()
# 角色升级
if self.character_used.database_table_name == 'user_char':
self.character_used.upgrade(
self.user, self.exp_times*self.user_play.rating*6)
if self.user.current_map.curr_position == self.user.current_map.step_count-1 and self.user.current_map.is_repeatable: # 循环图判断
self.user.current_map.curr_position = 0
self.user.current_map.update()
def update(self) -> None:
'''世界模式更新'''
self.before_update()
self.before_calculate()
self.user.current_map.climb(self.final_progress)
self.after_climb()
self.after_update()
class WorldPlay(BaseWorldPlay):
def __init__(self, c=None, user=None, user_play=None) -> None:
super().__init__(c, user, user_play)
self.prog_tempest: float = None
self.prog_skill_increase: float = None
self.kanae_added_progress: float = None # 群愿往外拿
self.kanae_stored_progress: float = None # 往群愿里塞
# self.user.kanae_stored_prog: float 群愿有的
def to_dict(self) -> dict:
r = super().to_dict()
# 基础进度加上搭档倍数 不带 character_bonus_progress 但是带 kanae 技能
r['progress_partial_after_stat'] = self.progress_normalized
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
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.kanae_added_progress is not None:
r['kanae_added_progress'] = self.kanae_added_progress
if self.kanae_stored_progress is not None:
r['kanae_stored_progress'] = self.kanae_stored_progress
r['partner_adjusted_prog'] = self.partner_adjusted_prog
r["user_map"]["steps"] = [x.to_dict()
for x in self.user.current_map.steps_for_climbing]
return r
@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)
@property
def character_bonus_progress(self) -> float:
return self.character_bonus_progress_normalized * self.step_times
@property
def base_progress(self) -> float:
return 2.5 + 2.45 * self.user_play.rating**0.5
@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)
@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
@property
def progress_normalized(self) -> float:
return self.base_progress * (self.partner_adjusted_prog / 50)
def after_update(self) -> None:
'''世界模式更新'''
super().after_update()
# 更新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)
# 更新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
if self.kanae_added_progress is None:
return
self.kanae_stored_progress = 0
self.user.update_user_one_column('kanae_stored_prog', 0)
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()

View File

@@ -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`.

View File

@@ -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)]

View File

@@ -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
}
]

View File

@@ -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
}
]

View File

@@ -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);

View File

@@ -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"
}
]
}
]
}

View File

@@ -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()

View File

@@ -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

View File

@@ -1,4 +1,4 @@
# import binascii
import binascii
import logging
import socketserver
import threading
@@ -21,11 +21,12 @@ class UDP_handler(socketserver.BaseRequestHandler):
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 @@ class UDP_handler(socketserver.BaseRequestHandler):
# 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 @@ class TCP_handler(socketserver.StreamRequestHandler):
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 @@ class TCP_handler(socketserver.StreamRequestHandler):
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

View File

@@ -7,15 +7,18 @@ from time import time
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 @@ class TCPRouter:
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 @@ class TCPRouter:
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 @@ class TCPRouter:
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 @@ class TCPRouter:
# 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 @@ class TCPRouter:
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 @@ class TCPRouter:
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 @@ class TCPRouter:
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 @@ class TCPRouter:
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 @@ class TCPRouter:
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 @@ class TCPRouter:
# 房间信息更新
# 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 @@ class TCPRouter:
'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
}

View File

@@ -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.difficulty = 0xff
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.score = 0
self.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.last_character_id = 0xff
self.is_uncapped = 0
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.best_score_flag = 0
self.best_player_flag = 0
self.score = Score()
self.last_score = Score()
self.finish_flag = 0
self.player_state = 1
@@ -45,6 +93,16 @@ class Player:
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 @@ class Player:
'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 @@ class Player:
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 @@ class Room:
@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 @@ class Room:
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 @@ class Room:
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 @@ class Room:
def is_finish(self):
# 是否全部进入结算
if self.state == 8:
if self.state != 7:
return False
for i in self.players:
@@ -245,27 +399,110 @@ class Room:
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.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 > max_score:
max_score = player.last_score
max_score_i = [i]
elif player.last_score == max_score:
max_score_i.append(i)
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

View File

@@ -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 @@ class CommandParser:
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 @@ class CommandParser:
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 @@ class CommandParser:
# 尝试进入结算
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 @@ class CommandParser:
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 @@ class CommandParser:
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 @@ class CommandParser:
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_11 = False
flag_12 = False
if player.online == 0:
flag_12 = True
player.online = 1
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 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
self.room.state = 2
if player.player_state != self.command[32]:
flag_12 = True
player.player_state = self.command[32]
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 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]
if self.room.state in (5, 6) and self.room.should_next_state:
# 此处不清楚
self.room.state = 7
flag_13 = True
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 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.download_percent != self.command[35]:
flag_12 = True
player.download_percent = self.command[35]
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 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 != bi(self.command[24:28]):
flag_12 = True
player.score = bi(self.command[24:28])
if self.room.is_ready(3, 4):
if self.room.is_ready(8, 1):
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()
self.room.state = 1
self.room.song_idx = 0xffff
logging.info(f'Room `{self.room.room_code}` starts playing')
if self.room.state == 8 and self.room.should_next_state:
flag_0c = True
flag_13 = True
self.room.state = 1
self.room.song_idx = 0xffff
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:
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 @@ class CommandParser:
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 @@ class CommandParser:
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)]

View File

@@ -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 = b'\x11\x11\x11\x11\x00\x00\x00\x00'
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
@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 @@ class CommandSender:
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 @@ class CommandSender:
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)))

View File

@@ -27,6 +27,7 @@ import server
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.config['SESSION_TYPE'] = 'filesystem'
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/<string:token>', 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)

View File

@@ -1,3 +1,3 @@
flask>=2.0.2
cryptography>=3.0.0
cryptography>=35.0.0
limits>=2.7.0

View File

@@ -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)
score, user, world, mission)
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)
__bp_old = Blueprint('old_server', __name__)
@__bp_old.route('/<path:any>', 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

View File

@@ -3,8 +3,9 @@ from traceback import format_exc
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')

View File

@@ -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()
})

View File

@@ -2,7 +2,8 @@ from flask import Blueprint, request
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/<room_code>/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({})

View File

@@ -4,8 +4,11 @@ from urllib.parse import parse_qs, urlparse
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 core.user import UserOnline
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()

View File

@@ -2,6 +2,7 @@ from time import time
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({

View File

@@ -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']) # 邮箱验证重发

View File

@@ -2,7 +2,7 @@ from flask import Blueprint, request
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)]
})

View File

@@ -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;

View File

@@ -34,6 +34,11 @@
<span class="difficulty_byd">BYD</span>
<span class="song-rating">{{song['rating_byn']}}</span>
{% endif %}
<br />
{% if song['rating_etr'] %}
<span class="difficulty_etr">ETR</span>
<span class="song-rating">{{song['rating_etr']}}</span>
{% endif %}
</div>

View File

@@ -28,6 +28,7 @@
<option value='fragment'>Fragment</option>
<option value='memory'>Memory</option>
<option value='anni5tix'>Anniversary 5 ticket</option>
<option value='pick_ticket'>Pick ticket</option>
</select>
</div>
<label for="amount">Amount</label>

View File

@@ -39,6 +39,7 @@
<option value='fragment'>Fragment</option>
<option value='memory'>Memory</option>
<option value='anni5tix'>Anniversary 5 ticket</option>
<option value='pick_ticket'>Pick ticket</option>
</select>
</div>
<label for="amount">Amount</label>

View File

@@ -19,6 +19,8 @@
<input name="rating_ftr" id="rating_ftr" required>
<label for="rating_byd">Beyond chart const</label>
<input name="rating_byd" id="rating_byd" required>
<label for="rating_etr">Eternal chart const</label>
<input name="rating_etr" id="rating_etr" required>
<div class="content">如果没有某个谱面,应该填入-1。</div>
<div class="content">If there is no some chart, fill in -1 please.</div>
<input type="submit" value="Add">

View File

@@ -13,7 +13,8 @@
这里可以将旧版本的数据库同步到新版本的数据库,并刷新用户拥有的全角色列表。<br />
可上传文件: arcaea_database.db<br />
新数据库不存在的数据会被添加,存在的重复数据也会被改变。<br /><br />
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.<br />
Uploadable files: arcaea_database.db<br />
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 @@
<div class="content">Here you can refresh the ratings of all scores in the database. The purpose is to deal
with the updating of songs' chart consts.</div>
</form>
<br />
<hr />
<form action="/web/updatedatabase/refreshsbundle" method="post">
<div class="title">Refresh content bundles</div>
<br />
<input type="submit" value="Refresh">
<div class="content">这里可以刷新储存在内存中的 bundle 文件夹下所有热更新文件的信息。目的是应对热更新文件的修改。</div>
<div class="content">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.</div>
</form>
{% endblock %}

View File

@@ -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 all_song():
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 add_song():
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 add_song():
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:

View File

@@ -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

View File

@@ -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

View File

@@ -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('/<path:p>')
def hello(p):
r = {"success": False, "error_code": 2}
return jsonify(r)
if __name__ == '__main__':
app.run(host=HOST, port=PORT)

View File

@@ -34,7 +34,7 @@ class Connect():
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.')