72 Commits

Author SHA1 Message Date
Lost-MSth
cf20a2c7cb #234 2026-01-29 23:35:56 +08:00
Lost-MSth
bfb6a2ddda [Bug fix][Enhance] Link Play Unlock Bug
- For Arcaea 6.12.0
- Support for "is_allow_marketing_email"
- Support for config "LOG_FOLDER_PATH" (Thanks to PR #234)
- Fix a bug about one song unlock bug in Link Play, due to wrong length of song unlock bytes.
2026-01-29 23:33:00 +08:00
Lost-MSth
8180eef856 Merge pull request #234 from A-random-githuber/dev 2026-01-29 23:29:30 +08:00
A-random-githuber
2cc69a1100 oop 2025-12-26 00:35:34 +07:00
A-random-githuber
73f28f38d3 Cache song 2025-12-26 00:33:28 +07:00
A-random-githuber
707f881bdb Cache song 2025-12-26 00:27:29 +07:00
A-random-githuber
71b789fbaa Song cache 2025-12-26 00:26:22 +07:00
Lost-MSth
d11a8435b3 [Enhance] World Parallel
- For Arcaea 6.11.3
- Add partial support for World Parallel Mode.
- Add some new partners, including "VIIM", "Helena" and "Yuno".

Note: Link Play server has updated and do not support old clients anymore. World Parallel Mode and Link Play may have bugs, because I haven't check out all things carefully.
2025-12-25 01:24:35 +08:00
Lost-MSth
17998ee655 [Bug Fix] Fix /unlock/me/awaken_maya endpoint
- For Arcaea 6.11.0
- Fix a bug that custom errors in `/unlock/me/awaken_maya` cause client errors. #233
2025-11-27 22:49:21 +08:00
Lost-MSth
39e1e52d36 [Enhance] Support skill_hikari_tairitsu_debut skill
- For Arcaea 6.10.8
- Add the new character "Hikari & Tairitsu[ANS] (Debut)", and add support for its skill.
2025-11-23 01:34:40 +08:00
Lost-MSth
52bb834a8a [Enhance] Support Maya's Awaken
- For Arcaea 6.10.1
- Add an endpoint for Maya's awaken. #229
- Add the new character "Hikari & Tairitsu[ANS] (Breakthrough)". (values maybe incorrect)
2025-10-30 23:31:58 +08:00
Lost-MSth
65f6d212fe [Enhance] Maya uncap skill
- Add support for `skill_maya_uncap`
- For Arcaea 6.9.1
2025-10-02 17:45:20 +08:00
Lost-MSth
9c27b998ef [Enhance][Bug fix] API for custom banner
- For Arcaea 6.8.2
- Add support for "skill_vita_arc".
- Now `/users/<user_id> PUT` API endpoint can change the user's custom banner. #223
- Fix a bug that the playing result of beyond world map shows zero value at partner multiply term.
2025-09-04 23:39:00 +08:00
Lost-MSth
a3891a901f Merge pull request #222 from rlarhsid/dev
Adding `core_alluring`
2025-09-04 21:07:29 +08:00
Lost-MSth
32b708b7de Merge pull request #225 from YinMo19/dev
[enhance] sql efficiency
2025-09-04 21:07:01 +08:00
YinMo19
eee34bae80 [enhance] sql efficiency 2025-08-31 16:14:02 +08:00
ceed
029d43e3f5 Bug Fix for core_alluring
- Added `core_alluring` in arc_data.py.
2025-08-24 23:03:46 +09:00
Lost-MSth
05bdd42679 [Enhance] Support skill_nonoka_uncap
- Add support for `skill_nonoka_uncap`.
- For Arcaea 6.8.0
2025-08-21 16:13:43 +08:00
Lost-MSth
5b5f544a19 Merge pull request #220 from YinMo19/dev
[fix] fix sql syntax
2025-08-21 16:10:28 +08:00
YinMo19
d05618049c [fix] fix sql syntax 2025-08-21 13:48:10 +08:00
Lost-MSth
2e43dfbdff Merge pull request #219 from c8763yee/feat/audioOverride-without-remote_dl
- Make server serve audio files for songs with both `audioOverride=True` and `remote_dl=False` flags, when specifying `songlist` file in server.
2025-08-19 17:20:03 +08:00
c8763yee
7f94416189 fix: 3.ogg not download when audioOverride but remote_dl is false 2025-08-15 19:42:58 +08:00
Lost-MSth
4911511ed7 [Bug fix] Import way to run
- Fix a bug that if you import the main app, especially when using gunicorn, you will miss some necessary initial operations. #216
2025-08-06 17:29:05 +08:00
Lost-MSth
46b3b053cf Update to v2.12.1 2025-07-24 23:01:55 +08:00
Lost-MSth
6c6a13086f [Enhance] Support skill_nami_sui
- For Arcaea 6.6.0
- New partners "Tairitsu & El Fail" and "Nami & Sui (Twilight)"
- Add support for the character skill `skill_nami_sui`.
2025-06-29 21:47:57 +08:00
Lost-MSth
97b86a4381 [Enhance] Limited custom banner
- For Arcaea 6.5.0
- Add limited support for custom banners. It will be improved in the future.  #202
2025-05-22 21:34:25 +08:00
Lost-MSth
a3bc071416 [Bug fix] Link Play song idx too big error
- Fix a bug that if song's idx is too big, the server will catch the error, which makes Link Play cannot work.
- For Arcaea 6.4.0/1
2025-04-24 23:56:59 +08:00
Lost-MSth
c4da67cd94 [Enhance] Account Endpoints
- Adapt to new account registration and login endpoints.
- For Arcaea 6.3.2
2025-03-28 00:37:32 +08:00
Lost-MSth
3424ad67cb [Enhance] Support skill_hikari_selene
- For Arcaea 6.2.6
- New partner "Hikari & Selene Sheryl"
- Add support for the character skill `skill_hikari_selene`.
2025-02-27 22:36:12 +08:00
Lost-MSth
ecfb360228 Merge pull request #195 from YinMo19/dev
[bug fix] Add rating_etr to SaveUpdateScore
2025-02-15 18:39:52 +08:00
YinMo19
5a37766cb9 [bug fix] Add rating_etr to SaveUpdateScore 2025-02-15 18:32:33 +08:00
Lost-MSth
a23e5372fb [Enhance][Bug fix] Fatalis values & Salt skill
- For Arcaea 6.2.3
- Add support for `skill_salt`.
- Add support for dynamic values of "Hikari (Fatalis)", which is depended by world mode total steps.
- Fix a bug that the character "Hikari (Fatalis)" cannot be used in world mode. (due to 3f5281582c)
2025-02-06 23:40:00 +08:00
Lost-MSth
44d7d7e490 [Enhance] Insight
- For Arcaea 6.1.1
- Change the state of users' character "Insight" when completing some packs. #189
2024-12-20 21:30:39 +08:00
Lost-MSth
3f5281582c [Enhance] New skills
- For Arcaea 6.0.3
- Add support for `skill_chinatsu` and `skill_intruder`.
- Add support for the invasion toggle (toggle intruder's skill states).

Note that the invasion toggle in 4 states, which is different from the official server.
2024-11-25 15:29:11 +08:00
Lost-MSth
a6c26dedfe [Bug fix] Link Play second play error
- Fix a bug that if players do not start the first play in one room, their scores will be wrong.
- For Arcaea 5.10.6
2024-10-26 17:07:36 +08:00
Lost-MSth
6f964d7a78 Merge pull request #184 from Lost-MSth/master
Fix dev branch
2024-10-03 15:57:26 +08:00
Lost-MSth
f3a1a5e0a9 Merge pull request #183 from Lost-MSth/dev
Update to v2.12.0

close #110 #139 #166 #170
2024-10-03 15:49:47 +08:00
Lost-MSth
bd40704fa7 Update to v2.12.0 2024-10-03 15:48:33 +08:00
Lost-MSth
9c9af892bd [Bug Fix][Enhance] PTT update lately & Register rate limiter
- Fix a bug that PTT updates delay to next play.
- Add the IP and the device rate limiters for user register.
2024-09-28 14:59:24 +08:00
Lost-MSth
dc1ca344e5 [Enhance][Bug Fix]
- For Arcaea 5.10.4
- Add support for the Link Play notification setting.
- Fix a bug that public rooms cannot be matched possibly if some players leave the room.
- Continue to fix the bug that the player cannot complete one course twice.
- Continue to fix the bug that users cannot change online settings.
2024-09-27 00:28:56 +08:00
Lost-MSth
c52fda6f47 Merge pull request #182 from Guzi422/dev
Dev bug fix about notification setting & course
2024-09-27 00:19:17 +08:00
Guzi_422
4c40d8e21f Merge pull request #1 from Lost-MSth/dev
[Bug fix] Link Play kicked out & last song scores
2024-09-14 21:43:13 +08:00
Lost-MSth
27567b8a5c [Bug fix] Link Play kicked out & last song scores
- Fix a bug that players will be kicked out when they haven't downloaded the chart before.
- Fix a bug that the last song's scores will disappear when all players return to room.
- For Arcaea 5.10.2
2024-09-14 12:11:54 +08:00
Guzi422
10a816b161 [Enhance][Bug fix] User settings & /user/me/setting error
- Add a new settings 'mp_notification_enabled'
- Fix a bug that user can not change online settings. (Show Potential/Stamina Notification/Invite Notification)
2024-09-08 22:07:59 +08:00
Guzi422
c3af92f29b 修复当用户再次通过已经通过的段位时无法正常上传分数的问题 2024-09-08 21:24:40 +08:00
Lost-MSth
014531f3f1 [Enhance] Link Play 2.0 e.t.c.
- For Arcaea 5.10.1(c)
- Add support for Link Play 2.0.
- New partners "Luna & Ilot" and "Eto & Hoppe"
- Add support for the skill of "Eto & Hoppe".
- Add support for refreshing ratings of Recent 30 via API and webpage.

Note: This is a bug testing version.
2024-09-06 22:43:38 +08:00
Lost-MSth
59422f96b5 [Enhance] Full unlock for "alterego"
- Add support for full cloud save  unlocking of "alterego"
- For Arcaea 5.9.0
2024-07-30 19:39:46 +08:00
Lost-MSth
9bb7171354 [Bug fix] Update error & About bundle download
- Data for Arcaea 5.8.0
- Fix a bug that the server's database updates from an old version may meet a challenge if `recent30` table has some strange data.
- Fix the issue where the bundle downloading memory cache does not clean up.
- Add a config term to only change the X-Accel-Redirect prefix for bundle downloading.
2024-07-02 22:18:00 +08:00
Lost-MSth
c38208832b [Enhance] World map sub folder
- Make the world maps' folder can have sub folders.
2024-06-20 00:13:58 +08:00
Lost-MSth
64526e3c4b [Bug fix] video download error with songlist
- Fix a bug that the video files cannot be downloaded when
the `songlist` file exists. #177
2024-06-14 23:47:52 +08:00
Lost-MSth
dd0bc64c93 [Bug fix] A typo causes updating error 2024-06-14 22:20:28 +08:00
Lost-MSth
2a08c9cd14 [Enhance] World rank score mechanism
- Adjust world rank mechanism to be closer to the official one.

Note: You need to refresh rating in web admin backend after updating, and the users need to get a better or new score to refresh world rank.
2024-06-14 16:06:26 +08:00
Lost-MSth
338f6579aa [Bug fix] User delete bug in register
- Fix a bug that new user can not register when some users delete their accounts.
2024-05-08 21:17:51 +08:00
Lost-MSth
9636cfcae8 [Enhance][Bug fix] ETR world score & Finale & Bundle Update Message
- Start to consider the ETR scores when getting the sum of all scores in global ranking.
- Add support for automatically adding partner "Hikari & Tairitsu (Reunion)" and "Hikari (Fatalis)", to try to unlock Finale stories correctly. #110 #164
- Fix a bug that the POST requests will be neglected if a new content bundle update exists.
2024-05-06 18:01:34 +08:00
Lost-MSth
5c539bdf59 [Enhance] Recent 30
- Update Recent 30 mechanism.
- Alter Recent 30 table structure.

Note:
1. This is a TEST version. Maybe there are many bugs.
2. This special version is a line of demarcation.
2024-04-30 00:27:23 +08:00
Lost-MSth
efedd96908 [Enhance][Bug fix] Another bundle mode & skill_amane bug
- For Arcaea 5.6.0
- Fix a bug that `skill_amane` may arise error when the step type of world map is null.
- Add a new bundle update mode, which is same with official server, that the server will find a update path from old version to new version, ignoring the application version restrictions.
2024-04-26 17:55:29 +08:00
Lost-MSth
2d498ae02b [Enhance] Link Play Unlock for ETR
- Add support for the new client with ETR difficulty in Link Play #160
2024-04-12 16:16:35 +08:00
Lost-MSth
ab201c9d48 [Bug fix][Enhance] Songlist parser & Bunder version error message
- For Arcaea 5.5.8 (no test)
- New character "Ai-chan"
- Add a small tool to send error message
- A `songlist` parser problem #156
- Fix that header checker of bundle version gives incorrect error messages.
2024-04-07 21:55:52 +08:00
Lost-MSth
02bf565ebd [Bug fix] API song difficulty ETR
- Fix a but that some API cannot handle ETR difficulty.
- New character "Vita(Cadenza)"
- For Arcaea 5.5.0 / 5.5.6
2024-04-01 16:41:15 +08:00
Lost-MSth
a05bb4854d [Enhance][Bug fix][Doc] ETR for songdb tool & Update notify
- Add support for ETR difficulties in the `update_song.py` tool
- Make content bundle updates mandatory for client
- README update
2024-03-17 22:35:52 +08:00
Lost-MSth
328be5b923 Merge pull request #153 from tenshi0xx/dev
- Fix ETR difficulty download
- Fix ETR score uploading
2024-03-16 12:43:10 +08:00
Tenshi
2f826e9ffc Fix ETR Score sending 2024-03-16 10:45:11 +07:00
Tenshi
eeced74f50 Fix ETR difficulty download 2024-03-16 10:38:47 +07:00
Lost-MSth
8f66b90912 [Enhance] Content bundle
- Add support for content bundles
- For Arcaea 5.4.0 play store version and iOS version
2024-03-11 16:31:21 +08:00
Lost-MSth
d65cc3bcbe [Enhance] Missions & ETR
- Add support for missions
- PTT mechanism: Change first play protection to new best protection
- Adapt to the new difficulty ETR
- Uncap DORO*C
- Incomplete support for "pick_ticket"
- Fix requirements: cryptography >= 35.0.0

Note: This is an intermediate test version, only for Arcaea 5.4.0c. Next version will adapt to 5.4.0.
2024-03-10 11:26:21 +08:00
Lost-MSth
e206247c09 [Enhance] Self account delete
- Add support for users destroy their own accounts
2024-02-25 17:28:54 +08:00
Lost-MSth
b3bf55407f [Enhance] Game new/old API prefix list support
- Add some endpoints for old API prefixes to notify users to update the client; add support for multiple game prefixes
2024-02-24 16:39:15 +08:00
Lost-MSth
50369d1f23 [Bug fix] API for Link Play
- Fix a bug that API for Link Play cannot work
- Add an example breached map #148
2024-02-20 23:04:58 +08:00
Lost-MSth
9d096f480b [Enhance] Breached World
- Add support for Breached World Map
- Change the recover time of using fragments buying stamina to 23 hours
- For Arcaea 5.3.0
2024-01-30 15:16:26 +08:00
Lost-MSth
2edf559373 [Enhance] Support uncapped Kanae's skill
- Add support for `skill_kanae_uncap`
- For Arcaea 5.2.6
2024-01-15 23:33:09 +08:00
Lost-MSth
746712718b [Refactor][Bug fix] World mode & skills
- Code refactor for world mode
- Try to fix some skills to keep it same with the official server (especially `ayu_uncap`), and adjust the order of World Mode progress calculation (maybe still not right).
- Uncap Kanae

Note: not ready for Kanae's uncapping skill (I do not know its actual effect.)
2024-01-13 15:14:54 +08:00
Lost-MSth
de701b4d1b [Bug fix] Course high score record
- Fix a logical bug that the course's high score will not update if the user does not complete the whole course challenge.
2023-12-07 18:00:23 +08:00
67 changed files with 4906 additions and 1112 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/

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,69 @@ 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.1
> v2.11.2.1 ~ v2.11.2.7 for Arcaea 4.5.0 ~ 5.2.0
> v2.12.0.1 ~ v2.12.0.10 for Arcaea 5.10.6 ~ 6.7.1
>
> Here are not all bug fixes and features.
- 适用于 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 6.7.1 版本
For Arcaea 6.7.1
- 添加一些新搭档和搭档的觉醒形态,并支持他们的技能
Add some new partners, uncap some others, and add support for their skills.
- 支持“光 (Fatalis)”的由世界模式总台阶数决定的动态搭档数值
Add support for dynamic values of "Hikari (Fatalis)", which is depended by world mode total steps.
- 支持新版本客户端的账户注册和登录接口
Adapt to new account registration and login endpoints of the new version client.
- 修复 Link Play 房间中玩家历史分数异常的问题
Fix a bug that if players do not start the first play in one room, their scores will be wrong.
- 修复因为歌曲 idx 数值过大导致服务端错误,致使 Link Play 模块无法工作的问题
Fix a bug that if song's idx is too big, the server will catch the error, which makes Link Play cannot work.
- 修复谱面难度 ETR 相关问题
Fix some bugs about the ETR difficulty.
## 运行环境与依赖 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 +157,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 +167,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

@@ -81,7 +81,7 @@ def users_user_get(user, user_id):
@bp.route('/<int:user_id>', methods=['PUT'])
@role_required(request, ['change'])
@request_json_handle(request, optional_keys=['name', 'password', 'user_code', 'ticket', 'email'], must_change=True)
@request_json_handle(request, optional_keys=['name', 'password', 'user_code', 'ticket', 'email', 'custom_banner'], must_change=True)
@api_try
def users_user_put(data, user, user_id):
'''修改一个用户'''
@@ -110,6 +110,11 @@ def users_user_put(data, user, user_id):
raise InputError('Ticket must be int')
u.ticket = data['ticket']
r['ticket'] = u.ticket
if 'custom_banner' in data:
if not isinstance(data['custom_banner'], str):
raise InputError('Value `custom_banner` must be str')
u.custom_banner = data['custom_banner']
r['custom_banner'] = u.custom_banner
u.update_columns(d=r)
return success_return(r)

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

@@ -2,6 +2,7 @@ from .config_manager import Config
from .constant import Constant
from .error import ArcError, InputError, ItemNotEnough, NoData
from .item import CollectionItemMixin, ItemCore
from .sql import UserKVTable
class Level:
@@ -55,6 +56,7 @@ class Skill:
class CharacterValue:
def __init__(self, start: float = 0, mid: float = 0, end: float = 0) -> None:
self.set_parameter(start, mid, end)
self.addition: float = 0
@staticmethod
def _calc_char_value_20_math(level: int, value_1: float, value_20: float) -> float:
@@ -87,9 +89,9 @@ class CharacterValue:
def get_value(self, level: Level):
if level.min_level <= level.level <= level.mid_level:
return self._calc_char_value_20_math(level.level, self.start, self.mid)
return self._calc_char_value_20_math(level.level, self.start, self.mid) + self.addition
if level.mid_level < level.level <= level.max_level:
return self._calc_char_value_30(level.level, self.mid, self.end)
return self._calc_char_value_30(level.level, self.mid, self.end) + self.addition
return 0
@@ -120,6 +122,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
@@ -219,6 +233,8 @@ class UserCharacter(Character):
self.skill_flag: bool = None
self.fatalis_is_limited: bool = False
@property
def skill_id_displayed(self) -> str:
'''对外显示的技能id'''
@@ -283,6 +299,22 @@ class UserCharacter(Character):
if self.character_id in (21, 46):
self.voice = [0, 1, 2, 3, 100, 1000, 1001]
if self.character_id == 55:
# fatalis 提升数值
# prog & overdrive += 世界模式中完成的所有非无限地图的台阶数之和 / 30
if Config.CHARACTER_FULL_UNLOCK:
addition = Constant.FATALIS_MAX_VALUE
self.fatalis_is_limited = True
else:
kvd = UserKVTable(self.c, self.user.user_id, 'world')
steps = kvd['total_step_count'] or 0
addition = steps / 30
if addition >= Constant.FATALIS_MAX_VALUE:
addition = Constant.FATALIS_MAX_VALUE
self.fatalis_is_limited = True
self.prog.addition = addition
self.overdrive.addition = addition
self.select_character_core()
def to_dict(self) -> dict:
@@ -309,7 +341,7 @@ class UserCharacter(Character):
if self.voice:
r['voice'] = self.voice
if self.character_id == 55:
r['fatalis_is_limited'] = False # emmmmmmm
r['fatalis_is_limited'] = self.fatalis_is_limited
if self.character_id in [1, 6, 7, 17, 18, 24, 32, 35, 52]:
r['base_character_id'] = 1

View File

@@ -12,10 +12,13 @@ class Config:
SONG_FILE_HASH_PRE_CALCULATE = True
GAME_API_PREFIX = '/evolution/23'
GAME_API_PREFIX = ['/apricotduck/38', '/'] # 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,24 +78,36 @@ class Config:
SAVE_FULL_UNLOCK = False
ALLOW_SELF_ACCOUNT_DELETE = False
# ------------------------------------------
# You can change this to make another PTT mechanism.
BEST30_WEIGHT = 1 / 40
RECENT10_WEIGHT = 1 / 40
INVASION_START_WEIGHT = 0.1
INVASION_HARD_WEIGHT = 0.1
MAX_FRIEND_COUNT = 50
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'
LOG_FOLDER_PATH = './log/'
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.12.1.8'
ARCAEA_DATABASE_VERSION = 'v2.12.1.8'
ARCAEA_LOG_DATBASE_VERSION = 'v1.1'
@@ -10,7 +11,12 @@ class Constant:
MAX_STAMINA = 12
# INSIGHT_STATES = [x for x in range(7)]
INSIGHT_TOGGLE_STATES = [3, 4, 5, 6]
# DEFAULT_INSIGHT_STATE = Config.DEFAULT_INSIGHT_STATE
STAMINA_RECOVER_TICK = 1800000
FRAGSTAM_RECOVER_TICK = 23 * 3600 * 1000
COURSE_STAMINA_COST = 4
@@ -30,6 +36,7 @@ class Constant:
SKILL_FATALIS_WORLD_LOCKED_TIME = 3600000
SKILL_MIKA_SONGS = ['aprilshowers', 'seventhsense', 'oshamascramble',
'amazingmightyyyy', 'cycles', 'maxrage', 'infinity', 'temptation']
FATALIS_MAX_VALUE = 100
MAX_FRIEND_COUNT = Config.MAX_FRIEND_COUNT
@@ -38,18 +45,25 @@ class Constant:
BEST30_WEIGHT = Config.BEST30_WEIGHT
RECENT10_WEIGHT = Config.RECENT10_WEIGHT
INVASION_START_WEIGHT = Config.INVASION_START_WEIGHT
INVASION_HARD_WEIGHT = Config.INVASION_HARD_WEIGHT
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_UNLOCK_LENGTH = 1024 # Units: bytes
LINKPLAY_TIMEOUT = 5 # Units: seconds
LINKPLAY_HOST = '127.0.0.1' if Config.SET_LINKPLAY_SERVER_AS_SUB_PROCESS else Config.LINKPLAY_HOST
@@ -59,6 +73,12 @@ 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,25 +80,28 @@ 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', [])):
r |= 8
else: # 針對remote_dl為False時BYD難度強制下載的處理
for i in song.get('difficulties', []):
if i['ratingClass'] == 3:
r |= 8
if i.get('audioOverride', False):
r |= 64
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,19 @@ 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
if index >= Constant.LINKPLAY_UNLOCK_LENGTH * 8:
continue
user_song_unlock[index // 8] |= 1 << (index % 8)
return bytes(user_song_unlock)
@@ -52,6 +40,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 +63,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 +81,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 +118,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 +133,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 +147,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 +175,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 +199,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 +229,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):
'''
云存档更新成绩,是覆盖式更新
@@ -113,19 +171,24 @@ class SaveUpdateScore(BaseOperation):
song_id = list(set(song_id_1 + song_id_2))
c.execute(
f'''select song_id, rating_pst, rating_prs, rating_ftr, rating_byn from chart where song_id in ({','.join(['?']*len(song_id))})''', song_id)
f'''select song_id, rating_pst, rating_prs, rating_ftr, rating_byn, rating_etr from chart where song_id in ({','.join(['?']*len(song_id))})''', song_id)
x = c.fetchall()
song_chart_const = {i[0]: [i[1], i[2], i[3], i[4]]
song_chart_const = {i[0]: [i[1], i[2], i[3], i[4], i[5]]
for i in x} # chart const * 10
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,16 +196,16 @@ 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:
c.execute(
'''select song_id, rating_pst, rating_prs, rating_ftr, rating_byn from chart''')
song_chart_const = {i[0]: [i[1], i[2], i[3], i[4]]
'''select song_id, rating_pst, rating_prs, rating_ftr, rating_byn, rating_etr from chart''')
song_chart_const = {i[0]: [i[1], i[2], i[3], i[4], i[5]]
for i in c.fetchall()} # chart const * 10
c.execute('''select user_id from user_save''')
for y in c.fetchall():
@@ -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

@@ -1,5 +1,6 @@
from base64 import b64encode
from os import urandom
from random import choices
from time import time
from .bgtask import BGTask, logdb_execute
@@ -11,7 +12,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 +31,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 +95,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 +126,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 +202,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 +229,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
@@ -223,10 +245,23 @@ class UserPlay(UserScore):
self.course_play: 'CoursePlay' = None
self.combo_interval_bonus: int = None # 不能给 None 以外的默认值
self.hp_interval_bonus: int = None # 不能给 None 以外的默认值
self.fever_bonus: int = None # 不能给 None 以外的默认值
self.rank_bonus: int = None # 不能给 None 以外的默认值
self.maya_gauge: int = None # 不能给 None 以外的默认值
self.nextstage_bonus: int = None # 不能给 None 以外的默认值
self.skill_cytusii_flag: str = None
self.skill_chinatsu_flag: str = None
self.highest_health: int = None
self.lowest_health: int = None
# room score
self.room_code: str = None
self.room_total_score: int = None
self.room_total_players: int = None
self.invasion_flag: int = None # 1: invasion_start, 2: invasion_hard
def to_dict(self) -> dict:
# 不能super
if self.is_world_mode is None or self.course_play_state is None:
@@ -245,7 +280,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,12 +295,23 @@ 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
x = x + str(self.combo_interval_bonus)
if self.hp_interval_bonus is not None and self.hp_interval_bonus < 0:
return False
if self.fever_bonus is not None and (self.fever_bonus < 0 or self.fever_bonus > self.perfect_count * 5):
# fever 等级最高为 5
return False
if self.rank_bonus is not None and (self.rank_bonus < 0 or self.rank_bonus > 4):
return False
y = f'{self.user.user_id}{self.song_hash}'
checksum = md5(x+md5(y))
@@ -304,16 +350,27 @@ class UserPlay(UserScore):
self.prog_boost_multiply = int(x[10])
self.beyond_boost_gauge_usage = int(x[11])
self.skill_cytusii_flag = x[12]
self.skill_chinatsu_flag = x[13]
self.invasion_flag = x[14]
self.is_world_mode = True
self.course_play_state = -1
def set_play_state_for_world(self, stamina_multiply: int = 1, fragment_multiply: int = 100, prog_boost_multiply: int = 0, beyond_boost_gauge_usage: int = 0, skill_cytusii_flag: str = None) -> None:
def set_play_state_for_world(
self,
stamina_multiply: int = 1,
fragment_multiply: int = 100,
prog_boost_multiply: int = 0,
beyond_boost_gauge_usage: int = 0,
skill_cytusii_flag: str = None,
skill_chinatsu_flag: str = None
) -> None:
self.song_token = b64encode(urandom(64)).decode()
self.stamina_multiply = int(stamina_multiply)
self.fragment_multiply = int(fragment_multiply)
self.prog_boost_multiply = int(prog_boost_multiply)
self.beyond_boost_gauge_usage = int(beyond_boost_gauge_usage)
self.skill_cytusii_flag = skill_cytusii_flag
self.skill_chinatsu_flag = skill_chinatsu_flag
if self.prog_boost_multiply != 0 or self.beyond_boost_gauge_usage != 0:
self.c.execute('''select prog_boost, beyond_boost_gauge from user where user_id=:a''', {
'a': self.user.user_id})
@@ -324,10 +381,6 @@ class UserPlay(UserScore):
# 注意偷懒了没判断是否是beyond图
self.beyond_boost_gauge_usage = 0
self.clear_play_state()
self.c.execute('''insert into songplay_token values(:t,:a,:b,:c,'',-1,0,0,:d,:e,:f,:g,:h)''', {
'a': self.user.user_id, 'b': self.song.song_id, 'c': self.song.difficulty, 'd': self.stamina_multiply, 'e': self.fragment_multiply, 'f': self.prog_boost_multiply, 'g': self.beyond_boost_gauge_usage, 'h': self.skill_cytusii_flag, 't': self.song_token})
self.user.select_user_about_current_map()
self.user.current_map.select_map_info()
@@ -335,17 +388,26 @@ class UserPlay(UserScore):
if self.user.stamina.stamina < self.user.current_map.stamina_cost * self.stamina_multiply:
raise StaminaNotEnough('Stamina is not enough.')
fatalis_stamina_multiply = 1
self.user.select_user_about_character()
if not self.user.is_skill_sealed:
self.user.character.select_character_info()
if self.user.character.skill_id_displayed == 'skill_fatalis':
# 特殊判断hikari fatalis的双倍体力消耗
self.user.stamina.stamina -= self.user.current_map.stamina_cost * \
self.stamina_multiply * 2
self.user.stamina.update()
return None
# invasion 扔骰子
_flag = choices([0, 1, 2], [
max(1 - Constant.INVASION_START_WEIGHT - Constant.INVASION_HARD_WEIGHT, 0), Constant.INVASION_START_WEIGHT, Constant.INVASION_HARD_WEIGHT])[0]
if self.user.is_insight_enabled and _flag != 0:
self.invasion_flag = _flag
self.user.stamina.stamina -= self.user.current_map.stamina_cost * self.stamina_multiply
elif self.user.character.skill_id_displayed == 'skill_fatalis':
# 特殊判断hikari fatalis的双倍体力消耗
fatalis_stamina_multiply = 2
self.clear_play_state()
self.c.execute('''insert into songplay_token values(:t,:a,:b,:c,'',-1,0,0,:d,:e,:f,:g,:h,:i,:j)''', {
'a': self.user.user_id, 'b': self.song.song_id, 'c': self.song.difficulty, 'd': self.stamina_multiply, 'e': self.fragment_multiply, 'f': self.prog_boost_multiply, 'g': self.beyond_boost_gauge_usage, 'h': self.skill_cytusii_flag, 'i': self.skill_chinatsu_flag, 'j': self.invasion_flag, 't': self.song_token})
self.user.stamina.stamina -= self.user.current_map.stamina_cost * \
self.stamina_multiply * fatalis_stamina_multiply
self.user.stamina.update()
def set_play_state_for_course(self, use_course_skip_purchase: bool, course_id: str = None) -> None:
@@ -358,8 +420,8 @@ class UserPlay(UserScore):
self.course_play.score = 0
self.course_play.clear_type = 3 # 设置为PM即最大值
self.c.execute('''insert into songplay_token values(?,?,?,?,?,?,?,?,1,100,0,0,"")''', (self.song_token, self.user.user_id, self.song.song_id,
self.song.difficulty, self.course_play.course_id, self.course_play_state, self.course_play.score, self.course_play.clear_type))
self.c.execute('''insert into songplay_token values(?,?,?,?,?,?,?,?,1,100,0,0,"","",0)''', (self.song_token, self.user.user_id, self.song.song_id,
self.song.difficulty, self.course_play.course_id, self.course_play_state, self.course_play.score, self.course_play.clear_type))
self.user.select_user_about_stamina()
if use_course_skip_purchase:
x = ItemCore(self.c)
@@ -385,53 +447,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 +491,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 +520,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 +546,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 +563,120 @@ 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 = ? and song_id != '' order by time_played DESC''', (self.user.user_id,))
self.r30_tuples = self.c.fetchall()
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 = ? and song_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():
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

@@ -18,6 +18,9 @@ class Chart:
'difficulty': self.difficulty,
'chart_const': self.chart_const
}
def to_tuple(self) -> tuple:
return (self.song_id, self.difficulty)
@property
def chart_const(self) -> float:
@@ -33,7 +36,7 @@ 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,11 +506,54 @@ 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()
@register
def atexit():
MemoryDatabase.conn.close()
class UserKVTable:
'''用户键值对表'''
def __init__(self, c=None, user_id: int = None, class_name: str = None) -> None:
self.c = c
self.user_id = user_id
self.class_name = class_name
def get(self, key: str, idx: int = 0):
'''获取键值对'''
x = self.c.execute(
'''select value from user_kvdata where user_id = ? and class = ? and key = ? and idx = ?''', (self.user_id, self.class_name, key, idx)).fetchone()
return x[0] if x else None
def set(self, key: str, value, idx: int = 0) -> None:
'''设置键值对'''
self.c.execute('''insert or replace into user_kvdata values(?,?,?,?,?)''',
(self.user_id, self.class_name, key, idx, value))
def __getitem__(self, args):
if isinstance(args, tuple):
return self.get(*args)
else:
return self.get(args)
def __setitem__(self, args, value):
if isinstance(args, tuple):
self.set(args[0], value, args[1])
else:
self.set(args, value)

