179 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
Lost-MSth
b6663ac0dc Merge pull request #140 from Lost-MSth/dev
Update to v2.11.3
2023-12-03 17:28:07 +08:00
Lost-MSth
de1d46f96e Update to v2.11.3 2023-12-03 17:23:15 +08:00
Lost-MSth
f3c17cdde7 [Enhance] Link Play Rooms info & API for that
- Add an HTTP API endpoint for getting the information of rooms and players in Link Play
2023-12-03 16:42:53 +08:00
Lost-MSth
150686d9f8 [Refactor] Link Play TCP data transmission
- Code refactor of Link Play TCP data transmission for better security and scalability
2023-12-03 00:38:43 +08:00
Lost-MSth
3e93082a3c Add character Luin 2023-12-01 14:14:17 +08:00
Lost-MSth
04010d9c33 [Enhance] Link Play log & video download
- Add support for downloading `video_720.mp4` & `video_1080.mp4`
- More detailed log for Link Play #135
2023-10-23 22:21:49 +08:00
Lost-MSth
1f389e4073 [Enhance] Save unlock? & songlist parser?
- Support more things in full cloud save unlocking and songlist parser
- For Arcaea 5.0.1

> NOTE: May not work well because this client version is not completed and confusing!!
2023-09-28 21:48:54 +08:00
Lost-MSth
5788972692 [Bug fix]? A small encoding extension
- Use bytes instead of string to pass the data to the `json` module
- For Arcaea 4.7.2
2023-09-14 22:01:15 +08:00
Lost-MSth
f373c7b052 [Enhance] Restrict download by user's purchase
- 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)

#128
2023-09-08 11:38:46 +08:00
Lost-MSth
04c6d1a0e0 [Enhance][Bug fix] Support skill_maya & World mode value display bug
- Add support for the skill 'skill_maya'
- Fix a bug that some characters' skill cannot display proper values in world mode progression
- Fix a bug that 'skill_mithra' results in adding `prog` value instead of world mode progress
- For Arcaea 4.7.0
2023-08-18 16:45:27 +08:00
Lost-MSth
41409dee27 [Enhance] Support 'skill_hikari_vanessa'
- Add support for the skill 'skill_hikari_vanessa'
- For Arcaea 4.6.0
2023-07-19 23:27:21 +08:00
Lost-MSth
4527339d78 [Enhance] Support 'skill_ilith_ivy'
- Add support for the skill 'skill_ilith_ivy'
- For Arcaea 4.5.0
2023-06-28 22:34:43 +08:00
Lost-MSth
5a4ff11578 Merge pull request #114 from Lost-MSth/dev
Dev
2023-05-25 22:44:22 +08:00
Lost-MSth
18e79a7799 Update to v2.11.2
- Unlock the character **Nami (Twilight)**
- Update README.md
2023-05-25 22:42:03 +08:00
Lost-MSth
e548d5639f [Enhance] API for getting PTT record
- Add an API endpoint for getting the user's rating records.
- more detailed INFO messages for  login and register
- Add `finale/finale_end` endpoint #110
2023-05-23 21:25:27 +08:00
Lost-MSth
bd74d96250 [Enhance] PTT record & log DB cleaner
- Add support for recording users' potential each day
- Add a log database cleaner tool
- A small change: `/user/<user_id> PUT` API can ban user now.
2023-05-03 00:37:41 +08:00
Lost-MSth
880b66a995 [Enhance] Add background task
- Add background task module for log database
- Update some data for Arcaea v4.4.4
2023-04-28 14:27:11 +08:00
Lost-MSth
8efd11cd02 [Bug fix] Character 5 value error
- Fix a bug that the character **Hikari & Tairitsu (Reunion)** cannot be uncapped. #100
2023-04-01 17:11:45 +08:00
Lost-MSth
1c58aeb526 Merge pull request #99 from Lost-MSth/dev
Update to v2.11.1
2023-03-24 19:11:32 +08:00
Lost-MSth
6f82e9b269 Update to v2.11.1 2023-03-24 19:09:47 +08:00
Lost-MSth
f92d8a9642 [Enhance] Skill of Mithra
- Add support for the skill of Mithra
2023-03-23 23:17:02 +08:00
Lost-MSth
1672d337ff [Enhance] [Refactor] Add API about characters
- Add API endpoints about characters
- Some small changes in refactoring
2023-03-22 22:27:21 +08:00
Lost-MSth
70d27be7c7 Merge pull request #94 from Lost-MSth/dev
Update to v2.11.0
2023-03-05 22:58:19 +08:00
Lost-MSth
8d856696ca Update to v2.11.0
- Change characters' value
2023-03-05 22:56:55 +08:00
Lost-MSth
ed8d0aa73c [Enhance] BYD boost & BYD chain maps
- Add support for beyond gauge boost
- Add support for beyond chain maps
- Add support skills of uncapped ilith and mika
- Unlock four chars and uncapped ilith
- Some changes in some values' algorithms
2023-03-02 23:25:38 +08:00
Lost-MSth
a84ec560dd [Enhance] API for redeem
- Add API endpoints for redeem system
- Continue to fix the bug mentioned in 930faf508d
2023-02-28 18:28:09 +08:00
Lost-MSth
930faf508d [Refactor] Refactor for link play
- Refactor simply for link play subprogram
- Fix a logic bug that the room without anyone can be entered
2023-02-27 23:41:32 +08:00
Lost-MSth
7ece2598d1 [Enhance] API for presents & character values
- Add API endpoints for presents
- Change character value algorithm
- Update character values (I forgot in v2.10.3)
2023-02-10 18:15:53 +08:00
Lost-MSth
6f39274b99 [Enhance] API for purchases, items, operations
- Add API endpoints for purchases, items, and operations
- Header checker? :)
2023-02-08 18:18:04 +08:00
Lost-MSth
e21cf89b1b Merge pull request #90 from Lost-MSth/dev
Dev
2023-01-26 21:19:32 +08:00
Lost-MSth
fbd5d83626 Update to v2.10.3 [Enhance] map local restrict
- Add support for locally restricting songs or challenges in the map of world mode
2023-01-26 21:15:09 +08:00
Lost-MSth
9fbdcd5edb [Refactor][Enhance] unlock items & Steps' difficulty restrict
- Refactor some codes about unlocking or locking some users' packs and singles
- Add support for restricting songs' difficulty in the map's steps of world mode
2023-01-24 16:45:09 +08:00
Lost-MSth
88d949fc18 [Bug fix] block headers without app version
- Fix a bug that headers without `AppVersion` are allowed in client version checking.
2023-01-22 22:09:15 +08:00
Lost-MSth
9636722709 [Enhance] Add API about songs
- Add some API endpoints, including creating, changing, deleting song info.
2023-01-22 18:39:15 +08:00
Lost-MSth
9c90d6ef89 [Enhance] Add an option about file hash pre-calc
- Add an option to disable song file hash pre-calculation
2023-01-14 18:32:53 +08:00
Lost-MSth
af03a48134 [Enhance] Cloud save update scores & capture auth error
- Restore the feature that cloud save can be used to cover best scores
- Capture error that the request does not have `Authorization` in header
2023-01-13 17:50:01 +08:00
Lost-MSth
1fde2801ad Merge pull request #84 from Lost-MSth/dev
Update to v2.10.2
2022-12-22 16:06:02 +08:00
Lost-MSth
f250701eca Update to v2.10.2
close #77 #83 #78 #64 #80 #81 #83
2022-12-22 16:02:44 +08:00
Lost-MSth
627985d1af [Doc] Game download link & some small changes 2022-12-19 20:38:37 +08:00
Lost-MSth
7324c9bd76 [Enhance] API song ranklist
- Add an API endpoint for getting the rank list of a song's chart #81
- Make the `run.bat` script pause when meeting an error #82
2022-12-19 20:20:31 +08:00
Lost-MSth
426f65ea9e [Enhance][Refactor] unranked score & warning log
- Add an option that can be used to forbid some illegal scores
- Add brief warning logs for custom exceptions
2022-11-28 21:58:06 +08:00
Lost-MSth
a7a9a4ba3d [Enhance][Bug fix] Add log db & log bug
- Add a log database to record all playing scores
- Fix a bug that if an exception is raised before flask app runs, logger will not work well.
2022-11-26 21:03:29 +08:00
Lost-MSth
84b0e869a5 [Refactor] Init files Encoding & Refresh rating
- Now initial files can be other encoding types which are supported by JSON module
- Code refactor for refreshing all scores' rating
2022-11-24 21:40:44 +08:00
Lost-MSth
bd9eb28832 Merge pull request #75 from Lost-MSth/dev
Update to v2.10.1
2022-11-10 18:33:50 +08:00
Lost-MSth
e3d5c19569 Update to v2.10.1 2022-11-10 18:32:53 +08:00
Lost-MSth
e1ce4d9ec4 [Enhance][Bug fix] download songlist & new skill & API null recent
- Add `amane` and add support for `skill_amane`
- Add a parser for `songlist` to specify downloadable files
- Fix a bug that users with no recent scores cannot get `recent30` via API
2022-11-10 18:00:27 +08:00
Lost-MSth
b70bfd6081 [Enhance] API user change
- Add a method of API to change the user's info
2022-11-07 22:52:42 +08:00
Lost-MSth
7cc17181e1 [Bug fix][Refactor] Course scores & Download
- Fix a bug that scores in course mode cannot calculate rating
- Make some optimization for downloading songs
2022-11-07 21:14:05 +08:00
Lost-MSth
e22485e4e0 [Bug fix] Course mode cannot start
- Fix a bug due to 4da3f38f6e that course mode cannot start
2022-11-06 15:06:01 +08:00
Lost-MSth
65aed56c3e Merge pull request #73 from Lost-MSth/dev
Dev
2022-11-05 21:33:57 +08:00
Lost-MSth
e6cb0f8981 Update README.md 2022-11-05 21:31:31 +08:00
Lost-MSth
4a5083c597 [Bug Fix][Enhance] Endless loop & waitress log
- Fix a bug due to 6fcca17918 that there is a endless loop in calculating recent 10 updating

- Add info log for `waitress`
2022-11-05 21:17:44 +08:00
Lost-MSth
78d28f8886 [Enhance][Bug fix] Waitress & API token
- Fix a bug that using API token in config file will not success
- Add deployment mode `waitress`
2022-11-03 13:42:39 +08:00
Lost-MSth
3fde61c639 [Bug fix] Hot fix for a funny thing
- Fix a bug that the order of the users on the score ranks is chaotic
2022-11-01 18:46:36 +08:00
Lost-MSth
cf350842ac [Refactor] Performance optimization
- Put download token in memory database
- Accelerate multiple querying in `best_score` table
2022-11-01 18:08:02 +08:00
Lost-MSth
e04c8ecf2d [Bug fix] Hot fix for typing error & import error 2022-10-31 19:29:31 +08:00
Lost-MSth
4da3f38f6e [Enhance] Performance optimization
- Change journal mode to WAL and enlarge cache size for sqlite3 database
- Use monkey patch to do asynchrony
- Make gevent use Flask's logger
2022-10-31 17:49:19 +08:00
Lost-MSth
40630fff4d [Refactor] Database initialization and migration
- Code refactoring for database initialization and migration
2022-10-21 18:19:37 +08:00
Lost-MSth
ca03360f0c [Enhance] API query param
- Add support for the `query` param in `GET` requests
2022-10-19 20:08:41 +08:00
Lost-MSth
d150553e6b [Enhance] Some config options & Login limiter
- Add limiter for login and API login
- Add some config options

- Delete `setting.py` files
2022-10-16 17:07:32 +08:00
Lost-MSth
ba36190f30 [Enhance][Bug fix] Improve setting file & ...
- Fix a small bug that `best30` of API cannot have scores whose songs are not in database
- At present the setting file can be a module or a file with some of options
- Limiter can have multiple rules together now
2022-10-16 15:49:49 +08:00
Lost-MSth
68a83a29d2 [Enhance] Add limiter & Building API
- Add a custom limiter and use it for limiting users' download instead of using database
> So add a requirement `limits`.

- Fix a character's value
- Change the email max length to 64
- Change something about API's roles and powers
- Add an API endpoint for getting users' roles and powers
2022-10-12 15:27:45 +08:00
Lost-MSth
2a3ee0f0c7 Merge pull request #69 from Lost-MSth/dev
Update to v2.10.0
2022-10-04 18:55:55 +08:00
Lost-MSth
a04df8bba6 Update README.md 2022-10-04 18:53:40 +08:00
Lost-MSth
fb110fd01c Update to v2.10.0 2022-10-04 18:51:49 +08:00
Lost-MSth
7a614eaa57 [Enhance] Add download error status code 2022-10-04 00:28:19 +08:00
Lost-MSth
8f1c28eb5c [Enhance] API & LinkPlay & something else
- Add song names for getting the score list (For API)
- Try to ensure thread safety in Link Play (twice again)
- Small change about database timeout
- Have a try to use `nginx` and `gevent` (Yes. I ensure it works well. This will not be viewed as a formal and necessary update.)
2022-10-03 23:46:52 +08:00
Lost-MSth
299c8c582c [Bug Fix][Enhance] About Link Play
- Fix a bug that the room host will be changed late when finishing a song
- Add logging module for linkplay server
2022-09-19 23:13:51 +08:00
Lost-MSth
6298b37ea2 Rearrange Link Play module
- Try to ensure thread safety by using TCP socket
2022-09-19 01:46:01 +08:00
Lost-MSth
b1575af0db Update the tool update_song.py 2022-09-10 20:34:21 +08:00
Lost-MSth
7bf43c3e07 Database performance optimization for large quantities
- Add two indices for avoiding possible slow queries

> This is an experimental handle for the database which has some tables over one hundred thousand lines.
2022-08-29 22:41:31 +08:00
Lost-MSth
12cd187563 Fix my rank
- ditto
2022-08-25 14:57:31 +08:00
Lost-MSth
574d2c9d9f Fix my rank
- Fix a bug that `my rank` doesn't work correctly (There may still be the problem)
2022-08-25 13:40:16 +08:00
Lost-MSth
732947a3f0 Fix some bugs
- Second try to fix a bug about API
- Fix a bug that the `prog boost` shows the incorrect value
- Fix a bug that the rewards can be get repeatedly in World Mode
- Fix a bug that second tube of blood of the world boss won't change
2022-08-11 22:38:26 +08:00
Lost-MSth
27ee147989 Fix two small bugs
- Fix a bug that the level of the character can exceed the max level
- Fix a bug that the response is incorrect when upgrading the characters by `generic core`
2022-08-03 22:00:42 +08:00
Lost-MSth
b38dbf1a55 Fix two small bugs
- Fix a bug that something of the finale challenge cannot be unlocked
- Fix a bug that the users' items will not be zero, which will disturb some purchase operations
2022-08-03 15:44:46 +08:00
Lost-MSth
d852be8029 Fix a bug about course system
- Fix a bug that the hidden courses cannot appear even if their requirements are satisfied
2022-07-30 17:55:01 +08:00
Lost-MSth
4f52df35ce Fix a small bug
- Fix a bug that the clients under v4.0.0 cannot upload the cloud save
2022-07-29 21:31:38 +08:00
Lost-MSth
cbd2198e43 Update issue templates 2022-07-29 21:22:47 +08:00
Lost-MSth
cdf76fb93a Just add something for unlocking songs
#62
2022-07-23 19:03:22 +08:00
Lost-MSth
5fba93524c Add a new feature
- Add support for specifying the songs of which only the `beyond` chart should be served #60
2022-07-22 17:20:13 +08:00
Lost-MSth
93f4ad4999 Fix some bugs
- Fix a bug that `GET` requests without data will report an error in API
- Fix a bug that `aggregate` requests will get an error when the inner function raises an error
- Fix a bug that the charts of a course cannot be the same because of the incorrect primary keys
- Fix a bug that global ranking scores cannot be calculated if there are no chart in the database #61
- The first try to fix #60 (I don't think it can be done so easily.)
2022-07-20 21:59:26 +08:00
Lost-MSth
47f05cdf1e Fix a bug and add a new thing
- Add support for logging Arcaea's errors
- Fix a bug when world maps' data don't have some unnecessary parts the client of iOS may break down
2022-07-16 19:50:07 +08:00
Lost-MSth
dbfed37dda Merge pull request #55 from Lost-MSth/dev
Update to v2.9.1
2022-07-13 23:06:33 +08:00
Lost-MSth
6801af197f Update to v2.9.1
- Fix a bug that the characters have wrong values
2022-07-13 23:05:28 +08:00
Lost-MSth
210ba99606 Fix an immaterial bug
- Delete a redundant import
2022-07-10 21:52:52 +08:00
Lost-MSth
0705c35be8 Merge pull request #54 from Lost-MSth/master
Update readme.md
2022-07-10 21:48:33 +08:00
Lost-MSth
c9bcbb8ec4 Update README.md 2022-07-10 21:47:43 +08:00
Lost-MSth
ac0071bfd6 Merge pull request #53 from Lost-MSth/dev
Fix a big bug
2022-07-09 23:06:06 +08:00
Lost-MSth
ef8e551261 Fix a big bug
- Fix a bug that the server gets all items as one user's item
2022-07-09 23:05:33 +08:00
Lost-MSth
55ab6c43cb Merge pull request #52 from Lost-MSth/dev
Update to v2.9
2022-07-09 22:03:47 +08:00
Lost-MSth
15134173c9 Ready to Update to v2.9
- Add support for finale state data
2022-07-09 22:02:46 +08:00
Lost-MSth
9f41ef0a70 'Small' update
- Add Course Mode
- Add support for downloading additional files
- Add support for `skill_fatalis` (Hikari booms your world.)

- Delete my hair X<

> WHERE IS MY TAIRITSU ?!
> Some other things are not supported now, such as... the feature which is one line above.
2022-07-09 01:15:03 +08:00
Lost-MSth
af3e91b3e8 Code refactoring
- Code refactoring mainly for API
- Delete a useless option in `setting.py`
- Change some constants in Link Play mode
2022-07-06 22:07:00 +08:00
Lost-MSth
9d746eab2d Update score.py
Fix two bugs.
2022-07-04 23:14:23 +08:00
Lost-MSth
6fcca17918 Code refactoring
- Code refactoring
- Fix a bug that the other player will not become the host of the room at once, when the player disconnect in link play.

> Maybe add many unknown bugs. XD
> The song database `arcsong.db` will not used in the future. You can use a tool in `tool` folder to import old data.
2022-07-04 18:36:30 +08:00
Lost-MSth
9de62d3645 Update to v2.8.6 without release
- Code refactoring
- Update the song database
- Fix a bug of the friend system which arose in the last version
- Fix a bug of duplicate calculating finishing states when players finish playing the chart in Link Play.
2022-05-26 18:56:33 +08:00
Lost-MSth
96fbd26342 Update to v2.8.5 2022-04-28 18:29:26 +08:00
Lost-MSth
aa77a4c903 Update requirements 2022-04-16 18:37:11 +08:00
Lost-MSth
6075bda5d8 Merge pull request #49 from Young-Lord/patch-1
Modify the import of link_play module
2022-04-16 18:35:23 +08:00
LY
409c2c032b Modify the location of the import of link_play
... to reduce unnecessary imports (both link_play and cryptography)
2022-04-16 18:29:40 +08:00
Lost-MSth
e8c15f5069 Add a register api
- Add a register api
- Change requirements, which flask version should be over 2.0
2022-04-10 21:02:46 +08:00
Lost-MSth
35c34f25d5 Try to fix a bug in Unix
multiprocess bug of python in Unix operating system:
The subprocess will not start when using 'fork' mode. This will make the UDP server down.
2022-04-05 22:12:23 +08:00
Lost-MSth
4b8c5a520e Fix a bug about core
A typing error
2022-04-02 12:53:42 +08:00
Lost-MSth
1235733ddf Update to v2.8.6 2022-04-01 13:37:38 +08:00
Lost-MSth
cb6425a0d1 Update to v2.8.3 without release
- Unlock the character **linka**.
- Update the song database.
2022-03-24 16:30:46 +08:00
Lost-MSth
3a1c731e24 Update to v2.8.2 without release
- Try to add support for Anniversary 5 ticket
- Update the song database

> Well, there may be bugs with Anniversary 5 ticket. Remember it can only be used when the pack is on sale.
2022-03-09 18:44:00 +08:00
125 changed files with 17877 additions and 9114 deletions

42
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,42 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
<!-- Please use a clear and concise title 能不能来个正常点的标题 -->
<!-- English or Chinese is ok 中英皆可 -->
**Describe the bug 问题描述**
A clear and concise description of what the bug is.
**To Reproduce 复现过程**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Error log 报错日志**
<!-- Including logs, texts in CMD, some messages in some softwares... -->
```
[Error] Balabalabalabala...
```
**Screenshots 截图**
<!-- Well, if you have error logs, pictures are optional. It is too slow for me to open a picture. -->
<!-- 有错误日志就别放图了,网速有多慢你懂的 -->
If applicable, add screenshots to help explain your problem.
**Environment 环境**
- Server OS: [e.g. Windows 11 Beta]
- Client OS: [e.g. Android 10]
- Server Version: [e.g. v2.8.1]
- Client Version [e.g. 4.0.256c]
- Others like python version, flask version, some proxy softwares if you used...
**Additional context 附加内容**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

14
.github/ISSUE_TEMPLATE/q-a.md vendored Normal file
View File

@@ -0,0 +1,14 @@
---
name: Q&A
about: Ask a question or wonder something
title: ''
labels: Q&A
assignees: ''
---
**What you want to know 你想知道什么**
Clearly describe your question here.
**Additional context 附加内容**
Add anything other here. Your abilities or knowledge base, your contact information, some pictures...

26
.gitignore vendored
View File

@@ -1,5 +1,29 @@
# log files
*.log
*.log.*
# SSL cert
*.pem
*.key
# sqlite3 database
*.db
*.db.bak*
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
*$py.class
# setting/config files
latest version/config/
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/

165
README.md
View File

@@ -1,8 +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 服务器,可以模拟游戏的主要功能。这可能是我第一次写这种大程序,若有不妥之处,敬请谅解。
本程序主要用于学习研究,不得用于任何商业行为,否则后果自负,这不是强制要求,只是一个提醒与警告。
@@ -14,112 +16,165 @@ This procedure is mainly used for study and research, and shall not be used for
> It looks stupid, but it works!
## 特性 Features
有以下 We have
:x: : 不支持 Not supported
:warning: : 可能存在问题 / 可能与官方不一样 Possible issues / may differ from official
:wastebasket: : 不再更新,可能会移除或重构 No longer updated, may be removed or refactored
:construction: : 建设中 In construction
- 登录、注册 Login and registration
- 多设备登录 Multi device login
- 多设备自动封号 Auto-ban of multiple devices
- :warning: 多设备登录 Multi device login
- 登录频次限制 Login rate limit
- 注册频次限制 Register rate limit
- :warning: 销号 Destroy account
- 成绩上传 Score upload
- PTT
- 成绩校验 Score check
- 成绩排名 Score rank
- 潜力值机制 Potential
- Best 30
- :warning: Recent Top 10
- 世界排名 Global rank
- 排名 Rank
- Link Play
- 段位系统 Course system
- :warning: Link Play 2.0
- 好友系统 Friends
- 数据同步 Data synchronization
- 爬梯 Climbing steps
- 自定义世界模式 Customizable World Mode
- 自定义歌曲下载 Customizable songs download
- 单曲和曲包购买(没啥用) Single songs and song packs purchase(useless)
- :x: 好友位提升 Max friend number increase
- 云端存档 Cloud save
- 尝试全剧情、曲目解锁 Try to unlock all the stories and songs
- 世界模式 World mode
- 体力系统 Stamina system
- :warning: 普通梯子强化和绳子强化 Normal steps boost & beyond boost
- :warning: 角色技能 Character skills
- 歌曲下载 Songs downloading
- :x: 加密下载 Encrypted downloading
- 下载校验 Download check
- 下载频次限制 Download rate limit
- 内容捆绑包热更新 Content bundle hot update
- 购买系统 Purchase system
- 单曲和曲包 Single & Pack
- :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
- 全剧情解锁 Unlock all the stories
- 后台查询 Background search
- 后台自定义信息 Customize some things in the background
- 成绩校验 Score check
- 下载校验 Download check
- 数据记录 Data recording
- 用户成绩 Users' scores
- 用户每日潜力值 Users' daily potential
- :wastebasket: 简单的网页管理后台 Simple web admin backend
- :construction: API
- 服务器日志 Server log
没有以下 We don't have
- 服务器安全性保证 Server security assurance
可能有问题 There may be problems
- Recent 30
- 一些歌曲的解锁 Some songs' unlocking
- 同设备多共存登录 Multiple app logins on the same device
## 说明 Statement
只是很有趣,用处探索中。
It is just so interesting. What it can do is under exploration.
## 下载 Download
[这里 Here](https://github.com/Lost-MSth/Arcaea-server/releases)
[Arcaea-CN official](https://arcaea.lowiro.com/zh)
[Arcaea-lowi.ro](https://lowi.ro)
[Arcaea-RhyDown](https://rhydown.com)
## 更新日志 Update log
只保留最新版本 Only keep the latest version.
> 提醒:更新时请注意保留原先的数据库,以防数据丢失。
> 提醒:更新时请注意保留原先的数据库,以防数据丢失。
> Tips: When updating, please keep the original database in case of data loss.
>
> Tips: When updating, please keep the original database in case of data loss.
### Version 2.8.1
- 适用于Arcaea 3.12.0版本 For Arcaea 3.12.0
- 更新了歌曲数据库 Update the song database.
- 新搭档**维塔**已解锁 Unlock the character **Vita**.
- 新增对**维塔**的技能支持 Add support for the skill of **Vita**.
- 修正世界模式进度计算方式 Revise the algorithm of world mode progress.
- 修复世界模式下 **对立(风暴)** 数值计算错误的问题 Fix the wrong value of **Tairitsu(Tempest)** in the World Mode.
- 以下是累积更新 The following are cumulative updates:
- #35 集成式接口优化By Young-Lord Optimize `aggregate` interface. (By Young-Lord)
- 新增填写Link Play服务器地址的选项解决地址无法正确自动获取的问题 Add the option of filling in Link Play server address, which can solve the problem that the address cannot be obtained automatically.
- 修复高版本iOS客户端无法登陆的问题 Fix a bug that the high version of iOS client cannot log in.
- 修复有关下载的安全性问题 Fix a safety problem about downloading.
- 修复有不同音频的Beyond谱面无法下载的问题 Fix a bug about unable to download a beyond chart which has another audio file.
> 其它小改动请参考各个 commit 信息。
> Please refer to the commit messages for other minor changes.
### Version 2.12.1
> 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 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
- Flask module, Cryptography module
- Windows / Linux / Mac OS / Android...
- Python >= 3.6
- Flask >= 2.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
[中文 / English](https://github.com/Lost-MSth/Arcaea-server/wiki/Q&A)
## 鸣谢 Thanks
歌曲数据库来自 Using song database from
[BotArcAPI releases](https://github.com/TheSnowfield/BotArcAPI/releases)
~~[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.
网站图标来自 Using favicon from [black fate - てんてん - pixiv](https://www.pixiv.net/artworks/82374369)
## 联系方式 Contact
如有必要,可以联系本人 Contact me if necessary
邮箱 Emailth84292@foxmail.com
邮箱 Emailarcaea@lost-msth.cn
## 支持一下 Support me
生活不易。 Life is not easy.
[支付宝 Alipay](https://github.com/Lost-MSth/Arcaea-server/blob/master/pic/Alipay.jpg)
[微信 WeChat](https://github.com/Lost-MSth/Arcaea-server/blob/master/pic/WeChat.png)
## 使用许可 Use license
[MIT](LICENSE) © Lost

View File

@@ -0,0 +1,10 @@
from flask import Blueprint
from . import (users, songs, token, system, items,
purchases, presents, redeems, characters, multiplay)
bp = Blueprint('api', __name__, url_prefix='/api/v1')
l = [users, songs, token, system, items, purchases,
presents, redeems, characters, multiplay]
for i in l:
bp.register_blueprint(i.bp)

View File

@@ -1,156 +1,123 @@
import hashlib
import base64
import time
import random
from server.sql import Connect
import functools
from setting import Config
from flask import jsonify
from base64 import b64decode
from functools import wraps
from json import loads
from traceback import format_exc
from flask import current_app
from core.api_user import APIUser
from core.config_manager import Config
from core.error import ArcError, InputError, NoAccess, PostError
from core.sql import Connect
from .api_code import error_return
from .constant import Constant
class User():
# 用户类,当数据类型用
def __init__(self, user_id=None, role='', power=[]):
self.user_id = user_id
self.role = role
self.power = power
def login(auth: str, ip: str):
# 登录接口,返回字典和错误码
try:
auth_decode = bytes.decode(base64.b64decode(auth))
except:
return {}, -100
if not ':' in auth_decode:
return {}, -100
name, password = auth_decode.split(':', 1)
with Connect() as c:
hash_pwd = hashlib.sha256(password.encode("utf8")).hexdigest()
c.execute('''select user_id, password from user where name = :name''', {
'name': name})
x = c.fetchone()
if x is None:
return {}, -201
if x[1] == '':
return {}, -202
if hash_pwd != x[1]:
return {}, -201
user_id = str(x[0])
now = int(time.time() * 1000)
token = hashlib.sha256(
(user_id + str(random.randint(10000, 99999)) + str(now)).encode("utf8")).hexdigest()
c.execute('''delete from api_login where user_id=?''', (user_id,))
c.execute('''insert into api_login values(?,?,?,?)''',
(user_id, token, now, ip))
return {'token': token, 'user_id': user_id}, 0
def logout(user: User):
# 登出接口,返回错误码
code = 999
with Connect() as c:
c.execute('''delete from api_login where user_id=?''', (user.user_id,))
code = 0
return code
def id_get_role_id(c, user_id):
# user_id获取role_id
role_id = 1
c.execute('''select role_id from user_role where user_id = :a''',
{'a': user_id})
x = c.fetchone()
if x is not None:
role_id = int(x[0])
return role_id
def role_id_get_role(c, role_id):
# role_id获取role
role = ''
c.execute('''select role_name from role where role_id = :a''',
{'a': role_id})
x = c.fetchone()
if x is not None:
role = x[0]
return role
def api_token_get_id(c, token):
# api的token获取user_id
user_id = None
c.execute('''select user_id from api_login where token = :token''', {
'token': token})
x = c.fetchone()
if x is not None:
user_id = x[0]
return user_id
def get_role_power(c, role_id):
# 获取role_id对应power返回列表
role_power = []
c.execute('''select power_name from power where power_id in (select power_id from role_power where role_id=:a)''', {
'a': role_id})
x = c.fetchall()
for i in x:
role_power.append(i[0])
return role_power
def role_required(request, power=[]):
# api token验证写成了修饰器
def role_required(request, powers=[]):
'''api token验证写成了修饰器'''
def decorator(view):
@functools.wraps(view)
@wraps(view)
def wrapped_view(*args, **kwargs):
try:
request.json # 检查请求json格式
if request.data:
request.json # 检查请求json格式
except:
return jsonify({'status': 400, 'code': -1, 'data': {}, 'msg': 'Payload must be a valid json'})
return error_return(PostError('Payload must be a valid json', api_error_code=-1), 400)
if not 'Token' in request.headers:
return jsonify({'status': 401, 'code': -1, 'data': {}, 'msg': 'No token'})
return error_return(PostError('No token', api_error_code=-1), 401)
user = User()
if Config.API_TOKEN == request.headers['Token'] and Config.API_TOKEN != '':
user.user_id = 0
elif power == []:
return jsonify({'status': 403, 'code': -1, 'data': {}, 'msg': 'No permission'})
else:
with Connect() as c:
user.user_id = api_token_get_id(
c, request.headers['Token'])
if user.user_id is None:
return jsonify({'status': 401, 'code': -1, 'data': {}, 'msg': 'No token'})
user = APIUser()
with Connect() as c:
user.c = c
if Config.API_TOKEN == request.headers['Token'] and Config.API_TOKEN != '':
user.set_role_system()
else:
try:
user.select_user_id_from_api_token(
request.headers['Token'])
user.select_role_and_powers()
role_id = id_get_role_id(c, user.user_id)
user.role = role_id_get_role(c, role_id)
user.role_power = get_role_power(c, role_id)
f = False
for i in power:
if i in user.role_power:
f = True
break
if not f:
return jsonify({'status': 403, 'code': -1, 'data': {}, 'msg': 'No permission'})
if not any(user.role.has_power(y) for y in powers):
return error_return(NoAccess('No permission', api_error_code=-1), 403)
except ArcError as e:
return error_return(e, 401)
return view(user, *args, **kwargs)
return wrapped_view
return decorator
def request_json_handle(request, required_keys: list = [], optional_keys: list = [], must_change: bool = False, is_batch: bool = False):
'''
提取post参数返回dict写成了修饰器
parameters:
`request`: `Request` - 当前请求
`required_keys`: `list` - 必须的参数
`optional_keys`: `list` - 可选的参数
`must_change`: `bool` - 当全都是可选参数时,是否必须有至少一项修改
'''
def decorator(view):
@wraps(view)
def wrapped_view(*args, **kwargs):
data = {}
if request.data:
json_data = request.get_json()
else:
if request.method == 'GET' and 'query' in request.args:
# 处理axios没法GET传data的问题
try:
json_data = loads(
b64decode(request.args['query']).decode())
except:
raise PostError(api_error_code=-105)
else:
json_data = {}
for key in required_keys:
if key not in json_data:
return error_return(InputError(f'Missing parameter: {key}', api_error_code=-100))
data[key] = json_data[key]
if is_batch:
for key in Constant.PATCH_KEYS:
if key in json_data:
data[key] = json_data[key]
if not isinstance(data[key], list):
return error_return(InputError(f'Parameter {key} must be a list', api_error_code=-100))
if not data:
return error_return(InputError('No change', api_error_code=-100))
else:
for key in optional_keys:
if key in json_data:
data[key] = json_data[key]
if must_change and not data:
return error_return(InputError('No change', api_error_code=-100))
return view(data, *args, **kwargs)
return wrapped_view
return decorator
def api_try(view):
'''替代try/except记录`ArcError`为warning'''
@wraps(view)
def wrapped_view(*args, **kwargs):
try:
data = view(*args, **kwargs)
if data is None:
return error_return()
return data
except ArcError as e:
if Config.ALLOW_WARNING_LOG:
current_app.logger.warning(format_exc())
return error_return(e, e.status)
return wrapped_view

View File

@@ -1,19 +1,45 @@
def code_get_msg(code):
# api接口code获取msg返回字符串
msg = {
'0': '',
'-1': 'See status code',
'-2': 'No data',
'-3': 'No data or user',
'-4': 'No user_id',
'-100': 'Wrong post data',
'-101': 'Wrong data type',
'-102': 'Wrong query parameter',
'-103': 'Wrong sort parameter',
'-104': 'Wrong sort order parameter',
'-201': 'Wrong username or password',
'-202': 'User is banned',
'-999': 'Unknown error'
}
from flask import jsonify
return msg[str(code)]
from core.error import ArcError
default_error = ArcError('Unknown Error')
CODE_MSG = {
0: '',
-1: 'See status code', # 基础错误
-2: 'No data',
-3: 'No data or user', # 不确定是无数据还是无用户
-4: 'Data exist',
-100: 'Invalid post data', # 1xx数据错误
-101: 'Invalid data type',
-102: 'Invalid query parameter',
-103: 'Invalid sort parameter',
-104: 'Invalid sort order parameter',
-105: 'Invalid URL parameter',
-110: 'Invalid user_id',
-120: 'Invalid item type',
-121: 'No such item',
-122: 'Item already exists',
-123: 'The collection already has this item',
-124: 'The collection does not have this item',
-130: 'No such character',
-131: 'Invalid skill ID',
-200: 'No permission', # 2xx用户相关错误
-201: 'Wrong username or password',
-202: 'User is banned',
-203: 'Too many login attempts',
-210: 'Username exists',
-211: 'Email address exists',
-212: 'User code exists',
-213: 'Too many register attempts',
-999: 'Unknown error'
}
def success_return(data: dict = {}, status: int = 200, msg: str = ''):
return jsonify({'code': 0, 'data': data, 'msg': msg}), status
def error_return(e: 'ArcError' = default_error, status: int = 200):
return jsonify({'code': e.api_error_code, 'data': {} if e.extra_data is None else e.extra_data, 'msg': CODE_MSG[e.api_error_code] if e.message is None else e.message}), status

View File

@@ -1,228 +0,0 @@
from flask import (
Blueprint, request, jsonify
)
import functools
import api.api_auth
import api.users
import api.songs
from api.api_code import code_get_msg
bp = Blueprint('api', __name__, url_prefix='/api/v1')
class Query():
# 查询类,当查询附加参数的数据类型用
def __init__(self, limit=-1, offset=0, query={}, sort=[]) -> None:
self.limit = limit
self.offset = offset
self.query = query # {'name': 'admin'}
self.sort = sort # [{'column': 'user_id', 'order': 'ASC'}, ...]
def get_query_parameter(request, query_able=[], sort_able=[]):
# 提取查询请求参数返回Query类查询参数写成修饰器
def decorator(view):
@functools.wraps(view)
def wrapped_view(*args, **kwargs):
re = Query()
if 'limit' in request.json:
try:
re.limit = int(request.json['limit'])
except:
return jsonify({'status': 200, 'code': -101, 'data': {}, 'msg': code_get_msg(-101)})
if 'offset' in request.json:
try:
re.offset = int(request.json['offset'])
except:
return jsonify({'status': 200, 'code': -101, 'data': {}, 'msg': code_get_msg(-101)})
if 'query' in request.json:
re.query = request.json['query']
for i in re.query:
if i not in query_able:
return jsonify({'status': 200, 'code': -102, 'data': {}, 'msg': code_get_msg(-102)})
if 'sort' in request.json:
re.sort = request.json['sort']
for i in re.sort:
if 'column' not in i or i['column'] not in sort_able:
return jsonify({'status': 200, 'code': -103, 'data': {}, 'msg': code_get_msg(-103)})
if not 'order' in i:
i['order'] = 'ASC'
else:
if i['order'] not in ['ASC', 'DESC']:
return jsonify({'status': 200, 'code': -104, 'data': {}, 'msg': code_get_msg(-104)})
return view(re, *args, **kwargs)
return wrapped_view
return decorator
def return_encode(code: int = 0, data: dict = {}, status: int = 200, msg: str = ''):
# 构造返回返回jsonify处理过后的response_class
if msg == '':
msg = code_get_msg(code)
if code < 0:
return jsonify({'status': status, 'code': code, 'data': {}, 'msg': msg})
else:
return jsonify({'status': status, 'code': code, 'data': data, 'msg': msg})
@bp.route('/')
def ping():
return return_encode()
@bp.route('/token', methods=['POST'])
def token_post():
# 登录获取token
# {'auth': `base64(user_id:password)`}
if 'auth' in request.json:
data, code = api.api_auth.login(
str(request.json['auth']), request.remote_addr)
return return_encode(code, data)
else:
return return_encode(-1, {}, 401, 'No authentication')
@bp.route('/token', methods=['GET'])
@api.api_auth.role_required(request, ['select_me', 'select'])
def token_get(user):
# 判断登录有效性
return return_encode()
@bp.route('/token', methods=['DELETE'])
@api.api_auth.role_required(request, ['change_me', 'select_me', 'select'])
def token_delete(user):
# 登出
return return_encode(api.api_auth.logout(user))
@bp.route('/users', methods=['GET'])
@api.api_auth.role_required(request, ['select'])
@get_query_parameter(request, ['user_id', 'name', 'user_code'], [
'user_id', 'name', 'user_code', 'join_date', 'rating_ptt', 'time_played', 'ticket', 'world_rank_score'])
def users_get(query, user):
# 查询全用户信息
data = api.users.get_users(query)
if not data:
return return_encode(-2)
return return_encode(0, data)
@bp.route('/users/<int:user_id>', methods=['GET'])
@api.api_auth.role_required(request, ['select', 'select_me'])
def users_user_get(user, user_id):
# 查询用户信息
if user_id == 'me':
user_id = user.user_id
if user_id <= 0:
return return_encode(-4)
if user_id != user.user_id and not 'select' in user.power and user.user_id != 0: # 查别人需要select权限
return return_encode(-1, {}, 403, 'No permission')
data = api.users.get_user_info(user_id)
if not data:
return return_encode(-3)
return return_encode(0, data)
@bp.route('/users/<int:user_id>/b30', methods=['GET'])
@api.api_auth.role_required(request, ['select', 'select_me'])
def users_user_b30_get(user, user_id):
# 查询用户b30
if user_id <= 0:
return return_encode(-4)
if user_id != user.user_id and not 'select' in user.power and user.user_id != 0: # 查别人需要select权限
return return_encode(-1, {}, 403, 'No permission')
data = api.users.get_user_b30(user_id)
if data['data'] == []:
return return_encode(-3)
return return_encode(0, data)
@bp.route('/users/<int:user_id>/best', methods=['GET'])
@api.api_auth.role_required(request, ['select', 'select_me'])
@get_query_parameter(request, ['song_id', 'difficulty'], [
'song_id', 'difficulty', 'score', 'time_played', 'rating'])
def users_user_best_get(query, user, user_id):
# 查询用户所有best成绩
if user_id <= 0:
return return_encode(-4)
if user_id != user.user_id and not 'select' in user.power and user.user_id != 0: # 查别人需要select权限
return return_encode(-1, {}, 403, 'No permission')
data = api.users.get_user_best(user_id, query)
if data['data'] == []:
return return_encode(-3)
return return_encode(0, data)
@bp.route('/users/<int:user_id>/r30', methods=['GET'])
@api.api_auth.role_required(request, ['select', 'select_me'])
def users_user_r30_get(user, user_id):
# 查询用户r30
if user_id <= 0:
return return_encode(-4)
if user_id != user.user_id and not 'select' in user.power and user.user_id != 0: # 查别人需要select权限
return return_encode(-1, {}, 403, 'No permission')
data = api.users.get_user_r30(user_id)
if data['data'] == []:
return return_encode(-3)
return return_encode(0, data)
@bp.route('/songs/<string:song_id>', methods=['GET'])
@api.api_auth.role_required(request, ['select', 'select_song_info'])
def songs_song_get(user, song_id):
# 查询歌曲信息
data = api.songs.get_song_info(song_id)
if not data:
return return_encode(-2)
return return_encode(0, data)
@bp.route('/songs', methods=['GET'])
@api.api_auth.role_required(request, ['select', 'select_song_info'])
@get_query_parameter(request, ['sid', 'name_en', 'name_jp', 'pakset', 'artist'], [
'sid', 'name_en', 'name_jp', 'pakset', 'artist', 'date', 'rating_pst', 'rating_prs', 'rating_ftr', 'rating_byn'])
def songs_get(query, user):
# 查询全歌曲信息
data = api.songs.get_songs(query)
if not data:
return return_encode(-2)
return return_encode(0, data)

View File

@@ -0,0 +1,134 @@
from flask import Blueprint, request
from core.error import InputError, NoData
from core.item import ItemFactory
from core.character import Character
from core.sql import Connect, Query, Sql
from .api_auth import api_try, request_json_handle, role_required
from .api_code import success_return
from .constant import Constant
bp = Blueprint('characters', __name__, url_prefix='/characters')
@bp.route('', methods=['GET'])
@role_required(request, ['select'])
@request_json_handle(request, optional_keys=Constant.QUERY_KEYS)
@api_try
def characters_get(data, user):
'''查询全角色信息'''
A = ['character_id', 'name', 'skill_id',
'skill_id_uncap', 'char_type', 'is_uncapped']
B = ['name', 'skill_id', 'skill_id_uncap']
C = ['name', 'frag1', 'prog1', 'overdrive1', 'frag20',
'prog20', 'overdrive20', 'frag30', 'prog30', 'overdrive30']
with Connect() as c:
query = Query(A, B, C).from_dict(data)
x = Sql(c).select('character', query=query)
r = [Character().from_list(i) for i in x]
if not r:
raise NoData(api_error_code=-2)
return success_return([x.to_dict() for x in r])
@bp.route('/<int:character_id>', methods=['GET'])
@role_required(request, ['select'])
@api_try
def characters_character_get(user, character_id: int):
# 包含core
with Connect() as c:
c = Character(c).select(character_id)
c.select_character_core()
return success_return(c.to_dict(has_cores=True))
@bp.route('/<int:character_id>', methods=['PUT'])
@role_required(request, ['change'])
@request_json_handle(request, optional_keys=['max_level', 'skill_id', 'skill_id_uncap', 'skill_unlock_level', 'skill_requires_uncap', 'char_type', 'is_uncapped', 'frag1', 'prog1', 'overdrive1', 'frag20', 'prog20', 'overdrive20', 'frag30', 'prog30', 'overdrive30'], must_change=True)
@api_try
def characters_character_put(data, user, character_id: int):
'''修改角色信息'''
if ('skill_id' in data and data['skill_id'] != '' and data['skill_id'] not in Constant.SKILL_IDS) or ('skill_id_uncap' in data and data['skill_id_uncap'] != '' and data['skill_id_uncap'] not in Constant.SKILL_IDS):
raise InputError('Invalid skill_id', api_error_code=-131)
with Connect() as c:
c = Character(c).select(character_id)
try:
if 'max_level' in data:
c.max_level = int(data['max_level'])
if 'skill_id' in data:
c.skill_id = data['skill_id']
if 'skill_id_uncap' in data:
c.skill_id_uncap = data['skill_id_uncap']
if 'skill_unlock_level' in data:
c.skill_unlock_level = int(data['skill_unlock_level'])
if 'skill_requires_uncap' in data:
c.skill_requires_uncap = data['skill_requires_uncap'] == 1
if 'char_type' in data:
c.char_type = int(data['char_type'])
if 'is_uncapped' in data:
c.is_uncapped = data['is_uncapped'] == 1
t = ['frag1', 'prog1', 'overdrive1', 'frag20', 'prog20',
'overdrive20', 'frag30', 'prog30', 'overdrive30']
for i in t:
if i not in data:
continue
if i.endswith('1'):
x = getattr(c, i[:-1])
x.start = float(data[i])
elif i.endswith('20'):
x = getattr(c, i[:-2])
x.mid = float(data[i])
else:
x = getattr(c, i[:-2])
x.end = float(data[i])
except ValueError as e:
raise InputError('Invalid input', api_error_code=-101) from e
c.update()
return success_return(c.to_dict())
@bp.route('/<int:character_id>/cores', methods=['GET'])
@role_required(request, ['select'])
@api_try
def characters_character_cores_get(user, character_id: int):
with Connect() as c:
c = Character(c)
c.character_id = character_id
c.select_character_core()
return success_return(c.uncap_cores_to_dict())
@bp.route('/<int:character_id>/cores', methods=['PATCH'])
@role_required(request, ['change'])
@request_json_handle(request, is_batch=True)
@api_try
def characters_character_cores_patch(data, user, character_id: int):
'''修改角色觉醒cores'''
def force_type_core(x: dict) -> dict:
x['item_type'] = 'core'
x['type'] = 'core'
return x
with Connect() as c:
ch = Character(c)
ch.character_id = character_id
ch.select_character_core()
ch.remove_items([ItemFactory.from_dict(x, c=c)
for x in map(force_type_core, data.get('remove', []))])
ch.add_items([ItemFactory.from_dict(x, c=c)
for x in map(force_type_core, data.get('create', []))])
updates = list(map(force_type_core, data.get('update', [])))
for x in updates:
if 'amount' not in x:
raise InputError('`amount` is required in `update`')
if not isinstance(x['amount'], int) or x['amount'] <= 0:
raise InputError(
'`amount` must be a positive integer', api_error_code=-101)
ch.update_items(
[ItemFactory.from_dict(x, c=c) for x in updates])
return success_return(ch.uncap_cores_to_dict())

View File

@@ -0,0 +1,7 @@
class Constant:
QUERY_KEYS = ['limit', 'offset', 'query', 'fuzzy_query', 'sort']
PATCH_KEYS = ['create', 'update', 'remove']
SKILL_IDS = ['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']

115
latest version/api/items.py Normal file
View File

@@ -0,0 +1,115 @@
from flask import Blueprint, request
from core.error import DataExist, InputError, NoData
from core.item import Item, ItemFactory
from core.sql import Connect, Query, Sql
from .api_auth import api_try, request_json_handle, role_required
from .api_code import success_return
from .constant import Constant
bp = Blueprint('items', __name__, url_prefix='/items')
@bp.route('', methods=['GET'])
@role_required(request, ['select'])
@request_json_handle(request, optional_keys=Constant.QUERY_KEYS)
@api_try
def items_get(data, user):
'''查询全物品信息'''
with Connect() as c:
query = Query(['item_id', 'type'], ['item_id'],
['item_id']).from_dict(data)
x = Sql(c).select('item', query=query)
r: 'list[Item]' = []
for i in x:
r.append(ItemFactory.from_dict({
'item_id': i[0],
'type': i[1],
'is_available': i[2] == 1
}))
if not r:
raise NoData(api_error_code=-2)
return success_return([x.to_dict(has_is_available=True, has_amount=False) for x in r])
ALLOW_ITEM_TYPE = ['pack', 'single', 'world_song', 'character']
@bp.route('', methods=['POST'])
@role_required(request, ['change'])
@request_json_handle(request, required_keys=['item_id', 'type'], optional_keys=['is_available'])
@api_try
def items_post(data, user):
'''新增物品'''
if data['type'] not in ALLOW_ITEM_TYPE:
raise InputError(
f'Invalid item type: `{data["type"]}`', api_error_code=-120)
with Connect() as c:
item = ItemFactory.from_dict(data, c=c)
if item.select_exists():
raise DataExist(
f'Item `{item.item_type}`: `{item.item_id}` already exists', api_error_code=-122)
item.insert()
return success_return(item.to_dict(has_is_available=True, has_amount=False))
@bp.route('/<string:item_type>/<string:item_id>', methods=['DELETE'])
@role_required(request, ['change'])
@api_try
def items_item_delete(user, item_type, item_id):
'''删除物品'''
if item_type not in ALLOW_ITEM_TYPE:
raise InputError(
f'Invalid item type: `{item_type}`', api_error_code=-120)
with Connect() as c:
item = ItemFactory.from_dict({
'item_id': item_id,
'type': item_type
}, c=c)
if not item.select_exists():
raise NoData(
f'No such item `{item_type}`: `{item_id}`', api_error_code=-121)
item.delete()
return success_return()
@bp.route('/<string:item_type>/<string:item_id>', methods=['PUT'])
@role_required(request, ['change'])
@request_json_handle(request, optional_keys=['is_available'], must_change=True)
@api_try
def items_item_put(data, user, item_type, item_id):
'''修改物品'''
if item_type not in ALLOW_ITEM_TYPE:
raise InputError(
f'Invalid item type: `{item_type}`', api_error_code=-120)
if not isinstance(data['is_available'], bool):
raise InputError('`is_available` must be a boolean',
api_error_code=-101)
with Connect() as c:
item = ItemFactory.from_dict({
'item_id': item_id,
'type': item_type,
'is_available': data['is_available']
}, c=c)
if not item.select_exists():
raise NoData(
f'No such item `{item_type}`: `{item_id}`', api_error_code=-121)
item.update()
return success_return(item.to_dict(has_is_available=True, has_amount=False))
@bp.route('/<string:item_type>/<string:item_id>', methods=['GET'])
@role_required(request, ['select'])
@api_try
def items_item_get(user, item_type, item_id):
'''查询单个物品信息'''
with Connect() as c:
item = ItemFactory.from_dict({
'item_id': item_id,
'type': item_type
}, c=c)
item.select()
return success_return(item.to_dict(has_is_available=True, has_amount=False))

View File

@@ -0,0 +1,21 @@
from flask import Blueprint, request
from core.linkplay import RemoteMultiPlayer
from .api_auth import api_try, request_json_handle, role_required
from .api_code import success_return
bp = Blueprint('multiplay', __name__, url_prefix='/multiplay')
@bp.route('/rooms', methods=['GET'])
@role_required(request, ['select'])
@request_json_handle(request, optional_keys=['offset', 'limit'])
@api_try
def rooms_get(data, user):
'''获取房间列表'''
r = RemoteMultiPlayer().get_rooms(offset=data.get(
'offset', 0), limit=data.get('limit', 100))
return success_return(r)

View File

@@ -0,0 +1,122 @@
from flask import Blueprint, request
from core.error import DataExist, InputError, NoData
from core.item import ItemFactory
from core.present import Present
from core.sql import Connect, Query, Sql
from .api_auth import api_try, request_json_handle, role_required
from .api_code import success_return
from .constant import Constant
bp = Blueprint('presents', __name__, url_prefix='/presents')
@bp.route('', methods=['GET'])
@role_required(request, ['select'])
@request_json_handle(request, optional_keys=Constant.QUERY_KEYS)
@api_try
def presents_get(data, user):
'''查询全present信息'''
with Connect() as c:
query = Query(['present_id'], ['present_id', 'description'], [
'present_id', 'expire_ts']).from_dict(data)
x = Sql(c).select('present', query=query)
r = [Present().from_list(i) for i in x]
if not r:
raise NoData(api_error_code=-2)
return success_return([x.to_dict(has_items=False) for x in r])
@bp.route('', methods=['POST'])
@role_required(request, ['insert'])
@request_json_handle(request, required_keys=['present_id', 'description', 'expire_ts'], optional_keys=['items'])
@api_try
def presents_post(data, user):
'''添加present注意可以有items不存在的item会自动创建'''
with Connect() as c:
p = Present(c).from_dict(data)
if p.select_exists():
raise DataExist(
f'Present `{p.present_id}` already exists')
p.insert_all()
return success_return(p.to_dict(has_items='items' in data))
@bp.route('/<string:present_id>', methods=['GET'])
@role_required(request, ['select'])
@api_try
def presents_present_get(user, present_id: str):
'''查询单个present信息'''
with Connect() as c:
p = Present(c).select(present_id)
p.select_items()
return success_return(p.to_dict())
@bp.route('/<string:present_id>', methods=['DELETE'])
@role_required(request, ['delete'])
@api_try
def presents_present_delete(user, present_id: str):
'''删除present会连带删除present_item'''
with Connect() as c:
Present(c).select(present_id).delete_all()
return success_return()
@bp.route('/<string:present_id>', methods=['PUT'])
@role_required(request, ['change'])
@request_json_handle(request, optional_keys=['description', 'expire_ts'], must_change=True)
@api_try
def presents_present_put(data, user, present_id: str):
'''更新present信息注意不能有items'''
with Connect() as c:
p = Present(c).select(present_id)
if 'description' in data:
p.description = str(data['description'])
if 'expire_ts' in data:
p.expire_ts = int(data['expire_ts'])
p.update()
return success_return(p.to_dict(has_items=False))
@bp.route('/<string:present_id>/items', methods=['GET'])
@role_required(request, ['select'])
@api_try
def presents_present_items_get(user, present_id: str):
'''查询present的items'''
with Connect() as c:
p = Present(c)
p.present_id = present_id
p.select_items()
return success_return([x.to_dict(has_is_available=True) for x in p.items])
@bp.route('/<string:present_id>/items', methods=['PATCH'])
@role_required(request, ['change'])
@request_json_handle(request, is_batch=True)
@api_try
def presents_present_items_patch(data, user, present_id: str):
'''增删改单个present的items'''
with Connect() as c:
p = Present(c)
p.present_id = present_id
p.select_items()
p.remove_items([ItemFactory.from_dict(x, c=c)
for x in data.get('remove', [])])
p.add_items([ItemFactory.from_dict(x, c=c)
for x in data.get('create', [])])
updates = data.get('update', [])
for x in updates:
if 'amount' not in x:
raise InputError('`amount` is required in `update`')
if not isinstance(x['amount'], int) or x['amount'] <= 0:
raise InputError(
'`amount` must be a positive integer', api_error_code=-101)
p.update_items([ItemFactory.from_dict(x, c=c) for x in updates])
return success_return([x.to_dict(has_is_available=True) for x in p.items])

View File

@@ -0,0 +1,163 @@
from flask import Blueprint, request
from core.error import DataExist, InputError, NoData
from core.item import ItemFactory
from core.purchase import Purchase
from core.sql import Connect, Query, Sql
from .api_auth import api_try, request_json_handle, role_required
from .api_code import success_return
from .constant import Constant
bp = Blueprint('purchases', __name__, url_prefix='/purchases')
@bp.route('', methods=['GET'])
@role_required(request, ['select'])
@request_json_handle(request, optional_keys=Constant.QUERY_KEYS)
@api_try
def purchases_get(data, user):
'''查询全购买信息'''
with Connect() as c:
query = Query(['purchase_name', 'discount_reason'], ['purchase_name'], [
'purchase_name', 'price', 'orig_price', 'discount_from', 'discount_to']).from_dict(data)
x = Sql(c).select('purchase', query=query)
r = [Purchase().from_list(i) for i in x]
if not r:
raise NoData(api_error_code=-2)
return success_return([x.to_dict(has_items=False, show_real_price=False) for x in r])
@bp.route('', methods=['POST'])
@role_required(request, ['change'])
@request_json_handle(request, required_keys=['purchase_name', 'orig_price'], optional_keys=['price', 'discount_from', 'discount_to', 'discount_reason', 'items'])
@api_try
def purchases_post(data, user):
'''新增购买注意可以有items不存在的item会自动创建'''
with Connect() as c:
purchase = Purchase(c).from_dict(data)
if purchase.select_exists():
raise DataExist(
f'Purchase `{purchase.purchase_name}` already exists')
purchase.insert_all()
return success_return(purchase.to_dict(has_items='items' in data, show_real_price=False))
@bp.route('/<string:purchase_name>', methods=['GET'])
@role_required(request, ['select'])
@api_try
def purchases_purchase_get(user, purchase_name: str):
'''查询单个购买信息'''
with Connect() as c:
return success_return(Purchase(c).select(purchase_name).to_dict(show_real_price=False))
@bp.route('/<string:purchase_name>', methods=['DELETE'])
@role_required(request, ['change'])
@api_try
def purchases_purchase_delete(user, purchase_name: str):
'''删除单个购买信息会连带删除purchase_item'''
with Connect() as c:
Purchase(c).select(purchase_name).delete_all()
return success_return()
@bp.route('/<string:purchase_name>', methods=['PUT'])
@role_required(request, ['change'])
@request_json_handle(request, optional_keys=['price', 'orig_price', 'discount_from', 'discount_to', 'discount_reason'], must_change=True)
@api_try
def purchases_purchase_put(data, user, purchase_name: str):
'''修改单个购买信息注意不能有items'''
with Connect() as c:
purchase = Purchase(c).select(purchase_name)
t = ['price', 'orig_price', 'discount_from', 'discount_to']
for i in t:
if i in data:
setattr(purchase, i, int(data[i]))
if 'discount_reason' in data:
purchase.discount_reason = str(data['discount_reason'])
purchase.update()
return success_return(purchase.to_dict(has_items=False, show_real_price=False))
@bp.route('/<string:purchase_name>/items', methods=['GET'])
@role_required(request, ['select'])
@api_try
def purchases_purchase_items_get(user, purchase_name: str):
'''查询单个购买的所有items'''
with Connect() as c:
p = Purchase(c)
p.purchase_name = purchase_name
p.select_items()
return success_return([x.to_dict(has_is_available=True) for x in p.items])
# @bp.route('/<string:purchase_name>/items', methods=['POST'])
# @role_required(request, ['change'])
# @request_json_handle(request, required_keys=['item_id', 'type'], optional_keys=['amount'])
# @api_try
# def purchases_purchase_items_post(data, user, purchase_name: str):
# '''新增单个购买的批量items'''
# with Connect() as c:
# p = Purchase(c)
# p.purchase_name = purchase_name
# p.select_items()
# p.add_items([ItemFactory().from_dict(data)])
# return success_return([x.to_dict(has_is_available=True) for x in p.items])
@bp.route('/<string:purchase_name>/items', methods=['PATCH'])
@role_required(request, ['change'])
@request_json_handle(request, is_batch=True)
@api_try
def purchases_purchase_items_patch(data, user, purchase_name: str):
'''增删改单个购买的批量items'''
with Connect() as c:
p = Purchase(c)
p.purchase_name = purchase_name
p.select_items()
p.remove_items([ItemFactory.from_dict(x, c=c)
for x in data.get('remove', [])])
p.add_items([ItemFactory.from_dict(x, c=c)
for x in data.get('create', [])])
updates = data.get('update', [])
for x in updates:
if 'amount' not in x:
raise InputError('`amount` is required in `update`')
if not isinstance(x['amount'], int) or x['amount'] <= 0:
raise InputError(
'`amount` must be a positive integer', api_error_code=-101)
p.update_items([ItemFactory.from_dict(x, c=c) for x in updates])
return success_return([x.to_dict(has_is_available=True) for x in p.items])
# @bp.route('/<string:purchase_name>/items/<string:item_type>/<string:item_id>', methods=['DELETE'])
# @role_required(request, ['change'])
# @api_try
# def purchases_purchase_items_item_delete(user, purchase_name: str, item_type: str, item_id: str):
# '''删除单个购买的单个item'''
# with Connect() as c:
# p = Purchase(c)
# p.purchase_name = purchase_name
# p.select_items()
# p.delete_items([ItemFactory().from_dict(
# {'item_type': item_type, 'item_id': item_id})])
# return success_return()
# @bp.route('/<string:purchase_name>/items/<string:item_type>/<string:item_id>', methods=['PUT'])
# @role_required(request, ['change'])
# @request_json_handle(request, optional_keys=['amount', 'is_available'], must_change=True)
# @api_try
# def purchases_purchase_items_item_put(data, user, purchase_name: str, item_type: str, item_id: str):
# '''修改单个购买的单个item'''
# with Connect() as c:
# p = Purchase(c)
# p.purchase_name = purchase_name
# pass
# return success_return()

View File

@@ -0,0 +1,119 @@
from flask import Blueprint, request
from core.error import DataExist, InputError, NoData
from core.item import ItemFactory
from core.redeem import Redeem
from core.sql import Connect, Query, Sql
from .api_auth import api_try, request_json_handle, role_required
from .api_code import success_return
from .constant import Constant
bp = Blueprint('redeems', __name__, url_prefix='/redeems')
@bp.route('', methods=['GET'])
@role_required(request, ['select'])
@request_json_handle(request, optional_keys=Constant.QUERY_KEYS)
@api_try
def redeems_get(data, user):
'''查询全redeem信息'''
with Connect() as c:
query = Query(['code', 'type'], ['code'], ['code']).from_dict(data)
x = Sql(c).select('redeem', query=query)
r = [Redeem().from_list(i) for i in x]
if not r:
raise NoData(api_error_code=-2)
return success_return([x.to_dict(has_items=False) for x in r])
@bp.route('', methods=['POST'])
@role_required(request, ['insert'])
@request_json_handle(request, required_keys=['code', 'type'], optional_keys=['items'])
@api_try
def redeems_post(data, user):
'''添加redeem注意可以有items不存在的item会自动创建'''
with Connect() as c:
r = Redeem(c).from_dict(data)
if r.select_exists():
raise DataExist(
f'redeem `{r.code}` already exists')
r.insert_all()
return success_return(r.to_dict(has_items='items' in data))
@bp.route('/<string:code>', methods=['GET'])
@role_required(request, ['select'])
@api_try
def redeems_redeem_get(user, code: str):
'''查询单个redeem信息'''
with Connect() as c:
r = Redeem(c).select(code)
r.select_items()
return success_return(r.to_dict())
@bp.route('/<string:code>', methods=['DELETE'])
@role_required(request, ['delete'])
@api_try
def redeems_redeem_delete(user, code: str):
'''删除redeem会连带删除redeem_item'''
with Connect() as c:
Redeem(c).select(code).delete_all()
return success_return()
@bp.route('/<string:code>', methods=['PUT'])
@role_required(request, ['change'])
@request_json_handle(request, optional_keys=['type'], must_change=True)
@api_try
def redeems_redeem_put(data, user, code: str):
'''更新redeem信息注意不能有items'''
with Connect() as c:
r = Redeem(c).select(code)
if 'type' in data:
r.redeem_type = int(data['type'])
r.update()
return success_return(r.to_dict(has_items=False))
@bp.route('/<string:code>/items', methods=['GET'])
@role_required(request, ['select'])
@api_try
def redeems_redeem_items_get(user, code: str):
'''查询redeem的items'''
with Connect() as c:
r = Redeem(c)
r.code = code
r.select_items()
return success_return([x.to_dict(has_is_available=True) for x in r.items])
@bp.route('/<string:code>/items', methods=['PATCH'])
@role_required(request, ['change'])
@request_json_handle(request, is_batch=True)
@api_try
def redeems_redeem_items_patch(data, user, code: str):
'''增删改单个redeem的items'''
with Connect() as c:
r = Redeem(c)
r.code = code
r.select_items()
r.remove_items([ItemFactory.from_dict(x, c=c)
for x in data.get('remove', [])])
r.add_items([ItemFactory.from_dict(x, c=c)
for x in data.get('create', [])])
updates = data.get('update', [])
for x in updates:
if 'amount' not in x:
raise InputError('`amount` is required in `update`')
if not isinstance(x['amount'], int) or x['amount'] <= 0:
raise InputError(
'`amount` must be a positive integer', api_error_code=-101)
r.update_items([ItemFactory.from_dict(x, c=c) for x in updates])
return success_return([x.to_dict(has_is_available=True) for x in r.items])

View File

@@ -1,58 +1,115 @@
from server.sql import Connect
from server.sql import Sql
import time
from flask import Blueprint, request
from core.error import DataExist, InputError, NoData
from core.rank import RankList
from core.song import Song
from core.sql import Connect, Query, Sql
from .api_auth import api_try, request_json_handle, role_required
from .api_code import success_return
from .constant import Constant
bp = Blueprint('songs', __name__, url_prefix='/songs')
def get_song_info(song_id):
# 查询指定歌曲信息,返回字典
r = {}
with Connect('./database/arcsong.db') as c:
c.execute('''select * from songs where sid=:a''', {'a': song_id})
x = c.fetchone()
if x:
r = {'song_id': x[0],
'name': {'name_en': x[1],
'name_jp': x[2]},
'pakset': x[5],
'artist': x[6],
'date': x[9],
'rating_pst': x[13]/10,
'rating_prs': x[14]/10,
'rating_ftr': x[15]/10,
'rating_byn': x[16]/10,
'difficultly_pst': x[17]/2,
'difficultly_prs': x[18]/2,
'difficultly_ftr': x[19]/2,
'difficultly_byn': x[20]/2
}
return r
@bp.route('/<string:song_id>', methods=['GET'])
@role_required(request, ['select', 'select_song_info'])
@api_try
def songs_song_get(user, song_id):
'''查询歌曲信息'''
with Connect() as c:
s = Song(c, song_id).select()
return success_return(s.to_dict())
def get_songs(query=None):
# 查询全部歌曲信息,返回字典列表
r = []
@bp.route('/<string:song_id>', methods=['PUT'])
@role_required(request, ['change'])
@request_json_handle(request, optional_keys=['name', 'charts'], must_change=True)
@api_try
def songs_song_put(data, user, song_id):
'''修改歌曲信息'''
with Connect() as c:
s = Song(c, song_id).select()
if 'name' in data:
s.name = str(data['name'])
if 'charts' in data:
for i in data['charts']:
if 'difficulty' in i and 'chart_const' in i:
s.charts[i['difficulty']].defnum = round(
i['chart_const'] * 10)
with Connect('./database/arcsong.db') as c:
x = Sql.select(c, 'songs', [], query)
s.update()
return success_return(s.to_dict())
if x:
for i in x:
r.append({'sid': i[0],
'name': {'name_en': i[1],
'name_jp': i[2]},
'pakset': i[5],
'artist': i[6],
'date': i[9],
'rating_pst': i[13]/10,
'rating_prs': i[14]/10,
'rating_ftr': i[15]/10,
'rating_byn': i[16]/10,
'difficultly_pst': i[17]/2,
'difficultly_prs': i[18]/2,
'difficultly_ftr': i[19]/2,
'difficultly_byn': i[20]/2
})
return r
@bp.route('/<string:song_id>', methods=['DELETE'])
@role_required(request, ['change'])
@api_try
def songs_song_delete(user, song_id):
'''删除歌曲信息'''
with Connect() as c:
s = Song(c, song_id)
if not s.select_exists():
raise NoData(f'No such song: `{song_id}`')
s.delete()
return success_return()
@bp.route('', methods=['GET'])
@role_required(request, ['select', 'select_song_info'])
@request_json_handle(request, optional_keys=Constant.QUERY_KEYS)
@api_try
def songs_get(data, user):
'''查询全歌曲信息'''
A = ['song_id', 'name']
B = ['song_id', 'name', 'rating_pst',
'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)
r = []
for i in x:
r.append(Song(c).from_list(i))
if not r:
raise NoData(api_error_code=-2)
return success_return([x.to_dict() for x in r])
@bp.route('', methods=['POST'])
@role_required(request, ['change'])
@request_json_handle(request, ['song_id', 'charts'], ['name'])
@api_try
def songs_post(data, user):
'''添加歌曲信息'''
with Connect() as c:
s = Song(c).from_dict(data)
if s.select_exists():
raise DataExist(f'Song `{s.song_id}` already exists')
s.insert()
return success_return(s.to_dict())
@bp.route('/<string:song_id>/<int:difficulty>/rank', methods=['GET'])
@role_required(request, ['select', 'select_song_rank', 'select_song_rank_top'])
@request_json_handle(request, optional_keys=['limit'])
@api_try
def songs_song_difficulty_rank_get(data, user, song_id, difficulty):
'''查询歌曲某个难度的成绩排行榜和游戏内接口相似只允许limit'''
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')
if user.role.only_has_powers(['select_song_rank_top'], ['select', 'select_song_rank']):
# 限制低权限只能查询前20名
if limit > 20 or limit < 0:
limit = 20
with Connect() as c:
rank_list = RankList(c)
rank_list.song.set_chart(song_id, difficulty)
rank_list.limit = limit
# 不检查歌曲是否存在,不存在返回的是空列表
rank_list.select_top()
return success_return(rank_list.to_dict_list())

View File

@@ -0,0 +1,32 @@
from flask import Blueprint, request
from core.error import ArcError
from core.operation import BaseOperation
from .api_auth import api_try, role_required
from .api_code import success_return
bp = Blueprint('system', __name__, url_prefix='/system')
operation_dict = {i._name: i for i in BaseOperation.__subclasses__()}
@bp.route('/operations', methods=['GET'])
@role_required(request, ['system'])
@api_try
def operations_get(user):
return success_return(list(operation_dict.keys()))
@bp.route('/operations/<string:operation_name>', methods=['POST'])
@role_required(request, ['system'])
@api_try
def operations_operation_post(user, operation_name: str):
if operation_name not in operation_dict:
raise ArcError(
f'No such operation: `{operation_name}`', api_error_code=-1, status=404)
x = operation_dict[operation_name]()
x.set_params(**request.get_json())
x.run()
return success_return()

View File

@@ -0,0 +1,55 @@
from base64 import b64decode
from flask import Blueprint, current_app, request
from core.api_user import APIUser
from core.error import PostError
from core.sql import Connect
from .api_auth import api_try, request_json_handle, role_required
from .api_code import success_return
bp = Blueprint('token', __name__, url_prefix='/token')
@bp.route('', methods=['POST'])
@request_json_handle(request, required_keys=['auth'])
@api_try
def token_post(data):
'''
登录获取token
{'auth': base64('<user_id>:<password>')}
'''
try:
auth_decode = bytes.decode(b64decode(data['auth']))
except Exception as e:
raise PostError(api_error_code=-100) from e
if ':' not in auth_decode:
raise PostError(api_error_code=-100)
name, password = auth_decode.split(':', 1)
with Connect() as c:
user = APIUser(c)
user.login(name, password, request.remote_addr)
current_app.logger.info(f'API user `{user.user_id}` log in')
return success_return({'token': user.api_token, 'user_id': user.user_id})
@bp.route('', methods=['GET'])
@role_required(request, ['select_me', 'select'])
@api_try
def token_get(user):
'''判断登录有效性'''
return success_return()
@bp.route('', methods=['DELETE'])
@role_required(request, ['change_me', 'select_me', 'select'])
@api_try
def token_delete(user):
'''登出'''
with Connect() as c:
user.c = c
user.logout()
return success_return()

View File

@@ -1,99 +1,233 @@
from server.sql import Connect
from server.sql import Sql
import time
import web.webscore
import server.info
from flask import Blueprint, request
from core.api_user import APIUser
from core.config_manager import Config
from core.error import InputError, NoAccess, NoData
from core.score import Potential, UserScoreList
from core.sql import Connect, Query, Sql
from core.user import UserChanger, UserInfo, UserRegister
from core.util import get_today_timestamp
from .api_auth import api_try, request_json_handle, role_required
from .api_code import error_return, success_return
from .constant import Constant
bp = Blueprint('users', __name__, url_prefix='/users')
def get_users(query=None):
# 获取全用户信息,返回字典列表
r = []
@bp.route('', methods=['POST'])
@role_required(request, ['change'])
@request_json_handle(request, ['name', 'password', 'email'])
@api_try
def users_post(data, user):
'''注册一个用户'''
with Connect() as c:
x = Sql.select(c, 'user', [], query)
if x:
for i in x:
if i[23] != -1:
character_id = i[23]
else:
character_id = i[6]
r.append({
'user_id': i[0],
'name': i[1],
'join_date': i[3],
'user_code': i[4],
'rating_ptt': i[5]/100,
'character_id': character_id,
'is_char_uncapped': i[8] == 1,
'is_char_uncapped_override': i[9] == 1,
'is_hide_rating': i[10],
'ticket': i[26]
})
return r
new_user = UserRegister(c)
new_user.set_name(data['name'])
new_user.set_password(data['password'])
new_user.set_email(data['email'])
new_user.register()
return success_return({'user_id': new_user.user_id, 'user_code': new_user.user_code})
def get_user_info(user_id):
# 获取用户信息返回字典其实就是调用user/me信息
r = {}
@bp.route('', methods=['GET'])
@role_required(request, ['select'])
@request_json_handle(request, optional_keys=Constant.QUERY_KEYS)
@api_try
def users_get(data, user):
'''查询全用户信息'''
A = ['user_id', 'name', 'user_code']
B = ['user_id', 'name', 'user_code', 'join_date',
'rating_ptt', 'time_played', 'ticket', 'world_rank_score']
with Connect() as c:
r = server.info.get_user_me(c, user_id)
query = Query(A, A, B).from_dict(data)
x = Sql(c).select('user', query=query)
r = []
for i in x:
r.append(UserInfo(c).from_list(i))
return r
if not r:
raise NoData(api_error_code=-2)
return success_return([{
'user_id': x.user_id,
'name': x.name,
'join_date': x.join_date,
'user_code': x.user_code,
'rating_ptt': x.rating_ptt,
'character_id': x.character.character_id,
'is_char_uncapped': x.character.is_uncapped,
'is_char_uncapped_override': x.character.is_uncapped_override,
'is_hide_rating': x.is_hide_rating,
'ticket': x.ticket
} for x in r])
def get_user_b30(user_id):
# 获取用户b30信息返回字典
@bp.route('/<int:user_id>', methods=['GET'])
@role_required(request, ['select', 'select_me'])
@api_try
def users_user_get(user, user_id):
'''查询用户信息'''
if user_id <= 0:
return error_return(InputError(api_error_code=-110))
# 查别人需要select权限
if user_id != user.user_id and not user.role.has_power('select'):
return error_return(NoAccess('No permission', api_error_code=-1), 403)
r = []
with Connect() as c:
r = web.webscore.get_user_score(c, user_id, 30)
bestptt = 0
for i in r:
if i['rating']:
bestptt += i['rating']
if 'time_played' in i:
i['time_played'] = int(time.mktime(time.strptime(
i['time_played'], '%Y-%m-%d %H:%M:%S')))
return {'user_id': user_id, 'b30_ptt': bestptt / 30, 'data': r}
u = UserInfo(c, user_id)
return success_return(u.to_dict())
def get_user_best(user_id, query=None):
# 获取用户b30信息返回字典
r = []
@bp.route('/<int:user_id>', methods=['PUT'])
@role_required(request, ['change'])
@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):
'''修改一个用户'''
with Connect() as c:
x = Sql.select(c, 'best_score', [], query)
if x:
for i in x:
r.append({
"song_id": i[1],
"difficulty": i[2],
"score": i[3],
"shiny_perfect_count": i[4],
"perfect_count": i[5],
"near_count": i[6],
"miss_count": i[7],
"health": i[8],
"modifier": i[9],
"time_played": i[10],
"best_clear_type": i[11],
"clear_type": i[12],
"rating": i[13]
})
return {'user_id': user_id, 'data': r}
u = UserChanger(c, user_id)
r = {}
r['user_id'] = user_id
if 'name' in data:
u.set_name(data['name'])
r['name'] = u.name
if 'password' in data:
if data['password'] == '':
u.password = ''
r['password'] = ''
else:
u.set_password(data['password'])
r['password'] = u.hash_pwd
if 'email' in data:
u.set_email(data['email'])
r['email'] = u.email
if 'user_code' in data:
u.set_user_code(data['user_code'])
r['user_code'] = u.user_code
if 'ticket' in data:
if not isinstance(data['ticket'], int):
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)
def get_user_r30(user_id):
# 获取用户r30信息返回字典
@bp.route('/<int:user_id>/b30', methods=['GET'])
@role_required(request, ['select', 'select_me'])
@api_try
def users_user_b30_get(user, user_id):
'''查询用户b30'''
if user_id <= 0:
return error_return(InputError(api_error_code=-110))
# 查别人需要select权限
if user_id != user.user_id and not user.role.has_power('select'):
return error_return(NoAccess('No permission', api_error_code=-1), 403)
r = []
with Connect() as c:
r, r10_ptt = web.webscore.get_user_recent30(c, user_id)
x = UserScoreList(c, UserInfo(c, user_id))
x.query.limit = 30
x.select_from_user()
if not x.scores:
raise NoData(
f'No best30 data of user `{user_id}`', api_error_code=-3)
x.select_song_name()
r = x.to_dict_list()
rating_sum = sum(i.rating for i in x.scores)
return success_return({'user_id': user_id, 'b30_ptt': rating_sum / 30, 'data': r})
return {'user_id': user_id, 'r10_ptt': r10_ptt, 'data': r}
@bp.route('/<int:user_id>/best', methods=['GET'])
@role_required(request, ['select', 'select_me'])
@request_json_handle(request, optional_keys=Constant.QUERY_KEYS)
@api_try
def users_user_best_get(data, user, user_id):
'''查询用户所有best成绩'''
if user_id <= 0:
return error_return(InputError(api_error_code=-110))
# 查别人需要select权限
if user_id != user.user_id and not user.role.has_power('select'):
return error_return(NoAccess('No permission', api_error_code=-1), 403)
with Connect() as c:
x = UserScoreList(c, UserInfo(c, user_id))
x.query.from_dict(data)
x.select_from_user()
if not x.scores:
raise NoData(
f'No best score data of user `{user_id}`', api_error_code=-3)
r = x.to_dict_list()
return success_return({'user_id': user_id, 'data': r})
@bp.route('/<int:user_id>/r30', methods=['GET'])
@role_required(request, ['select', 'select_me'])
@api_try
def users_user_r30_get(user, user_id):
'''查询用户r30'''
if user_id <= 0:
return error_return(InputError(api_error_code=-110))
# 查别人需要select权限
if user_id != user.user_id and not user.role.has_power('select'):
return error_return(NoAccess('No permission', api_error_code=-1), 403)
with Connect() as c:
p = Potential(c, UserInfo(c, user_id))
return success_return({'user_id': user_id, 'r10_ptt': p.recent_10 / 10, 'data': p.recent_30_to_dict_list()})
@bp.route('/<int:user_id>/role', methods=['GET'])
@role_required(request, ['select', 'select_me'])
@api_try
def users_user_role_get(user, user_id):
'''查询用户role和powers'''
if user_id <= 0:
return error_return(InputError(api_error_code=-110))
if user_id == user.user_id:
return success_return({'user_id': user.user_id, 'role': user.role.role_id, 'powers': [i.power_id for i in user.role.powers]})
# 查别人需要select权限
if not user.role.has_power('select'):
return error_return(NoAccess('No permission', api_error_code=-1), 403)
with Connect() as c:
x = APIUser(c, user_id)
x.select_role_and_powers()
return success_return({'user_id': x.user_id, 'role': x.role.role_id, 'powers': [i.power_id for i in x.role.powers]})
@bp.route('/<int:user_id>/rating', methods=['GET'])
@role_required(request, ['select', 'select_me'])
@request_json_handle(request, optional_keys=['start_timestamp', 'end_timestamp', 'duration'])
@api_try
def users_user_rating_get(data, user, user_id):
'''查询用户历史rating`duration`是相对于今天的天数'''
# 查别人需要select权限
if user_id != user.user_id and not user.role.has_power('select'):
return error_return(NoAccess('No permission', api_error_code=-1), 403)
start_timestamp = data.get('start_timestamp', None)
end_timestamp = data.get('end_timestamp', None)
duration = data.get('duration', None)
sql = '''select time, rating_ptt from user_rating where user_id = ?'''
sql_data = [user_id]
if start_timestamp is not None and end_timestamp is not None:
sql += ''' and time between ? and ?'''
sql_data += [start_timestamp, end_timestamp]
elif duration is not None:
sql += ''' and time between ? and ?'''
t = get_today_timestamp()
sql_data += [t - duration * 24 * 3600, t]
with Connect(Config.SQLITE_LOG_DATABASE_PATH) as c:
c.execute(sql, sql_data)
r = c.fetchall()
return success_return({'user_id': user_id, 'data': [{'time': i[0], 'rating_ptt': i[1]} for i in r]})

View File

@@ -1,6 +1,7 @@
class Config():
'''
This is the setting file. You can change some parameters here.
This is the example setting file.
The user's setting file's name is `config.py`.
'''
'''
@@ -9,7 +10,7 @@ class Config():
Host and port of your server
'''
HOST = '0.0.0.0'
PORT = '80'
PORT = 80
'''
--------------------
'''
@@ -19,7 +20,7 @@ class Config():
游戏API地址前缀
Game API's URL prefix
'''
GAME_API_PREFIX = '/bridge/18'
GAME_API_PREFIX = '/join/21'
'''
--------------------
'''
@@ -30,29 +31,30 @@ class Config():
Allowed game versions
If it is blank, all are allowed.
'''
ALLOW_APPVERSION = ['3.5.3', '3.5.3c', '3.12.0', '3.12.0c']
ALLOW_APPVERSION = ['3.12.6', '3.12.6c',
'4.0.256', '4.0.256c', '4.1.0', '4.1.0c']
'''
--------------------
'''
'''
--------------------
联机功能的端口号若为空则默认不开启联机功能
Port of your link play server
If it is blank, link play will be unavailable.
联机功能相关设置请确保与Link Play服务器端的设置一致
Setting of your link play server
Please ensure that the settings on the side of Link Play server are consistent.
'''
UDP_PORT = '10900'
'''
--------------------
'''
'''
--------------------
联机功能地址留空则自动获取
Link Play address
If left blank, it will be obtained automatically.
'''
LINK_PLAY_HOST = '' # ***.com
# SET_LINKPLAY_SERVER_AS_SUB_PROCESS: 是否同时在本地启动Link Play服务器
# SET_LINKPLAY_SERVER_AS_SUB_PROCESS: If it is `True`, the link play server will run with the main server locally at the same time.
SET_LINKPLAY_SERVER_AS_SUB_PROCESS = True
# LINKPLAY_HOST: 对主服务器来说的Link Play服务器的地址
# LINKPLAY_HOST: The address of the linkplay server based on the main server. If it is blank, the link play feature will be disabled.
LINKPLAY_HOST = '0.0.0.0'
LINKPLAY_UDP_PORT = 10900
LINKPLAY_TCP_PORT = 10901
LINKPLAY_AUTHENTICATION = 'my_link_play_server'
# LINKPLAY_DISPLAY_HOST: 对客户端来说的Link Play服务器地址如果为空则自动获取
# LINKPLAY_DISPLAY_HOST: The address of the linkplay server based on the client. If it is blank, the host of link play server for the client will be obtained automatically.
LINKPLAY_DISPLAY_HOST = ''
'''
--------------------
'''
@@ -186,7 +188,8 @@ class Config():
是否记录详细的服务器日志
If recording detailed server logs is enabled
'''
ALLOW_LOG_INFO = False
ALLOW_INFO_LOG = False
ALLOW_WARNING_LOG = False
'''
--------------------
'''
@@ -247,18 +250,10 @@ class Config():
--------------------
是否强制使用全解锁云端存档
If forcing full unlocked cloud save is enabled
请注意当前对于最终结局的判定为固定在`Testify`解锁之前
Please note that the current setting of the finale state is before the unlock of `Testify`
'''
SAVE_FULL_UNLOCK = False
'''
--------------------
'''
'''
--------------------
是否使用最好的 10 条记录而不是最近的 30 条记录中较好的 10 来计算 PTT
Calculate PTT with best 10 instead of recent best 10
'''
USE_B10_AS_R10 = False
'''
--------------------
'''

View File

@@ -0,0 +1,152 @@
from hashlib import sha256
from os import urandom
from time import time
from .config_manager import Config
from .error import NoAccess, NoData, RateLimit, UserBan
from .limiter import ArcLimiter
from .user import UserOnline
class Power:
def __init__(self, c=None):
self.c = c
self.power_id: str = None
self.caption: str = None
@classmethod
def from_dict(cls, d: dict, c=None) -> 'Power':
p = cls(c)
p.power_id = d['power_id']
p.caption = d['caption']
return p
class Role:
def __init__(self, c=None):
self.c = c
self.role_id: str = None
self.caption: str = None
self.powers: list = None
def has_power(self, power_id: str) -> bool:
'''判断role是否有power'''
return any(power_id == i.power_id for i in self.powers)
def only_has_powers(self, power_ids: list, anti_power_ids: list = None) -> bool:
'''判断role是否全有power_ids里的power且没有anti_power_ids里的任何一个power'''
flags = [False] * len(power_ids)
if anti_power_ids is None:
anti_power_ids = []
for i in self.powers:
if i.power_id in anti_power_ids:
return False
for j, k in enumerate(power_ids):
if i.power_id == k:
flags[j] = True
return all(flags)
def select_from_id(self, role_id: int = None) -> 'Role':
'''用role_id查询role'''
if role_id is not None:
self.role_id = role_id
self.c.execute('''select caption from role where role_id = :a''',
{'a': self.role_id})
x = self.c.fetchone()
if x is None:
raise NoData(
f'The role `{self.role_id}` does not exist.', api_error_code=-200)
self.caption = x[0]
return self
def select_powers(self) -> None:
'''查询role的全部powers'''
self.powers = []
self.c.execute('''select * from power where power_id in (select power_id from role_power where role_id=:a)''', {
'a': self.role_id})
x = self.c.fetchall()
for i in x:
self.powers.append(Power.from_dict(
{'power_id': i[0], 'caption': i[1]}, self.c))
class APIUser(UserOnline):
limiter = ArcLimiter(Config.API_LOGIN_RATE_LIMIT, 'api_login')
def __init__(self, c=None, user_id=None) -> None:
super().__init__(c, user_id)
self.api_token: str = None
self.role: 'Role' = None
self.ip: str = None
def set_role_system(self) -> None:
'''设置为最高权限用户API接口'''
self.user_id = 0
self.role = Role(self.c)
self.role.role_id = 'system'
self.role.select_powers()
def select_role(self) -> None:
'''查询user的role'''
self.c.execute('''select role_id from user_role where user_id = :a''',
{'a': self.user_id})
x = self.c.fetchone()
self.role = Role(self.c)
if x is None:
# 默认role为user
self.role.role_id = 'user'
else:
self.role.role_id = x[0]
def select_role_and_powers(self) -> None:
'''查询user的role以及role的powers'''
self.select_role()
self.role.select_powers()
def select_user_id_from_api_token(self, api_token: str = None) -> None:
if api_token is not None:
self.api_token = api_token
self.c.execute('''select user_id from api_login where token = :token''', {
'token': self.api_token})
x = self.c.fetchone()
if x is None:
raise NoAccess('No token', api_error_code=-1)
self.user_id = x[0]
def logout(self) -> None:
self.c.execute(
'''delete from api_login where user_id=?''', (self.user_id,))
def login(self, name: str = None, password: str = None, ip: str = None) -> None:
if name is not None:
self.name = name
if password is not None:
self.password = password
if ip is not None:
self.ip = ip
if not self.limiter.hit(name):
raise RateLimit(
f'Too many login attempts of username {name}', api_error_code=-205)
self.c.execute('''select user_id, password from user where name = :a''', {
'a': self.name})
x = self.c.fetchone()
if x is None:
raise NoData(
f'The user `{self.name}` does not exist.', api_error_code=-201, status=401)
if x[1] == '':
raise UserBan(f'The user `{x[0]}` is banned.')
if self.hash_pwd != x[1]:
raise NoAccess(f'The password of user `{x[0]}` is incorrect.',
api_error_code=-201, status=401)
self.user_id = x[0]
now = int(time() * 1000)
self.api_token = sha256(
(str(self.user_id) + str(now)).encode("utf8") + urandom(8)).hexdigest()
self.logout()
self.c.execute('''insert into api_login values(?,?,?,?)''',
(self.user_id, self.api_token, now, self.ip))

View File

@@ -0,0 +1,50 @@
from atexit import register
from concurrent.futures import ThreadPoolExecutor
from .constant import Constant
from .sql import Connect
class BGTask:
executor = ThreadPoolExecutor(max_workers=1)
def __init__(self, func, *args, **kwargs):
self.future = self.executor.submit(func, *args, **kwargs)
def result(self):
return self.future.result()
def cancel(self) -> bool:
return self.future.cancel()
def done(self) -> bool:
return self.future.done()
@staticmethod
def shutdown(wait: bool = True):
BGTask.executor.shutdown(wait)
@register
def atexit():
BGTask.shutdown()
def logdb_execute_func(sql, *args, **kwargs):
with Connect(Constant.SQLITE_LOG_DATABASE_PATH) as c:
c.execute(sql, *args, **kwargs)
def logdb_execute_many_func(sql, *args, **kwargs):
with Connect(Constant.SQLITE_LOG_DATABASE_PATH) as c:
c.executemany(sql, *args, **kwargs)
def logdb_execute(sql: str, *args, **kwargs):
'''异步执行SQL日志库写入注意不会直接返回结果'''
return BGTask(logdb_execute_func, sql, *args, **kwargs)
def logdb_execute_many(sql: str, *args, **kwargs):
'''异步批量执行SQL日志库写入注意不会直接返回结果'''
return BGTask(logdb_execute_many_func, sql, *args, **kwargs)

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

@@ -0,0 +1,489 @@
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:
mid_level = 20
min_level = 1
def __init__(self) -> None:
self.max_level: int = None
self.level: int = None
self.exp: float = None
@property
def level_exp(self):
return Constant.LEVEL_STEPS[self.level]
def add_exp(self, exp_addition: float):
# 添加经验计算
exp = self.exp + exp_addition
if exp >= Constant.LEVEL_STEPS[self.max_level]:
self.exp = Constant.LEVEL_STEPS[self.max_level]
self.level = self.max_level
return None
a = []
b = []
for k, v in Constant.LEVEL_STEPS.items():
a.append(k)
b.append(v)
if exp < b[0]: # 向下溢出是异常状态不该被try捕获不然数据库无法回滚
raise ValueError('EXP value error.')
i = len(a) - 1
while exp < b[i]:
i -= 1
self.exp = exp
self.level = a[i]
class Skill:
def __init__(self) -> None:
self.skill_id: str = None
self.skill_id_uncap: str = None
self.skill_unlock_level: int = None
self.skill_requires_uncap: bool = None
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:
# by Lost-MSth
# 4/6859 = 0.00058317539
if level <= 10:
return 0.00058317539 * (level - 1) ** 3 * (value_20 - value_1) + value_1
return - 0.00058317539 * (20 - level) ** 3 * (value_20 - value_1) + value_20
# @staticmethod
# def _calc_char_value_20(level: int, stata, statb, lva=1, lvb=20) -> float:
# # 计算1~20级搭档数值的核心函数返回浮点数来自https://redive.estertion.win/arcaea/calc/
# n = [0, 0, 0.0005831753900000081, 0.004665403120000065, 0.015745735529959858, 0.03732322495992008, 0.07289692374980007, 0.12596588423968, 0.2000291587694801, 0.29858579967923987, 0.42513485930893946,
# 0.5748651406910605, 0.7014142003207574, 0.7999708412305152, 0.8740341157603029, 0.9271030762501818, 0.962676775040091, 0.9842542644700301, 0.9953345968799998, 0.9994168246100001, 1]
# e = n[lva] - n[lvb]
# a = stata - statb
# r = a / e
# d = stata - n[lva] * r
# return d + r * n[level]
@staticmethod
def _calc_char_value_30(level, stata, statb, lva=20, lvb=30):
# 计算21~30级搭档数值返回浮点数
return (level - lva) * (statb - stata) / (lvb - lva) + stata
def set_parameter(self, start: float = 0, mid: float = 0, end: float = 0):
self.start: float = start
self.mid: float = mid
self.end: float = end
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) + self.addition
if level.mid_level < level.level <= level.max_level:
return self._calc_char_value_30(level.level, self.mid, self.end) + self.addition
return 0
class Character(CollectionItemMixin):
database_table_name = None
collection_item_const = {
'name': 'character',
'table_name': 'char_item',
'table_primary_key': 'character_id',
'id_name': 'character_id',
'items_name': 'uncap_cores'
}
def __init__(self, c=None) -> None:
self.c = c
self.character_id: int = None
self.name: str = None
self.char_type: int = None
self.is_uncapped: bool = None
self.is_uncapped_override: bool = None
self.skill = Skill()
self.level = Level()
self.frag = CharacterValue()
self.prog = CharacterValue()
self.overdrive = CharacterValue()
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
def uncap_cores_to_dict(self):
return [x.to_dict(character_format=True) for x in self.uncap_cores]
@property
def is_uncapped_displayed(self) -> bool:
'''对外显示的uncap状态'''
return False if self.is_uncapped_override else self.is_uncapped
@property
def is_base_character(self) -> bool:
# 应该是只有对立这样
return self.character_id == 1
def to_dict(self, has_cores=False) -> dict:
# 用于API里游戏内的数据不太一样故不能super
r = {
'character_id': self.character_id,
'name': self.name,
'char_type': self.char_type,
'is_uncapped': self.is_uncapped,
'max_level': self.level.max_level,
'skill_id': self.skill.skill_id,
'skill_unlock_level': self.skill.skill_unlock_level,
'skill_requires_uncap': self.skill.skill_requires_uncap,
'skill_id_uncap': self.skill.skill_id_uncap,
'frag1': self.frag.start,
'frag20': self.frag.mid,
'frag30': self.frag.end,
'prog1': self.prog.start,
'prog20': self.prog.mid,
'prog30': self.prog.end,
'overdrive1': self.overdrive.start,
'overdrive20': self.overdrive.mid,
'overdrive30': self.overdrive.end,
}
if has_cores:
r['uncap_cores'] = self.uncap_cores_to_dict()
return r
def from_list(self, l: tuple) -> 'Character':
self.character_id = l[0]
self.name = l[1]
self.level.max_level = l[2]
self.frag.set_parameter(l[3], l[6], l[9])
self.prog.set_parameter(l[4], l[7], l[10])
self.overdrive.set_parameter(l[5], l[8], l[11])
self.skill.skill_id = l[12]
self.skill.skill_unlock_level = l[13]
self.skill.skill_requires_uncap = l[14] == 1
self.skill.skill_id_uncap = l[15]
self.char_type = l[16]
self.is_uncapped = l[17] == 1
return self
def select(self, character_id: int = None) -> 'Character':
if character_id is not None:
self.character_id = character_id
self.c.execute(
'select * from character where character_id = ?', (self.character_id,))
x = self.c.fetchone()
if not x:
raise NoData(
f'No such character: {self.character_id}', api_error_code=-130)
return self.from_list(x)
def update(self) -> None:
self.c.execute('''update character set name = ?, max_level = ?, frag1 = ?, frag20 = ?, frag30 = ?, prog1 = ?, prog20 = ?, prog30 = ?, overdrive1 = ?, overdrive20 = ?, overdrive30 = ?, skill_id = ?, skill_unlock_level = ?, skill_requires_uncap = ?, skill_id_uncap = ?, char_type = ?, is_uncapped = ? where character_id = ?''', (
self.name, self.level.max_level, self.frag.start, self.frag.mid, self.frag.end, self.prog.start, self.prog.mid, self.prog.end, self.overdrive.start, self.overdrive.mid, self.overdrive.end, self.skill.skill_id, self.skill.skill_unlock_level, self.skill.skill_requires_uncap, self.skill.skill_id_uncap, self.char_type, self.is_uncapped, self.character_id))
def select_character_core(self):
# 获取此角色所需核心
self.c.execute(
'''select item_id, amount from char_item where character_id = ? and type="core"''', (self.character_id,))
x = self.c.fetchall()
if x:
self.uncap_cores = []
for i in x:
self.uncap_cores.append(ItemCore(self.c, i[0], i[1]))
class UserCharacter(Character):
'''
用户角色类
property: `user` - `User`类或子类的实例
'''
database_table_name = 'user_char_full' if Config.CHARACTER_FULL_UNLOCK else 'user_char'
def __init__(self, c, character_id=None, user=None) -> None:
super().__init__()
self.c = c
self.character_id = character_id
self.user = user
self.skill_flag: bool = None
self.fatalis_is_limited: bool = False
@property
def skill_id_displayed(self) -> str:
'''对外显示的技能id'''
if self.is_uncapped_displayed and self.skill.skill_id_uncap:
return self.skill.skill_id_uncap
if self.skill.skill_id and self.level.level >= self.skill.skill_unlock_level:
return self.skill.skill_id
return None
@property
def skill_state(self) -> str:
if self.skill_id_displayed == 'skill_maya':
return 'add_random' if self.skill_flag else 'remove_random'
return None
def select_character_uncap_condition(self, user=None):
# parameter: user - User类或子类的实例
# 获取此角色的觉醒信息
if user:
self.user = user
self.c.execute(f'''select is_uncapped, is_uncapped_override from {self.database_table_name} where user_id = :a and character_id = :b''',
{'a': self.user.user_id, 'b': self.character_id})
x = self.c.fetchone()
if not x:
self.is_uncapped = False
self.is_uncapped_override = False
# raise NoData('The character of the user does not exist.')
else:
self.is_uncapped = x[0] == 1
self.is_uncapped_override = x[1] == 1
def select_character_info(self, user=None):
# parameter: user - User类或子类的实例
# 获取所给用户此角色信息
if user:
self.user = user
self.c.execute(f'''select * from {self.database_table_name} a,character b where a.user_id=? and a.character_id=b.character_id and a.character_id=?''',
(self.user.user_id, self.character_id))
y = self.c.fetchone()
if y is None:
raise NoData('The character of the user does not exist.')
self.name = y[8]
self.char_type = y[23]
self.is_uncapped = y[4] == 1
self.is_uncapped_override = y[5] == 1
self.level.level = y[2]
self.level.exp = y[3]
self.level.max_level = y[9]
self.frag.set_parameter(y[10], y[13], y[16])
self.prog.set_parameter(y[11], y[14], y[17])
self.overdrive.set_parameter(y[12], y[15], y[18])
self.skill.skill_id = y[19]
self.skill.skill_id_uncap = y[22]
self.skill.skill_unlock_level = y[20]
self.skill.skill_requires_uncap = y[21] == 1
self.skill_flag = y[6] == 1
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:
if self.char_type is None:
self.select_character_info(self.user)
r = {'base_character': self.is_base_character,
"is_uncapped_override": self.is_uncapped_override,
"is_uncapped": self.is_uncapped,
"uncap_cores": self.uncap_cores_to_dict(),
"char_type": self.char_type,
"skill_id_uncap": self.skill.skill_id_uncap,
"skill_requires_uncap": self.skill.skill_requires_uncap,
"skill_unlock_level": self.skill.skill_unlock_level,
"skill_id": self.skill.skill_id,
"overdrive": self.overdrive.get_value(self.level),
"prog": self.prog.get_value(self.level),
"frag": self.frag.get_value(self.level),
"level_exp": self.level.level_exp,
"exp": self.level.exp,
"level": self.level.level,
"name": self.name,
"character_id": self.character_id
}
if self.voice:
r['voice'] = self.voice
if self.character_id == 55:
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
x = self.skill_state
if x:
r['skill_state'] = x
return r
def change_uncap_override(self, user=None):
# parameter: user - User类或子类的实例
# 切换觉醒状态
if user:
self.user = user
self.c.execute(f'''select is_uncapped, is_uncapped_override from {self.database_table_name} where user_id = :a and character_id = :b''',
{'a': self.user.user_id, 'b': self.character_id})
x = self.c.fetchone()
if x is None or x[0] == 0:
raise ArcError('Unknown Error')
self.c.execute('''update user set is_char_uncapped_override = :a where user_id = :b''', {
'a': 1 if x[1] == 0 else 0, 'b': self.user.user_id})
self.c.execute(f'''update {self.database_table_name} set is_uncapped_override = :a where user_id = :b and character_id = :c''', {
'a': 1 if x[1] == 0 else 0, 'b': self.user.user_id, 'c': self.character_id})
self.is_uncapped_override = x[1] == 0
def character_uncap(self, user=None):
# parameter: user - User类或子类的实例
# 觉醒角色
if user:
self.user = user
if Config.CHARACTER_FULL_UNLOCK:
# 全解锁了你觉醒个鬼啊
raise ArcError('All characters are available.')
if not self.uncap_cores:
self.select_character_core()
if self.is_uncapped is None:
self.c.execute(
'''select is_uncapped from user_char where user_id=? and character_id=?''', (self.user.user_id, self.character_id))
x = self.c.fetchone()
if x and x[0] == 1:
raise ArcError('The character has been uncapped.')
elif self.is_uncapped:
raise ArcError('The character has been uncapped.')
for i in self.uncap_cores:
self.c.execute(
'''select amount from user_item where user_id=? and item_id=? and type="core"''', (self.user.user_id, i.item_id))
y = self.c.fetchone()
if i.amount > 0 and (not y or i.amount > y[0]):
raise ItemNotEnough('The cores are not enough.')
for i in self.uncap_cores:
i.user_claim_item(self.user, reverse=True)
self.c.execute('''update user_char set is_uncapped=1, is_uncapped_override=0 where user_id=? and character_id=?''',
(self.user.user_id, self.character_id))
self.is_uncapped = True
self.is_uncapped_override = False
def upgrade(self, user=None, exp_addition: float = 0) -> None:
# parameter: user - User类或子类的实例
# 升级角色
if user:
self.user = user
if exp_addition == 0:
return None
if Config.CHARACTER_FULL_UNLOCK:
# 全解锁了你升级个鬼啊
raise ArcError('All characters are available.')
if self.level.exp is None:
self.select_character_info(self.user)
if self.is_uncapped is None:
self.c.execute(
'''select is_uncapped from user_char where user_id=? and character_id=?''', (self.user.user_id, self.character_id))
x = self.c.fetchone()
if x:
self.is_uncapped = x[0] == 1
self.level.max_level = 30 if self.is_uncapped else 20
self.level.add_exp(exp_addition)
self.c.execute('''update user_char set level=?, exp=? where user_id=? and character_id=?''',
(self.level.level, self.level.exp, self.user.user_id, self.character_id))
def upgrade_by_core(self, user=None, core=None):
'''
以太之滴升级注意这里core.amount应该是负数
parameter: `user` - `User`类或子类的实例
`core` - `ItemCore`类或子类的实例
'''
if user:
self.user = user
if not core:
raise InputError('No `core_generic`.')
if core.item_id != 'core_generic':
raise ArcError('Core type error.')
if core.amount >= 0:
raise InputError(
'The amount of `core_generic` should be negative.')
core.user_claim_item(self.user)
self.upgrade(self.user, - core.amount * Constant.CORE_EXP)
def change_skill_state(self) -> None:
'''翻转技能状态,目前为 skill_miya 专用'''
self.skill_flag = not self.skill_flag
self.c.execute(f'''update {self.database_table_name} set skill_flag = ? where user_id = ? and character_id = ?''', (
1 if self.skill_flag else 0, self.user.user_id, self.character_id))
class UserCharacterList:
'''
用户拥有角色列表类
properties: `user` - `User`类或子类的实例
'''
database_table_name = 'user_char_full' if Config.CHARACTER_FULL_UNLOCK else 'user_char'
def __init__(self, c=None, user=None):
self.c = c
self.user = user
self.characters: list = []
def select_user_characters(self):
self.c.execute(
f'''select character_id from {self.database_table_name} where user_id=?''', (self.user.user_id,))
x = self.c.fetchall()
self.characters: list = []
if x:
for i in x:
self.characters.append(UserCharacter(self.c, i[0], self.user))
def select_characters_info(self):
for i in self.characters:
i.select_character_info(self.user)

View File

@@ -0,0 +1,121 @@
class Config:
'''
Default config
'''
HOST = '0.0.0.0'
PORT = 80
DEPLOY_MODE = 'flask_multithread'
USE_PROXY_FIX = False
USE_CORS = False
SONG_FILE_HASH_PRE_CALCULATE = True
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'
LINKPLAY_UDP_PORT = 10900
LINKPLAY_TCP_PORT = 10901
LINKPLAY_AUTHENTICATION = 'my_link_play_server'
LINKPLAY_DISPLAY_HOST = ''
LINKPLAY_TCP_SECRET_KEY = '1145141919810'
SSL_CERT = ''
SSL_KEY = ''
IS_APRILFOOLS = True
WORLD_RANK_MAX = 200
AVAILABLE_MAP = [] # list[str]
USERNAME = 'admin'
PASSWORD = 'admin'
SECRET_KEY = '1145141919810'
API_TOKEN = ''
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
ALLOW_SCORE_WITH_NO_SONG = True
ALLOW_INFO_LOG = False
ALLOW_WARNING_LOG = False
DEFAULT_MEMORIES = 0
UPDATE_WITH_NEW_CHARACTER_DATA = True
CHARACTER_FULL_UNLOCK = True
WORLD_SONG_FULL_UNLOCK = True
WORLD_SCENERY_FULL_UNLOCK = True
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:
@staticmethod
def load(config) -> None:
for k, v in config.__dict__.items():
if k.startswith('__') or k.endswith('__'):
continue
if hasattr(Config, k):
setattr(Config, k, v)

View File

@@ -0,0 +1,134 @@
from .config_manager import Config
ARCAEA_SERVER_VERSION = 'v2.12.1.8'
ARCAEA_DATABASE_VERSION = 'v2.12.1.8'
ARCAEA_LOG_DATBASE_VERSION = 'v1.1'
class Constant:
BAN_TIME = [1, 3, 7, 15, 31]
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
CORE_EXP = 250
LEVEL_STEPS = {1: 0, 2: 50, 3: 100, 4: 150, 5: 200, 6: 300, 7: 450, 8: 650, 9: 900, 10: 1200, 11: 1600, 12: 2100, 13: 2700, 14: 3400, 15: 4200, 16: 5100,
17: 6100, 18: 7200, 19: 8500, 20: 10000, 21: 11500, 22: 13000, 23: 14500, 24: 16000, 25: 17500, 26: 19000, 27: 20500, 28: 22000, 29: 23500, 30: 25000}
WORLD_VALUE_NAME_ENUM = ['frag', 'prog', 'over']
FREE_PACK_NAME = 'base'
SINGLE_PACK_NAME = 'single'
ETO_UNCAP_BONUS_PROGRESS = 7
LUNA_UNCAP_BONUS_PROGRESS = 7
AYU_UNCAP_BONUS_PROGRESS = 5
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
MY_RANK_MAX_LOCAL_POSITION = 5
MY_RANK_MAX_GLOBAL_POSITION = 9999
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 = 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
LINKPLAY_TCP_PORT = Config.LINKPLAY_TCP_PORT
LINKPLAY_UDP_PORT = Config.LINKPLAY_UDP_PORT
LINKPLAY_AUTHENTICATION = Config.LINKPLAY_AUTHENTICATION
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),
(0x02429E, 0x0449A4), (0x099C9C,
0x07CFB4), (0x0785BF, 0x019B2C),
(0x0EFF43, 0x0841BF), (0x07C88B,
0x0DE9FC), (0x000778, 0x064815),
(0x0E62E3, 0x079F02), (0x0188FE,
0x0923EB), (0x0E06CD, 0x0E1A26),
(0x00669E, 0x0C8BE1), (0x0BEB7A, 0x05D635), (0x040E6F,
0x0B465B), (0x0568EC, 0x07ED2B),
(0x189614, 0x00A3D2), (0x62D98D,
0x45E5CA), (0x6D8769, 0x473F0E),
(0x922E4F, 0x667D6C), (0x021F5C,
0x298839), (0x2A1201, 0x49FB7E),
(0x158B81, 0x8D905D), (0x2253A5,
0x7E7067), (0x3BEF79, 0x9368E9),
(0x00669E, 0x0C8BE1), (0x0BEB7A,
0x05D635), (0x040E6F, 0x0B465B),
(0x756276, 0x55CD57), (0x130055, 0x7010E7), (0x55E28D,
0x4477FB), (0x5E99CB, 0x81060E),
(0x7F43A4, 0x8FEC56), (0x69412F,
0x32735C), (0x8FF846, 0x14B5A1),
(0x8716BE, 0x5C78BE), (0x62ED0E,
0x348E4B), (0x4B20C8, 0x56E0C3),
(0x0AF6BC, 0x872441), (0x8825BC,
0x94B315), (0x792784, 0x5B2C8E),
(0x1AE3A7, 0x688E97), (0x0D630F,
0x06BE78), (0x792784, 0x5B2C8E),
(0x314869, 0x41CCC1), (0x311934, 0x24DD94), (0x190EDB,
0x33993D), (0x25F5C5, 0x15FAE6),
(0x18CA10, 0x1B761A), (0x51BE82,
0x120089), (0x51D3B6, 0x2C29A2),
(0x402075, 0x4A89B2), (0x00697B,
0x0E6497), (0x6D872D, 0x618AE7),
(0x3DC0BE, 0x4E2AC8), (0x8C6ACF,
0x9776CF), (0x84673B, 0x5CA060),
(0x4B05EC, 0x97FDFE), (0x207258,
0x02BB9B), (0x20A9EE, 0x1BA4BB),
(0x503D21, 0x6A41D0), (0x1C256C,
0x6DD3BC), (0x6E4E0C, 0x89FDAA), (0x3C5F95, 0x3BA786),
(0XFEA5, 0x2e4ca), (0X7BF653, 0x4befd11), (0X46BEA7B,
0x11d3684), (0X8BFB04, 0xa83d6c1),
(0X5D6FC5, 0xab97ef), (0X237206D, 0xdfef2), (0XA3DEE,
0x6CB300), (0XA35687B, 0xE456CDEA)
]
DATABASE_MIGRATE_TABLES = ['user', 'friend', 'best_score', 'recent30', 'user_world', 'item', 'user_item', 'purchase', 'purchase_item', 'user_save',
'login', 'present', 'user_present', 'present_item', 'redeem', 'user_redeem', 'redeem_item', 'api_login', 'chart', 'user_course', 'user_char', 'user_role']
LOG_DATABASE_MIGRATE_TABLES = ['cache', 'user_score', 'user_rating']
UPDATE_WITH_NEW_CHARACTER_DATA = Config.UPDATE_WITH_NEW_CHARACTER_DATA

View File

@@ -0,0 +1,301 @@
from .error import NoData
from .item import ItemFactory
from .song import Chart
class CourseChart(Chart):
def __init__(self, c=None, song_id: str = None, difficulty: int = None) -> None:
super().__init__(c, song_id, difficulty)
self.flag_as_hidden: bool = None
def from_dict(self, d: dict) -> 'CourseChart':
self.song_id = d['id']
self.difficulty = d['difficulty']
self.flag_as_hidden = d.get('flag_as_hidden', False)
return self
def to_dict(self) -> dict:
return {
'id': self.song_id,
'difficulty': self.difficulty,
'flag_as_hidden': self.flag_as_hidden
}
def insert_course_chart(self, course_id: str, song_index: int) -> None:
self.c.execute('insert into course_chart values (?,?,?,?,?)',
(course_id, self.song_id, self.difficulty, self.flag_as_hidden, song_index))
class Course:
def __init__(self, c=None) -> None:
self.c = c
self.course_id: str = None
self.course_name: str = None
self.dan_name: str = None
self.style: int = None
self.gauge_requirement: str = None
self.flag_as_hidden_when_requirements_not_met: bool = None
self.can_start: bool = None # 这有什么鬼用?
self.requirements: list = []
self.charts: list = [None, None, None, None]
self.items: list = []
def to_dict(self) -> dict:
if self.course_name is None:
self.select_course()
if not self.items:
self.select_course_item()
if not self.charts:
self.select_course_chart()
return {
'course_id': self.course_id,
'course_name': self.course_name,
'dan_name': self.dan_name,
'style': self.style,
'gauge_requirement': self.gauge_requirement,
'flag_as_hidden_when_requirements_not_met': self.flag_as_hidden_when_requirements_not_met,
'can_start': self.can_start,
'requirements': [{'value': x, 'type': 'course'} for x in self.requirements],
'songs': [x.to_dict() for x in self.charts],
'rewards': [str(x) for x in self.items]
}
def from_dict(self, d: dict) -> 'Course':
self.course_id = d['course_id']
self.course_name = d.get('course_name', '')
self.dan_name = d.get('dan_name', '')
self.style = d.get('style', 1)
self.gauge_requirement = d.get('gauge_requirement', 'default')
self.flag_as_hidden_when_requirements_not_met = d.get(
'flag_as_hidden_when_requirements_not_met', False)
self.can_start = d.get('can_start', True)
self.requirements = [x['value'] for x in d.get('requirements', [])]
self.charts = [CourseChart(self.c).from_dict(x)
for x in d.get('songs', [])]
self.items = [ItemFactory.from_str(x, self.c)
for x in d.get('rewards', [])]
return self
def from_list(self, l: list) -> 'Course':
self.course_id = l[0]
self.course_name = l[1]
self.dan_name = l[2]
self.style = l[3] if l[3] else 1
self.gauge_requirement = l[4] if l[4] else 'default'
self.flag_as_hidden_when_requirements_not_met = l[5] == 1
self.can_start = l[6] == 1
return self
def select_course(self, course_id: str = None) -> 'Course':
if course_id is not None:
self.course_id = course_id
self.c.execute(
'''select * from course where course_id = ?''', (self.course_id,))
x = self.c.fetchone()
if x is None:
raise NoData(f'The course `{self.course_id}` is not found.')
return self.from_list(x)
def select_course_chart(self) -> None:
self.c.execute(
'''select * from course_chart where course_id = ?''', (self.course_id,))
for i in self.c.fetchall():
self.charts[i[4]] = CourseChart(self.c).from_dict({
'id': i[1],
'difficulty': i[2],
'flag_as_hidden': i[3] == 1
})
def select_course_requirement(self) -> None:
self.c.execute(
'''select required_id from course_requirement where course_id = ?''', (self.course_id,))
self.requirements = [x[0] for x in self.c.fetchall()]
def select_course_item(self) -> None:
self.c.execute(
'''select * from course_item where course_id = ?''', (self.course_id,))
self.items = [ItemFactory.from_dict({
'item_id': x[1],
'type': x[2],
'amount': x[3] if x[3] else 1,
}, self.c) for x in self.c.fetchall()]
def insert_course(self) -> None:
self.c.execute(
'''insert into course values (?,?,?,?,?,?,?)''', (self.course_id, self.course_name, self.dan_name, self.style, self.gauge_requirement, self.flag_as_hidden_when_requirements_not_met, self.can_start))
def insert_course_item(self) -> None:
for i in self.items:
self.c.execute('''insert into course_item values (?,?,?,?)''',
(self.course_id, i.item_id, i.item_type, i.amount))
def insert_course_chart(self) -> None:
for i, x in enumerate(self.charts):
x.insert_course_chart(self.course_id, i)
def insert_course_requirement(self) -> None:
for i in self.requirements:
self.c.execute('''insert into course_requirement values (?,?)''',
(self.course_id, i))
def insert_all(self) -> None:
self.insert_course()
self.insert_course_item()
self.insert_course_chart()
self.insert_course_requirement()
class UserCourse(Course):
'''
用户课题类
parameter: `user` - `User`类或子类的实例
'''
def __init__(self, c=None, user=None) -> None:
super().__init__(c)
self.user = user
# 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.best_clear_type is None:
self.select_user_course()
r.update({
'is_completed': self.is_completed,
'high_score': self.high_score,
'best_clear_type': self.best_clear_type
})
return r
def select_user_course(self, course_id: str = None) -> None:
if course_id is not None:
self.course_id = course_id
self.c.execute('''select * from user_course where user_id = ? and course_id = ?''',
(self.user.user_id, self.course_id))
x = self.c.fetchone()
if x is None:
self.high_score = 0
self.best_clear_type = 0
else:
self.high_score = x[2]
self.best_clear_type = x[3]
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))
class UserCourseList:
'''
用户课题列表类
parameter: `user` - `User`类或子类的实例
'''
def __init__(self, c=None, user=None) -> None:
self.c = c
self.user = user
self.courses: list = []
def to_dict_list(self) -> list:
# 临时修复满足条件也无法解锁隐藏段位的问题
completed_course_id_list: list = []
r: list = []
for x in self.courses:
if x.is_completed:
completed_course_id_list.append(x.course_id)
r.append(x.to_dict())
for x in r:
for i in x['requirements']:
if i['value'] in completed_course_id_list:
i['is_fullfilled'] = True
return r
def select_all(self) -> None:
self.c.execute('''select * from course''')
self.courses = [UserCourse(self.c, self.user).from_list(x)
for x in self.c.fetchall()]
for i in self.courses:
i.select_user_course()
i.select_course_chart()
i.select_course_item()
i.select_course_requirement()
class CoursePlay(UserCourse):
'''
课题模式打歌类联动UserPlay
parameter: `user` - `UserOnline`类或子类的实例
'user_play` - `UserPlay`类的实例
'''
def __init__(self, c=None, user=None, user_play=None) -> None:
super().__init__(c, user)
self.user_play = user_play
self.score: int = None
self.clear_type: int = None
def to_dict(self) -> dict:
return {
'rewards': [x.to_dict() for x in self.items],
"current_stamina": self.user.stamina.stamina,
"max_stamina_ts": self.user.stamina.max_stamina_ts,
'user_course_banners': self.user.course_banners
}
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
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
self.user_play.update_play_state_for_course()
if self.user_play.course_play_state == 4:
self.user.select_user_about_stamina()
if not self.is_completed:
self.select_course_item()
for i in self.items:
i.user_claim_item(self.user)
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.insert_or_update_user_course()

View File

@@ -0,0 +1,354 @@
import os
from functools import lru_cache
from json import loads
from time import time
from flask import url_for
from .config_manager import Config
from .constant import Constant
from .error import NoAccess
from .limiter import ArcLimiter
from .user import User
from .util import get_file_md5, md5
@lru_cache(maxsize=8192)
def get_song_file_md5(song_id: str, file_name: str) -> str:
path = os.path.join(Constant.SONG_FILE_FOLDER_PATH, song_id, file_name)
if not os.path.isfile(path):
return None
return get_file_md5(path)
class SonglistParser:
'''songlist文件解析器'''
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: bitmap
pack_info: 'dict[str, set]' = {} # {pack_id: {song_id, ...}, ...}
free_songs: set = set() # {song_id, ...}
world_songs: set = set() # {world_song_id, ...}
def __init__(self, path=Constant.SONGLIST_FILE_PATH) -> None:
self.path = path
self.data: list = []
self.parse()
@staticmethod
def is_available_file(song_id: str, file_name: str) -> bool:
'''判断文件是否允许被下载'''
if song_id not in SonglistParser.songs:
# songlist没有则只限制文件名
return file_name in SonglistParser.FILE_NAMES
rule = SonglistParser.songs[song_id]
for i, v in enumerate(SonglistParser.FILE_NAMES):
if file_name == v and rule & (1 << i) != 0:
return True
return False
@staticmethod
def get_user_unlocks(user) -> set:
'''user: UserInfo类或子类的实例'''
x = SonglistParser
if user is None:
return set()
r = set()
for i in user.packs:
if i in x.pack_info:
r.update(x.pack_info[i])
if Constant.SINGLE_PACK_NAME in x.pack_info:
r.update(x.pack_info[Constant.SINGLE_PACK_NAME]
& set(user.singles))
r.update(set(i if i[-1] != '3' else i[:-1]
for i in (x.world_songs & set(user.world_songs))))
r.update(x.free_songs)
return r
def parse_one(self, song: dict) -> dict:
'''解析单个歌曲'''
# TODO: byd_local_unlock ???
if not 'id' in song:
return {}
r = 0
if 'remote_dl' in song and song['remote_dl']:
r |= 32
for i in song.get('difficulties', []):
if i['ratingClass'] == 3 and i.get('audioOverride', False):
r |= 64
r |= 1 << i['ratingClass']
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[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}
def parse_one_unlock(self, song: dict) -> None:
'''解析单个歌曲解锁方式'''
if not 'id' in song or not 'set' in song or not 'purchase' in song:
return {}
x = SonglistParser
if Constant.FREE_PACK_NAME == song['set']:
if any(i['ratingClass'] == 3 for i in song.get('difficulties', [])):
x.world_songs.add(song['id'] + '3')
x.free_songs.add(song['id'])
return None
if song.get('world_unlock', False):
x.world_songs.add(song['id'])
if song['purchase'] == '':
return None
x.pack_info.setdefault(song['set'], set()).add(song['id'])
def parse(self) -> None:
'''解析songlist文件'''
if not os.path.isfile(self.path):
return
with open(self.path, 'rb') as f:
self.data = loads(f.read()).get('songs', [])
self.has_songlist = True
for x in self.data:
self.songs.update(self.parse_one(x))
self.parse_one_unlock(x)
class UserDownload:
'''
用户下载类
properties: `user` - `UserInfo`类或子类的实例
'''
limiter = ArcLimiter(
str(Constant.DOWNLOAD_TIMES_LIMIT) + '/day', 'download')
def __init__(self, c_m=None, user=None) -> None:
self.c_m = c_m
self.user = user
self.song_id: str = None
self.file_name: str = None
self.token: str = None
self.token_time: int = None
@property
def is_limited(self) -> bool:
'''是否达到用户最大下载量'''
if self.user is None:
self.select_for_check()
return not self.limiter.test(str(self.user.user_id))
@property
def is_valid(self) -> bool:
'''链接是否有效且未过期'''
if self.token_time is None:
self.select_for_check()
return int(time()) - self.token_time <= Constant.DOWNLOAD_TIME_GAP_LIMIT
def download_hit(self) -> bool:
'''下载次数+1返回成功与否bool值'''
return self.limiter.hit(str(self.user.user_id))
def select_for_check(self) -> None:
'''利用token、song_id、file_name查询其它信息'''
self.c_m.execute('''select user_id, time from download_token where song_id=? and file_name=? and token = ? limit 1;''',
(self.song_id, self.file_name, self.token))
x = self.c_m.fetchone()
if not x:
raise NoAccess('The token `%s` is not valid.' %
self.token, status=403)
self.user = User()
self.user.user_id = x[0]
self.token_time = x[1]
def generate_token(self) -> None:
self.token_time = int(time())
self.token = md5(str(self.user.user_id) + self.song_id +
self.file_name + str(self.token_time) + str(os.urandom(8)))
def insert_download_token(self) -> None:
'''将数据插入数据库,让这个下载链接可用'''
self.c_m.execute('''insert or replace into download_token values(:a,:b,:c,:d,:e)''', {
'a': self.user.user_id, 'b': self.song_id, 'c': self.file_name, 'd': self.token, 'e': self.token_time})
@property
def url(self) -> str:
'''生成下载链接'''
if self.token is None:
self.generate_token()
# self.insert_download_token() # 循环插入速度慢改为executemany
if Constant.DOWNLOAD_LINK_PREFIX:
prefix = Constant.DOWNLOAD_LINK_PREFIX
if prefix[-1] != '/':
prefix += '/'
return f'{prefix}{self.song_id}/{self.file_name}?t={self.token}'
return url_for('download', file_path=f'{self.song_id}/{self.file_name}', t=self.token, _external=True)
@property
def hash(self) -> str:
return get_song_file_md5(self.song_id, self.file_name)
class DownloadList(UserDownload):
'''
下载列表类
properties: `user` - `User`类或子类的实例
'''
def __init__(self, c_m=None, user=None) -> None:
super().__init__(c_m, user)
self.song_ids: list = None
self.url_flag: bool = None
self.downloads: list = []
self.urls: dict = {}
@classmethod
def initialize_cache(cls) -> None:
'''初始化歌曲数据缓存包括md5、文件目录遍历、解析songlist'''
SonglistParser()
if Config.SONG_FILE_HASH_PRE_CALCULATE:
x = cls()
x.url_flag = False
x.add_songs()
del x
@staticmethod
def clear_all_cache() -> None:
'''清除所有歌曲文件有关缓存'''
get_song_file_md5.cache_clear()
DownloadList.get_one_song_file_names.cache_clear()
DownloadList.get_all_song_ids.cache_clear()
SonglistParser.songs = {}
SonglistParser.pack_info = {}
SonglistParser.free_songs = set()
SonglistParser.world_songs = set()
SonglistParser.has_songlist = False
def clear_download_token(self) -> None:
'''清除过期下载链接'''
self.c_m.execute('''delete from download_token where time<?''',
(int(time()) - Constant.DOWNLOAD_TIME_GAP_LIMIT,))
def insert_download_tokens(self) -> None:
'''插入所有下载链接'''
self.c_m.executemany('''insert or replace into download_token values(?,?,?,?,?)''', [(
self.user.user_id, x.song_id, x.file_name, x.token, x.token_time) for x in self.downloads])
@staticmethod
@lru_cache(maxsize=2048)
def get_one_song_file_names(song_id: str) -> list:
'''获取一个歌曲文件夹下的所有合法文件名有lru缓存'''
r = []
for i in os.scandir(os.path.join(Constant.SONG_FILE_FOLDER_PATH, song_id)):
file_name = i.name
if i.is_file() and SonglistParser.is_available_file(song_id, file_name):
r.append(file_name)
return r
def add_one_song(self, song_id: str) -> None:
re = {}
for i in self.get_one_song_file_names(song_id):
x = UserDownload(self.c_m, self.user)
self.downloads.append(x)
x.song_id = song_id
x.file_name = i
if i == 'base.ogg':
if 'audio' not in re:
re['audio'] = {}
re['audio']["checksum"] = x.hash
if self.url_flag:
re['audio']["url"] = x.url
elif i == '3.ogg':
if 'audio' not in re:
re['audio'] = {}
if self.url_flag:
re['audio']['3'] = {"checksum": x.hash, "url": x.url}
else:
re['audio']['3'] = {"checksum": x.hash}
elif i in ('video.mp4', 'video_audio.ogg', 'video_720.mp4', 'video_1080.mp4'):
if 'additional_files' not in re:
re['additional_files'] = []
if self.url_flag:
re['additional_files'].append(
{"checksum": x.hash, "url": x.url, 'file_name': i})
else:
re['additional_files'].append(
{"checksum": x.hash, 'file_name': i})
# 有参数 requirement 作用未知
else:
if 'chart' not in re:
re['chart'] = {}
if self.url_flag:
re['chart'][i[0]] = {"checksum": x.hash, "url": x.url}
else:
re['chart'][i[0]] = {"checksum": x.hash}
self.urls.update({song_id: re})
@staticmethod
@lru_cache()
def get_all_song_ids() -> list:
'''获取全歌曲文件夹列表有lru缓存'''
return [i.name for i in os.scandir(Constant.SONG_FILE_FOLDER_PATH) if i.is_dir()]
def add_songs(self, song_ids: list = None) -> None:
'''添加一个或多个歌曲到下载列表,若`song_ids`为空,则添加所有歌曲'''
if song_ids is not None:
self.song_ids = song_ids
if not self.song_ids:
self.song_ids = self.get_all_song_ids()
if Config.DOWNLOAD_FORBID_WHEN_NO_ITEM and SonglistParser.has_songlist:
# 没有歌曲时不允许下载
self.song_ids = list(SonglistParser.get_user_unlocks(
self.user) & set(self.song_ids))
for i in self.song_ids:
self.add_one_song(i)
else:
if Config.DOWNLOAD_FORBID_WHEN_NO_ITEM and SonglistParser.has_songlist:
# 没有歌曲时不允许下载
self.song_ids = list(SonglistParser.get_user_unlocks(
self.user) & set(self.song_ids))
for i in self.song_ids:
if os.path.isdir(os.path.join(Constant.SONG_FILE_FOLDER_PATH, i)):
self.add_one_song(i)
if self.url_flag:
self.clear_download_token()
self.insert_download_tokens()

View File

@@ -0,0 +1,120 @@
class ArcError(Exception):
def __init__(self, message=None, error_code=108, api_error_code=-999, extra_data=None, status=200) -> None:
self.message: str = message
self.error_code: int = error_code
self.api_error_code: int = api_error_code
self.extra_data = extra_data
self.status: int = status
def __str__(self) -> str:
return repr(self.message)
class InputError(ArcError):
'''输入类型错误'''
def __init__(self, message=None, error_code=108, api_error_code=-100, extra_data=None, status=200) -> None:
super().__init__(message, error_code, api_error_code, extra_data, status)
class DataExist(ArcError):
'''数据存在'''
def __init__(self, message=None, error_code=108, api_error_code=-4, extra_data=None, status=200) -> None:
super().__init__(message, error_code, api_error_code, extra_data, status)
class NoData(ArcError):
'''数据不存在'''
def __init__(self, message=None, error_code=108, api_error_code=-2, extra_data=None, status=200) -> None:
super().__init__(message, error_code, api_error_code, extra_data, status)
class PostError(ArcError):
'''缺少输入'''
def __init__(self, message=None, error_code=108, api_error_code=-100, extra_data=None, status=200) -> None:
super().__init__(message, error_code, api_error_code, extra_data, status)
class UserBan(ArcError):
'''用户封禁'''
def __init__(self, message=None, error_code=121, api_error_code=-202, extra_data=None, status=200) -> None:
super().__init__(message, error_code, api_error_code, extra_data, status)
class ItemNotEnough(ArcError):
'''物品数量不足'''
def __init__(self, message=None, error_code=-6, api_error_code=-999, extra_data=None, status=200) -> None:
super().__init__(message, error_code, api_error_code, extra_data, status)
class ItemUnavailable(ArcError):
'''物品不可用'''
def __init__(self, message=None, error_code=-6, api_error_code=-999, extra_data=None, status=200) -> None:
super().__init__(message, error_code, api_error_code, extra_data, status)
class RedeemUnavailable(ArcError):
'''兑换码不可用'''
def __init__(self, message=None, error_code=505, api_error_code=-999, extra_data=None, status=200) -> None:
super().__init__(message, error_code, api_error_code, extra_data, status)
class MapLocked(ArcError):
'''地图锁定'''
def __init__(self, message=None, error_code=108, api_error_code=-999, extra_data=None, status=200) -> None:
super().__init__(message, error_code, api_error_code, extra_data, status)
class StaminaNotEnough(ArcError):
'''体力不足'''
def __init__(self, message=None, error_code=107, api_error_code=-999, extra_data=None, status=200) -> None:
super().__init__(message, error_code, api_error_code, extra_data, status)
class TicketNotEnough(ArcError):
'''记忆源点不足'''
def __init__(self, message=None, error_code=-6, api_error_code=-999, extra_data=None, status=200) -> None:
super().__init__(message, error_code, api_error_code, extra_data, status)
class FriendError(ArcError):
'''好友系统出错'''
def __init__(self, message=None, error_code=108, api_error_code=-999, extra_data=None, status=200) -> None:
super().__init__(message, error_code, api_error_code, extra_data, status)
class NoAccess(ArcError):
'''无权限'''
def __init__(self, message=None, error_code=108, api_error_code=-999, extra_data=None, status=403) -> None:
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
class RateLimit(ArcError):
'''频率达到限制'''
def __init__(self, message=None, error_code=108, api_error_code=-999, extra_data=None, status=429) -> None:
super().__init__(message, error_code, api_error_code, extra_data, status)

368
latest version/core/init.py Normal file
View File

@@ -0,0 +1,368 @@
import os
import sys
from importlib import import_module
from json import load
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_DATABASE_VERSION, ARCAEA_LOG_DATBASE_VERSION
from core.course import Course
from core.download import DownloadList
from core.purchase import Purchase
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:
def __init__(self, db_path: str = Config.SQLITE_DATABASE_PATH, init_folder_path: str = Config.DATABASE_INIT_PATH) -> None:
self.db_path = db_path
self.init_folder_path = init_folder_path
self.c = None
self.init_data = None
@property
def sql_path(self) -> str:
return os.path.join(self.init_folder_path, 'tables.sql')
@property
def course_path(self) -> str:
return os.path.join(self.init_folder_path, 'courses.json')
@property
def pack_path(self) -> str:
return os.path.join(self.init_folder_path, 'packs.json')
@property
def single_path(self) -> str:
return os.path.join(self.init_folder_path, 'singles.json')
def table_init(self) -> None:
'''初始化数据库结构'''
with open(self.sql_path, 'r', encoding='utf-8') as f:
self.c.executescript(f.read())
self.c.execute('''insert into config values("version", :a);''', {
'a': ARCAEA_DATABASE_VERSION})
def character_init(self) -> None:
'''初始化搭档信息'''
uncapped_characters = self.init_data.char_core.keys()
for i in range(0, len(self.init_data.char)):
skill_requires_uncap = 1 if i == 2 else 0
if i in uncapped_characters:
max_level = 30
uncapable = 1
else:
max_level = 20
uncapable = 0
sql = '''insert into character values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'''
self.c.execute(sql, (i, self.init_data.char[i], max_level, self.init_data.frag1[i], self.init_data.prog1[i], self.init_data.overdrive1[i], self.init_data.frag20[i], self.init_data.prog20[i], self.init_data.overdrive20[i],
self.init_data.frag30[i], self.init_data.prog30[i], self.init_data.overdrive30[i], self.init_data.skill_id[i], self.init_data.skill_unlock_level[i], skill_requires_uncap, self.init_data.skill_id_uncap[i], self.init_data.char_type[i], uncapable))
self.c.execute('''insert into character values(?,?,20,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)''', (
99, 'shirahime', 38, 33, 28, 66, 58, 50, 66, 58, 50, 'frags_preferred_song', 0, 0, '', 0))
for i in self.init_data.char_core:
self.c.executemany('''insert into char_item values(?,?,'core',?)''', [
(i, j['core_id'], j['amount']) for j in self.init_data.char_core[i]])
def insert_purchase_item(self, purchases: list):
'''处理singles和packs'''
for i in purchases:
x = Purchase(self.c).from_dict(i)
x.insert_all()
def item_init(self) -> None:
'''初始化物品信息'''
self.c.executemany('''insert into item values(?,"core",1)''',
[(i,) for i in self.init_data.cores])
self.c.executemany('''insert into item values(?,"world_song",1)''', [
(i,) for i in self.init_data.world_songs])
self.c.executemany('''insert into item values(?,"world_unlock",1)''', [
(i,) for i in self.init_data.world_unlocks])
self.c.executemany('''insert into item values(?,"course_banner",1)''', [
(i,) for i in self.init_data.course_banners])
self.c.execute('''insert into item values(?,?,?)''',
('fragment', 'fragment', 1))
self.c.execute('''insert into item values(?,?,?)''',
('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))
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 = []
with open(self.course_path, 'rb') as f:
courses = load(f)
for i in courses:
x = Course(self.c).from_dict(i)
x.insert_all()
def role_power_init(self) -> None:
'''初始化power和role'''
self.c.executemany('''insert into role values(?,?)''', [(
self.init_data.role[i], self.init_data.role_caption[i]) for i in range(len(self.init_data.role))])
self.c.executemany('''insert into power values(?,?)''', [(
self.init_data.power[i], self.init_data.power_caption[i]) for i in range(len(self.init_data.power))])
for i in self.init_data.role_power:
self.c.executemany('''insert into role_power values(?,?)''', [
(i, j) for j in self.init_data.role_power[i]])
def admin_init(self) -> None:
'''初始化测试账号'''
x = UserRegister(self.c)
x.user_code = '123456789'
x.user_id = 2000000
x.name = 'admin'
x.email = 'admin@admin.com'
now = int(time() * 1000)
x._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': 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 user_role values(?, "admin")''', (x.user_id,))
def init(self) -> None:
sys.path.append(os.path.join(sys.path[0], self.init_folder_path))
self.init_data = import_module('arc_data').InitData
with Connect(self.db_path) as c:
self.c = c
self.table_init()
self.character_init()
self.item_init()
self.course_init()
self.role_power_init()
self.admin_init()
class LogDatabaseInit:
def __init__(self, db_path: str = Config.SQLITE_LOG_DATABASE_PATH, init_folder_path: str = Config.DATABASE_INIT_PATH) -> None:
self.db_path = db_path
self.init_folder_path = init_folder_path
self.c = None
@property
def sql_path(self) -> str:
return os.path.join(self.init_folder_path, 'log_tables.sql')
def table_init(self) -> None:
'''初始化数据库结构'''
with open(self.sql_path, 'r') as f:
self.c.executescript(f.read())
self.c.execute(
'''insert into cache values("version", :a, -1);''', {'a': ARCAEA_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:
self.c = c
self.table_init()
class FileChecker:
'''文件检查及初始化类'''
def __init__(self, logger=None):
self.logger = logger
def check_file(self, file_path: str) -> bool:
f = os.path.isfile(file_path)
if not f:
self.logger.warning('File `%s` is missing.' % file_path)
return f
def check_folder(self, folder_path: str) -> bool:
f = os.path.isdir(folder_path)
if not f:
self.logger.warning('Folder `%s` is missing.' % folder_path)
return f
def _check_update_database_log(self) -> bool:
if not self.check_file(Config.SQLITE_LOG_DATABASE_PATH):
# 新建日志数据库
try:
self.logger.info(
f'Try to new the file {Config.SQLITE_LOG_DATABASE_PATH}')
LogDatabaseInit().init()
self.logger.info(
f'Success to new the file {Config.SQLITE_LOG_DATABASE_PATH}')
except Exception as e:
self.logger.error(format_exc())
self.logger.error(
f'Failed to new the file {Config.SQLITE_LOG_DATABASE_PATH}')
return False
else:
# 检查更新
with Connect(Config.SQLITE_LOG_DATABASE_PATH) as c:
try:
x = c.execute(
'''select value from cache where key="version"''').fetchone()
except:
x = None
if not x or x[0] != ARCAEA_LOG_DATBASE_VERSION:
self.logger.warning(
f'Maybe the file `{Config.SQLITE_LOG_DATABASE_PATH}` is an old version.')
try:
self.logger.info(
f'Try to update the file `{Config.SQLITE_LOG_DATABASE_PATH}`')
self.update_log_database()
self.logger.info(
f'Success to update the file `{Config.SQLITE_LOG_DATABASE_PATH}`')
except Exception as e:
self.logger.error(format_exc())
self.logger.error(
f'Failed to update the file `{Config.SQLITE_LOG_DATABASE_PATH}`')
return False
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(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(f'Failed to new the file `{db_path}`.')
return False
else:
# 检查更新
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_DATABASE_VERSION:
self.logger.warning(
f'Maybe the file `{db_path}` is an old version. Version: {x[0] if x else "None"}')
try:
self.logger.info(
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(db_path, os.path.join(
Config.SQLITE_DATABASE_BACKUP_FOLDER_PATH, os.path.split(db_path)[-1] + '.bak'))
try:
copy2(backup_path, db_path)
except:
copy(backup_path, db_path)
temp_path = os.path.join(
*os.path.split(db_path)[:-1], 'old_arcaea_database.db')
if os.path.isfile(temp_path):
os.remove(temp_path)
try_rename(db_path, temp_path)
init_class().init()
self.update_database(temp_path, db_path)
self.logger.info(
f'Success to update the file `{db_path}`.')
except Exception as e:
self.logger.error(format_exc())
self.logger.warning(
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:
'''更新数据库,并删除旧文件'''
if os.path.isfile(old_path) and os.path.isfile(new_path):
DatabaseMigrator(old_path, new_path).update_database()
os.remove(old_path)
@staticmethod
def update_log_database(old_path: str = Config.SQLITE_LOG_DATABASE_PATH) -> None:
'''直接更新日志数据库'''
if os.path.isfile(old_path):
LogDatabaseMigrator(old_path).update_database()
def check_song_file(self) -> bool:
'''检查song有关文件并初始化缓存'''
f = self.check_folder(Config.SONG_FILE_FOLDER_PATH)
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.')
except Exception as e:
self.logger.error(format_exc())
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() and self.check_content_bundle() and self.check_update_database() and self.check_world_map()

526
latest version/core/item.py Normal file
View File

@@ -0,0 +1,526 @@
from .config_manager import Config
from .error import (DataExist, InputError, ItemNotEnough, ItemUnavailable,
NoData)
class Item:
item_type = None
def __init__(self, c=None) -> None:
self.item_id = None
self.__amount = None
self.is_available = None
self.c = c
def __eq__(self, other: 'Item') -> bool:
return self.item_id == other.item_id and self.item_type == other.item_type
@property
def amount(self):
return self.__amount
@amount.setter
def amount(self, value: int):
self.__amount = int(value)
def to_dict(self, has_is_available: bool = False, has_amount: bool = True) -> dict:
r = {
'id': self.item_id,
'type': self.item_type
}
if has_amount:
r['amount'] = self.amount
if has_is_available:
r['is_available'] = self.is_available
return r
def user_claim_item(self, user):
# parameter: user - User类或子类的实例
pass
def select_exists(self):
self.c.execute('''select exists(select * from item where item_id=? and type=?)''',
(self.item_id, self.item_type))
return bool(self.c.fetchone()[0])
def insert(self, ignore: bool = False):
sql = '''insert into item values(?,?,?)''' if not ignore else '''insert or ignore into item values(?,?,?)'''
self.c.execute(sql, (self.item_id, self.item_type, self.is_available))
def delete(self):
self.c.execute('''delete from item where item_id=? and type=?''',
(self.item_id, self.item_type))
def update(self):
self.c.execute('''update item set is_available=? where item_id=? and type=?''',
(self.is_available, self.item_id, self.item_type))
def select(self):
self.c.execute('''select is_available from item where item_id=? and type=?''',
(self.item_id, self.item_type))
x = self.c.fetchone()
if not x:
raise NoData(
f'No such item `{self.item_type}`: `{self.item_id}`', api_error_code=-121)
self.is_available = x[0]
class UserItem(Item):
def __init__(self, c=None) -> None:
super().__init__()
self.c = c
self.user = None
def select_user_item(self, user=None):
'''
查询用户item
parameter: `user` - `User`类或子类的实例
'''
if user is not None:
self.user = user
self.c.execute('''select amount from user_item where user_id=? and item_id=? and type=?''',
(self.user.user_id, self.item_id, self.item_type))
x = self.c.fetchone()
if x:
self.amount = x[0] if x[0] is not None else 1
else:
self.amount = 0
class NormalItem(UserItem):
def __init__(self, c=None) -> None:
super().__init__()
self.c = c
def user_claim_item(self, user):
self.user = user
if not self.is_available:
self.c.execute(
'''select is_available from item where item_id=? and type=?''', (self.item_id, self.item_type))
x = self.c.fetchone()
if x:
if x[0] == 0:
self.is_available = False
raise ItemUnavailable('The item is unavailable.')
self.is_available = True
else:
raise NoData('No item data.')
self.c.execute('''select exists(select * from user_item where user_id=? and item_id=? and type=?)''',
(self.user.user_id, self.item_id, self.item_type))
if self.c.fetchone() == (0,):
self.c.execute('''insert into user_item values(:a,:b,:c,1)''',
{'a': self.user.user_id, 'b': self.item_id, 'c': self.item_type})
class PositiveItem(UserItem):
def __init__(self, c=None) -> None:
super().__init__()
self.c = c
def user_claim_item(self, user):
'''添加或使用用户item注意是+amount'''
self.user = user
self.c.execute('''select amount from user_item where user_id=? and item_id=? and type=?''',
(self.user.user_id, self.item_id, self.item_type))
x = self.c.fetchone()
if x:
if x[0] + self.amount < 0: # 数量不足
raise ItemNotEnough(
'The user does not have enough `%s`.' % self.item_id)
self.c.execute('''update user_item set amount=? where user_id=? and item_id=? and type=?''',
(x[0]+self.amount, self.user.user_id, self.item_id, self.item_type))
else:
if self.amount < 0: # 添加数量错误
raise InputError(
'The amount of `%s` is wrong.' % self.item_id)
self.c.execute('''insert into user_item values(?,?,?,?)''',
(self.user.user_id, self.item_id, self.item_type, self.amount))
class ItemCore(PositiveItem):
item_type = 'core'
def __init__(self, c=None, core_type: str = '', amount: int = 0) -> None:
super().__init__(c)
self.is_available = True
self.item_id = core_type
self.amount = amount
def __str__(self) -> str:
return self.item_id + '_' + str(self.amount)
def to_dict(self, has_is_available: bool = False, has_amount: bool = True, character_format: bool = False) -> dict:
if character_format:
# 搭档的core是特殊格式的
return {'core_type': self.item_id, 'amount': self.amount}
return super().to_dict(has_is_available=has_is_available, has_amount=has_amount)
def user_claim_item(self, user, reverse: bool = False) -> None:
# 骚操作将amount变为负数后使用再变回来
if reverse:
self.amount = -self.amount
super().user_claim_item(user)
if reverse:
self.amount = -self.amount
class ItemCharacter(UserItem):
item_type = 'character'
def __init__(self, c) -> None:
super().__init__()
self.c = c
self.is_available = True
def set_id(self, character_id: str) -> None:
# 将name: str转为character_id: int存到item_id里
if character_id.isdigit():
self.item_id = int(character_id)
else:
self.c.execute(
'''select character_id from character where name=?''', (character_id,))
x = self.c.fetchone()
if x:
self.item_id = x[0]
else:
raise NoData('No character `%s`.' % character_id)
def user_claim_item(self, user):
if not isinstance(self.item_id, int):
self.set_id(self.item_id)
self.c.execute(
'''select exists(select * from user_char where user_id=? and character_id=?)''', (user.user_id, self.item_id))
if self.c.fetchone() == (0,):
self.c.execute(
'''insert into user_char values(?,?,1,0,0,0,0)''', (user.user_id, self.item_id))
class Memory(UserItem):
item_type = 'memory'
def __init__(self, c) -> None:
super().__init__()
self.c = c
self.is_available = True
def user_claim_item(self, user):
self.c.execute(
'''select ticket from user where user_id=?''', (user.user_id,))
x = self.c.fetchone()
if x is not None:
self.c.execute('''update user set ticket=? where user_id=?''',
(x[0]+self.amount, user.user_id))
else:
raise NoData('The ticket of the user is null.')
class Fragment(UserItem):
item_type = 'fragment'
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
def __str__(self) -> str:
return 'fragment' + str(self.amount)
class Anni5tix(PositiveItem):
item_type = 'anni5tix'
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) -> None:
super().__init__(c)
self.is_available = True
class WorldUnlock(NormalItem):
item_type = 'world_unlock'
def __init__(self, c) -> None:
super().__init__(c)
self.is_available = True
class CourseBanner(NormalItem):
item_type = 'course_banner'
def __init__(self, c) -> None:
super().__init__(c)
self.is_available = True
def __str__(self) -> str:
return str(self.item_id)
class Single(NormalItem):
item_type = 'single'
class Pack(NormalItem):
item_type = 'pack'
class ProgBoost(UserItem):
item_type = 'prog_boost_300'
def __init__(self, c) -> None:
super().__init__(c)
def user_claim_item(self, user):
'''
世界模式prog_boost
parameters: `user` - `UserOnline`类或子类的实例
'''
user.update_user_one_column('prog_boost', 300)
class Stamina6(UserItem):
item_type = 'stamina6'
def __init__(self, c=None) -> None:
super().__init__(c)
self.item_id = 'stamina6'
self.amount = 1
def user_claim_item(self, user):
'''
世界模式记忆源点或残片买体力+6
顺手清一下世界模式过载状态
'''
user.select_user_about_stamina()
user.stamina.stamina += 6
user.stamina.update()
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
def get_item(self, item_type: str):
'''
根据item_type实例化对应的item类
return: Item类或子类的实例
'''
if item_type == 'core':
return ItemCore(self.c)
elif item_type == 'character':
return ItemCharacter(self.c)
elif item_type == 'memory':
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':
return WorldUnlock(self.c)
elif item_type == 'single':
return Single(self.c)
elif item_type == 'pack':
return Pack(self.c)
elif item_type == 'fragment':
return Fragment(self.c)
elif item_type == 'prog_boost_300':
return ProgBoost(self.c)
elif item_type == 'stamina6':
return Stamina6(self.c)
elif item_type == 'course_banner':
return CourseBanner(self.c)
else:
raise InputError(
f'The item type `{item_type}` is invalid.', api_error_code=-120)
@classmethod
def from_dict(cls, d: dict, c=None):
'''注意这里没有处理character_id的转化是为了世界模式的map服务的'''
if 'item_type' in d:
item_type = d['item_type']
elif 'type' in d:
item_type = d['type']
else:
raise InputError('The dict of item is wrong.')
i = cls().get_item(item_type)
if c is not None:
i.c = c
if 'item_id' in d:
i.item_id = d['item_id']
elif 'id' in d:
i.item_id = d['id']
else:
i.item_id = item_type
i.amount = d.get('amount', 1)
i.is_available = d.get('is_available', True)
return i
@classmethod
def from_str(cls, s: str, c=None):
if s.startswith('fragment'):
item_type = 'fragment'
item_id = 'fragment'
amount = int(s[8:])
elif s.startswith('core'):
item_type = 'core'
x = s.split('_')
item_id = x[0] + '_' + x[1]
amount = int(x[-1])
elif s.startswith('course_banner'):
item_type = 'course_banner'
item_id = s
amount = 1
else:
raise InputError('The string of item is wrong.')
i = cls().get_item(item_type)
if c is not None:
i.c = c
i.item_id = item_id
i.amount = amount
i.is_available = True
return i
class UserItemList:
'''
用户的item列表
注意只能查在user_item里面的character不行
properties: `user` - `User`类或子类的实例
'''
def __init__(self, c=None, user=None):
self.c = c
self.user = user
self.items: list = []
def select_from_type(self, item_type: str) -> 'UserItemList':
'''
根据item_type搜索用户的item
'''
if Config.WORLD_SONG_FULL_UNLOCK and item_type == 'world_song' or Config.WORLD_SCENERY_FULL_UNLOCK and item_type == 'world_unlock':
self.c.execute(
'''select item_id from item where type=?''', (item_type,))
else:
self.c.execute('''select item_id, amount from user_item where type = :a and user_id = :b''', {
'a': item_type, 'b': self.user.user_id})
x = self.c.fetchall()
if not x:
return self
self.items: list = []
for i in x:
if len(i) > 1:
amount = i[1] if i[1] is not None else 1
else:
amount = 1
self.items.append(ItemFactory.from_dict(
{'item_id': i[0], 'amount': amount, 'item_type': item_type}, self.c))
return self
class CollectionItemMixin:
'''
批量修改一些集合中的items
'''
collection_item_const = {
'name': 'collection',
'table_name': 'collection_item',
'table_primary_key': 'collection_id',
'id_name': 'collection_id',
'items_name': 'items'
}
def add_items(self, items: 'list[Item]') -> None:
collection_id: 'str' = getattr(
self, self.collection_item_const['id_name'])
collection_items: 'list[Item]' = getattr(
self, self.collection_item_const['items_name'])
for i in items:
if not i.select_exists():
raise NoData(
f'No such item `{i.item_type}`: `{i.item_id}`', api_error_code=-121)
if i in collection_items:
raise DataExist(
f'Item `{i.item_type}`: `{i.item_id}` already exists in {self.collection_item_const["name"]} `{collection_id}`', api_error_code=-123)
self.c.executemany(f'''insert into {self.collection_item_const["table_name"]} values (?, ?, ?, ?)''', [
(collection_id, i.item_id, i.item_type, i.amount) for i in items])
collection_items.extend(items)
def remove_items(self, items: 'list[Item]') -> None:
collection_id: 'str' = getattr(
self, self.collection_item_const['id_name'])
collection_items: 'list[Item]' = getattr(
self, self.collection_item_const['items_name'])
for i in items:
if i not in collection_items:
raise NoData(
f'No such item `{i.item_type}`: `{i.item_id}` in {self.collection_item_const["name"]} `{collection_id}`', api_error_code=-124)
self.c.executemany(f'''delete from {self.collection_item_const["table_name"]} where {self.collection_item_const["table_primary_key"]}=? and item_id=? and type=?''', [
(collection_id, i.item_id, i.item_type) for i in items])
for i in items:
collection_items.remove(i)
def update_items(self, items: 'list[Item]') -> None:
collection_id: 'str' = getattr(
self, self.collection_item_const['id_name'])
collection_items: 'list[Item]' = getattr(
self, self.collection_item_const['items_name'])
for i in items:
if i not in collection_items:
raise NoData(
f'No such item `{i.item_type}`: `{i.item_id}` in {self.collection_item_const["name"]} `{collection_id}`', api_error_code=-124)
self.c.executemany(f'''update {self.collection_item_const["table_name"]} set amount=? where {self.collection_item_const["table_primary_key"]}=? and item_id=? and type=?''', [
(i.amount, collection_id, i.item_id, i.item_type) for i in items])
for i in items:
collection_items[collection_items.index(i)].amount = i.amount

View File

@@ -0,0 +1,31 @@
from limits import parse_many, strategies
from limits.storage import storage_from_string
class ArcLimiter:
storage = storage_from_string("memory://")
strategy = strategies.FixedWindowRateLimiter(storage)
def __init__(self, limit_str: str = None, namespace: str = None):
self._limits: list = None
self.limits = limit_str
self.namespace = namespace
@property
def limits(self) -> list:
return self._limits
@limits.setter
def limits(self, value: str):
if value is None:
return
self._limits = parse_many(value)
def hit(self, key: str, cost: int = 1) -> bool:
flag = True
for limit in self.limits:
flag &= self.strategy.hit(limit, self.namespace, key, cost)
return flag
def test(self, key: str) -> bool:
return all(self.strategy.test(limit, self.namespace, key) for limit in self.limits)

View File

@@ -0,0 +1,361 @@
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
from .constant import Constant
from .user import UserInfo
from .util import aes_gcm_128_decrypt, aes_gcm_128_encrypt
socket.setdefaulttimeout(Constant.LINKPLAY_TIMEOUT)
def get_song_unlock(client_song_map: 'dict[str, list]') -> bytes:
'''处理可用歌曲bit返回bytes'''
user_song_unlock = [0] * Constant.LINKPLAY_UNLOCK_LENGTH
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)
class Player(UserInfo):
def __init__(self, c=None, user_id=None) -> None:
super().__init__(c, user_id)
self.player_id: int = 0
self.token: int = 0
self.key: bytes = None
self.__song_unlock: bytes = None
self.client_song_map: dict = None
self.last_match_timestamp: int = 0
self.match_times: int = None # 已匹配次数,减 1 后乘 5 就大致是匹配时间
self.match_room: Room = None # 匹配到的房间,这个仅用来在两个人同时匹配时使用,一人建房,通知另一个人加入
def to_dict(self) -> dict:
return {
'userId': self.user_id,
'playerId': str(self.player_id),
'token': str(self.token),
'key': (b64encode(self.key)).decode()
}
@property
def song_unlock(self) -> bytes:
if self.__song_unlock is None:
self.get_song_unlock()
return self.__song_unlock
def get_song_unlock(self, client_song_map: dict = None) -> bytes:
if client_song_map is not None:
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:
self.room_id: int = 0
self.room_code: str = 'AAAA00'
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(),
'shareToken': self.share_token
}
class RemoteMultiPlayer:
TCP_AES_KEY = Constant.LINKPLAY_TCP_SECRET_KEY.encode(
'utf-8').ljust(16, b'\x00')[:16]
def __init__(self) -> None:
self.user: 'Player' = None
self.room: 'Room' = None
self.data_recv: 'dict | list' = None
def to_dict(self) -> dict:
return dict(self.room.to_dict(), **self.user.to_dict())
@staticmethod
def tcp(data: bytes) -> bytes:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.connect((Constant.LINKPLAY_HOST,
Constant.LINKPLAY_TCP_PORT))
sock.sendall(data)
try:
cipher_len = int.from_bytes(sock.recv(8), byteorder='little')
if cipher_len > Constant.LINKPLAY_TCP_MAX_LENGTH:
raise ArcError(
'Too long body from link play server', status=400)
iv = 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)
except socket.timeout as e:
raise Timeout(
'Timeout when waiting for data from link play server.', status=400) from e
# print(received)
return received
def data_swap(self, data: dict) -> dict:
iv, ciphertext, tag = aes_gcm_128_encrypt(
self.TCP_AES_KEY, dumps(data).encode('utf-8'), b'')
send_data = Constant.LINKPLAY_AUTHENTICATION.encode(
'utf-8') + len(ciphertext).to_bytes(8, byteorder='little') + iv + tag + ciphertext
recv_data = self.tcp(send_data)
self.data_recv = loads(recv_data)
code = self.data_recv['code']
if code != 0:
raise ArcError(f'Link Play error code: {code}', code, status=400)
return self.data_recv
def create_room(self, user: 'Player' = None) -> None:
'''创建房间'''
if user is not None:
self.user = user
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'),
'rating_ptt': self.user.rating_ptt,
'is_hide_rating': self.user.is_hide_rating,
'match_times': self.user.match_times
}
})
self.room = Room()
x = self.data_recv['data']
self.room.room_code = x['room_code']
self.room.room_id = int(x['room_id'])
self.room.song_unlock = self.user.song_unlock
self.user.token = int(x['token'])
self.user.key = b64decode(x['key'])
self.user.player_id = int(x['player_id'])
def join_room(self, room: 'Room' = None, user: 'Player' = None) -> None:
'''加入房间'''
if user is not None:
self.user = user
if room is not None:
self.room = room
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,
'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']
self.room.room_code = x['room_code']
self.room.room_id = int(x['room_id'])
self.room.song_unlock = b64decode(x['song_unlock'])
self.user.token = int(x['token'])
self.user.key = b64decode(x['key'])
self.user.player_id = int(x['player_id'])
def update_room(self, user: 'Player' = None) -> None:
'''更新房间'''
if user is not None:
self.user = user
self.user.select_user_about_link_play()
self.data_swap({
'endpoint': 'update_room',
'data': {
'token': self.user.token,
'rating_ptt': self.user.rating_ptt,
'is_hide_rating': self.user.is_hide_rating
}
})
self.room = Room()
x = self.data_recv['data']
self.room.room_code = x['room_code']
self.room.room_id = int(x['room_id'])
self.room.song_unlock = b64decode(x['song_unlock'])
self.user.key = b64decode(x['key'])
self.user.player_id = int(x['player_id'])
def get_rooms(self, offset=0, limit=50) -> dict:
'''获取房间列表'''
self.data_swap({
'endpoint': 'get_rooms',
'data': {
'offset': offset,
'limit': limit
}
})
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

@@ -0,0 +1,387 @@
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:
_name: str = None
def __init__(self, *args, **kwargs):
pass
def __call__(self, *args, **kwargs) -> None:
return self.run(*args, **kwargs)
def set_params(self, *args, **kwargs) -> None:
pass
def run(self, *args, **kwargs) -> None:
raise NotImplementedError
class RefreshAllScoreRating(BaseOperation):
'''
刷新所有成绩的评分
包括 score_v2
'''
_name = 'refresh_all_score_rating'
def run(self):
# 追求效率不用Song类尽量不用对象
# 但其实还是很慢
with Connect() as c:
c.execute(
'''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]
c.execute(
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, 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, 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 = []
where_values = []
for k in y:
ptt = Score.calculate_rating(defnum, k[1])
ptt = max(ptt, 0)
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', '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):
'''
刷新歌曲文件缓存包括文件hash缓存重建、文件目录重遍历、songlist重解析
注意在设置里预先计算关闭的情况下文件hash不会计算
'''
_name = 'refresh_song_file_cache'
def run(self):
DownloadList.clear_all_cache()
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):
'''
云存档更新成绩,是覆盖式更新
提供user参数时只更新该用户的成绩否则更新所有用户的成绩
'''
_name = 'save_update_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)
def run(self, user=None):
'''
parameter:
`user` - `User`类或子类的实例
'''
if user is not None:
self.user = user
if self.user is not None and self.user.user_id is not None:
self._one_user_update()
else:
self._all_update()
def _one_user_update(self):
with Connect() as c:
save = SaveData(c)
save.select_scores(self.user)
clear_state = {f'{i["song_id"]}{i["difficulty"]}': i['clear_type']
for i in save.clearlamps_data}
song_id_1 = [i['song_id'] for i in save.scores_data]
song_id_2 = [i['song_id'] for i in save.clearlamps_data]
song_id = list(set(song_id_1 + song_id_2))
c.execute(
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], 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:
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]
else:
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, score_v2))
c.executemany(
'''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, 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():
user = User()
user.user_id = y[0]
save = SaveData(c)
save.select_scores(user)
clear_state = {f'{i["song_id"]}{i["difficulty"]}': i['clear_type']
for i in save.clearlamps_data}
new_scores = []
for i in save.scores_data:
rating = 0
score_v2 = 0
if i['song_id'] in song_chart_const:
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]
else:
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, score_v2))
c.executemany(
'''insert or replace into best_score values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', new_scores)
class UnlockUserItem(BaseOperation):
'''
全解锁/锁定用户物品
提供user参数时只更新该用户的否则更新所有用户的
'''
_name = 'unlock_user_item'
ALLOW_TYPES = ['single', 'pack', 'world_song',
'course_banner', 'world_unlock']
def __init__(self, user=None, method: str = 'unlock', item_types: list = ['single', 'pack']):
self.user = user
self.set_params(method=method, item_types=item_types)
def set_params(self, user_id: int = None, method: str = 'unlock', item_types: list = ['single', 'pack'], *args, **kwargs):
if user_id is not None:
self.user = User()
self.user.user_id = int(user_id)
if method in ['unlock', 'lock']:
self.method = method
if isinstance(item_types, list) and all(i in self.ALLOW_TYPES for i in item_types):
self.item_types = item_types
def run(self):
if self.user is not None and self.user.user_id is not None:
if self.method == 'unlock':
self._one_user_insert()
else:
self._one_user_delete()
else:
if self.method == 'unlock':
self._all_insert()
else:
self._all_delete()
def _one_user_insert(self):
with Connect() as c:
c.execute(
'''select exists(select * from user where user_id = ?)''', (self.user.user_id,))
if not c.fetchone()[0]:
raise NoData(
f'No such user: `{self.user.user_id}`', api_error_code=-110)
c.execute(
f'''select item_id, type from item where type in ({','.join(['?'] * len(self.item_types))})''', self.item_types)
sql_list = [(self.user.user_id, i[0], i[1])
for i in c.fetchall()]
c.executemany(
'''insert or ignore into user_item values (?, ?, ?, 1)''', sql_list)
def _all_insert(self):
with Connect() as c:
c.execute('''select user_id from user''')
x = c.fetchall()
c.execute(
f'''select item_id, type from item where type in ({','.join(['?'] * len(self.item_types))})''', self.item_types)
y = c.fetchall()
sql_list = [(i[0], j[0], j[1])
for i in x for j in y]
c.executemany(
'''insert or ignore into user_item values (?, ?, ?, 1)''', sql_list)
def _one_user_delete(self):
with Connect() as c:
c.execute(
f'''delete from user_item where user_id = ? and type in ({','.join(['?'] * len(self.item_types))})''', (self.user.user_id, *self.item_types))
def _all_delete(self):
with Connect() as c:
c.execute(
f'''delete from user_item where type in ({','.join(['?'] * len(self.item_types))})''', self.item_types)
def _delete_one_table(c, table_name, user_id):
c.execute(
f'''insert into db_deleted.{table_name} select * from {table_name} where user_id = ?''', (user_id,))
c.execute(f'''delete from {table_name} where user_id = ?''', (user_id,))
class DeleteUserScore(BaseOperation):
'''
删除单用户成绩,不包含 recent 数据
'''
_name = 'delete_user_score'
def __init__(self, user=None):
self.user = user
def set_params(self, user_id: int = None, *args, **kwargs):
if user_id is not None:
self.user = User()
self.user.user_id = int(user_id)
return self
def run(self):
assert self.user is not None
with Connect() as c:
c.execute('''attach database ? as db_deleted''',
(Constant.SQLITE_DATABASE_DELETED_PATH,))
_delete_one_table(c, 'best_score', self.user.user_id)
_delete_one_table(c, 'recent30', self.user.user_id)
class DeleteOneUser(BaseOperation):
'''
删除单用户
'''
_name = 'delete_one_user'
TABLES = ['best_score', 'recent30', 'user_char', 'user_course', 'user_item',
'user_present', 'user_redeem', 'user_role', 'user_save', 'user_world', 'user']
def __init__(self, user=None):
self.user = user
def set_params(self, user_id: int = None, *args, **kwargs):
if user_id is not None:
self.user = User()
self.user.user_id = int(user_id)
return self
def run(self):
assert self.user is not None
with Connect() as c:
c.execute('''attach database ? as db_deleted''',
(Constant.SQLITE_DATABASE_DELETED_PATH,))
self._clear_login(c)
self._data_save(c)
def _data_save(self, c):
c.execute(
f'''insert into db_deleted.friend select * from friend where user_id_me = ? or user_id_other = ?''', (self.user.user_id, self.user.user_id))
c.execute(f'''delete from friend where user_id_me = ? or user_id_other = ?''',
(self.user.user_id, self.user.user_id))
[_delete_one_table(c, x, self.user.user_id) for x in self.TABLES]
def _clear_login(self, c):
c.execute('''delete from login where user_id = ?''',
(self.user.user_id,))
c.execute('''delete from api_login where user_id = ?''',
(self.user.user_id,))

View File

@@ -0,0 +1,196 @@
from time import time
from .error import ArcError, NoData
from .item import CollectionItemMixin, ItemFactory
class Present(CollectionItemMixin):
collection_item_const = {
'name': 'present',
'table_name': 'present_item',
'table_primary_key': 'present_id',
'id_name': 'present_id',
'items_name': 'items'
}
def __init__(self, c=None) -> None:
self.c = c
self.present_id: str = None
self.expire_ts: int = None
self.description: str = None
self.items: list = None
@property
def is_expired(self) -> bool:
return self.expire_ts < int(time() * 1000)
def to_dict(self, has_items: bool = True) -> dict:
r = {
'present_id': self.present_id,
'expire_ts': self.expire_ts,
'description': self.description
}
if has_items:
r['items'] = [x.to_dict() for x in self.items]
return r
def from_dict(self, d: dict) -> 'Present':
self.present_id = d['present_id']
self.expire_ts = int(d['expire_ts'])
self.description = d.get('description', '')
self.items = [ItemFactory.from_dict(
i, c=self.c) for i in d.get('items', [])]
return self
def from_list(self, l: list) -> 'Present':
self.present_id = l[0]
self.expire_ts = int(l[1]) if l[1] else 0
self.description = l[2] if l[2] else ''
return self
def select_exists(self) -> bool:
'''
查询present是否存在
'''
self.c.execute(
'''select exists(select * from present where present_id=?)''', (self.present_id,))
return bool(self.c.fetchone()[0])
def select(self, present_id: str = None) -> 'Present':
'''
用present_id查询信息
'''
if present_id:
self.present_id = present_id
self.c.execute(
'''select * from present where present_id=:a''', {'a': self.present_id})
x = self.c.fetchone()
if x is None:
raise NoData('The present `%s` does not exist.' % self.present_id)
self.from_list(x)
return self
def select_items(self) -> None:
'''
查询奖励的物品
'''
self.c.execute(
'''select * from present_item where present_id=:a''', {'a': self.present_id})
self.items = [ItemFactory.from_dict({
'item_id': i[1],
'type': i[2],
'amount': i[3] if i[3] else 1
}, self.c) for i in self.c.fetchall()]
def insert_items(self) -> None:
for i in self.items:
self.c.execute('''insert or ignore into item values(?,?,?)''',
(i.item_id, i.item_type, i.is_available))
self.c.execute('''insert or ignore into present_item values(?,?,?,?)''',
(self.present_id, i.item_id, i.item_type, i.amount))
def insert(self) -> None:
self.c.execute('''insert into present values(?,?,?)''',
(self.present_id, self.expire_ts, self.description))
def insert_all(self) -> None:
self.insert()
self.insert_items()
def delete(self) -> None:
self.c.execute(
'''delete from present where present_id=?''', (self.present_id,))
def delete_present_item(self) -> None:
self.c.execute(
'''delete from present_item where present_id=?''', (self.present_id,))
def delete_all(self) -> None:
self.delete_present_item()
self.delete()
def update(self) -> None:
self.c.execute('''update present set expire_ts=?, description=? where present_id=?''',
(self.expire_ts, self.description, self.present_id))
class UserPresent(Present):
'''
用户登录奖励类
忽视了description的多语言
properties: `user` - `User`类或子类的实例
'''
def __init__(self, c=None, user=None) -> None:
super().__init__(c)
self.user = user
def delete_user_present(self) -> None:
'''
删除用户奖励
'''
self.c.execute('''delete from user_present where user_id=:a and present_id=:b''',
{'a': self.user.user_id, 'b': self.present_id})
def claim_user_present(self, present_id: str = None, user=None) -> None:
'''
确认并删除用户奖励
'''
if present_id:
self.present_id = present_id
if user:
self.user = user
if self.expire_ts is None:
self.select()
if self.items is None:
self.select_items()
self.c.execute('''select exists(select * from user_present where user_id=:a and present_id=:b)''',
{'a': self.user.user_id, 'b': self.present_id})
if self.c.fetchone() == (0,):
raise NoData('The present `%s` for the user `%s` does not exist.' % (
self.present_id, self.user.user_id))
self.delete_user_present()
if self.is_expired:
raise ArcError('The present `%s` has expired.' % self.present_id)
for i in self.items:
i.user_claim_item(self.user)
class UserPresentList:
def __init__(self, c=None, user=None) -> None:
self.c = c
self.user = user
self.presents: list = None
def to_dict_list(self) -> list:
return [x.to_dict() for x in self.presents]
def select_user_presents(self) -> None:
'''
查询用户全部奖励
'''
self.c.execute(
'''select * from present where present_id in (select present_id from user_present where user_id=:a)''', {'a': self.user.user_id})
x = self.c.fetchall()
self.presents = []
if not x:
return None
for i in x:
p = UserPresent(self.c, self.user)
p.present_id = i[0]
p.expire_ts = i[1]
p.description = i[2]
if not p.is_expired:
p.select_items()
self.presents.append(p)

View File

@@ -0,0 +1,254 @@
from time import time
from .error import InputError, NoData, TicketNotEnough
from .item import CollectionItemMixin, ItemFactory
class Purchase(CollectionItemMixin):
'''
购买类
properties: `user` - `User`类或子类的实例
'''
collection_item_const = {
'name': 'purchase',
'table_name': 'purchase_item',
'table_primary_key': 'purchase_name',
'id_name': 'purchase_name',
'items_name': 'items'
}
def __init__(self, c=None, user=None):
self.c = c
self.user = user
self.purchase_name: str = None
self.price: int = None
self.orig_price: int = None
self.discount_from: int = None
self.discount_to: int = None
self.discount_reason: str = None
self.items: list = []
# TODO: "discount_reason": extend, sale
@property
def price_displayed(self) -> int:
'''
返回显示的价格
'''
if self.discount_from > 0 and self.discount_to > 0:
if self.discount_from <= int(time() * 1000) <= self.discount_to:
if self.discount_reason == 'anni5tix':
x = ItemFactory(self.c).get_item('anni5tix')
x.item_id = 'anni5tix'
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
def to_dict(self, has_items: bool = True, show_real_price: bool = True) -> dict:
if show_real_price:
price = self.price_displayed
r = {
'name': self.purchase_name,
'price': price if show_real_price else self.price,
'orig_price': self.orig_price,
}
if has_items:
r['items'] = [x.to_dict(has_is_available=True) for x in self.items]
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 in ('anni5tix', 'pick_ticket') and price == 0):
r['discount_reason'] = self.discount_reason
return r
def from_dict(self, d: dict) -> 'Purchase':
self.purchase_name = d.get('name') or d.get('purchase_name')
if not self.purchase_name:
raise InputError('purchase_name is required')
self.orig_price = int(d['orig_price'])
self.price = d.get('price', self.orig_price)
self.discount_from = d.get('discount_from', -1)
self.discount_to = d.get('discount_to', -1)
self.discount_reason = d.get('discount_reason', '')
for i in d.get('items', []):
self.items.append(ItemFactory.from_dict(i))
return self
def from_list(self, l: list) -> 'Purchase':
self.purchase_name = l[0]
self.price = l[1]
self.orig_price = l[2]
self.discount_from = l[3] if l[3] else -1
self.discount_to = l[4] if l[4] else -1
self.discount_reason = l[5] if l[5] else ''
return self
def insert_all(self) -> None:
'''向数据库插入包括item表和purchase_item表'''
self.insert()
self.insert_items()
def insert(self) -> None:
'''向数据库插入不包括item表和purchase_item表'''
self.c.execute('''insert into purchase values(?,?,?,?,?,?)''',
(self.purchase_name, self.price, self.orig_price, self.discount_from, self.discount_to, self.discount_reason))
# def insert_only_purchase_item(self) -> None:
# '''向数据库插入purchase_item表'''
# for i in self.items:
# self.c.execute('''insert into purchase_item values(?,?,?,?)''',
# (self.purchase_name, i.item_id, i.item_type, i.amount))
def insert_items(self) -> None:
'''向数据库插入物品,注意已存在的物品不会变更'''
for i in self.items:
self.c.execute('''insert or ignore into item values(?,?,?)''',
(i.item_id, i.item_type, i.is_available))
self.c.execute('''insert or ignore into purchase_item values(?,?,?,?)''',
(self.purchase_name, i.item_id, i.item_type, i.amount))
def select_exists(self, purchase_name: str = None) -> bool:
'''
用purchase_name查询存在性
'''
if purchase_name:
self.purchase_name = purchase_name
self.c.execute(
'''select exists(select * from purchase where purchase_name=?)''', (self.purchase_name,))
return self.c.fetchone() == (1,)
def select(self, purchase_name: str = None) -> 'Purchase':
'''
用purchase_name查询信息
'''
if purchase_name:
self.purchase_name = purchase_name
self.c.execute(
'''select * from purchase where purchase_name=:name''', {'name': self.purchase_name})
x = self.c.fetchone()
if not x:
raise NoData(
f'Purchase `{self.purchase_name}` does not exist.', 501)
self.price = x[1]
self.orig_price = x[2]
self.discount_from = x[3] if x[3] else -1
self.discount_to = x[4] if x[4] else -1
self.discount_reason = x[5] if x[5] else ''
self.select_items()
return self
def select_items(self) -> None:
'''从数据库拉取purchase_item数据'''
self.c.execute(
'''select item_id, type, amount from purchase_item where purchase_name=:a''', {'a': self.purchase_name})
x = self.c.fetchall()
# if not x:
# raise NoData(
# f'The items of the purchase `{self.purchase_name}` does not exist.', 501)
self.items = []
t = None
for i in x:
if i[0] == self.purchase_name:
# 物品排序,否则客户端报错
t = ItemFactory.from_dict({
'item_id': i[0],
'type': i[1],
'amount': i[2] if i[2] else 1
}, self.c)
else:
self.items.append(ItemFactory.from_dict({
'item_id': i[0],
'type': i[1],
'amount': i[2] if i[2] else 1
}, self.c))
if t is not None:
self.items = [t] + self.items
def buy(self) -> None:
'''进行购买'''
if self.price is None or self.orig_price is None:
self.select()
if not self.items:
self.select_items()
self.user.select_user_one_column('ticket', 0)
price_used = self.price_displayed
if self.user.ticket < price_used:
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 price_used == 0:
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:
self.user.ticket -= price_used
self.user.update_user_one_column('ticket')
for i in self.items:
i.user_claim_item(self.user)
def delete_purchase_item(self) -> None:
'''删除purchase_item表'''
self.c.execute(
'''delete from purchase_item where purchase_name=?''', (self.purchase_name, ))
def delete(self) -> None:
'''删除purchase表'''
self.c.execute(
'''delete from purchase where purchase_name=?''', (self.purchase_name, ))
def delete_all(self) -> None:
'''删除purchase表和purchase_item表'''
self.delete_purchase_item()
self.delete()
def update(self) -> None:
'''更新purchase表'''
self.c.execute('''update purchase set price=:a, orig_price=:b, discount_from=:c, discount_to=:d, discount_reason=:e where purchase_name=:f''', {
'a': self.price, 'b': self.orig_price, 'c': self.discount_from, 'd': self.discount_to, 'e': self.discount_reason, 'f': self.purchase_name})
class PurchaseList:
'''
购买列表类
property: `user` - `User`类或子类的实例
'''
def __init__(self, c=None, user=None):
self.c = c
self.user = user
self.purchases: list = []
def to_dict_list(self) -> list:
return [x.to_dict() for x in self.purchases]
def select_from_type(self, item_type: str) -> 'PurchaseList':
self.c.execute('''select purchase_name from purchase_item where type = :a''', {
'a': item_type})
x = self.c.fetchall()
if not x:
return self
self.purchases: list = []
for i in x:
self.purchases.append(Purchase(self.c, self.user).select(i[0]))
return self

159
latest version/core/rank.py Normal file
View File

@@ -0,0 +1,159 @@
from .constant import Constant
from .score import UserScore
from .song import Chart
from .sql import Query, Sql
from .user import UserInfo
class RankList:
'''
排行榜类
默认limit=20limit<0认为是all
property: `user` - `User`类或者子类的实例
'''
def __init__(self, c=None) -> None:
self.c = c
self.list: list = []
self.song = Chart()
self.limit: int = 20
self.user = None
def to_dict_list(self) -> list:
return [x.to_dict() for x in self.list]
def select_top(self) -> None:
'''
得到top分数表
'''
if self.limit >= 0:
self.c.execute('''select * from best_score where song_id = :song_id and difficulty = :difficulty order by score DESC, time_played DESC limit :limit''', {
'song_id': self.song.song_id, 'difficulty': self.song.difficulty, 'limit': self.limit})
else:
self.c.execute('''select * from best_score where song_id = :song_id and difficulty = :difficulty order by score DESC, time_played DESC''', {
'song_id': self.song.song_id, 'difficulty': self.song.difficulty})
x = self.c.fetchall()
if not x:
return None
user_info_list = Sql(self.c).select('user', ['user_id', 'name', 'character_id', 'is_skill_sealed', 'is_char_uncapped',
'is_char_uncapped_override', 'favorite_character'], Query().from_args({'user_id': [i[0] for i in x]}))
user_info_dict = {i[0]: i[1:] for i in user_info_list}
rank = 0
self.list = []
for i in x:
rank += 1
y = UserScore(self.c, UserInfo(self.c, i[0])).from_list(i)
y.song = self.song
y.rank = rank
y.user.from_list_about_character(user_info_dict[i[0]])
self.list.append(y)
def select_friend(self, user=None, limit=Constant.MAX_FRIEND_COUNT) -> None:
'''
得到用户好友分数表
'''
self.limit = limit
if user:
self.user = user
user_ids = [self.user.user_id] + [x[0] for x in self.user.friend_ids]
self.c.execute(f'''select * from best_score where user_id in ({','.join(['?'] * len(user_ids))}) and song_id = ? and difficulty = ? order by score DESC, time_played DESC limit ?''', user_ids + [
self.song.song_id, self.song.difficulty, self.limit])
x = self.c.fetchall()
if not x:
return None
user_info_list = Sql(self.c).select('user', ['user_id', 'name', 'character_id', 'is_skill_sealed', 'is_char_uncapped',
'is_char_uncapped_override', 'favorite_character'], Query().from_args({'user_id': [i[0] for i in x]}))
user_info_dict = {i[0]: i[1:] for i in user_info_list}
rank = 0
self.list = []
for i in x:
rank += 1
y = UserScore(self.c, UserInfo(self.c, i[0])).from_list(i)
y.song = self.song
y.rank = rank
y.user.from_list_about_character(user_info_dict[i[0]])
self.list.append(y)
@staticmethod
def get_my_rank_parameter(my_rank: int, amount: int, all_limit: int = 20, max_local_position: int = Constant.MY_RANK_MAX_LOCAL_POSITION, max_global_position: int = Constant.MY_RANK_MAX_GLOBAL_POSITION):
'''
计算我的排名中的查询参数
returns:
`sql_limit`: int - 查询limit参数
`sql_offset`: int - 查询offset参数
`need_myself`: bool - 是否需要在排名结尾添加自己
'''
sql_limit = all_limit
sql_offset = 0
need_myself = False
if my_rank <= max_local_position: # 排名在前面,前方人数不足
pass
elif my_rank > max_global_position: # 排名太后了,不显示排名
sql_limit -= 1
sql_offset = max_global_position - all_limit + 1
need_myself = True
elif amount - my_rank < all_limit - max_local_position: # 后方人数不足,显示排名
sql_offset = amount - all_limit
elif max_local_position <= my_rank <= max_global_position - all_limit + max_local_position - 1: # 前方人数足够,显示排名
sql_offset = my_rank - max_local_position
else: # 我已经忘了这是什么了
sql_offset = max_global_position - all_limit
return sql_limit, sql_offset, need_myself
def select_me(self, user=None) -> None:
'''
得到我的排名分数表
'''
if user:
self.user = user
self.c.execute('''select score, time_played from best_score where user_id = :user_id and song_id = :song_id and difficulty = :difficulty''', {
'user_id': self.user.user_id, 'song_id': self.song.song_id, 'difficulty': self.song.difficulty})
x = self.c.fetchone()
if not x:
return None
self.c.execute('''select count(*) from best_score where song_id = :song_id and difficulty = :difficulty and ( score > :score or (score = :score and time_played > :time_played) )''', {
'user_id': self.user.user_id, 'song_id': self.song.song_id, 'difficulty': self.song.difficulty, 'score': x[0], 'time_played': x[1]})
my_rank = int(self.c.fetchone()[0]) + 1
self.c.execute('''select count(*) from best_score where song_id=:a and difficulty=:b''',
{'a': self.song.song_id, 'b': self.song.difficulty})
sql_limit, sql_offset, need_myself = self.get_my_rank_parameter(
my_rank, int(self.c.fetchone()[0]), self.limit)
self.c.execute('''select * from best_score where song_id = :song_id and difficulty = :difficulty order by score DESC, time_played DESC limit :limit offset :offset''', {
'song_id': self.song.song_id, 'difficulty': self.song.difficulty, 'limit': sql_limit, 'offset': sql_offset})
x = self.c.fetchall()
if x:
user_info_list = Sql(self.c).select('user', ['user_id', 'name', 'character_id', 'is_skill_sealed', 'is_char_uncapped',
'is_char_uncapped_override', 'favorite_character'], Query().from_args({'user_id': [i[0] for i in x]}))
user_info_dict = {i[0]: i[1:] for i in user_info_list}
rank = sql_offset if sql_offset > 0 else 0
self.list = []
for i in x:
rank += 1
y = UserScore(self.c, UserInfo(self.c, i[0])).from_list(i)
y.song = self.song
y.rank = rank
y.user.from_list_about_character(user_info_dict[i[0]])
self.list.append(y)
if need_myself:
y = UserScore(self.c, UserInfo(self.c, self.user.user_id))
y.song = self.song
y.select_score()
y.rank = -1
self.list.append(y)

View File

@@ -0,0 +1,155 @@
from .error import NoData, RedeemUnavailable
from .item import CollectionItemMixin, ItemFactory
class Redeem(CollectionItemMixin):
collection_item_const = {
'name': 'redeem',
'table_name': 'redeem_item',
'table_primary_key': 'code',
'id_name': 'code',
'items_name': 'items'
}
def __init__(self, c=None) -> None:
self.c = c
self.code: str = None
self.redeem_type: int = None
self.items: list = []
self.fragment: int = None
def to_dict(self, has_items: bool = True) -> dict:
r = {
'code': self.code,
'type': self.redeem_type
}
if has_items:
r['items'] = [x.to_dict() for x in self.items]
return r
def from_dict(self, d: dict) -> 'Redeem':
self.code = str(d['code'])
self.redeem_type = int(d.get('type') or d.get('redeem_type', 0))
self.items = [ItemFactory.from_dict(
i, c=self.c) for i in d.get('items', [])]
return self
def from_list(self, l: list) -> 'Redeem':
self.code = l[0]
self.redeem_type = l[1]
return self
def select_exists(self) -> bool:
self.c.execute(
'''select exists(select * from redeem where code=?)''', (self.code,))
return bool(self.c.fetchone()[0])
def select(self, code: str = None) -> 'Redeem':
if code:
self.code = code
self.c.execute('''select * from redeem where code=:a''',
{'a': self.code})
x = self.c.fetchone()
if x is None:
raise NoData(f'The redeem `{self.code}` does not exist.', 504)
self.redeem_type = x[1]
return self
def select_items(self) -> None:
self.c.execute('''select * from redeem_item where code=:a''',
{'a': self.code})
self.items = [ItemFactory.from_dict({
'item_id': i[1],
'type': i[2],
'amount': i[3] if i[3] else 1
}, self.c) for i in self.c.fetchall()]
def insert(self) -> None:
self.c.execute('''insert into redeem values(?,?)''',
(self.code, self.redeem_type))
def insert_items(self) -> None:
for i in self.items:
i.insert(ignore=True)
self.c.execute('''insert into redeem_item values(?,?,?,?)''', (
self.code, i.item_id, i.item_type, i.amount))
def insert_all(self) -> None:
self.insert()
self.insert_items()
def delete(self) -> None:
self.c.execute('''delete from redeem where code=?''', (self.code,))
def delete_redeem_item(self) -> None:
self.c.execute(
'''delete from redeem_item where code=?''', (self.code,))
def delete_all(self) -> None:
self.delete_redeem_item()
self.delete()
def update(self) -> None:
self.c.execute('''update redeem set type=? where code=?''',
(self.redeem_type, self.code))
class UserRedeem(Redeem):
'''
用户兑换码类
properties: `user` - `User`类或子类的实例
'''
def __init__(self, c=None, user=None) -> None:
super().__init__(c)
self.user = user
@property
def is_available(self) -> bool:
if self.redeem_type is None:
self.select()
if self.redeem_type == 0:
# 一次性
self.c.execute(
'''select exists(select * from user_redeem where code=:a)''', {'a': self.code})
if self.c.fetchone() == (1,):
return False
elif self.redeem_type == 1:
# 每个玩家一次
self.c.execute('''select exists(select * from user_redeem where code=:a and user_id=:b)''',
{'a': self.code, 'b': self.user.user_id})
if self.c.fetchone() == (1,):
return False
return True
def insert_user_redeem(self) -> None:
self.c.execute('''insert into user_redeem values(:b,:a)''',
{'a': self.code, 'b': self.user.user_id})
def claim_user_redeem(self, code: str = None) -> None:
if code:
self.code = code
if not self.is_available:
if self.redeem_type == 0:
raise RedeemUnavailable(
'The redeem `%s` is unavailable.' % self.code)
if self.redeem_type == 1:
raise RedeemUnavailable(
'The redeem `%s` is unavailable.' % self.code, 506)
if not self.items:
self.select_items()
self.insert_user_redeem()
self.fragment = 0
for i in self.items:
if i.item_type == 'fragment':
self.fragment += i.amount
else:
i.user_claim_item(self.user)

142
latest version/core/save.py Normal file
View File

@@ -0,0 +1,142 @@
import json
from time import time
from .config_manager import Config
from .constant import Constant
from .error import InputError, NoData
from .util import md5
class SaveData:
def __init__(self, c=None) -> None:
self.c = c
self.user = None
self.scores_data = []
self.clearlamps_data = []
self.clearedsongs_data = []
self.unlocklist_data = []
self.installid_data: str = ''
self.devicemodelname_data: str = ''
self.story_data = []
self.createdAt: int = 0
self.finalestate_data: str = ''
def to_dict(self):
return {
"user_id": self.user.user_id,
"story": {
"": self.story_data
},
"devicemodelname": {
"val": self.devicemodelname_data
},
"installid": {
"val": self.installid_data
},
"unlocklist": {
"": self.unlocklist_data
},
"clearedsongs": {
"": self.clearedsongs_data
},
"clearlamps": {
"": self.clearlamps_data
},
"scores": {
"": self.scores_data
},
"version": {
"val": 1
},
"createdAt": self.createdAt,
'finalestate': {
'val': self.finalestate_data
}
}
def select_scores(self, user) -> None:
'''
parameter: `user` - `User`类或子类的实例
'''
self.user = user
self.c.execute('''select scores_data, clearlamps_data from user_save where user_id=:a''',
{'a': user.user_id})
x = self.c.fetchone()
if not x:
raise NoData(f'User `{user.user_id}` has no cloud save data')
self.scores_data: list = json.loads(x[0])[""]
self.clearlamps_data: list = json.loads(x[1])[""]
def select_all(self, user) -> None:
'''
parameter: `user` - `User`类或子类的实例
'''
self.user = user
self.c.execute('''select * from user_save where user_id=:a''',
{'a': user.user_id})
x = self.c.fetchone()
if x:
self.scores_data = json.loads(x[1])[""]
self.clearlamps_data = json.loads(x[2])[""]
self.clearedsongs_data = json.loads(x[3])[""]
self.unlocklist_data = json.loads(x[4])[""]
self.installid_data = json.loads(x[5])["val"]
self.devicemodelname_data = json.loads(x[6])["val"]
self.story_data = json.loads(x[7])[""]
if x[8] is not None:
self.createdAt = int(x[8])
self.finalestate_data = x[9] if x[9] is not None else ''
if Config.SAVE_FULL_UNLOCK:
self.installid_data = "0fcec8ed-7b62-48e2-9d61-55041a22b123" # 使得可以进入存档选择上传或下载界面
for i in self.story_data:
i['c'] = True
i['r'] = True
for i in self.unlocklist_data:
x = i['unlock_key']
if x[-3:] == '101':
i['complete'] = 100
elif x[:16] == 'aegleseeker|2|3|':
i['complete'] = 10
elif x == 'saikyostronger|2|3|einherjar|2':
i['complete'] = 6
elif x == 'saikyostronger|2|3|laqryma|2':
i['complete'] = 3
elif x[-5:-2] == '109':
i['complete'] = 3
elif x.endswith('112'):
i['complete'] = 999
else:
i['complete'] = 1
self.finalestate_data = '|'.join(
['0', '100'] + [str(x[0]) if i in [64, 65, 66, 71] else str(x[1]) for i, x in enumerate(Constant.FINALE_SWITCH)] + ['1337'])
def update_all(self, user) -> None:
'''
parameter: `user` - `User`类或子类的实例
'''
self.createdAt = int(time() * 1000)
self.c.execute('''insert or replace into user_save values(:a,:b,:c,:d,:e,:f,:g,:h,:i,:j)''', {
'a': user.user_id, 'b': json.dumps({'': self.scores_data}), 'c': json.dumps({'': self.clearlamps_data}), 'd': json.dumps({'': self.clearedsongs_data}), 'e': json.dumps({'': self.unlocklist_data}), 'f': json.dumps({'val': self.installid_data}), 'g': json.dumps({'val': self.devicemodelname_data}), 'h': json.dumps({'': self.story_data}), 'i': self.createdAt, 'j': self.finalestate_data})
def set_value(self, key: str, value: str, checksum: str) -> None:
'''
从Arcaea客户端给的奇怪字符串中获取存档数据并进行数据校验
'''
if not value:
return None
if key not in self.__dict__:
raise KeyError(
'Property `%s` is not found in the instance of `SaveData` class.' % key)
if md5(value) == checksum:
if key in ('installid_data', 'devicemodelname_data', 'finalestate_data'):
self.__dict__[key] = json.loads(value)['val']
else:
self.__dict__[key] = json.loads(value)['']
else:
raise InputError('Hash value of cloud save data mismatches.')

View File

@@ -0,0 +1,718 @@
from base64 import b64encode
from os import urandom
from random import choices
from time import time
from .bgtask import BGTask, logdb_execute
from .config_manager import Config
from .constant import Constant
from .course import CoursePlay
from .error import NoData, StaminaNotEnough
from .item import ItemCore
from .song import Chart
from .sql import Connect, Query, Sql
from .util import get_today_timestamp, md5
from .world import BeyondWorldPlay, BreachedWorldPlay, WorldPlay
class Score:
def __init__(self) -> None:
self.c = None
self.song: 'Chart' = Chart()
self.score: int = None
self.shiny_perfect_count: int = None
self.perfect_count: int = None
self.near_count: int = None
self.miss_count: int = None
self.health: int = None
self.modifier: int = None
self.time_played: int = None
self.best_clear_type: int = None
self.clear_type: int = None
self.rating: float = None
self.score_v2: float = None # for `world_rank_score` of global rank
def set_score(self, score: int, shiny_perfect_count: int, perfect_count: int, near_count: int, miss_count: int, health: int, modifier: int, time_played: int, clear_type: int):
self.score = int(score) if score is not None else 0
self.shiny_perfect_count = int(
shiny_perfect_count) if shiny_perfect_count is not None else 0
self.perfect_count = int(
perfect_count) if perfect_count is not None else 0
self.near_count = int(near_count) if near_count is not None else 0
self.miss_count = int(miss_count) if miss_count is not None else 0
self.health = int(health) if health is not None else 0
self.modifier = int(modifier) if modifier is not None else 0
self.time_played = int(time_played) if time_played is not None else 0
self.clear_type = int(clear_type) if clear_type is not None else 0
@staticmethod
def get_song_grade(score: int) -> int:
'''分数转换为评级'''
if score >= 9900000: # EX+
return 6
if score >= 9800000: # EX
return 5
if score >= 9500000: # AA
return 4
if score >= 9200000: # A
return 3
if score >= 8900000: # B
return 2
if score >= 8600000: # C
return 1
return 0
@property
def song_grade(self) -> int:
return self.get_song_grade(self.score)
@staticmethod
def get_song_state(clear_type: int) -> int:
'''clear_type转换为成绩状态用数字大小标识便于比较'''
if clear_type == 3: # PM
return 5
if clear_type == 2: # FC
return 4
if clear_type == 5: # Hard Clear
return 3
if clear_type == 1: # Clear
return 2
if clear_type == 4: # Easy Clear
return 1
return 0 # Track Lost
@property
def song_state(self) -> int:
return self.get_song_state(self.clear_type)
@property
def all_note_count(self) -> int:
return self.perfect_count + self.near_count + self.miss_count
@property
def is_valid(self) -> bool:
'''分数有效性检查'''
if self.shiny_perfect_count < 0 or self.perfect_count < 0 or self.near_count < 0 or self.miss_count < 0 or self.score < 0 or self.time_played <= 0:
return False
if self.song.difficulty not in (0, 1, 2, 3, 4):
return False
all_note = self.all_note_count
if all_note == 0:
return False
calc_score = 10000000 / all_note * \
(self.perfect_count + self.near_count/2) + self.shiny_perfect_count
if abs(calc_score - self.score) >= 5:
return False
return True
@staticmethod
def calculate_rating(defnum: float, score: int) -> float:
'''计算rating谱面定数小于等于0视为Unrank返回值会为-1这里的defnum = Chart const'''
if not defnum or defnum <= 0:
# 谱面没定数或者定数小于等于0被视作Unrank
return -1
if score >= 10000000:
ptt = defnum + 2
elif score < 9800000:
ptt = defnum + (score-9500000) / 300000
ptt = max(ptt, 0)
else:
ptt = defnum + 1 + (score-9800000) / 200000
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 & 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:
r = {
"rating": self.rating,
"modifier": self.modifier,
"time_played": self.time_played,
"health": self.health,
"clear_type": self.clear_type,
"miss_count": self.miss_count,
"near_count": self.near_count,
"perfect_count": self.perfect_count,
"shiny_perfect_count": self.shiny_perfect_count,
"score": self.score,
"difficulty": self.song.difficulty,
"song_id": self.song.song_id
}
if self.song.song_name is not None:
r["song_name"] = self.song.song_name
return r
class UserScore(Score):
def __init__(self, c=None, user=None) -> None:
'''
parameter: `user` - `UserInfo`类或子类的实例
'''
super().__init__()
self.c = c
self.user = user
self.rank = None # 成绩排名给Ranklist用的
def select_score(self) -> None:
'''查询成绩以及用户搭档信息,单次查询可用,不要集体循环查询'''
self.c.execute('''select * from best_score 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})
x = self.c.fetchone()
if x is None:
raise NoData('No score data.')
self.user.select_user_about_character()
self.from_list(x)
def from_list(self, x: list) -> 'UserScore':
if self.song.song_id is None:
self.song.song_id = x[1]
if self.song.difficulty is None:
self.song.difficulty = x[2]
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
def to_dict(self, has_user_info: bool = True) -> dict:
r = super().to_dict()
r['best_clear_type'] = self.best_clear_type
if has_user_info:
r['user_id'] = self.user.user_id
r['name'] = self.user.name
r['is_skill_sealed'] = self.user.is_skill_sealed
character = self.user.character_displayed
r['is_char_uncapped'] = character.is_uncapped_displayed
r['character'] = character.character_id
if self.rank:
r['rank'] = self.rank
return r
class UserPlay(UserScore):
def __init__(self, c=None, user=None) -> None:
super().__init__(c, user)
self.song_token: str = None
self.song_hash: str = None
self.submission_hash: str = None
self.beyond_gauge: int = None
self.unrank_flag: bool = None
self.new_best_protect_flag: bool = None
self.ptt: 'Potential' = None
self.is_world_mode: bool = None
self.stamina_multiply: int = None
self.fragment_multiply: int = None
self.prog_boost_multiply: int = None
self.beyond_boost_gauge_usage: int = None
self.ptt: Potential = None # 临时用来计算用户ptt的
self.world_play: 'WorldPlay' = None
self.course_play_state: int = None
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:
return {}
if self.course_play_state == 4:
r = self.course_play.to_dict()
elif self.is_world_mode:
r = self.world_play.to_dict()
else:
r = {}
r['user_rating'] = self.user.rating_ptt
r['finale_challenge_higher'] = self.rating > self.ptt.value
r['global_rank'] = self.user.global_rank
r['finale_play_value'] = 9.065 * self.rating ** 0.5 # by Lost-MSth
return r
@property
def is_protected(self) -> bool:
return self.health == -1 or int(self.score) >= 9800000 or self.new_best_protect_flag
@property
def is_valid(self) -> bool:
'''分数有效性检查带hash校验'''
if not super().is_valid:
return False
# 歌曲谱面MD5检查服务器没有谱面就不管了
from .download import get_song_file_md5
songfile_hash = get_song_file_md5(
self.song.song_id, str(self.song.difficulty) + '.aff')
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}'''
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))
if checksum != self.submission_hash:
return False
return True
def get_play_state(self) -> None:
'''检查token当然这里不管有没有是用来判断世界模式和课题模式的'''
if self.song_token == '1145141919810':
# 硬编码检查,绕过数据库
self.is_world_mode = False
self.course_play_state = -1
return None
self.c.execute(
'''select * from songplay_token where token=:a ''', {'a': self.song_token})
x = self.c.fetchone()
if not x:
self.is_world_mode = False
self.course_play_state = -1
return None
# raise NoData('No token data.')
# self.song.set_chart(x[2], x[3])
if x[4]:
self.course_play = CoursePlay(self.c, self.user, self)
self.course_play.course_id = x[4]
self.course_play.score = x[6]
self.course_play.clear_type = x[7]
self.is_world_mode = False
self.course_play_state = x[5]
else:
self.stamina_multiply = int(x[8])
self.fragment_multiply = int(x[9])
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,
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})
x = self.c.fetchone()
if x:
self.prog_boost_multiply = 300 if x[0] == 300 else 0
if x[1] < self.beyond_boost_gauge_usage or self.beyond_boost_gauge_usage not in (100, 200):
# 注意偷懒了没判断是否是beyond图
self.beyond_boost_gauge_usage = 0
self.user.select_user_about_current_map()
self.user.current_map.select_map_info()
self.user.select_user_about_stamina()
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()
# 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
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:
'''课题模式打歌初始化'''
self.song_token = 'c_' + b64encode(urandom(64)).decode()
if course_id is not None:
self.course_play.course_id = course_id
self.course_play_state = 0
self.course_play.score = 0
self.course_play.clear_type = 3 # 设置为PM即最大值
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)
x.item_id = 'core_course_skip_purchase'
x.amount = -1
x.user_claim_item(self.user)
else:
if self.user.stamina.stamina < Constant.COURSE_STAMINA_COST:
raise StaminaNotEnough('Stamina is not enough.')
self.user.stamina.stamina -= Constant.COURSE_STAMINA_COST
self.user.stamina.update()
def update_token_for_course(self) -> None:
'''课题模式更新token并查用户体力'''
previous_token = self.song_token
self.song_token = 'c_' + b64encode(urandom(64)).decode()
self.c.execute('''update songplay_token set token=? where token=?''',
(self.song_token, previous_token))
self.user.select_user_about_stamina()
def update_play_state_for_course(self) -> None:
self.c.execute('''update songplay_token set course_state=?, course_score=?, course_clear_type=? where token=?''',
(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''', {
'a': self.user.user_id})
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,
self.score, self.shiny_perfect_count, self.perfect_count, self.near_count, self.miss_count, self.health, self.modifier, self.clear_type, self.rating))
def record_rating_ptt(self, user_rating_ptt: float) -> None:
'''向log数据库记录用户ptt变化'''
today_timestamp = get_today_timestamp()
with Connect(Config.SQLITE_LOG_DATABASE_PATH) as c2:
old_ptt = c2.execute('''select rating_ptt from user_rating where user_id=? and time=?''', (
self.user.user_id, today_timestamp)).fetchone()
old_ptt = 0 if old_ptt is None else old_ptt[0]
if old_ptt != user_rating_ptt:
c2.execute('''insert or replace into user_rating values(?,?,?)''',
(self.user.user_id, today_timestamp, user_rating_ptt))
def upload_score(self) -> None:
'''上传分数包括user的recent更新best更新recent30更新世界模式计算'''
self.get_play_state()
self.get_rating_by_calc()
if self.rating < 0:
self.unrank_flag = True
self.rating = 0
else:
self.unrank_flag = False
self.time_played = int(time())
# 记录分数
self.record_score()
# recent更新
self.c.execute('''update user set song_id = :b, difficulty = :c, 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''', {
'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 * 1000})
# 成绩录入
self.c.execute('''select score, best_clear_type from best_score 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})
x = self.c.fetchone()
if not x:
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.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.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.ptt.r30_push_score(self)
# 总PTT更新
user_rating_ptt = self.ptt.value
self.user.rating_ptt = int(user_rating_ptt * 100)
BGTask(self.record_rating_ptt, user_rating_ptt) # 记录总PTT变换
self.c.execute('''update user set rating_ptt = :a where user_id = :b''', {
'a': self.user.rating_ptt, 'b': self.user.user_id})
# 世界模式判断
if self.is_world_mode:
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()
# 课题模式判断
if self.course_play_state >= 0:
self.course_play.update()
class Potential:
'''
用户潜力值计算处理类
property: `user` - `User`类或子类的实例
'''
def __init__(self, c=None, user=None):
self.c = c
self.user = user
self.r30_tuples: 'list[tuple[int, str, int, float]]' = None
self.r30: 'list[Score]' = None
self.b30: list = None
@property
def value(self) -> float:
'''计算用户潜力值'''
return self.best_30 * Constant.BEST30_WEIGHT + self.recent_10 * Constant.RECENT10_WEIGHT
@property
def best_30(self) -> float:
'''获取用户best30的总潜力值'''
self.c.execute('''select rating from best_score where user_id = :a order by rating DESC limit 30''', {
'a': self.user.user_id})
return sum(x[0] for x in self.c.fetchall())
def select_recent_30_tuple(self) -> None:
'''获取用户recent30数据'''
self.c.execute(
'''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 = []
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_tuples is None:
self.select_recent_30_tuple()
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]
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()
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
for i in range(30):
if self.r30_tuples[i][0] == r_index:
self.r30_tuples[i] = x
break
def r30_push_score(self, user_score: 'UserPlay | UserScore') -> None:
'''根据新成绩调整 r30'''
if self.r30_tuples is None:
self.select_recent_30_tuple()
if len(self.r30_tuples) < 30:
self.update_one_r30(len(self.r30_tuples), user_score)
return None
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:
'''
用户分数查询类
properties: `user` - `User`类或子类的实例
'''
def __init__(self, c=None, user=None):
self.c = c
self.user = user
self.scores: list = None
self.query: 'Query' = Query(['user_id', 'song_id', 'difficulty'], ['song_id'], [
'rating', 'difficulty', 'song_id', 'score', 'time_played'])
def to_dict_list(self) -> list:
return [x.to_dict(has_user_info=False) for x in self.scores]
def select_from_user(self, user=None) -> None:
'''获取用户的best_score数据'''
if user is not None:
self.user = user
self.query.query_append({'user_id': self.user.user_id})
self.query.sort += [{'column': 'rating', 'order': 'DESC'}]
x = Sql(self.c).select('best_score', query=self.query)
self.scores = [UserScore(self.c, self.user).from_list(i) for i in x]
def select_song_name(self) -> None:
'''为所有成绩中的song_id查询song_name'''
if self.scores is None:
return
for score in self.scores:
self.c.execute(
'''select name from chart where song_id = ?''', (score.song.song_id,))
x = self.c.fetchone()
score.song.song_name = x[0] if x else ''

120
latest version/core/song.py Normal file
View File

@@ -0,0 +1,120 @@
from .error import NoData
from .config_manager import Config
class Chart:
# defnum: chart const * 10
def __init__(self, c=None, song_id: str = None, difficulty: int = None) -> None:
self.c = c
self.song_id: str = None
self.difficulty: int = None
self.set_chart(song_id, difficulty)
self.defnum: int = None
self.song_name: str = None
def to_dict(self) -> dict:
return {
'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:
return self.defnum / 10 if self.defnum else -1
@property
def song_id_difficulty(self) -> str:
return '%s%d' % (self.song_id, self.difficulty)
def set_chart(self, song_id: str = None, difficulty: int = None) -> None:
self.song_id = song_id
self.difficulty = int(difficulty) if difficulty is not None else None
def select(self) -> None:
self.c.execute(
'''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:
self.defnum = -10
else:
raise NoData(f'The song `{self.song_id}` does not exist.', 120)
else:
self.defnum = x[self.difficulty]
class Song:
def __init__(self, c=None, song_id: str = None) -> None:
self.c = c
self.song_id: str = song_id
self.name: str = None
self.charts: dict = None
def to_dict(self) -> dict:
return {
'song_id': self.song_id,
'name': self.name,
'charts': [chart.to_dict() for chart in self.charts]
}
def from_list(self, x: list) -> 'Song':
if self.song_id is None:
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), 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), 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(
chart['chart_const'] * 10)
return self
def delete(self) -> None:
self.c.execute(
'''delete from chart where song_id=?''', (self.song_id,))
def update(self) -> None:
'''全部更新'''
self.c.execute(
'''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, self.charts[4].defnum))
def select_exists(self, song_id: str = None) -> bool:
if song_id is not None:
self.song_id = song_id
self.c.execute(
'''select exists(select * from chart where song_id=?)''', (self.song_id,))
return bool(self.c.fetchone()[0])
def select(self, song_id: str = None) -> 'Song':
if song_id is not None:
self.song_id = song_id
self.c.execute('''select * from chart where song_id=:a''', {
'a': self.song_id})
x = self.c.fetchone()
if x is None:
raise NoData(f'The song `{self.song_id}` does not exist.')
return self.from_list(x)

559
latest version/core/sql.py Normal file
View File

@@ -0,0 +1,559 @@
import os
import sqlite3
import traceback
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:
# 数据库连接类,上下文管理
logger = None
def __init__(self, file_path: str = Constant.SQLITE_DATABASE_PATH, in_memory: bool = False, logger=None) -> None:
"""
数据库连接默认连接arcaea_database.db
接受:文件路径
返回sqlite3连接操作对象
"""
self.file_path = file_path
self.in_memory: bool = in_memory
if logger is not None:
self.logger = logger
self.conn: sqlite3.Connection = None
self.c: sqlite3.Cursor = None
def __enter__(self) -> sqlite3.Cursor:
if self.in_memory:
self.conn = sqlite3.connect(
'file:arc_tmp?mode=memory&cache=shared', uri=True, timeout=10)
else:
self.conn = sqlite3.connect(self.file_path, timeout=10)
self.c = self.conn.cursor()
return self.c
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
flag = True
if exc_type is not None:
if issubclass(exc_type, ArcError):
flag = False
else:
self.conn.rollback()
self.logger.error(
traceback.format_exception(exc_type, exc_val, exc_tb))
self.conn.commit()
self.conn.close()
return flag
class Query:
'''查询参数类'''
def __init__(self, query_able: list = None, fuzzy_query_able: list = None, sort_able: list = None) -> None:
self.query_able: list = query_able # None表示不限制
self.fuzzy_query_able: list = fuzzy_query_able # None表示不限制
self.sort_able: list = sort_able
self.__limit: int = -1
self.__offset: int = 0
# {'name': 'admin'} or {'name': ['admin', 'user']}
self.__query: dict = {}
self.__fuzzy_query: dict = {} # {'name': 'dmi'}
# [{'column': 'user_id', 'order': 'ASC'}, ...]
self.__sort: list = []
@property
def limit(self) -> int:
return self.__limit
@limit.setter
def limit(self, limit: int) -> None:
if not isinstance(limit, int):
raise InputError(api_error_code=-101)
self.__limit = limit
@property
def offset(self) -> int:
return self.__offset
@offset.setter
def offset(self, offset: int) -> None:
if not isinstance(offset, int):
raise InputError(api_error_code=-101)
self.__offset = offset
@property
def query(self) -> dict:
return self.__query
@query.setter
def query(self, query: dict) -> None:
self.__query = {}
self.query_append(query)
def query_append(self, query: dict) -> None:
if not isinstance(query, dict):
raise InputError(api_error_code=-101)
if self.query_able is not None and query and not set(query).issubset(set(self.query_able)):
raise InputError(api_error_code=-102)
if not self.__query:
self.__query = query
else:
self.__query.update(query)
@property
def fuzzy_query(self) -> dict:
return self.__fuzzy_query
@fuzzy_query.setter
def fuzzy_query(self, fuzzy_query: dict) -> None:
self.__fuzzy_query = {}
self.fuzzy_query_append(fuzzy_query)
def fuzzy_query_append(self, fuzzy_query: dict) -> None:
if not isinstance(fuzzy_query, dict):
raise InputError(api_error_code=-101)
if self.fuzzy_query_able is not None and fuzzy_query and not set(fuzzy_query).issubset(set(self.fuzzy_query_able)):
raise InputError(api_error_code=-102)
if not self.__fuzzy_query:
self.__fuzzy_query = fuzzy_query
else:
self.__fuzzy_query.update(fuzzy_query)
@property
def sort(self) -> list:
return self.__sort
@sort.setter
def sort(self, sort: list) -> None:
if not isinstance(sort, list):
raise InputError(api_error_code=-101)
if self.sort_able is not None and sort:
for x in sort:
if not isinstance(x, dict):
raise InputError(api_error_code=-101)
if 'column' not in x or x['column'] not in self.sort_able:
raise InputError(api_error_code=-103)
if 'order' not in x:
x['order'] = 'ASC'
else:
if x['order'] not in ['ASC', 'DESC']:
raise InputError(api_error_code=-104)
self.__sort = sort
def set_value(self, limit=-1, offset=0, query=None, fuzzy_query=None, sort=None) -> None:
self.limit = limit
self.offset = offset
self.query = query if query is not None else {}
self.fuzzy_query = fuzzy_query if fuzzy_query is not None else {}
self.sort = sort if sort is not None else []
def from_dict(self, d: dict) -> 'Query':
self.set_value(d.get('limit', -1), d.get('offset', 0),
d.get('query', {}), d.get('fuzzy_query', {}), d.get('sort', []))
return self
def from_args(self, query: dict, limit: int = -1, offset: int = 0, sort: list = None, fuzzy_query: dict = None) -> 'Query':
self.set_value(limit, offset, query, fuzzy_query, sort)
return self
class Sql:
'''
数据库增查删改类
'''
def __init__(self, c=None) -> None:
self.c = c
@staticmethod
def get_select_sql(table_name: str, target_column: list = None, query: 'Query' = None):
'''拼接单表内行查询单句sql语句返回语句和参数列表'''
sql_list = []
if not target_column:
sql = f'select * from {table_name}'
else:
sql = f"select {', '.join(target_column)} from {table_name}"
if query is None:
return sql, sql_list
where_key = []
for k, v in query.query.items():
if isinstance(v, list):
where_key.append(f"{k} in ({','.join(['?'] * len(v))})")
sql_list.extend(v)
else:
where_key.append(f'{k}=?')
sql_list.append(v)
for k, v in query.fuzzy_query.items():
where_key.append(f'{k} like ?')
sql_list.append(f'%{v}%')
if where_key:
sql += ' where '
sql += ' and '.join(where_key)
if query.sort:
sql += ' order by ' + \
', '.join([x['column'] + ' ' + x['order'] for x in query.sort])
if query.limit >= 0:
sql += ' limit ? offset ?'
sql_list.append(query.limit)
sql_list.append(query.offset)
return sql, sql_list
@staticmethod
def get_insert_sql(table_name: str, key: list = None, value_len: int = None, insert_type: str = None) -> str:
'''拼接insert语句请注意只返回sql语句insert_type为replace或ignore'''
if key is None:
key = []
insert_type = 'replace' if insert_type in [
'replace', 'R', 'r', 'REPLACE'] else 'ignore'
return ('insert into ' if insert_type is None else 'insert or ' + insert_type + ' into ') + table_name + ('(' + ','.join(key) + ')' if key else '') + ' values(' + ','.join(['?'] * (len(key) if value_len is None else value_len)) + ')'
@staticmethod
def get_update_sql(table_name: str, d: dict = None, query: 'Query' = None):
if not d:
return None
sql_list = []
sql = f"update {table_name} set {','.join([f'{k}=?' for k in d.keys()])}"
sql_list.extend(d.values())
if query is None:
return sql, sql_list
where_key = []
for k, v in query.query.items():
if isinstance(v, list):
where_key.append(f"{k} in ({','.join(['?'] * len(v))})")
sql_list.extend(v)
else:
where_key.append(f'{k}=?')
sql_list.append(v)
for k, v in query.fuzzy_query.items():
where_key.append(f'{k} like ?')
sql_list.append(f'%{v}%')
if where_key:
sql += ' where '
sql += ' and '.join(where_key)
return sql, sql_list
@staticmethod
def get_update_many_sql(table_name: str, key: list = None, where_key: list = None) -> str:
'''拼接update语句这里不用Query类也不用字典请注意只返回sql语句'''
if not key or not where_key:
return None
return f"update {table_name} set {','.join([f'{k}=?' for k in key])} where {' and '.join([f'{k}=?' for k in where_key])}"
@staticmethod
def get_delete_sql(table_name: str, query: 'Query' = None):
'''拼接删除语句query中只有query和fuzzy_query会被处理'''
sql = f'delete from {table_name}'
if query is None:
return sql, []
sql_list = []
where_key = []
for k, v in query.query.items():
if isinstance(v, list):
where_key.append(f"{k} in ({','.join(['?'] * len(v))})")
sql_list.extend(v)
else:
where_key.append(f'{k}=?')
sql_list.append(v)
for k, v in query.fuzzy_query.items():
where_key.append(f'{k} like ?')
sql_list.append(f'%{v}%')
if where_key:
sql += ' where '
sql += ' and '.join(where_key)
return sql, sql_list
def select(self, table_name: str, target_column: list = None, query: 'Query' = None) -> list:
'''单表内行select单句sql语句返回fetchall数据'''
sql, sql_list = self.get_select_sql(table_name, target_column, query)
self.c.execute(sql, sql_list)
return self.c.fetchall()
def select_exists(self, table_name: str, target_column: list = None, query: 'Query' = None) -> bool:
'''单表内行select exists单句sql语句返回bool值'''
sql, sql_list = self.get_select_sql(table_name, target_column, query)
self.c.execute('select exists(' + sql + ')', sql_list)
return self.c.fetchone() == (1,)
def insert(self, table_name: str, key: list, value: tuple, insert_type: str = None) -> None:
'''单行插入或覆盖插入key传[]则为全部列insert_type为replace或ignore'''
self.c.execute(self.get_insert_sql(
table_name, key, len(value), insert_type), value)
def insert_many(self, table_name: str, key: list, value_list: list, insert_type: str = None) -> None:
'''多行插入或覆盖插入key传[]则为全部列insert_type为replace或ignore'''
if not value_list:
return
self.c.executemany(self.get_insert_sql(
table_name, key, len(value_list[0]), insert_type), value_list)
def update(self, table_name: str, d: dict, query: 'Query' = None) -> None:
'''单表内行update单句sql语句'''
if not d:
return
sql, sql_list = self.get_update_sql(table_name, d, query)
self.c.execute(sql, sql_list)
def update_many(self, table_name: str, key: list, value_list: list, where_key: list, where_value_list: list) -> None:
'''单表内行update多句sql语句这里不用Query类也不用字典要求值list长度一致有点像insert_many'''
if not key or not value_list or not where_key or not where_value_list or not len(key) == len(value_list[0]) or not len(where_key) == len(where_value_list[0]) or not len(value_list) == len(where_value_list):
raise ValueError
self.c.executemany(self.get_update_many_sql(
table_name, key, where_key), [x + y for x, y in zip(value_list, where_value_list)])
def delete(self, table_name: str, query: 'Query' = None) -> None:
'''删除query中只有query和fuzzy_query会被处理'''
sql, sql_list = self.get_delete_sql(table_name, query)
self.c.execute(sql, sql_list)
def get_table_info(self, table_name: str):
'''得到表结构,返回主键列表和字段名列表'''
pk = []
name = []
self.c.execute(f'''pragma table_info ("{table_name}")''') # 这里无法参数化
x = self.c.fetchall()
if x:
for i in x:
name.append(i[1])
if i[5] != 0:
pk.append(i[1])
return pk, name
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中的冲突信息会被覆盖'''
c1.execute(
'''select * from sqlite_master where type = 'table' and name = :a''', {'a': table_name})
c2.execute(
'''select * from sqlite_master where type = 'table' and name = :a''', {'a': table_name})
if not c1.fetchone() or not c2.fetchone():
return False
sql1 = Sql(c1)
sql2 = Sql(c2)
db1_pk, db1_name = sql1.get_table_info(table_name)
db2_pk, db2_name = sql2.get_table_info(table_name)
if db1_pk != db2_pk:
return False
public_column = list(filter(lambda x: x in db2_name, db1_name))
sql2.insert_many(table_name, public_column, sql1.select(
table_name, public_column), insert_type='replace')
return True
@staticmethod
def update_user_char_full(c) -> None:
'''用character表数据更新user_char_full'''
c.execute('''select character_id, max_level, is_uncapped from character''')
x = c.fetchall()
c.execute('''select user_id from user''')
y = c.fetchall()
c.execute('''delete from user_char_full''')
for i in x:
exp = 25000 if i[1] == 30 else 10000
c.executemany('''insert into user_char_full values(?,?,?,?,?,?,0)''', [
(j[0], i[0], i[1], exp, i[2], 0) for j in y])
def update_database(self) -> None:
'''
将c1数据库不存在数据加入或覆盖到c2数据库上
对于c2更新一些表并用character数据更新user_char_full
'''
with Connect(self.c2_path) as c2:
with Connect(self.c1_path) as c1:
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:
self.update_one_table(c1, c2, 'character')
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:
def __init__(self, c1_path: str = Config.SQLITE_LOG_DATABASE_PATH) -> None:
self.c1_path = c1_path
# self.c2_path = c2_path
self.init_folder_path = Config.DATABASE_INIT_PATH
self.c = None
@property
def sql_path(self) -> str:
return os.path.join(self.init_folder_path, 'log_tables.sql')
def table_update(self) -> None:
'''直接更新数据库结构'''
with open(self.sql_path, 'r') as f:
self.c.executescript(f.read())
self.c.execute(
'''insert or replace into cache values("version", :a, -1);''', {'a': ARCAEA_LOG_DATBASE_VERSION})
def update_database(self) -> None:
with Connect(self.c1_path) as c:
self.c = c
self.table_update()
class MemoryDatabase:
conn = sqlite3.connect('file:arc_tmp?mode=memory&cache=shared', uri=True)
def __init__(self):
self.c = self.conn.cursor()
self.c.execute('''PRAGMA journal_mode = OFF''')
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

@@ -0,0 +1,19 @@
from time import time
from .constant import Constant
class GameInfo:
def __init__(self):
pass
def to_dict(self) -> dict:
return {
"max_stamina": Constant.MAX_STAMINA,
"stamina_recover_tick": Constant.STAMINA_RECOVER_TICK,
"core_exp": Constant.CORE_EXP,
"curr_ts": int(time()*1000),
"level_steps": [{'level': k, 'level_exp': v} for k, v in Constant.LEVEL_STEPS.items()],
"world_ranking_enabled": True,
"is_byd_chapter_unlocked": True
}

885
latest version/core/user.py Normal file
View File

@@ -0,0 +1,885 @@
import base64
import hashlib
import time
from os import urandom
from random import randint
from .character import UserCharacter, UserCharacterList
from .config_manager import Config
from .constant import Constant
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, UserKVTable
from .world import Map, MapParser, UserMap, UserStamina
def code_get_id(c, user_code: str) -> int:
# 用user_code获取user_id
c.execute('''select user_id from user where user_code = :a''',
{'a': user_code})
x = c.fetchone()
if x is not None:
user_id = int(x[0])
else:
raise NoData('No user.', 401, -3)
return user_id
class User:
def __init__(self) -> None:
self.name: str = None
self.email: str = None
self.password: str = None
self.user_id: int = None
self.user_code: str = None
self.join_date = None
self.rating_ptt: int = None # 100 times
self.ticket: int = None
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值'''
return hashlib.sha256(self.password.encode("utf8")).hexdigest()
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
def set_name(self, name: str):
if 3 <= len(name) <= 16:
self.c.execute(
'''select exists(select * from user where name = :name)''', {'name': name})
if self.c.fetchone() == (0,):
self.name = name
else:
raise DataExist('Username exists.', 101, -210)
else:
raise InputError('Username is invalid.')
def set_password(self, password: str):
if 8 <= len(password) <= 32:
self.password = password
else:
raise InputError('Password is invalid.')
def set_email(self, email: str):
# 邮箱格式懒得多判断
if 4 <= len(email) <= 64 and '@' in email and '.' in email:
self.c.execute(
'''select exists(select * from user where email = :email)''', {'email': email})
if self.c.fetchone() == (0,):
self.email = email
else:
raise DataExist('Email address exists.', 102, -211)
else:
raise InputError('Email address is invalid.')
def set_user_code(self, user_code: str) -> None:
'''设置用户的user_code'''
if len(user_code) == 9 and user_code.isdigit():
self.c.execute(
'''select exists(select * from user where user_code = ?)''', (user_code, ))
if self.c.fetchone() == (0,):
self.user_code = user_code
else:
raise DataExist('User code exists.', 103, -212)
else:
raise InputError('User code is invalid.')
def _build_user_code(self):
# 生成9位的user_code用的自然是随机
random_times = 0
while random_times <= 1000:
random_times += 1
user_code = ''.join([str(randint(0, 9)) for _ in range(9)])
self.c.execute('''select exists(select * from user where user_code = :user_code)''',
{'user_code': user_code})
if self.c.fetchone() == (0,):
break
if random_times <= 1000:
self.user_code = user_code
else:
raise ArcError('No available user code.')
def _build_user_id(self):
# 生成user_id往后加1
self.c.execute('''select max(user_id) from user''')
x = self.c.fetchone()
if x[0] is not None:
self.user_id = x[0] + 1
else:
self.user_id = 2000001
def _insert_user_char(self):
# 为用户添加初始角色
self.c.execute('''insert into user_char values(?,?,?,?,?,?,0)''',
(self.user_id, 0, 1, 0, 0, 0))
self.c.execute('''insert into user_char values(?,?,?,?,?,?,0)''',
(self.user_id, 1, 1, 0, 0, 0))
self.c.execute(
'''select character_id, max_level, is_uncapped from character''')
x = self.c.fetchall()
if x:
for i in x:
exp = 25000 if i[1] == 30 else 10000
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, 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()
if self.user_id is None:
self._build_user_id()
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, 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):
# 密码和token的加密方式为 SHA-256
limiter = ArcLimiter(Config.GAME_LOGIN_RATE_LIMIT, 'game_login')
def __init__(self, c) -> None:
super().__init__()
self.c = c
self.device_id = None
self.ip = None
self.token = None
self.now = 0
def set_name(self, name: str):
self.name = name
def set_password(self, password: str):
self.password = password
def set_device_id(self, device_id: str):
self.device_id = device_id
def set_ip(self, ip: str):
self.ip = ip
def _arc_auto_ban(self):
# 多设备自动封号机制,返回封号时长
self.c.execute('''delete from login where user_id=?''',
(self.user_id, ))
self.c.execute(
'''select ban_flag from user where user_id=?''', (self.user_id,))
x = self.c.fetchone()
if x and x[0] != '' and x[0] is not None:
last_ban_time = int(x[0].split(':', 1)[0])
i = 0
while i < len(Constant.BAN_TIME) - 1 and Constant.BAN_TIME[i] <= last_ban_time:
i += 1
ban_time = Constant.BAN_TIME[i]
else:
ban_time = Constant.BAN_TIME[0]
ban_flag = ':'.join(
(str(ban_time), str(self.now + ban_time * 86400000)))
self.c.execute('''update user set ban_flag=? where user_id=?''',
(ban_flag, self.user_id))
return ban_time * 86400000
def _check_device(self, device_list):
should_delete_num = len(
device_list) + 1 - Config.LOGIN_DEVICE_NUMBER_LIMIT
if not Config.ALLOW_LOGIN_SAME_DEVICE:
if self.device_id in device_list: # 对相同设备进行删除
self.c.execute('''delete from login where login_device=:a and user_id=:b''', {
'a': self.device_id, 'b': self.user_id})
should_delete_num = len(
device_list) + 1 - device_list.count(self.device_id) - Config.LOGIN_DEVICE_NUMBER_LIMIT
if should_delete_num >= 1: # 删掉多余token
if not Config.ALLOW_LOGIN_SAME_DEVICE and Config.ALLOW_BAN_MULTIDEVICE_USER_AUTO: # 自动封号检查
self.c.execute(
'''select count(*) from login where user_id=? and login_time>?''', (self.user_id, self.now-86400000))
if self.c.fetchone()[0] >= Config.LOGIN_DEVICE_NUMBER_LIMIT:
remaining_ts = self._arc_auto_ban()
raise UserBan('Too many devices logging in during 24 hours.', 105, extra_data={
'remaining_ts': remaining_ts})
self.c.execute('''delete from login where rowid in (select rowid from login where user_id=:user_id limit :a);''',
{'user_id': self.user_id, 'a': int(should_delete_num)})
def login(self, name: str = '', password: str = '', device_id: str = '', ip: str = ''):
if name:
self.set_name(name)
if password:
self.set_password(password)
if device_id:
self.set_device_id(device_id)
if ip:
self.set_ip(ip)
if not self.limiter.hit(name):
raise RateLimit(
f'Too many login attempts of username `{name}`', 123, -203)
self.c.execute('''select user_id, password, ban_flag from user where name = :name''', {
'name': self.name})
x = self.c.fetchone()
if x is None:
raise NoData(f'Username `{self.name}` does not exist.', 104)
self.user_id = x[0]
self.now = int(time.time() * 1000)
if x[2] is not None and x[2] != '':
# 自动封号检查
ban_timestamp = int(x[2].split(':', 1)[1])
if ban_timestamp > self.now:
raise UserBan(f'Too many devices user `{self.user_id}` logging in during 24 hours.', 105, extra_data={
'remaining_ts': ban_timestamp-self.now})
if x[1] == '':
# 账号封禁
raise UserBan(
f'The account `{self.user_id}` has been banned.', 106)
if x[1] != self.hash_pwd:
raise NoAccess(f'Wrong password of user `{self.user_id}`', 104)
self.token = base64.b64encode(hashlib.sha256(
(str(self.user_id) + str(self.now)).encode("utf8") + urandom(8)).digest()).decode()
self.c.execute(
'''select login_device from login where user_id = :user_id''', {"user_id": self.user_id})
y = self.c.fetchall()
if y:
self._check_device([i[0] if i[0] else '' for i in y])
self.c.execute('''insert into login values(:access_token, :user_id, :time, :ip, :device_id)''', {
'user_id': self.user_id, 'access_token': self.token, 'device_id': self.device_id, 'time': self.now, 'ip': self.ip})
class UserAuth(User):
def __init__(self, c) -> None:
super().__init__()
self.c = c
self.token = None
def token_get_id(self):
# 用token获取id没有考虑不同用户token相同情况说不定会有bug
self.c.execute('''select user_id from login where access_token = :token''', {
'token': self.token})
x = self.c.fetchone()
if x is not None:
self.user_id = x[0]
else:
raise NoAccess('Wrong token.', -4)
return self.user_id
class UserInfo(User):
def __init__(self, c, user_id=None) -> None:
User.__init__(self)
self.c = c
self.user_id = user_id
self.character = None
self.is_skill_sealed = False
self.is_hide_rating = False
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
self.characters: 'UserCharacterList' = None
self.__friends: list = None
self.__world_unlocks: list = None
self.__world_songs: list = None
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:
x = UserItemList(self.c, self).select_from_type('core')
self.__cores = [{'core_type': i.item_id,
'amount': i.amount} for i in x.items]
return self.__cores
@property
def singles(self) -> list:
if self.__singles is None:
x = UserItemList(self.c, self).select_from_type('single')
self.__singles = [i.item_id for i in x.items]
return self.__singles
@property
def packs(self) -> list:
if self.__packs is None:
x = UserItemList(self.c, self).select_from_type('pack')
self.__packs = [i.item_id for i in x.items]
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:
x = UserItemList(self.c, self).select_from_type(
'world_unlock')
self.__world_unlocks = [i.item_id for i in x.items]
return self.__world_unlocks
@property
def world_songs(self) -> list:
if self.__world_songs is None:
x = UserItemList(
self.c, self).select_from_type('world_song')
self.__world_songs = [i.item_id for i in x.items]
return self.__world_songs
@property
def course_banners(self) -> list:
if self.__course_banners is None:
x = UserItemList(
self.c, self).select_from_type('course_banner')
self.__course_banners = [i.item_id for i in x.items]
return self.__course_banners
def select_characters(self) -> None:
self.characters = UserCharacterList(self.c, self)
self.characters.select_user_characters()
@property
def characters_list(self) -> list:
if self.characters is None:
self.select_characters()
return [x.character_id for x in self.characters.characters]
@property
def character_displayed(self) -> 'UserCharacter':
'''对外显示的角色'''
if self.favorite_character is None:
return self.character
self.favorite_character.select_character_uncap_condition(self)
return self.favorite_character
@property
def friend_ids(self) -> list:
self.c.execute('''select user_id_other from friend where user_id_me = :user_id''', {
'user_id': self.user_id})
return self.c.fetchall()
@property
def friends(self) -> list:
# 得到用户的朋友列表
if self.__friends is None:
s = []
for i in self.friend_ids:
self.c.execute('''select exists(select * from friend where user_id_me = :x and user_id_other = :y)''',
{'x': i[0], 'y': self.user_id})
is_mutual = self.c.fetchone() == (1,)
you = UserOnline(self.c, i[0])
you.select_user()
character = you.character if you.favorite_character is None else you.favorite_character
character.select_character_uncap_condition(you)
rating = you.rating_ptt if not you.is_hide_rating else -1
s.append({
"is_mutual": is_mutual,
"is_char_uncapped_override": character.is_uncapped_override,
"is_char_uncapped": character.is_uncapped,
"is_skill_sealed": you.is_skill_sealed,
"rating": rating,
"join_date": you.join_date,
"character": character.character_id,
"recent_score": you.recent_score_list,
"name": you.name,
"user_id": you.user_id
})
s.sort(key=lambda item: item["recent_score"][0]["time_played"] if len(
item["recent_score"]) > 0 else 0, reverse=True)
self.__friends = s
return self.__friends
@property
def recent_score_list(self) -> list:
# 用户最近一次成绩,是列表
if self.name is None:
self.select_user()
if self.recent_score.song.song_id is None:
return []
self.c.execute('''select best_clear_type from best_score where user_id=:u and song_id=:s and difficulty=:d''', {
'u': self.user_id, 's': self.recent_score.song.song_id, 'd': self.recent_score.song.difficulty})
y = self.c.fetchone()
best_clear_type = y[0] if y is not None else self.recent_score.clear_type
r = self.recent_score.to_dict()
r["best_clear_type"] = best_clear_type
return [r]
def select_curr_available_maps(self) -> None:
self.curr_available_maps: list = []
for i in Config.AVAILABLE_MAP:
self.curr_available_maps.append(Map(i))
@property
def curr_available_maps_list(self) -> list:
if self.curr_available_maps is None:
self.select_curr_available_maps()
return [x.to_dict() for x in self.curr_available_maps]
def to_dict(self) -> dict:
'''返回用户信息的字典,其实就是/user/me'''
if self.name is None:
self.select_user()
# 这是考虑有可能favourite_character设置了用户未拥有的角色同时提前计算角色列表
character_list = self.characters_list
if self.favorite_character and self.favorite_character.character_id in character_list:
favorite_character_id = self.favorite_character.character_id
else:
favorite_character_id = -1
if self.character.character_id not in character_list:
self.character.character_id = 0
return {
"is_aprilfools": Config.IS_APRILFOOLS,
"curr_available_maps": self.curr_available_maps_list,
"character_stats": [x.to_dict() for x in self.characters.characters],
"friends": self.friends,
"settings": {
"favorite_character": favorite_character_id,
"is_hide_rating": self.is_hide_rating,
"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,
"user_code": self.user_code,
"display_name": self.name,
"ticket": self.ticket,
"character": self.character.character_id,
"is_locked_name_duplicate": False,
"is_skill_sealed": self.is_skill_sealed,
"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,
"world_unlocks": self.world_unlocks,
"world_songs": self.world_songs,
"singles": self.singles,
"packs": self.packs,
"characters": character_list,
"cores": self.cores,
"recent_score": self.recent_score_list,
"max_friend": Constant.MAX_FRIEND_COUNT,
"rating": self.rating_ptt,
"join_date": self.join_date,
"global_rank": self.global_rank,
'country': '',
'course_banners': self.course_banners,
'world_mode_locked_end_ts': self.world_mode_locked_end_ts,
'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':
'''从数据库user表全部数据获取信息'''
if not x:
return None
if self.user_id is None:
self.user_id = x[0]
self.name = x[1]
self.join_date = int(x[3])
self.user_code = x[4]
self.rating_ptt = x[5]
self.character = UserCharacter(self.c, x[6])
self.is_skill_sealed = x[7] == 1
self.character.is_uncapped = x[8] == 1
self.character.is_uncapped_override = x[9] == 1
self.is_hide_rating = x[10] == 1
self.recent_score.song.song_id = x[11]
self.recent_score.song.difficulty = x[12]
self.recent_score.set_score(
x[13], x[14], x[15], x[16], x[17], x[18], x[19], x[20], x[21])
self.recent_score.rating = x[22]
self.favorite_character = None if x[23] == - \
1 else UserCharacter(self.c, x[23])
self.max_stamina_notification_enabled = x[24] == 1
self.current_map = Map(x[25]) if x[25] is not None else Map('')
self.ticket = x[26]
self.prog_boost = x[27] if x[27] is not None else 0
self.email = x[28] if x[28] is not None else ''
self.world_rank_score = x[29] if x[29] is not None else 0
self.ban_flag = x[30] if x[30] is not None else ''
self.next_fragstam_ts = x[31] if x[31] else 0
self.stamina = UserStamina(self.c, self)
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
def select_user(self) -> None:
# 查user表所有信息
self.c.execute(
'''select * from user where user_id = :x''', {'x': self.user_id})
x = self.c.fetchone()
if not x:
raise NoData('No user.', 108, -3)
self.from_list(x)
def select_user_about_current_map(self) -> None:
self.c.execute('''select current_map from user where user_id = :a''',
{'a': self.user_id})
x = self.c.fetchone()
if x:
self.current_map = Map(x[0])
def select_user_about_stamina(self) -> None:
self.c.execute('''select max_stamina_ts, stamina from user where user_id = :a''',
{'a': self.user_id})
x = self.c.fetchone()
if not x:
raise NoData('No user.', 108, -3)
self.stamina = UserStamina(self.c, self)
self.stamina.set_value(x[0], x[1])
def from_list_about_character(self, x: list) -> None:
'''从数据库user表获取搭档信息'''
self.name = x[0]
self.character = UserCharacter(self.c, x[1], self)
self.is_skill_sealed = x[2] == 1
self.character.is_uncapped = x[3] == 1
self.character.is_uncapped_override = x[4] == 1
self.favorite_character = None if x[5] == - \
1 else UserCharacter(self.c, x[5], self)
def select_user_about_character(self) -> None:
'''
查询user表有关搭档的信息
'''
self.c.execute('''select name, character_id, is_skill_sealed, is_char_uncapped, is_char_uncapped_override, favorite_character from user where user_id = :a''', {
'a': self.user_id})
x = self.c.fetchone()
if not x:
raise NoData('No user.', 108, -3)
self.from_list_about_character(x)
def select_user_about_world_play(self) -> None:
'''
查询user表有关世界模式打歌的信息
'''
self.c.execute(
'''select character_id, max_stamina_ts, stamina, is_skill_sealed, is_char_uncapped, is_char_uncapped_override, current_map, world_mode_locked_end_ts, beyond_boost_gauge, kanae_stored_prog from user where user_id=?''', (self.user_id,))
x = self.c.fetchone()
if not x:
raise NoData('No user.', 108, -3)
self.character = UserCharacter(self.c, x[0], self)
self.stamina = UserStamina(self.c, self)
self.stamina.set_value(x[1], x[2])
self.is_skill_sealed = x[3] == 1
self.character.is_uncapped = x[4] == 1
self.character.is_uncapped_override = x[5] == 1
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 not self.world_rank_score:
return 0
self.c.execute(
'''select count(*) from user where world_rank_score > ?''', (self.world_rank_score,))
y = self.c.fetchone()
if y and y[0] + 1 <= Config.WORLD_RANK_MAX:
return y[0] + 1
return 0
def update_global_rank(self) -> None:
'''用户世界排名计算,有新增成绩则要更新'''
self.c.execute(
'''
with user_scores as (
select song_id, difficulty, score_v2 from best_score where user_id = ? and difficulty in (2, 3, 4)
)
select sum(a) from(
select sum(score_v2) as a from user_scores where difficulty = 2 and song_id in (select song_id from chart where rating_ftr > 0)
union
select sum(score_v2) as a from user_scores where difficulty = 3 and song_id in (select song_id from chart where rating_byn > 0)
union
select sum(score_v2) as a from user_scores where difficulty = 4 and song_id in (select song_id from chart where rating_etr > 0)
)
''',
(self.user_id,)
)
x = self.c.fetchone()
if x[0] is None:
return
self.c.execute(
'''update user set world_rank_score = ? where user_id = ?''', (x[0], self.user_id))
self.world_rank_score = x[0]
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 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
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
def select_user_one_column(self, column_name: str, default_value=None, data_type=None) -> None:
'''
查询user表的某个属性
请注意必须是一个普通属性,不能是一个类的实例
'''
if column_name not in self.__dict__:
raise InputError('No such column.')
self.c.execute(f'''select {column_name} from user where user_id = :a''', {
'a': self.user_id})
x = self.c.fetchone()
if not x:
raise NoData('No user.', 108, -3)
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:
'''
更新user表的某个属性
请注意必须是一个普通属性,不能是一个类的实例
'''
if column_name not in self.__dict__:
raise InputError('No such column.')
if value is not None:
self.__dict__[column_name] = value
self.c.execute(f'''update user set {column_name} = :a where user_id = :b''', {
'a': self.__dict__[column_name], 'b': self.user_id})
class UserOnline(UserInfo):
def __init__(self, c, user_id=None) -> None:
super().__init__(c, user_id)
def change_character(self, character_id: int, skill_sealed: bool = False):
'''用户角色改变,包括技能封印的改变'''
self.character = UserCharacter(self.c, character_id, self)
self.character.select_character_uncap_condition()
self.is_skill_sealed = skill_sealed
self.c.execute('''update user set is_skill_sealed = :a, character_id = :b, is_char_uncapped = :c, is_char_uncapped_override = :d where user_id = :e''', {
'a': 1 if self.is_skill_sealed else 0, 'b': self.character.character_id, 'c': self.character.is_uncapped, 'd': self.character.is_uncapped_override, 'e': self.user_id})
def add_friend(self, friend_id: int):
'''加好友'''
if self.user_id == friend_id:
raise FriendError('Add yourself as a friend.', 604)
self.c.execute('''select exists(select * from friend where user_id_me = :x and user_id_other = :y)''',
{'x': self.user_id, 'y': friend_id})
if self.c.fetchone() == (0,):
self.c.execute('''insert into friend values(:a, :b)''', {
'a': self.user_id, 'b': friend_id})
else:
raise FriendError('The user has been your friend.', 602)
def delete_friend(self, friend_id: int):
'''删好友'''
self.c.execute('''select exists(select * from friend where user_id_me = :x and user_id_other = :y)''',
{'x': self.user_id, 'y': friend_id})
if self.c.fetchone() == (1,):
self.c.execute('''delete from friend where user_id_me = :x and user_id_other = :y''',
{'x': self.user_id, 'y': friend_id})
else:
raise FriendError('No user or the user is not your friend.', 401)
def change_favorite_character(self, character_id: int) -> None:
'''更改用户的favorite_character'''
self.favorite_character = UserCharacter(self.c, character_id, self)
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):
def __init__(self, c, user_id=None) -> None:
super().__init__(c, user_id)
def update_columns(self, columns: list = None, d: dict = None) -> None:
if columns is not None:
d = {}
for column in columns:
if column == 'password' and self.password != '':
d[column] = self.hash_pwd
else:
d[column] = self.__dict__[column]
Sql(self.c).update('user', d, Query().from_args(
{'user_id': self.user_id}))

View File

@@ -0,0 +1,74 @@
import hashlib
import os
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=16),
).encryptor()
encryptor.authenticate_additional_data(associated_data)
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
return (iv, ciphertext, encryptor.tag)
def aes_gcm_128_decrypt(key, associated_data, iv, ciphertext, tag):
decryptor = Cipher(
algorithms.AES(key),
modes.GCM(iv, tag, min_tag_length=16),
).decryptor()
decryptor.authenticate_additional_data(associated_data)
return decryptor.update(ciphertext) + decryptor.finalize()
def md5(code: str) -> str:
# md5加密算法
code = code.encode()
md5s = hashlib.md5()
md5s.update(code)
codes = md5s.hexdigest()
return codes
def get_file_md5(file_path: str) -> str:
'''计算文件MD5假设是文件'''
myhash = hashlib.md5()
with open(file_path, 'rb') as f:
while True:
b = f.read(8192)
if not b:
break
myhash.update(b)
return myhash.hexdigest()
def try_rename(path: str, new_path: str) -> str:
'''尝试重命名文件,并尝试避免命名冲突(在后面加自增数字),返回最终路径'''
final_path = new_path
if os.path.exists(new_path):
i = 1
while os.path.exists(new_path + str(i)):
i += 1
final_path = new_path + str(i)
os.rename(path, final_path)
return final_path
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 != '']))

1241
latest version/core/world.py Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

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,524 +1,15 @@
import sqlite3
import time
import json
import os
import sys
from importlib import import_module
# 数据库初始化文件删掉arcaea_database.db文件后运行即可谨慎使用
ARCAEA_SERVER_VERSION = 'v2.8.1'
def main(path='./'):
conn = sqlite3.connect(path+'arcaea_database.db')
c = conn.cursor()
c.execute('''create table if not exists config(id text primary key,
value text
);''')
c.execute('''insert into config values("version", :a);''',
{'a': ARCAEA_SERVER_VERSION})
c.execute('''create table if not exists user(user_id int primary key,
name text unique,
password text not null,
join_date char(20),
user_code char(10),
rating_ptt int,
character_id int,
is_skill_sealed int,
is_char_uncapped int,
is_char_uncapped_override int,
is_hide_rating int,
song_id text,
difficulty int,
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,
rating real,
favorite_character int,
max_stamina_notification_enabled int,
current_map text,
ticket int,
prog_boost int,
email text,
world_rank_score int,
ban_flag text,
next_fragstam_ts int,
max_stamina_ts int,
stamina int
);''')
c.execute('''create table if not exists login(access_token text,
user_id int,
login_time int,
login_ip text,
login_device text,
primary key(access_token, user_id)
);''')
c.execute('''create table if not exists friend(user_id_me int,
user_id_other int,
primary key (user_id_me, user_id_other)
);''')
c.execute('''create table if not exists best_score(user_id int,
song_id text,
difficulty int,
score int,
shiny_perfect_count int,
perfect_count int,
near_count int,
miss_count int,
health int,
modifier int,
time_played int,
best_clear_type int,
clear_type int,
rating real,
primary key(user_id, song_id, difficulty)
);''')
c.execute('''create table if not exists user_char(user_id int,
character_id int,
level int,
exp real,
is_uncapped int,
is_uncapped_override int,
primary key(user_id, character_id)
);''')
c.execute('''create table if not exists user_char_full(user_id int,
character_id int,
level int,
exp real,
is_uncapped int,
is_uncapped_override int,
primary key(user_id, character_id)
);''')
c.execute('''create table if not exists character(character_id int primary key,
name text,
max_level int,
frag1 real,
prog1 real,
overdrive1 real,
frag20 real,
prog20 real,
overdrive20 real,
frag30 real,
prog30 real,
overdrive30 real,
skill_id text,
skill_unlock_level int,
skill_requires_uncap int,
skill_id_uncap text,
char_type int,
is_uncapped int
);''')
c.execute('''create table if not exists char_item(character_id int,
item_id text,
type text,
amount int,
primary key(character_id, item_id, type)
);''')
c.execute('''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
);''')
c.execute('''create table if not exists user_world(user_id int,
map_id text,
curr_position int,
curr_capture real,
is_locked int,
primary key(user_id, map_id)
);''')
c.execute('''create table if not exists world_songplay(user_id int,
song_id text,
difficulty int,
stamina_multiply int,
fragment_multiply int,
prog_boost_multiply int,
primary key(user_id, song_id, difficulty)
);''')
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)
);''')
c.execute('''create table if not exists user_download(user_id int,
token text,
time int,
primary key(user_id, token, time)
);''')
c.execute('''create table if not exists item(item_id text,
type text,
is_available int,
_id text,
primary key(item_id, type)
);''')
c.execute('''create table if not exists user_item(user_id int,
item_id text,
type text,
amount int,
primary key(user_id, item_id, type)
);''')
c.execute('''create table if not exists purchase(purchase_name text primary key,
price int,
orig_price int,
discount_from int,
discount_to int
);''')
c.execute('''create table if not exists purchase_item(purchase_name text,
item_id text,
type text,
amount int,
primary key(purchase_name, item_id, type)
);''')
c.execute('''create table if not exists user_save(user_id int primary key,
scores_data text,
clearlamps_data text,
clearedsongs_data text,
unlocklist_data text,
installid_data text,
devicemodelname_data text,
story_data text,
createdAt int
);''')
c.execute('''create table if not exists present(present_id text primary key,
expire_ts int,
description text
);''')
c.execute('''create table if not exists user_present(user_id int,
present_id text,
primary key(user_id, present_id)
);''')
c.execute('''create table if not exists present_item(present_id text,
item_id text,
type text,
amount int,
primary key(present_id, item_id, type)
);''')
c.execute('''create table if not exists songfile(song_id text,
file_type int,
md5 text,
primary key(song_id, file_type)
);''')
c.execute('''create table if not exists redeem(code text primary key,
type int
);''')
c.execute('''create table if not exists user_redeem(user_id int,
code text,
primary key(user_id, code)
);''')
c.execute('''create table if not exists redeem_item(code text,
item_id text,
type text,
amount int,
primary key(code, item_id, type)
);''')
c.execute('''create table if not exists role(role_id int primary key,
role_name text,
caption text
);''')
c.execute('''create table if not exists user_role(user_id int,
role_id int,
primary key(user_id, role_id)
);''')
c.execute('''create table if not exists power(power_id int primary key,
power_name text,
caption text
);''')
c.execute('''create table if not exists role_power(role_id int,
power_id int,
primary key(role_id, power_id)
);''')
c.execute('''create table if not exists api_login(user_id int,
token text,
login_time int,
login_ip text,
primary key(user_id, token)
);''')
# 搭档初始化
char = ['hikari', 'tairitsu', 'kou', 'sapphire', 'lethe', '', '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']
skill_id = ['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', '', '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_id_uncap = ['', '', 'frags_kou', '', 'visual_ink', '', '', '', '', '', '', 'eto_uncap', 'luna_uncap', 'shirabe_entry_fee',
'', '', '', '', '', 'ayu_uncap', '', 'frags_yume', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '']
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]
frag1 = [55, 55, 60, 50, 47, 0, 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, 50, 25, 58, 50, 61, 45, 45, 38, 34]
prog1 = [35, 55, 47, 50, 60, 0, 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]
overdrive1 = [35, 55, 25, 50, 47, 0, 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, 50, 30, 49, 15, 34, 45, 45, 38, 67]
frag20 = [78, 80, 90, 75, 70, 0, 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, 100, 50, 68, 60, 90, 67, 50, 60, 51]
prog20 = [61, 80, 70, 75, 90, 0, 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, 100, 55, 65, 59, 90, 50, 90, 90, 75]
overdrive20 = [61, 80, 47, 75, 70, 0, 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, 100, 40, 69, 62, 51, 90, 67, 60, 100]
frag30 = [88, 90, 100, 75, 80, 0, 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, 100, 50, 68, 60, 90, 67, 50, 60, 51]
prog30 = [71, 90, 80, 75, 100, 0, 90, 102, 84, 78, 105, 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, 100, 55, 65, 59, 90, 50, 90, 90, 75]
overdrive30 = [71, 90, 57, 75, 80, 0, 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, 100, 40, 69, 62, 51, 90, 67, 60, 100]
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, 0, 0, 0, 0, 0, 0, 0, 0, 2]
char_core = {
0: [{'core_id': 'core_hollow', 'amount': 25}, {'core_id': 'core_desolate', 'amount': 5}],
1: [{'core_id': 'core_hollow', 'amount': 5}, {'core_id': 'core_desolate', 'amount': 25}],
2: [{'core_id': 'core_hollow', 'amount': 5}, {'core_id': 'core_crimson', 'amount': 25}],
4: [{'core_id': 'core_ambivalent', 'amount': 25}, {'core_id': 'core_desolate', 'amount': 5}],
13: [{'core_id': 'core_scarlet', 'amount': 30}],
21: [{'core_id': 'core_scarlet', 'amount': 30}],
26: [{'core_id': 'core_chunithm', 'amount': 15}],
27: [{'core_id': 'core_chunithm', 'amount': 15}],
28: [{'core_id': 'core_chunithm', 'amount': 15}],
29: [{'core_id': 'core_chunithm', 'amount': 15}],
36: [{'core_id': 'core_chunithm', 'amount': 15}],
42: [{'core_id': 'core_chunithm', 'amount': 15}],
43: [{'core_id': 'core_chunithm', 'amount': 15}],
11: [{'core_id': 'core_binary', 'amount': 25}, {'core_id': 'core_hollow', 'amount': 5}],
12: [{'core_id': 'core_binary', 'amount': 25}, {'core_id': 'core_desolate', 'amount': 5}],
19: [{'core_id': 'core_colorful', 'amount': 30}]
}
for i in range(0, 55):
skill_requires_uncap = 1 if i == 2 else 0
if i in [0, 1, 2, 4, 13, 26, 27, 28, 29, 36, 21, 42, 43, 11, 12, 19]:
sql = '''insert into character values(?,?,30,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)'''
c.execute(sql, (i, char[i], frag1[i], prog1[i], overdrive1[i], frag20[i], prog20[i], overdrive20[i],
frag30[i], prog30[i], overdrive30[i], skill_id[i], skill_unlock_level[i], skill_requires_uncap, skill_id_uncap[i], char_type[i]))
else:
if i != 5 and i != 46:
sql = '''insert into character values(?,?,20,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)'''
c.execute(sql, (i, char[i], frag1[i], prog1[i], overdrive1[i], frag20[i], prog20[i], overdrive20[i],
frag30[i], prog30[i], overdrive30[i], skill_id[i], skill_unlock_level[i], skill_requires_uncap, skill_id_uncap[i], char_type[i]))
c.execute('''insert into character values(?,?,20,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)''', (99,
'shirahime', 38, 33, 28, 66, 58, 50, 66, 58, 50, 'frags_preferred_song', 0, 0, '', 0))
for i in char_core:
for j in char_core[i]:
c.execute('''insert into char_item values(?,?,'core',?)''',
(i, j['core_id'], j['amount']))
cores = ['core_hollow', 'core_desolate', 'core_chunithm', 'core_crimson',
'core_ambivalent', 'core_scarlet', 'core_groove', 'core_generic', 'core_binary', 'core_colorful']
for i in cores:
c.execute('''insert into item values(?,"core",1,'')''', (i,))
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"]
for i in world_songs:
c.execute('''insert into item values(?,"world_song",1,'')''', (i,))
world_unlocks = ["scenery_chap1", "scenery_chap2",
"scenery_chap3", "scenery_chap4", "scenery_chap5", "scenery_chap6"]
for i in world_unlocks:
c.execute('''insert into item values(?,"world_unlock",1,'')''', (i,))
c.execute('''insert into item values(?,?,?,?)''',
('fragment', 'fragment', 1, ''))
c.execute('''insert into item values(?,?,?,?)''',
('memory', 'memory', 1, ''))
def insert_items(c, items):
# 物品数据导入
for i in items:
if 'discount_from' not in i:
discount_from = -1
else:
discount_from = i['discount_from']
if 'discount_to' not in i:
discount_to = -1
else:
discount_to = i['discount_to']
c.execute('''insert into purchase values(?,?,?,?,?)''',
(i['name'], i['price'], i['orig_price'], discount_from, discount_to))
for j in i['items']:
if "_id" not in j:
_id = ''
else:
_id = j['_id']
c.execute(
'''select exists(select * from item where item_id=?)''', (j['id'],))
if c.fetchone() == (0,):
c.execute('''insert into item values(?,?,?,?)''',
(j['id'], j['type'], j['is_available'], _id))
if 'amount' in j:
amount = j['amount']
else:
amount = 1
c.execute('''insert into purchase_item values(?,?,?,?)''',
(i['name'], j['id'], j['type'], amount))
# item初始化
f = open(path+'singles.json', 'r')
singles = json.load(f)
f.close()
insert_items(c, singles)
f = open(path+'packs.json', 'r')
packs = json.load(f)
f.close()
insert_items(c, packs)
# api权限与权限组初始化
role = ['admin', 'user', 'selecter']
role_caption = ['管理员', '用户', '查询接口']
power = ['select', 'select_me', 'change', 'change_me', 'grant',
'grant_inf', 'select_song_rank', 'select_song_info']
power_caption = ['总体查询权限', '自我查询权限', '总体修改权限',
'自我修改权限', '授权权限', '下级授权权限', '歌曲排行榜查询权限', '歌曲信息查询权限']
role_power = {'0': [0, 1, 3, 5, 6, 7],
'1': [1, 3, 6, 7],
'2': [0]
}
for i in range(0, len(role)):
c.execute('''insert into role values(:a,:b,:c)''', {
'a': i, 'b': role[i], 'c': role_caption[i]})
for i in range(0, len(power)):
c.execute('''insert into power values(:a,:b,:c)''', {
'a': i, 'b': power[i], 'c': power_caption[i]})
for i in role_power:
for j in role_power[i]:
c.execute('''insert into role_power values(:a,:b)''',
{'a': int(i), 'b': j})
conn.commit()
conn.close()
def arc_register(name: str):
def build_user_code(c):
return '123456789'
def build_user_id(c):
return 2000000
def insert_user_char(c, user_id):
# 为用户添加可用角色
c.execute('''insert into user_char values(?,?,?,?,?,?)''',
(user_id, 0, 30, 25000, 1, 0))
c.execute('''insert into user_char values(?,?,?,?,?,?)''',
(user_id, 1, 30, 25000, 1, 0))
c.execute(
'''select character_id, max_level, is_uncapped from character''')
x = c.fetchall()
if x:
for i in x:
exp = 25000 if i[1] == 30 else 10000
c.execute('''insert into user_char_full values(?,?,?,?,?,?)''',
(user_id, i[0], i[1], exp, i[2], 0))
conn = sqlite3.connect(path+'arcaea_database.db')
c = conn.cursor()
hash_pwd = '41e5653fc7aeb894026d6bb7b2db7f65902b454945fa8fd65a6327047b5277fb'
c.execute(
'''select exists(select * from user where name = :name)''', {'name': name})
if c.fetchone() == (0,):
user_code = build_user_code(c)
user_id = build_user_id(c)
now = int(time.time() * 1000)
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)
values(:user_id, :name, :password, :join_date, :user_code, 1250, 1, 0, 1, 0, 0, -1, 0, '', 114514)
''', {'user_code': user_code, 'user_id': user_id, 'join_date': now, 'name': name, 'password': hash_pwd})
c.execute('''insert into recent30(user_id) values(:user_id)''', {
'user_id': user_id})
c.execute(
'''insert into best_score values(2000000,'vexaria',3,10000000,100,0,0,0,100,0,1599667200,3,3,10.8)''')
insert_user_char(c, user_id)
conn.commit()
conn.close()
return None
else:
conn.commit()
conn.close()
return None
arc_register('admin')
def main(core_path: str = '../', db_path: str = './arcaea_database.db', init_folder_path: str = './init'):
sys.path.append(os.path.join(sys.path[0], core_path))
sys.path.append(os.path.join(sys.path[0], core_path, './core/'))
init = import_module('init').DatabaseInit(db_path, init_folder_path)
init.init()
if __name__ == '__main__':

View File

@@ -0,0 +1,99 @@
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', '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', '', '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_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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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}],
1: [{'core_id': 'core_hollow', 'amount': 5}, {'core_id': 'core_desolate', 'amount': 25}],
2: [{'core_id': 'core_hollow', 'amount': 5}, {'core_id': 'core_crimson', 'amount': 25}],
4: [{'core_id': 'core_ambivalent', 'amount': 25}, {'core_id': 'core_desolate', 'amount': 5}],
13: [{'core_id': 'core_scarlet', 'amount': 30}],
21: [{'core_id': 'core_scarlet', 'amount': 30}],
26: [{'core_id': 'core_chunithm', 'amount': 15}],
27: [{'core_id': 'core_chunithm', 'amount': 15}],
28: [{'core_id': 'core_chunithm', 'amount': 15}],
29: [{'core_id': 'core_chunithm', 'amount': 15}],
36: [{'core_id': 'core_chunithm', 'amount': 15}],
42: [{'core_id': 'core_chunithm', 'amount': 15}],
43: [{'core_id': 'core_chunithm', 'amount': 15}],
11: [{'core_id': 'core_binary', 'amount': 25}, {'core_id': 'core_hollow', 'amount': 5}],
12: [{'core_id': 'core_binary', 'amount': 25}, {'core_id': 'core_desolate', 'amount': 5}],
19: [{'core_id': 'core_colorful', 'amount': 30}],
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}],
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_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', '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_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 = ['系统', '管理员', '用户', '查询接口']
power = ['system', 'select', 'select_me', 'change', 'change_me', 'grant',
'grant_inf', 'select_song_rank', 'select_song_info', 'select_song_rank_top']
power_caption = ['系统权限', '总体查询权限', '自我查询权限', '总体修改权限',
'自我修改权限', '授权权限', '下级授权权限', '歌曲排行榜查询权限', '歌曲信息查询权限', '歌曲排行榜有限查询权限']
role_power = {'system': power,
'admin': ['select', 'select_me', 'change_me', 'grant_inf', 'select_song_rank_top', 'select_song_info'],
'user': ['select_me', 'change_me', 'select_song_rank', 'select_song_info'],
'selector': ['select']
}

View File

@@ -0,0 +1,494 @@
[
{
"course_id": "4.0-dan-1",
"style": 1,
"songs": [
{
"flag_as_hidden": false,
"difficulty": 1,
"id": "inkarusi"
},
{
"flag_as_hidden": false,
"difficulty": 1,
"id": "snowwhite"
},
{
"flag_as_hidden": false,
"difficulty": 1,
"id": "sakurafubuki"
},
{
"flag_as_hidden": false,
"difficulty": 1,
"id": "purgatorium"
}
],
"gauge_requirement": "default",
"requirements": [],
"flag_as_hidden_when_requirements_not_met": false,
"dan_name": "Phase 1",
"course_name": "新世界的第一步",
"can_start": true,
"is_completed": false,
"high_score": 0,
"best_clear_type": -1,
"rewards": [
"fragment300",
"course_banner_1"
]
},
{
"course_id": "4.0-dan-2",
"style": 2,
"songs": [
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "vexaria"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "clotho"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "brandnewworld"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "oblivia"
}
],
"gauge_requirement": "default",
"requirements": [],
"flag_as_hidden_when_requirements_not_met": false,
"dan_name": "Phase 2",
"course_name": "一次心跳的加速",
"can_start": true,
"is_completed": false,
"high_score": 0,
"best_clear_type": -1,
"rewards": [
"fragment400",
"course_banner_2"
]
},
{
"course_id": "4.0-dan-3",
"style": 3,
"songs": [
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "suomi"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "grimheart"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "bookmaker"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "avril"
}
],
"gauge_requirement": "default",
"requirements": [],
"flag_as_hidden_when_requirements_not_met": false,
"dan_name": "Phase 3",
"course_name": "永不屈服的精神",
"can_start": true,
"is_completed": false,
"high_score": 0,
"best_clear_type": -1,
"rewards": [
"fragment500",
"course_banner_3"
]
},
{
"course_id": "4.0-dan-4",
"style": 4,
"songs": [
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "gekka"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "solitarydream"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "kanagawa"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "iconoclast"
}
],
"gauge_requirement": "default",
"requirements": [],
"flag_as_hidden_when_requirements_not_met": false,
"dan_name": "Phase 4",
"course_name": "光芒的永恒之地",
"can_start": true,
"is_completed": false,
"high_score": 0,
"best_clear_type": -1,
"rewards": [
"fragment600",
"course_banner_4"
]
},
{
"course_id": "4.0-dan-5",
"style": 5,
"songs": [
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "faintlight"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "eveninginscarlet"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "kyogenkigo"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "blrink"
}
],
"gauge_requirement": "default",
"requirements": [],
"flag_as_hidden_when_requirements_not_met": false,
"dan_name": "Phase 5",
"course_name": "玻璃之中的残酷",
"can_start": true,
"is_completed": false,
"high_score": 0,
"best_clear_type": -1,
"rewards": [
"fragment700",
"course_banner_5"
]
},
{
"course_id": "4.0-dan-6",
"style": 6,
"songs": [
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "vindication"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "heartjackin"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "ascent"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "macromod"
}
],
"gauge_requirement": "default",
"requirements": [],
"flag_as_hidden_when_requirements_not_met": false,
"dan_name": "Phase 6",
"course_name": "沉浸于悲喜交加",
"can_start": true,
"is_completed": false,
"high_score": 0,
"best_clear_type": -1,
"rewards": [
"fragment800",
"course_banner_6"
]
},
{
"course_id": "4.0-dan-7",
"style": 7,
"songs": [
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "galaxyfriends"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "neokosmo"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "conflict"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "redolentshape"
}
],
"gauge_requirement": "default",
"requirements": [],
"flag_as_hidden_when_requirements_not_met": false,
"dan_name": "Phase 7",
"course_name": "在命运到来之际",
"can_start": true,
"is_completed": false,
"high_score": 0,
"best_clear_type": -1,
"rewards": [
"fragment900",
"course_banner_7"
]
},
{
"course_id": "4.0-dan-8",
"style": 8,
"songs": [
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "corruption"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "neowings"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "valhallazero"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "izana"
}
],
"gauge_requirement": "default",
"requirements": [],
"flag_as_hidden_when_requirements_not_met": false,
"dan_name": "Phase 8",
"course_name": "趋于混沌的时空",
"can_start": true,
"is_completed": false,
"high_score": 0,
"best_clear_type": -1,
"rewards": [
"fragment1000",
"course_banner_8"
]
},
{
"course_id": "4.0-dan-9",
"style": 9,
"songs": [
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "felis"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "lightningscrew"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "seclusion"
},
{
"flag_as_hidden": false,
"difficulty": 2,
"id": "livefastdieyoung"
}
],
"gauge_requirement": "default",
"requirements": [
{
"value": "4.0-dan-8",
"type": "course"
}
],
"flag_as_hidden_when_requirements_not_met": true,
"dan_name": "Phase 9",
"course_name": "分崩离析的自我",
"can_start": true,
"is_completed": false,
"high_score": 0,
"best_clear_type": -1,
"rewards": [
"fragment1000",
"core_generic_10",
"course_banner_9"
]
},
{
"course_id": "4.0-dan-10",
"style": 10,
"songs": [
{
"flag_as_hidden": true,
"difficulty": 2,
"id": "cyaegha"
},
{
"flag_as_hidden": true,
"difficulty": 2,
"id": "divinelight"
},
{
"flag_as_hidden": true,
"difficulty": 2,
"id": "dantalion"
},
{
"flag_as_hidden": true,
"difficulty": 2,
"id": "aegleseeker"
}
],
"gauge_requirement": "default",
"requirements": [
{
"value": "4.0-dan-9",
"type": "course"
}
],
"flag_as_hidden_when_requirements_not_met": true,
"dan_name": "Phase 10",
"course_name": "光与对立的洪流",
"can_start": true,
"is_completed": false,
"high_score": 0,
"best_clear_type": -1,
"rewards": [
"fragment1000",
"core_generic_10",
"course_banner_10"
]
},
{
"course_id": "4.0-dan-11",
"style": 11,
"songs": [
{
"flag_as_hidden": true,
"difficulty": 3,
"id": "infinitestrife"
},
{
"flag_as_hidden": true,
"difficulty": 2,
"id": "grievouslady"
},
{
"flag_as_hidden": true,
"difficulty": 2,
"id": "fractureray"
},
{
"flag_as_hidden": true,
"difficulty": 3,
"id": "tempestissimo"
}
],
"gauge_requirement": "default",
"requirements": [
{
"value": "4.0-dan-10",
"type": "course"
}
],
"flag_as_hidden_when_requirements_not_met": true,
"dan_name": "Phase 11",
"course_name": "创世之曙光",
"can_start": true,
"is_completed": false,
"high_score": 0,
"best_clear_type": -1,
"rewards": [
"fragment2000",
"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

@@ -0,0 +1,30 @@
create table if not exists cache(key text primary key,
value text,
expire_time int
);
create table if not exists user_score(user_id int,
song_id text,
difficulty int,
time_played int,
score int,
shiny_perfect_count int,
perfect_count int,
near_count int,
miss_count int,
health int,
modifier int,
clear_type int,
rating real,
primary key(user_id, song_id, difficulty, time_played)
);
create table if not exists user_rating(user_id int,
time int,
rating_ptt real,
primary key(user_id, time)
);
create index if not exists user_score_1 on user_score (song_id, difficulty);
create index if not exists user_score_2 on user_score (time_played);
PRAGMA journal_mode = WAL;
PRAGMA default_cache_size = 4000;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,298 @@
create table if not exists config(id text primary key, value text);
create table if not exists user(user_id int primary key,
name text unique,
password text,
join_date char(20),
user_code char(10),
rating_ptt int,
character_id int,
is_skill_sealed int,
is_char_uncapped int,
is_char_uncapped_override int,
is_hide_rating int,
song_id text,
difficulty int,
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,
rating real,
favorite_character int,
max_stamina_notification_enabled int,
current_map text,
ticket int,
prog_boost int,
email text,
world_rank_score int,
ban_flag text,
next_fragstam_ts int,
max_stamina_ts int,
stamina int,
world_mode_locked_end_ts int,
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,
login_time int,
login_ip text,
login_device text,
primary key(access_token, user_id)
);
create table if not exists friend(user_id_me int,
user_id_other int,
primary key (user_id_me, user_id_other)
);
create table if not exists best_score(user_id int,
song_id text,
difficulty int,
score int,
shiny_perfect_count int,
perfect_count int,
near_count int,
miss_count int,
health int,
modifier int,
time_played int,
best_clear_type int,
clear_type int,
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,
character_id int,
level int,
exp real,
is_uncapped int,
is_uncapped_override int,
skill_flag int,
primary key(user_id, character_id)
);
create table if not exists user_char_full(user_id int,
character_id int,
level int,
exp real,
is_uncapped int,
is_uncapped_override int,
skill_flag int,
primary key(user_id, character_id)
);
create table if not exists character(character_id int primary key,
name text,
max_level int,
frag1 real,
prog1 real,
overdrive1 real,
frag20 real,
prog20 real,
overdrive20 real,
frag30 real,
prog30 real,
overdrive30 real,
skill_id text,
skill_unlock_level int,
skill_requires_uncap int,
skill_id_uncap text,
char_type int,
is_uncapped int
);
create table if not exists char_item(character_id int,
item_id text,
type text,
amount int,
primary key(character_id, item_id, type)
);
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,
curr_position int,
curr_capture real,
is_locked int,
primary key(user_id, map_id)
);
create table if not exists songplay_token(token text primary key,
user_id int,
song_id text,
difficulty int,
course_id text,
course_state int,
course_score int,
course_clear_type int,
stamina_multiply int,
fragment_multiply int,
prog_boost_multiply int,
beyond_boost_gauge_usage int,
skill_cytusii_flag text,
skill_chinatsu_flag text,
invasion_flag int
);
create table if not exists item(item_id text,
type text,
is_available int,
primary key(item_id, type)
);
create table if not exists user_item(user_id int,
item_id text,
type text,
amount int,
primary key(user_id, item_id, type)
);
create table if not exists purchase(purchase_name text primary key,
price int,
orig_price int,
discount_from int,
discount_to int,
discount_reason text
);
create table if not exists purchase_item(purchase_name text,
item_id text,
type text,
amount int,
primary key(purchase_name, item_id, type)
);
create table if not exists user_save(user_id int primary key,
scores_data text,
clearlamps_data text,
clearedsongs_data text,
unlocklist_data text,
installid_data text,
devicemodelname_data text,
story_data text,
createdAt int,
finalestate_data text
);
create table if not exists present(present_id text primary key,
expire_ts int,
description text
);
create table if not exists user_present(user_id int,
present_id text,
primary key(user_id, present_id)
);
create table if not exists present_item(present_id text,
item_id text,
type text,
amount int,
primary key(present_id, item_id, type)
);
create table if not exists chart(song_id text primary key,
name text,
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
);
create table if not exists user_redeem(user_id int,
code text,
primary key(user_id, code)
);
create table if not exists redeem_item(code text,
item_id text,
type text,
amount int,
primary key(code, item_id, type)
);
create table if not exists role(role_id text primary key,
caption text
);
create table if not exists user_role(user_id int,
role_id text,
primary key(user_id, role_id)
);
create table if not exists power(power_id text primary key,
caption text
);
create table if not exists role_power(role_id text,
power_id text,
primary key(role_id, power_id)
);
create table if not exists api_login(user_id int,
token text,
login_time int,
login_ip text,
primary key(user_id, token)
);
create table if not exists course(course_id text primary key,
course_name text,
dan_name text,
style int,
gauge_requirement text,
flag_as_hidden_when_requirements_not_met int,
can_start int
);
create table if not exists user_course(user_id int,
course_id text,
high_score int,
best_clear_type int,
primary key(user_id, course_id)
);
create table if not exists course_chart(course_id text,
song_id text,
difficulty int,
flag_as_hidden int,
song_index int,
primary key(course_id, song_index)
);
create table if not exists course_requirement(course_id text,
required_id text,
primary key(course_id, required_id)
);
create table if not exists course_item(course_id text,
item_id text,
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);
PRAGMA journal_mode = WAL;
PRAGMA default_cache_size = 8000;

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

@@ -1,556 +0,0 @@
[
{
"name": "core",
"items": [
{
"type": "pack",
"id": "core",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 500,
"orig_price": 500,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "shiawase",
"items": [
{
"type": "pack",
"id": "shiawase",
"is_available": true
},
{
"type": "character",
"id": "kou",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 500,
"orig_price": 500,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "dynamix",
"items": [
{
"type": "pack",
"id": "dynamix",
"is_available": true
},
{
"type": "character",
"id": "sapphire",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 500,
"orig_price": 500,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "mirai",
"items": [
{
"type": "pack",
"id": "mirai",
"is_available": true
},
{
"type": "character",
"id": "lethe",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 500,
"orig_price": 500,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "yugamu",
"items": [
{
"type": "pack",
"id": "yugamu",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 500,
"orig_price": 500,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "lanota",
"items": [
{
"type": "pack",
"id": "lanota",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 500,
"orig_price": 500,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "nijuusei",
"items": [
{
"type": "pack",
"id": "nijuusei",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 500,
"orig_price": 500,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "rei",
"items": [
{
"type": "pack",
"id": "rei",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 500,
"orig_price": 500,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "tonesphere",
"items": [
{
"type": "pack",
"id": "tonesphere",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 500,
"orig_price": 500,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "groovecoaster",
"items": [
{
"type": "pack",
"id": "groovecoaster",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 500,
"orig_price": 500,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "zettai",
"items": [
{
"type": "pack",
"id": "zettai",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 500,
"orig_price": 500,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "chunithm",
"items": [
{
"type": "pack",
"id": "chunithm",
"is_available": true
},
{
"type": "core",
"amount": 3,
"id": "core_generic",
"is_available": true
}
],
"price": 300,
"orig_price": 300
},
{
"name": "prelude",
"items": [
{
"type": "pack",
"id": "prelude",
"is_available": true
},
{
"type": "core",
"amount": 4,
"id": "core_generic",
"is_available": true
}
],
"price": 400,
"orig_price": 400,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "omatsuri",
"items": [
{
"type": "pack",
"id": "omatsuri",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 500,
"orig_price": 500,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "vs",
"items": [
{
"type": "pack",
"id": "vs",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 500,
"orig_price": 500,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "extend",
"items": [
{
"type": "pack",
"id": "extend",
"is_available": true
},
{
"type": "core",
"amount": 7,
"id": "core_generic",
"is_available": true
}
],
"price": 0,
"orig_price": 700,
"discount_from": 1615248000000,
"discount_to": 1615852799000
},
{
"name": "alice",
"items": [
{
"type": "pack",
"id": "alice",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 500,
"price": 500
},
{
"name": "alice_append_1",
"items": [
{
"type": "pack",
"id": "alice_append_1",
"is_available": true
},
{
"type": "core",
"amount": 3,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 300,
"price": 300
},
{
"name": "ongeki",
"items": [
{
"type": "pack",
"id": "ongeki",
"is_available": true
},
{
"type": "core",
"amount": 4,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 400,
"price": 400
},
{
"name": "maimai",
"items": [
{
"type": "pack",
"id": "maimai",
"is_available": true
},
{
"type": "core",
"amount": 4,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 400,
"price": 400
},
{
"name": "chunithm_append_1",
"items": [
{
"type": "pack",
"id": "chunithm_append_1",
"is_available": true
},
{
"type": "core",
"amount": 3,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 300,
"price": 300
},
{
"name": "observer_append_1",
"items": [
{
"type": "pack",
"id": "observer_append_1",
"is_available": true
},
{
"type": "core",
"amount": 3,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 300,
"price": 300
},
{
"name": "observer",
"items": [
{
"type": "pack",
"id": "observer",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 500,
"price": 500
},
{
"name": "observer_append_2",
"items": [
{
"type": "pack",
"id": "observer_append_2",
"is_available": true
},
{
"type": "core",
"amount": 3,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 300,
"price": 300
},
{
"name": "wacca",
"items": [
{
"type": "pack",
"id": "wacca",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 500,
"price": 500
},
{
"name": "nijuusei_append_1",
"items": [
{
"type": "pack",
"id": "nijuusei_append_1",
"is_available": true
},
{
"type": "core",
"amount": 3,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 300,
"price": 300
},
{
"name": "dividedheart",
"items": [
{
"type": "pack",
"id": "dividedheart",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 500,
"price": 500
},
{
"name": "musedash",
"items": [
{
"type": "pack",
"id": "musedash",
"is_available": true
},
{
"type": "character",
"id": "marija",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"price": 400,
"orig_price": 400
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"songs": [
{
"id": "dement",
"set": "base",
"purchase": "",
"difficulties": [
{
"ratingClass": 0,
"rating": 3
},
{
"ratingClass": 1,
"rating": 6
},
{
"ratingClass": 2,
"rating": 7
},
{
"ratingClass": 3,
"rating": 9
}
]
}
]
}

View File

@@ -0,0 +1 @@
from .main import link_play

View File

@@ -1,14 +1,14 @@
import os
from os import urandom
from cryptography.hazmat.primitives.ciphers import (
Cipher, algorithms, modes
)
def encrypt(key, plaintext, associated_data):
iv = os.urandom(12)
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

@@ -0,0 +1,39 @@
class Config:
'''
Link Play server configuration
'''
'''
服务器地址、端口号、校验码、传输加密密钥
Server address, port, verification code, and encryption key
'''
HOST = '0.0.0.0'
UDP_PORT = 10900
TCP_PORT = 10901
AUTHENTICATION = 'my_link_play_server'
TCP_SECRET_KEY = '1145141919810'
'''
--------------------------------------------------
'''
DEBUG = False
TCP_MAX_LENGTH = 0x0FFFFFFF
TIME_LIMIT = 3600000
COMMAND_INTERVAL = 1000000
PLAYER_PRE_TIMEOUT = 3000000
PLAYER_TIMEOUT = 15000000
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

@@ -0,0 +1,114 @@
import binascii
import logging
import socketserver
import threading
from json import dumps, loads
from .aes import decrypt, encrypt
from .config import Config
from .store import Store, TCPRouter, clear_player, clear_room
from .udp_class import bi
from .udp_parser import CommandParser
logging.basicConfig(format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
level=logging.INFO)
class UDP_handler(socketserver.BaseRequestHandler):
def handle(self):
client_msg, server = self.request
# print(client_msg)
try:
token = client_msg[:8]
iv = client_msg[8:20]
tag = client_msg[20:36]
ciphertext = client_msg[36:]
user = Store.link_play_data.get(bi(token))
if user is None:
return None
plaintext = decrypt(user['key'], b'', iv, ciphertext, tag)
except Exception as e:
logging.error(e)
return None
# if Config.DEBUG:
# logging.info(
# f'UDP-From-{self.client_address[0]}-{binascii.b2a_hex(plaintext)}')
commands = CommandParser(
user['room'], user['player_index']).get_commands(plaintext)
if user['room'].players[user['player_index']].player_id == 0:
clear_player(bi(token))
if user['room'].player_num == 0:
clear_room(user['room'])
commands = [i for i in commands if i[2] == 0x12]
# 处理不能正确被踢的问题
for i in commands:
iv, ciphertext, tag = encrypt(user['key'], i, b'')
# if Config.DEBUG:
# logging.info(
# f'UDP-To-{self.client_address[0]}-{binascii.b2a_hex(i)}')
server.sendto(token + iv + tag + ciphertext, self.client_address)
AUTH_LEN = len(Config.AUTHENTICATION)
TCP_AES_KEY = Config.TCP_SECRET_KEY.encode('utf-8').ljust(16, b'\x00')[:16]
class TCP_handler(socketserver.StreamRequestHandler):
def handle(self):
try:
if self.rfile.read(AUTH_LEN).decode('utf-8') != Config.AUTHENTICATION:
self.wfile.write(b'No authentication')
logging.warning(
f'TCP-{self.client_address[0]}-No authentication')
return None
cipher_len = int.from_bytes(self.rfile.read(8), byteorder='little')
if cipher_len > Config.TCP_MAX_LENGTH:
self.wfile.write(b'Body too long')
logging.warning(f'TCP-{self.client_address[0]}-Body too long')
return None
iv = 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)
message = self.data.decode('utf-8')
data = loads(message)
except Exception as e:
logging.error(e)
return None
if Config.DEBUG:
logging.info(f'TCP-From-{self.client_address[0]}-{message}')
r = TCPRouter(data).handle()
try:
r = dumps(r)
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 + ciphertext
except Exception as e:
logging.error(e)
return None
self.wfile.write(r)
def link_play(ip: str = Config.HOST, udp_port: int = Config.UDP_PORT, tcp_port: int = Config.TCP_PORT):
udp_server = socketserver.ThreadingUDPServer((ip, udp_port), UDP_handler)
tcp_server = socketserver.ThreadingTCPServer((ip, tcp_port), TCP_handler)
threads = [threading.Thread(target=udp_server.serve_forever), threading.Thread(
target=tcp_server.serve_forever)]
[t.start() for t in threads]
[t.join() for t in threads]

View File

@@ -0,0 +1,410 @@
import logging
from base64 import b64decode, b64encode
from os import urandom
from random import randint
from threading import RLock
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: "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()
def random_room_code():
re = ''
for _ in range(4):
re += chr(randint(65, 90))
for _ in range(2):
re += str(randint(0, 9))
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:
x = bi(urandom(length))
while x in dataset or x == 0:
x = bi(urandom(length))
else:
x = random_func()
while x in dataset:
x = random_func()
return x
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}`')
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}`')
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):
# 内存清理,应对玩家不正常退出
with Store.lock:
clean_room_list = []
clean_player_list = []
for token, v in Store.link_play_data.items():
room = v['room']
if now - room.timestamp >= Config.TIME_LIMIT:
clean_room_list.append(room.room_id)
if now - room.players[v['player_index']].last_timestamp // 1000 >= Config.TIME_LIMIT:
clean_player_list.append(token)
for room_id, v in Store.room_id_dict.items():
if now - v.timestamp >= Config.TIME_LIMIT:
clean_room_list.append(room_id)
for room_id in clean_room_list:
if room_id in Store.room_id_dict:
clear_room(Store.room_id_dict[room_id])
for token in clean_player_list:
clear_player(token)
class TCPRouter:
clean_timer = 0
router = {
'debug',
'create_room',
'join_room',
'update_room',
'get_rooms',
'select_room',
'get_match_rooms'
}
def __init__(self, raw_data: 'dict | list'):
self.raw_data = raw_data # data: dict {endpoint: str, data: dict}
self.data = raw_data['data']
self.endpoint = raw_data['endpoint']
def debug(self) -> dict:
if Config.DEBUG:
return {'result': eval(self.data['code'])}
return {'hello_world': 'ok'}
@staticmethod
def clean_check():
now = round(time() * 1000)
if now - TCPRouter.clean_timer >= Config.TIME_LIMIT:
logging.info('Start cleaning memory...')
TCPRouter.clean_timer = now
memory_clean(now)
def handle(self) -> dict:
self.clean_check()
if self.endpoint not in self.router:
return {'code': 999}
try:
r = getattr(self, self.endpoint)()
except Exception as e:
logging.error(e)
return {'code': 999}
if isinstance(r, int):
return {'code': r}
return {
'code': 0,
'data': r
}
@staticmethod
def generate_player(name: str) -> Player:
player_id = unique_random(Store.player_dict, 3)
player = Player()
player.player_id = player_id
player.set_player_name(name)
Store.player_dict[player_id] = player
return player
@staticmethod
def generate_room() -> Room:
room_id = unique_random(Store.room_id_dict)
room = Room()
room.room_id = room_id
room.timestamp = round(time() * 1000000)
Store.room_id_dict[room_id] = room
room_code = unique_random(
Store.room_code_dict, random_func=random_room_code)
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:
# 开房
# data = ['1', name, song_unlock, ]
# 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:
room = self.generate_room()
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
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,
'player_index': 0,
'player_id': player.player_id
}
logging.info(f'TCP-Create room `{room.room_code}` by player `{name}`')
return {
'room_code': room.room_code,
'room_id': room.room_id,
'token': token,
'key': b64encode(key).decode('utf-8'),
'player_id': player.player_id
}
def join_room(self) -> 'dict | int':
# 入房
# data = ['2', name, song_unlock, room_code]
# song_unlock: base64 str
room_code = self.data['room_code'].upper()
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:
# 房间号错误 / 房间不存在
return 1202
room: Room = Store.room_code_dict[room_code]
player_num = room.player_num
if player_num == 4:
# 满人
return 1201
if player_num == 0:
# 房间不存在
return 1202
if room.state not in (0, 1, 2) or (room.is_public and match_times is None):
# 无法加入
return 1205
token = unique_random(Store.link_play_data)
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.player_index = i
break
Store.link_play_data[token] = {
'key': key,
'room': room,
'player_index': player.player_index,
'player_id': player.player_id
}
logging.info(f'TCP-Player `{name}` joins room `{room_code}`')
return {
'room_code': room_code,
'room_id': room.room_id,
'token': token,
'key': b64encode(key).decode('utf-8'),
'player_id': player.player_id,
'song_unlock': b64encode(room.song_unlock).decode('utf-8')
}
def update_room(self) -> dict:
# 房间信息更新
# data = ['3', token]
token = int(self.data['token'])
rating_ptt = self.data.get('rating_ptt', 0)
is_hide_rating = self.data.get('is_hide_rating', False)
with Store.lock:
if token not in Store.link_play_data:
return 108
r = Store.link_play_data[token]
room = r['room']
# 更新玩家信息
player_index = r['player_index']
player = room.players[player_index]
player.rating_ptt = rating_ptt
player.is_hide_rating = is_hide_rating
cs = CommandSender(room)
room.command_queue.append(cs.command_12(player_index))
logging.info(f'TCP-Room `{room.room_code}` info update')
return {
'room_code': room.room_code,
'room_id': room.room_id,
'key': b64encode(r['key']).decode('utf-8'),
# changed from room.players[r['player_index']].player_id,
'player_id': r['player_id'],
'song_unlock': b64encode(room.song_unlock).decode('utf-8')
}
def get_rooms(self) -> dict:
# 获取房间列表与详细信息
offset = int(self.data.get('offset', 0))
if offset < 0:
offset = 0
limit = min(int(self.data.get('limit', 100)), 100)
if limit < 0:
limit = 100
n = 0
m = 0
rooms = []
f = False
f2 = False
for room in Store.room_id_dict.values():
if room.player_num == 0:
continue
if m < offset:
m += 1
continue
if f:
# 处理刚好有 limit 个房间的情况
f2 = True
break
n += 1
rooms.append(room.to_dict())
if n >= limit:
f = True
return {
'amount': n,
'offset': offset,
'limit': limit,
'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

@@ -0,0 +1,508 @@
import logging
from time import time
from random import randint
from .config import Config
def b(value, length=1):
return value.to_bytes(length=length, byteorder='little')
def bi(value):
return int.from_bytes(value, byteorder='little')
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.is_uncapped = 0
self.score = Score()
self.last_score = Score()
self.finish_flag = 0
self.player_state = 1
self.download_percent = 0
self.online = 0
self.last_timestamp = 0
self.extra_command_queue = []
self.song_unlock: bytes = b'\x00' * Config.LINK_PLAY_UNLOCK_LENGTH
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')
def to_dict(self) -> dict:
return {
'multiplay_player_id': self.player_id,
'name': self.name,
'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_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.score.difficulty,
'score': self.score.score,
'cleartype': self.score.cleartype,
},
'player_state': self.player_state,
'last_timestamp': self.last_timestamp,
}
def set_player_name(self, player_name: str):
self.player_name = player_name.encode('ascii')
if len(self.player_name) > 16:
self.player_name = self.player_name[:16]
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 # 疑似 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(0), Player(1), Player(2), Player(3)]
self.interval = 1000
self.times = 100 # ???
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['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_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)
@property
def command_queue_length(self) -> int:
return len(self.command_queue)
@property
def player_num(self) -> int:
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):
# 检测玩家是否被自动踢出房间 / 离线判断
now = round(time() * 1000000) if now is None else now
flag = False
player_index_list = []
for i, x in enumerate(self.players):
if x.player_id == 0 or x.last_timestamp == 0:
continue
if now - x.last_timestamp >= Config.PLAYER_TIMEOUT:
self.delete_player(i)
flag = True
player_index_list.append(i)
elif x.online == 1 and now - x.last_timestamp >= Config.PLAYER_PRE_TIMEOUT:
x.online = 0
player_index_list.append(i)
return flag, player_index_list
def get_players_info(self):
# 获取所有玩家信息
re = bytearray()
for i in self.players:
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\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):
# 轮换房主
for i in range(4):
if self.players[i].player_id == self.host_id:
for j in range(1, 4):
player = self.players[(i + j) % 4]
if player.player_id != 0:
self.host_id = player.player_id
logging.info(
f'Player `{player.name}` becomes the host of room `{self.room_code}`')
break
break
def delete_player(self, player_index: int):
# 删除某个玩家
player = self.players[player_index]
if player.player_id == self.host_id:
self.make_round()
logging.info(
f'Player `{player.name}` leaves room `{self.room_code}`')
self.players[player_index].online = 0
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)
for i in self.players:
if i.player_id != 0:
r &= bi(i.song_unlock)
self.song_unlock = b(r, Config.LINK_PLAY_UNLOCK_LENGTH)
def is_ready(self, old_state: int, player_state: int):
# 是否全部准备就绪
if self.state == old_state:
for i in self.players:
if i.player_id != 0 and (i.player_state != player_state or i.online == 0):
return False
return True
return False
def is_finish(self):
# 是否全部进入结算
if self.state != 7:
return False
for i in self.players:
if i.player_id != 0 and (i.finish_flag == 0 or i.online == 0):
return False
return True
def make_finish(self):
# 结算
self.state = 8
self.last_song_idx = self.song_idx
max_score = 0
max_score_i = []
for i in range(4):
player = self.players[i]
if player.player_id == 0:
continue
player.finish_flag = 0
player.last_score.copy(player.score)
player.last_score.best_player_flag = 0
if player.last_score.score > max_score:
max_score = player.last_score.score
max_score_i = [i]
elif player.last_score.score == max_score:
max_score_i.append(i)
for i in max_score_i:
self.players[i].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}` - {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

@@ -0,0 +1,386 @@
import logging
from .config import Config
from .udp_class import Room, bi
from .udp_sender import CommandSender
class CommandParser:
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
self.player_index = player_index
self.s = CommandSender(self.room)
self.command: bytes = None
def get_commands(self, command: bytes):
self.command = command
r = getattr(self, self.route[self.command[2]])()
re = []
flag_13 = False
for i in range(max(bi(self.command[12:16]), self.room.players[self.player_index].start_command_num), self.room.command_queue_length):
if self.room.command_queue[i][2] == 0x13:
if flag_13:
break
flag_13 = True
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[-12:]
self.room.players[self.player_index].extra_command_queue = []
if r:
re += r
return re
def command_01(self):
# 给房主
player_id = bi(self.command[24:32])
for i in self.room.players:
if i.player_id == player_id and i.online == 1:
self.room.host_id = player_id
logging.info(
f'Player `{i.name}` becomes the host of room `{self.room.room_code}`')
self.s.random_code = self.command[16:24]
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 = 5
if self.room.state == 2:
flag = 0
self.room.state = 3
self.room.song_idx = song_idx
self.room.command_queue.append(self.s.command_11())
self.room.command_queue.append(self.s.command_13())
return [self.s.command_0d(flag)]
def command_03(self):
# 尝试进入结算
self.s.random_code = self.command[16:24]
player = self.room.players[self.player_index]
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
self.room.command_queue.append(self.s.command_12(self.player_index))
if self.room.is_finish():
self.room.make_finish()
self.room.command_queue.append(self.s.command_13())
def command_04(self):
# 踢人
self.s.random_code = self.command[16:24]
player_id = bi(self.command[24:32])
flag = 2
if self.room.players[self.player_index].player_id == self.room.host_id and player_id != self.room.host_id:
for i in range(4):
if self.room.players[i].player_id == player_id:
flag = 1
self.room.delete_player(i)
self.room.command_queue.append(self.s.command_12(i))
self.room.command_queue.append(self.s.command_14())
break
return [self.s.command_0d(flag)]
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: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):
# 可能弃用
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):
self.s.random_code = self.command[16:24]
player = self.room.players[self.player_index]
if bi(self.command[12:16]) == 0:
player.online = 1
self.room.state = 1
self.room.update_song_unlock()
player.start_command_num = self.room.command_queue_length
self.room.command_queue.append(self.s.command_15())
return None
flag_0c = False
if self.s.timestamp - player.last_timestamp >= Config.COMMAND_INTERVAL:
flag_0c = True
player.last_timestamp = self.s.timestamp
# 离线判断
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_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
if self.room.state == 5:
flag_13 = True
if self.room.is_ready(5, 6):
self.room.state = 6
if self.room.is_ready(5, 7):
self.room.state = 7
if self.room.state in (5, 6) and self.room.should_next_state:
# 此处不清楚
self.room.state = 7
flag_13 = True
if self.room.state in (7, 8):
player_now_timer = bi(self.command[28:32])
if player.score.timer < player_now_timer or player_now_timer == 0 and player.score.timer != 0:
player.last_score.timer = player.score.timer
player.last_score.score = player.score.score
player.score.timer = player_now_timer
player.score.score = bi(self.command[24:28])
if player.score.timer != 0 or self.room.state != 8:
for i in self.room.players:
i.extra_command_queue.append(
self.s.command_0e(self.player_index))
if self.room.is_ready(8, 1):
flag_13 = True
self.room.state = 1
self.room.song_idx = 0xffff
if self.room.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.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_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):
# 退出房间
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_11())
self.room.command_queue.append(self.s.command_13())
self.room.command_queue.append(self.s.command_14())
def command_0b(self):
# 推荐歌曲
song_idx = 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_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

@@ -0,0 +1,89 @@
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'\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 = 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 + PADDING[x]
def command_prefix(self, command: bytes):
length = self.room.command_queue_length
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))
def command_0c(self):
return self.command_encode((*self.command_prefix(b'\x0c'), self.random_code, b(self.room.state), b(self.room.countdown, 4), b(self.timestamp, 8)))
def command_0d(self, code: int):
# 3 你不是房主
# 5 有玩家目前无法开始
# 6 需要更多玩家以开始
# 7 有玩家无法游玩这首歌
return self.command_encode((*self.command_prefix(b'\x0d'), self.random_code, b(code)))
def command_0e(self, player_index: int):
# 分数广播
# 我猜616 写错了,首先 4 个 00 大概是分数使用了 8 bytes 转换,其次上一个分数根本就不需要哈哈哈哈哈哈!
player = self.room.players[player_index]
return self.command_encode((*self.command_prefix(b'\x0e'), 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):
# 歌曲推荐
player = self.room.players[player_index]
return self.command_encode((*self.command_prefix(b'\x0f'), b(player.player_id, 8), b(song_idx, 2)))
def command_10(self):
# 房主宣告
return self.command_encode((*self.command_prefix(b'\x10'), self.random_code, b(self.room.host_id, 8)))
def command_11(self):
return self.command_encode((*self.command_prefix(b'\x11'), self.random_code, self.room.get_players_info()))
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), player.info))
def command_13(self):
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, 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)))

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,2 +1,4 @@
cd /d %~dp0
python -B main.py
:: Develop server
python -B main.py
pause

View File

@@ -0,0 +1,9 @@
import os
import sys
import linkplay_server
os.chdir(sys.path[0])
if __name__ == '__main__':
linkplay_server.link_play()

View File

@@ -0,0 +1,39 @@
from flask import Blueprint, jsonify
from core.config_manager import Config
from . import (auth, course, friend, multiplayer, others, present, purchase,
score, user, world, mission)
__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

@@ -1,224 +0,0 @@
import os
import hashlib
from flask import url_for
import sqlite3
from server.sql import Connect
import time
from setting import Config
time_limit = Config.DOWNLOAD_TIMES_LIMIT # 每个玩家24小时下载次数限制
time_gap_limit = Config.DOWNLOAD_TIME_GAP_LIMIT # 下载链接有效秒数
def get_file_md5(file_path):
# 计算文件MD5
if not os.path.isfile(file_path):
return None
myhash = hashlib.md5()
with open(file_path, 'rb') as f:
while True:
b = f.read(8096)
if not b:
break
myhash.update(b)
return myhash.hexdigest()
def get_url(file_path, **kwargs):
# 获取下载地址
t = ''
if 't' in kwargs:
t = kwargs['t']
if Config.DOWNLOAD_LINK_PREFIX:
prefix = Config.DOWNLOAD_LINK_PREFIX
if prefix[-1] != '/':
prefix += '/'
return prefix + file_path + '?t=' + t
else:
return url_for('download', file_path=file_path, t=t, _external=True)
def get_one_song(c, user_id, song_id, file_dir='./database/songs', url_flag=True):
# 获取一首歌的下载链接,返回字典
dir_list = os.listdir(os.path.join(file_dir, song_id))
re = {}
now = int(time.time())
c.execute('''delete from download_token where user_id=:a and song_id=:b''', {
'a': user_id, 'b': song_id})
for i in dir_list:
if os.path.isfile(os.path.join(file_dir, song_id, i)) and i in ['0.aff', '1.aff', '2.aff', '3.aff', 'base.ogg', '3.ogg']:
token = hashlib.md5(
(str(user_id) + song_id + i + str(now)).encode(encoding='UTF-8')).hexdigest()
if i == 'base.ogg':
if 'audio' not in re:
re['audio'] = {}
c.execute(
'''select md5 from songfile where song_id=:a and file_type=-1''', {'a': song_id})
x = c.fetchone()
if x:
checksum = x[0]
else:
checksum = get_file_md5(os.path.join(
file_dir, song_id, 'base.ogg'))
re['audio']["checksum"] = checksum
if url_flag:
re['audio']["url"] = get_url(
file_path=song_id+'/base.ogg', t=token)
elif i == '3.ogg':
if 'audio' not in re:
re['audio'] = {}
c.execute(
'''select md5 from songfile where song_id=:a and file_type=-2''', {'a': song_id})
x = c.fetchone()
if x:
checksum = x[0]
else:
checksum = get_file_md5(os.path.join(
file_dir, song_id, '3.ogg'))
if url_flag:
re['audio']['3'] = {"checksum": checksum, "url": get_url(
file_path=song_id+'/3.ogg', t=token)}
else:
re['audio']['3'] = {"checksum": checksum}
else:
if 'chart' not in re:
re['chart'] = {}
c.execute(
'''select md5 from songfile where song_id=:a and file_type=:b''', {'a': song_id, 'b': int(i[0])})
x = c.fetchone()
if x:
checksum = x[0]
else:
checksum = get_file_md5(os.path.join(file_dir, song_id, i))
if url_flag:
re['chart'][i[0]] = {"checksum": checksum, "url": get_url(
file_path=song_id+'/'+i, t=token)}
else:
re['chart'][i[0]] = {"checksum": checksum}
if url_flag:
c.execute('''insert into download_token values(:a,:b,:c,:d,:e)''', {
'a': user_id, 'b': song_id, 'c': i, 'd': token, 'e': now})
return {song_id: re}
def get_all_songs(user_id, file_dir='./database/songs', url_flag=True):
# 获取所有歌的下载链接,返回字典
dir_list = os.listdir(file_dir)
re = {}
with Connect() as c:
for i in dir_list:
if os.path.isdir(os.path.join(file_dir, i)):
re.update(get_one_song(c, user_id, i, file_dir, url_flag))
return re
def get_some_songs(user_id, song_ids):
# 获取一些歌的下载链接,返回字典
re = {}
with Connect() as c:
for song_id in song_ids:
re.update(get_one_song(c, user_id, song_id))
return re
def is_token_able_download(t, path):
# token是否可以下载返回错误码0即可以
errorcode = 108
with Connect() as c:
c.execute('''select * from download_token where token = :t limit 1''',
{'t': t})
x = c.fetchone()
now = int(time.time())
if x and now - x[4] <= time_gap_limit and x[1]+'/'+x[2] == path:
c.execute(
'''select count(*) from user_download where user_id = :a''', {'a': x[0]})
y = c.fetchone()
if y and y[0] <= time_limit:
c.execute('''insert into user_download values(:a,:b,:c)''', {
'a': x[0], 'b': x[3], 'c': now})
errorcode = 0
else:
errorcode = 903
else:
errorcode = 108
return errorcode
def is_able_download(user_id):
# 是否可以下载,返回布尔值
f = False
with Connect() as c:
now = int(time.time())
c.execute(
'''delete from user_download where user_id = :a and time <= :b''', {'a': user_id, 'b': now - 24*3600})
c.execute(
'''select count(*) from user_download where user_id = :a''', {'a': user_id})
y = c.fetchone()
if y and y[0] <= time_limit:
f = True
else:
f = False
return f
def initialize_one_songfile(c, song_id, file_dir='./database/songs'):
# 计算并添加歌曲md5到表中无返回
dir_list = os.listdir(os.path.join(file_dir, song_id))
for i in dir_list:
if os.path.isfile(os.path.join(file_dir, song_id, i)) and i in ['0.aff', '1.aff', '2.aff', '3.aff', 'base.ogg', '3.ogg']:
if i == 'base.ogg':
c.execute('''insert into songfile values(:a,-1,:c)''', {
'a': song_id, 'c': get_file_md5(os.path.join(file_dir, song_id, 'base.ogg'))})
elif i == '3.ogg':
c.execute('''insert into songfile values(:a,-2,:c)''', {
'a': song_id, 'c': get_file_md5(os.path.join(file_dir, song_id, '3.ogg'))})
else:
c.execute('''insert into songfile values(:a,:b,:c)''', {
'a': song_id, 'b': int(i[0]), 'c': get_file_md5(os.path.join(file_dir, song_id, i))})
return
def initialize_songfile(file_dir='./database/songs'):
# 初始化歌曲数据的md5表返回错误信息
error = None
try:
dir_list = os.listdir(file_dir)
except:
error = 'OS error!'
return error
try:
conn = sqlite3.connect('./database/arcaea_database.db')
c = conn.cursor()
except:
error = 'Database error!'
return error
try:
c.execute('''delete from songfile''')
for i in dir_list:
if os.path.isdir(os.path.join(file_dir, i)):
initialize_one_songfile(c, i, file_dir)
except:
error = 'Initialization error!'
finally:
conn.commit()
conn.close()
return error

View File

@@ -1,113 +0,0 @@
from .sql import Connect
import base64
def get_song_unlock(client_song_map):
# 处理可用歌曲bit返回bytes
user_song_unlock = [0] * 512
for i in range(0, 1024, 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
return bytes(user_song_unlock)
def create_room(conn, user_id, client_song_map):
# 创建房间,返回错误码和房间与用户信息
error_code = 108
with Connect() as c:
c.execute('''select name from user where user_id=?''', (user_id,))
x = c.fetchone()
if x is not None:
name = x[0]
song_unlock = get_song_unlock(client_song_map)
conn.send((1, name, song_unlock))
data = conn.recv()
if data[0] == 0:
error_code = 0
return error_code, {'roomCode': data[1],
'roomId': str(data[2]),
'token': str(data[3]),
'key': (base64.b64encode(data[4])).decode(),
'playerId': str(data[5]),
'userId': user_id,
'orderedAllowedSongs': (base64.b64encode(song_unlock)).decode()
}
return error_code, None
def join_room(conn, user_id, client_song_map, room_code):
# 加入房间,返回错误码和房间与用户信息
error_code = 108
with Connect() as c:
c.execute('''select name from user where user_id=?''', (user_id,))
x = c.fetchone()
if x is not None:
name = x[0]
song_unlock = get_song_unlock(client_song_map)
conn.send((2, name, song_unlock, room_code))
data = conn.recv()
if data[0] == 0:
error_code = 0
return error_code, {'roomCode': data[1],
'roomId': str(data[2]),
'token': str(data[3]),
'key': (base64.b64encode(data[4])).decode(),
'playerId': str(data[5]),
'userId': user_id,
'orderedAllowedSongs': (base64.b64encode(data[6])).decode()
}
else:
error_code = data[0]
return error_code, None
def update_room(conn, user_id, token):
# 更新房间,返回错误码和房间与用户信息
error_code = 108
conn.send((3, int(token)))
data = conn.recv()
if data[0] == 0:
error_code = 0
return error_code, {'roomCode': data[1],
'roomId': str(data[2]),
'token': token,
'key': (base64.b64encode(data[3])).decode(),
'playerId': str(data[4]),
'userId': user_id,
'orderedAllowedSongs': (base64.b64encode(data[5])).decode()
}
else:
error_code = data[0]
return error_code, None

View File

@@ -1,282 +0,0 @@
from server.sql import Connect
import server.item
import server.character
import time
import json
def int2b(x):
# int与布尔值转换
if x is None or x == 0:
return False
else:
return True
def get_purchase(c, type='pack'):
# 读取packs内容返回字典列表
c.execute(
'''select * from purchase where purchase_name in (select purchase_name from purchase_item where type = :a)''', {'a': type})
x = c.fetchall()
if not x:
return []
re = []
for i in x:
items = []
c.execute(
'''select a.*, b.amount from item a, purchase_item b where a.item_id=b.item_id and a.type=b.type and b.purchase_name=:name''', {'name': i[0]})
y = c.fetchall()
t = None
if y:
for j in y:
if j[3]:
amount = j[3]
else:
amount = 1
if i[0] == j[0]:
# 物品排序,否则客户端报错
t = {
"type": j[1],
"id": j[0],
"is_available": int2b(j[2]),
'amount': amount
}
else:
items.append({
"type": j[1],
"id": j[0],
"is_available": int2b(j[2]),
"amount": amount
})
if t is not None:
# 放到列表头
items = [t, items]
r = {"name": i[0],
"items": items,
"price": i[1],
"orig_price": i[2]}
if i[3] > 0:
r['discount_from'] = i[3]
if i[4] > 0:
r['discount_to'] = i[4]
re.append(r)
return re
def get_single_purchase():
# main里面没开数据库这里写一下代替
re = []
with Connect() as c:
re = get_purchase(c, type='single')
return re
def buy_item(c, user_id, price):
# 购买接口,返回成功与否标识符和剩余源点数
c.execute('''select ticket from user where user_id = :a''',
{'a': user_id})
ticket = c.fetchone()
if ticket:
ticket = ticket[0]
else:
ticket = 0
if ticket < price:
return False, ticket
c.execute('''update user set ticket = :b where user_id = :a''',
{'a': user_id, 'b': ticket-price})
return True, ticket - price
def buy_thing(user_id, purchase_id):
# 购买物品接口,返回字典
success_flag = False
ticket = 0
packs = []
singles = []
characters = []
with Connect() as c:
c.execute('''select price, orig_price, discount_from, discount_to from purchase where purchase_name=:a''',
{'a': purchase_id})
x = c.fetchone()
price = 0
flag = False
if x:
price = x[0]
orig_price = x[1]
discount_from = x[2]
discount_to = x[3]
else:
return {
"success": False,
"error_code": 501
}
c.execute(
'''select item_id, type, amount from purchase_item where purchase_name=:a''', {'a': purchase_id})
x = c.fetchall()
if x:
now = int(time.time() * 1000)
if not(discount_from <= now <= discount_to):
price = orig_price
flag, ticket = buy_item(c, user_id, price)
if flag:
for i in x:
if i[2]:
amount = i[2]
else:
amount = 1
server.item.claim_user_item(c, user_id, i[0], i[1], amount)
success_flag = True
else:
return {
"success": False,
"error_code": 501
}
packs = server.item.get_user_items(c, user_id, 'pack')
singles = server.item.get_user_items(c, user_id, 'single')
characters = server.character.get_user_characters(c, user_id)
return {
"success": success_flag,
"value": {'user_id': user_id,
'ticket': ticket,
'packs': packs,
'singles': singles,
'characters': characters
}
}
def get_prog_boost(user_id):
# 世界模式源韵强化扣50源点返回剩余源点数
ticket = -1
with Connect() as c:
flag, ticket = buy_item(c, user_id, 50)
if flag:
c.execute('''update user set prog_boost = 1 where user_id = :a''', {
'a': user_id})
if ticket >= 0:
return ticket, None
else:
return 0, 108
def get_user_present(c, user_id):
# 获取用户奖励,返回字典列表
c.execute(
'''select * from present where present_id in (select present_id from user_present where user_id=:a)''', {'a': user_id})
x = c.fetchall()
re = []
now = int(time.time() * 1000)
if x:
for i in x:
if now <= int(i[1]):
c.execute(
'''select * from present_item where present_id=?''', (i[0],))
y = c.fetchall()
items = []
if y:
for j in y:
if j is not None:
items.append({
"type": j[2],
"id": j[1],
"amount": j[3]
})
re.append({'expire_ts': i[1],
'description': i[2],
'present_id': i[0],
'items': items
})
return re
def claim_user_present(user_id, present_id):
# 确认并删除用户奖励,返回成功与否的布尔值
flag = False
with Connect() as c:
c.execute('''select exists(select * from user_present where user_id=:a and present_id=:b)''',
{'a': user_id, 'b': present_id})
if c.fetchone() == (1,):
c.execute('''delete from user_present where user_id=:a and present_id=:b''',
{'a': user_id, 'b': present_id})
c.execute('''select * from present where present_id=:b''',
{'b': present_id})
x = c.fetchone()
now = int(time.time() * 1000)
if now <= int(x[1]):
# 处理memory
c.execute(
'''select * from present_item where present_id=?''', (x[0],))
y = c.fetchall()
flag = True
if y:
for j in y:
if j is not None:
flag = flag and server.item.claim_user_item(
c, user_id, j[1], j[2], j[3])
else:
# 过期
flag = False
return flag
def claim_user_redeem(user_id, code):
# 处理兑换码,返回碎片数量和错误码
fragment = 0
error_code = 108
with Connect() as c:
c.execute('''select * from redeem where code=:a''', {'a': code})
x = c.fetchone()
if not x:
return 0, 504
if x[1] == 0: # 一次性
c.execute(
'''select exists(select * from user_redeem where code=:a)''', {'a': code})
if c.fetchone() == (1,):
return 0, 505
elif x[1] == 1: # 每个玩家一次
c.execute('''select exists(select * from user_redeem where code=:a and user_id=:b)''',
{'a': code, 'b': user_id})
if c.fetchone() == (1,):
return 0, 506
c.execute('''insert into user_redeem values(:b,:a)''',
{'a': code, 'b': user_id})
c.execute('''select * from redeem_item where code=?''', (code,))
x = c.fetchall()
flag = True
if x:
for i in x:
if i[2] == 'fragment':
fragment += i[3]
else:
flag = flag and server.item.claim_user_item(
c, user_id, i[1], i[2], i[3])
if flag:
error_code = None
return fragment, error_code

View File

@@ -1,663 +0,0 @@
from server.config import Constant
from server.sql import Connect
import time
import json
import server.arcworld
import hashlib
from setting import Config
def b2int(x):
# int与布尔值转换
if x:
return 1
else:
return 0
def int2b(x):
# int与布尔值转换
if x is None or x == 0:
return False
else:
return True
def md5(code):
# md5加密算法
code = code.encode()
md5s = hashlib.md5()
md5s.update(code)
codes = md5s.hexdigest()
return codes
def get_score(c, user_id, song_id, difficulty):
# 根据user_id、song_id、难度得到该曲目最好成绩返回字典
c.execute('''select * from best_score where user_id = :a and song_id = :b and difficulty = :c''',
{'a': user_id, 'b': song_id, 'c': difficulty})
x = c.fetchone()
if x is not None:
c.execute('''select name, character_id, is_skill_sealed, is_char_uncapped, is_char_uncapped_override, favorite_character from user where user_id = :a''', {
'a': user_id})
y = c.fetchone()
if y is not None:
character = y[1]
is_char_uncapped = int2b(y[3])
if y[5] != -1:
character = y[5]
if not Config.CHARACTER_FULL_UNLOCK:
c.execute('''select is_uncapped, is_uncapped_override from user_char where user_id=:a and character_id=:b''', {
'a': user_id, 'b': character})
else:
c.execute('''select is_uncapped, is_uncapped_override from user_char_full where user_id=:a and character_id=:b''', {
'a': user_id, 'b': character})
z = c.fetchone()
if z:
if z[1] == 0:
is_char_uncapped = int2b(z[0])
else:
is_char_uncapped = False
else:
if y[4] == 1:
is_char_uncapped = False
return {
"user_id": x[0],
"song_id": x[1],
"difficulty": x[2],
"score": x[3],
"shiny_perfect_count": x[4],
"perfect_count": x[5],
"near_count": x[6],
"miss_count": x[7],
"health": x[8],
"modifier": x[9],
"time_played": x[10],
"best_clear_type": x[11],
"clear_type": x[12],
"name": y[0],
"character": character,
"is_skill_sealed": int2b(y[2]),
"is_char_uncapped": is_char_uncapped
}
else:
return {}
else:
return {}
def arc_score_friend(user_id, song_id, difficulty, limit=50):
# 得到用户好友分数表默认最大50个
r = []
with Connect() as c:
c.execute('''select user_id from best_score where user_id in (select :user_id union select user_id_other from friend where user_id_me = :user_id) and song_id = :song_id and difficulty = :difficulty order by score DESC, time_played DESC limit :limit''', {
'user_id': user_id, 'song_id': song_id, 'difficulty': difficulty, 'limit': limit})
x = c.fetchall()
if x != []:
rank = 0
for i in x:
rank += 1
y = get_score(c, i[0], song_id, difficulty)
y['rank'] = rank
r.append(y)
return r
def arc_score_top(song_id, difficulty, limit=20):
# 得到top分数表默认最多20个如果是负数则全部查询
r = []
with Connect() as c:
if limit >= 0:
c.execute('''select user_id from best_score where song_id = :song_id and difficulty = :difficulty order by score DESC, time_played DESC limit :limit''', {
'song_id': song_id, 'difficulty': difficulty, 'limit': limit})
else:
c.execute('''select user_id from best_score where song_id = :song_id and difficulty = :difficulty order by score DESC, time_played DESC''', {
'song_id': song_id, 'difficulty': difficulty})
x = c.fetchall()
if x != []:
rank = 0
for i in x:
rank += 1
y = get_score(c, i[0], song_id, difficulty)
y['rank'] = rank
r.append(y)
return r
def arc_score_me(user_id, song_id, difficulty, limit=20):
# 得到用户的排名默认最大20个
r = []
with Connect() as c:
c.execute('''select exists(select * from best_score where user_id = :user_id and song_id = :song_id and difficulty = :difficulty)''', {
'user_id': user_id, 'song_id': song_id, 'difficulty': difficulty})
if c.fetchone() == (1,):
c.execute('''select count(*) from best_score where song_id = :song_id and difficulty = :difficulty and (score>(select score from best_score where user_id = :user_id and song_id = :song_id and difficulty = :difficulty) or (score>(select score from best_score where user_id = :user_id and song_id = :song_id and difficulty = :difficulty) and time_played > (select time_played from best_score where user_id = :user_id and song_id = :song_id and difficulty = :difficulty)) )''', {
'user_id': user_id, 'song_id': song_id, 'difficulty': difficulty})
x = c.fetchone()
myrank = int(x[0]) + 1
c.execute('''select count(*) from best_score where song_id=:a and difficulty=:b''',
{'a': song_id, 'b': difficulty})
amount = int(c.fetchone()[0])
if myrank <= 4: # 排名在前4
return arc_score_top(song_id, difficulty, limit)
elif myrank >= 5 and myrank <= 9999 - limit + 4 and amount >= 10000: # 万名内前面有4个人
c.execute('''select user_id from best_score where song_id = :song_id and difficulty = :difficulty order by score DESC, time_played DESC limit :limit offset :offset''', {
'song_id': song_id, 'difficulty': difficulty, 'limit': limit, 'offset': myrank - 5})
x = c.fetchall()
if x != []:
rank = myrank - 5
for i in x:
rank += 1
y = get_score(c, i[0], song_id, difficulty)
y['rank'] = rank
r.append(y)
elif myrank >= 10000: # 万名外
c.execute('''select user_id from best_score where song_id = :song_id and difficulty = :difficulty order by score DESC, time_played DESC limit :limit offset :offset''', {
'song_id': song_id, 'difficulty': difficulty, 'limit': limit - 1, 'offset': 9999-limit})
x = c.fetchall()
if x != []:
rank = 9999 - limit
for i in x:
rank += 1
y = get_score(c, i[0], song_id, difficulty)
y['rank'] = rank
r.append(y)
y = get_score(c, user_id, song_id, difficulty)
y['rank'] = -1
r.append(y)
elif amount - myrank < limit - 5: # 后方人数不足
c.execute('''select user_id from best_score where song_id = :song_id and difficulty = :difficulty order by score DESC, time_played DESC limit :limit offset :offset''', {
'song_id': song_id, 'difficulty': difficulty, 'limit': limit, 'offset': amount - limit})
x = c.fetchall()
if x != []:
rank = amount - limit
if rank < 0:
rank = 0
for i in x:
rank += 1
y = get_score(c, i[0], song_id, difficulty)
y['rank'] = rank
r.append(y)
else:
c.execute('''select user_id from best_score where song_id = :song_id and difficulty = :difficulty order by score DESC, time_played DESC limit :limit offset :offset''', {
'song_id': song_id, 'difficulty': difficulty, 'limit': limit, 'offset': 9998-limit})
x = c.fetchall()
if x != []:
rank = 9998 - limit
for i in x:
rank += 1
y = get_score(c, i[0], song_id, difficulty)
y['rank'] = rank
r.append(y)
return r
def calculate_rating(defnum, score):
# 计算rating
if score >= 10000000:
ptt = defnum + 2
elif score < 9800000:
ptt = defnum + (score-9500000) / 300000
if ptt < 0 and defnum != -10:
ptt = 0
else:
ptt = defnum + 1 + (score-9800000) / 200000
return ptt
def get_one_ptt(song_id, difficulty, score: int) -> float:
# 单曲ptt计算ptt为负说明没谱面定数数据
ptt = -10
with Connect('./database/arcsong.db') as c:
if difficulty == 0:
c.execute('''select rating_pst from songs where sid = :sid;''', {
'sid': song_id})
elif difficulty == 1:
c.execute('''select rating_prs from songs where sid = :sid;''', {
'sid': song_id})
elif difficulty == 2:
c.execute('''select rating_ftr from songs where sid = :sid;''', {
'sid': song_id})
elif difficulty == 3:
c.execute('''select rating_byn from songs where sid = :sid;''', {
'sid': song_id})
x = c.fetchone()
defnum = -10 # 没在库里的全部当做定数-10
if x is not None and x != '':
defnum = float(x[0]) / 10
if defnum <= 0:
defnum = -10 # 缺少难度的当做定数-10
ptt = calculate_rating(defnum, score)
return ptt
def get_song_grade(x):
# 成绩转换评级
if x >= 9900000: # EX+
return 6
elif x < 9900000 and x >= 9800000: # EX
return 5
elif x < 9800000 and x >= 9500000: # AA
return 4
elif x < 9500000 and x >= 9200000: # A
return 3
elif x < 9200000 and x >= 8900000: # B
return 2
elif x < 8900000 and x >= 8600000: # C
return 1
else:
return 0
def get_song_state(x):
# 返回成绩状态,便于比较
if x == 3: # PM
return 5
elif x == 2: # FC
return 4
elif x == 5: # Hard Clear
return 3
elif x == 1: # Clear
return 2
elif x == 4: # Easy Clear
return 1
else: # Track Lost
return 0
def get_user_ptt_float(c, user_id) -> float:
# 总ptt计算返回浮点数
sumr = 0
c.execute('''select rating from best_score where user_id = :a order by rating DESC limit 30''', {
'a': user_id})
x = c.fetchall()
if not Config.USE_B10_AS_R10:
if x != []:
for i in x:
sumr += float(i[0])
c.execute('''select * from recent30 where user_id = :a''',
{'a': user_id})
x = c.fetchone()
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
else:
if x != []:
for i in range(len(x)):
t = float(x[i][0])
sumr += t
if i < 10:
sumr += t
return sumr/40
def get_user_ptt(c, user_id) -> int:
# 总ptt计算返回4位整数向下取整
return int(get_user_ptt_float(c, user_id)*100)
def get_user_world_rank(c, user_id) -> int:
# 用户世界排名计算同时返回排名值如果超过设定最大值返回0
with Connect('./database/arcsong.db') as c2:
c2.execute(
'''select sid, rating_ftr, rating_byn from songs''')
x = c2.fetchall()
if x:
song_list_ftr = [user_id]
song_list_byn = [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])
if len(song_list_ftr) >= 2:
c.execute('''select sum(score) from best_score where user_id=? and difficulty=2 and song_id in ({0})'''.format(
','.join(['?']*(len(song_list_ftr)-1))), tuple(song_list_ftr))
x = c.fetchone()
if x[0] is not None:
score_sum = x[0]
else:
score_sum = 0
if len(song_list_byn) >= 2:
c.execute('''select sum(score) from best_score where user_id=? and difficulty=3 and song_id in ({0})'''.format(
','.join(['?']*(len(song_list_byn)-1))), tuple(song_list_byn))
x = c.fetchone()
if x[0] is not None:
score_sum += x[0]
else:
score_sum += 0
c.execute('''update user set world_rank_score = :b where user_id = :a''', {
'a': user_id, 'b': score_sum})
c.execute(
'''select count(*) from user where world_rank_score > ?''', (score_sum,))
x = c.fetchone()
if x and x[0] + 1 <= Config.WORLD_RANK_MAX:
return x[0] + 1
else:
return 0
def update_recent30(c, user_id, song_id, rating, is_protected):
# 刷新r30这里的判断方法存疑这里的song_id结尾包含难度数字
def insert_r30table(c, user_id, a, b):
# 更新r30表
c.execute('''delete from recent30 where user_id = :a''',
{'a': user_id})
sql = 'insert into recent30 values(' + str(user_id)
for i in range(0, 30):
if a[i] is not None and b[i] is not None:
sql = sql + ',' + str(a[i]) + ',"' + b[i] + '"'
else:
sql = sql + ',0,""'
sql = sql + ')'
c.execute(sql)
c.execute('''select * from recent30 where user_id = :a''', {'a': user_id})
x = c.fetchone()
if not x:
x = [None] * 61
x[0] = user_id
for i in range(2, 61, 2):
x[i] = ''
songs = []
flag = True
for i in range(2, 61, 2):
if x[i] is None or x[i] == '':
r30_id = 29
flag = False
break
if x[i] not in songs:
songs.append(x[i])
if flag:
n = len(songs)
if n >= 11:
r30_id = 29
elif song_id not in songs and n == 10:
r30_id = 29
elif song_id in songs and n == 10:
i = 29
while x[i*2+2] == song_id:
i -= 1
r30_id = i
elif song_id not in songs and n == 9:
i = 29
while x[i*2+2] == song_id:
i -= 1
r30_id = i
else:
r30_id = 29
a = []
b = []
for i in range(1, 61, 2):
a.append(x[i])
b.append(x[i+1])
if is_protected:
ptt_pre = get_user_ptt_float(c, user_id)
a_pre = [x for x in a]
b_pre = [x for x in b]
for i in range(r30_id, 0, -1):
a[i] = a[i-1]
b[i] = b[i-1]
a[0] = rating
b[0] = song_id
insert_r30table(c, user_id, a, b)
if is_protected:
ptt = get_user_ptt_float(c, user_id)
if ptt < ptt_pre:
# 触发保护
if song_id in b_pre:
for i in range(29, -1, -1):
if song_id == b_pre[i] and rating > a_pre[i]:
# 发现重复歌曲更新到最高rating
a_pre[i] = rating
break
insert_r30table(c, user_id, a_pre, b_pre)
return None
def arc_score_post(user_id, song_id, difficulty, score, shiny_perfect_count, perfect_count, near_count, miss_count, health, modifier, beyond_gauge, clear_type):
# 分数上传返回变化后的ptt和世界模式变化
ptt = None
re = None
with Connect() as c:
rating = get_one_ptt(song_id, difficulty, score)
if rating < 0: # 没数据不会向recent30里记入
unrank_flag = True
rating = 0
else:
unrank_flag = False
now = int(time.time() * 1000)
# recent 更新
c.execute('''update user set song_id = :b, difficulty = :c, 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''', {
'a': user_id, 'b': song_id, 'c': difficulty, 'd': score, 'e': shiny_perfect_count, 'f': perfect_count, 'g': near_count, 'h': miss_count, 'i': health, 'j': modifier, 'k': clear_type, 'l': rating, 'm': now})
# 成绩录入
c.execute('''select score, best_clear_type from best_score where user_id = :a and song_id = :b and difficulty = :c''', {
'a': user_id, 'b': song_id, 'c': difficulty})
now = int(now // 1000)
x = c.fetchone()
if x is None:
first_protect_flag = True # 初见保护
c.execute('''insert into best_score values(:a,:b,:c,:d,:e,:f,:g,:h,:i,:j,:k,:l,:m,:n)''', {
'a': user_id, 'b': song_id, 'c': difficulty, 'd': score, 'e': shiny_perfect_count, 'f': perfect_count, 'g': near_count, 'h': miss_count, 'i': health, 'j': modifier, 'k': now, 'l': clear_type, 'm': clear_type, 'n': rating})
else:
first_protect_flag = False
if get_song_state(clear_type) > get_song_state(int(x[1])): # 状态更新
c.execute('''update best_score set best_clear_type = :a where user_id = :b and song_id = :c and difficulty = :d''', {
'a': clear_type, 'b': user_id, 'c': song_id, 'd': difficulty})
if score >= int(x[0]): # 成绩更新
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': user_id, 'b': song_id, 'c': difficulty, 'd': score, 'e': shiny_perfect_count, 'f': perfect_count, 'g': near_count, 'h': miss_count, 'i': health, 'j': modifier, 'k': clear_type, 'l': rating, 'm': now})
if not unrank_flag:
# recent30 更新
if health == -1 or int(score) >= 9800000 or first_protect_flag:
update_recent30(c, user_id, song_id +
str(difficulty), rating, True)
else:
update_recent30(c, user_id, song_id +
str(difficulty), rating, False)
# 总PTT更新
ptt = get_user_ptt(c, user_id)
c.execute('''update user set rating_ptt = :a where user_id = :b''', {
'a': ptt, 'b': user_id})
# 世界模式判断
c.execute('''select stamina_multiply,fragment_multiply,prog_boost_multiply from world_songplay where user_id=:a and song_id=:b and difficulty=:c''', {
'a': user_id, 'b': song_id, 'c': difficulty})
x = c.fetchone()
if x:
re = server.arcworld.world_update(
c, user_id, song_id, difficulty, rating, clear_type, beyond_gauge, health, x[0], x[1], x[2])
re['global_rank'] = get_user_world_rank(c, user_id) # 更新世界排名
re["user_rating"] = ptt
else:
re = {'global_rank': get_user_world_rank(
c, user_id), 'user_rating': ptt}
return ptt, re
def arc_score_check(user_id, song_id, difficulty, score, shiny_perfect_count, perfect_count, near_count, miss_count, health, modifier, beyond_gauge, clear_type, song_token, song_hash, submission_hash):
# 分数校验,返回布尔值
if shiny_perfect_count < 0 or perfect_count < 0 or near_count < 0 or miss_count < 0 or score < 0:
return False
if difficulty not in [0, 1, 2, 3]:
return False
all_note = perfect_count + near_count + miss_count
ascore = 10000000 / all_note * \
(perfect_count + near_count/2) + shiny_perfect_count
if abs(ascore - score) >= 5:
return False
with Connect() as c: # 歌曲谱面MD5检查服务器没有谱面就不管了
c.execute('''select md5 from songfile where song_id=:a and file_type=:b''', {
'a': song_id, 'b': int(difficulty)})
x = c.fetchone()
if x:
if x[0] != song_hash:
return False
x = song_token + song_hash + song_id + str(difficulty) + str(score) + str(shiny_perfect_count) + str(
perfect_count) + str(near_count) + str(miss_count) + str(health) + str(modifier) + str(clear_type)
y = str(user_id) + song_hash
checksum = md5(x+md5(y))
if checksum != submission_hash:
return False
return True
def refresh_all_score_rating():
# 刷新所有best成绩的rating
error = 'Unknown error.'
with Connect('./database/arcsong.db') as c:
c.execute(
'''select sid, rating_pst, rating_prs, rating_ftr, rating_byn from songs''')
x = c.fetchall()
if x:
song_list = [i[0] for i in x]
with Connect() as c:
c.execute('''update best_score set rating=0 where song_id not in ({0})'''.format(
','.join(['?']*len(song_list))), tuple(song_list))
for i in x:
for j in range(0, 4):
defnum = -10 # 没在库里的全部当做定数-10
if i is not None:
defnum = float(i[j+1]) / 10
if defnum <= 0:
defnum = -10 # 缺少难度的当做定数-10
c.execute('''select user_id, score from best_score where song_id=:a and difficulty=:b''', {
'a': i[0], 'b': j})
y = c.fetchall()
if y:
for k in y:
ptt = calculate_rating(defnum, k[1])
if ptt < 0:
ptt = 0
c.execute('''update best_score set rating=:a where user_id=:b and song_id=:c and difficulty=:d''', {
'a': ptt, 'b': k[0], 'c': i[0], 'd': j})
error = None
else:
error = 'No song data.'
return error
def arc_all_post(user_id, scores_data, clearlamps_data, clearedsongs_data, unlocklist_data, installid_data, devicemodelname_data, story_data):
# 向云端同步,无返回
with Connect() as c:
now = int(time.time() * 1000)
c.execute('''delete from user_save where user_id=:a''', {'a': user_id})
c.execute('''insert into user_save values(:a,:b,:c,:d,:e,:f,:g,:h,:i)''', {
'a': user_id, 'b': scores_data, 'c': clearlamps_data, 'd': clearedsongs_data, 'e': unlocklist_data, 'f': installid_data, 'g': devicemodelname_data, 'h': story_data, 'i': now})
return None
def arc_all_get(user_id):
# 从云端同步,返回字典
scores_data = []
clearlamps_data = []
clearedsongs_data = []
unlocklist_data = []
installid_data = ''
devicemodelname_data = ''
story_data = []
createdAt = 0
with Connect() as c:
c.execute('''select * from user_save where user_id=:a''',
{'a': user_id})
x = c.fetchone()
if x:
scores_data = json.loads(x[1])[""]
clearlamps_data = json.loads(x[2])[""]
clearedsongs_data = json.loads(x[3])[""]
unlocklist_data = json.loads(x[4])[""]
installid_data = json.loads(x[5])["val"]
devicemodelname_data = json.loads(x[6])["val"]
story_data = json.loads(x[7])[""]
if x[8]:
createdAt = int(x[8])
if Config.SAVE_FULL_UNLOCK:
installid_data = "0fcec8ed-7b62-48e2-9d61-55041a22b123"
story_data = Constant.story_data
unlocklist_data = Constant.unlocklist_data
return {
"user_id": user_id,
"story": {
"": story_data
},
"devicemodelname": {
"val": devicemodelname_data
},
"installid": {
"val": installid_data
},
"unlocklist": {
"": unlocklist_data
},
"clearedsongs": {
"": clearedsongs_data
},
"clearlamps": {
"": clearlamps_data
},
"scores": {
"": scores_data
},
"version": {
"val": 1
},
"createdAt": createdAt
}

View File

@@ -1,672 +0,0 @@
import json
from server.sql import Connect
from .config import Constant
from setting import Config
import server.item
import server.character
import server.info
import server.arcpurchase
import os
import time
import random
def int2b(x):
# int与布尔值转换
if x is None or x == 0:
return False
else:
return True
def calc_stamina(max_stamina_ts, curr_stamina):
# 计算体力,返回剩余体力数值
stamina = int(Constant.MAX_STAMINA - (max_stamina_ts -
int(time.time()*1000)) / Constant.STAMINA_RECOVER_TICK)
if stamina >= Constant.MAX_STAMINA:
if curr_stamina >= Constant.MAX_STAMINA:
stamina = curr_stamina
else:
stamina = Constant.MAX_STAMINA
if stamina < 0:
stamina = 0
return stamina
def get_world_name(file_dir='./database/map'):
# 获取所有地图名称,返回列表
L = []
for root, dirs, files in os.walk(file_dir):
for file in files:
if os.path.splitext(file)[1] == '.json':
L.append(os.path.splitext(file)[0])
return L
def get_world_info(map_id):
# 读取json文件内容返回字典
world_info = {}
with open('./database/map/'+map_id+'.json', 'r') as f:
world_info = json.load(f)
return world_info
def get_user_world_info(user_id, map_id):
# 读取json文件内容加上用户信息返回字典
info = get_world_info(map_id)
with Connect() as c:
c.execute('''select * from user_world where map_id = :a and user_id = :b''',
{'a': map_id, 'b': user_id})
x = c.fetchone()
if x:
info['curr_position'] = x[2]
info['curr_capture'] = x[3]
info['is_locked'] = int2b(x[4])
else:
info['curr_position'] = 0
info['curr_capture'] = 0
info['is_locked'] = True
c.execute('''insert into user_world values(:a,:b,0,0,1)''', {
'a': user_id, 'b': map_id})
return info
def get_current_map(user_id):
# 得到user的当前图返回字符串
re = ''
with Connect() as c:
c.execute('''select current_map from user where user_id = :a''',
{'a': user_id})
x = c.fetchone()
if x:
re = x[0]
return re
def get_world_all(user_id):
# 读取所有地图信息并处理,返回字典列表
re = []
worlds = get_world_name()
with Connect() as c:
for map_id in worlds:
info = get_world_info(map_id)
steps = info['steps']
del info['steps']
rewards = []
for step in steps:
if 'items' in step:
rewards.append(
{'items': step['items'], 'position': step['position']})
info['rewards'] = rewards
c.execute('''select * from user_world where map_id = :a and user_id = :b''',
{'a': map_id, 'b': user_id})
x = c.fetchone()
if x:
info['curr_position'] = x[2]
info['curr_capture'] = x[3]
info['is_locked'] = int2b(x[4])
else:
info['curr_position'] = 0
info['curr_capture'] = 0
info['is_locked'] = True
c.execute('''insert into user_world values(:a,:b,0,0,1)''', {
'a': user_id, 'b': map_id})
re.append(info)
return re
def get_available_maps():
# 获取当前可用图(用户设定的),返回字典列表
re = []
for i in Config.AVAILABLE_MAP:
info = get_world_info(i)
del info['steps']
try:
del info['is_locked']
del info['curr_position']
del info['curr_capture']
except:
pass
re.append(info)
return re
def get_user_world(user_id, map_id):
# 获取用户图信息,返回字典
re = {}
with Connect() as c:
c.execute('''select * from user_world where map_id = :a and user_id = :b''',
{'a': map_id, 'b': user_id})
x = c.fetchone()
re = {
"user_id": user_id,
"curr_position": 0,
"curr_capture": 0,
"is_locked": True,
"map_id": map_id
}
if x:
re['curr_position'] = x[2]
re['curr_capture'] = x[3]
re['is_locked'] = int2b(x[4])
else:
c.execute('''insert into user_world values(:a,:b,0,0,1)''', {
'a': user_id, 'b': map_id})
return re
def unlock_user_world(user_id, map_id):
# 解锁用户的图,返回成功与否布尔值
with Connect() as c:
c.execute(
'''select is_locked from user_world where map_id=? and user_id=?''', (map_id, user_id))
x = c.fetchone()
if x:
is_locked = x[0]
else:
is_locked = 1
c.execute('''insert into user_world values(:a,:b,0,0,1)''', {
'a': user_id, 'b': map_id})
if is_locked == 1:
map_info = get_world_info(map_id)
if 'require_type' in map_info and map_info['require_type'] != '':
if map_info['require_type'] in ['pack', 'single']:
c.execute('''select exists(select * from user_item where user_id=? and item_id=? and type=?)''',
(user_id, map_info['require_id'], map_info['require_type']))
if c.fetchone() == (0,):
return False
c.execute(
'''update user_world set is_locked=0 where user_id=? and map_id=?''', (user_id, map_id))
return True
def change_user_current_map(user_id, map_id):
# 改变用户当前图
with Connect() as c:
c.execute('''update user set current_map = :a where user_id=:b''', {
'a': map_id, 'b': user_id})
return None
def play_world_song(user_id, args):
# 声明是世界模式的打歌,并且记录加成信息,返回字典
r = {}
with Connect() as c:
stamina_multiply = 1
fragment_multiply = 100
prog_boost_multiply = 0
if 'stamina_multiply' in args:
stamina_multiply = int(args['stamina_multiply'])
if 'fragment_multiply' in args:
fragment_multiply = int(args['fragment_multiply'])
if 'prog_boost_multiply' in args:
c.execute('''select prog_boost from user where user_id=:a''', {
'a': user_id})
x = c.fetchone()
if x and x[0] == 1:
prog_boost_multiply = 300
c.execute('''delete from world_songplay where user_id=:a and song_id=:b and difficulty=:c''', {
'a': user_id, 'b': args['song_id'], 'c': args['difficulty']})
c.execute('''insert into world_songplay values(:a,:b,:c,:d,:e,:f)''', {
'a': user_id, 'b': args['song_id'], 'c': args['difficulty'], 'd': stamina_multiply, 'e': fragment_multiply, 'f': prog_boost_multiply})
c.execute('''select current_map from user where user_id = :a''', {
'a': user_id})
map_id = c.fetchone()[0]
info = get_world_info(map_id)
# 体力计算
c.execute(
'''select max_stamina_ts, stamina from user where user_id=?''', (user_id,))
x = c.fetchone()
max_stamina_ts = x[0] if x and x[0] is not None else 0
stamina = x[1] if x and x[1] is not None else 12
now = int(time.time() * 1000)
# 体力不足
if calc_stamina(max_stamina_ts, stamina) < info['stamina_cost']:
return {}
stamina = calc_stamina(max_stamina_ts, stamina) - \
info['stamina_cost'] * stamina_multiply
max_stamina_ts = now + Constant.STAMINA_RECOVER_TICK * \
(Constant.MAX_STAMINA - stamina)
c.execute('''update user set max_stamina_ts=?, stamina=? where user_id=?''',
(max_stamina_ts, stamina, user_id))
r = {
"stamina": stamina,
"max_stamina_ts": max_stamina_ts,
"token": "13145201919810"
}
return r
def climb_step(user_id, map_id, step, prev_capture, prev_position):
# 爬梯子,返回奖励列表,台阶列表,当前的位置和坐标,图信息
info = get_world_info(map_id)
step_count = int(info['step_count'])
restrict_ids = [[]] * step_count
capture = [0] * step_count
reward_bundle = [""] * step_count # 暂且不用
restrict_id = [""] * step_count
restrict_type = [""] * step_count
items = [[]] * step_count
step_type = [[]] * step_count
speed_limit_value = [0] * step_count
plus_stamina_value = [0] * step_count
for i in info['steps']:
capture[i['position']] = i['capture']
if 'items' in i:
items[i['position']] = i['items']
if 'restrict_id' in i:
restrict_id[i['position']] = i['restrict_id']
if 'restrict_ids' in i:
restrict_ids[i['position']] = i['restrict_ids']
if 'restrict_type' in i:
restrict_type[i['position']] = i['restrict_type']
if 'step_type' in i:
step_type[i['position']] = i['step_type']
if "speedlimit" in i['step_type']:
speed_limit_value[i['position']] = i['speed_limit_value']
if "plusstamina" in i['step_type']:
plus_stamina_value[i['position']] = i['plus_stamina_value']
if info['is_beyond']: # beyond判断
dt = info['beyond_health'] - prev_capture
if dt >= step:
curr_capture = prev_capture + step
else:
curr_capture = info['beyond_health']
i = 0
t = prev_capture + step
while i < step_count and t > 0:
dt = capture[i]
if dt > t:
t = 0
else:
t -= dt
i += 1
if i >= step_count:
curr_position = step_count - 1
else:
curr_position = i
else:
i = prev_position
j = prev_capture
t = step
while t > 0 and i < step_count:
dt = capture[i] - j
if dt > t:
j += t
t = 0
else:
t -= dt
j = 0
i += 1
if i >= step_count:
curr_position = step_count - 1
curr_capture = 0
else:
curr_position = i
curr_capture = j
rewards = []
steps = []
for i in range(prev_position, curr_position+1):
if items[i]:
rewards.append({'position': i, 'items': items[i]})
x = {
"map_id": map_id,
"position": i,
"restrict_ids": restrict_ids[i],
"capture": capture[i],
"reward_bundle": reward_bundle[i],
"restrict_id": restrict_id[i],
"restrict_type": restrict_type[i]
}
if step_type[i]:
x['step_type'] = step_type[i]
if speed_limit_value[i]:
x['speed_limit_value'] = speed_limit_value[i]
if plus_stamina_value[i]:
x['plus_stamina_value'] = plus_stamina_value[i]
steps.append(x)
return rewards, steps, curr_position, curr_capture, info
def world_update(c, user_id, song_id, difficulty, rating, clear_type, beyond_gauge, health, stamina_multiply=1, fragment_multiply=100, prog_boost_multiply=0):
# 成绩上传后世界模式更新,返回字典
step_times = stamina_multiply * fragment_multiply / \
100 * (prog_boost_multiply+100)/100
exp_times = stamina_multiply * (prog_boost_multiply+100)/100
if prog_boost_multiply != 0:
c.execute('''update user set prog_boost = 0 where user_id = :a''', {
'a': user_id})
c.execute('''delete from world_songplay where user_id=:a and song_id=:b and difficulty=:c''', {
'a': user_id, 'b': song_id, 'c': difficulty})
c.execute(
'''select character_id, max_stamina_ts, stamina, is_skill_sealed, is_char_uncapped, is_char_uncapped_override from user where user_id=?''', (user_id,))
x = c.fetchone()
character_id = x[0] if x and x[0] is not None else 0
max_stamina_ts = x[1] if x and x[1] is not None else 0
stamina = x[2] if x and x[2] is not None else 12
is_skill_sealed = x[3] if x and x[3] is not None else 1
skill = False
skill_uncap = False
level = 1
exp = 0
frag = 50
prog = 50
overdrive = 50
if not is_skill_sealed:
if x:
skill = True
if x[4] is not None and x[4] == 1:
skill_uncap = True
if x[5] is not None and x[5] == 1:
skill_uncap = False
c.execute('''select frag1,prog1,overdrive1,frag20,prog20,overdrive20,frag30,prog30,overdrive30,skill_id,skill_id_uncap from character where character_id=?''', (character_id,))
x = c.fetchone()
if Config.CHARACTER_FULL_UNLOCK:
c.execute('''select level, exp from user_char_full where user_id = :a and character_id = :b''', {
'a': user_id, 'b': character_id})
else:
c.execute('''select level, exp from user_char where user_id = :a and character_id = :b''', {
'a': user_id, 'b': character_id})
y = c.fetchone()
if y:
level = y[0]
exp = y[1]
else:
level = 1
exp = 0
if x:
frag = server.character.calc_char_value(level, x[0], x[3], x[6])
prog = server.character.calc_char_value(level, x[1], x[4], x[7])
overdrive = server.character.calc_char_value(
level, x[2], x[5], x[8])
if x[9] is not None and x[9] != '' and skill:
skill = x[9]
else:
skill = None
if x[10] is not None and x[9] != '' and skill_uncap:
skill_uncap = x[10]
else:
skill_uncap = None
else:
frag = 0
prog = 0
overdrive = 0
skill = None
skill_uncap = None
skill_special = ''
if skill_uncap is not None and skill_uncap and skill_uncap in ['eto_uncap', 'luna_uncap', 'ayu_uncap', 'skill_vita']:
skill_special = skill_uncap
elif skill is not None and skill and skill in ['eto_uncap', 'luna_uncap', 'ayu_uncap', 'skill_vita']:
skill_special = skill
c.execute('''select current_map from user where user_id = :a''', {
'a': user_id})
map_id = c.fetchone()[0]
if beyond_gauge == 0: # 是否是beyond挑战
prog_tempest = 0
if not is_skill_sealed and character_id == 35:
# 风暴对立
if Config.CHARACTER_FULL_UNLOCK:
prog_tempest = 60
else:
c.execute(
'''select sum(level) from user_char where user_id=?''', (user_id,))
prog_tempest = int(x[0]) / 10 if x else 0
if prog_tempest > 60:
prog_tempest = 60
elif prog_tempest < 0:
prog_tempest = 0
base_step = 2.5 + 2.45*rating**0.5
step = base_step * (prog + prog_tempest) / 50 * step_times
else:
info = get_world_info(map_id)
if clear_type == 0:
base_step = 25/28 + (rating)**0.5 * 0.43
else:
base_step = 75/28 + (rating)**0.5 * 0.43
if character_id in info['character_affinity']:
affinity_multiplier = info['affinity_multiplier'][info['character_affinity'].index(
character_id)]
else:
affinity_multiplier = 1
if skill_special == 'skill_vita':
# vita技能overdrive随回忆率提升提升量最多为10
# 此处采用线性函数
overdrive_extra = 0
if 0 < health <= 100:
overdrive_extra = health / 10
overdrive += overdrive_extra
step = base_step * overdrive / 50 * \
step_times * affinity_multiplier
c.execute('''select * from user_world where user_id = :a and map_id =:b''',
{'a': user_id, 'b': map_id})
y = c.fetchone()
if y[4] == 1: # 图不可用
return {}
rewards, steps, curr_position, curr_capture, info = climb_step(
user_id, map_id, step, y[3], y[2])
# Eto、Luna、Ayu的技能
character_bonus_progress = None
if skill_special == 'eto_uncap':
# eto觉醒技能获得残片奖励时世界模式进度加7
fragment_flag = False
for i in rewards:
for j in i['items']:
if j['type'] == 'fragment':
fragment_flag = True
break
if fragment_flag:
break
if fragment_flag:
character_bonus_progress = Constant.ETO_UNCAP_BONUS_PROGRESS
step += character_bonus_progress * step_times
rewards, steps, curr_position, curr_capture, info = climb_step(
user_id, map_id, step, y[3], y[2]) # 二次爬梯,重新计算
elif skill_special == 'luna_uncap':
# luna觉醒技能限制格开始时世界模式进度加7
if 'restrict_id' in steps[0] and 'restrict_type' in steps[0] and steps[0]['restrict_type'] != '' and steps[0]['restrict_id'] != '':
character_bonus_progress = Constant.LUNA_UNCAP_BONUS_PROGRESS
step += character_bonus_progress * step_times
rewards, steps, curr_position, curr_capture, info = climb_step(
user_id, map_id, step, y[3], y[2]) # 二次爬梯,重新计算
elif skill_special == 'ayu_uncap':
# ayu觉醒技能世界模式进度+5或-5但不会小于0
if random.random() >= 0.5:
character_bonus_progress = Constant.AYU_UNCAP_BONUS_PROGRESS
else:
character_bonus_progress = -Constant.AYU_UNCAP_BONUS_PROGRESS
step += character_bonus_progress * step_times
if step < 0:
character_bonus_progress += step / step_times
step = 0
rewards, steps, curr_position, curr_capture, info = climb_step(
user_id, map_id, step, y[3], y[2]) # 二次爬梯,重新计算
for i in rewards: # 物品分发
for j in i['items']:
amount = j['amount'] if 'amount' in j else 1
item_id = j['id'] if 'id' in j else ''
server.item.claim_user_item(c, user_id, item_id, j['type'], amount)
if 'step_type' in steps[-1]:
if 'plusstamina' in steps[-1]['step_type'] and 'plus_stamina_value' in steps[-1]:
# 体力格子
max_stamina_ts, stamina = add_stamina(
c, user_id, int(steps[-1]['plus_stamina_value']))
# 角色升级
if not Config.CHARACTER_FULL_UNLOCK:
exp, level = server.character.calc_level_up(
c, user_id, character_id, exp, exp_times*rating*6)
c.execute('''update user_char set level=?, exp=? where user_id=? and character_id=?''',
(level, exp, user_id, character_id))
else:
exp = Constant.LEVEL_STEPS[level]
re = {
"rewards": rewards,
"exp": exp,
"level": level,
"base_progress": base_step,
"progress": step,
"user_map": {
"user_id": user_id,
"curr_position": curr_position,
"curr_capture": curr_capture,
"is_locked": int2b(y[4]),
"map_id": map_id,
"prev_capture": y[3],
"prev_position": y[2],
"beyond_health": info['beyond_health']
},
"char_stats": {
"character_id": character_id,
"frag": frag,
"prog": prog,
"overdrive": overdrive
},
"current_stamina": calc_stamina(max_stamina_ts, stamina),
"max_stamina_ts": max_stamina_ts
}
if beyond_gauge == 0:
re["user_map"]["steps"] = steps
else:
re["user_map"]["steps"] = len(steps)
if character_id == 35 and not is_skill_sealed:
re['char_stats']['prog_tempest'] = prog_tempest
re['char_stats']['prog'] += prog_tempest
if character_bonus_progress is not None:
re['character_bonus_progress'] = character_bonus_progress
if stamina_multiply != 1:
re['stamina_multiply'] = stamina_multiply
if fragment_multiply != 100:
re['fragment_multiply'] = fragment_multiply
if prog_boost_multiply != 0:
re['prog_boost_multiply'] = prog_boost_multiply
if curr_position == info['step_count']-1 and info['is_repeatable']: # 循环图判断
curr_position = 0
c.execute('''update user_world set curr_position=:a, curr_capture=:b where user_id=:c and map_id=:d''', {
'a': curr_position, 'b': curr_capture, 'c': user_id, 'd': map_id})
return re
def add_stamina(c, user_id, add_stamina):
# 增添体力返回max_stamina_ts和stamina
now = int(time.time() * 1000)
c.execute(
'''select max_stamina_ts, stamina from user where user_id=?''', (user_id,))
x = c.fetchone()
if x and x[0] is not None and x[1] is not None:
stamina = calc_stamina(x[0], x[1]) + add_stamina
max_stamina_ts = now - \
(stamina-Constant.MAX_STAMINA) * \
Constant.STAMINA_RECOVER_TICK
else:
max_stamina_ts = now
stamina = Constant.MAX_STAMINA
c.execute('''update user set max_stamina_ts=?, stamina=? where user_id=?''',
(max_stamina_ts, stamina, user_id))
return max_stamina_ts, stamina
def buy_stamina_by_fragment(user_id):
# 残片买体力,返回字典和错误码
r = {}
with Connect() as c:
c.execute(
'''select next_fragstam_ts from user where user_id=?''', (user_id,))
x = c.fetchone()
if x:
now = int(time.time() * 1000)
next_fragstam_ts = x[0] if x[0] else 0
if now < next_fragstam_ts:
return {}, 905
next_fragstam_ts = now + 24*3600*1000
max_stamina_ts, stamina = add_stamina(c, user_id, 6)
c.execute('''update user set next_fragstam_ts=?, max_stamina_ts=?, stamina=? where user_id=?''',
(next_fragstam_ts, max_stamina_ts, stamina, user_id))
r = {
"user_id": user_id,
"stamina": stamina,
"max_stamina_ts": max_stamina_ts,
"next_fragstam_ts": next_fragstam_ts
}
return r, None
def buy_stamina_by_ticket(user_id):
# 源点买体力,返回字典和错误码
r = {}
with Connect() as c:
flag, ticket = server.arcpurchase.buy_item(c, user_id, 50)
if flag:
max_stamina_ts, stamina = add_stamina(c, user_id, 6)
c.execute('''update user set max_stamina_ts=?, stamina=? where user_id=?''',
(max_stamina_ts, stamina, user_id))
r = {
"user_id": user_id,
"stamina": stamina,
"max_stamina_ts": max_stamina_ts,
"ticket": ticket
}
else:
return None, 501
return r, None

View File

@@ -1,233 +1,67 @@
import hashlib
import time
from server.sql import Connect
import functools
from setting import Config
from flask import jsonify
import base64
from functools import wraps
BAN_TIME = [1, 3, 7, 15, 31]
from flask import Blueprint, current_app, g, jsonify, request
from core.error import ArcError, NoAccess
from core.sql import Connect
from core.user import UserAuth, UserLogin
from .func import arc_try, error_return, header_check
bp = Blueprint('auth', __name__, url_prefix='/auth')
def arc_login(name: str, password: str, device_id: str, ip: str): # 登录判断
# 查询数据库中的user表验证账号密码返回并记录token和user_id多返回个error code和extra
# token采用user_id和时间戳连接后hash生成真的是瞎想的没用bear
# 密码和token的加密方式为 SHA-256
@bp.route('/login', methods=['POST']) # 登录接口
@arc_try
def login():
headers = request.headers
e = header_check(request)
if e is not None:
raise e
error_code = 108
token = None
user_id = None
request.form['grant_type']
with Connect() as c:
hash_pwd = hashlib.sha256(password.encode("utf8")).hexdigest()
c.execute('''select user_id, password, ban_flag from user where name = :name''', {
'name': name})
x = c.fetchone()
if x is not None:
now = int(time.time() * 1000)
if x[2] is not None and x[2] != '':
# 自动封号检查
ban_timestamp = int(x[2].split(':', 1)[1])
if ban_timestamp > now:
return None, 105, {'remaining_ts': ban_timestamp-now}
if x[1] == '':
# 账号封禁
error_code = 106
elif x[1] == hash_pwd:
user_id = str(x[0])
token = hashlib.sha256(
(user_id + str(now)).encode("utf8")).hexdigest()
c.execute(
'''select login_device from login where user_id = :user_id''', {"user_id": user_id})
y = c.fetchall()
if y:
device_list = []
for i in y:
if i[0]:
device_list.append(i[0])
else:
device_list.append('')
should_delete_num = len(
device_list) + 1 - Config.LOGIN_DEVICE_NUMBER_LIMIT
if not Config.ALLOW_LOGIN_SAME_DEVICE:
if device_id in device_list: # 对相同设备进行删除
c.execute('''delete from login where login_device=:a and user_id=:b''', {
'a': device_id, 'b': user_id})
should_delete_num = len(
device_list) + 1 - device_list.count(device_id) - Config.LOGIN_DEVICE_NUMBER_LIMIT
if should_delete_num >= 1: # 删掉多余token
if not Config.ALLOW_LOGIN_SAME_DEVICE and Config.ALLOW_BAN_MULTIDEVICE_USER_AUTO: # 自动封号检查
c.execute(
'''select count(*) from login where user_id=? and login_time>?''', (user_id, now-86400000))
if c.fetchone()[0] >= Config.LOGIN_DEVICE_NUMBER_LIMIT:
remaining_ts = arc_auto_ban(c, user_id, now)
return None, None, 105, {'remaining_ts': remaining_ts}
c.execute('''delete from login where rowid in (select rowid from login where user_id=:user_id limit :a);''',
{'user_id': user_id, 'a': int(should_delete_num)})
c.execute('''insert into login values(:access_token, :user_id, :time, :ip, :device_id)''', {
'user_id': user_id, 'access_token': token, 'device_id': device_id, 'time': now, 'ip': ip})
error_code = None
else:
# 密码错误
error_code = 104
id_pwd = headers['Authorization']
id_pwd = base64.b64decode(id_pwd[6:]).decode()
name, password = id_pwd.split(':', 1)
if 'DeviceId' in headers:
device_id = headers['DeviceId']
else:
# 用户名错误
error_code = 104
device_id = 'low_version'
return token, user_id, error_code, None
user = UserLogin(c)
user.login(name, password, device_id, request.remote_addr)
current_app.logger.info(f'User `{user.user_id}` log in')
return jsonify({"success": True, "token_type": "Bearer", 'user_id': user.user_id, 'access_token': user.token})
def arc_register(name: str, password: str, device_id: str, email: str, ip: str): # 注册
# 账号注册记录hash密码、用户名和邮箱生成user_id和user_code自动登录返回token
# token和密码的处理同登录部分
def build_user_code(c):
# 生成9位的user_code用的自然是随机
import random
flag = True
while flag:
user_code = ''.join([str(random.randint(0, 9)) for i in range(9)])
c.execute('''select exists(select * from user where user_code = :user_code)''',
{'user_code': user_code})
if c.fetchone() == (0,):
flag = False
return user_code
def build_user_id(c):
# 生成user_id往后加1
c.execute('''select max(user_id) from user''')
x = c.fetchone()
if x[0] is not None:
return x[0] + 1
else:
return 2000001
def insert_user_char(c, user_id):
# 为用户添加初始角色
c.execute('''insert into user_char values(?,?,?,?,?,?)''',
(user_id, 0, 1, 0, 0, 0))
c.execute('''insert into user_char values(?,?,?,?,?,?)''',
(user_id, 1, 1, 0, 0, 0))
c.execute('''select character_id, max_level, is_uncapped from character''')
x = c.fetchall()
if x:
for i in x:
exp = 25000 if i[1] == 30 else 10000
c.execute('''insert into user_char_full values(?,?,?,?,?,?)''',
(user_id, i[0], i[1], exp, i[2], 0))
user_id = None
token = None
error_code = 108
with Connect() as c:
hash_pwd = hashlib.sha256(password.encode("utf8")).hexdigest()
c.execute(
'''select exists(select * from user where name = :name)''', {'name': name})
if c.fetchone() == (0,):
c.execute(
'''select exists(select * from user where email = :email)''', {'email': email})
if c.fetchone() == (0,):
user_code = build_user_code(c)
user_id = build_user_id(c)
now = int(time.time() * 1000)
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': user_code, 'user_id': user_id, 'join_date': now, 'name': name, 'password': hash_pwd, 'memories': Config.DEFAULT_MEMORIES, 'email': email})
c.execute('''insert into recent30(user_id) values(:user_id)''', {
'user_id': user_id})
token = hashlib.sha256(
(str(user_id) + str(now)).encode("utf8")).hexdigest()
c.execute('''insert into login values(:access_token, :user_id, :time, :ip, :device_id)''', {
'user_id': user_id, 'access_token': token, 'device_id': device_id, 'time': now, 'ip': ip})
insert_user_char(c, user_id)
error_code = 0
else:
error_code = 102
else:
error_code = 101
return user_id, token, error_code
def token_get_id(token: str):
# 用token获取id没有考虑不同用户token相同情况说不定会有bug
user_id = None
with Connect() as c:
c.execute('''select user_id from login where access_token = :token''', {
'token': token})
x = c.fetchone()
if x is not None:
user_id = x[0]
return user_id
def code_get_id(user_code):
# 用user_code获取id
user_id = None
with Connect() as c:
c.execute('''select user_id from user where user_code = :a''',
{'a': user_code})
x = c.fetchone()
if x is not None:
user_id = x[0]
return user_id
def auth_required(request):
def auth_required(req):
# arcaea登录验证写成了修饰器
def decorator(view):
@functools.wraps(view)
@wraps(view)
def wrapped_view(*args, **kwargs):
user_id = None
headers = request.headers
headers = req.headers
if 'AppVersion' in headers: # 版本检查
if Config.ALLOW_APPVERSION:
if headers['AppVersion'] not in Config.ALLOW_APPVERSION:
return jsonify({"success": False, "error_code": 1203})
e = header_check(req)
if e is not None:
current_app.logger.warning(
f' - {e.error_code}|{e.api_error_code}: {e}')
return error_return(e)
if 'Authorization' in headers:
token = headers['Authorization']
token = token[7:]
user_id = token_get_id(token)
if user_id is not None:
return view(user_id, *args, **kwargs)
else:
return jsonify({"success": False, "error_code": 108})
with Connect() as c:
try:
user = UserAuth(c)
token = headers.get('Authorization')
if not token:
raise NoAccess('No token.', -4)
user.token = token[7:]
user_id = user.token_get_id()
g.user = user
except ArcError as e:
return error_return(e)
return view(user_id, *args, **kwargs)
return wrapped_view
return decorator
def arc_auto_ban(c, user_id, now):
# 多设备自动封号机制,返回封号时长
c.execute('''delete from login where user_id=?''', (user_id, ))
c.execute('''select ban_flag from user where user_id=?''', (user_id,))
x = c.fetchone()
if x and x[0] != '' and x[0] is not None:
last_ban_time = int(x[0].split(':', 1)[0])
i = 0
while i < len(BAN_TIME) - 1 and BAN_TIME[i] <= last_ban_time:
i += 1
ban_time = BAN_TIME[i]
else:
ban_time = BAN_TIME[0]
ban_flag = ':'.join((str(ban_time), str(now + ban_time*24*60*60*1000)))
c.execute('''update user set ban_flag=? where user_id=?''',
(ban_flag, user_id))
return ban_time*24*60*60*1000

View File

@@ -1,254 +0,0 @@
from setting import Config
from server.sql import Connect
from .config import Constant
import server.info
import server.item
import server.setme
def int2b(x):
# int与布尔值转换
if x is None or x == 0:
return False
else:
return True
def get_level_steps():
# 返回level_steps字典数组
return [{'level': i, 'level_exp': Constant.LEVEL_STEPS[i]} for i in Constant.LEVEL_STEPS]
def calc_char_value(level, value1, value20, value30):
# 计算搭档数值的核心函数,返回浮点数
def calc_char_value_20(level, stata, statb, lva=1, lvb=20):
# 计算1~20级搭档数值的核心函数返回浮点数来自https://redive.estertion.win/arcaea/calc/
n = [0, 0, 0.0005831753900000081, 0.004665403120000065, 0.015745735529959858, 0.03732322495992008, 0.07289692374980007, 0.12596588423968, 0.2000291587694801, 0.29858579967923987, 0.42513485930893946,
0.5748651406910605, 0.7014142003207574, 0.7999708412305152, 0.8740341157603029, 0.9271030762501818, 0.962676775040091, 0.9842542644700301, 0.9953345968799998, 0.9994168246100001, 1]
e = n[lva] - n[lvb]
a = stata - statb
r = a / e
d = stata - n[lva] * r
return d + r * n[level]
def calc_char_value_30(level, stata, statb, lva=20, lvb=30):
# 计算21~30级搭档数值返回浮点数
return (level - lva) * (statb - stata) / (lvb - lva) + stata
if level < 1 or level > 30:
return 0
elif 1 <= level <= 20:
return calc_char_value_20(level, value1, value20)
else:
return calc_char_value_30(level, value20, value30)
def get_char_core(c, character_id):
# 得到对应角色觉醒所需的核心,返回字典列表
r = []
c.execute(
'''select item_id, amount from char_item where character_id = ? and type="core"''', (character_id,))
x = c.fetchall()
if x:
for i in x:
r.append({'core_type': i[0], 'amount': i[1]})
return r
def get_user_characters(c, user_id):
# 获取用户所拥有角色,返回列表
c.execute('''select character_id from user_char where user_id = :user_id''',
{'user_id': user_id})
x = c.fetchall()
characters = []
if x:
for i in x:
characters.append(i[0])
return characters
def get_user_character(c, user_id):
# 得到用户拥有的角色列表,返回列表
if Config.CHARACTER_FULL_UNLOCK:
c.execute('''select * from user_char_full a,character b where a.user_id = :user_id and a.character_id=b.character_id''',
{'user_id': user_id})
else:
c.execute('''select * from user_char a,character b where a.user_id = :user_id and a.character_id=b.character_id''',
{'user_id': user_id})
x = c.fetchall()
if not x and not Config.CHARACTER_FULL_UNLOCK:
# 添加初始角色
c.execute('''insert into user_char values(?,?,?,?,?,?)''',
(user_id, 0, 1, 0, 0, 0))
c.execute('''insert into user_char values(?,?,?,?,?,?)''',
(user_id, 1, 1, 0, 0, 0))
c.execute('''select * from user_char a,character b where a.user_id = :user_id and a.character_id=b.character_id''',
{'user_id': user_id})
x = c.fetchall()
if not x:
return []
r = []
for i in x:
char = {
"is_uncapped_override": int2b(i[5]),
"is_uncapped": int2b(i[4]),
"uncap_cores": get_char_core(c, i[1]),
"char_type": i[22],
"skill_id_uncap": i[21],
"skill_requires_uncap": int2b(i[20]),
"skill_unlock_level": i[19],
"skill_id": i[18],
"overdrive": calc_char_value(i[2], i[11], i[14], i[17]),
"prog": calc_char_value(i[2], i[10], i[13], i[16]),
"frag": calc_char_value(i[2], i[9], i[12], i[15]),
"level_exp": Constant.LEVEL_STEPS[i[2]],
"exp": i[3],
"level": i[2],
"name": i[7],
"character_id": i[1]
}
if i[1] == 21:
char["voice"] = [0, 1, 2, 3, 100, 1000, 1001]
r.append(char)
return r
def get_one_character(c, user_id, character_id):
# 得到用户某个拥有的角色列表,返回字典
if Config.CHARACTER_FULL_UNLOCK:
c.execute('''select * from user_char_full a,character b where a.user_id = :user_id and a.character_id=b.character_id and a.character_id=:a''',
{'user_id': user_id, 'a': character_id})
else:
c.execute('''select * from user_char a,character b where a.user_id = :user_id and a.character_id=b.character_id and a.character_id=:a''',
{'user_id': user_id, 'a': character_id})
x = c.fetchone()
if not x:
return {}
r = {
"is_uncapped_override": int2b(x[5]),
"is_uncapped": int2b(x[4]),
"uncap_cores": get_char_core(c, x[1]),
"char_type": x[22],
"skill_id_uncap": x[21],
"skill_requires_uncap": int2b(x[20]),
"skill_unlock_level": x[19],
"skill_id": x[18],
"overdrive": calc_char_value(x[2], x[11], x[14], x[17]),
"prog": calc_char_value(x[2], x[10], x[13], x[16]),
"frag": calc_char_value(x[2], x[9], x[12], x[15]),
"level_exp": Constant.LEVEL_STEPS[x[2]],
"exp": x[3],
"level": x[2],
"name": x[7],
"character_id": x[1]
}
if x[1] == 21:
r["voice"] = [0, 1, 2, 3, 100, 1000, 1001]
return r
def calc_level_up(c, user_id, character_id, exp, exp_addition):
# 计算角色升级,返回当前经验和等级
exp += exp_addition
if exp >= Constant.LEVEL_STEPS[20]: # 未觉醒溢出
c.execute('''select is_uncapped from user_char where user_id=? and character_id=?''',
(user_id, character_id))
x = c.fetchone()
if x and x[0] == 0:
return Constant.LEVEL_STEPS[20], 20
a = []
b = []
for i in Constant.LEVEL_STEPS:
a.append(i)
b.append(Constant.LEVEL_STEPS[i])
if exp >= b[-1]: # 溢出
return b[-1], a[-1]
if exp < b[0]: # 向下溢出,是异常状态
return 0, 1
i = len(a) - 1
while exp < b[i]:
i -= 1
return exp, a[i]
def char_use_core(user_id, character_id, amount):
# 以太之滴升级返回user_idcore状态角色状态的字典
r = None
with Connect() as c:
c.execute(
'''select amount from user_item where user_id=? and item_id="core_generic" and type="core"''', (user_id,))
x = c.fetchone()
if x:
pre_amount = x[0]
else:
pre_amount = 0
if amount <= pre_amount:
c.execute(
'''select exp from user_char where user_id=? and character_id=?''', (user_id, character_id))
x = c.fetchone()
if x:
exp, level = calc_level_up(
c, user_id, character_id, x[0], amount*Config.CORE_EXP)
c.execute('''update user_char set level=?, exp=? where user_id=? and character_id=?''',
(level, exp, user_id, character_id))
server.item.claim_user_item(
c, user_id, 'core_generic', 'core', -amount)
r = {'character': [get_one_character(c, user_id, character_id)]}
r['cores'] = server.item.get_user_cores(c, user_id)
r['user_id'] = user_id
return r
def char_uncap(user_id, character_id):
# 角色觉醒返回user_idcore状态角色状态的字典
r = None
with Connect() as c:
c.execute('''select * from char_item where character_id=?''',
(character_id,))
x = c.fetchall()
if not x:
return None
success = True
for i in x:
c.execute(
'''select amount from user_item where user_id=? and item_id=? and type=?''', (user_id, i[1], i[2]))
y = c.fetchone()
if not y or i[3] > y[0]:
success = False
break
if success:
c.execute('''update user_char set is_uncapped=1, is_uncapped_override=0 where user_id=? and character_id=?''',
(user_id, character_id))
for i in x:
server.item.claim_user_item(c, user_id, i[1], i[2], -i[3])
r = {'character': [get_one_character(c, user_id, character_id)]}
r['cores'] = server.item.get_user_cores(c, user_id)
r['user_id'] = user_id
return r

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
from flask import Blueprint, request
from core.constant import Constant
from core.course import UserCourseList
from core.item import ItemCore
from core.sql import Connect
from core.user import UserOnline
from .auth import auth_required
from .func import arc_try, success_return
bp = Blueprint('course', __name__, url_prefix='/course')
@bp.route('/me', methods=['GET'])
@auth_required(request)
@arc_try
def course_me(user_id):
with Connect() as c:
user = UserOnline(c, user_id)
core = ItemCore(c)
core.item_id = 'core_course_skip_purchase'
core.select_user_item(user)
x = UserCourseList(c, user)
x.select_all()
return success_return({
'courses': x.to_dict_list(),
"stamina_cost": Constant.COURSE_STAMINA_COST,
"course_skip_purchase_ticket": core.amount
})

View File

@@ -0,0 +1,44 @@
from flask import Blueprint, request
from core.sql import Connect
from core.user import UserOnline, code_get_id
from .auth import auth_required
from .func import arc_try, success_return
bp = Blueprint('friend', __name__, url_prefix='/friend')
@bp.route('/me/add', methods=['POST']) # 加好友
@auth_required(request)
@arc_try
def add_friend(user_id):
with Connect() as c:
friend_code = request.form['friend_code']
friend_id = code_get_id(c, friend_code)
user = UserOnline(c, user_id)
user.add_friend(friend_id)
return success_return({
"user_id": user.user_id,
"updatedAt": "2020-09-07T07:32:12.740Z",
"createdAt": "2020-09-06T10:05:18.471Z",
"friends": user.friends
})
@bp.route('/me/delete', methods=['POST']) # 删好友
@auth_required(request)
@arc_try
def delete_friend(user_id):
with Connect() as c:
friend_id = int(request.form['friend_id'])
user = UserOnline(c, user_id)
user.delete_friend(friend_id)
return success_return({
"user_id": user.user_id,
"updatedAt": "2020-09-07T07:32:12.740Z",
"createdAt": "2020-09-06T10:05:18.471Z",
"friends": user.friends
})

View File

@@ -0,0 +1,119 @@
from functools import wraps
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, LowVersion, NoAccess
has_arc_hash = False
try:
from core.arc_crypto import ArcHashChecker # type: ignore
has_arc_hash = True
except ModuleNotFoundError:
pass
default_error = ArcError('Unknown Error', status=500)
def error_return(e: ArcError = default_error): # 错误返回
# -7 处理交易时发生了错误
# -5 所有的曲目都已经下载完毕
# -4 您的账号已在别处登录
# -3 无法连接至服务器
# 2 Arcaea服务器正在维护
# 5 请更新 Arcaea 到最新版本
# 9 新版本请等待几分钟
# 11 有游戏内容需要更新,即将返回主界面
# 100 无法在此ip地址下登录游戏
# 101 用户名占用
# 102 电子邮箱已注册
# 103 已有一个账号由此设备创建
# 104 用户名密码错误
# 105 24小时内登入两台设备
# 106 121 账户冻结
# 107 你没有足够的体力
# 113 活动已结束
# 114 该活动已结束,您的成绩不会提交
# 115 请输入有效的电子邮箱地址
# 120 封号警告
# 122 账户暂时冻结
# 123 账户被限制
# 124 你今天不能再使用这个IP地址创建新的账号
# 150 非常抱歉您已被限制使用此功能
# 151 目前无法使用此功能
# 160 账户未邮箱认证,请检查邮箱
# 161 账户认证过期,请重新注册
# 401 用户不存在
# 403 无法连接至服务器
# 501 502 -6 此物品目前无法获取
# 504 无效的序列码
# 505 此序列码已被使用
# 506 你已拥有了此物品
# 601 好友列表已满
# 602 此用户已是好友
# 604 你不能加自己为好友
# 903 下载量超过了限制请24小时后重试
# 905 请在再次使用此功能前等待24小时
# 910 重新请求验证邮件前需等待x分钟 extra: retry_at
# 1001 设备数量达到上限
# 1002 此设备已使用过此功能
# 1201 房间已满
# 1202 房间号码无效
# 1203 请将Arcaea更新至最新版本
# 1205 此房间目前无法加入
# 9801 下载歌曲时发生问题,请再试一次
# 9802 保存歌曲时发生问题,请检查设备空间容量
# 9803 下载已取消
# 9905 没有在云端发现任何数据
# 9907 更新数据时发生了问题
# 9908 服务器只支持最新的版本,请更新 Arcaea
# 其它 发生未知错误
r = {"success": False, "error_code": e.error_code}
if e.extra_data:
r['extra'] = e.extra_data
return jsonify(r), e.status
def success_return(value=None):
r = {"success": True}
if value is not None:
r['value'] = value
return jsonify(r)
def arc_try(view):
'''替代try/except记录`ArcError`为warning'''
@wraps(view)
def wrapped_view(*args, **kwargs):
try:
data = view(*args, **kwargs)
if data is None:
return error_return()
return data
except ArcError as e:
if Config.ALLOW_WARNING_LOG:
current_app.logger.warning(format_exc())
user = g.get("user", None)
current_app.logger.warning(
f'{user.user_id if user is not None else ""} - {e.error_code}|{e.api_error_code}: {e}')
return error_return(e)
return wrapped_view
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 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')
return None

View File

@@ -1,288 +0,0 @@
from server.sql import Connect
import server.arcworld
import server.arcpurchase
import server.arcdownload
import server.character
import server.item
import time
from setting import Config
from .config import Constant
def int2b(x):
# int与布尔值转换
if x is None or x == 0:
return False
else:
return True
def get_recent_score(c, user_id):
# 得到用户最近一次的成绩,返回列表
c.execute('''select * from user where user_id = :x''', {'x': user_id})
x = c.fetchone()
if x is not None:
if x[11] is not None:
c.execute('''select best_clear_type from best_score where user_id=:u and song_id=:s and difficulty=:d''', {
'u': user_id, 's': x[11], 'd': x[12]})
y = c.fetchone()
if y is not None:
best_clear_type = y[0]
else:
best_clear_type = x[21]
return [{
"rating": x[22],
"modifier": x[19],
"time_played": x[20],
"health": x[18],
"best_clear_type": best_clear_type,
"clear_type": x[21],
"miss_count": x[17],
"near_count": x[16],
"perfect_count": x[15],
"shiny_perfect_count": x[14],
"score": x[13],
"difficulty": x[12],
"song_id": x[11]
}]
return []
def get_user_friend(c, user_id):
# 得到用户的朋友列表,返回列表
c.execute('''select user_id_other from friend where user_id_me = :user_id''', {
'user_id': user_id})
x = c.fetchall()
s = []
if x != [] and x[0][0] is not None:
for i in x:
c.execute('''select exists(select * from friend where user_id_me = :x and user_id_other = :y)''',
{'x': i[0], 'y': user_id})
if c.fetchone() == (1,):
is_mutual = True
else:
is_mutual = False
c.execute('''select * from user where user_id = :x''', {'x': i[0]})
y = c.fetchone()
if y is not None:
character = y[6]
is_char_uncapped = int2b(y[8])
is_char_uncapped_override = int2b(y[9])
if y[23] != -1:
character = y[23]
if not Config.CHARACTER_FULL_UNLOCK:
c.execute('''select is_uncapped, is_uncapped_override from user_char where user_id=:a and character_id=:b''', {
'a': i[0], 'b': character})
else:
c.execute('''select is_uncapped, is_uncapped_override from user_char_full where user_id=:a and character_id=:b''', {
'a': i[0], 'b': character})
z = c.fetchone()
if z:
is_char_uncapped = int2b(z[0])
is_char_uncapped_override = int2b(z[1])
rating = y[5]
if int2b(y[10]):
rating = -1
s.append({
"is_mutual": is_mutual,
"is_char_uncapped_override": is_char_uncapped_override,
"is_char_uncapped": is_char_uncapped,
"is_skill_sealed": int2b(y[7]),
"rating": rating,
"join_date": int(y[3]),
"character": character,
"recent_score": get_recent_score(c, i[0]),
"name": y[1],
"user_id": i[0]
})
s.sort(key=lambda item: item["recent_score"][0]["time_played"] if len(
item["recent_score"]) > 0 else 0, reverse=True)
return s
def get_user_me(c, user_id):
# 构造user/me的数据返回字典
c.execute('''select * from user where user_id = :x''', {'x': user_id})
x = c.fetchone()
r = {}
if x is not None:
user_character = server.character.get_user_character(c, user_id)
# 下面没有使用get_user_characters函数是为了节省一次查询
characters = []
for i in user_character:
characters.append(i['character_id'])
character_id = x[6]
if character_id not in characters:
character_id = 0
c.execute(
'''update user set character_id=0 where user_id=?''', (user_id,))
favorite_character_id = x[23]
if favorite_character_id not in characters: # 这是考虑有可能favourite_character设置了用户未拥有的角色
favorite_character_id = -1
c.execute(
'''update user set favorite_character=-1 where user_id=?''', (user_id,))
# 计算世界排名
c.execute(
'''select world_rank_score from user where user_id=?''', (user_id,))
y = c.fetchone()
if y and y[0]:
c.execute(
'''select count(*) from user where world_rank_score > ?''', (y[0],))
y = c.fetchone()
if y and y[0] + 1 <= Config.WORLD_RANK_MAX:
world_rank = y[0] + 1
else:
world_rank = 0
else:
world_rank = 0
# 源点强化
prog_boost = 0
if x[27] and x[27] != 0:
prog_boost = 300
# 体力计算
next_fragstam_ts = -1
max_stamina_ts = 1586274871917
stamina = 12
if x[31]:
next_fragstam_ts = x[31]
if x[32]:
max_stamina_ts = x[32]
if x[33]:
stamina = x[33]
r = {"is_aprilfools": Config.IS_APRILFOOLS,
"curr_available_maps": server.arcworld.get_available_maps(),
"character_stats": user_character,
"friends": get_user_friend(c, user_id),
"settings": {
"favorite_character": favorite_character_id,
"is_hide_rating": int2b(x[10]),
"max_stamina_notification_enabled": int2b(x[24])
},
"user_id": user_id,
"name": x[1],
"user_code": x[4],
"display_name": x[1],
"ticket": x[26],
"character": character_id,
"is_locked_name_duplicate": False,
"is_skill_sealed": int2b(x[7]),
"current_map": x[25],
"prog_boost": prog_boost,
"next_fragstam_ts": next_fragstam_ts,
"max_stamina_ts": max_stamina_ts,
"stamina": server.arcworld.calc_stamina(max_stamina_ts, stamina),
"world_unlocks": server.item.get_user_items(c, user_id, 'world_unlock'),
"world_songs": server.item.get_user_items(c, user_id, 'world_song'),
"singles": server.item.get_user_items(c, user_id, 'single'),
"packs": server.item.get_user_items(c, user_id, 'pack'),
"characters": characters,
"cores": server.item.get_user_cores(c, user_id),
"recent_score": get_recent_score(c, user_id),
"max_friend": 50,
"rating": x[5],
"join_date": int(x[3]),
"global_rank": world_rank
}
return r
def get_user_me_c(user_id):
# user/me调用上边没开数据库这里开一下
with Connect() as c:
return get_user_me(c, user_id)
def get_purchase_pack(user_id):
# 返回曲包数据
with Connect() as c:
return server.arcpurchase.get_purchase(c, 'pack')
def get_game_info():
# 返回游戏基本信息
r = {
"max_stamina": Constant.MAX_STAMINA,
"stamina_recover_tick": Constant.STAMINA_RECOVER_TICK,
"core_exp": Constant.CORE_EXP,
"curr_ts": int(time.time()*1000),
"level_steps": server.character.get_level_steps(),
"world_ranking_enabled": True,
"is_byd_chapter_unlocked": True
}
return r
def get_user_present(user_id):
# 返回奖励信息
with Connect() as c:
return server.arcpurchase.get_user_present(c, user_id)
def arc_aggregate_small(user_id):
# 返回用户数据
r = {"success": False}
with Connect() as c:
r = {"success": True,
"value": [{
"id": 0,
"value": get_user_me(c, user_id)
}]}
return r
def arc_aggregate_big(user_id):
# 返回比较全的用户数据
r = {"success": False}
with Connect() as c:
# 防止数据库锁
id_2 = server.arcdownload.get_all_songs(user_id, url_flag=False)
id_5 = {
"current_map": server.arcworld.get_current_map(user_id),
"user_id": user_id,
"maps": server.arcworld.get_world_all(user_id)
}
r = {"success": True,
"value": [{
"id": 0,
"value": get_user_me(c, user_id)
}, {
"id": 1,
"value": server.arcpurchase.get_purchase(c, 'pack')
}, {
"id": 2,
"value": id_2
}, {
"id": 3,
"value": {
"max_stamina": Constant.MAX_STAMINA,
"stamina_recover_tick": Constant.STAMINA_RECOVER_TICK,
"core_exp": Constant.CORE_EXP,
"curr_ts": int(time.time()*1000),
"level_steps": server.character.get_level_steps(),
"world_ranking_enabled": True,
"is_byd_chapter_unlocked": True
}
}, {
"id": 4,
"value": server.arcpurchase.get_user_present(c, user_id)
}, {
"id": 5,
"value": id_5
}
]}
return r

View File

@@ -1,98 +0,0 @@
import os
from shutil import copy, copy2
from server.sql import Connect
from database.database_initialize import main, ARCAEA_SERVER_VERSION
from web.system import update_database
def try_rename(path, new_path):
# 尝试重命名文件,并尝试避免命名冲突,返回最终路径
final_path = new_path
if os.path.exists(new_path):
i = 1
while os.path.exists(new_path + str(i)):
i += 1
os.rename(path, new_path + str(i))
final_path = new_path + str(i)
else:
os.rename(path, new_path)
return final_path
def check_before_run(app):
# 运行前检查关键文件,返回布尔值,其实是因为有人经常忘了
f = True
if not os.path.exists('setting.py'):
app.logger.warning('File `setting.py` is missing.')
f = False
if not os.path.exists('database'):
app.logger.warning('Folder `database` is missing.')
f = False
if not os.path.exists('database/songs'):
app.logger.warning('Folder `database/songs` is missing.')
f = False
if not os.path.exists('database/arcaea_database.db'):
app.logger.warning('File `database/arcaea_database.db` is missing.')
f = False
try:
app.logger.info(
'Try to new the file `database/arcaea_database.db`.')
main('./database/')
app.logger.info(
'Success to new the file `database/arcaea_database.db`.')
f = True
except:
app.logger.warning(
'Fail to new the file `database/arcaea_database.db`.')
else:
with Connect() 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:
app.logger.warning(
'Maybe the file `database/arcaea_database.db` is an old version.')
try:
app.logger.info(
'Try to update the file `database/arcaea_database.db`.')
path = try_rename('database/arcaea_database.db',
'database/arcaea_database.db.bak')
try:
copy2(path, 'database/arcaea_database.db')
except:
copy(path, 'database/arcaea_database.db')
if os.path.isfile("database/old_arcaea_database.db"):
os.remove('database/old_arcaea_database.db')
try_rename('database/arcaea_database.db',
'database/old_arcaea_database.db')
main('./database/')
update_database()
app.logger.info(
'Success to update the file `database/arcaea_database.db`.')
except:
app.logger.warning(
'Fail to update the file `database/arcaea_database.db`.')
if not os.path.exists('database/arcsong.db'):
app.logger.warning('File `database/arcsong.db` is missing.')
f = False
return f

View File

@@ -1,106 +0,0 @@
from setting import Config
def get_user_items(c, user_id, item_type):
# 得到用户的物品,返回列表,不包含数量信息
if item_type == 'world_song' and Config.WORLD_SONG_FULL_UNLOCK or item_type == 'world_unlock' and Config.WORLD_SCENERY_FULL_UNLOCK:
c.execute('''select item_id from item where type=?''', (item_type,))
else:
c.execute('''select item_id from user_item where user_id = :user_id and type = :item_type''',
{'user_id': user_id, 'item_type': item_type})
x = c.fetchall()
if not x:
return []
re = []
for i in x:
re.append(i[0])
return re
def get_user_cores(c, user_id):
# 得到用户的core返回字典列表
r = []
c.execute(
'''select item_id, amount from user_item where user_id = ? and type="core"''', (user_id,))
x = c.fetchall()
if x:
for i in x:
if i[1]:
amount = i[1]
else:
amount = 0
r.append({'core_type': i[0], 'amount': amount})
return r
def claim_user_item(c, user_id, item_id, item_type, amount=1):
# 处理用户物品,包括添加和删除操作,返回成功与否布尔值
# 这个接口允许memory变成负数所以不能用来购买
try:
amount = int(amount)
except:
return False
if item_type not in ['memory', 'character']:
c.execute('''select is_available from item where item_id=? and type=?''',
(item_id, item_type))
x = c.fetchone()
if x:
if x[0] == 0:
return False
else:
return False
if item_type == 'core':
c.execute(
'''select amount from user_item where user_id=? and item_id=? and type="core"''', (user_id, item_id))
x = c.fetchone()
if x:
if x[0] + amount < 0: # 数量不足
return False
c.execute('''update user_item set amount=? where user_id=? and item_id=? and type="core"''',
(x[0]+amount, user_id, item_id))
else:
if amount < 0: # 添加数量错误
return False
c.execute('''insert into user_item values(?,?,"core",?)''',
(user_id, item_id, amount))
elif item_type == 'memory':
c.execute('''select ticket from user where user_id=?''', (user_id,))
x = c.fetchone()
if x is not None:
c.execute('''update user set ticket=? where user_id=?''',
(x[0]+amount, user_id))
else:
return False
elif item_type == 'character':
if not item_id.isdigit():
c.execute(
'''select character_id from character where name=?''', (item_id,))
x = c.fetchone()
if x:
character_id = x[0]
else:
return False
else:
character_id = int(item_id)
c.execute(
'''select exists(select * from user_char where user_id=? and character_id=?)''', (user_id, character_id))
if c.fetchone() == (0,):
c.execute('''insert into user_char values(?,?,1,0,0,0)''',
(user_id, character_id))
elif item_type in ['world_song', 'world_unlock', 'single', 'pack']:
c.execute('''select exists(select * from user_item where user_id=? and item_id=? and type=?)''',
(user_id, item_id, item_type))
if c.fetchone() == (0,):
c.execute('''insert into user_item values(:a,:b,:c,1)''',
{'a': user_id, 'b': item_id, 'c': item_type})
return True

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

@@ -0,0 +1,172 @@
from flask import Blueprint, request
from core.config_manager import Config
from core.error import ArcError
from core.linkplay import MatchStore, Player, RemoteMultiPlayer, Room
from core.notification import RoomInviteNotification
from core.sql import Connect
from .auth import auth_required
from .func import arc_try, success_return
bp = Blueprint('multiplayer', __name__, url_prefix='/multiplayer')
@bp.route('/me/room/create', methods=['POST']) # 创建房间
@auth_required(request)
@arc_try
def room_create(user_id):
if not Config.LINKPLAY_HOST:
raise ArcError('The link play server is unavailable.', 151, status=404)
with Connect() as c:
x = RemoteMultiPlayer()
user = Player(c, user_id)
user.get_song_unlock(request.json['clientSongMap'])
x.create_room(user)
r = x.to_dict()
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/room/join/<room_code>', methods=['POST']) # 加入房间
@auth_required(request)
@arc_try
def room_join(user_id, room_code):
if not Config.LINKPLAY_HOST:
raise ArcError('The link play server is unavailable.', 151, status=404)
with Connect() as c:
x = RemoteMultiPlayer()
user = Player(c, user_id)
user.get_song_unlock(request.json['clientSongMap'])
room = Room()
room.room_code = room_code
x.join_room(room, user)
r = x.to_dict()
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/update', methods=['POST']) # 更新房间
@auth_required(request)
@arc_try
def multiplayer_update(user_id):
if not Config.LINKPLAY_HOST:
raise ArcError('The link play server is unavailable.', 151, status=404)
with Connect() as c:
x = RemoteMultiPlayer()
user = Player(c, user_id)
user.token = int(request.json['token'])
x.update_room(user)
r = x.to_dict()
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/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

@@ -0,0 +1,205 @@
import json
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 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
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, get_single
from .score import song_score_friend
from .user import user_me
from .world import world_all
bp = Blueprint('others', __name__)
@bp.route('/game/info', methods=['GET']) # 系统信息
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
def download_song(user_id):
with Connect(in_memory=True) as c_m:
with Connect() as c:
x = DownloadList(c_m, UserOnline(c, user_id))
x.song_ids = request.args.getlist('sid')
x.url_flag = json.loads(request.args.get('url', 'true'))
if x.url_flag and x.is_limited:
raise RateLimit('You have reached the download limit.', 903)
x.add_songs()
return success_return(x.urls)
@bp.route('/finale/progress', methods=['GET'])
def finale_progress():
# 世界boss血条
return success_return({'percentage': 100000})
@bp.route('/finale/finale_start', methods=['POST'])
@auth_required(request)
@arc_try
def finale_start(user_id):
# testify开始对立再见
# 但是对立不再见
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'])
@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,
'/purchase/bundle/single': get_single
}
@bp.route('/compose/aggregate', methods=['GET']) # 集成式请求
def aggregate():
try:
# global request
finally_response = {'success': True, 'value': []}
# request_ = request
get_list = json.loads(request.args.get('calls'))
if len(get_list) > 10:
# 请求太多驳回
return error_return()
for i in get_list:
endpoint = i['endpoint']
request.args = ImmutableMultiDict(
{key: value[0] for key, value in parse_qs(urlparse(endpoint).query).items()})
resp_t = map_dict[urlparse(endpoint).path]()
if isinstance(resp_t, tuple):
# The response may be a tuple, if it is an error response
resp_t = resp_t[0]
if hasattr(resp_t, "response"):
resp_t = resp_t.response[0].decode().rstrip('\n')
resp = json.loads(resp_t)
if hasattr(resp, 'get') and resp.get('success') is False:
finally_response = {'success': False, 'error_code': resp.get(
'error_code'), 'id': i['id']}
if "extra" in resp:
finally_response['extra'] = resp['extra']
# 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_
return jsonify(finally_response)
except KeyError:
return error_return()

View File

@@ -0,0 +1,32 @@
from flask import Blueprint, request
from core.present import UserPresent, UserPresentList
from core.sql import Connect
from core.user import UserOnline
from .auth import auth_required
from .func import arc_try, success_return
bp = Blueprint('present', __name__, url_prefix='/present')
@bp.route('/me', methods=['GET']) # 用户奖励信息
@auth_required(request)
@arc_try
def present_info(user_id):
with Connect() as c:
x = UserPresentList(c, UserOnline(c, user_id))
x.select_user_presents()
return success_return(x.to_dict_list())
@bp.route('/me/claim/<present_id>', methods=['POST']) # 礼物确认
@auth_required(request)
@arc_try
def claim_present(user_id, present_id):
with Connect() as c:
x = UserPresent(c, UserOnline(c, user_id))
x.claim_user_present(present_id)
return success_return()

View File

@@ -0,0 +1,156 @@
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
from core.redeem import UserRedeem
from core.sql import Connect
from core.user import UserOnline
from .auth import auth_required
from .func import arc_try, success_return
bp = Blueprint('purchase', __name__, url_prefix='/purchase')
@bp.route('/bundle/pack', methods=['GET']) # 曲包信息
@auth_required(request)
@arc_try
def bundle_pack(user_id):
with Connect() as c:
x = PurchaseList(c, UserOnline(c, user_id)
).select_from_type('pack')
return success_return(x.to_dict_list())
@bp.route('/bundle/single', methods=['GET']) # 单曲购买信息获取
@auth_required(request)
@arc_try
def get_single(user_id):
with Connect() as c:
x = PurchaseList(c, UserOnline(c, user_id)
).select_from_type('single')
return success_return(x.to_dict_list())
@bp.route('/bundle/bundle', methods=['GET']) # 捆绑包
def bundle_bundle():
return success_return([])
# 感觉上是定死的,没意义啊
# return success_return([{
# "name": "chronicles",
# "items": [{
# "type": "bundle",
# "id": "core"
# }, {
# "type": "bundle",
# "id": "prelude"
# }, {
# "type": "bundle",
# "id": "rei"
# }, {
# "type": "bundle",
# "id": "yugamu"
# }, {
# "type": "bundle",
# "id": "vs"
# }],
# "orig_price": 2000,
# "price": 2000,
# "discount_from": 1657152000000,
# "discount_to": 1758447999000,
# "discount_reason": "chronicle"
# }])
@bp.route('/me/pack', methods=['POST']) # 曲包和单曲购买
@auth_required(request)
@arc_try
def buy_pack_or_single(user_id):
with Connect() as c:
if 'pack_id' in request.form:
purchase_name = request.form['pack_id']
elif 'single_id' in request.form:
purchase_name = request.form['single_id']
else:
return success_return()
x = Purchase(c, UserOnline(c, user_id)).select(purchase_name)
x.buy()
return success_return({
'user_id': x.user.user_id,
'ticket': x.user.ticket,
'packs': x.user.packs,
'singles': x.user.singles,
'characters': x.user.characters_list
})
@bp.route('/me/item', methods=['POST']) # 特殊购买world模式boost和stamina
@auth_required(request)
@arc_try
def buy_special(user_id):
with Connect() as c:
if 'item_id' not in request.form:
raise PostError('`item_id` is required')
item_id = request.form['item_id']
x = Purchase(c, UserOnline(c, user_id))
x.purchase_name = item_id
x.price = 50
x.orig_price = 50
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}
if item_id == 'stamina6':
r['stamina'] = x.user.stamina.stamina
r['max_stamina_ts'] = x.user.stamina.max_stamina_ts
r['world_mode_locked_end_ts'] = -1
return success_return(r)
@bp.route('/me/stamina/<buy_stamina_type>', methods=['POST']) # 购买体力
@auth_required(request)
@arc_try
def purchase_stamina(user_id, buy_stamina_type):
with Connect() as c:
if buy_stamina_type != 'fragment':
raise InputError('Invalid type of buying stamina')
user = UserOnline(c, user_id)
user.select_user_one_column('next_fragstam_ts', -1)
now = int(time()*1000)
if user.next_fragstam_ts > now:
return ItemUnavailable('Buying stamina by fragment is not available yet.', 905)
user.update_user_one_column(
'next_fragstam_ts', now + Constant.FRAGSTAM_RECOVER_TICK)
s = Stamina6(c)
s.user_claim_item(user)
return success_return({
"user_id": user.user_id,
"stamina": user.stamina.stamina,
"max_stamina_ts": user.stamina.max_stamina_ts,
"next_fragstam_ts": user.next_fragstam_ts,
'world_mode_locked_end_ts': -1
})
@bp.route('/me/redeem', methods=['POST']) # 兑换码
@auth_required(request)
@arc_try
def redeem(user_id):
with Connect() as c:
x = UserRedeem(c, UserOnline(c, user_id))
x.claim_user_redeem(request.form['code'])
return success_return({"coupon": "fragment" + str(x.fragment) if x.fragment > 0 else ""})

View File

@@ -0,0 +1,180 @@
from random import randint
from time import time
from flask import Blueprint, request
from core.constant import Constant
from core.course import CoursePlay
from core.error import InputError
from core.rank import RankList
from core.score import UserPlay
from core.sql import Connect
from core.user import UserOnline
from .auth import auth_required
from .func import arc_try, success_return
bp = Blueprint('score', __name__, url_prefix='/score')
@bp.route('/token', methods=['GET']) # 成绩上传所需的token显然我不想验证
def score_token():
return success_return({'token': '1145141919810'})
@bp.route('/token/world', methods=['GET']) # 世界模式成绩上传所需的token
@auth_required(request)
@arc_try
def score_token_world(user_id):
d = request.args.get
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':
# 处理 ivy 技能或者 vanessa 技能
# 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(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, 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_flag and skill_id:
r['play_parameters'] = {
skill_id: list(
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)
@bp.route('/token/course', methods=['GET']) # 课题模式成绩上传所需的token
@auth_required(request)
@arc_try
def score_token_course(user_id):
with Connect() as c:
use_course_skip_purchase = request.args.get(
'use_course_skip_purchase', 'false') == 'true'
user = UserOnline(c, user_id)
user_play = UserPlay(c, user)
user_play.song_token = request.args.get('previous_token', None)
user_play.get_play_state()
status = 'created'
if user_play.course_play_state == -1:
# 没有token课题模式刚开始
course_play = CoursePlay(c, user, user_play)
course_play.course_id = request.args['course_id']
user_play.course_play = course_play
user_play.set_play_state_for_course(
use_course_skip_purchase)
elif 0 <= user_play.course_play_state <= 3:
# 验证token
user_play.update_token_for_course()
else:
# 课题模式已经结束
user_play.clear_play_state()
user.select_user_about_stamina()
status = 'cleared' if user_play.course_play_state == 4 else 'failed'
return success_return({
"stamina": user.stamina.stamina,
"max_stamina_ts": user.stamina.max_stamina_ts,
"token": user_play.song_token,
'status': status
})
@bp.route('/song', methods=['POST']) # 成绩上传
@auth_required(request)
@arc_try
def song_score_post(user_id):
with Connect() as c:
x = UserPlay(c, UserOnline(c, user_id))
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()
return success_return(x.to_dict())
@bp.route('/song', methods=['GET']) # TOP20
@auth_required(request)
@arc_try
def song_score_top(user_id):
with Connect() as c:
rank_list = RankList(c)
rank_list.song.set_chart(request.args.get(
'song_id'), request.args.get('difficulty'))
rank_list.select_top()
return success_return(rank_list.to_dict_list())
@bp.route('/song/me', methods=['GET']) # 我的排名默认最多20
@auth_required(request)
@arc_try
def song_score_me(user_id):
with Connect() as c:
rank_list = RankList(c)
rank_list.song.set_chart(request.args.get(
'song_id'), request.args.get('difficulty'))
rank_list.select_me(UserOnline(c, user_id))
return success_return(rank_list.to_dict_list())
@bp.route('/song/friend', methods=['GET']) # 好友排名默认最多50
@auth_required(request)
@arc_try
def song_score_friend(user_id):
with Connect() as c:
rank_list = RankList(c)
rank_list.song.set_chart(request.args.get(
'song_id'), request.args.get('difficulty'))
rank_list.select_friend(UserOnline(c, user_id))
return success_return(rank_list.to_dict_list())

View File

@@ -1,160 +0,0 @@
from server.sql import Connect
from setting import Config
from .config import Constant
import server.info
import server.character
def b2int(x):
# int与布尔值转换
if x:
return 1
else:
return 0
def int2b(x):
# int与布尔值转换
if x is None or x == 0:
return False
else:
return True
def change_char(user_id, character_id, skill_sealed):
# 角色改变,包括技能封印的改变,返回成功与否的布尔值
re = False
with Connect() as c:
if Config.CHARACTER_FULL_UNLOCK:
c.execute('''select is_uncapped, is_uncapped_override from user_char_full where user_id = :a and character_id = :b''',
{'a': user_id, 'b': character_id})
else:
c.execute('''select is_uncapped, is_uncapped_override from user_char where user_id = :a and character_id = :b''',
{'a': user_id, 'b': character_id})
x = c.fetchone()
if x:
is_uncapped = x[0]
is_uncapped_override = x[1]
else:
return False
if skill_sealed == 'false':
skill_sealed = False
else:
skill_sealed = True
c.execute('''update user set is_skill_sealed = :a, character_id = :b, is_char_uncapped = :c, is_char_uncapped_override = :d where user_id = :e''', {
'a': b2int(skill_sealed), 'b': character_id, 'c': is_uncapped, 'd': is_uncapped_override, 'e': user_id})
re = True
return re
def change_char_uncap(user_id, character_id):
# 角色觉醒改变,返回字典
r = None
with Connect() as c:
if not Config.CHARACTER_FULL_UNLOCK:
c.execute('''select is_uncapped, is_uncapped_override from user_char where user_id = :a and character_id = :b''',
{'a': user_id, 'b': character_id})
else:
c.execute('''select is_uncapped, is_uncapped_override from user_char_full where user_id = :a and character_id = :b''',
{'a': user_id, 'b': character_id})
x = c.fetchone()
if x is not None and x[0] == 1:
c.execute('''update user set is_char_uncapped_override = :a where user_id = :b''', {
'a': b2int(x[1] == 0), 'b': user_id})
if not Config.CHARACTER_FULL_UNLOCK:
c.execute('''update user_char set is_uncapped_override = :a where user_id = :b and character_id = :c''', {
'a': b2int(x[1] == 0), 'b': user_id, 'c': character_id})
c.execute(
'''select * from user_char a,character b where a.user_id=? and a.character_id=b.character_id and a.character_id=?''', (user_id, character_id))
else:
c.execute('''update user_char_full set is_uncapped_override = :a where user_id = :b and character_id = :c''', {
'a': b2int(x[1] == 0), 'b': user_id, 'c': character_id})
c.execute(
'''select * from user_char_full a,character b where a.user_id=? and a.character_id=b.character_id and a.character_id=?''', (user_id, character_id))
y = c.fetchone()
if y is not None:
r = {
"is_uncapped_override": int2b(y[5]),
"is_uncapped": int2b(y[4]),
"uncap_cores": server.character.get_char_core(c, y[1]),
"char_type": y[22],
"skill_id_uncap": y[21],
"skill_requires_uncap": int2b(y[20]),
"skill_unlock_level": y[19],
"skill_id": y[18],
"overdrive": server.character.calc_char_value(y[2], y[11], y[14], y[17]),
"prog": server.character.calc_char_value(y[2], y[10], y[13], y[16]),
"frag": server.character.calc_char_value(y[2], y[9], y[12], y[15]),
"level_exp": Constant.LEVEL_STEPS[y[2]],
"exp": y[3],
"level": y[2],
"name": y[7],
"character_id": y[1]
}
return r
def arc_sys_set(user_id, value, set_arg):
# 三个设置PTT隐藏、体力满通知、最爱角色无返回
with Connect() as c:
if 'favorite_character' in set_arg:
value = int(value)
c.execute('''update user set favorite_character = :a where user_id = :b''', {
'a': value, 'b': user_id})
else:
if value == 'false':
value = False
else:
value = True
if 'is_hide_rating' in set_arg:
c.execute('''update user set is_hide_rating = :a where user_id = :b''', {
'a': b2int(value), 'b': user_id})
if 'max_stamina_notification_enabled' in set_arg:
c.execute('''update user set max_stamina_notification_enabled = :a where user_id = :b''', {
'a': b2int(value), 'b': user_id})
return None
def arc_add_friend(user_id, friend_id):
# 加好友返回好友列表或者是错误码602、604
if user_id == friend_id:
return 604
r = None
with Connect() as c:
c.execute('''select exists(select * from friend where user_id_me = :x and user_id_other = :y)''',
{'x': user_id, 'y': friend_id})
if c.fetchone() == (0,):
c.execute('''insert into friend values(:a, :b)''',
{'a': user_id, 'b': friend_id})
r = server.info.get_user_friend(c, user_id)
else:
r = 602
return r
def arc_delete_friend(user_id, friend_id):
# 删好友,返回好友列表
r = None
with Connect() as c:
c.execute('''select exists(select * from friend where user_id_me = :x and user_id_other = :y)''',
{'x': user_id, 'y': friend_id})
if c.fetchone() == (1,):
c.execute('''delete from friend where user_id_me = :x and user_id_other = :y''',
{'x': user_id, 'y': friend_id})
r = server.info.get_user_friend(c, user_id)
return r

View File

@@ -1,83 +0,0 @@
import sqlite3
from flask import current_app
import traceback
class Connect():
# 数据库连接类,上下文管理
def __init__(self, file_path='./database/arcaea_database.db'):
"""
数据库连接默认连接arcaea_database.db
接受:文件路径
返回sqlite3连接操作对象
"""
self.file_path = file_path
def __enter__(self):
self.conn = sqlite3.connect(self.file_path)
self.c = self.conn.cursor()
return self.c
def __exit__(self, exc_type, exc_val, exc_tb):
if self.conn:
self.conn.commit()
self.conn.close()
if exc_type is not None:
current_app.logger.error(
traceback.format_exception(exc_type, exc_val, exc_tb))
return True
class Sql():
@staticmethod
def select(c, table_name, target_column=[], query=None):
# 执行查询单句sql语句返回fetchall数据
# 使用准确查询,且在单表内
sql = 'select '
sql_dict = {}
if len(target_column) >= 2:
sql += target_column[0]
for i in range(1, len(target_column)):
sql += ',' + target_column[i]
sql += ' from ' + table_name
elif len(target_column) == 1:
sql += target_column[0] + ' from ' + table_name
else:
sql += '* from ' + table_name
where_field = []
where_value = []
if query:
for i in query.query:
where_field.append(i)
where_value.append(query.query[i])
if where_field and where_value:
sql += ' where '
sql += where_field[0] + '=:' + where_field[0]
sql_dict[where_field[0]] = where_value[0]
if len(where_field) >= 2:
for i in range(1, len(where_field)):
sql_dict[where_field[i]] = where_value[i]
sql += ' and ' + where_field[i] + '=:' + where_field[i]
if query and query.sort:
sql += ' order by ' + \
query.sort[0]['column'] + ' ' + query.sort[0]['order']
for i in range(1, len(query.sort)):
sql += ', ' + query.sort[i]['column'] + \
' ' + query.sort[i]['order']
if query and query.limit >= 0:
sql += ' limit :limit offset :offset'
sql_dict['limit'] = query.limit
sql_dict['offset'] = query.offset
c.execute(sql, sql_dict)
return c.fetchall()

View File

@@ -0,0 +1,202 @@
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
from .auth import auth_required
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)
if error is not None:
raise error
with Connect() as c:
new_user = UserRegister(c)
new_user.set_name(request.form['name'])
new_user.set_password(request.form['password'])
new_user.set_email(request.form['email'])
if 'device_id' in request.form:
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'
ip = request.remote_addr
new_user.register(device_id, ip)
# 注册后自动登录
user = UserLogin(c)
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})
@bp.route('/me', methods=['GET']) # 用户信息
@auth_required(request)
@arc_try
def user_me(user_id):
with Connect() as c:
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
def character_change(user_id):
with Connect() as c:
user = UserOnline(c, user_id)
user.change_character(
int(request.form['character']), request.form['skill_sealed'] == 'true')
return success_return({'user_id': user.user_id, 'character': user.character.character_id})
# 角色觉醒切换
@bp.route('/me/character/<int:character_id>/toggle_uncap', methods=['POST'])
@auth_required(request)
@arc_try
def toggle_uncap(user_id, character_id):
with Connect() as c:
user = User()
user.user_id = user_id
character = UserCharacter(c, character_id)
character.change_uncap_override(user)
character.select_character_info(user)
return success_return({'user_id': user.user_id, 'character': [character.to_dict()]})
# 角色觉醒
@bp.route('/me/character/<int:character_id>/uncap', methods=['POST'])
@auth_required(request)
@arc_try
def character_first_uncap(user_id, character_id):
with Connect() as c:
user = UserOnline(c, user_id)
character = UserCharacter(c, character_id)
character.select_character_info(user)
character.character_uncap(user)
return success_return({'user_id': user.user_id, 'character': [character.to_dict()], 'cores': user.cores})
# 角色使用以太之滴
@bp.route('/me/character/<int:character_id>/exp', methods=['POST'])
@auth_required(request)
@arc_try
def character_exp(user_id, character_id):
with Connect() as c:
user = UserOnline(c, user_id)
character = UserCharacter(c, character_id)
character.select_character_info(user)
core = ItemCore(c)
core.amount = - int(request.form['amount'])
core.item_id = 'core_generic'
character.upgrade_by_core(user, core)
return success_return({'user_id': user.user_id, 'character': [character.to_dict()], 'cores': user.cores})
@bp.route('/me/save', methods=['GET']) # 从云端同步
@auth_required(request)
@arc_try
def cloud_get(user_id):
with Connect() as c:
user = User()
user.user_id = user_id
save = SaveData(c)
save.select_all(user)
return success_return(save.to_dict())
@bp.route('/me/save', methods=['POST']) # 向云端同步
@auth_required(request)
@arc_try
def cloud_post(user_id):
with Connect() as c:
user = User()
user.user_id = user_id
save = SaveData(c)
save.set_value(
'scores_data', request.form['scores_data'], request.form['scores_checksum'])
save.set_value(
'clearlamps_data', request.form['clearlamps_data'], request.form['clearlamps_checksum'])
save.set_value(
'clearedsongs_data', request.form['clearedsongs_data'], request.form['clearedsongs_checksum'])
save.set_value(
'unlocklist_data', request.form['unlocklist_data'], request.form['unlocklist_checksum'])
save.set_value(
'installid_data', request.form['installid_data'], request.form['installid_checksum'])
save.set_value('devicemodelname_data',
request.form['devicemodelname_data'], request.form['devicemodelname_checksum'])
save.set_value(
'story_data', request.form['story_data'], request.form['story_checksum'])
save.set_value(
'finalestate_data', request.form.get('finalestate_data'), request.form.get('finalestate_checksum'))
save.update_all(user)
return success_return({'user_id': user.user_id})
@bp.route('/me/setting/<set_arg>', methods=['POST']) # 三个设置
@auth_required(request)
@arc_try
def sys_set(user_id, set_arg):
with Connect() as c:
value = request.form['value']
user = UserOnline(c, user_id)
if 'favorite_character' == set_arg:
user.change_favorite_character(int(value))
else:
value = 'true' == value
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):
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

@@ -0,0 +1,48 @@
from flask import Blueprint, request
from core.sql import Connect
from core.user import UserOnline
from core.world import MapParser, UserMap
from .auth import auth_required
from .func import arc_try, success_return
bp = Blueprint('world', __name__, url_prefix='/world')
@bp.route('/map/me', methods=['GET']) # 获得世界模式信息,所有地图
@auth_required(request)
@arc_try
def world_all(user_id):
with Connect() as c:
user = UserOnline(c, user_id)
user.select_user_about_current_map()
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 MapParser.get_world_all(c, user)]
})
@bp.route('/map/me', methods=['POST']) # 进入地图
@auth_required(request)
@arc_try
def world_in(user_id):
with Connect() as c:
arcmap = UserMap(c, request.form['map_id'], UserOnline(c, user_id))
if arcmap.unlock():
return success_return(arcmap.to_dict())
@bp.route('/map/me/<map_id>', methods=['GET']) # 获得单个地图完整信息
@auth_required(request)
@arc_try
def world_one(user_id, map_id):
with Connect() as c:
arcmap = UserMap(c, map_id, UserOnline(c, user_id))
arcmap.change_user_current_map()
return success_return({
"user_id": user_id,
"current_map": map_id,
"maps": [arcmap.to_dict(has_map_info=True, has_steps=True)]
})

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