View File

@@ -11,9 +11,10 @@ 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
from .sql import Query, Sql, UserKVTable
from .world import Map, MapParser, UserMap, UserStamina
def code_get_id(c, user_code: str) -> int:
@@ -46,6 +47,8 @@ class User:
self.world_rank_score: int = None
self.ban_flag = None
self.is_allow_marketing_email = False
@property
def hash_pwd(self) -> str:
'''`password`的SHA-256值'''
@@ -53,6 +56,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 +146,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()
@@ -149,11 +164,9 @@ class UserRegister(User):
self._insert_user_char()
self.c.execute('''insert into user(user_id, name, password, join_date, user_code, rating_ptt,
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})
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, is_allow_marketing_email)
values(:user_id, :name, :password, :join_date, :user_code, 0, 0, 0, 0, 0, 0, -1, 0, '', :memories, 0, :email, :is_allow_marketing_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, 'is_allow_marketing_email': self.is_allow_marketing_email})
class UserLogin(User):
@@ -306,13 +319,19 @@ 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
self.stamina: 'UserStamina' = None
self.insight_state: int = None
self.custom_banner = None
self.__cores: list = None
self.__packs: list = None
self.__singles: list = None
@@ -323,6 +342,12 @@ class UserInfo(User):
self.curr_available_maps: list = None
self.__course_banners: list = None
@property
def is_insight_enabled(self) -> bool:
if self.insight_state is None:
self.select_user_one_column('insight_state', 4, int)
return self.insight_state == 3 or self.insight_state == 5
@property
def cores(self) -> list:
if self.__cores is None:
@@ -348,6 +373,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 +520,9 @@ 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,
"is_allow_marketing_email": self.is_allow_marketing_email,
},
"user_id": self.user_id,
"name": self.name,
@@ -501,6 +535,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 +553,24 @@ 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',
'custom_banner': self.custom_banner,
# 'subscription_multiplier': 114,
# 'memory_boost_ticket': 5,
'insight_state': self.insight_state, # 0~2 不可选3 技能激活4 未激活5 激活可选6 未激活可选
# 'enabled_features': [
# {
# "metadata": ["USA"],
# "feature": "paymentlink"
# }
# ],
'has_email': self.email != '',
}
def from_list(self, x: list) -> 'UserInfo':
@@ -559,6 +611,14 @@ 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
self.insight_state = x[38]
self.custom_banner = x[39] if x[39] is not None else ''
self.is_allow_marketing_email = x[40] == 1
return self
@@ -616,7 +676,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 +690,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 +725,59 @@ 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:
def update_user_world_complete_info(self) -> None:
'''
更新用户的世界模式完成信息,包括两个部分
1. 每个章节的完成地图数量,为了 salt 技能
2. 全世界模式完成台阶数之和,为了 fatalis 技能
'''
kvd = UserKVTable(self.c, self.user_id, 'world')
for chapter_id, map_ids in MapParser.chapter_info_without_repeatable.items():
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))
f'''select map_id, curr_position from user_world where user_id = ? and map_id in ({','.join(['?']*len(map_ids))})''',
(self.user_id, *map_ids)
)
x = self.c.fetchall()
n = 0
for map_id, curr_position in x:
step_count = MapParser.world_info[map_id]['step_count']
if curr_position == step_count - 1:
n += 1
kvd['chapter_complete_count', chapter_id] = n
x = self.c.fetchone()
if x[0] is not None:
score_sum += x[0]
self.c.execute(
'''select sum(curr_position) + count(*) from user_world where user_id = ?''', (self.user_id,)
)
x = self.c.fetchone()
if x is not None:
kvd['total_step_count'] = x[0] or 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 +790,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:
'''
@@ -754,6 +853,19 @@ class UserOnline(UserInfo):
self.c.execute('''update user set favorite_character = :a where user_id = :b''',
{'a': self.favorite_character.character_id, 'b': self.user_id})
def toggle_invasion(self) -> None:
self.c.execute(
'''select insight_state from user where user_id = ?''', (self.user_id,))
x = self.c.fetchone()
if not x:
raise NoData('No user.', 108, -3)
self.insight_state = x[0]
rq = Constant.INSIGHT_TOGGLE_STATES
self.insight_state = rq[(rq.index(self.insight_state) + 1) % len(rq)]
self.c.execute(
'''update user set insight_state = ? where user_id = ?''', (self.insight_state, self.user_id))
class UserChanger(UserInfo, UserRegister):

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

File diff suppressed because it is too large Load Diff

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', 'Forlorn(Ascendant - 6th Seeker)', 'Chinatsu', 'Tsumugi', 'Nai', 'Selene Sheryl (MIR-203)', 'Salt', 'Acid', 'Hikari & Selene Sheryl (Fracture & MIR-203)', 'Hikari & El Clear', 'Tairitsu & El Fail', 'Nami & Sui (Twilight)', 'Nonoka (Azure)', 'Vita (Wanderer)', 'Hikari & Tairitsu[ANS] (Breakthrough)', 'Hikari & Tairitsu[ANS] (Debut)', 'VIIM', 'Helena', 'Yuno', 'Shirahime (Flow)']
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_nell', 'skill_chinatsu', 'skill_tsumugi', 'skill_nai', 'skill_selene', 'skill_salt', 'skill_acid', 'skill_hikari_selene', 'skill_hikari_clear', 'skill_tairitsu_fail', 'skill_nami_sui', 'skill_nonoka', 'skill_vita_arc', '', 'skill_hikari_tairitsu_debut', 'skill_hp_slow_drain', 'skill_hprate_based_on_hp', 'skill_lost_to_85', 'skill_frag_doubled_after_earning_X']
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_maya_uncap', '', 'skill_luin_uncap', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', 'skill_nonoka_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, 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, 63, 37, 23, 59, 45, 20, 43, 50, 22, 37, 26, 47, 34, 45, 23, 47, 42, 70, 41]
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, 40, 50, 45, 41, 12, 31, 72, 40, 16, 33, 35, 23, 24, 46, 26, 49, 32, 35, 43, 45, 62, 77, 42, 44, 33]
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, 35, 34, 45, 41, 12, 19, 38, 40, 26, 39, 56, 20, 25, 46, 18, 71, 29, 25, 73, 45, 62, 47, 71, 44, 11]
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, 115, 70, 50, 61.6, 48, 37, 90, 60, 50, 92, 66, 44, 79, 50, 47, 55, 49, 79, 52, 90, 35, 68, 63, 103, 65]
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, 80, 80, 67, 41, 55, 50, 103, 66, 35, 52, 65, 50, 43, 84, 55, 73, 59, 60, 64, 67, 94, 110, 63, 65, 58]
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, 75, 54, 90, 41, 34, 30, 55, 66, 55, 62, 81, 44, 46, 84, 39, 105, 55, 43, 110, 50, 94, 68, 105, 65, 13]
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, 50, 115, 80, 50, 61.6, 48, 37, 90, 60, 50, 102, 76, 44, 89, 50, 47, 55, 49, 89, 52, 90, 35, 68, 63, 103, 65]
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, 57, 80, 90, 67, 41, 55, 50, 103, 66, 35, 62, 75, 50, 53, 84, 55, 73, 59, 70, 64, 67, 94, 110, 63, 65, 58]
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, 57, 75, 64, 90, 41, 34, 30, 55, 66, 55, 72, 91, 44, 56, 84, 39, 105, 55, 53, 110, 50, 94, 68, 105, 65, 13]
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, 4, 2, 0, 0, 0, 0, 4, 0, 0, 0, 2, 0, 2, 2, 0, 2, 0, 0, 0, 0, 0, 2, 0, 0, 0]
char_core = {
0: [{'core_id': 'core_hollow', 'amount': 25}, {'core_id': 'core_desolate', 'amount': 5}],
@@ -61,19 +61,28 @@ 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}],
81: [{'core_id': 'core_chunithm', 'amount': 15}],
82: [{'core_id': 'core_chunithm', 'amount': 15}],
84: [{'core_id': 'core_maimai', 'amount': 15}],
89: [{'core_id': 'core_hollow', 'amount': 5}, {'core_id': 'core_alluring', 'amount': 25}],
71: [],
}
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', 'core_maimai', 'core_alluring']
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', 'prayer', 'astralexe', 'trpno', 'blackmirror', 'tau', 'snowwhite3', 'altale3', 'energysynergymatrix3', 'anokumene3', 'nhelv3', 'wontbackdown', 'someday', 'disintegration', 'acheron', 'rostpagegene', 'awakening', 'mod', 'particle', 'paradoxpalette', 'xxium', 'tabootears', 'undefined', 'mask']
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_chap8", "scenery_beyond"]
course_banners = ['course_banner_' + str(i) for i in range(1, 12)]
# TODO: online_banners
role = ['system', 'admin', 'user', 'selecter']
role_caption = ['系统', '管理员', '用户', '查询接口']

View File

@@ -445,5 +445,50 @@
"core_generic_20",
"course_banner_11"
]
},
{
"course_id": "6.0-dan-12",
"style": 12,
"songs": [
{
"flag_as_hidden": true,
"difficulty": 2,
"id": "abstrusedilemma"
},
{
"flag_as_hidden": true,
"difficulty": 3,
"id": "worldender"
},
{
"flag_as_hidden": true,
"difficulty": 4,
"id": "alterego"
},
{
"flag_as_hidden": true,
"difficulty": 3,
"id": "designant"
}
],
"gauge_requirement": "default",
"requirements": [
{
"value": "4.0-dan-11",
"type": "course"
}
],
"flag_as_hidden_when_requirements_not_met": true,
"dan_name": "Phase 12",
"course_name": "侵蚀万物的色彩",
"can_start": true,
"is_completed": false,
"high_score": 0,
"best_clear_type": -1,
"rewards": [
"fragment2000",
"core_generic_20",
"course_banner_12"
]
}
]

View File

@@ -790,5 +790,311 @@
],
"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
},
{
"name": "ongeki_append_2",
"items": [
{
"type": "pack",
"id": "ongeki_append_2",
"is_available": true
},
{
"type": "core",
"amount": 4,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 400,
"price": 400
},
{
"name": "lephon",
"items": [
{
"type": "pack",
"id": "lephon",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 500,
"price": 500
},
{
"name": "chunithm_append_3",
"items": [
{
"type": "pack",
"id": "chunithm_append_3",
"is_available": true
},
{
"type": "core",
"amount": 4,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 400,
"price": 400
},
{
"name": "maimai_append_2",
"items": [
{
"type": "pack",
"id": "maimai_append_2",
"is_available": true
},
{
"type": "core",
"amount": 4,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 400,
"price": 400
},
{
"name": "undertale",
"items": [
{
"type": "pack",
"id": "undertale",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 500,
"price": 500
},
{
"name": "djmax",
"items": [
{
"type": "pack",
"id": "djmax",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 500,
"price": 500
},
{
"name": "djmax_append_1",
"items": [
{
"type": "pack",
"id": "djmax_append_1",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 500,
"price": 500
},
{
"name": "anima",
"items": [
{
"id": "anima",
"type": "pack",
"is_available": true
},
{
"id": "core_generic",
"type": "core",
"amount": 5,
"is_available": true
}
],
"orig_price": 500,
"price": 500
},
{
"name": "anima_append_1",
"items": [
{
"id": "anima_append_1",
"type": "pack",
"is_available": true
},
{
"id": "core_generic",
"type": "core",
"amount": 5,
"is_available": true
}
],
"orig_price": 500,
"price": 500
},
{
"name": "eclipse",
"items": [
{
"id": "eclipse",
"type": "pack",
"is_available": true
},
{
"id": "core_generic",
"type": "core",
"amount": 5,
"is_available": true
}
],
"orig_price": 500,
"price": 500
},
{
"name": "extend_4",
"items": [
{
"id": "extend_4",
"type": "pack",
"is_available": true
},
{
"id": "core_generic",
"type": "core",
"amount": 7,
"is_available": true
}
],
"orig_price": 700,
"price": 700
},
{
"name": "nextstage",
"items": [
{
"id": "nextstage",
"type": "pack",
"is_available": true
},
{
"id": "core_generic",
"type": "core",
"amount": 5,
"is_available": true
}
],
"orig_price": 500,
"price": 500
},
{
"name": "megarex",
"items": [
{
"id": "megarex",
"type": "pack",
"is_available": true
},
{
"id": "core_generic",
"type": "core",
"amount": 5,
"is_available": true
}
],
"orig_price": 500,
"price": 500
}
]

View File

@@ -1558,5 +1558,689 @@
],
"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
},
{
"name": "thirdsun",
"items": [
{
"type": "single",
"id": "thirdsun",
"is_available": true
},
{
"type": "core",
"amount": 1,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "crimsonquartz",
"items": [
{
"type": "single",
"id": "crimsonquartz",
"is_available": true
},
{
"type": "core",
"amount": 1,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "diesirae",
"items": [
{
"type": "single",
"id": "diesirae",
"is_available": true
},
{
"type": "core",
"amount": 1,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "spiritdauntless",
"items": [
{
"type": "single",
"id": "spiritdauntless",
"is_available": true
},
{
"type": "core",
"amount": 1,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "placebobattler",
"items": [
{
"type": "single",
"id": "placebobattler",
"is_available": true
},
{
"type": "core",
"amount": 1,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "megalovaniarmx",
"items": [
{
"type": "single",
"id": "megalovaniarmx",
"is_available": true
},
{
"type": "core",
"amount": 1,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "shinchoku",
"items": [
{
"type": "single",
"id": "shinchoku",
"is_available": true
},
{
"type": "core",
"amount": 1,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "dullblade",
"items": [
{
"type": "single",
"id": "dullblade",
"is_available": true
},
{
"type": "core",
"amount": 1,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "tictactoe",
"items": [
{
"type": "single",
"id": "tictactoe",
"is_available": true
},
{
"type": "core",
"amount": 1,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "dontdie",
"items": [
{
"type": "single",
"id": "dontdie",
"is_available": true
},
{
"type": "core",
"amount": 1,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "miles",
"items": [
{
"type": "single",
"id": "miles",
"is_available": true
},
{
"type": "core",
"amount": 1,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "eternitybreak",
"items": [
{
"id": "eternitybreak",
"type": "single",
"is_available": true
},
{
"id": "core_generic",
"type": "core",
"amount": 1,
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "xterfusion",
"items": [
{
"id": "xterfusion",
"type": "single",
"is_available": true
},
{
"id": "core_generic",
"type": "core",
"amount": 1,
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "angelsboundary",
"items": [
{
"id": "angelsboundary",
"type": "single",
"is_available": true
},
{
"id": "core_generic",
"type": "core",
"amount": 1,
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "grandspell",
"items": [
{
"id": "grandspell",
"type": "single",
"is_available": true
},
{
"id": "core_generic",
"type": "core",
"amount": 1,
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "paranoidarc",
"items": [
{
"id": "paranoidarc",
"type": "single",
"is_available": true
},
{
"id": "core_generic",
"type": "core",
"amount": 1,
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "subsphere",
"items": [
{
"id": "subsphere",
"type": "single",
"is_available": true
},
{
"id": "core_generic",
"type": "core",
"amount": 1,
"is_available": true
}
],
"orig_price": 100,
"price": 100
},
{
"name": "theraft",
"items": [
{
"id": "theraft",
"type": "single",
"is_available": true
},
{
"id": "core_generic",
"type": "core",
"amount": 1,
"is_available": true
}
],
"orig_price": 100,
"price": 100
}
]

View File

@@ -34,7 +34,12 @@ 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,
insight_state default 4, -- 这里写 4 是为了避免不能切换状态和无故被侵入
custom_banner text,
is_allow_marketing_email int default 0
);
create table if not exists login(access_token text,
user_id int,
@@ -60,7 +65,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 +112,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,
@@ -187,7 +148,9 @@ stamina_multiply int,
fragment_multiply int,
prog_boost_multiply int,
beyond_boost_gauge_usage int,
skill_cytusii_flag text
skill_cytusii_flag text,
skill_chinatsu_flag text,
invasion_flag int
);
create table if not exists item(item_id text,
type text,
@@ -240,10 +203,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 +274,23 @@ 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)
);
-- value 无类型
create table if not exists user_kvdata(
user_id int,
class text,
key text,
idx int,
value,
primary key(user_id, class, key, idx)
);
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
LINK_PLAY_UNLOCK_LENGTH = 1024
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,36 +118,38 @@ 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())
def command_07(self):
self.s.random_code = self.command[16:24]
self.room.players[self.player_index].song_unlock = self.command[24:536]
self.room.players[self.player_index].song_unlock = self.command[24:24 +
Config.LINK_PLAY_UNLOCK_LENGTH]
self.room.update_song_unlock()
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 +159,171 @@ 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')
for p in self.room.players:
# 防止提前结算
p.finish_flag = 0
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 +331,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 +342,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'\x0E'
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

@@ -7,7 +7,8 @@ from core.config_manager import Config, ConfigManager
if os.path.exists('config.py') or os.path.exists('config'):
# 导入用户自定义配置
ConfigManager.load(import_module('config').Config)
ConfigManager.load(import_module("config").Config)
# TODO: More config file formats
if Config.DEPLOY_MODE == 'gevent':
# 异步
@@ -17,7 +18,7 @@ if Config.DEPLOY_MODE == 'gevent':
import sys
from logging.config import dictConfig
from multiprocessing import Process, set_start_method
from multiprocessing import Process, current_process, set_start_method
from traceback import format_exc
from flask import Flask, make_response, request, send_from_directory
@@ -27,6 +28,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 +56,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 +83,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 +102,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 +147,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)
@@ -148,7 +171,7 @@ def generate_log_file_dict(level: str, filename: str) -> dict:
}
def main():
def pre_main():
log_dict = {
'version': 1,
'root': {
@@ -161,7 +184,7 @@ def main():
'stream': 'ext://flask.logging.wsgi_errors_stream',
'formatter': 'default'
},
"error_file": generate_log_file_dict('ERROR', './log/error.log')
"error_file": generate_log_file_dict('ERROR', f'{Config.LOG_FOLDER_PATH}/error.log')
},
'formatters': {
'default': {
@@ -172,11 +195,11 @@ def main():
if Config.ALLOW_INFO_LOG:
log_dict['root']['handlers'].append('info_file')
log_dict['handlers']['info_file'] = generate_log_file_dict(
'INFO', './log/info.log')
'INFO', f'{Config.LOG_FOLDER_PATH}/info.log')
if Config.ALLOW_WARNING_LOG:
log_dict['root']['handlers'].append('warning_file')
log_dict['handlers']['warning_file'] = generate_log_file_dict(
'WARNING', './log/warning.log')
'WARNING', f'{Config.LOG_FOLDER_PATH}/warning.log')
dictConfig(log_dict)
@@ -186,6 +209,8 @@ def main():
input('Press ENTER key to exit.')
sys.exit()
def main():
if Config.LINKPLAY_HOST and Config.SET_LINKPLAY_SERVER_AS_SUB_PROCESS:
from linkplay_server import link_play
process = [Process(target=link_play, args=(
@@ -201,6 +226,11 @@ def main():
tcp_server_run()
# must run for init
# this ensures avoiding duplicate init logs for some reason
if current_process().name == 'MainProcess':
pre_main()
if __name__ == '__main__':
set_start_method("spawn")
main()

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.bp2, 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

@@ -36,12 +36,6 @@ def login():
return jsonify({"success": True, "token_type": "Bearer", 'user_id': user.user_id, 'access_token': user.token})
@bp.route('/verify', methods=['POST']) # 邮箱验证进度查询
@arc_try
def email_verify():
raise ArcError('Email verification unavailable.', 151, status=404)
def auth_required(req):
# arcaea登录验证写成了修饰器
def decorator(view):

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,12 @@ 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.character import UserCharacter
from core.download import DownloadList
from core.error import RateLimit
from core.error import ArcError, 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 +17,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 +30,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 +77,98 @@ 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('/insight/me/complete/<string:pack_id>', methods=['POST'])
@auth_required(request)
@arc_try
def insight_complete(user_id, pack_id):
# insight state change
with Connect() as c:
u = UserOnline(c, user_id)
if pack_id == 'eden_append_1':
item = ItemCharacter(c)
item.set_id('72') # Insight (Ascendant - 8th Seeker)
item.user_claim_item(u)
u.update_user_one_column('insight_state', 1)
elif pack_id == 'lephon':
u.update_user_one_column('insight_state', 3)
else:
raise ArcError('Invalid pack_id', 151, status=404)
return success_return({
'insight_state': u.insight_state
})
@bp.route('/unlock/me/awaken_maya', methods=['POST'])
@auth_required(request)
@arc_try
def awaken_maya(user_id):
with Connect() as c:
ch = UserCharacter(c, 71, UserOnline(c, user_id))
ch.select_character_info()
try:
ch.character_uncap()
# ensure no error raised
except ArcError:
pass
return success_return({
'user_id': user_id,
'updated_characters': [ch.to_dict()]
})
@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 +193,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

@@ -29,11 +29,12 @@ def score_token_world(user_id):
d = request.args.get
stamina_multiply = int(d('stamina_multiply', 1))
fragment_multiply = int(d('fragment_multiply', 100))
prog_boost_multiply = int(d('prog_boost_multiply', 0))
beyond_boost_gauge_use = int(d('beyond_boost_gauge_use', 0))
stamina_multiply = d('stamina_multiply', 1, type=int)
fragment_multiply = d('fragment_multiply', 100, type=int)
prog_boost_multiply = d('prog_boost_multiply', 0, type=int)
beyond_boost_gauge_use = d('beyond_boost_gauge_use', 0, type=int)
skill_cytusii_flag = None
skill_chinatsu_flag = None
skill_id = d('skill_id')
if (skill_id == 'skill_ilith_ivy' or skill_id == 'skill_hikari_vanessa') and d('is_skill_sealed') == 'false':
@@ -41,23 +42,34 @@ def score_token_world(user_id):
# TODO: 需要重构整个 user_play世界模式 / 课题模式,所以现在临时 work 一下
skill_cytusii_flag = ''.join([str(randint(0, 2)) for _ in range(5)])
if skill_id == 'skill_chinatsu' and d('is_skill_sealed') == 'false':
skill_chinatsu_flag = ''.join([str(randint(0, 2)) for _ in range(7)])
skill_flag = skill_cytusii_flag or skill_chinatsu_flag
with Connect() as c:
x = UserPlay(c, UserOnline(c, user_id))
x.song.set_chart(request.args['song_id'], int(
request.args['difficulty']))
x.song.set_chart(d('song_id'), d('difficulty', type=int))
x.set_play_state_for_world(
stamina_multiply, fragment_multiply, prog_boost_multiply, beyond_boost_gauge_use, skill_cytusii_flag)
stamina_multiply, fragment_multiply, prog_boost_multiply, beyond_boost_gauge_use, skill_cytusii_flag, skill_chinatsu_flag)
r = {
"stamina": x.user.stamina.stamina,
"max_stamina_ts": x.user.stamina.max_stamina_ts,
"token": x.song_token,
'play_parameters': {},
}
if skill_cytusii_flag and skill_id:
if skill_flag and skill_id:
r['play_parameters'] = {
skill_id: list(
map(lambda x: Constant.WORLD_VALUE_NAME_ENUM[int(x)], skill_cytusii_flag))
map(lambda x: Constant.WORLD_VALUE_NAME_ENUM[int(x)], skill_flag)),
}
if x.invasion_flag == 1:
r['play_parameters']['invasion_start'] = True
elif x.invasion_flag == 2:
r['play_parameters']['invasion_hard'] = True
return success_return(r)
@@ -105,18 +117,27 @@ def score_token_course(user_id):
def song_score_post(user_id):
with Connect() as c:
x = UserPlay(c, UserOnline(c, user_id))
x.song_token = request.form['song_token']
x.song_hash = request.form['song_hash']
x.song.set_chart(
request.form['song_id'], request.form['difficulty'])
x.set_score(request.form['score'], request.form['shiny_perfect_count'], request.form['perfect_count'], request.form['near_count'],
request.form['miss_count'], request.form['health'], request.form['modifier'], int(time() * 1000), request.form['clear_type'])
x.beyond_gauge = int(request.form['beyond_gauge'])
x.submission_hash = request.form['submission_hash']
if 'combo_interval_bonus' in request.form:
x.combo_interval_bonus = int(request.form['combo_interval_bonus'])
x.highest_health = request.form.get("highest_health", type=int)
x.lowest_health = request.form.get("lowest_health", type=int)
f = request.form
x.song_token = f['song_token']
x.song_hash = f['song_hash']
x.song.set_chart(f['song_id'], f['difficulty'])
x.set_score(f['score'], f['shiny_perfect_count'], f['perfect_count'], f['near_count'],
f['miss_count'], f['health'], f['modifier'], int(time() * 1000), f['clear_type'])
x.beyond_gauge = int(f['beyond_gauge'])
x.submission_hash = f['submission_hash']
x.combo_interval_bonus = f.get('combo_interval_bonus', type=int)
x.hp_interval_bonus = f.get('hp_interval_bonus', type=int)
# visible_map_count
x.fever_bonus = f.get('fever_bonus', type=int)
x.rank_bonus = f.get('rank_bonus', type=int)
x.maya_gauge = f.get('maya_gauge', type=int)
x.nextstage_bonus = f.get('nextstage_bonus', type=int)
x.highest_health = f.get("highest_health", type=int)
x.lowest_health = f.get("lowest_health", type=int)
x.room_code = f.get('room_code')
x.room_total_score = f.get('room_total_score', type=int)
x.room_total_players = f.get('room_total_players', type=int)
if not x.is_valid:
raise InputError('Invalid score.', 107)
x.upload_score()

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
@@ -12,8 +14,11 @@ from .func import arc_try, header_check, success_return
bp = Blueprint('user', __name__, url_prefix='/user')
bp2 = Blueprint('account', __name__, url_prefix='/account')
@bp.route('', methods=['POST']) # 注册接口
@bp2.route('', methods=['POST'])
@arc_try
def register():
error = header_check(request)
@@ -29,13 +34,15 @@ def register():
device_id = request.form['device_id']
else:
device_id = 'low_version'
if 'is_allow_marketing_email' in request.form:
new_user.is_allow_marketing_email = request.form['is_allow_marketing_email'] == 'true'
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})
@@ -48,6 +55,16 @@ def user_me(user_id):
return success_return(UserOnline(c, user_id).to_dict())
@bp.route('/me/toggle_invasion', methods=['POST']) # insight skill
@auth_required(request)
@arc_try
def toggle_invasion(user_id):
with Connect() as c:
user = UserOnline(c, user_id)
user.toggle_invasion()
return success_return({'user_id': user.user_id, 'insight_state': user.insight_state})
@bp.route('/me/character', methods=['POST']) # 角色切换
@auth_required(request)
@arc_try
@@ -155,19 +172,31 @@ 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', 'is_allow_marketing_email'):
user.update_user_one_column(set_arg, value)
return success_return(user.to_dict())
@bp.route('/me/request_delete', methods=['POST']) # 删除账号
@bp2.route('/me/request_delete', methods=['POST'])
@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']) # 邮箱验证重发
@bp2.route('/email/resend_verify', methods=['POST'])
@arc_try
def email_resend_verify():
raise ArcError('Email verification unavailable.', 151, status=404)
@bp.route('/verify', methods=['POST']) # 邮箱验证状态查询
@bp2.route('/verify', methods=['POST'])
@arc_try
def email_verify():
raise ArcError('Email verification unavailable.', 151, status=404)

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

@@ -16,6 +16,7 @@
<option value="1">Present</option>
<option value="2">Future</option>
<option value="3">Beyond</option>
<option value="4">Eternal</option>
</select>
<br />

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

@@ -15,6 +15,7 @@
<option value="1">Present</option>
<option value="2">Future</option>
<option value="3">Beyond</option>
<option value="4">Eternal</option>
</select>
<br />
<input type="submit" value="Find">
@@ -33,6 +34,8 @@
<span class="difficulty_prs">PRS</span>
{% elif difficulty == 2 %}
<span class="difficulty_ftr">FTR</span>
{% elif difficulty == 4 %}
<span class="difficulty_etr">ETR</span>
{% else %}
<span class="difficulty_byd">BYD</span>

View File

@@ -25,6 +25,8 @@
<span class="difficulty_prs">PRS</span>
{% elif post['difficulty'] == 2 %}
<span class="difficulty_ftr">FTR</span>
{% elif post['difficulty'] == 4 %}
<span class="difficulty_etr">ETR</span>
{% else %}
<span class="difficulty_byd">BYD</span>
{% endif %}

View File

@@ -44,6 +44,8 @@
<span class="difficulty_prs">PRS</span>
{% elif user['difficulty'] == 2 %}
<span class="difficulty_ftr">FTR</span>
{% elif user['difficulty'] == 4 %}
<span class="difficulty_etr">ETR</span>
{% else %}
<span class="difficulty_byd">BYD</span>
{% endif %}
@@ -176,6 +178,8 @@
<span class="difficulty_prs">PRS</span>
{% elif i['difficulty'] == 2 %}
<span class="difficulty_ftr">FTR</span>
{% elif i['difficulty'] == 4 %}
<span class="difficulty_etr">ETR</span>
{% else %}
<span class="difficulty_byd">BYD</span>
{% endif %}

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', 'skill_nell', 'skill_chinatsu', 'skill_tsumugi', 'skill_nai', 'skill_selene', 'skill_salt', 'skill_acid', 'skill_hikari_selene', 'skill_hikari_clear', 'skill_tairitsu_fail', 'skill_nami_sui', 'skill_nonoka', 'skill_nonoka_uncap', 'skill_vita_arc', 'skill_maya_uncap', 'skill_hikari_tairitsu_debut', 'skill_hp_slow_drain', 'skill_lost_to_85', 'skill_hprate_based_on_hp', 'skill_frag_doubled_after_earning_X']
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.')