526 Commits

Author SHA1 Message Date
Azalea
aeafa6a396 [M] Rename to avoid migration conflict 2025-12-14 05:27:17 +09:00
Paiton Bertschy
448426a96d chore: remove a300 events (dummy events) 2025-12-14 05:27:17 +09:00
thewiilover
dfa6176689 chore: ad chusan xverse a071 to a300 event ids 2025-12-14 05:27:17 +09:00
Menci
d996fba291 [+] Fedy for AquaGameOptions 2025-12-13 10:21:42 +09:00
Menci
0cb2a95ff3 [F] Fix inheritance in KClass<T>.vars() 2025-12-13 10:21:42 +09:00
Menci
be5220fd51 [O] Use Iterable<T>.mapApply() 2025-12-13 10:21:42 +09:00
Menci
f23c0d6fe1 [RF] Re-organize game options 2025-12-13 10:21:42 +09:00
Menci
5aca650602 [F] CardTimestamp relationship definition 2025-12-10 14:03:51 +09:00
Menci
7c72348016 [+] Card Timestamp 2025-12-10 14:03:51 +09:00
radiumerst
5eee6505f9 Fix: KALEIDXSCOPE crashing on final phase
Add gates 7-10 to prevent crashing when entered

Set Gates 1-9 to LIFE999 BASIC
Set Gate 10 to LIFE999/999 BASIC
2025-12-10 14:03:27 +09:00
Azalea
43b7ea65a5 [-] Replace login blocking with an @ AquaDX prefix 2025-11-23 23:43:23 +08:00
Paiton Bertschy
85149dcd03 Update src/main/resources/db/80/V1000_61__ongeki_refresh_a017_to_a032.sql
Co-authored-by: 凌莞~(=^▽^=) <opensource@c5y.moe>
2025-11-19 13:33:23 +08:00
Paiton Bertschy
a767c8949c Update src/main/resources/db/80/V1000_61__ongeki_refresh_a017_to_a032.sql
Co-authored-by: 凌莞~(=^▽^=) <opensource@c5y.moe>
2025-11-19 13:33:23 +08:00
Paiton Bertschy
bbeb476a62 Update src/main/resources/db/80/V1000_61__ongeki_refresh_a017_to_a032.sql
Co-authored-by: 凌莞~(=^▽^=) <opensource@c5y.moe>
2025-11-19 13:33:23 +08:00
Paiton Bertschy
c444350cef Remove duplicate entries from SQL data
Removed duplicate entries for '7mai' and 'HiTECH NINJA' in the SQL file.
2025-11-19 13:33:23 +08:00
thewiilover
13a318d519 chroe: adds all the new content ids for ongeki 2025-11-19 13:33:23 +08:00
thewiilover
e744d96c96 chore: add ongeki ReFresh event ids from a017-a032 to new migration 2025-11-19 13:33:23 +08:00
Clansty
491044d37a [-] remove munet migration notice 2025-11-14 22:11:21 +08:00
Azalea
9d30cf1e7d [+] Pagination 2025-11-14 18:52:46 +08:00
凌莞~(=^▽^=)
d2608472d8 fix: total_point null (#185) 2025-10-25 03:46:05 +08:00
Menci
34aae0c87a [F] Player name validation (#186) 2025-10-21 03:46:43 +09:00
Clansty
69bd35a579 [O] Change tip 2025-10-11 04:05:56 +08:00
Menci
3e6c0b4159 feat: user management APIs (#184) 2025-10-07 13:21:01 -07:00
Menci
a33ec8b11c feat: crop pfp to at most 1024px (#183) 2025-10-07 13:20:49 -07:00
Azalea
dd03ca38a1 [F] Fix memory leak 2025-10-07 03:40:49 +08:00
Menci
1cac5e451a refactor: user registrar (#182) 2025-10-06 12:32:52 -07:00
Azalea
010d4592e4 hot fix 2025-10-07 00:30:48 +08:00
Menci
b0d0f8ef7d feat: some data management APIs (#176) 2025-10-06 09:27:39 -07:00
Raymond
967d311ee4 chusan x-v a001 (#180)
Co-authored-by: asterisk727 <59166650+asterisk727@users.noreply.github.com>
2025-10-04 10:59:02 -07:00
crxmsxn
d5b777d720 fix: mobile styling for favorites (#181) 2025-10-04 10:57:31 -07:00
Raymond
2ab2666ad0 Feat: Favorites (for all supported games) (#174) 2025-09-29 22:56:26 -07:00
alexay7
4971f2be78 [+] Add support for geki cm and fix chusan cm implementation (#175) 2025-09-29 22:47:31 -07:00
Menci
b0a49d6626 [+] Add APIs (#177) 2025-09-29 21:54:58 -07:00
凌莞~(=^▽^=)
d830854eaa fix: X-Verse Username (#179) 2025-09-29 22:35:56 -04:00
凌莞~(=^▽^=)
68820d5a86 chore: Bump versions 2025-09-13 21:49:46 +08:00
Raymond
8b079bc40b fix: nvm looks like shit 2025-09-12 11:34:25 -04:00
Raymond
b0dd9b845f chore: bump versions 2025-09-12 11:33:23 -04:00
Azalea
b3d0670e1d [F] FUCK JS 2025-09-11 09:51:14 +09:00
Clansty
e4734924f3 [O] Change Migrated display 2025-08-26 20:08:13 +08:00
Raymond
6ca419dd5b fix: un-revert prefectures :BocchiSobSmile: (#173)
Co-authored-by: alexay7 <43906716+alexay7@users.noreply.github.com>
2025-08-22 05:24:48 -07:00
Raymond
fc3f2171ee revert: prefectures (temporary) 2025-08-22 06:02:22 -04:00
alexay7
3d95a84739 feat: Add prefecture modification support (#170) 2025-08-21 16:19:25 -04:00
crxmsxn
15412911a9 fix: batch manual bugfixes (#168) 2025-08-14 12:32:55 -04:00
Raymond
9dc7a790cc Session Token Revitalization (#167) 2025-08-13 05:15:16 -04:00
Keeboy99
d0b67c37f6 fix: 🚑 fix ongeki re:fresh support by @Keeboy99 2025-08-11 23:51:49 -04:00
Clansty
f6aa7d1fe3 [+] register notice 2025-08-07 17:52:31 +08:00
Clansty
2dc53cfbd7 [+] Migrate button 2025-08-07 17:52:31 +08:00
Raymond
db43e18b16 fix: 🚑 increase expiration time
this is a temporary fix until i implement token revitalization where i might turn it back down to 7 days
2025-08-04 07:03:37 -04:00
1a54527428 [+] Gradle packageThin task for separated jar and libs 2025-08-03 08:22:09 -04:00
Azalea
73026911da [F] Properly fix v41 2025-08-01 15:32:05 +09:00
Azalea
86558cd07e [+] Longer log retention 2025-08-01 00:49:43 +09:00
Azalea
218d2788e8 [F] Fix migration collision 2025-08-01 00:22:21 +09:00
Azalea
0a37c2a854 Delete src/main/resources/db/40/V1000_41__add_aquanet_user_fedy.sql 2025-07-31 11:19:14 -04:00
asterisk727
7eda890473 feat: cn translation for password reset 2025-07-31 17:56:47 +09:00
Raymond
2431bd09af fix: clear code after used 2025-07-31 17:56:47 +09:00
Raymond
7b21a38e17 fix: typo in error 2025-07-31 17:56:47 +09:00
Raymond
bf51f48961 fix: clear sessions upon password reset 2025-07-31 17:56:47 +09:00
Raymond
92868201a3 fix: typos, sql, some strings were odd 2025-07-31 17:56:47 +09:00
asterisk727
c01c40fe45 fix: bug fixes to password reset (INCOMPLETE) 2025-07-31 17:56:47 +09:00
Raymond
39ed8af840 feat: swap auId in JWT for individual token
note: has not been tested to ensure there are no collisions, todo
2025-07-31 17:56:47 +09:00
asterisk727
82adf5c138 feature: password reset 2025-07-31 17:56:47 +09:00
凌莞~(=^▽^=)
e0d12acf61 [+] Chusan event A181 to A191 (#157) 2025-07-31 04:55:15 -04:00
Paiton Bertschy
955743aecd chore: add a172 event ids (#156)
Co-authored-by: 凌莞~(=^▽^=) <i@gao4.pw>
2025-07-31 04:54:44 -04:00
Raymond
4fb815a184 fix: correct filename 2025-07-26 23:51:00 -04:00
13ffe45dc6 [+] CardMaker maimai event (#159) 2025-07-25 03:29:54 -04:00
crxmsxn
5b699a2c3c feature: Batch-Manual export for CHUNITHM (#161) 2025-07-24 23:04:16 -04:00
Raymond
bd32677e9e fix: subtrophies on userbox not showing up correctly 2025-07-20 12:03:21 -04:00
Adelyn Flowers
a98db63bec Add missing chusan opts from bad migration (#155) 2025-07-09 02:56:39 -04:00
Clansty
2430b8c448 [+] Auto redirect when migrated 2025-07-06 16:29:36 +08:00
Menci
e3486042a5 [F] Data import fix (#153) 2025-07-04 12:45:09 -04:00
Menci
d79a4e5499 [+] Data support APIs (#151) 2025-07-04 00:01:32 -04:00
Menci
068b6179e5 fixup (#152) 2025-06-27 02:03:47 -04:00
Menci
3b90ac3c77 add stubs 2025-06-25 19:29:39 +09:00
Menci
42b8eabb3a revert 2025-06-25 19:29:39 +09:00
Menci
11dbe849cf add mai2 fields 2025-06-25 19:29:39 +09:00
Menci
ac6cbb9dd3 add mai2 fields 2025-06-25 19:29:39 +09:00
Menci
5c1f659437 export options 2025-06-25 19:29:39 +09:00
Clansty
155202dab9 chore: hide migrated cards in ranking 2025-06-23 09:41:49 +08:00
Raymond
71512bdad4 fix: typo 2025-06-18 11:43:53 -04:00
Raymond
88d4a3d298 style: move from "confirm" to "verify" by May's request 2025-06-18 13:52:31 +09:00
Raymond
2563a31d15 fix: 🎨 migrate from / to /confirm for email confirmation 2025-06-18 13:52:31 +09:00
Raymond
9c91d730b4 fix: remove unnecessary check 2025-06-01 15:09:29 -04:00
Teud
3aaeebae96 [+] Chusan Verse: Add events from A161 to A171 2025-06-01 02:59:39 +09:00
noarchwastaken
63a5f4441f [+] Chusan Verse: Add events from A152, A153, A161 2025-05-27 23:21:13 +09:00
Paiton Bertschy
47a171b1a4 Raymond was mad 2025-05-19 23:06:52 -05:00
Clansty
a3b3b3dd93 [F] Mai2 unable to register new user 2025-05-03 16:06:27 +08:00
Azalea
98f128ae07 Update src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-02 16:28:09 -04:00
Clansty
7fd20c3d9a [F] Should not display migrated user in ranking 2025-05-02 16:28:09 -04:00
Clansty
d7b45e4ce7 [+] clearMigrateFlag 2025-05-02 16:28:09 -04:00
Clansty
2e1eb2c879 [+] Login Alert for chusan, ongeki and wacca 2025-05-02 16:28:09 -04:00
Clansty
21142a53df [+] Login Alert for net and mai2 2025-05-02 16:28:09 -04:00
Azalea
1f847439a7 [F] Fix ghost card handling 2025-05-02 16:28:09 -04:00
Azalea
236266cd7a [F] Fix migration ID collision 2025-05-02 16:28:09 -04:00
Azalea
611f6dbffc [F] Fix updated usage 2025-05-02 16:28:09 -04:00
Azalea
1d4bb9b534 [+] Minato migration 2025-05-02 16:28:09 -04:00
Azalea
110a2144fa [F] Fix tablename 2025-05-01 21:02:57 -04:00
Azalea
c87889ba41 [+] Music popularity api 2025-05-01 20:54:27 -04:00
Raymond
88ea5c83b5 fix: Harmonization
i hope you're happy sourcery
2025-04-18 23:47:31 -04:00
Raymond
e962ac7ca7 fix: chinese trans 2025-04-18 23:47:31 -04:00
Raymond
7f4ee0784e fix: add options on website 2025-04-18 23:47:31 -04:00
Raymond
f222632dfb feat: symbol chat 2025-04-18 23:47:31 -04:00
Paiton Bertschy
8d65952e40 Verse plus isn't a thing (yet( 2025-04-18 14:53:34 -05:00
Azalea
6b99ab9e43 [+] Blacklist 2025-04-07 23:57:03 -04:00
Azalea
646136e6ea [+] Restore QQ invite 2025-04-02 14:45:38 -04:00
Raymond
3f2a337497 fix: egg not wokring 2025-04-02 12:25:21 -04:00
Azalea
2798e81f49 [-] Remove qq invite for now... 2025-04-02 12:16:56 -04:00
Azalea
d2983253bb [O] Communities 2025-04-02 12:10:58 -04:00
Raymond
fa0ebd20c3 [+] Chusan Userbox Egg 2025-04-02 12:03:19 -04:00
Raymond
dc8482b884 fix: mobile improvements 2025-04-02 12:03:19 -04:00
Raymond
64fe4b682a fuck me 2025-04-02 12:03:19 -04:00
Raymond
636bea080a fix: userbox fix 2025-04-02 12:03:19 -04:00
Raymond
24a6efa2c7 [+] Chusan Userbox Upgrade 3 2025-04-02 12:03:19 -04:00
Azalea
07e5d0e983 [U] Update readme 2025-03-31 12:18:24 -04:00
Azalea
f67879a847 [+] Chusan Verse+: Unlock event 2025-03-31 12:18:24 -04:00
Azalea
ce7f35bada [+] Chusan Verse+: New apis 2025-03-31 12:18:24 -04:00
Azalea
76145bc354 [U] Maimai Prism+: Update readme 2025-03-31 12:18:24 -04:00
Azalea
677b84b13c [+] Maimai Prism+: SQL for new ext field 2025-03-31 12:18:24 -04:00
Azalea
f336408951 [+] Maimai Prism+: New ext field 2025-03-31 12:18:24 -04:00
Azalea
1abd176616 [+] Maimai Prism+: New no-op api 2025-03-31 12:18:24 -04:00
Azalea
18d09f4184 [F] Fix ongeki user music detail 2025-03-29 13:37:58 -04:00
Azalea
756f274155 Ongeki Refactor (#134) 2025-03-29 13:21:09 -04:00
Azalea
25340075d5 [-] Remove temp file 2025-03-29 12:54:19 -04:00
Azalea
a9c0fe5ff8 [F] Ongeki: Field inconsistencies 2025-03-29 12:50:19 -04:00
Azalea
348b2e17f0 [F] Wacca and ongeki lastRomVersion fields 2025-03-29 11:53:34 -04:00
Raymond
a6837f4555 logout button (#137)
* feat: log out button

* fix: use i18n
2025-03-27 16:27:48 -04:00
Azalea
7e4b4991fd [M] Move file to the correct place 2025-03-27 11:08:22 -04:00
Azalea
f774d33966 Merge branch 'v1-dev' into ongeki 2025-03-27 11:04:18 -04:00
f61ca2d647 [+] Ongeki Re:Fresh A016 (#135) 2025-03-27 10:57:24 -04:00
Azalea
3c1d3013ce [-] Ongeki: Remove unused models 2025-03-27 05:22:35 -04:00
Azalea
685129fede [O] Optimize logging 2025-03-27 05:16:24 -04:00
Azalea
c32d334aab [M] Move sql 2025-03-27 05:01:42 -04:00
Azalea
577b758c99 [F] Ongeki: Fix path vars 2025-03-27 05:00:47 -04:00
Azalea
8b9797595a [O] Better logging 2025-03-27 04:58:43 -04:00
Azalea
0e6c55c56e [F] Ongeki: Fix key mismatch 2025-03-27 03:20:31 -04:00
Azalea
e65269ad29 [F] Ongeki: Fix unique constraints 2025-03-27 00:37:27 -04:00
Azalea
e00bbeadde [O] Ongeki: Upsert user all 2025-03-27 00:17:02 -04:00
Azalea
1193192e81 [O] Ongeki: Refactor other endpoints 2025-03-27 00:16:25 -04:00
Azalea
95286bae1c [O] Ongeki: Transform user list endpoints 2025-03-26 22:19:49 -04:00
Azalea
654cda736d [O] Ongeki: User unpaged apis 2025-03-26 21:45:13 -04:00
Azalea
a2b27090db [+] Ongeki: Solve Re:Fresh conflicts 2025-03-26 20:56:21 -04:00
Azalea
278b0205fc Merge branch 'v1-dev' into ongeki 2025-03-26 20:24:23 -04:00
Azalea
90f8cd8c65 [O] Ongeki: Static and game handlers 2025-03-26 20:23:24 -04:00
Azalea
05b8eda84a [U] Update readme 2025-03-26 20:00:45 -04:00
忍野ペンギン
2ea3a2a8e4 [+] Support for Ongeki Re:Fresh (#133)
* [+] Minimum working support for Ongeki Re:Fresh

* [+] Re:Fresh: Add user event map and missing fields

* [+] Re:Fresh: Extract user skin
2025-03-26 19:54:46 -04:00
Azalea
e58e84da35 [O] Ongeki: Response models 2025-03-26 19:23:30 -04:00
Azalea
8a35cf002f [F] Ongeki: AllArgsConstructor 2025-03-26 19:10:30 -04:00
Azalea
c98e73883b [O] Ongeki: Make game entities constructor consistent 2025-03-26 19:06:53 -04:00
Azalea
f3e83193d6 [O] Ongeki: Merge game entities 2025-03-26 19:02:45 -04:00
Azalea
c0604bc989 [M] Ongeki: Move model 2025-03-26 18:56:33 -04:00
Azalea
9af383af88 [O] Ongeki: Merge response pojo 2025-03-26 18:51:58 -04:00
Azalea
9c295f6012 [-] Ongeki: Remove unnecessary JsonProperty 2025-03-26 18:48:25 -04:00
Azalea
57d83439f3 [F] Ongeki: Fix constructor inconsistencies 2025-03-26 18:36:30 -04:00
Azalea
7320a982f6 [O] Ongeki: Generalize user entity 2025-03-26 18:25:25 -04:00
Azalea
42ffea41ab [F] Ongeki: Fix field names 2025-03-26 18:15:15 -04:00
Azalea
1c1350d84b [O] Ongeki: Transform user entities 2025-03-26 18:09:54 -04:00
Azalea
8be5dc20a9 [+] Ongeki: Serialization consistency test 2025-03-26 18:09:11 -04:00
Azalea
0429cb060c [+] Ongeki: All repos component 2025-03-26 17:49:22 -04:00
Azalea
13aabda72a [O] Ongeki: Rename repos 2025-03-26 17:45:52 -04:00
Azalea
73281d1316 [O] Ongeki: Generalize user repos 2025-03-26 17:40:36 -04:00
Azalea
fdfdf66fa3 [O] Ongeki: Remove unused repo functions 2025-03-26 17:33:46 -04:00
Azalea
d43a0dd862 [O] Ongeki: Merge repos file 2025-03-26 17:32:25 -04:00
Azalea
cbf1e2709a [F] Fix namespace 2025-03-26 17:29:46 -04:00
Azalea
fb75cd1add [O] Ongeki: Refactor User repos 2025-03-26 17:28:59 -04:00
Azalea
d34b34b5bd [F] Fix export 2025-03-26 17:20:50 -04:00
Azalea
1df5b4e8ba [-] Remove old aqua apis 2025-03-26 17:18:08 -04:00
Azalea
ff9ee24894 [O] Ongeki: Refactor Game repos 2025-03-26 17:16:34 -04:00
Azalea
b4b70f7efe [+] Expose sdk to window 2025-03-26 16:40:45 -04:00
Azalea
20ca84e5ab [+] Add country override field 2025-03-26 16:23:49 -04:00
Azalea
90b259b609 [+] Add endpoint to change rom version 2025-03-26 16:23:30 -04:00
Azalea
f463aea3ef [+] discord LLM prompt 2025-03-25 18:39:03 -04:00
Raymond
064f674b14 userbox import improvements (#132)
* fix: scan for option data correctly

* fix: i18n update for aquabox

i used google translate

* fix: remove log

* [F] fix chinese

---------

Co-authored-by: Azalea <22280294+hykilpikonna@users.noreply.github.com>
2025-03-25 12:46:18 -04:00
Azalea
d94a011413 [F] Fix ip 2025-03-23 18:26:53 -04:00
Raymond
8549a5caae docs: remove ARRR 2025-03-22 02:17:38 -04:00
Azalea
3ea63a5ccf [-] Omit stack trace for decompress failure 2025-03-21 21:04:40 -04:00
Azalea
23ddb2c6e1 [F] Fix ongeki rating 2025-03-21 21:01:19 -04:00
Azalea
c524950e35 [O] Longer timeout 2025-03-21 20:57:05 -04:00
Azalea
4434b6ca2a [-] Omit stack trace for session last use time error 2025-03-21 20:56:34 -04:00
Azalea
e29a0eff17 [F] Fix missless export 2025-03-21 20:39:47 -04:00
Azalea
bac33c66d9 [F] Fix merge conflict 2025-03-21 19:53:39 -04:00
Azalea
3c790134ee Merge branch 'broker' into v1-dev 2025-03-21 19:47:35 -04:00
Azalea
c1196042bf [F] Fix sql 2025-03-21 19:08:04 -04:00
Azalea
8649a74612 [F] 不太聪明喵 2025-03-21 18:47:34 -04:00
Kanon
7ab58c6495 Modify the Chu3 rating return accuracy (#127)
* Modify the Chu3 rating return accuracy

* [F] Fix aquabox-url doc
2025-03-21 18:35:41 -04:00
Azalea
7fb46441f4 AquaTrans and stuff (#131) 2025-03-21 18:35:01 -04:00
Azalea Gui
f72ee54ff4 Merge branch 'v1-dev' into verse 2025-03-21 18:32:37 -04:00
Azalea
8578f6e048 [+] i18n 2025-03-21 18:30:18 -04:00
Azalea
bfdcdc30d6 [F] Fix dns 2025-03-21 16:58:00 -04:00
Azalea
2def7a8861 Update src/main/java/icu/samnyan/aqua/net/transfer/AllNetClient.kt
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-03-21 16:53:35 -04:00
Azalea
166fd9e6b7 Update src/main/java/ext/Http.kt
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-03-21 16:52:54 -04:00
Azalea
8efb3d7554 Merge branch 'v1-dev' into broker 2025-03-21 16:48:58 -04:00
Azalea
02b4a70dd2 [+] HTTP timeout 2025-03-21 16:47:43 -04:00
Azalea
cb039df33e [+] Devnotes 2025-03-21 16:47:19 -04:00
Azalea
da648190db [F] Fix error display 2025-03-21 16:46:35 -04:00
Azalea
2b26304d92 [+] More events 2025-03-21 16:41:33 -04:00
Azalea
33f97fe21f [M] Move sql migrations 2025-03-21 16:41:26 -04:00
Azalea
5fe20906d9 [F] Fix artemis response reading 2025-03-21 07:59:31 -04:00
Azalea
2f319e661b [F] Fix artemis "Store ID cannot be 0!" 2025-03-20 11:07:47 -04:00
Azalea
35e7d796ab [F] Fix port with aimedb 2025-03-20 10:33:27 -04:00
Azalea
3d93cc300a [O] Read everything from aimedb 2025-03-20 10:33:13 -04:00
Azalea
e7085f7602 [F] Fix DFI response compat 2025-03-20 09:50:23 -04:00
Azalea
4f3a6cba45 [F] Fix regex 2025-03-20 07:10:17 -04:00
Azalea
05dea088df [O] Replace alert with confirm window 2025-03-20 06:29:59 -04:00
Azalea
edf5dd133b [O] Make confirm callback nullable 2025-03-20 06:28:28 -04:00
Azalea
917f8476b9 [F] Fix promise 2025-03-20 06:26:02 -04:00
Azalea
b2f10e31f7 [F] Fix confirm overlay bind 2025-03-20 06:24:41 -04:00
Azalea
22fffcc422 [O] Replace confirm alert with ui 2025-03-20 06:13:27 -04:00
Azalea
c93f47744b [+] Complete transfer logic 2025-03-20 06:06:35 -04:00
Azalea
c6af5b7d87 [O] Disable on tested 2025-03-20 06:05:46 -04:00
Azalea
182c3ba393 [+] selectJsonFile 2025-03-20 06:05:03 -04:00
Azalea
4e249601fe [M] Move download function to libs/ui 2025-03-20 06:04:35 -04:00
Azalea
2f966d4fa9 [+] Add expected error type 2025-03-20 06:04:03 -04:00
Azalea
2ef950ae26 [+] Streaming post in frontend 2025-03-19 04:59:18 -04:00
Azalea
219705d2f3 [F] Fix error messages 2025-03-19 04:58:42 -04:00
Azalea
aa6730b9da [F] Fix apis 2025-03-19 04:58:32 -04:00
Azalea
b519537b69 [O] Proper null handling for http 2025-03-19 04:58:22 -04:00
Azalea
1a0e70636a [O] Proper null handling for json 2025-03-19 04:57:44 -04:00
Azalea
da8337a681 [F] Fix wording 2025-03-19 04:57:10 -04:00
Azalea
ac8e2981a9 [+] SQLite sucks 2025-03-19 04:42:29 -04:00
Azalea
444595406b Update application.properties 2025-03-18 09:03:27 -04:00
Azalea
b31e450c4b Update docker-compose.yml 2025-03-18 09:02:54 -04:00
Azalea
77e94595d8 Fix default port binding 2025-03-17 19:49:18 -04:00
Azalea
7fd02976cb [F] Fix PowerOn header 2025-03-17 14:04:51 -04:00
Azalea
8d3613201e [F] Fix toString calling lazy implicitly 2025-03-17 14:04:44 -04:00
Azalea
7320f0ca7f [F] Fix types 2025-03-17 14:02:50 -04:00
MoeGrid
f57f2dd585 修复开启allnet.server.check-keychip时SDED检查游戏网络BAD的问题 2025-03-17 12:49:21 -04:00
MoeGrid
3ac6d79644 将config.ts中的部分配置移动到.env中,方便私有部署。
将public中的psd文件移出,防止被打包到dist。
判断TURNSTILE_SITE_KEY为空时不开启此功能。
2025-03-17 01:28:02 -04:00
Azalea
a076d50cb3 Update gradle.yml 2025-03-15 20:40:10 -04:00
Azalea
072e3519bb [+] Allow overriding PowerOn addr 2025-03-15 19:18:25 -04:00
Raymond
6fe9fbb6cc feat: add sub trophies for VERSE!!!!! 2025-03-15 02:58:22 -04:00
Azalea
f7e5cd1a05 [-] Remove unused sdk 2025-03-12 01:44:11 -04:00
Azalea
519f4fc74c [+] Types 2025-03-12 01:43:56 -04:00
Azalea
66e5395a60 [O] no 200 error :((((( 2025-03-12 01:36:25 -04:00
Azalea
e54619da46 [U] Update doc 2025-03-12 01:32:38 -04:00
Azalea
9b8a76349f [U] Update FAQ 2025-03-12 01:29:01 -04:00
Azalea
5bf017395c [+] Return the trophy sub 1 2 for userbox 2025-03-11 23:33:23 -04:00
Azalea
eb45075414 [+] Input validation 2025-03-11 18:32:00 -04:00
Azalea
c4e0717317 [+] Skeleton ui for transfer 2025-03-11 18:20:49 -04:00
Azalea
67d2e52fbc [+] Ongeki export 2025-03-11 16:51:46 -04:00
Azalea
d5b4e1ca14 [+] Push and pull apis 2025-03-11 10:22:28 -04:00
Azalea
72181e7ef7 [+] AimeDB register when not found 2025-03-11 09:53:22 -04:00
Azalea
d4d3a2b36c [O] Better encapsulation 2025-03-11 09:04:39 -04:00
Azalea
1dcaddb4c4 [+] New20 2025-03-11 08:15:19 -04:00
Azalea
0c891218b2 [+] new rating list 2025-03-11 08:03:10 -04:00
Azalea
8fd378852f [+] Mai2 broker 2025-03-11 05:11:10 -04:00
Azalea
aecb5572cd [O] Finish mai2 refactor 2025-03-11 03:45:54 -04:00
Azalea
6ffee3466f [U] Add comment 2025-03-11 02:49:35 -04:00
Azalea
9b7f50aebb [+] Maimai data broker 2025-03-11 02:48:56 -04:00
Azalea
5375c3c1fa [F] Fix json 2025-03-11 02:48:31 -04:00
Azalea
c2cd281efe [U] Automatic deploy docker every week 2025-03-11 01:51:22 -04:00
Azalea
6252cbbefe [+] Data broker api 2025-03-10 18:11:47 -04:00
Azalea
3b2199127b [+] Http extensions 2025-03-10 16:46:39 -04:00
Azalea
d903a2bc69 [F] Fix convert 2025-03-10 16:44:29 -04:00
Azalea
c53b3967cd [+] Chusan export tool 2025-03-10 16:41:33 -04:00
Azalea
b811432e7e [+] Different commands 2025-03-10 16:41:05 -04:00
Azalea
23c83fed8b [+] Application entry 2025-03-10 16:40:50 -04:00
Azalea
fe6d95786b [U] Upgrade kt 2025-03-10 16:40:31 -04:00
Azalea
c3963e7fe2 [+] AimeDB client 2025-03-10 12:57:23 -04:00
Azalea
9d8a3c5132 Update README.md 2025-03-10 02:41:30 -04:00
Azalea
af734b7814 [+] Send AllNet and obtain poweron url 2025-03-10 01:46:33 -04:00
Azalea
4be340c723 [+] HTTP extensions 2025-03-10 01:45:47 -04:00
Azalea
5816f5dffb [-] Verse: Give up on rec rating 2025-03-09 11:42:51 -04:00
Azalea
44f62e8f54 [F] Verse: Fix list empty case 2025-03-09 11:42:51 -04:00
Azalea
6bdfab6cba [F] Verse: FIx recommendation return format 2025-03-09 11:42:51 -04:00
Azalea
e1d33691b4 [+] Verse: Model inference progress bar 2025-03-09 11:42:51 -04:00
Azalea
47a508e8a9 [F] Verse: Fix id mismatch in recommend music 2025-03-09 11:42:51 -04:00
Azalea
d983d7a5f5 [+] Verse: Return recommended music 2025-03-09 11:42:51 -04:00
Azalea
226ba475aa [+] Verse: Generalize AI recommender 2025-03-09 11:42:51 -04:00
Azalea
8bbde9e7e3 [F] Verse: Fix userid null 2025-03-09 11:42:51 -04:00
Azalea
9ac4b56ef7 [+] Verse: Return user challenge 2025-03-09 11:42:51 -04:00
Azalea
cc5ffdf644 [+] Verse: Add user data fields 2025-03-09 11:42:51 -04:00
Azalea
6d02c53eb3 [+] Verse: Save unlock challenge 2025-03-09 11:42:51 -04:00
Azalea
6e6adb8caa [+] Verse: Save rating 2025-03-09 11:42:51 -04:00
Azalea
b0392cd3e6 [+] Unlock challenge? 2025-03-09 11:42:51 -04:00
Azalea
43a54be20e [-] Verse: Give up on rec rating 2025-03-09 11:37:14 -04:00
Azalea
dc7f8e990b [F] Verse: Fix list empty case 2025-03-09 11:36:37 -04:00
Azalea
ff3d6da461 [F] Verse: FIx recommendation return format 2025-03-09 11:25:24 -04:00
Azalea
30600a5b9c [+] Verse: Model inference progress bar 2025-03-09 10:57:49 -04:00
Azalea
54eada1a66 [F] Verse: Fix id mismatch in recommend music 2025-03-09 10:54:46 -04:00
Azalea
c74c0456de [+] Verse: Return recommended music 2025-03-09 10:27:15 -04:00
Azalea
e3db8a1fdf [+] Verse: Generalize AI recommender 2025-03-09 10:19:39 -04:00
Azalea
b40dcf85bd [F] Verse: Fix userid null 2025-03-09 10:07:00 -04:00
Azalea
9c76286660 [+] Verse: Return user challenge 2025-03-09 10:00:37 -04:00
Azalea
a099d8bdf3 [+] Verse: Add user data fields 2025-03-09 09:58:42 -04:00
Azalea
b2f680da4e [+] Verse: Save unlock challenge 2025-03-09 09:57:46 -04:00
Azalea
18ecbe0f44 [+] Verse: Save rating 2025-03-09 09:57:09 -04:00
Azalea
bef38ce45f [+] Unlock challenge? 2025-03-09 07:10:15 -04:00
Azalea
70a90d9a92 [O] 0-index page 2025-03-05 00:51:49 -05:00
Azalea
7ce4b0058e [+] Paged ranking 2025-03-05 00:48:08 -05:00
Azalea
a105871a98 [+] User info endpoint 2025-03-03 18:08:21 -05:00
Azalea
d5be354a84 [+] KanadeDX card number notice 2025-03-01 23:26:20 -05:00
凌莞~(=^▽^=)
3da92de951 [F] Card access time is not correctly set (#120) 2025-03-01 16:31:39 -05:00
Azalea Gui
5caeaccec8 [M] Merge local branch bun.lockb 2025-03-01 01:39:28 -05:00
alix
eef40e39d1 implement memorial photo viewer (#119)
* commit current progress\

will prob work on my mac ltr

* more transferring to different device

* grammar

* [F] Fix warning inconsistency

* [O] Split status overlays

* [S] Better styling

* [+] i18n

* [+] Display photos tab conditionally

---------

Co-authored-by: Azalea <22280294+hykilpikonna@users.noreply.github.com>
2025-03-01 01:35:49 -05:00
Azalea
6c21afaa57 [F] Fix settings colliding with userbox? 2025-02-28 23:51:00 -05:00
Raymond
ca09e0e3f7 fix: formatting of bio 2025-02-28 23:51:00 -05:00
Raymond
fb9ef65346 fix: error in notice text 2025-02-28 23:51:00 -05:00
Raymond
344f62c275 refactor: don't add unnecessary game types 2025-02-28 23:51:00 -05:00
Raymond
d28b9bf5a8 feat: preferred game
this needs more testing
2025-02-28 23:51:00 -05:00
Raymond
b23385ba28 fix: add navigation to i18n, tooltip for profile 2025-02-28 23:51:00 -05:00
Raymond
586d108d32 feat: add announcement 2025-02-28 23:51:00 -05:00
Raymond
df395a613f fix: use rating numbers from dds, swap userplate font again
changed font to Gothic A1, added new DDS to userbox (all users wishing to benefit MUST update aquabox), added rating numbers
2025-02-28 23:51:00 -05:00
Raymond
45e3f23dc9 fix: change font on userplate from Arial to Choco Cooky
i lied about cfvhoco cooky but it's so funny we really needt o do that at some point
2025-02-28 23:51:00 -05:00
Raymond
113769643a feat: add additional information on user home
adds display name (if available), moves game name (if display name is visible) and shows username (if available)
2025-02-28 23:51:00 -05:00
Raymond
ecf12175f4 fix: remove unnecessary check 2025-02-28 23:51:00 -05:00
Raymond
0a0f350a1d fix: textarea instead of input for bio 2025-02-28 23:51:00 -05:00
Raymond
ff7abf4c41 fix: remove div around box 2025-02-28 23:51:00 -05:00
Raymond
7fc328b60a feat: add bio to profile 2025-02-28 23:51:00 -05:00
Azalea
5adbcc0aff [O] Protect against path traversal 2025-02-26 21:20:54 -05:00
Azalea
6bdfc69668 [+] Net photo APIs 2025-02-26 19:42:39 -05:00
Azalea
547ad4d0f8 [+] Second kaleidx area 2025-02-25 19:35:34 -05:00
Clansty
122a6776a2 [O] Mai2 set isCurrent = false for userLoginBonus on saving to enable select login bonus every time 2025-02-26 04:08:48 +08:00
Azalea
b840f6709b Merge branch 'v1-dev' of https://github.com/MewoLab/AquaDX into v1-dev 2025-02-24 18:15:55 -05:00
Azalea
ab6f6cd990 [U] Update readme 2025-02-24 18:15:53 -05:00
Azalea
97a56bbbfd [F] Fix rival 2025-02-24 03:40:47 -05:00
Azalea
a2b127cf4f [F] Fix duplicate 2025-02-23 19:03:46 -05:00
Azalea
a4524a7182 [+] Verse events 2025-02-23 18:56:36 -05:00
Azalea
0133f85800 [+] Ongeki settings on Web UI 2025-02-23 06:12:15 -05:00
Azalea
9745e65eed [+] Ongeki infinite kaika 2025-02-23 05:58:03 -05:00
Azalea
43e5f93a37 Update self-hosting.md 2025-02-22 22:53:23 -05:00
Azalea
07607a489c Update self-hosting.md 2025-02-22 22:52:47 -05:00
Azalea
46e82eae3c [U] Add note about verse 2025-02-22 19:19:13 -05:00
Azalea
3f0d1e345b [U] Verse is supported 2025-02-22 19:12:33 -05:00
Clansty
d2a2bad111 [+] Mai2 add query for owned items 2025-02-18 12:22:19 +08:00
Clansty
7427609bee [+] Mai2 add endpoints to get and set login bonus 2025-02-16 22:37:04 +08:00
Clansty
7431e58f70 [+] Mai2 add nameplate fields to settableFields 2025-02-13 13:43:41 +08:00
Clansty
519f67071d [+] Add lookup by net username for debug-user-profile 2025-02-12 00:39:54 +08:00
Porta
56fe553870 [+] Add Dockerfile and devcontainer configuration for AquaDX development environment 2025-02-09 10:15:28 -05:00
Porta
c69570147a BUG: Fix gradlew not being executable during docker build. 2025-02-09 06:38:05 -05:00
Azalea
d031602789 [+] Display country 2025-02-09 00:22:17 -05:00
Azalea
b26a5a566b [+] Country code to emoji 2025-02-09 00:22:00 -05:00
Azalea
93d28db11a [+] Twemoji country flag font 2025-02-09 00:21:39 -05:00
Azalea
5a06ae52a9 [+] Country code names 2025-02-08 23:43:03 -05:00
Menci
81743b22e9 [+] Know game version from user-name-plate query 2025-02-03 06:42:56 -05:00
Menci
2ef4b7241d [+] MaiMile and UserIntimate (partner closeness) 2025-02-01 05:28:25 -05:00
Menci
9d19c99abe [F] "Felica" -> "FeliCa" 2025-01-28 05:00:54 +08:00
Menci
db7be134c7 [O] LinkCard UX improvements and i18n fixes 2025-01-28 04:57:58 +08:00
Azalea
99e1d130f0 [F] Fix infinite loop 2025-01-19 13:34:42 -05:00
Azalea
f33e1a0ae0 [F] Fix dates and timezones 2025-01-18 22:03:28 -05:00
split / May
5fb5e52e54 fix: 🐛 use accept= property correctly, use exclusively the correct mimetype for jpeg 2025-01-18 21:53:28 -05:00
Raymond
16112d4f1d feat: pfp cropper + enhancements 2025-01-18 21:53:28 -05:00
Raymond
7d214ba214 feat: add export to chu3 2025-01-18 21:53:28 -05:00
Raymond
2a6f3745c3 feat: add warning to general settings 2025-01-18 21:53:28 -05:00
凌莞~(=^▽^=)
151535139f [+] Allow disable music rank for own machine (#110)
* [+] Allow disable music rank for own machine

* fix
2025-01-19 01:43:58 +08:00
Azalea
dfee2cd71f [+] Better kaleidx 2025-01-18 00:28:32 -05:00
Azalea
b178d7fd8d [O] Let's assume everyone found their keys 2025-01-17 20:44:51 -05:00
Azalea
43582f0528 [F] Fix kaleidx parsing 2025-01-17 18:48:47 -05:00
Azalea
202df27f88 [F] Fix server return type 2025-01-17 13:02:55 -05:00
Azalea
93518aa1f4 [+] Log on dup 2025-01-17 10:40:45 -05:00
Azalea
1421d55a56 [F] Fix duplicate uploads 2025-01-17 10:25:04 -05:00
Azalea
762f0ef445 [O] Limit activity count 2025-01-17 10:18:22 -05:00
Azalea
deb923fcfd [-] User activity should not be unique 2025-01-17 10:16:31 -05:00
Azalea
9210582a4b Revert "[-] Remove unique update for now"
This reverts commit df2f05a914.
2025-01-17 10:15:37 -05:00
Azalea
24c83f9596 [F] Fix unique in upsert 2025-01-17 10:10:41 -05:00
Azalea
7a18b4499d [F] Fix userid 2025-01-17 10:06:17 -05:00
Azalea
c6bd6e862d [+] 150 2025-01-17 09:58:14 -05:00
Azalea
df2f05a914 [-] Remove unique update for now 2025-01-17 09:45:21 -05:00
Azalea
d79af91a8d [F] Fix 2025-01-17 09:40:56 -05:00
Azalea
f43eaa4577 [F] Fix key conflicts 2025-01-17 09:32:31 -05:00
Azalea
f6cd0edbc2 [F] Fix mai unique 2025-01-17 09:27:52 -05:00
Azalea
709419bc2d [U] Bump readme 2025-01-17 09:22:22 -05:00
Azalea
95460ca98f [U] Bump readme 2025-01-17 08:29:43 -05:00
Azalea
277f103535 [F] Fix primary key 2025-01-17 08:27:15 -05:00
Azalea
34ed1af242 [+] Implement GetUserKaleidxScope 2025-01-17 08:20:36 -05:00
Azalea
4328ca3280 [+] Insert Kaleidx on upsert 2025-01-17 08:19:04 -05:00
Azalea
1075256f21 [+] disableArea for events 2025-01-17 08:07:39 -05:00
Azalea
69ec608212 [+] db model for kaleidx 2025-01-17 08:07:24 -05:00
Azalea
3a4651adcd [+] ExtBool2 2025-01-17 08:06:48 -05:00
Azalea
1801c25fdc [+] New upsert field 2025-01-17 07:54:19 -05:00
Azalea
dc7e7b2c20 [F] Use datetime 2025-01-17 07:54:09 -05:00
Azalea
f654e12546 [+] Mai2UserKaleidx 2025-01-17 07:48:28 -05:00
Azalea
3c1dbeab15 [+] UploadUserPlaylogListApi 2025-01-17 07:41:23 -05:00
Azalea
f6f17dd328 [+] New item types 2025-01-17 07:39:56 -05:00
Azalea
2b6c283cd1 [-] Revert aeab453e 2025-01-17 06:17:32 -05:00
Azalea
a374f7a44b [O] Security: keychip check on remove 2025-01-16 19:01:51 -05:00
Azalea
10933046d6 [O] Reduce recruit TTL 2025-01-16 18:55:22 -05:00
Azalea
d7abb343a7 [F] Remove TCP Ack of ack 2025-01-16 17:50:59 -05:00
Azalea
aeab453e8b [-] Remove broadcast to reduce abuse 2025-01-16 17:47:55 -05:00
Azalea
c5dbe778ea [M] FutariServer > FutariRelay 2025-01-16 17:04:36 -05:00
Azalea
b17d784d80 [O] Hash user ids 2025-01-16 15:49:16 -05:00
Azalea
146e4bac0f [+] Recruit lobby 2025-01-16 15:19:50 -05:00
Clansty
8f7f422b28 [+] AquaMai GetServerAnnouncementApi placeholder 2025-01-16 15:50:53 +08:00
Azalea
2d35d41779 [F] Thread close 2025-01-15 19:27:34 -05:00
Azalea
3114a9b8c6 [O] No leaks 2025-01-15 19:27:20 -05:00
Azalea
830b10878e [F] Fix other people disconnecting causing broadcast to crash 2025-01-15 12:57:04 -05:00
Azalea
7363bb307d [F] Fix destination processing order 2025-01-15 10:56:37 -05:00
Azalea
2e0c567158 [F] Fix broadcast fail 2025-01-14 10:11:49 -05:00
Azalea
1a82fa27a9 [U] Update usages 2025-01-13 21:17:14 -05:00
Azalea
146f171cbc [+] Str.some 2025-01-13 21:16:30 -05:00
Azalea
8a04bb014a [M] Split code 2025-01-13 21:16:19 -05:00
Azalea
5290597b2b [O] Futari serailization 2025-01-13 21:14:44 -05:00
Azalea
661af76ed6 [+] TCP 2025-01-13 05:21:23 -05:00
Azalea
18d95a1ccd [F] Fix udp sendclass and recv 2025-01-12 22:27:01 -05:00
Azalea
0f87ed82e3 [F] Fix ranking type 2025-01-12 06:56:18 -05:00
Azalea
bb9bce67d8 [F] Fix recommend select music 2025-01-12 06:47:26 -05:00
Azalea
208fb8cf73 [+] Game music popularity for chusan 2025-01-12 05:54:15 -05:00
Azalea
ef8cb7e0ee [+] Game music popularity handler 2025-01-12 05:54:07 -05:00
Azalea
52ef582be6 [+] Implement GetUserRecommendSelectMusic 2025-01-12 05:52:53 -05:00
Azalea Gui
5dd06ba501 [+] requirements.txt 2025-01-12 04:40:08 -05:00
Azalea Gui
f6a5a03346 [+] Log recommendations 2025-01-12 04:38:43 -05:00
Azalea Gui
767d396171 [U] Update application properties 2025-01-12 04:29:20 -05:00
Azalea
da159b715c [+] Recommender ALS model 2025-01-12 04:28:36 -05:00
Azalea
79fa5448a0 [+] Recommender integration 2025-01-12 04:22:07 -05:00
Azalea Gui
b5f41cdab9 [M] Reorganize migrations 2025-01-12 02:41:15 -05:00
Menci
99507c7c6d [F] Mai2 music ranking fix (#108)
* Revert "[O] No blocking tasks on startup"

This reverts commit 9d05ef6808.

* Revert "[O] Let json lib do its magic"

This reverts commit 5923987c7f.

* Reapply "[O] No blocking tasks on startup"

This reverts commit e06e8b4cf0.

---------

Co-authored-by: Azalea <22280294+hykilpikonna@users.noreply.github.com>
2025-01-12 15:19:43 +08:00
Azalea
d7db45d700 [F] Fix list 2025-01-11 21:26:39 -05:00
Azalea
111304481f [F] Fix key mismatch 2025-01-11 21:23:12 -05:00
Azalea
64827ec0fc [O] Merge more apis 2025-01-11 21:15:16 -05:00
Azalea
c5b40f64e4 [O] Mai2 migrate 2025-01-11 20:53:59 -05:00
Azalea
401d182fc6 [O] Use logger alias 2025-01-11 19:16:46 -05:00
Azalea
3878103eaa [+] Todo notice 2025-01-11 19:12:11 -05:00
Azalea
5f6cd43b35 [+] Show More option 2025-01-11 19:06:28 -05:00
Menci
03b452e426 [+] Mai2 music ranking 2025-01-11 18:50:01 -05:00
Azalea
528960940c [U] Update ktor 2025-01-11 18:52:29 -05:00
Azalea
9063fbc13d [O] Return more ratings from backend 2025-01-11 12:52:50 -05:00
Azalea
d14998565b [O] Correctly compute rating change 2025-01-11 12:39:14 -05:00
Azalea
4a71e15fd5 [O] Split chuni userbox display
Co-Authored-By: Raymond <101374892+raymonable@users.noreply.github.com>
2025-01-11 12:35:17 -05:00
Azalea
94a0086fdd [O] Reformat GetUserMusic 2025-01-11 02:19:45 -05:00
Azalea
65cc3095e2 [F] Fix no image icon on missing texture 2025-01-08 01:27:00 -05:00
Azalea
cd90f2745a [F] Fix chusan cascade 2025-01-08 01:08:05 -05:00
Azalea
69a0e60fee [+] 20101 server 2025-01-07 12:04:32 -05:00
Azalea
aad43c9f9c [F] Fix matching start & end time? 2025-01-07 10:59:03 -05:00
Azalea
f033496d20 Merge branch 'v1-dev' of https://github.com/MewoLab/AquaDX into v1-dev 2025-01-07 10:38:36 -05:00
Azalea
b6757434b7 [+] Note about duolinguo 2025-01-07 10:38:29 -05:00
Azalea
3e9abda042 Update chu3-national-matching.md (#104) 2025-01-06 20:08:38 -05:00
Clansty
cf1e745c14 [O] Support option folder in root 2025-01-07 06:08:25 +08:00
Paiton Bertschy
eb1745d179 Update chu3-national-matching.md
Correct my shitty spelling
2025-01-06 07:16:32 -06:00
Paiton Bertschy
2b88713315 Update chu3-national-matching.md 2025-01-06 07:10:55 -06:00
Azalea
ef435130ee fix: penguin appearance fixes (#103) 2025-01-06 04:43:50 -05:00
Azalea
17df365b2c [+] more docs 2025-01-05 23:32:40 -05:00
Azalea
852617975b [+] More docs 2025-01-05 23:31:16 -05:00
Raymond
90bed4413e fix: penguin appearance fixes 2025-01-05 23:04:06 -05:00
Azalea
ba59649946 [U] docs formatting 2025-01-05 21:21:39 -05:00
Azalea
e90e79ebf4 [M] docs move self hosting to separate document 2025-01-05 21:17:56 -05:00
Azalea
18c84ae310 [U] docs update game notes 2025-01-05 21:15:28 -05:00
Azalea
f06031f753 [U] docs update faq 2025-01-05 21:15:16 -05:00
Azalea
1d605ebb94 [-] Remove outdated changelog 2025-01-05 20:46:39 -05:00
Azalea
e3145bbdd6 [+] Scam notice 2025-01-05 20:41:45 -05:00
Azalea
130129c9bd [U] Docs formatting 2025-01-05 20:27:14 -05:00
Azalea
6d5a61fe04 [U] Docs formatting 2025-01-05 20:22:18 -05:00
Azalea
ee4f923a2d [U] docs 2025-01-05 20:19:02 -05:00
Azalea
500e469ee0 [F] Fix cmission 2025-01-05 19:34:11 -05:00
Azalea
cf7af0ff34 Merge branch 'v1-dev' of https://github.com/MewoLab/AquaDX into v1-dev 2025-01-05 07:57:42 -05:00
Azalea
e92c962c14 [+] Allow set username 2025-01-05 07:57:31 -05:00
Azalea
e37ca4a18e [O] Split input field 2025-01-05 07:57:19 -05:00
Clansty
8efc1a96ea [+] userRating api for chu3 2025-01-05 20:49:01 +08:00
Azalea
16de6ec208 [+] i18n 2025-01-05 07:48:46 -05:00
Azalea
7824ab907b [RF] move userMusicFromList to GameApiController and add GenericUserM… (#102) 2025-01-05 07:26:31 -05:00
Azalea
574e885da3 aquabox improvements (#99) 2025-01-05 07:23:16 -05:00
Clansty
ccd88a10ab [RF] move userMusicFromList to GameApiController and add GenericUserMusicRepo 2025-01-05 19:54:15 +08:00
Azalea
3a69717a9d Merge branch 'v1-dev' into pr/99 2025-01-05 06:54:15 -05:00
Azalea
8b1fe940d2 Update README.md 2025-01-05 19:32:59 +08:00
Azalea
7083e1a117 [F] Fix duolinguo integration 2025-01-05 05:59:54 -05:00
Azalea
51af357c5a [F] Fix username check 2025-01-05 05:21:47 -05:00
Azalea
7ad4bc2ba5 [F] Fix favorite 2025-01-05 05:02:25 -05:00
Azalea
6cfd0a91fc [F] Fix username mismatch 2025-01-05 04:39:11 -05:00
Azalea
a70e9130f5 [F] Fix duplicate key 2025-01-05 04:25:11 -05:00
Azalea
01cb0c4b90 [+] Add playlog info to battle log when upsert 2025-01-05 04:23:41 -05:00
Azalea
4c3ed1d0da [F] Fix recentNBMusicList return 2025-01-05 04:13:38 -05:00
Azalea
250d92d225 [+] toDict 2025-01-05 04:13:27 -05:00
Azalea
e16bb5a34f [+] Add playlog fields to net battle log table 2025-01-05 04:13:20 -05:00
Azalea
9ff07f9f2b [F] Fix fav item list/ 2025-01-05 03:29:42 -05:00
Azalea
7e2cc100e6 [+] More docs 2025-01-05 03:17:42 -05:00
Azalea
4820c38bce [+] Implement GetUserNetBattleData 2025-01-05 02:20:15 -05:00
Azalea
89e682df3d [+] Store userMisc at upsert 2025-01-05 02:14:30 -05:00
Azalea
d25678d7b4 [+] UserMisc 2025-01-05 02:14:16 -05:00
Azalea
56e424c29b [+] Mut 2025-01-05 02:13:55 -05:00
Azalea
96fb815bd8 [F] Fix username encoding in net battle log 2025-01-05 01:20:09 -05:00
Azalea
41636b09db [+] ARRR 2025-01-05 01:14:50 -05:00
Azalea
8b2518a25d [+] More docs on matching 2025-01-05 00:38:54 -05:00
Raymond
9db091a2d2 fix: fix typo 2025-01-04 22:31:26 -05:00
Raymond
d220f369e9 fix: 🐛 aquabox 2025-01-04 22:30:40 -05:00
Azalea
73792e4294 [+] Store net battle log at upsert 2025-01-04 22:12:41 -05:00
Raymond
8b9236ae43 feat: 🎨 finalize server url mode 2025-01-04 22:04:09 -05:00
Azalea
877c23b9d7 [+] Fav music fix 2025-01-04 21:55:13 -05:00
Azalea
aed5c20700 [F] Fix event return data type mismatch 2025-01-04 21:48:11 -05:00
Raymond
f6efd392b9 refactor: fix merge conflicts #1 2025-01-04 20:17:33 -05:00
Azalea
9197b3ca93 [O] Todo :( 2025-01-04 20:14:38 -05:00
Azalea
01a064f1ab [O] Game entities 2025-01-04 20:07:16 -05:00
Azalea
af9cd81220 [O] Complete kt rewrite 2025-01-04 19:59:46 -05:00
Azalea
8203a70b60 [O] Rewrite CM chusan apis 2025-01-04 19:49:36 -05:00
Azalea
f290e6e576 [+] Auto redirect to /home when already logged in 2025-01-04 19:32:02 -05:00
Raymond
dbe3f3393c style: 🎨 small sass change 2025-01-04 19:22:22 -05:00
Raymond
a87dec0d64 fix: 🐛 disable click cursor on userplates that aren't yorus 2025-01-04 19:21:02 -05:00
Raymond
14bca470bb fix: 🐛 fix items not appearing as intended 2025-01-04 19:14:36 -05:00
Azalea
df3deee316 [-] Remove unused services 2025-01-04 18:53:36 -05:00
Azalea
62a55a40c2 [O] Rewrite the rest of chusan in kotlin 2025-01-04 18:47:23 -05:00
Raymond
42a4a11c49 fix: fixes / extra documentation texts 2025-01-04 18:18:37 -05:00
Azalea
3ef7f40e37 [-] Chusan drops database 2025-01-04 18:05:44 -05:00
Raymond
82a0473287 feat: add url support (wip) 2025-01-04 18:00:08 -05:00
Azalea
41e746a70e [-] Remove user-box-all-items api 2025-01-04 17:54:40 -05:00
Azalea
e2d6e29d7b [+] Add net battle log sql table 2025-01-04 17:52:46 -05:00
Azalea
5445fbed6c Merge branch 'v1-dev' of https://github.com/MewoLab/AquaDX into v1-dev 2025-01-04 17:13:44 -05:00
Azalea
e9e9e0a621 [F] Fix types 2025-01-04 17:13:41 -05:00
Azalea
891dffce8d Update chu3-national-matching.md (#101) 2025-01-04 16:49:38 -05:00
Paiton Bertschy
4ad66fa4dc Update chu3-national-matching.md
Fix spelling mistake lol
2025-01-04 14:58:46 -06:00
Azalea
5570aa79f7 [+] NAT and firewall 2025-01-04 12:09:32 -05:00
Azalea
288d336fb6 Merge branch 'v1-dev' of https://github.com/MewoLab/AquaDX into v1-dev 2025-01-04 08:49:13 -05:00
Azalea
d7b7d617bd [F] Fix user activity saving 2025-01-04 08:48:56 -05:00
Azalea
c0f7d11828 [+] Net battle log class 2025-01-04 08:48:48 -05:00
Azalea
1bd4f4f423 [-] Remove unused user id 2025-01-04 08:48:29 -05:00
Azalea
ce130c1e15 [+] More chusan upsert data types 2025-01-04 08:48:21 -05:00
Clansty
e69a201e97 [+] Drag and drop to link card 2025-01-04 21:17:30 +08:00
Clansty
143b36ab66 [O] Card binding optimize 2025-01-04 20:40:35 +08:00
Azalea
90900446ee Update README.md (#100) 2025-01-03 19:07:58 -05:00
Azalea
29d34fb52c Update README.md
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-01-04 08:07:50 +08:00
Paiton Bertschy
241dbc8238 Update README.md
Update Readme to reflect that the Japan version of buddies plus is supported
2025-01-03 17:42:21 -06:00
Azalea
aea202bcf2 [+] More collab servers 2025-01-03 18:10:28 -05:00
Azalea
0bc4f14845 [S] fit color scheme 2025-01-03 17:29:29 -05:00
Azalea
7539d75ddb [+] i18n for matching 2025-01-03 17:24:40 -05:00
Azalea
9ea0dcb5a7 [U] Docs 2025-01-03 17:19:09 -05:00
Azalea
40707b7d23 [U] Docs indentation 2025-01-03 17:16:29 -05:00
Azalea
d146ed9a7d [U] Docs 2025-01-03 17:13:37 -05:00
Azalea
a09329eb82 [+] Docs 2025-01-03 17:12:52 -05:00
Azalea
0bda8406c3 [+] Submit matching settings 2025-01-03 16:51:03 -05:00
Azalea
9489151bc1 [+] UI Matching settings 2025-01-03 16:33:38 -05:00
Azalea
80fc8417dc [-] Remove proxied matching prop 2025-01-03 16:33:03 -05:00
Azalea
cb0f46c5db [F] Fix logging? 2025-01-03 16:32:26 -05:00
Azalea
85b5910ea9 [-] Remove proxied matching, not very useful 2025-01-03 16:32:13 -05:00
Azalea
d5a1a26091 [+] Return matching api 2025-01-03 16:31:58 -05:00
Azalea
256b48a0ad [+] Matching server in game option table 2025-01-03 16:31:42 -05:00
Azalea
bfb269b378 [+] option i18n 2025-01-03 16:31:25 -05:00
Azalea
54bed879a5 [+] Matching server options 2025-01-03 16:30:06 -05:00
Azalea
4ded3d9752 [+] TODO 2025-01-03 16:10:01 -05:00
Raymond
f68bd54ccd fix: add tooltip to trophy
(helpful for when it's longer than the box has)
2025-01-03 14:07:59 -05:00
Azalea
2edad4efdb [F] Fix rank 2025-01-03 10:17:21 -05:00
Raymond
08af00da29 feat: 💄 aquabox on profiles + avatar fixes 2025-01-03 09:21:43 -05:00
704 changed files with 15729 additions and 18219 deletions

20
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM gradle:8.8.0-jdk21
ENV NODE_VERSION=22
RUN apt-get update && \
apt-get install -y curl && \
curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
apt-get install -y nodejs && \
npm install -g npm@latest
RUN npm install -g bun
RUN apt-get install -y maven
RUN gradle --version && \
node --version && \
npm --version && \
bun --version
WORKDIR /workspace

View File

@@ -0,0 +1,17 @@
{
"name": "AquaDX Dev Container",
"build": {
"dockerfile": "Dockerfile"
},
"customizations": {
"vscode": {
"extensions": [
"vscjava.vscode-gradle",
"vscjava.vscode-java-pack",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"fwcd.kotlin"
]
}
}
}

View File

@@ -6,6 +6,8 @@ on:
branches:
- main
workflow_dispatch:
schedule:
- cron: '0 0 * * 0' # Runs at midnight UTC every Sunday
jobs:
build-and-push:

View File

@@ -9,19 +9,17 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v3
with:
java-version: '17'
java-version: '21'
distribution: 'temurin'
server-id: github
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Build with Gradle
run: |
mkdir data
bash ./src/main/resources/meta/update.sh
chmod +x gradlew
./gradlew build

1
.gitignore vendored
View File

@@ -83,3 +83,4 @@ src/main/resources/meta/*/*.json
*.salive
test-diff
htmlReport
docs/logs

9
AquaNet/.env Normal file
View File

@@ -0,0 +1,9 @@
VITE_AQUA_HOST=https://aquadx.net/aqua
VITE_DATA_HOST=https://aquadx.net
VITE_AQUA_CONNECTION=aquadx.hydev.org
VITE_TURNSTILE_SITE_KEY=0x4AAAAAAASGA2KQEIelo9P9
VITE_DISCORD_INVITE=https://discord.gg/FNgveqFF7s
VITE_TELEGRAM_INVITE=https://t.me/+zBL4RZdyfvUzZGU1
VITE_QQ_INVITE=https://qm.qq.com/q/dpYmGoVHnG

5
AquaNet/.gitignore vendored
View File

@@ -31,3 +31,8 @@ dist-ssr
!.yarn/releases
!.yarn/sdks
!.yarn/versions
public/chu3
# local env file
*.local

BIN
AquaNet/bun.lockb Normal file → Executable file

Binary file not shown.

View File

@@ -39,6 +39,7 @@
"lxgw-wenkai-lite-webfont": "^1.7.0",
"modern-normalize": "^3.0.1",
"moment": "^2.30.1",
"svelte-easy-crop": "^4.0.0",
"svelte5-router": "^3.0.1"
},
"packageManager": "pnpm@9.7.0+sha512.dc09430156b427f5ecfc79888899e1c39d2d690f004be70e05230b72cb173d96839587545d09429b55ac3c429c801b4dc3c0e002f653830a420fa2dd4e3cf9cf"

2605
AquaNet/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,163 @@
/*
Happy April Fools!
This theme will stay here.
Note that I made it with Stylish in mind, it's quite jank.
*/
* {
font-family: "ヒラギノ角ゴ Pro W3", "メイリオ", Meiryo, " Pゴシック",
"MS P Gothic", sans-serif;
}
nav > a,
nav > *.active,
.setting-icon path {
color: unset !important;
}
.aqua-tooltip {
background: black;
}
.fw-block {
background: none !important;
box-shadow: none !important;
}
#app {
background: url(/assets/theme/cn/logo.bin),
#f9f9db;
background-repeat: no-repeat;
background-position: 50% 4px;
max-width: 528px !important;
margin: 0 auto;
padding: 100px 0 0 0 !important;
height: unset !important;
box-shadow: -8px 0 0 0 #fdd500, -12px 0 0 0 #f9f9db, 8px 0 0 0 #fdd500,
12px 0 0 0 #f9f9db;
}
nav:has(.logo) {
position: absolute !important;
top: 0;
left: 0;
width: calc(100% - 96px);
}
nav {
color: black;
}
.user-pfp {
margin-top: -56px !important;
}
.outer-title-options,
.outer-title-options *,
nav.tabs {
color: white !important;
}
.outer-title-options {
margin-top: 0 !important;
display: unset !important;
}
.outer-title-options h2 {
width: 460px;
position: relative;
right: 20px;
display: flex;
justify-content: center;
margin: 0 0 10px 0 !important;
background: url(/assets/theme/cn/header.bin);
}
.chuni-userbox-row {
flex-wrap: wrap;
}
.chuni-userbox button {
width: calc(100% / 4) !important;
font-size: 0px;
}
.chuni-userbox-row button {
width: unset !important;
flex: 0 1 calc(100% / 3) !important;
}
.chuni-userbox-row button img {
overflow: hidden;
font-size: 10px;
}
.chuni-nameplate {
background: none !important;
position: relative !important;
left: 20px;
}
.chuni-userbox {
background: none !important;
}
main {
max-width: calc(460px - 40px) !important;
margin: 16px auto 0 auto !important;
background: #2c4056 !important;
border-radius: unset !important;
padding: 10px 20px !important;
}
main:has(.user-pfp) {
margin: 64px auto 0 auto !important;
}
.rating-composition {
display: flex !important;
flex-wrap: wrap;
gap: 0 !important;
}
.rating-composition > div {
width: 47.5%;
margin: 1.25%;
}
.map-detail-container {
background: none !important;
border-radius: 0 !important;
}
.lv {
border-radius: 0 !important;
display: flex;
align-items: center;
justify-content: center;
padding: 0 !important;
width: 50px !important;
}
.rank-text {
min-width: 20px !important;
}
.chuni-userbox-container {
flex-wrap: wrap;
}
.profile-bio-text {
white-space: unset !important;
}
.chuni-penguin-container {
padding: 64px 0;
width: 100%;
background: linear-gradient(
180deg,
rgba(249, 249, 219, 1) 0%,
rgba(249, 249, 219, 1) 69%,
rgba(231, 231, 202, 1) 70%,
rgba(231, 231, 202, 1) 100%
);
}
body {
background: #fdd500 !important;
color: white;
}
@media (max-width: 1200px) {
#app {
background-position: 50% 60px !important;
padding-top: 150px !important;
}
}
@media (max-width: 1028px) {
#app {
background-size: 90%;
}
.user-pfp {
margin-top: -36px !important;
}
.user-pfp nav {
top: -10px !important;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -4,10 +4,15 @@
import UserHome from "./pages/UserHome.svelte";
import Home from "./pages/Home.svelte";
import Ranking from "./pages/Ranking.svelte";
import { USER } from "./libs/sdk";
import { CARD, USER } from "./libs/sdk";
import type { AquaNetUser } from "./libs/generalTypes";
import Settings from "./pages/User/Settings.svelte";
import { pfp } from "./libs/ui"
import MaiPhoto from "./pages/MaiPhoto.svelte";
import { pfp, tooltip } from "./libs/ui"
import { ANNOUNCEMENT } from "./libs/config";
import { t } from "./libs/i18n";
import Transfer from "./pages/Transfer/Transfer.svelte";
import { link } from "d3";
console.log(`%c
┏━┓ ┳━┓━┓┏━
@@ -23,9 +28,26 @@
export let url = "";
let me: AquaNetUser
let playedMai = false
if (USER.isLoggedIn()) USER.me().then(m => me = m).catch(e => console.error(e))
if (USER.isLoggedIn())
{
USER.me().then(m => {
me = m
CARD.userGames(me.username).then(game => {
playedMai = !!game.mai2
})
}).catch(e => console.error(e))
const themeStyle = document.createElement("link");
themeStyle.rel = "stylesheet";
switch (localStorage.getItem("theme")) {
case "cn":
themeStyle.href = "/assets/theme/cn.css";
};
if (themeStyle.href)
document.head.appendChild(themeStyle);
}
let path = window.location.pathname;
</script>
@@ -36,12 +58,20 @@
<span>AquaNet</span>
</a>
{/if}
<a href="/home">home</a>
<div on:click={() => alert("Coming soon™")} on:keydown={e => e.key === "Enter" && alert("Coming soon™")}
role="button" tabindex="0">maps</div>
<a href="/ranking">rankings</a>
{#if ANNOUNCEMENT}
<div class="announcement">
<strong>{t('navigation.notice')}</strong>: {ANNOUNCEMENT}
</div>
{/if}
<a href="/home">{t('navigation.home').toLowerCase()}</a>
<!-- <div on:click={() => alert("Coming soon™")} on:keydown={e => e.key === "Enter" && alert("Coming soon™")}
role="button" tabindex="0">{t('navigation.maps').toLowerCase()}</div> -->
<a href="/ranking">{t('navigation.rankings').toLowerCase()}</a>
{#if playedMai}
<a href="/pictures">photo</a>
{/if}
{#if me}
<a href="/u/{me.username}">
<a href="/u/{me.username}" use:tooltip={t('navigation.profile')}>
<img alt="profile" class="pfp" use:pfp={me}/>
</a>
{/if}
@@ -49,12 +79,16 @@
<Router {url}>
<Route path="/" component={Welcome} />
<Route path="/verify" component={Welcome} /> <!-- For email verification only, backwards compatibility with AquaNet2 in the future -->
<Route path="/reset-password" component={Welcome} />
<Route path="/home" component={Home} />
<Route path="/ranking" component={Ranking} />
<Route path="/ranking/:game" component={Ranking} />
<Route path="/u/:username" component={UserHome} />
<Route path="/u/:username/:game" component={UserHome} />
<Route path="/settings" component={Settings} />
<Route path="/pictures" component={MaiPhoto} />
<Route path="/transfer" component={Transfer} />
</Router>
<style lang="sass">
@@ -75,9 +109,25 @@
img
width: 1.5rem
height: 1.5rem
border-radius: 50%
border-radius: vars.$border-radius
object-fit: cover
.announcement
position: absolute
left: 50%
transform: translate(-50%, 0)
top: 0
width: 50%
height: 100%
display: flex
justify-content: center
align-content: center
z-index: -1
background: linear-gradient(90deg, #6f0f0f00 0%, vars.$c-shadow 50%, #6f0f0f00 100%)
font-size: 1.125em
text-decoration: none !important
color: inherit !important
.pfp
width: 2rem
height: 2rem

View File

@@ -1,5 +1,6 @@
@use "sass:color"
@use "vars"
@import 'components/font/twemoji-flags.css'
@import 'lxgw-wenkai-lite-webfont/style.css'
html
@@ -78,6 +79,7 @@ button
opacity: 0.9
cursor: pointer
transition: vars.$transition
white-space: nowrap
button:hover
border-color: vars.$c-main
@@ -126,11 +128,13 @@ button.icon
// --lv-color: 239, 242, 225
--lv-text-clip: linear-gradient(110deg, #5ac42c, #5ccc22, #959f26, #cc7c23, #c93143, #8f4876, #4c3eb1, #3c3397)
.warning
color: vars.$c-warning
.error
color: vars.$c-error
input
input, textarea
border-radius: vars.$border-radius
border: 1px solid transparent
padding: 0.6em 1.2em
@@ -140,6 +144,10 @@ input
background-color: vars.$ov-lighter
transition: vars.$transition
box-sizing: border-box
resize: none
textarea
height: 5em
// Dropdown
select
@@ -182,6 +190,9 @@ input:focus, input:focus-visible
border: 1px solid vars.$c-main
outline: none
input.warning
border: 1px solid vars.$c-warning
input.error
border: 1px solid vars.$c-error
@@ -307,6 +318,9 @@ main.content
max-width: 400px
.aqua-tooltip
z-index: 900
.no-margin
margin: 0

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
export let page: number
export let totalPages: number
const dispatch = createEventDispatcher()
let editing = false
let inputPage: number
function updatePage(newPage: number) {
if (newPage > 0 && newPage <= totalPages) dispatch('updatePage', newPage)
}
function startEditing() {
inputPage = page
editing = true
}
function finishEditing() {
editing = false
if (inputPage !== page) updatePage(inputPage)
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') finishEditing()
else if (event.key === 'Escape') editing = false
}
</script>
<div class="pagination">
<button on:click={() => updatePage(page - 1)} disabled={page <= 1}>Previous</button>
{#if editing}
<input bind:value={inputPage} on:blur={finishEditing} on:keydown={handleKeydown} min="1" max={totalPages} autofocus/>
{:else}
<span on:click={startEditing} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && startEditing()}>
Page {page} of {totalPages}
</span>
{/if}
<button on:click={() => updatePage(page + 1)} disabled={page >= totalPages}>Next</button>
</div>
<style lang="sass">
.pagination
display: flex
justify-content: center
align-items: center
margin: 1rem 0
gap: 1rem
input
width: 100px
text-align: center
span[role="button"]
cursor: pointer
</style>

View File

@@ -1,87 +1,52 @@
<!-- Svelte 4.2.11 -->
<script lang="ts">
import { fade } from 'svelte/transition'
import type { ConfirmProps } from "../libs/generalTypes";
import { DISCORD_INVITE } from "../libs/config";
import Icon from "@iconify/svelte";
import { t } from "../libs/i18n"
// Props
export let confirm: ConfirmProps | null = null
export let error: string | null
export let loading: boolean = false
</script>
{#if confirm}
<div class="overlay" transition:fade>
<div>
<h2>{confirm.title}</h2>
<span>{confirm.message}</span>
<div class="actions">
{#if confirm.cancel}
<!-- Svelte LSP is very annoying here -->
<button on:click={() => {
confirm && confirm.cancel && confirm.cancel()
// Set to null
confirm = null
}}>{t('action.cancel')}</button>
{/if}
<button on:click={() => confirm && confirm.confirm()} class:error={confirm.dangerous}>{t('action.confirm')}</button>
</div>
</div>
</div>
{/if}
{#if error}
<div class="overlay" transition:fade>
<div>
<h2 class="error">{t('status.error')}</h2>
<span>{t('status.error.hint')}<a href={DISCORD_INVITE}>{t('status.error.hint.link')}</a></span>
<span>{t('status.detail', { detail: error })}</span>
<div class="actions">
<button on:click={() => location.reload()} class="error">
{t('action.refresh')}
</button>
</div>
</div>
</div>
{/if}
{#if loading && !error}
<div class="overlay loading" transition:fade>
<Icon class="icon" icon="svg-spinners:pulse-2"/>
<span><span>LOADING</span></span>
</div>
{/if}
<style lang="sass">
.actions
display: flex
gap: 16px
button
width: 100%
.loading.overlay
font-size: 28rem
:global(.icon)
opacity: 0.5
> span
position: absolute
inset: 0
display: flex
justify-content: center
align-items: center
background: transparent
letter-spacing: 20px
margin-left: 20px
font-size: 1.5rem
</style>
<!-- Svelte 4.2.11 -->
<script lang="ts">
import { fade } from 'svelte/transition'
import type { ConfirmProps } from "../libs/generalTypes";
import { t } from "../libs/i18n"
import Loading from './ui/Loading.svelte';
import Error from './ui/Error.svelte';
// Props
export let confirm: ConfirmProps | null = null
export let error: string | null = null
export let loading: boolean = false
function doConfirm(fn?: () => void) {
confirm = null
fn && fn()
}
</script>
{#if confirm}
<div class="overlay" transition:fade>
<div>
<h2>{confirm.title}</h2>
<span>{confirm.message}</span>
<div class="actions">
{#if confirm.cancel}
<button on:click={() => doConfirm(confirm?.cancel)}>{t('action.cancel')}</button>
{/if}
<button on:click={() => doConfirm(confirm?.confirm)} class:error={confirm.dangerous}>{t('action.confirm')}</button>
</div>
</div>
</div>
{/if}
{#if error}
<Error {error}/>
{/if}
{#if loading && !error}
<Loading/>
{/if}
<style lang="sass">
.actions
display: flex
gap: 16px
button
width: 100%
</style>

View File

@@ -60,7 +60,7 @@
.tooltip
position: absolute
z-index: 1000
z-index: 900
background: white
padding: 10px 16px
border-radius: vars.$border-radius

Binary file not shown.

View File

@@ -0,0 +1,11 @@
@font-face {
font-family: 'TwemojiCountryFlags';
src: url('./TwemojiCountryFlags.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
.country {
font-family: TwemojiCountryFlags,"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif;
font-size: 2em;
}

View File

@@ -0,0 +1,211 @@
<script lang="ts">
import { fade, slide } from "svelte/transition";
import { CHU3_MATCHINGS } from "../../libs/config.js";
import type { ChusanMatchingOption, GameOption } from "../../libs/generalTypes.js";
import { t, ts } from "../../libs/i18n.js";
import { DATA, SETTING } from "../../libs/sdk.js";
import StatusOverlays from "../StatusOverlays.svelte";
import GameSettingFields from "./GameSettingFields.svelte";
let custom = false
let overlay = false
let loading = false
let error = ""
let changed: string[] = [];
let symbols: Record<number, number> = {};
let allItems: Record<string, Record<string, { name: string }>> = {}
let submitting: string | undefined | null;
let existingUrl = "";
SETTING.get().then(s => {
existingUrl = s.filter(it => it.key === 'chusanMatchingServer')[0]?.value
if (existingUrl && !CHU3_MATCHINGS.some(it => it.matching === existingUrl)) {
custom = true
}
const symbolKey = "chusanSymbolChat"
s.forEach(opt => {
if (opt.key.substring(0, symbolKey.length) == symbolKey && opt.value)
symbols[parseInt(opt.key.substring(symbolKey.length))] = opt.value;
})
})
async function fetchSymbolData() {
allItems = await DATA.allItems('chu3').catch(_ => {
loading = false
error = t("userbox.error.nodata")
}) as typeof allItems
}
async function submitSymbol(id: number) {
if (submitting) return false
const field = `chusanSymbolChat${id + 1}`;
submitting = field
await SETTING.set(field, symbols[id + 1]).catch(e => error = e.message).finally(() => submitting = null);
changed = changed.filter(v => v != `chusanSymbolChat${id}`)
return true
}
// Click on "Custom" option"
function clickCustom() {
custom = true
overlay = false
}
// Click on a matching option, set the reflector and matching server
function clickOption(opt: ChusanMatchingOption) {
Promise.all([
SETTING.set('chusanMatchingReflector', opt.reflector),
SETTING.set('chusanMatchingServer', opt.matching),
]).then(() => {
overlay = false
custom = false
existingUrl = opt.matching
}).catch(e => error = e.message)
}
</script>
<StatusOverlays {error} {loading}/>
<div class="matching">
<h2>{t("userbox.header.matching")}</h2>
<p class="notice">{t("settings.cabNotice")}</p>
<div class="matching-selector">
<button on:click={_ => overlay = true}>{t('userbox.matching.select')}</button>
</div>
{#if custom}
<GameSettingFields game="chu3-matching"/>
{/if}
<h2>{t("userbox.header.matching.symbolChat")}</h2>
{#await fetchSymbolData() then}
{#each {length: 4}, i}
<div class="field">
<label for={`chusanSymbolChat${i}`}>{ts(`userbox.matching.symbolChat`) + ` #${i + 1}`}</label>
<div>
<select bind:value={symbols[i + 1]} id={`chusanSymbolChat${i}`} on:change={() => {changed = [...changed, `chusanSymbolChat${i}`];}}>
<option value={null}>{ts(`userbox.matching.symbolChat.default`)}</option>
{#each Object.entries(allItems.symbolChat).filter((f) => parseInt(f[0]) !== 0) as [id, option]}
<option value={parseInt(id)}>{option?.name || `(unknown ${id})`}</option>
{/each}
</select>
{#if changed.includes(`chusanSymbolChat${i}`)}
<button transition:slide={{axis: "x"}} disabled={!!submitting} on:click={() => submitSymbol(i)}>
{t("settings.profile.save")}
</button>
{/if}
</div>
</div>
{/each}
{/await}
</div>
{#if overlay}
<div class="overlay" transition:fade>
<div>
<div>
<h2>{t('userbox.header.matching')}</h2>
<p>{t('userbox.matching.select.sub')}</p>
</div>
<div class="options">
<!-- Selectable options -->
{#each CHU3_MATCHINGS as option}
<div class="clickable option" on:click={() => clickOption(option)}
role="button" tabindex="0" on:keypress={e => e.key === 'Enter' && clickOption(option)}
class:selected={!custom && existingUrl === option.matching}>
<span class="name">{option.name}</span>
<div class="links">
<a href={option.ui} target="_blank" rel="noopener">{t('userbox.matching.option.ui')}</a> /
<a href={option.guide} target="_blank" rel="noopener">{t('userbox.matching.option.guide')}</a>
</div>
<div class="divider"></div>
<div class="coop">
<span>{t('userbox.matching.option.collab')}</span>
<div>
{#each option.coop as coop}
<span>{coop}</span>
{/each}
</div>
</div>
</div>
{/each}
<!-- Placeholder option for "Custom" -->
<div class="clickable option" on:click={clickCustom}
role="button" tabindex="0" on:keypress={e => e.key === 'Enter' && clickCustom()}
class:selected={custom}>
<span class="name">{t('userbox.matching.custom.name')}</span>
<p class="notice custom">{t('userbox.matching.custom.sub')}</p>
</div>
</div>
</div>
</div>
{/if}
<style lang="sass">
@use "../../vars"
.matching
display: flex
flex-direction: column
gap: 12px
h2
margin-bottom: 0
p.notice
opacity: 0.6
margin: 0
&.custom
font-size: 0.9rem
.options
display: flex
flex-wrap: wrap
gap: 1rem
.option
flex: 1
display: flex
flex-direction: column
align-items: center
border-radius: vars.$border-radius
background: vars.$ov-light
padding: 1rem
min-width: 150px
&.selected
border: 1px solid vars.$c-main
.divider
width: 100%
height: 0.5px
background: white
opacity: 0.2
margin: 0.8rem 0
.name
font-size: 1.1rem
font-weight: bold
.coop
text-align: center
div
display: flex
flex-direction: column
font-size: 0.9rem
opacity: 0.6
</style>

View File

@@ -6,15 +6,13 @@
type UserBox,
type UserItem,
} from "../../libs/generalTypes";
import { DATA, USER, USERBOX } from "../../libs/sdk";
import { DATA, USER, USERBOX, GAME } from "../../libs/sdk";
import { t, ts } from "../../libs/i18n";
import { DATA_HOST, FADE_IN, FADE_OUT, HAS_USERBOX_ASSETS } from "../../libs/config";
import { FADE_IN, FADE_OUT, USERBOX_DEFAULT_URL } from "../../libs/config";
import { fade, slide } from "svelte/transition";
import StatusOverlays from "../StatusOverlays.svelte";
import Icon from "@iconify/svelte";
import GameSettingFields from "./GameSettingFields.svelte";
import { filter } from "d3";
import { coverNotFound } from "../../libs/ui";
import { userboxFileProcess, ddsDB, initializeDb } from "../../libs/userbox/userbox"
@@ -23,6 +21,8 @@
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
import { DDS } from "../../libs/userbox/dds";
import ChuniMatchingSettings from "./ChuniMatchingSettings.svelte";
import InputField from "../ui/InputField.svelte";
let user: AquaNetUser
let [loading, error, submitting, preview] = [true, "", "", ""]
@@ -31,12 +31,13 @@
// Available (unlocked) options for each kind of item
// In allItems: 'namePlate', 'frame', 'trophy', 'mapIcon', 'systemVoice', 'avatarAccessory'
let allItems: Record<string, Record<string, { name: string }>> = {}
let iKinds = { namePlate: 1, frame: 2, trophy: 3, mapIcon: 8, systemVoice: 9, avatarAccessory: 11 }
let iKinds = { namePlate: 1, frame: 2, trophy: 3, trophySub1: 4, trophySub2: 5, mapIcon: 8, systemVoice: 9, avatarAccessory: 11 }
// In userbox: 'nameplateId', 'frameId', 'trophyId', 'mapIconId', 'voiceId', 'avatar{Wear/Head/Face/Skin/Item/Front/Back}'
let userbox: UserBox
let avatarKinds = ['Wear', 'Head', 'Face', 'Skin', 'Item', 'Front', 'Back'] as const
// iKey should match allItems keys, and ubKey should match userbox keys
let userItems: { iKey: string, ubKey: keyof UserBox, items: UserItem[] }[] = []
let userNameField: any
// Submit changes
function submit(field: keyof UserBox) {
@@ -58,13 +59,18 @@
})
if (!profile) return
userbox = profile.user
userNameField = {key: "gameUsername", value: userbox.userName, type: "String"}
userItems = Object.entries(iKinds).flatMap(([iKey, iKind]) => {
if (iKey != 'avatarAccessory') {
let ubKey = `${iKey}Id`
if (iKey.slice('trophy'.length, 'trophy'.length + 3) == "Sub") {
ubKey = `trophyIdSub${iKey.slice('trophySub'.length, 'trophySub'.length + 1)}`;
iKey = `trophy`;
}
if (ubKey == 'namePlateId') ubKey = 'nameplateId'
if (ubKey == 'systemVoiceId') ubKey = 'voiceId'
return [{ iKey, ubKey: ubKey as keyof UserBox,
items: profile.items.filter(x => x.itemKind === iKind)
items: profile.items.filter(x => x.itemKind === iKind || (iKey == "trophy" && x.itemKind == 3))
}]
}
@@ -91,14 +97,170 @@
user = u
return fetchData()
}).catch((e) => { loading = false; error = e.message });
function exportData() {
submitting = "export"
GAME.export('chu3')
.then(data => download(JSON.stringify(data), `AquaDX_chu3_export_${userbox.userName}.json`))
.catch(e => error = e.message)
.finally(() => submitting = "")
}
async function exportBatchManual() {
submitting = "batchExport"
const DIFFICULTY_MAP: Record<number, string> = {
0: "BASIC",
1: "ADVANCED",
2: "EXPERT",
3: "MASTER",
4: "ULTIMA"
} as const // WORLD'S END scores not supported by Tachi
const DAN_MAP: Record<number, string> = {
1: "DAN_I",
2: "DAN_II",
3: "DAN_III",
4: "DAN_IV",
5: "DAN_V",
6: "DAN_INFINITE"
} as const
const SKILL_IDS: Record<number, string> = {
100009: 'CATASTROPHY',
102009: 'CATASTROPHY',
103007: 'CATASTROPHY',
100008: 'ABSOLUTE',
101008: 'ABSOLUTE',
102008: 'ABSOLUTE',
103006: 'ABSOLUTE',
100007: 'BRAVE',
101007: 'BRAVE',
102007: 'BRAVE',
103005: 'BRAVE',
100005: 'HARD',
100006: 'HARD',
101004: 'HARD',
101005: 'HARD',
101006: 'HARD',
102004: 'HARD',
102005: 'HARD',
102006: 'HARD',
103002: 'HARD',
103003: 'HARD',
103004: 'HARD'
} as const
// Shamelessly stolen from https://github.com/beer-psi/saekawa/commit/b3bee13e126df2f4e2a449bdf971debb8c95ba40, needs to be updated every major version :(
let data: any
let output: any = {
"meta": {
"game": "chunithm",
"playtype": "Single",
"service": "AquaDX-Manual"
},
"scores": [],
"classes": {}
}
try {
data = await GAME.export('chu3')
}
catch (e) {
error = e.message
submitting = ""
return
}
if (data && "userPlaylogList" in data) {
for (let score of data.userPlaylogList) {
let clearLamp = null
let noteLamp = null
if (score.level in DIFFICULTY_MAP) {
if (score.isClear) {
clearLamp = score.skillId in SKILL_IDS ? SKILL_IDS[score.skillId] : "CLEAR"
}
else {
clearLamp = "FAILED"
}
if (score.score === 1010000) {
noteLamp = "ALL JUSTICE CRITICAL"
}
else if (score.isAllJustice) {
noteLamp = "ALL JUSTICE"
}
else if (score.isFullCombo) {
noteLamp = "FULL COMBO"
}
else {
noteLamp = "NONE"
}
output.scores.push({
"score": score.score,
"clearLamp": clearLamp,
"noteLamp": noteLamp,
"judgements": {
"jcrit": score.judgeHeaven + score.judgeCritical,
"justice": score.judgeJustice,
"attack": score.judgeAttack,
"miss": score.judgeGuilty
},
"matchType": "inGameID",
"identifier": score.musicId.toString(),
"difficulty": DIFFICULTY_MAP[score.level],
"timeAchieved": score.sortNumber * 1000,
"optional": {
"maxCombo": score.maxCombo
}
})
}
}
}
if (data.userData.classEmblemMedal in DAN_MAP) {
output.classes["dan"] = DAN_MAP[data.userData.classEmblemMedal]
}
if (data.userData.classEmblemBase in DAN_MAP) {
output.classes["emblem"] = DAN_MAP[data.userData.classEmblemBase]
}
download(JSON.stringify(output), `AquaDX_chu3_BatchManualExport_${userbox.userName}.json`)
submitting = ""
}
function download(data: string, filename: string) {
const blob = new Blob([data]);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
}
function g(v: string) {
if (v != ("\x63\x68\x75\x6E\x69\x74\x68\x6D ").repeat(3).trim()) return;
const t = v.substring(5, 6) + v.substring(1, 2) + "eme";
if (!localStorage.getItem(t)) {
localStorage.setItem(t, v.substring(0, 1) + "\x6E");
} else
localStorage.removeItem(t);
setTimeout(location.reload, 1000); // ?
}
let DDSreader: DDS | undefined;
let USERBOX_PROGRESS = 0;
let USERBOX_SETUP_RUN = false;
let USERBOX_SETUP_MODE = false;
let USERBOX_SETUP_TEXT = t("userbox.new.setup");
let USERBOX_ENABLED = useLocalStorage("userboxNew", false);
let USERBOX_PROFILE_ENABLED = useLocalStorage("userboxNewProfile", false);
let USERBOX_INSTALLED = false;
let USERBOX_SUPPORT = "webkitGetAsEntry" in DataTransferItem.prototype;
@@ -116,12 +278,37 @@
}) ?? "";
}
let USERBOX_URL_STATE = useLocalStorage("userboxURL", USERBOX_DEFAULT_URL);
function userboxHandleInput(baseURL: string, isSetByServer: boolean = false) {
if (baseURL != "")
try {
// validate url
new URL(baseURL, location.href);
} catch(err) {
if (isSetByServer)
return;
return error = t("userbox.new.error.invalidUrl")
}
USERBOX_URL_STATE.value = baseURL;
USERBOX_ENABLED.value = true;
USERBOX_PROFILE_ENABLED.value = true;
location.reload();
}
if (USERBOX_DEFAULT_URL && !USERBOX_URL_STATE.value)
userboxHandleInput(USERBOX_DEFAULT_URL, true);
indexedDB.databases().then(async (dbi) => {
let databaseExists = dbi.some(db => db.name == "userboxChusanDDS");
if (USERBOX_URL_STATE.value && databaseExists) {
indexedDB.deleteDatabase("userboxChusanDDS")
}
if (databaseExists) {
await initializeDb();
}
if (databaseExists || USERBOX_URL_STATE.value) {
DDSreader = new DDS(ddsDB);
USERBOX_INSTALLED = databaseExists;
USERBOX_INSTALLED = databaseExists || USERBOX_URL_STATE.value != "";
}
})
@@ -131,7 +318,12 @@
{#if !loading && !error}
<div out:fade={FADE_OUT} in:fade={FADE_IN}>
<h2>{t("userbox.header.general")}</h2>
<GameSettingFields game="chu3"/>
<div class="general-options">
<GameSettingFields game="chu3"/>
<InputField bind:field={userNameField}
callback={() => USERBOX.setUserBox({ field: "userName", value: userNameField.value })}/>
</div>
<h2>{t("userbox.header.userbox")}</h2>
{#if !USERBOX_ENABLED.value || !USERBOX_INSTALLED}
<div class="fields">
@@ -155,10 +347,10 @@
</div>
{:else}
<div class="chuni-userbox-container">
<ChuniUserplateComponent on:click={() => userboxSelected = "nameplateId"} chuniCharacter={userbox.characterId} chuniLevel={userbox.level} chuniRating={userbox.playerRating / 100}
<ChuniUserplateComponent chuniIsUserbox={true} on:click={() => userboxSelected = "nameplateId"} chuniCharacter={userbox.characterId} chuniLevel={userbox.level.toString()} chuniRating={userbox.playerRating / 100}
chuniNameplate={userbox.nameplateId} chuniName={userbox.userName} chuniTrophyName={allItems.trophy[userbox.trophyId].name}></ChuniUserplateComponent>
<ChuniPenguinComponent classPassthrough="chuni-penguin-float" chuniWear={userbox.avatarWear} chuniHead={userbox.avatarHead} chuniBack={userbox.avatarBack}
chuniFront={userbox.avatarFront} chuniFace={userbox.avatarFace} chuniItem={userbox.avatarItem}
<ChuniPenguinComponent chuniWear={userbox.avatarWear} chuniHead={userbox.avatarHead} chuniBack={userbox.avatarBack}
chuniFront={userbox.avatarFront} chuniFace={userbox.avatarFace} chuniItem={userbox.avatarItem}
chuniSkin={userbox.avatarSkin}></ChuniPenguinComponent>
</div>
<div class="chuni-userbox-row">
@@ -209,59 +401,70 @@
{/each}
</div>
{/if}
{#if HAS_USERBOX_ASSETS}
{#if USERBOX_INSTALLED}
<!-- god this is a mess but idgaf atp -->
<div class="field boolean" style:margin-top="1em">
<input type="checkbox" bind:checked={USERBOX_ENABLED.value} id="newUserbox">
<label for="newUserbox">
<span class="name">{t("userbox.new.activate")}</span>
<span class="desc">{t(`userbox.new.activate_desc`)}</span>
</label>
</div>
{/if}
{#if USERBOX_SUPPORT}
<p>
<button on:click={() => USERBOX_SETUP_RUN = !USERBOX_SETUP_RUN}>{t(!USERBOX_INSTALLED ? `userbox.new.activate_first` : `userbox.new.activate_update`)}</button>
</p>
{/if}
{#if !USERBOX_SUPPORT || !USERBOX_INSTALLED || !USERBOX_ENABLED.value}
<h2>{t("userbox.header.preview")}</h2>
<p class="notice">{t("userbox.preview.notice")}</p>
<input bind:value={preview} placeholder={t("userbox.preview.url")}/>
{#if preview}
<div class="preview">
{#each userItems.filter(v => v.iKey != 'trophy' && v.iKey != 'systemVoice') as { iKey, ubKey, items }, i}
<div>
<span>{ts(`userbox.${ubKey}`)}</span>
<img src={`${preview}/${iKey}/${userbox[ubKey].toString().padStart(8, '0')}.png`} alt="" on:error={coverNotFound} />
</div>
{/each}
</div>
{/if}
{/if}
{#if USERBOX_INSTALLED}
<!-- god this is a mess but idgaf atp -->
<div class="field boolean" style:margin-top="1em">
<input type="checkbox" bind:checked={USERBOX_ENABLED.value} id="newUserbox">
<label for="newUserbox">
<span class="name">{t("userbox.new.activate")}</span>
<span class="desc">{t(`userbox.new.activate_desc`)}</span>
</label>
</div>
<div class="field boolean" style:margin-top="1em">
<input type="checkbox" bind:checked={USERBOX_PROFILE_ENABLED.value} id="newUserboxProfile">
<label for="newUserboxProfile">
<span class="name">{t("userbox.new.activate_profile")}</span>
<span class="desc">{t(`userbox.new.activate_profile_desc`)}</span>
</label>
</div>
{/if}
{#if USERBOX_SUPPORT && !USERBOX_DEFAULT_URL}
<p>
<button on:click={() => USERBOX_SETUP_RUN = !USERBOX_SETUP_RUN}>{t(!USERBOX_INSTALLED ? `userbox.new.activate_first` : `userbox.new.activate_update`)}</button>
</p>
{/if}
<ChuniMatchingSettings/><br>
<button class="exportButton" on:click={exportData}>
<Icon icon="bxs:file-export"/>
{t('settings.export')}
</button>
<button class="exportBatchManualButton" on:click={exportBatchManual}>
<Icon icon="bxs:file-export"/>
{t('settings.batchManualExport')}
</button>
</div>
{/if}
{#if USERBOX_SETUP_RUN && !error}
<div class="overlay" transition:fade>
<div>
<h2>{t('userbox.new.name')}</h2>
<span>{USERBOX_SETUP_TEXT}</span>
<span>{USERBOX_SETUP_MODE ? t('userbox.new.url_warning') : USERBOX_SETUP_TEXT}</span>
<div class="actions">
{#if USERBOX_PROGRESS != 0}
<div class="progress">
<div class="progress-bar" style="width: {USERBOX_PROGRESS}%"></div>
</div>
{#if USERBOX_SETUP_MODE}
<input type="text" on:keyup={e => {if (e.key == "Enter") { userboxHandleInput((e.target as HTMLInputElement).value) } else g(e.currentTarget.value)}} class="add-margin" placeholder="Base URL">
{:else}
<button class="drop-btn">
<input type="file" on:input={userboxSafeDrop} on:click={e => e.preventDefault()}>
{t('userbox.new.drop')}
</button>
<button on:click={() => USERBOX_SETUP_RUN = false}>
{t('back')}
</button>
{#if USERBOX_PROGRESS != 0}
<div class="progress">
<div class="progress-bar" style="width: {USERBOX_PROGRESS}%"></div>
</div>
{:else}
<p class="notice add-margin">
{t('userbox.new.setup.notice')}
</p>
<button class="drop-btn">
<input type="file" on:input={userboxSafeDrop} on:click={e => e.preventDefault()}>
{t('userbox.new.drop')}
</button>
{/if}
{/if}
{#if USERBOX_PROGRESS == 0}
<button on:click={() => USERBOX_SETUP_RUN = false}>
{t('back')}
</button>
<button on:click={() => USERBOX_SETUP_MODE = !USERBOX_SETUP_MODE}>
{t(USERBOX_SETUP_MODE ? 'userbox.new.switch.to_drop' : 'userbox.new.switch.to_url')}
</button>
{/if}
</div>
</div>
@@ -278,11 +481,17 @@ input
h2
margin-bottom: 0.5rem
.general-options
display: flex
flex-direction: column
flex-wrap: wrap
gap: 12px
p.notice
opacity: 0.6
margin-top: 0
.progress
.progress
width: 100%
height: 10px
box-shadow: 0 0 1px 1px vars.$ov-lighter
@@ -296,13 +505,15 @@ p.notice
border-radius: 25px
.add-margin, .drop-btn
margin-bottom: 1em
.drop-btn
position: relative
width: 100%
aspect-ratio: 3
background: transparent
box-shadow: 0 0 1px 1px vars.$ov-lighter
margin-bottom: 1em
> input
position: absolute
@@ -415,10 +626,10 @@ p.notice
&.focused
filter: brightness(75%)
.chuni-userbox
.chuni-userbox
width: calc(100% - 20px)
height: 350px
display: flex
flex-direction: row
flex-wrap: wrap

View File

@@ -5,6 +5,7 @@
import { ts } from "../../libs/i18n";
import StatusOverlays from "../StatusOverlays.svelte";
import InputWithButton from "../ui/InputWithButton.svelte";
import InputField from "../ui/InputField.svelte";
export let game: string;
let gameFields: GameOption[] = []
@@ -26,23 +27,7 @@
<div class="fields">
{#each gameFields as field}
<div class="field {field.type.toLowerCase()}">
{#if field.type === "Boolean"}
<input id={field.key} type="checkbox" bind:checked={field.value}
on:change={() => submitGameOption(field.key, field.value)}/>
<label for={field.key}>
<span class="name">{ts(`settings.fields.${field.key}.name`)}</span>
<span class="desc">{ts(`settings.fields.${field.key}.desc`)}</span>
</label>
{/if}
{#if field.type === "String"}
<label for={field.key}>
<span class="name">{ts(`settings.fields.${field.key}.name`)}</span>
<span class="desc">{ts(`settings.fields.${field.key}.desc`)}</span>
</label>
<InputWithButton bind:field={field} callback={() => submitGameOption(field.key, field.value)}/>
{/if}
</div>
<InputField field={field} callback={() => submitGameOption(field.key, field.value)}/>
{/each}
</div>
@@ -53,24 +38,4 @@
display: flex
flex-direction: column
gap: 12px
.field.string
flex-direction: column
align-items: flex-start
gap: 0.5rem
.field.boolean
align-items: center
gap: 1rem
.field
display: flex
label
display: flex
flex-direction: column
max-width: max-content
.desc
opacity: 0.6
</style>

View File

@@ -1,15 +1,17 @@
<script>
import { fade } from "svelte/transition";
import { FADE_IN, FADE_OUT } from "../../libs/config";
import GameSettingFields from "./GameSettingFields.svelte";
import { ts } from "../../libs/i18n";
import { t, ts } from "../../libs/i18n";
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
import RegionSelector from "./RegionSelector.svelte";
const rounding = useLocalStorage("rounding", true);
</script>
<div out:fade={FADE_OUT} in:fade={FADE_IN} class="fields">
<GameSettingFields game="general"/>
<blockquote>
{ts("settings.gameNotice")}
</blockquote>
<div class="field">
<div class="bool">
<input id="rounding" type="checkbox" bind:checked={rounding.value}/>
@@ -19,9 +21,16 @@
</label>
</div>
</div>
<div class="divider"></div>
<blockquote>
{ts("settings.regionNotice")}
</blockquote>
<RegionSelector/>
</div>
<style lang="sass">
@use "../../vars"
.fields
display: flex
flex-direction: column
@@ -39,19 +48,10 @@
.desc
opacity: 0.6
.field
display: flex
flex-direction: column
label
max-width: max-content
> div:not(.bool)
display: flex
align-items: center
gap: 1rem
margin-top: 0.5rem
> input
flex: 1
.divider
width: 100%
height: 0.5px
background: white
opacity: 0.2
margin: 0.4rem 0
</style>

View File

@@ -5,6 +5,8 @@
import Icon from "@iconify/svelte";
import StatusOverlays from "../StatusOverlays.svelte";
import { GAME } from "../../libs/sdk";
import GameSettingFields from "./GameSettingFields.svelte";
import { download } from "../../libs/ui";
const profileFields = [
['name', t('settings.mai2.name')],
@@ -41,15 +43,6 @@
.catch(e => error = e.message)
.finally(() => submitting = "")
}
function download(data: string, filename: string) {
const blob = new Blob([data]);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
}
</script>
<div class="fields" out:fade={FADE_OUT} in:fade={FADE_IN}>
@@ -72,6 +65,7 @@
</div>
</div>
{/each}
<GameSettingFields game="mai2"/>
<button class="exportButton" on:click={exportData}>
<Icon icon="bxs:file-export"/>
{t('settings.export')}

View File

@@ -0,0 +1,9 @@
<script>
import { fade } from "svelte/transition";
import { FADE_IN, FADE_OUT } from "../../libs/config";
import GameSettingFields from "./GameSettingFields.svelte";
</script>
<div out:fade={FADE_OUT} in:fade={FADE_IN}>
<GameSettingFields game="ongeki"/>
</div>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { USER} from "../../libs/sdk";
import { ts } from "../../libs/i18n";
import StatusOverlays from "../StatusOverlays.svelte";
let regionId = $state(0);
let submitting = ""
let error: string;
const prefectures = ["None","Aichi","Aomori","Akita","Ishikawa","Ibaraki","Iwate","Ehime","Oita","Osaka","Okayama","Okinawa","Kagawa","Kagoshima","Kanagawa","Gifu","Kyoto","Kumamoto","Gunma","Kochi","Saitama","Saga","Shiga","Shizuoka","Shimane","Chiba","Tokyo","Tokushima","Tochigi","Tottori","Toyama","Nagasaki","Nagano","Nara","Niigata","Hyogo","Hiroshima","Fukui","Fukuoka","Fukushima","Hokkaido","Mie","Miyagi","Miyazaki","Yamagata","Yamaguchi","Yamanashi","Wakayama"]
USER.me().then(user => {
const parsedRegion = parseInt(user.region);
if (!isNaN(parsedRegion) && parsedRegion > 0) {
regionId = parsedRegion - 1;
} else {
regionId = 0;
}
})
async function saveNewRegion() {
if (submitting) return false
submitting = "region"
await USER.changeRegion(regionId+1).catch(e => error = e.message).finally(() => submitting = "")
return true
}
</script>
<div class="fields">
<label for="rounding">
<span class="name">{ts(`settings.regionSelector.title`)}</span>
<span class="desc">{ts(`settings.regionSelector.desc`)}</span>
</label>
<select bind:value={regionId} on:change={saveNewRegion}>
<option value={0} disabled selected>{ts("settings.regionSelector.select")}</option>
{#each prefectures.slice(1) as prefecture, index}
<option value={index}>{prefecture}</option>
{/each}
</select>
</div>
<StatusOverlays {error} loading={!!submitting}/>
<style lang="sass">
@use "../../vars"
.fields
display: flex
flex-direction: column
gap: 12px
label
display: flex
flex-direction: column
.desc
opacity: 0.6
</style>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { removeImg } from "../../../libs/ui";
import { DDS } from "../../../libs/userbox/dds"
import { ddsDB } from "../../../libs/userbox/userbox"
@@ -11,74 +12,93 @@
export var chuniItem = 1500001;
export var chuniFront = 1600001;
export var chuniBack = 1700001;
export var classPassthrough: string = ``
export var classPassthrough: string = ``;
</script>
<div class="chuni-penguin {classPassthrough}">
<div class="chuni-penguin-body">
<!-- Body -->
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniSkin.toString().padStart(8, "0")}`, 0, 0, 256, 400, 0.75) then imageURL}
<img class="chuni-penguin-skin" src={imageURL} alt="Body">
<img class="chuni-penguin-skin" src={imageURL} alt="Body" on:error={removeImg}>
{/await}
<!-- Face -->
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_face_00.dds", 0, 0, 225, 150, 0.75) then imageURL}
<img class="chuni-penguin-eyes chuni-penguin-accessory" src={imageURL} alt="Eyes">
<img class="chuni-penguin-eyes chuni-penguin-accessory" src={imageURL} alt="Eyes" on:error={removeImg}>
{/await}
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 86, 103, 96, 43, 0.75) then imageURL}
<img class="chuni-penguin-beak chuni-penguin-accessory" src={imageURL} alt="Beak">
{/await}
<!-- Arms (surfboard) -->
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 0, 0, 85, 160, 0.75) then imageURL}
<img class="chuni-penguin-arm-left chuni-penguin-arm" src={imageURL} alt="Left Arm">
{/await}
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 0, 0, 85, 160, 0.75) then imageURL}
<img class="chuni-penguin-arm-right chuni-penguin-arm" src={imageURL} alt="Right Arm">
<img class="chuni-penguin-beak chuni-penguin-accessory" src={imageURL} alt="Beak" on:error={removeImg}>
{/await}
{#if chuniItem != 1500001}
<!-- Arms (straight) -->
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 0, 0, 85, 160, 0.75) then imageURL}
<img class="chuni-penguin-arm-left chuni-penguin-arm" src={imageURL} alt="Left Arm" on:error={removeImg}/>
<div class="chuni-penguin-arm-left chuni-penguin-arm-type-1 chuni-penguin-arm">
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniItem.toString().padStart(8, "0")}`, 0, 0, 200, 544, 0.75) then imageURL}
<img class="chuni-penguin-item chuni-penguin-accessory chuni-penguin-item-left" src={imageURL} alt="Item" on:error={removeImg}>
{/await}
</div>
{/await}
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 0, 0, 85, 160, 0.75) then imageURL}
<img class="chuni-penguin-arm-right chuni-penguin-arm" src={imageURL} alt="Right Arm" on:error={removeImg}>
<div class="chuni-penguin-arm-right chuni-penguin-arm-type-1 chuni-penguin-arm">
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniItem.toString().padStart(8, "0")}`, 200, 0, 200, 544, 0.75) then imageURL}
<img class="chuni-penguin-item chuni-penguin-accessory chuni-penguin-item-right" src={imageURL} alt="Item" on:error={removeImg}>
{/await}
</div>
{/await}
{:else}
<!-- Arms (bent) -->
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 80, 0, 110, 100, 0.75) then imageURL}
<img class="chuni-penguin-arm-left chuni-penguin-arm chuni-penguin-arm-type-2" src={imageURL} alt="Left Arm" on:error={removeImg}>
{/await}
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 80, 0, 110, 100, 0.75) then imageURL}
<img class="chuni-penguin-arm-right chuni-penguin-arm chuni-penguin-arm-type-2" src={imageURL} alt="Right Arm" on:error={removeImg}>
{/await}
{/if}
<!-- Wear -->
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniWear.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01100001`) then imageURL}
<img class="chuni-penguin-wear chuni-penguin-accessory" src={imageURL} alt="Wear">
<img class="chuni-penguin-wear chuni-penguin-accessory" src={imageURL} alt="Wear" on:error={removeImg}>
{/await}
<!-- Head -->
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniHead.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01200001`) then imageURL}
<img class="chuni-penguin-head chuni-penguin-accessory" src={imageURL} alt="Head">
<img class="chuni-penguin-head chuni-penguin-accessory" src={imageURL} alt="Head" on:error={removeImg}>
{/await}
{#if chuniHead == 1200001}
<!-- If wearing original hat, add the feather and attachment -->
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 104, 153, 57, 58, 0.75) then imageURL}
<img class="chuni-penguin-head-2 chuni-penguin-accessory" src={imageURL} alt="Head2">
{/await}
<!-- If wearing original hat, add the feather -->
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 5, 160, 100, 150, 0.75) then imageURL}
<img class="chuni-penguin-head-3 chuni-penguin-accessory" src={imageURL} alt="Head3">
<img class="chuni-penguin-head-3 chuni-penguin-accessory" src={imageURL} alt="Head3" on:error={removeImg}>
{/await}
{/if}
<!-- Oops, I realized just now that the thing on it's forehead applies to all hats. My mistake! -->
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 105, 153, 56, 58, 0.75) then imageURL}
<img class="chuni-penguin-head-2 chuni-penguin-accessory" src={imageURL} alt="Head2" on:error={removeImg}>
{/await}
<!-- Face (Accessory) -->
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniFace.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01300001`) then imageURL}
<img class="chuni-penguin-face-accessory chuni-penguin-accessory" src={imageURL} alt="Face (Accessory)">
{/await}
<!-- Item -->
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniItem.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01500001`) then imageURL}
<img class="chuni-penguin-item chuni-penguin-accessory" src={imageURL} alt="Item">
<img class="chuni-penguin-face-accessory chuni-penguin-accessory" src={imageURL} alt="Face (Accessory)" on:error={removeImg}>
{/await}
<!-- Front -->
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniFront.toString().padStart(8, "0")}`, 0.75) then imageURL}
<img class="chuni-penguin-front chuni-penguin-accessory" src={imageURL} alt="Front">
<img class="chuni-penguin-front chuni-penguin-accessory" src={imageURL} alt="Front" on:error={removeImg}>
{/await}
<!-- Back -->
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniBack.toString().padStart(8, "0")}`, 0.75) then imageURL}
<img class="chuni-penguin-back chuni-penguin-accessory" src={imageURL} alt="Back">
<img class="chuni-penguin-back chuni-penguin-accessory" src={imageURL} alt="Back" on:error={removeImg}>
{/await}
</div>
<div class="chuni-penguin-feet">
<!-- Feet -->
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniSkin.toString().padStart(8, "0")}`, 0, 410, 167, 80, 0.75) then imageURL}
<img src={imageURL} alt="Feet">
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniSkin.toString().padStart(8, "0")}`, 0, 410, 85, 80, 0.75) then imageURL}
<img src={imageURL} alt="Foot" on:error={removeImg}>
{/await}
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniSkin.toString().padStart(8, "0")}`, 85, 410, 85, 80, 0.75) then imageURL}
<img src={imageURL} alt="Foot" on:error={removeImg}>
{/await}
</div>
</div>
@@ -86,11 +106,11 @@
<style lang="sass">
@keyframes chuniPenguinBodyBob
0%
transform: translate(-50%, 0%) translate(0%, -50%)
transform: translate(-50%, 5px) translate(0%, -50%)
50%
transform: translate(-50%, 10px) translate(0%, -50%)
100%
transform: translate(-50%, 0%) translate(0%, -50%)
100%
transform: translate(-50%, 5px) translate(0%, -50%)
@keyframes chuniPenguinArmLeft
0%
transform: translate(-50%, 0) rotate(-2deg)
@@ -108,35 +128,67 @@
img
-webkit-user-drag: none
user-select: none
.chuni-penguin
height: 512px
aspect-ratio: 1/2
position: relative
pointer-events: none
z-index: 1
&.chuni-penguin-float
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
.chuni-penguin-body, .chuni-penguin-feet
transform: translate(-50%, -50%)
position: absolute
left: 50%
.chuni-penguin-body
top: 50%
z-index: 1
animation: chuniPenguinBodyBob 2s infinite cubic-bezier(0.45, 0, 0.55, 1)
animation: chuniPenguinBodyBob 1s infinite cubic-bezier(0.45, 0, 0.55, 1)
.chuni-penguin-feet
top: 82.5%
top: 80%
z-index: 0
width: 175px
display: flex
justify-content: center
img
margin-left: auto
margin-right: auto
.chuni-penguin-arm
transform-origin: 95% 10%
transform-origin: 90% 10%
position: absolute
top: 40%
.chuni-penguin-arm-left
left: 0%
animation: chuniPenguinArmLeft 1.5s infinite cubic-bezier(0.45, 0, 0.55, 1)
.chuni-penguin-arm-right
left: 70%
animation: chuniPenguinArmRight 1.5s infinite cubic-bezier(0.45, 0, 0.55, 1)
z-index: 0
&.chuni-penguin-arm-type-1
width: calc(85px * 0.75)
height: calc(160px * 0.75)
z-index: 2
&.chuni-penguin-arm-type-2
transform-origin: 40% 10%
z-index: 2
&.chuni-penguin-arm-left
left: 0%
transform: translate(-50%, 0)
animation: chuniPenguinArmLeft 1s infinite cubic-bezier(0.45, 0, 0.55, 1)
&.chuni-penguin-arm-type-2
left: 15%
&.chuni-penguin-arm-right
left: 72.5%
transform: translate(-50%, 0) scaleX(-1)
animation: chuniPenguinArmRight 1s infinite cubic-bezier(0.45, 0, 0.55, 1)
&.chuni-penguin-arm-type-2
left: 95%
.chuni-penguin-accessory
transform: translate(-50%, -50%)
@@ -144,22 +196,32 @@
top: 50%
left: 50%
.chuni-penguin-item
z-index: 1
top: 25%
left: 0
&.chuni-penguin-item-left
transform: translate(-50%, -50%) rotate(-15deg)
&.chuni-penguin-item-right
transform: translate(-50%, -50%) scaleX(-1) rotate(15deg)
.chuni-penguin-eyes
top: 22.5%
.chuni-penguin-beak
top: 29.5%
.chuni-penguin-wear
top: 57.5%
top: 60%
.chuni-penguin-head
top: 7.5%
z-index: 10
.chuni-penguin-head-2
top: 12.5%
top: 13.5%
.chuni-penguin-head-3
top: -12.5%
.chuni-penguin-face-accessory
top: 27.5%
.chuni-penguin-back
z-index: -1
</style>
</style>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import { initializeDb } from "../../../libs/userbox/userbox"
import ChuniPenguinComponent from "./ChuniPenguin.svelte"
import ChuniUserplateComponent from "./ChuniUserplate.svelte"
import { type UserBox } from "../../../libs/generalTypes"
import { DATA, USERBOX } from "../../../libs/sdk"
import useLocalStorage from "../../../libs/hooks/useLocalStorage.svelte"
import { t } from "../../../libs/i18n"
/**
* This is a UserBox viewer on the Profile page (UserHome), added by raymond
* to view other user's penguins on their profile.
*/
export let game: string
export let username: string
export let error: string = ""
let USERBOX_ACTIVE = useLocalStorage("userboxNewProfile", false)
let USERBOX_INSTALLED = false
let userbox: UserBox
let allItems: Record<string, Record<string, { name: string }>> = {}
if (game == "chu3" && USERBOX_ACTIVE.value) {
indexedDB.databases().then(async (dbi) => {
let databaseExists = dbi.some(db => db.name == "userboxChusanDDS")
if (databaseExists) {
await initializeDb()
const profile = await USERBOX.getUserProfile(username).catch(_ => null)
if (!profile) return
userbox = profile
console.log(userbox)
allItems = await DATA.allItems('chu3').catch(_ => {
error = t("userbox.error.nodata")
}) as typeof allItems
USERBOX_INSTALLED = databaseExists
}
})
}
</script>
{#if USERBOX_ACTIVE.value && USERBOX_INSTALLED && game == "chu3"}
<div class="chuni-userbox-container">
<ChuniUserplateComponent chuniCharacter={userbox.characterId} chuniRating={userbox.playerRating / 100} chuniLevel={userbox.level.toString()}
chuniNameplate={userbox.nameplateId} chuniName={userbox.userName} chuniTrophyName={allItems.trophy[userbox.trophyId].name}></ChuniUserplateComponent>
<div class="chuni-penguin-container">
<ChuniPenguinComponent classPassthrough="chuni-penguin-float" chuniWear={userbox.avatarWear} chuniHead={userbox.avatarHead} chuniBack={userbox.avatarBack}
chuniFront={userbox.avatarFront} chuniFace={userbox.avatarFace} chuniItem={userbox.avatarItem}
chuniSkin={userbox.avatarSkin}></ChuniPenguinComponent>
</div>
</div>
{/if}
<style lang="sass">
.chuni-userbox-container
display: flex
align-items: center
justify-content: center
.chuni-penguin-container
height: 256px
aspect-ratio: 1
position: relative
@media (max-width: 1000px)
.chuni-userbox-container
flex-wrap: wrap
</style>

View File

@@ -1,28 +1,53 @@
<script lang="ts">
import { DDS } from "../../../libs/userbox/dds"
import { DDS, type RGB } from "../../../libs/userbox/dds"
import { ddsDB } from "../../../libs/userbox/userbox"
const DDSreader = new DDS(ddsDB);
export var chuniLevel: number = 1
export var chuniLevel: string = ""
export var chuniName: string = "AquaDX"
export var chuniRating: number = 1.23
export var chuniNameplate: number = 1
export var chuniCharacter: number = 0
export var chuniTrophyName: string = "NEWCOMER"
export var chuniIsUserbox: boolean = false;
let ratingToString = (rating: number) => {
return rating.toFixed(2)
}
interface RatingRange {
min: number,
offset: number,
color?: RGB
};
// https://en.wikipedia.org/wiki/Chunithm#Rating
const ratingColors: RatingRange[] = ([
{min: 0.00, offset: 4, color: {r: 0, g: 191, b: 64}},
{min: 4.00, offset: 4, color: {r: 255, g: 111, b: 0}},
{min: 7.00, offset: 4, color: {r: 255, g: 64, b: 64}},
{min: 10.00, offset: 4, color: {r: 147, g: 38, b: 255}},
{min: 12.00, offset: 3},
{min: 13.25, offset: 2},
{min: 14.50, offset: 1},
{min: 15.25, offset: 0},
{min: 16.00, offset: 5}
]).filter(f => f.min <= chuniRating);
const ratingDigitOrder = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "."]
const ratingColorData = (ratingColors[ratingColors.length - 1] ?? ratingColors[0]);
</script>
{#await DDSreader?.getFile(`nameplate:${chuniNameplate.toString().padStart(8, "0")}`) then nameplateURL}
{#await DDSreader?.getFile(`nameplate:${chuniNameplate.toString().padStart(8, "0")}`, `nameplate:00000001`) then nameplateURL}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div on:click class="chuni-nameplate" style:background={`url(${nameplateURL})`}>
{#await DDSreader?.getFile(`characterThumbnail:${chuniCharacter.toString().padStart(6, "0")}`) then characterThumbnailURL}
<div on:click class="chuni-nameplate" class:chuni-nameplate-clickable={chuniIsUserbox} style:background={`url(${nameplateURL})`}>
{#await DDSreader?.getFile(`characterThumbnail:${chuniCharacter.toString().padStart(6, "0")}`, `characterThumbnail:000000`) then characterThumbnailURL}
<img class="chuni-character" src={characterThumbnailURL} alt="Character">
{/await}
{#await DDSreader?.getFileFromSheet("surfboard:CHU_UI_title_rank_00_v10.dds", 5, 5 + (75 * 2), 595, 64) then trophyURL}
<div class="chuni-trophy">
<div class="chuni-trophy" title={chuniTrophyName}>
{chuniTrophyName}
<img src={trophyURL} class="chuni-trophy-bg" alt="Trophy">
</div>
<img src={trophyURL} class="chuni-trophy-bg" alt="Trophy" title={chuniTrophyName}>
{/await}
<div class="chuni-user-info">
<div class="chuni-user-name">
@@ -36,28 +61,49 @@
{chuniName}
</span>
</div>
<div class="chuni-user-rating">
RATING
<span class="chuni-user-rating-number">
{chuniRating}
</span>
<div class={`chuni-user-rating color-${ratingColorData.color}`}>
{#await DDSreader?.getFileFromSheet("surfboard:CHU_UI_Common_01_v11.dds", 485, 5 + (28 * ratingColorData.offset), 62, 15, undefined, ratingColorData.color) then url}
{#if url}
<img src={url} alt="Rating">
<span class="chuni-user-rating-number">
{#each ratingToString(chuniRating).split("") as digit}
{#await DDSreader?.getFileFromSheet("surfboard:CHU_UI_Common_01_v11.dds", 552 + (24 * (ratingDigitOrder.indexOf(digit) ?? 0)), 1 + (28 * ratingColorData.offset), 16, 20, undefined, ratingColorData.color) then url}
<img src={url} alt="Rating Digit">
{/await}
{/each}
</span>
{:else}
RATING
<span class="chuni-user-rating-number">
{ratingToString(chuniRating)}
</span>
{/if}
{/await}
</div>
</div>
</div>
{/await}
<style lang="sass">
@use "../../../vars"
@font-face
font-family: "Gothic A1"
src: url("/assets/fonts/GothicA1.woff2")
.chuni-nameplate
width: 576px
height: 228px
position: relative
font-size: 16px
/* Overlap penguin avatar when put side to side */
z-index: 2
cursor: pointer
z-index: 1
&.chuni-nameplate-clickable
cursor: pointer
.chuni-trophy
width: 410px
width: 390px
height: 45px
background-position: center
background-size: cover
@@ -71,17 +117,24 @@
top: 40px
font-size: 1.15em
font-family: sans-serif
font-family: "Gothic A1", sans-serif
font-weight: bold
overflow-x: hidden
white-space: nowrap
text-overflow: ellipsis
z-index: 1
text-shadow: 0 1px white
margin: 0 10px
img
width: 100%
height: 100%
position: absolute
z-index: -1
img.chuni-trophy-bg
width: 410px
height: 45px
position: absolute
top: 40px
right: 25px
z-index: -1
.chuni-character
position: absolute
@@ -109,15 +162,17 @@
display: flex
align-items: center
color: black
font-family: sans-serif
font-family: "Gothic A1", sans-serif
font-weight: bold
.chuni-user-name
flex: 1 0 65%
box-shadow: 0 1px 0 #ccc
white-space: nowrap
text-overflow: ellipsis
.chuni-user-level
font-size: 2em
font-size: 1.5em
margin-left: 10px
.chuni-user-name-text
@@ -128,7 +183,7 @@
flex: 1 0 35%
font-size: 0.875em
text-shadow: #333 1px 1px, #333 1px -1px, #333 -1px 1px, #333 -1px -1px
color: #ddf
color: #fff
.chuni-user-rating-number
font-size: 1.5em

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { t } from "../../libs/i18n";
import { DISCORD_INVITE } from "../../libs/config";
export let error: string;
export let expected: boolean = false;
</script>
<div class="overlay" transition:fade>
<div>
<h2 class="error">{t('status.error')}</h2>
{#if !expected}
<span>{t('status.error.hint')}<a href={DISCORD_INVITE}>{t('status.error.hint.link')}</a></span>
{/if}
<span class="detail">{error}</span>
<div class="actions">
<button on:click={() => location.reload()} class="error">
{t('action.refresh')}
</button>
</div>
</div>
</div>
<style lang="sass">
.actions
display: flex
gap: 16px
button
width: 100%
.detail
white-space: pre-line
font-size: 0.9em
line-height: 1.2
opacity: 0.8
</style>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { ts } from "../../libs/i18n";
import InputWithButton from "./InputWithButton.svelte";
export let field: {key: string, value: any, type: string, changed?: boolean};
export let callback: () => Promise<boolean>;
</script>
<div class="field {field.type.toLowerCase()}">
{#if field.type.toLowerCase() === "boolean"}
<input id={field.key} type="checkbox" bind:checked={field.value} on:change={callback}/>
<label for={field.key}>
<span class="name">{ts(`settings.fields.${field.key}.name`)}</span>
<span class="desc">{ts(`settings.fields.${field.key}.desc`)}</span>
</label>
{/if}
{#if field.type.toLowerCase() === "string"}
<label for={field.key}>
<span class="name">{ts(`settings.fields.${field.key}.name`)}</span>
<span class="desc">{ts(`settings.fields.${field.key}.desc`)}</span>
</label>
<InputWithButton bind:field={field} callback={callback}/>
{/if}
</div>
<style lang="sass">
.field.string
flex-direction: column
align-items: flex-start
gap: 0.5rem
.field.boolean
align-items: center
gap: 1rem
.field
display: flex
label
display: flex
flex-direction: column
max-width: max-content
.desc
opacity: 0.6
</style>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { fade } from 'svelte/transition'
</script>
<div class="overlay loading" transition:fade>
<Icon class="icon" icon="svg-spinners:pulse-2"/>
<span><span>LOADING</span></span>
</div>
<style lang="sass">
.loading.overlay
font-size: 28rem
:global(.icon)
opacity: 0.5
> span
position: absolute
inset: 0
display: flex
justify-content: center
align-items: center
background: transparent
letter-spacing: 20px
margin-left: 20px
font-size: 1.5rem
</style>

View File

@@ -1,19 +1,47 @@
import type { ChusanMatchingOption } from "./generalTypes"
export const AQUA_HOST = 'https://aquadx.net/aqua'
export const DATA_HOST = 'https://aquadx.net'
export const AQUA_HOST = import.meta.env.VITE_AQUA_HOST
export const DATA_HOST = import.meta.env.VITE_DATA_HOST
// This will be displayed for users to connect from the client
export const AQUA_CONNECTION = 'aquadx.hydev.org'
export const AQUA_CONNECTION = import.meta.env.VITE_AQUA_CONNECTION
export const TURNSTILE_SITE_KEY = '0x4AAAAAAASGA2KQEIelo9P9'
export const DISCORD_INVITE = 'https://discord.gg/FNgveqFF7s'
export const TELEGRAM_INVITE = 'https://t.me/+zBL4RZdyfvUzZGU1'
export const QQ_INVITE = 'https://qm.qq.com/q/wvNXbXbHbO'
export const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY
export const DISCORD_INVITE = import.meta.env.VITE_DISCORD_INVITE
export const TELEGRAM_INVITE = import.meta.env.VITE_TELEGRAM_INVITE
export const QQ_INVITE = import.meta.env.VITE_QQ_INVITE
// UI
export const FADE_OUT = { duration: 200 }
export const FADE_IN = { delay: 400 }
export const DEFAULT_PFP = '/assets/imgs/no_profile.png'
// USERBOX_ASSETS
export const ANNOUNCEMENT = '' // If set, will add an announcement to the top bar. Keep it short.
// Documentation for Userbox mode can be found in `docs/aquabox-url-mode.md`
// Please note that if this is set, it must be manually unset by users in Chuni Settings -> Update Userbox -> Switch to URL mode -> (empty value) -> Enter key
export const USERBOX_DEFAULT_URL = ""
export const HAS_USERBOX_ASSETS = true
// Meow meow meow
// Matching servers
export const CHU3_MATCHINGS: ChusanMatchingOption[] = [
{
name: "林国对战",
ui: "https://chu3-match.sega.ink/rooms",
guide: "https://performai.evilleaker.com/manual/games/chunithm/national_battle/",
matching: "https://chu3-match.sega.ink/",
reflector: "http://reflector.naominet.live:18080/",
coop: ["RinNET", "MysteriaNET"],
},
{
name: "Yukiotoko",
ui: "https://yukiotoko.metatable.sh/",
guide: "https://github.com/MewoLab/AquaDX/blob/v1-dev/docs/chu3-national-matching.md",
matching: "http://yukiotoko.chara.lol:9004/",
reflector: "http://yukiotoko.chara.lol:50201/",
coop: ["Missless", "CozyNet", "GMG"]
}
]

View File

@@ -1,3 +1,5 @@
export type Dict = Record<string, any>
export interface TrendEntry {
date: string
rating: number
@@ -17,6 +19,7 @@ export interface AquaNetUser {
email: string
displayName: string
country: string
region:string
lastLogin: number
regTime: number
profileLocation: string
@@ -48,7 +51,7 @@ export interface CardSummary {
export interface ConfirmProps {
title: string
message: string
confirm: () => void
confirm?: () => void
cancel?: () => void
dangerous?: boolean
}
@@ -104,7 +107,8 @@ export interface GenericGameSummary {
lastVersion: string
ratingComposition: { [key: string]: any }
recent: GenericGamePlaylog[]
rival?: boolean
rival?: boolean,
favorites?: number[]
}
export interface MusicMeta {
@@ -140,6 +144,8 @@ export interface UserBox {
frameId: number,
characterId: number,
trophyId: number,
trophyIdSub1: number,
trophyIdSub2: number,
mapIconId: number,
voiceId: number,
avatarWear: number,
@@ -153,3 +159,12 @@ export interface UserBox {
level: number
playerRating: number
}
export interface ChusanMatchingOption {
name: string
ui: string
guide: string
matching: string
reflector: string
coop: string[]
}

View File

@@ -2,6 +2,9 @@ import { EN_REF, type LocalizedMessages } from "./i18n/en_ref";
import { ZH } from "./i18n/zh";
import type { GameName } from "./scoring";
import zhCountires from "./i18n/zh_countries.json"
import enCountires from "./i18n/en_countries.json"
type Lang = 'en' | 'zh'
const msgs: Record<Lang, LocalizedMessages> = {
@@ -9,6 +12,10 @@ const msgs: Record<Lang, LocalizedMessages> = {
zh: ZH
}
const countries: Record<Lang, typeof enCountires> = {
en: enCountires,
zh: zhCountires
}
let lang: Lang = 'en'
@@ -48,5 +55,39 @@ export function t(key: keyof LocalizedMessages, variables?: { [index: string]: a
}
Object.assign(window, { t })
export function getCountryName(code: keyof typeof enCountires) {
return countries[lang][code]
}
export const GAME_TITLE: { [key in GameName]: string } =
{chu3: t("game.chu3"), mai2: t("game.mai2"), ongeki: t("game.ongeki"), wacca: t("game.wacca")}
/**
* Converts a two-letter country code to its corresponding flag emoji.
*
* The Unicode flag emoji is represented by two Regional Indicator Symbols.
* Each letter in the country code is transformed into a Regional Indicator Symbol
* by adding its alphabetical position (A = 0, B = 1, etc.) to the base code point U+1F1E6.
*
* @param countryCode - A two-letter ISO country code (e.g., "US", "GB").
* @returns The corresponding flag emoji if the country code is valid; otherwise, an empty string.
*/
export function countryCodeToEmoji(countryCode: string): string {
if (!countryCode) return ""
if (countryCode.length !== 2) return ""
// Convert the country code to uppercase to standardize it
const code = countryCode.toUpperCase();
// The base code point for Regional Indicator Symbol Letter A is 0x1F1E6.
const OFFSET = 0x1F1E6;
const firstCharCode = code.charCodeAt(0);
const secondCharCode = code.charCodeAt(1);
// 'A' has a char code of 65.
const firstIndicator = OFFSET + (firstCharCode - 65);
const secondIndicator = OFFSET + (secondCharCode - 65);
// Create and return the flag emoji string
return String.fromCodePoint(firstIndicator, secondIndicator);
}

View File

@@ -0,0 +1,248 @@
{
"AF": "Afghanistan",
"AX": "Aland Islands",
"AL": "Albania",
"DZ": "Algeria",
"AS": "American Samoa",
"AD": "Andorra",
"AO": "Angola",
"AI": "Anguilla",
"AQ": "Antarctica",
"AG": "Antigua And Barbuda",
"AR": "Argentina",
"AM": "Armenia",
"AW": "Aruba",
"AU": "Australia",
"AT": "Austria",
"AZ": "Azerbaijan",
"BS": "Bahamas",
"BH": "Bahrain",
"BD": "Bangladesh",
"BB": "Barbados",
"BY": "Belarus",
"BE": "Belgium",
"BZ": "Belize",
"BJ": "Benin",
"BM": "Bermuda",
"BT": "Bhutan",
"BO": "Bolivia",
"BA": "Bosnia And Herzegovina",
"BW": "Botswana",
"BV": "Bouvet Island",
"BR": "Brazil",
"IO": "British Indian Ocean Territory",
"BN": "Brunei Darussalam",
"BG": "Bulgaria",
"BF": "Burkina Faso",
"BI": "Burundi",
"KH": "Cambodia",
"CM": "Cameroon",
"CA": "Canada",
"CV": "Cape Verde",
"KY": "Cayman Islands",
"CF": "Central African Republic",
"TD": "Chad",
"CL": "Chile",
"CN": "China",
"CX": "Christmas Island",
"CC": "Cocos (Keeling) Islands",
"CO": "Colombia",
"KM": "Comoros",
"CG": "Congo",
"CD": "Congo, Democratic Republic",
"CK": "Cook Islands",
"CR": "Costa Rica",
"CI": "Cote D\"Ivoire",
"HR": "Croatia",
"CU": "Cuba",
"CY": "Cyprus",
"CZ": "Czech Republic",
"DK": "Denmark",
"DJ": "Djibouti",
"DM": "Dominica",
"DO": "Dominican Republic",
"EC": "Ecuador",
"EG": "Egypt",
"SV": "El Salvador",
"GQ": "Equatorial Guinea",
"ER": "Eritrea",
"EE": "Estonia",
"ET": "Ethiopia",
"FK": "Falkland Islands (Malvinas)",
"FO": "Faroe Islands",
"FJ": "Fiji",
"FI": "Finland",
"FR": "France",
"GF": "French Guiana",
"PF": "French Polynesia",
"TF": "French Southern Territories",
"GA": "Gabon",
"GM": "Gambia",
"GE": "Georgia",
"DE": "Germany",
"GH": "Ghana",
"GI": "Gibraltar",
"GR": "Greece",
"GL": "Greenland",
"GD": "Grenada",
"GP": "Guadeloupe",
"GU": "Guam",
"GT": "Guatemala",
"GG": "Guernsey",
"GN": "Guinea",
"GW": "Guinea-Bissau",
"GY": "Guyana",
"HT": "Haiti",
"HM": "Heard Island & Mcdonald Islands",
"VA": "Holy See (Vatican City State)",
"HN": "Honduras",
"HK": "Hong Kong",
"HU": "Hungary",
"IS": "Iceland",
"IN": "India",
"ID": "Indonesia",
"IR": "Iran, Islamic Republic Of",
"IQ": "Iraq",
"IE": "Ireland",
"IM": "Isle Of Man",
"IL": "Israel",
"IT": "Italy",
"JM": "Jamaica",
"JP": "Japan",
"JE": "Jersey",
"JO": "Jordan",
"KZ": "Kazakhstan",
"KE": "Kenya",
"KI": "Kiribati",
"KR": "Korea",
"KP": "North Korea",
"KW": "Kuwait",
"KG": "Kyrgyzstan",
"LA": "Lao People\"s Democratic Republic",
"LV": "Latvia",
"LB": "Lebanon",
"LS": "Lesotho",
"LR": "Liberia",
"LY": "Libyan Arab Jamahiriya",
"LI": "Liechtenstein",
"LT": "Lithuania",
"LU": "Luxembourg",
"MO": "Macao",
"MK": "Macedonia",
"MG": "Madagascar",
"MW": "Malawi",
"MY": "Malaysia",
"MV": "Maldives",
"ML": "Mali",
"MT": "Malta",
"MH": "Marshall Islands",
"MQ": "Martinique",
"MR": "Mauritania",
"MU": "Mauritius",
"YT": "Mayotte",
"MX": "Mexico",
"FM": "Micronesia, Federated States Of",
"MD": "Moldova",
"MC": "Monaco",
"MN": "Mongolia",
"ME": "Montenegro",
"MS": "Montserrat",
"MA": "Morocco",
"MZ": "Mozambique",
"MM": "Myanmar",
"NA": "Namibia",
"NR": "Nauru",
"NP": "Nepal",
"NL": "Netherlands",
"AN": "Netherlands Antilles",
"NC": "New Caledonia",
"NZ": "New Zealand",
"NI": "Nicaragua",
"NE": "Niger",
"NG": "Nigeria",
"NU": "Niue",
"NF": "Norfolk Island",
"MP": "Northern Mariana Islands",
"NO": "Norway",
"OM": "Oman",
"PK": "Pakistan",
"PW": "Palau",
"PS": "Palestinian Territory, Occupied",
"PA": "Panama",
"PG": "Papua New Guinea",
"PY": "Paraguay",
"PE": "Peru",
"PH": "Philippines",
"PN": "Pitcairn",
"PL": "Poland",
"PT": "Portugal",
"PR": "Puerto Rico",
"QA": "Qatar",
"RE": "Reunion",
"RO": "Romania",
"RU": "Russian Federation",
"RW": "Rwanda",
"BL": "Saint Barthelemy",
"SH": "Saint Helena",
"KN": "Saint Kitts And Nevis",
"LC": "Saint Lucia",
"MF": "Saint Martin",
"PM": "Saint Pierre And Miquelon",
"VC": "Saint Vincent And Grenadines",
"WS": "Samoa",
"SM": "San Marino",
"ST": "Sao Tome And Principe",
"SA": "Saudi Arabia",
"SN": "Senegal",
"RS": "Serbia",
"SC": "Seychelles",
"SL": "Sierra Leone",
"SG": "Singapore",
"SK": "Slovakia",
"SI": "Slovenia",
"SB": "Solomon Islands",
"SO": "Somalia",
"ZA": "South Africa",
"GS": "South Georgia And Sandwich Isl.",
"ES": "Spain",
"LK": "Sri Lanka",
"SD": "Sudan",
"SR": "Suriname",
"SJ": "Svalbard And Jan Mayen",
"SZ": "Swaziland",
"SE": "Sweden",
"CH": "Switzerland",
"SY": "Syrian Arab Republic",
"TW": "Taiwan",
"TJ": "Tajikistan",
"TZ": "Tanzania",
"TH": "Thailand",
"TL": "Timor-Leste",
"TG": "Togo",
"TK": "Tokelau",
"TO": "Tonga",
"TT": "Trinidad And Tobago",
"TN": "Tunisia",
"TR": "Turkey",
"TM": "Turkmenistan",
"TC": "Turks And Caicos Islands",
"TV": "Tuvalu",
"UG": "Uganda",
"UA": "Ukraine",
"AE": "United Arab Emirates",
"GB": "United Kingdom",
"US": "United States",
"UM": "United States Outlying Islands",
"UY": "Uruguay",
"UZ": "Uzbekistan",
"VU": "Vanuatu",
"VE": "Venezuela",
"VN": "Vietnam",
"VG": "Virgin Islands, British",
"VI": "Virgin Islands, U.S.",
"WF": "Wallis And Futuna",
"EH": "Western Sahara",
"YE": "Yemen",
"ZM": "Zambia",
"ZW": "Zimbabwe"
}

View File

@@ -27,27 +27,39 @@ export const EN_REF_USER = {
'UserHome.AddRival': "Add to Rival",
'UserHome.RemoveRival': "Remove from Rival",
'UserHome.InvalidGame': "Game ${game} is not supported on the web UI yet. We only support maimai, chunithm, wacca, and ongeki for now.",
'UserHome.ShowMoreRecent': 'Show more',
'UserHome.FavoriteSongs': 'Favorite Songs'
}
export const EN_REF_Welcome = {
'back': 'Back',
'email': 'Email',
'password': 'Password',
'new-password': 'New password',
'username': 'Username',
'welcome.btn-login': 'Log in',
'welcome.btn-signup': 'Sign up',
'welcome.email-password-missing': 'Email and password are required',
'welcome.btn-reset-password': 'Forgot password?',
'welcome.btn-submit-reset-password': 'Send reset link',
'welcome.btn-submit-new-password': 'Change password',
'welcome.email-missing': 'Email is required',
'welcome.password-missing': 'Password is required',
'welcome.username-missing': 'Username/email is required',
'welcome.email-password-missing': 'Email and password are required',
'welcome.waiting-turnstile': 'Waiting for Turnstile to verify your network environment...',
'welcome.turnstile-error': 'Error verifying your network environment. Please turn off your VPN and try again.',
'welcome.turnstile-timeout': 'Network verification timed out. Please try again.',
'welcome.verification-sent': 'A verification email has been sent to ${email}. Please check your inbox!',
'welcome.verify-state-0': 'You haven\'t verified your email. A verification email had been sent to your inbox less than a minute ago. Please check your inbox!',
'welcome.verify-state-1': 'You haven\'t verified your email. We\'ve already sent 3 emails over the last 24 hours so we\'ll not send another one. Please check your inbox!',
'welcome.reset-password-sent': 'A password reset email has been sent to ${email}. Please check your inbox!',
'welcome.verify-state-0': 'You haven\'t verified your email. A verification email has been sent to your inbox just now. Please check your inbox!',
'welcome.verify-state-1': 'You haven\'t verified your email. You have requested too many emails, please try again later.',
'welcome.verify-state-2': 'You haven\'t verified your email. We just sent you another verification email. Please check your inbox!',
'welcome.reset-state-0': 'A reset email has been sent to your inbox just now. Please check your inbox!',
'welcome.reset-state-1': 'Too many emails have been sent. Another will not be sent.',
'welcome.verifying': 'Verifying your email... please wait.',
'welcome.verified': 'Your email has been verified! You can now log in now.',
'welcome.verification-failed': 'Verification failed: ${message}. Please try again.',
'welcome.password-reset-done': 'Your password has been updated! Please log back in.',
}
export const EN_REF_LEADERBOARD = {
@@ -71,6 +83,11 @@ export const EN_REF_GENERAL = {
'action.refresh': 'Refresh',
'action.cancel': 'Cancel',
'action.confirm': 'Confirm',
'navigation.profile': 'Profile',
'navigation.maps': 'Maps',
'navigation.home': 'Home',
'navigation.rankings': 'Rankings',
'navigation.notice': 'Notice'
}
export const EN_REF_HOME = {
@@ -92,10 +109,11 @@ export const EN_REF_HOME = {
'home.linkcard.account-card': 'Account Card',
'home.linkcard.registered': 'Registered',
'home.linkcard.lastused': 'Last used',
'home.linkcard.enter-info': 'Please enter the following information',
'home.linkcard.enter-info': 'Please enter the following information, or drag and drop your aime.txt / felica.txt file here',
'home.linkcard.access-code': 'The 20-digit access code on the back of your card. (If it doesn\'t work, please try scanning your card in game and enter the access code shown on screen)',
'home.linkcard.enter-sn1': 'Download the NFC Tools app on your phone',
'home.linkcard.enter-sn2': 'and scan your card. Then, enter the Serial Number.',
'home.linkcard.kdx-notice': "If you're using KanadeDX, please enter the simulated card number (you can find it in settings > card).",
'home.linkcard.link': 'Link',
'home.linkcard.data-conflict': 'Data Conflict',
'home.linkcard.name': 'Name',
@@ -106,6 +124,7 @@ export const EN_REF_HOME = {
'home.linkcard.notfound': 'Card not found',
'home.linkcard.unlink': 'Unlink Card',
'home.linkcard.unlink-notice': 'Are you sure you want to unlink this card?',
'home.linkcard.felica-ac-warning': 'This Access Code is of a FeliCa AIC card.\nIf you are logging in with a physical card (not aime.txt emulation), unlike the official server, you need to bind the FeliCa SN of the card (or the 00-prefixed card number shown in the game) instead of this code.\nIf you are logging in with aime.txt emulation, please ignore this warning and proceed.',
'home.setup.welcome': 'Welcome! If you own an arcade cabinet or game setup, please follow the instructions below to set up the connection with AquaDX.',
'home.setup.blockquote': 'We assume that you already have the required files and can run the game (e.g. ROM and segatools) that come with the cabinet or game setup. If not, please contact the seller of your device for the required files, as we will not provide them for copyright reasons.',
'home.setup.get': 'Get started',
@@ -114,6 +133,9 @@ export const EN_REF_HOME = {
'home.setup.ask': 'If you have any questions, please ask in our',
'home.setup.support': 'server',
'home.setup.keychip-tips': 'This is your unique keychip, do not share it with anyone',
'home.community.discord': 'Discord',
'home.community.telegram': 'Telegram (Chinese)',
'home.community.qq': 'QQ (Chinese)',
'home.import.unknown-game': 'Unknown game type. Currently only maimai and chunithm are supported for importing.',
'home.import.new-data': 'Data to import',
'home.import.data-conflict': 'Proceed will override your current data',
@@ -125,48 +147,85 @@ export const EN_REF_SETTINGS = {
'settings.tabs.game': 'Game',
'settings.tabs.chu3': 'Chuni',
'settings.tabs.mai2': 'Mai',
'settings.tabs.ongeki': 'Ongeki',
'settings.tabs.wacca': 'Wacca',
'settings.fields.unlockMusic.name': 'Unlock All Music',
'settings.fields.unlockMusic.desc': 'Unlock all music and master difficulty in game.',
'settings.fields.unlockChara.name': 'Unlock All Characters',
'settings.fields.unlockChara.desc': 'Unlock all characters, voices, and partners in game.',
'settings.fields.unlockCollectables.name': 'Unlock All Collectables',
'settings.fields.unlockCollectables.desc': 'Unlock all collectables (nameplate, title, icon, frame) in game.',
'settings.fields.unlockTickets.name': 'Unlock All Tickets',
'settings.fields.unlockTickets.desc': 'Infinite map/ex tickets (note: maimai still limits which tickets can be used).',
'settings.fields.waccaInfiniteWp.name': 'Wacca: Infinite WP',
'settings.fields.waccaInfiniteWp.desc': 'Set WP to 999999',
'settings.fields.waccaAlwaysVip.name': 'Wacca: Always VIP',
'settings.fields.waccaAlwaysVip.desc': 'Set VIP expiration date to 2077-01-01',
'settings.fields.chusanTeamName.name': 'Chuni: Team Name',
'settings.fields.mai2UnlockMusic.name': 'Unlock All Music',
'settings.fields.mai2UnlockMusic.desc': 'Unlock all music and master difficulty.',
'settings.fields.mai2UnlockChara.name': 'Unlock All Characters',
'settings.fields.mai2UnlockChara.desc': 'Unlock all characters (new characters start at level 1).',
'settings.fields.mai2UnlockCharaMaxLevel.name': 'Max Character Level',
'settings.fields.mai2UnlockCharaMaxLevel.desc': 'Set all characters to max level.',
'settings.fields.mai2UnlockPartners.name': 'Unlock All Partners',
'settings.fields.mai2UnlockPartners.desc': 'Unlock all partners.',
'settings.fields.mai2UnlockCollectables.name': 'Unlock All Collectables',
'settings.fields.mai2UnlockCollectables.desc': 'Unlock all collectables (nameplate, title, icon, frame).',
'settings.fields.mai2UnlockTickets.name': 'Unlock All Tickets',
'settings.fields.mai2UnlockTickets.desc': 'Infinite tickets (Note: client still limits which tickets can be used).',
'settings.fields.waccaUnlockMusic.name': 'Unlock All Music',
'settings.fields.waccaUnlockMusic.desc': 'Unlock all music.',
'settings.fields.waccaUnlockPlates.name': 'Unlock All Plates',
'settings.fields.waccaUnlockPlates.desc': 'Unlock all plates.',
'settings.fields.waccaUnlockCollectables.name': 'Unlock All Collectables',
'settings.fields.waccaUnlockCollectables.desc': 'Unlock all collectables (icon, trophy).',
'settings.fields.waccaUnlockTickets.name': 'Infinite Tickets',
'settings.fields.waccaUnlockTickets.desc': 'Infinite tickets.',
'settings.fields.waccaInfiniteWp.name': 'Infinite WP',
'settings.fields.waccaInfiniteWp.desc': 'Set WP to 999999.',
'settings.fields.waccaAlwaysVip.name': 'Always VIP',
'settings.fields.waccaAlwaysVip.desc': 'Set VIP expiration date to 2077-01-01.',
'settings.fields.chusanTeamName.name': 'Team Name',
'settings.fields.chusanTeamName.desc': 'Customize the text displayed on the top of your profile.',
'settings.fields.chusanInfinitePenguins.name': 'Chuni: Infinite Penguins',
'settings.fields.chusanInfinitePenguins.name': 'Infinite Penguins',
'settings.fields.chusanInfinitePenguins.desc': 'Set penguin statues for character level prompting to 999.',
'settings.fields.chusanMatchingReflector.name': 'Matching Server Reflector',
'settings.fields.chusanMatchingReflector.desc': 'URL of the national matching server\'s UDP reflector.',
'settings.fields.chusanMatchingServer.name': 'Matching Server',
'settings.fields.chusanMatchingServer.desc': 'URL of the national matching server.',
'settings.fields.ongekiInfiniteKaika.name': 'Infinite Kaika',
'settings.fields.ongekiInfiniteKaika.desc': 'Set Kaika to 999',
'settings.fields.rounding.name': 'Score Rounding',
'settings.fields.rounding.desc': 'Round the score to one decimal place',
'settings.fields.gameUsername.name': 'In-Game Username',
'settings.fields.gameUsername.desc': 'Your name shown in game',
'settings.fields.optOutOfLeaderboard.name': 'Opt Out of Leaderboard',
'settings.fields.optOutOfLeaderboard.desc': 'You will still be able to see yourself on the leaderboard after logging in',
'settings.fields.enableMusicRank.name': 'Enable Recommended Music Rank on Your Machine',
'settings.fields.enableMusicRank.desc': 'If you have your own ranking, you can turn this off. It only affects your own machine',
'settings.mai2.name': 'Player Name',
'settings.profile.picture': 'Profile Picture',
'settings.profile.upload-new': 'Upload New',
'settings.profile.bad-format': 'Invalid image format. Supported types are PNG, JPG, JPEG, WEBP & GIF.',
'settings.profile.save': 'Save',
'settings.profile.name': 'Display Name',
'settings.profile.username': 'Username',
'settings.profile.password': 'Password',
'settings.profile.country': 'Country',
'settings.profile.location': 'Location',
'settings.profile.bio': 'Bio',
'settings.profile.unset': 'Unset',
'settings.profile.logout': 'Log out',
'settings.profile.unchanged': 'Unchanged',
'settings.export': 'Export Player Data',
'settings.batchManualExport': "Export in Batch Manual (for Tachi)",
'settings.cabNotice': "Note: These settings will only affect your own cab/setup. If you're playing on someone else's setup, please contact them to change these settings.",
'settings.gameNotice': "These only apply to Mai and Wacca.",
'settings.regionNotice': "These only apply to Mai, Ongeki and Chuni.",
'settings.regionSelector.title': "Prefecture Selector",
'settings.regionSelector.desc': "Select the region where you want the game to think you are playing",
'settings.regionSelector.select': "Select Prefecture",
}
export const EN_REF_USERBOX = {
'userbox.header.general': 'General Settings',
'userbox.header.matching': 'National Matching',
'userbox.header.matching.symbolChat': 'Chat Symbols (Matching)',
'userbox.header.userbox': 'UserBox Settings',
'userbox.header.preview': 'UserBox Preview',
'userbox.nameplateId': 'Nameplate',
'userbox.frameId': 'Frame',
'userbox.trophyId': 'Trophy (Title)',
'userbox.trophyIdSub1': 'Trophy Sub #1 (Title)',
'userbox.trophyIdSub2': 'Trophy Sub #2 (Title)',
'userbox.mapIconId': 'Map Icon',
'userbox.voiceId': 'System Voice',
'userbox.avatarWear': 'Avatar Wear',
@@ -176,23 +235,78 @@ export const EN_REF_USERBOX = {
'userbox.avatarItem': 'Avatar Item',
'userbox.avatarFront': 'Avatar Front',
'userbox.avatarBack': 'Avatar Back',
'userbox.preview.notice': 'To honor the copyright, we cannot host the images of the userbox items. However, if someone else is willing to provide the images, you can enter their URL here and it will be displayed.',
'userbox.preview.url': 'Image URL',
'userbox.error.nodata': 'Chuni data not found',
'userbox.matching.select': 'Select Matching Server',
'userbox.matching.select.sub': 'Choose the matching server you want to use.',
'userbox.matching.option.ui': 'Rooms',
'userbox.matching.option.guide': 'Guide',
'userbox.matching.option.collab': 'Collaborators',
'userbox.matching.custom.name': 'Custom',
'userbox.matching.custom.sub': 'Enter your own URL',
'userbox.matching.symbolChat': 'Message Choice',
'userbox.matching.symbolChat.default': 'Default',
'userbox.new.name': 'AquaBox',
'userbox.new.setup': 'Drag and drop your Chuni game folder (Lumi or newer) into the box below to display UserBoxes with their nameplate & avatar. All files are handled in-browser.',
'userbox.new.setup.notice': 'Select the highest folder containing your game data.',
'userbox.new.setup.processing_file': 'Processing',
'userbox.new.setup.finalizing': 'Saving to internal storage',
'userbox.new.drop': 'Drop game folder here',
'userbox.new.switch.to_url': 'Switch to URL mode',
'userbox.new.switch.to_drop': 'Switch to drop mode',
'userbox.new.url_warning': 'Enter in the path to access Userbox assets. You are responsible for any results in this state. Please read the documentation. Don\'t expect support for this mode.',
'userbox.new.activate_first': 'Enable AquaBox (game files required)',
'userbox.new.activate_update': 'Update AquaBox (game files required)',
'userbox.new.activate': 'Use AquaBox',
'userbox.new.activate_desc': 'Enable displaying UserBoxes with their nameplate & avatar',
'userbox.new.error.invalidFolder': 'The folder you selected is invalid. Ensure that your game\'s version is Lumi or newer and that the "A001" option pack is present.'
'userbox.new.activate_profile': 'Use AquaBox on profiles',
'userbox.new.activate_profile_desc': 'Enable displaying UserBoxes with their nameplate & avatar on profile pages',
'userbox.new.error.invalidFolder': 'The folder you selected is invalid. Ensure that your game\'s version is Lumi or newer and that the "A000" option pack is present.',
'userbox.new.error.invalidUrl': 'The URL you inputted is invalid.'
}
export const EN_REF_MAI_PHOTO = {
'maiphoto.title': 'Mai Memorial Photo Gallery',
'maiphoto.url_warning': 'Note: If you want to share a photo with your friend, please save the photo. Do not copy image URL because the URL contains sensitive information.',
'maiphoto.none': 'No photo found. You can upload photo by clicking upload at the end of each game session.',
}
export const EN_REF_AQUATRANS = {
'trans.title': '🏳️‍⚧️ AquaTrans™ Data Transfer',
'trans.confirm.unbackuped.title': 'Confirm transfer',
'trans.confirm.unbackuped.msg': "It seems like you haven't backed up your destination data. Are you sure you want to proceed? (This will overwrite your destination server's data)",
'trans.confirm.untested.title': 'Error',
'trans.confirm.untested.msg': "It seems like you haven't tested both connections yet. Please test the connections first.",
'trans.confirm.done.title': 'Done!',
'trans.confirm.done.msg': 'Transfer completed successfully! Your data on ${dst} is overwritten with your data from ${src}.',
'trans.alert.in-progress': "Transfer already in progress!",
'trans.prompt-html': `
<p>👋 Welcome to the AquaTrans™ server data transfer tool!</p>
<p>You can use this to export data from any server, and input data into any server using the connection credentials (card number, server address, and keychip id).</p>
<p>This tool will simulate a game client and pull your data from the source server, and push your data to the destination server.</p>
<p>Please fill out the info below to get started!</p>
`,
'trans.error.empty': 'Please fill out all fields.',
'trans.error.untested': 'Please test the connections first.',
'trans.success.import': 'Data imported successfully!',
'trans.source.title': 'Source Server',
'trans.target.title': 'Destination Server',
'trans.field.addr': 'Server Address',
'trans.field.keychip': 'Keychip ID',
'trans.field.game': 'Game',
'trans.field.version': 'Version',
'trans.field.card': 'Card Number',
'trans.btn.test': 'Test Connection',
'trans.btn.export': 'Export Data',
'trans.btn.import': 'Import Data',
'trans.blacklist': "Your server's rules doesn't allow using this tool. You might get banned if you try (idk, ask them if you want to know why)",
}
export const EN_REF = { ...EN_REF_USER, ...EN_REF_Welcome, ...EN_REF_GENERAL,
...EN_REF_LEADERBOARD, ...EN_REF_HOME, ...EN_REF_SETTINGS, ...EN_REF_USERBOX }
...EN_REF_LEADERBOARD, ...EN_REF_HOME, ...EN_REF_SETTINGS, ...EN_REF_USERBOX,
...EN_REF_MAI_PHOTO, ...EN_REF_AQUATRANS
}
export type LocalizedMessages = typeof EN_REF

View File

@@ -1,7 +1,9 @@
import {
EN_REF_AQUATRANS,
EN_REF_GENERAL,
EN_REF_HOME,
EN_REF_LEADERBOARD,
EN_REF_MAI_PHOTO,
EN_REF_SETTINGS,
EN_REF_USER,
EN_REF_USERBOX,
@@ -27,7 +29,7 @@ const zhUser: typeof EN_REF_USER = {
'UserHome.Version': '游戏版本',
'UserHome.RecentScores': '成绩',
'UserHome.NoData': '过去 ${days} 天内没有玩过',
'UserHome.UnknownSong': "(未知曲目)",
'UserHome.UnknownSong': "未知曲目",
'UserHome.Settings': '设置',
'UserHome.NoValidGame': "用户还没有玩过游戏",
'UserHome.ShowRanksDetails': "点击显示评分详细",
@@ -36,28 +38,40 @@ const zhUser: typeof EN_REF_USER = {
'UserHome.B50': "B50",
'UserHome.AddRival': "添加劲敌",
'UserHome.RemoveRival': "移除劲敌",
'UserHome.InvalidGame': "游戏 ${game} 还不支持网页端查看。我们目前只支持舞萌、中二、Wacca 和音击。",
'UserHome.InvalidGame': "游戏 ${game} 还不支持网页端查看。我们目前只支持舞萌、中二、华卡和音击。",
'UserHome.ShowMoreRecent': "显示更多",
'UserHome.FavoriteSongs': "收藏歌曲"
}
const zhWelcome: typeof EN_REF_Welcome = {
'back': '返回',
'email': '邮箱',
'password': '密码',
'new-password': '新密码',
'username': '用户名',
'welcome.btn-login': '登录',
'welcome.btn-signup': '注册',
'welcome.email-password-missing': '邮箱和密码必须填哦',
'welcome.btn-reset-password': '忘记密码?',
'welcome.btn-submit-reset-password': '发送重置链接',
'welcome.btn-submit-new-password': '修改密码',
'welcome.email-missing': '邮箱必须填哦',
'welcome.password-missing': '密码必须填哦',
'welcome.username-missing': '用户名/邮箱必须填哦',
'welcome.waiting-turnstile': '正在验证网络环境...',
'welcome.turnstile-error': '验证网络环境出错了请关闭VPN后重试',
'welcome.email-password-missing': '邮箱和密码必须填哦',
'welcome.waiting-turnstile': '正在验证网络环境',
'welcome.turnstile-error': '验证网络环境出错了,请关闭 VPN 后重试',
'welcome.turnstile-timeout': '验证网络环境超时了,请重试',
'welcome.verification-sent': '验证邮件已发送至 ${email},请翻翻收件箱',
'welcome.reset-password-sent': '重置邮件已发送至 ${email},请翻翻收件箱',
'welcome.verify-state-0': '您还没有验证邮箱哦!验证邮件一分钟内刚刚发到您的邮箱,请翻翻收件箱',
'welcome.verify-state-1': '您还没有验证邮箱哦我们在过去的24小时内已经发送了3封验证邮件,所以我们不会再发送了,请翻翻收件箱',
'welcome.verify-state-1': '您还没有验证邮箱哦!我们在过去的 24 小时内已经发送了 3 封验证邮件,所以我们不会再发送了,请翻翻收件箱',
'welcome.verify-state-2': '您还没有验证邮箱哦!我们刚刚又发送了一封验证邮件,请翻翻收件箱',
'welcome.verifying': '正在验证邮箱...请稍等',
'welcome.reset-state-0': '重置邮件刚刚发送到你的邮箱啦,请翻翻收件箱!',
'welcome.reset-state-1': '邮件发送次数过多,暂时不会再发送新的重置邮件了',
'welcome.verifying': '正在验证邮箱…请稍等',
'welcome.verified': '您的邮箱已经验证成功!您现在可以登录了',
'welcome.verification-failed': '验证失败:${message}。请重试',
'welcome.password-reset-done': '您的密码已更新!请重新登录',
}
const zhLeaderboard: typeof EN_REF_LEADERBOARD = {
@@ -73,7 +87,7 @@ const zhGeneral: typeof EN_REF_GENERAL = {
'game.mai2': "舞萌",
'game.chu3': "中二",
'game.ongeki': "音击",
'game.wacca': "Wacca",
'game.wacca': "华卡",
"status.error": "发生错误",
"status.error.hint": "出了一些问题,请稍后刷新重试或者",
"status.error.hint.link": "加我们的 Discord 群问一问",
@@ -81,6 +95,11 @@ const zhGeneral: typeof EN_REF_GENERAL = {
"action.refresh": "刷新",
"action.cancel": "取消",
"action.confirm": "确认",
'navigation.profile': '个人资料',
'navigation.maps': '地图',
'navigation.home': '首页',
'navigation.rankings': '排行榜',
'navigation.notice': '公告'
}
const zhHome: typeof EN_REF_HOME = {
@@ -90,7 +109,7 @@ const zhHome: typeof EN_REF_HOME = {
'home.manage-cards': '管理游戏卡',
'home.manage-cards-description': '绑定、解绑、管理游戏数据卡',
'home.link-card': '绑定游戏卡',
'home.link-cards-description':'绑定游戏数据卡 (Amusement IC 或 Aime 卡) 后才可以访问游戏存档哦',
'home.link-cards-description':'绑定游戏数据卡Amusement IC 或 Aime 卡后才可以访问游戏存档哦',
'home.join-community': '加入群组',
'home.join-community-description': '加入我们的聊天群组,与其他玩家聊天、获取帮助',
'home.setup': '连接到 AquaDX',
@@ -102,10 +121,11 @@ const zhHome: typeof EN_REF_HOME = {
'home.linkcard.account-card': "账户卡",
'home.linkcard.registered': "注册于",
'home.linkcard.lastused': "上次使用",
'home.linkcard.enter-info': "请输入以下信息",
'home.linkcard.access-code': "卡背面的20位卡号 (如果没有, 请尝试在游戏中扫描您的卡, 并输入屏幕上显示的卡号)",
'home.linkcard.enter-info': "请输入以下信息,或将 aime.txt / felica.txt 文件拖放到此区域",
'home.linkcard.access-code': "卡背面的 20 位卡号(如果提示找不到卡,请尝试使用游戏内置的显示卡号功能,输入游戏读取到的卡号",
'home.linkcard.enter-sn1': "在您的手机",
'home.linkcard.enter-sn2': "上下载 NFC Tools 并扫描您的卡。然后输入显示的 SN 号。",
'home.linkcard.kdx-notice': "如果你在玩 KanadeDX请在这里输入虚拟卡号可以在设置 > 卡号中找到卡号)",
'home.linkcard.link': "绑定",
'home.linkcard.data-conflict': "卡号冲突",
'home.linkcard.name': "名称",
@@ -115,15 +135,19 @@ const zhHome: typeof EN_REF_HOME = {
'home.linkcard.linked-another': "此卡已链接到其他用户",
'home.linkcard.notfound': "找不到卡",
'home.linkcard.unlink': "取消链接",
'home.linkcard.unlink-notice': "你确定要取消此卡的链接吗?",
'home.setup.welcome': "欢迎! 如果您有街机框体或者手台, 请按照以下说明设置以连接到 AquaDX.",
'home.setup.blockquote': "我们假设您已经拥有所需的文件, 并且可以启动机台或手台附带的游戏 (例如 ROM 和 segatools )。如果没有, 请联系您设备的卖家以获取所需的文件, 因为出于版权原因, 我们不会提供这些文件。",
'home.linkcard.unlink-notice': "你确定要取消此卡的链接吗",
'home.linkcard.felica-ac-warning': "该 Access Code 是一张 FeliCa AIC 卡。\n如果你使用实体卡而非 aime.txt 模拟)刷卡登录游戏,与官方服务器不同,你需要绑定该卡的 FeliCa SN或与之对应的游戏界面中查看得到的 00 开头的卡号)而非此号码。\n如果你使用 aime.txt 模拟登录,请忽略本警告继续绑定。",
'home.setup.welcome': "欢迎! 如果您有街机框体或者手台,请按照以下说明设置以连接到 AquaDX。",
'home.setup.blockquote': "我们假设您已经拥有所需的文件,并且可以启动机台或手台附带的游戏(例如 ROM 和 segatools。如果没有请联系您设备的卖家以获取所需的文件因为出于版权原因我们不会提供这些文件。",
'home.setup.get': "开始",
'home.setup.edit': "请打开您的 segatools.ini 文件并修改以下行",
'home.setup.test': "在您重新启动游戏后, 应该能够连接到 AquaDX。可以验证测试菜单中的网络测试测试连接是否全部良好。",
'home.setup.ask': "如果您有任何问题, 请加入我们的",
'home.setup.test': "在您重新启动游戏后应该能够连接到 AquaDX。可以验证测试菜单中的网络测试测试连接是否全部良好。",
'home.setup.ask': "如果您有任何问题请加入我们的",
'home.setup.support': "以获取支持",
'home.setup.keychip-tips': "这是你的狗号, 不要与任何人分享",
'home.setup.keychip-tips': "这是你的狗号不要与任何人分享",
'home.community.discord': 'Discord',
'home.community.telegram': 'Telegram (中文)',
'home.community.qq': 'QQ (中文)',
'home.import.unknown-game': '未知游戏类型 (目前导入只支持舞萌和中二)',
'home.import.new-data': '要导入的数据',
'home.import.data-conflict': '继续导入将覆盖现有数据',
@@ -135,48 +159,89 @@ const zhSettings: typeof EN_REF_SETTINGS = {
'settings.tabs.game': '游戏设置',
'settings.tabs.chu3': '中二',
'settings.tabs.mai2': '舞萌',
'settings.tabs.wacca': 'Wacca',
'settings.fields.unlockMusic.name': '解锁谱面',
'settings.fields.unlockMusic.desc': '在游戏中解锁所有曲目和大师难度谱面',
'settings.fields.unlockChara.name': '解锁角色',
'settings.fields.unlockChara.desc': '在游戏中解锁所有角色、语音和伙伴。',
'settings.fields.unlockCollectables.name': '解锁收藏品',
'settings.fields.unlockCollectables.desc': '在游戏中解锁所有收藏品(名牌、称号、图标、背景图)。',
'settings.fields.unlockTickets.name': '解锁游戏券',
'settings.fields.unlockTickets.desc': '无限跑图券/解锁券maimai 客户端仍限制一些券不能使用)。',
'settings.fields.waccaInfiniteWp.name': 'Wacca: 无限 WP',
'settings.fields.waccaInfiniteWp.desc': '将 WP 设置为 999999',
'settings.fields.waccaAlwaysVip.name': 'Wacca: 永久会员',
'settings.fields.waccaAlwaysVip.desc': '将 VIP 到期时间设置为 2077-01-01',
'settings.fields.chusanTeamName.name': '中二: 队伍名称',
'settings.tabs.ongeki': '音击',
'settings.tabs.wacca': '华卡',
'settings.fields.mai2UnlockMusic.name': '解锁谱面',
'settings.fields.mai2UnlockMusic.desc': '解锁所有曲目和大师难度谱面。',
'settings.fields.mai2UnlockChara.name': '解锁角色',
'settings.fields.mai2UnlockChara.desc': '解锁所有角色(新角色从 1 级开始)。',
'settings.fields.mai2UnlockCharaMaxLevel.name': '角色满级',
'settings.fields.mai2UnlockCharaMaxLevel.desc': '将所有角色设置为满级。',
'settings.fields.mai2UnlockPartners.name': '解锁搭档',
'settings.fields.mai2UnlockPartners.desc': '解锁所有搭档。',
'settings.fields.mai2UnlockCollectables.name': '解锁收藏品',
'settings.fields.mai2UnlockCollectables.desc': '解锁所有收藏品(姓名框、称号、头像、背景)。',
'settings.fields.mai2UnlockTickets.name': '解锁功能票',
'settings.fields.mai2UnlockTickets.desc': '无限功能票(注:客户端仍限制一些功能票不能使用)。',
'settings.fields.waccaUnlockMusic.name': '解锁谱面',
'settings.fields.waccaUnlockMusic.desc': '解锁所有曲目。',
'settings.fields.waccaUnlockPlates.name': '解锁铭牌',
'settings.fields.waccaUnlockPlates.desc': '解锁所有铭牌。',
'settings.fields.waccaUnlockCollectables.name': '解锁收藏品',
'settings.fields.waccaUnlockCollectables.desc': '解锁所有收藏品。',
'settings.fields.waccaUnlockTickets.name': '无限解锁券',
'settings.fields.waccaUnlockTickets.desc': '无限解锁券。',
'settings.fields.waccaInfiniteWp.name': '无限 WP',
'settings.fields.waccaInfiniteWp.desc': '将 WP 设置为 999999。',
'settings.fields.waccaAlwaysVip.name': '永久会员',
'settings.fields.waccaAlwaysVip.desc': '将 VIP 到期时间设置为 2077-01-01。',
'settings.fields.chusanTeamName.name': '队伍名称',
'settings.fields.chusanTeamName.desc': '自定义显示在个人资料顶部的文本。',
'settings.fields.chusanInfinitePenguins.name': '中二: 无限企鹅',
'settings.fields.chusanInfinitePenguins.name': '我是桐谷遥',
'settings.fields.chusanInfinitePenguins.desc': '将角色界限突破的企鹅雕像数量设置为 999。',
'settings.fields.chusanMatchingReflector.name': '全国对战 Reflector',
'settings.fields.chusanMatchingReflector.desc': '全国对战服务器的 UDP 反射服务器的 URL',
'settings.fields.chusanMatchingServer.name': '全国对战服务器',
'settings.fields.chusanMatchingServer.desc': '全国对战服务器的 URL',
'settings.fields.ongekiInfiniteKaika.name': '无限解花',
'settings.fields.ongekiInfiniteKaika.desc': '将解花数量设置为 999。',
'settings.fields.rounding.name': '分数舍入',
'settings.fields.rounding.desc': '把分数四舍五入到一位小数',
'settings.fields.gameUsername.name': '游戏用户名',
'settings.fields.gameUsername.desc': '在游戏中显示的用户名',
'settings.fields.optOutOfLeaderboard.name': '不参与排行榜',
'settings.fields.optOutOfLeaderboard.desc': '登录之后还是可以在排行榜上看到自己',
'settings.fields.enableMusicRank.name': '在你的机台上启用「推荐乐曲排行榜」',
'settings.fields.enableMusicRank.desc': '如果你自己设计了排行榜的话,可以关闭这个(会影响你自己的机器)。',
'settings.mai2.name': '玩家名字',
'settings.profile.picture': '头像',
'settings.profile.upload-new': '上传',
'settings.profile.bad-format': '无效的图片格式,支持的格式有 PNG、JPG、JPEG、WEBP 和 GIF。',
'settings.profile.save': '保存',
'settings.profile.name': '昵称',
'settings.profile.username': '用户名',
'settings.profile.password': '密码',
'settings.profile.country': '国家',
'settings.profile.location': '位置',
'settings.profile.bio': '简介',
'settings.profile.unset': '未设置',
'settings.profile.logout': '登出',
'settings.profile.unchanged': '未更改',
'settings.export': '导出玩家数据',
'settings.batchManualExport': "导出 Batch Manual 格式(用于 Tachi",
'settings.cabNotice': '注意:下面这些设置只会影响你自己的机器,如果你是在其他人的机器上玩的话,请联系机主来改设置',
'settings.gameNotice': "这些设置仅对舞萌和华卡生效。",
// AI
'settings.regionNotice': "这些设置仅适用于舞萌、音击和中二。",
// AI
'settings.regionSelector.title': "地区选择器",
// AI
'settings.regionSelector.desc': "选择游戏中显示的地区",
// AI
'settings.regionSelector.select': "选择地区",
}
export const zhUserbox: typeof EN_REF_USERBOX = {
'userbox.header.general': '游戏设置',
'userbox.header.matching': '全国对战',
'userbox.header.matching.symbolChat': '全国对战聊天表情',
'userbox.header.userbox': 'UserBox 设置',
'userbox.header.preview': 'UserBox 预览',
'userbox.nameplateId': '名牌',
'userbox.frameId': '边框',
'userbox.trophyId': '称号',
'userbox.trophyIdSub1': '称号2',
'userbox.trophyIdSub2': '称号3',
'userbox.mapIconId': '地图图标',
'userbox.voiceId': '系统语音',
'userbox.avatarWear': '企鹅服饰',
@@ -186,21 +251,77 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
'userbox.avatarItem': '企鹅物品',
'userbox.avatarFront': '企鹅前景',
'userbox.avatarBack': '企鹅背景',
'userbox.preview.notice': '「生存战略」:为了尊重版权,我们不会提供游戏内物品的图片。但是如果你认识其他愿意提供图床的人,在这里输入 URL 就可以显示出预览。',
'userbox.preview.url': '图床 URL',
'userbox.error.nodata': '未找到中二数据',
'userbox.matching.select': '选择对战服务器',
'userbox.matching.select.sub': '选择你想加入的跨服全国对战服务器',
'userbox.matching.option.ui': '房间列表',
'userbox.matching.option.guide': '教程',
'userbox.matching.option.collab': '合作伙伴',
'userbox.matching.custom.name': '自定义',
'userbox.matching.custom.sub': '输入其他的匹配 URL',
'userbox.matching.symbolChat': '表情选择',
'userbox.matching.symbolChat.default': '默认',
'userbox.new.name': 'AquaBox',
'userbox.new.setup': '将 ChuniLumi 或更高版本)的游戏文件夹拖放到下方区域,以显示带有名牌和头像的 UserBox。所有文件都在浏览器中处理。',
'userbox.new.setup': '将中二Lumi 或更高版本)的游戏文件夹拖放到下方区域,以显示带有名牌和头像的 UserBox。所有文件都在浏览器中处理。',
'userbox.new.setup.notice': '选择包含游戏数据的最外层文件夹。',
'userbox.new.setup.processing_file': '正在处理文件',
'userbox.new.setup.finalizing': '正在保存到内部存储',
'userbox.new.drop': '将游戏文件夹拖到此处',
'userbox.new.switch.to_url': '切换到 URL 模式',
'userbox.new.switch.to_drop': '切换到游戏目录模式',
'userbox.new.url_warning': '请输入访问 UserBox 资源的 URL请参阅文档',
'userbox.new.activate_first': '启用 AquaBox需要游戏文件',
'userbox.new.activate_update': '更新 AquaBox需要游戏文件',
'userbox.new.activate': '使用 AquaBox',
'userbox.new.activate_desc': '启用后可显示带有名牌和头像的 UserBox',
'userbox.new.error.invalidFolder': '所选文件夹无效。请确认游戏版本为 Lumi 或更新,并且包含 “A001” 选项包。'
'userbox.new.activate_profile': '在用户页面启用 AquaBox',
'userbox.new.activate_profile_desc': '启用后可在个人页面显示带有名牌和头像的 UserBox',
'userbox.new.error.invalidFolder': '所选文件夹无效。请确认游戏版本为 Lumi 或更新,并且包含 “A000” 选项包。',
'userbox.new.error.invalidUrl': '输入的 URL 无效。'
};
export const zhMaiPhoto: typeof EN_REF_MAI_PHOTO = {
'maiphoto.title': 'Mai 纪念照片库',
'maiphoto.url_warning': '注意:如果想与朋友分享图片的话,请先保存照片再发出去。不要复制图片 URL因为 URL 中包含 AquaDX 账号信息。',
'maiphoto.none': '还没有图片哦~ 可以在每次游戏结束的时候点击上传来上传照片。',
}
export const zhAquaTrans: typeof EN_REF_AQUATRANS = {
'trans.title': '🏳️‍⚧️ AquaTrans™ 数据迁移工具',
'trans.confirm.unbackuped.title': '确认迁移',
'trans.confirm.unbackuped.msg': '似乎还没有备份目标服务器的数据,真的要继续吗?(推荐先备份一下,因为迁移的时候会覆盖数据)',
'trans.confirm.untested.title': '不太聪明喵',
'trans.confirm.untested.msg': '在两个服务器上都测试完连接之后才能进行数据迁移哦!',
'trans.confirm.done.title': '完成!',
'trans.confirm.done.msg': '数据迁移成功!在 ${dst} 上的数据已被来自 ${src} 的数据覆盖。',
'trans.alert.in-progress': '在迁移了在迁移了',
'trans.prompt-html': `
<p>👋 欢迎使用 AquaTrans™ 服务器游玩数据迁移工具!</p>
<p>这个工具可以导出任意服务器的数据,并使用连接凭证(卡号、服务器地址和 Keychip ID将数据导入任何其他服务器。</p>
<p>我将模拟游戏客户端,从源服务器拉取游戏数据并推送到目标服务器。</p>
<p>填写下面的表格开始迁移吧!</p>
`,
'trans.error.empty': '请填写所有字段。',
'trans.error.untested': '请先进行连接测试。',
'trans.success.import': '数据导入成功!',
'trans.source.title': '源服务器',
'trans.target.title': '目标服务器',
'trans.field.addr': '服务器地址',
'trans.field.keychip': '狗号',
'trans.field.game': '游戏',
'trans.field.version': '版本',
'trans.field.card': '卡号',
'trans.btn.test': '测试连接',
'trans.btn.export': '导出数据',
'trans.btn.import': '导入数据',
'trans.blacklist': "这个服务器的服主把这个导出工具 ban 了,所以不能从这里导出",
}
export const ZH = { ...zhUser, ...zhWelcome, ...zhGeneral,
...zhLeaderboard, ...zhHome, ...zhSettings, ...zhUserbox }
...zhLeaderboard, ...zhHome, ...zhSettings, ...zhUserbox, ...zhMaiPhoto,
...zhAquaTrans
}

View File

@@ -0,0 +1,248 @@
{
"AF": "阿富汗",
"AX": "奥兰群岛",
"AL": "阿尔巴尼亚",
"DZ": "阿尔及利亚",
"AS": "美属萨摩亚",
"AD": "安道尔",
"AO": "安哥拉",
"AI": "安圭拉",
"AQ": "南极洲",
"AG": "安提瓜和巴布达",
"AR": "阿根廷",
"AM": "亚美尼亚",
"AW": "阿鲁巴",
"AU": "澳大利亚",
"AT": "奥地利",
"AZ": "阿塞拜疆",
"BS": "巴哈马",
"BH": "巴林",
"BD": "孟加拉国",
"BB": "巴巴多斯",
"BY": "白俄罗斯",
"BE": "比利时",
"BZ": "伯利兹",
"BJ": "贝宁",
"BM": "百慕大",
"BT": "不丹",
"BO": "玻利维亚",
"BA": "波斯尼亚和黑塞哥维那",
"BW": "博茨瓦纳",
"BV": "布维岛",
"BR": "巴西",
"IO": "英属印度洋领地",
"BN": "文莱达鲁萨兰国",
"BG": "保加利亚",
"BF": "布基纳法索",
"BI": "布隆迪",
"KH": "柬埔寨",
"CM": "喀麦隆",
"CA": "加拿大",
"CV": "佛得角",
"KY": "开曼群岛",
"CF": "中非共和国",
"TD": "乍得",
"CL": "智利",
"CN": "中国",
"CX": "圣诞岛",
"CC": "科科斯(基林)群岛",
"CO": "哥伦比亚",
"KM": "科摩罗",
"CG": "刚果",
"CD": "刚果民主共和国",
"CK": "库克群岛",
"CR": "哥斯达黎加",
"CI": "科特迪瓦",
"HR": "克罗地亚",
"CU": "古巴",
"CY": "塞浦路斯",
"CZ": "捷克共和国",
"DK": "丹麦",
"DJ": "吉布提",
"DM": "多米尼加",
"DO": "多米尼加共和国",
"EC": "厄瓜多尔",
"EG": "埃及",
"SV": "萨尔瓦多",
"GQ": "赤道几内亚",
"ER": "厄立特里亚",
"EE": "爱沙尼亚",
"ET": "埃塞俄比亚",
"FK": "福克兰群岛(马尔维纳斯群岛)",
"FO": "法罗群岛",
"FJ": "斐济",
"FI": "芬兰",
"FR": "法国",
"GF": "法属圭亚那",
"PF": "法属波利尼西亚",
"TF": "法属南部领土",
"GA": "加蓬",
"GM": "冈比亚",
"GE": "格鲁吉亚",
"DE": "德国",
"GH": "加纳",
"GI": "直布罗陀",
"GR": "希腊",
"GL": "格陵兰",
"GD": "格林纳达",
"GP": "瓜德罗普",
"GU": "关岛",
"GT": "危地马拉",
"GG": "根西岛",
"GN": "几内亚",
"GW": "几内亚比绍",
"GY": "圭亚那",
"HT": "海地",
"HM": "赫德岛和麦克唐纳群岛",
"VA": "梵蒂冈(教廷)",
"HN": "洪都拉斯",
"HK": "香港",
"HU": "匈牙利",
"IS": "冰岛",
"IN": "印度",
"ID": "印度尼西亚",
"IR": "伊朗(伊斯兰共和国)",
"IQ": "伊拉克",
"IE": "爱尔兰",
"IM": "马恩岛",
"IL": "以色列",
"IT": "意大利",
"JM": "牙买加",
"JP": "日本",
"JE": "泽西岛",
"JO": "约旦",
"KZ": "哈萨克斯坦",
"KE": "肯尼亚",
"KI": "基里巴斯",
"KR": "韩国",
"KP": "朝鲜",
"KW": "科威特",
"KG": "吉尔吉斯斯坦",
"LA": "老挝人民民主共和国",
"LV": "拉脱维亚",
"LB": "黎巴嫩",
"LS": "莱索托",
"LR": "利比里亚",
"LY": "利比亚阿拉伯民众国",
"LI": "列支敦士登",
"LT": "立陶宛",
"LU": "卢森堡",
"MO": "澳门",
"MK": "马其顿",
"MG": "马达加斯加",
"MW": "马拉维",
"MY": "马来西亚",
"MV": "马尔代夫",
"ML": "马里",
"MT": "马耳他",
"MH": "马绍尔群岛",
"MQ": "马提尼克",
"MR": "毛里塔尼亚",
"MU": "毛里求斯",
"YT": "马约特",
"MX": "墨西哥",
"FM": "密克罗尼西亚联邦",
"MD": "摩尔多瓦",
"MC": "摩纳哥",
"MN": "蒙古",
"ME": "黑山",
"MS": "蒙特塞拉特",
"MA": "摩洛哥",
"MZ": "莫桑比克",
"MM": "缅甸",
"NA": "纳米比亚",
"NR": "瑙鲁",
"NP": "尼泊尔",
"NL": "荷兰",
"AN": "荷属安的列斯",
"NC": "新喀里多尼亚",
"NZ": "新西兰",
"NI": "尼加拉瓜",
"NE": "尼日尔",
"NG": "尼日利亚",
"NU": "纽埃",
"NF": "诺福克岛",
"MP": "北马里亚纳群岛",
"NO": "挪威",
"OM": "阿曼",
"PK": "巴基斯坦",
"PW": "帕劳",
"PS": "被占领的巴勒斯坦领土",
"PA": "巴拿马",
"PG": "巴布亚新几内亚",
"PY": "巴拉圭",
"PE": "秘鲁",
"PH": "菲律宾",
"PN": "皮特凯恩",
"PL": "波兰",
"PT": "葡萄牙",
"PR": "波多黎各",
"QA": "卡塔尔",
"RE": "留尼汪",
"RO": "罗马尼亚",
"RU": "俄罗斯联邦",
"RW": "卢旺达",
"BL": "圣巴泰勒米",
"SH": "圣赫勒拿",
"KN": "圣基茨和尼维斯",
"LC": "圣卢西亚",
"MF": "圣马丁",
"PM": "圣皮埃尔和密克隆",
"VC": "圣文森特和格林纳丁斯",
"WS": "萨摩亚",
"SM": "圣马力诺",
"ST": "圣多美和普林西比",
"SA": "沙特阿拉伯",
"SN": "塞内加尔",
"RS": "塞尔维亚",
"SC": "塞舌尔",
"SL": "塞拉利昂",
"SG": "新加坡",
"SK": "斯洛伐克",
"SI": "斯洛文尼亚",
"SB": "所罗门群岛",
"SO": "索马里",
"ZA": "南非",
"GS": "南乔治亚和南桑威奇群岛",
"ES": "西班牙",
"LK": "斯里兰卡",
"SD": "苏丹",
"SR": "苏里南",
"SJ": "斯瓦尔巴和扬马延",
"SZ": "斯威士兰",
"SE": "瑞典",
"CH": "瑞士",
"SY": "叙利亚",
"TW": "台湾",
"TJ": "塔吉克斯坦",
"TZ": "坦桑尼亚",
"TH": "泰国",
"TL": "东帝汶",
"TG": "多哥",
"TK": "托克劳",
"TO": "汤加",
"TT": "特立尼达和多巴哥",
"TN": "突尼斯",
"TR": "土耳其",
"TM": "土库曼斯坦",
"TC": "特克斯和凯科斯群岛",
"TV": "图瓦卢",
"UG": "乌干达",
"UA": "乌克兰",
"AE": "阿拉伯联合酋长国",
"GB": "英国",
"US": "美国",
"UM": "美国本土外岛",
"UY": "乌拉圭",
"UZ": "乌兹别克斯坦",
"VU": "瓦努阿图",
"VE": "委内瑞拉",
"VN": "越南",
"VG": "英属维京群岛",
"VI": "美属维京群岛",
"WF": "瓦利斯和富图纳",
"EH": "西撒哈拉",
"YE": "也门",
"ZM": "赞比亚",
"ZW": "津巴布韦"
}

View File

@@ -67,7 +67,7 @@ const multTable = {
[ 60.0, 0, 'B' ],
[ 1.0, 0, 'C' ],
[ 0.0, 0, 'D' ]
]
],
}
export function getMult(achievement: number, game: GameName) {
@@ -134,8 +134,8 @@ export function parseComposition(item: string, allMusics: Record<string, MusicMe
if (!diff) return
if (game === 'mai2')
return Math.floor(diff * mult * (Math.min(100.5, score / 10000) / 100)).toFixed(0)
if (game === 'chu3')
return (chusanRating(diff, score) / 100).toFixed(1)
if (game === 'chu3' || game === 'ongeki')
return (Math.floor(chusanRating(diff, score)) / 100).toFixed(2)
}
return {

View File

@@ -8,13 +8,14 @@ import type {
TrendEntry,
AquaNetUser, GameOption,
UserBox,
UserItem
UserItem,
Dict
} from './generalTypes'
import type { GameName } from './scoring'
interface RequestInitWithParams extends RequestInit {
interface ExtReqInit extends RequestInit {
params?: { [index: string]: string }
localCache?: boolean
json?: any
}
/**
@@ -37,188 +38,113 @@ export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) =>
/**
* Fetch with url parameters
*/
export function fetchWithParams(input: URL | RequestInfo, init?: RequestInitWithParams): Promise<Response> {
export function fetchWithParams(input: URL | RequestInfo, init?: ExtReqInit): Promise<Response> {
return fetch(reconstructUrl(input, u => {
u.search = new URLSearchParams(init?.params ?? {}).toString()
}), init)
}
const cache: { [index: string]: any } = {}
/**
* Do something with the response when it's not ok
*
* @param res Response object
*/
async function ensureOk(res: Response) {
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
export async function post(endpoint: string, params: Record<string, any> = {}, init?: RequestInitWithParams): Promise<any> {
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
}
/**
* Post to an endpoint and return the response in JSON while doing error checks
* and handling token (and token expiry) automatically.
*
* @param endpoint The endpoint to post to (e.g., '/pull')
* @param params An object containing the request body or any necessary parameters
* @param init Additional fetch/init configuration
* @returns The JSON response from the server
*/
export async function post(endpoint: string, params: Dict = {}, init?: ExtReqInit): Promise<any> {
return postHelper(endpoint, params, init).then(it => it.json())
}
/**
* Actual impl of post(). This does not return JSON but returns response object.
*/
async function postHelper(endpoint: string, params: Dict = {}, init?: ExtReqInit): Promise<any> {
// Add token if exists
const token = localStorage.getItem('token')
if (token && !('token' in params)) params = { ...(params ?? {}), token }
if (init?.localCache) {
const cached = cache[endpoint + JSON.stringify(params) + JSON.stringify(init)]
if (cached) return cached
if (init?.json) {
init.body = JSON.stringify(init.json)
init.headers = { 'Content-Type': 'application/json', ...init.headers }
init.json = undefined
}
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'POST',
params,
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
const res = await fetchWithParams(AQUA_HOST + endpoint, { method: 'POST', params, ...init })
.catch(e => { console.error(e); throw new Error("Network error") })
await ensureOk(res)
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
const ret = res.json()
cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] = ret
return ret
return res
}
export async function get(endpoint: string, params:any,init?: RequestInitWithParams): Promise<any> {
// Add token if exists
const token = localStorage.getItem('token')
if (token && !('token' in params)) params = { ...(params ?? {}), token }
const decoder = new TextDecoder()
if (init?.localCache) {
const cached = cache[endpoint + JSON.stringify(init)]
if (cached) return cached
/**
* Post with a stream response. Similar to post(), but the response will stream messages to onChunk.
*/
export async function postStream(endpoint: string, params: Dict = {}, onChunk: (data: any) => void, init?: ExtReqInit): Promise<void> {
const res = await postHelper(endpoint, params, init)
if (!res.body) {
console.error('Response body is not a stream')
return
}
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'GET',
params,
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
// The response body is a ReadableStream. We'll read chunks as they arrive.
const reader = res.body?.getReader()
if (!reader) return
let buffer = ''
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
// Decode any new data, parse full lines, keep the rest in buffer
buffer += decoder.decode(value, { stream: true })
let fullLines = buffer.split('\n')
buffer = fullLines.pop() ?? ''
for (const line of fullLines) {
if (!line.trim()) continue // skip empty lines
onChunk(JSON.parse(line))
}
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
// If there's leftover data in 'buffer' after stream ends, parse
if (buffer.trim())
onChunk(JSON.parse(buffer.trim()))
} finally {
reader.releaseLock()
}
const ret = res.json()
cache[endpoint + JSON.stringify(init)] = ret
return ret
}
export async function put(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
// Add token if exists
const token = localStorage.getItem('token')
if (token && !('token' in params)) params = { ...(params ?? {}), token }
if (init?.localCache) {
const cached = cache[endpoint + JSON.stringify(params) + JSON.stringify(init)]
if (cached) return cached
}
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'PUT',
body: JSON.stringify(params),
headers:{
'Content-Type':'application/json',
...init?.headers
},
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
const ret = res.json()
cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] = ret
return ret
}
export async function realPost(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'POST',
body: JSON.stringify(params),
headers:{
'Content-Type':'application/json',
...init?.headers
},
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
return res.json()
}
/**
@@ -229,6 +155,7 @@ export async function realPost(endpoint: string, params: any, init?: RequestInit
async function register(user: { username: string, email: string, password: string, turnstile: string }) {
return await post('/api/v2/user/register', user)
}
async function login(user: { email: string, password: string, turnstile: string }) {
const data = await post('/api/v2/user/login', user)
@@ -236,12 +163,22 @@ async function login(user: { email: string, password: string, turnstile: string
localStorage.setItem('token', data.token)
}
async function resetPassword(user: { email: string, turnstile: string }) {
return await post('/api/v2/user/reset-password', user)
}
async function changePassword(user: { token: string, password: string }) {
return await post('/api/v2/user/change-password', user)
}
const isLoggedIn = () => !!localStorage.getItem('token')
const ensureLoggedIn = () => !isLoggedIn() && (window.location.href = '/')
export const USER = {
register,
login,
resetPassword,
changePassword,
confirmEmail: (token: string) =>
post('/api/v2/user/confirm-email', { token }),
me: (): Promise<AquaNetUser> => {
@@ -259,13 +196,17 @@ export const USER = {
},
isLoggedIn,
ensureLoggedIn,
changeRegion: (regionId: number) =>
post('/api/v2/user/change-region', { regionId }),
}
export const USERBOX = {
getProfile: (): Promise<{ user: UserBox, items: UserItem[] }> =>
get('/api/v2/game/chu3/user-box', {}),
post('/api/v2/game/chu3/user-box', {}),
setUserBox: (d: { field: string, value: number | string }) =>
post(`/api/v2/game/chu3/user-detail-set`, d),
getUserProfile: (username: string): Promise<UserBox> =>
post(`/api/v2/game/chu3/user-detail`, {username})
}
export const CARD = {
@@ -282,6 +223,8 @@ export const CARD = {
export const GAME = {
trend: (username: string, game: GameName): Promise<TrendEntry[]> =>
post(`/api/v2/game/${game}/trend`, { username }),
photos: (): Promise<string[]> =>
post(`/api/v2/game/mai2/my-photo`, { }),
userSummary: (username: string, game: GameName): Promise<GenericGameSummary> =>
post(`/api/v2/game/${game}/user-summary`, { username }),
ranking: (game: GameName): Promise<GenericRanking[]> =>
@@ -291,9 +234,9 @@ export const GAME = {
export: (game: GameName): Promise<Record<string, any>> =>
post(`/api/v2/game/${game}/export`),
import: (game: GameName, data: any): Promise<Record<string, any>> =>
post(`/api/v2/game/${game}/import`, {}, { body: JSON.stringify(data) }),
post(`/api/v2/game/${game}/import`, {}, { json: data }),
importMusicDetail: (game: GameName, data: any): Promise<Record<string, any>> =>
post(`/api/v2/game/${game}/import-music-detail`, {}, {body: JSON.stringify(data), headers: {'Content-Type': 'application/json'}}),
post(`/api/v2/game/${game}/import-music-detail`, {}, { json: data }),
setRival: (game: GameName, rivalUserName: string, isAdd: boolean) =>
post(`/api/v2/game/${game}/set-rival`, { rivalUserName, isAdd }),
}
@@ -310,4 +253,27 @@ export const SETTING = {
post('/api/v2/settings/get', {}),
set: (key: string, value: any) =>
post('/api/v2/settings/set', { key, value: `${value}` }),
detailSet: (game: string, field: string, value: any) =>
post(`/api/v2/game/${game}/user-detail-set`, { field, value }),
}
export const TRANSFER = {
check: (d: AllNetClient): Promise<TrCheckGood> =>
post('/api/v2/transfer/check', {}, { json: d }),
pull: (d: AllNetClient, callback: (data: TrStreamMessage) => void) =>
postStream('/api/v2/transfer/pull', {}, callback, { json: d }),
push: (d: AllNetClient, data: string) =>
post('/api/v2/transfer/push', {}, { json: { client: d, data } }),
}
export const FEDY = {
status: (): Promise<{ linkedAt: number }> =>
post('/api/v2/fedy/status'),
link: (nonce: string): Promise<{ linkedAt: number }> =>
post('/api/v2/fedy/link', { nonce }),
unlink: () =>
post('/api/v2/fedy/unlink'),
}
// @ts-ignore
window.sdk = { USER, USERBOX, CARD, GAME, DATA, SETTING, TRANSFER, FEDY }

View File

@@ -153,6 +153,7 @@ export const CHARTJS_OPT: ChartOptions<'line'> = {
export const pfpNotFound = (e: Event) => (e.target as HTMLImageElement).src = DEFAULT_PFP
export const coverNotFound = (e: Event) => (e.target as HTMLImageElement).src = "/assets/imgs/no_cover.jpg"
export const removeImg = (e: Event) => (e.target as HTMLImageElement).style.display = 'none'
/**
@@ -211,3 +212,53 @@ export function pfp(node: HTMLImageElement, me?: AquaNetUser) {
node.src = me?.profilePicture ? `${AQUA_HOST}/uploads/net/portrait/${me.profilePicture}` : DEFAULT_PFP
node.onerror = e => pfpNotFound(e as Event)
}
export function download(data: string, filename: string) {
const blob = new Blob([data]);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
}
export async function selectJsonFile(): Promise<any> {
return new Promise((resolve, reject) => {
// Create a hidden file input element
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.style.display = 'none';
// Listen for when the user selects a file
input.addEventListener('change', (event: Event) => {
const target = event.target as HTMLInputElement;
if (!target.files || target.files.length === 0) {
return reject(new Error("No file selected"));
}
const file = target.files[0];
const reader = new FileReader();
reader.onload = () => {
try {
const jsonData = JSON.parse(reader.result as string);
resolve(jsonData);
} catch (error) {
reject(new Error("Error parsing JSON: " + error));
}
};
reader.onerror = () => {
reject(new Error("Error reading file"));
};
reader.readAsText(file);
});
// Append the input to the DOM, trigger click, and then remove it
document.body.appendChild(input);
input.click();
document.body.removeChild(input);
});
}

View File

@@ -56,6 +56,12 @@ void main() {
gl_FragColor = texture2D(uTexture, vTextureCoord);
}`
export interface RGB {
r: number,
g: number,
b: number
}
export class DDS {
constructor(db: IDBDatabase | undefined) {
this.cache = new DDSCache(db);
@@ -241,13 +247,27 @@ export class DDS {
* @param s Scale factor
* @returns An object URL which correlates to a Blob
*/
async getFileFromSheet(path: string, x: number, y: number, w: number, h: number, s?: number): Promise<string> {
async getFileFromSheet(path: string, x: number, y: number, w: number, h: number, s?: number, color?: RGB): Promise<string> {
if (!await this.loadFile(path))
return "";
this.canvas2D.width = w * (s ?? 1);
this.canvas2D.height = h * (s ?? 1);
this.ctx.drawImage(this.canvasGL, x, y, w, h, 0, 0, w * (s ?? 1), h * (s ?? 1));
if (color) {
let colorData = this.ctx.getImageData(0, 0, this.canvas2D.width, this.canvas2D.height);
for (let i = 0; colorData.data.length > i; i++)
switch (i % 4) {
case 0:
colorData.data[i] *= (color.r / 255); break;
case 1:
colorData.data[i] *= (color.g / 255); break;
case 2:
colorData.data[i] *= (color.b / 255); break;
}
this.ctx.putImageData(colorData, 0, 0);
}
/* We don't want to cache this, it's a spritesheet piece. */
return URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([]));
};

View File

@@ -1,3 +1,6 @@
import useLocalStorage from "../hooks/useLocalStorage.svelte";
import { USERBOX_DEFAULT_URL } from "../config";
export default class DDSCache {
constructor(db: IDBDatabase | undefined) {
this.db = db;
@@ -43,7 +46,13 @@ export default class DDSCache {
* @param path Image path
*/
getFromDatabase(path: string): Promise<Blob | null> {
return new Promise((resolve, reject) => {
return new Promise(async (resolve, reject) => {
if (this.userboxURL.value != "") {
let targetPath = path.replaceAll(":", "/");
let response = await fetch(`${this.userboxURL.value}/${targetPath}.chu`).then(b => b.blob()).catch(reject);
if (response)
return resolve(response);
};
if (!this.db)
return resolve(null);
let transaction = this.db.transaction(["dds"], "readonly");
@@ -61,4 +70,5 @@ export default class DDSCache {
private urlCache: {scale: number, path: string, url: string}[] = [];
private db: IDBDatabase | undefined;
userboxURL = useLocalStorage("userboxURL", USERBOX_DEFAULT_URL);
}

View File

@@ -5,6 +5,7 @@ const isDirectory = (e: FileSystemEntry): e is FileSystemDirectoryEntry => e.isD
const isFile = (e: FileSystemEntry): e is FileSystemFileEntry => e.isFile
const getDirectory = (directory: FileSystemDirectoryEntry, path: string): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getDirectory(path, {}, d => res(d), e => rej()));
const getParent = (directory: FileSystemDirectoryEntry): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getParent(d => res(d), e => rej()));
const getFile = (directory: FileSystemDirectoryEntry, path: string): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getFile(path, {}, d => res(d), e => rej()));
const getFiles = async (directory: FileSystemDirectoryEntry): Promise<Array<FileSystemEntry>> => {
let reader = directory.createReader();
@@ -44,6 +45,26 @@ const getDirectoryFromPath = async (base: FileSystemDirectoryEntry, path: string
return directory;
}
const scanRecursive = async (root: FileSystemDirectoryEntry, target: string): Promise<FileSystemDirectoryEntry | undefined> => {
let directories: FileSystemEntry[] = [root];
while (directories.length > 0) {
const directory = directories[0] as FileSystemDirectoryEntry;
if (directory.isDirectory) {
if (directory.name == target)
return directory;
let children: FileSystemEntry[] = await new Promise(r => directory.createReader().readEntries(d => r(d)));
directories = [
...directories,
...(children.filter(v => v.isDirectory))
];
}
directories.shift();
}
return;
}
export let ddsDB: IDBDatabase | undefined ;
/* Technically, processName should be in the translation file but I figured it was such a small thing that it didn't REALLY matter... */
@@ -85,6 +106,8 @@ const DIRECTORY_PATHS = ([
([
"CHU_UI_Common_Avatar_body_00.dds",
"CHU_UI_Common_Avatar_face_00.dds",
"CHU_UI_Common_01_v11.dds",
"CHU_UI_TeamEmblem_01_v14.dds",
"CHU_UI_title_rank_00_v10.dds"
]).includes(name),
id: (name: string) => name
@@ -163,17 +186,17 @@ export function initializeDb() : Promise<void> {
export async function userboxFileProcess(folder: FileSystemEntry, progressUpdate: (progress: number, progressString: string) => void): Promise<string | null> {
if (!isDirectory(folder))
return t("userbox.new.error.invalidFolder")
if (!(await validateDirectories(folder, "bin/option")) && !(await validateDirectories(folder, "data/A000")))
return t("userbox.new.error.invalidFolder");
initializeDb();
const optionFolder = await getDirectoryFromPath(folder, "bin/option");
const optionFolder = await scanRecursive(folder, "A001");
if (optionFolder)
await scanOptionFolder(optionFolder, progressUpdate);
const dataFolder = await getDirectoryFromPath(folder, "data");
await scanOptionFolder((await getParent(optionFolder)) as FileSystemDirectoryEntry, progressUpdate);
const dataFolder = await scanRecursive(folder, "A000");
if (dataFolder)
await scanOptionFolder(dataFolder, progressUpdate);
await scanOptionFolder((await getParent(dataFolder)) as FileSystemDirectoryEntry, progressUpdate);
useLocalStorage("userboxURL", "").value = "";
useLocalStorage("userboxNew", false).value = true;
useLocalStorage("userboxNewProfile", false).value = true;
location.reload();
return null

View File

@@ -9,15 +9,23 @@
<div class="setup-instructions">
<h2>{t('home.join-community')}</h2>
<div class="grid cols-3 gap-4">
<CommunityCard color="82, 93, 233" icon="ic:baseline-discord" on:click={() => window.location.href = DISCORD_INVITE}>
<h3>Discord</h3>
</CommunityCard>
<CommunityCard color="46, 163, 224" icon="mingcute:telegram-fill" on:click={() => window.location.href = TELEGRAM_INVITE}>
<h3>Telegram</h3>
</CommunityCard>
<CommunityCard color="226, 60, 68" icon="ri:qq-fill" on:click={() => window.location.href = QQ_INVITE}>
<h3>QQ</h3>
</CommunityCard>
{#if DISCORD_INVITE}
<CommunityCard color="82, 93, 233" icon="ic:baseline-discord" on:click={() => window.location.href = DISCORD_INVITE}>
<h3>{t('home.community.discord')}</h3>
</CommunityCard>
{/if}
{#if TELEGRAM_INVITE}
<CommunityCard color="46, 163, 224" icon="mingcute:telegram-fill" on:click={() => window.location.href = TELEGRAM_INVITE}>
<h3>{t('home.community.telegram')}</h3>
</CommunityCard>
{/if}
{#if QQ_INVITE}
<CommunityCard color="226, 60, 68" icon="ri:qq-fill" on:click={() => window.location.href = QQ_INVITE}>
<h3>{t('home.community.qq')}</h3>
</CommunityCard>
{/if}
</div>
</div>

View File

@@ -2,12 +2,12 @@
<script lang="ts">
import { fade, slide } from "svelte/transition"
import type { Card, CardSummary, CardSummaryGame, ConfirmProps, AquaNetUser } from "../../libs/generalTypes";
import { CARD, USER } from "../../libs/sdk";
import type { Card, CardSummary, CardSummaryGame, ConfirmProps, AquaNetUser } from "../../libs/generalTypes"
import { CARD, USER } from "../../libs/sdk"
import moment from "moment"
import Icon from "@iconify/svelte";
import StatusOverlays from "../../components/StatusOverlays.svelte";
import { t } from "../../libs/i18n";
import Icon from "@iconify/svelte"
import StatusOverlays from "../../components/StatusOverlays.svelte"
import { t } from "../../libs/i18n"
// State
let state: 'ready' | 'linking-AC' | 'linking-SN' | 'loading' = "loading"
@@ -42,14 +42,22 @@
}
async function doLink(id: string, migrate: string) {
await CARD.link({cardId: id, migrate})
await updateMe()
try {
await CARD.link({cardId: id, migrate})
await updateMe()
if (linkingType === 'AC') inputAC = ""
if (linkingType === 'SN') inputSN = ""
} catch (e) {
setError(e.message, linkingType)
}
state = "ready"
}
let linkingType: 'AC' | 'SN' = null
async function link(type: 'AC' | 'SN') {
if (state !== 'ready' || accountCardSummary === null) return
state = "linking-" + type
linkingType = type
const id = type === 'AC' ? inputAC : inputSN
console.log("linking card", id)
@@ -64,7 +72,7 @@
// First, lookup the card summary
const card = (await CARD.summary(id).catch(e => {
// If card is not found, create a card and link it
if (e.message === t('home.linkcard.notfound')) {
if (e.message === 'Card not found') {
doLink(id, "")
return
}
@@ -156,40 +164,66 @@
}
}
function cursorPositionToCursorIndex(text: string, cursorPosition: number, effectiveCharsRegex: RegExp) {
const textBeforeCursor = text.slice(0, cursorPosition)
const ignoredChars = textBeforeCursor.replace(new RegExp(effectiveCharsRegex, "g"), "")
return textBeforeCursor.length - ignoredChars.length
}
function cursorIndexToCursorPosition(text: string, cursorIndex: number, effectiveCharsRegex: RegExp) {
let i = 0
while (i < text.length) {
while (i < text.length && !effectiveCharsRegex.test(text[i])) i++
if (cursorIndex === 0) break
cursorIndex--
i++
}
return i
}
// Access code input
const inputACRegex = /^(\d{4} ){0,4}\d{0,4}$/
let elemInputAC: HTMLInputElement
let inputOldAC = ""
let inputAC = ""
let errorAC = ""
let warningAC = ""
function inputACChange(e: any) {
e = e as InputEvent
function inputACChange() {
// Add spaces to the input
const old = inputAC
if (e.inputType === "insertText" && inputAC.length % 5 === 4 && inputAC.length < 24)
inputAC += " "
inputAC = inputAC.slice(0, 24)
if (inputAC !== old) errorAC = ""
const cursorIndex = cursorPositionToCursorIndex(inputAC, elemInputAC.selectionStart, /\d/)
inputAC = inputAC.replace(/\D/g, '').replace(/(.{4})/g, '$1 ').replace(/ $/, '')
const cursorPosition = cursorIndexToCursorPosition(inputAC, cursorIndex, /\d/)
setTimeout(() => elemInputAC.selectionStart = elemInputAC.selectionEnd = cursorPosition, 0)
if (inputAC !== inputOldAC) errorAC = ""
warningAC = inputAC[0] === "5" ? t('home.linkcard.felica-ac-warning') : ""
inputOldAC = inputAC
}
// Serial number input
const inputSNRegex = /^([0-9A-Fa-f]{0,2}:){0,7}[0-9A-Fa-f]{0,2}$/
let inputElemSN: HTMLInputElement
let inputOldSN = ""
let inputSN = ""
let errorSN = ""
function inputSNChange(e: any) {
e = e as InputEvent
function inputSNChange() {
// Add colons to the input
const old = inputSN
if (e.inputType === "insertText" && inputSN.length % 3 === 2 && inputSN.length < 23)
inputSN += ":"
inputSN = inputSN.toUpperCase().slice(0, 23)
if (inputSN !== old) errorSN = ""
inputSN = inputSN.toUpperCase()
const cursorIndex = cursorPositionToCursorIndex(inputSN, inputElemSN.selectionStart, /[0-9A-F]/)
inputSN = inputSN.replace(/[^0-9A-F]/g, '').replace(/(.{2})/g, '$1:').replace(/:$/, '')
const cursorPosition = cursorIndexToCursorPosition(inputSN, cursorIndex, /[0-9A-F]/)
setTimeout(() => inputElemSN.selectionStart = inputElemSN.selectionEnd = cursorPosition, 0)
if (inputSN !== inputOldSN) errorSN = ""
inputOldSN = inputSN
}
function formatLUID(luid: string, ghost: boolean = false) {
if (ghost) return luid.slice(0, 6) + " " + (luid.slice(6).match(/.{4}/g)?.join(" ") ?? "")
switch (cardType(luid)) {
case "Felica SN":
case "FeliCa SN":
return BigInt(luid).toString(16).toUpperCase().padStart(16, "0").match(/.{1,2}/g)!.join(":")
case "Access Code":
return luid.match(/.{4}/g)!.join(" ")
@@ -199,9 +233,9 @@
}
function cardType(luid: string) {
if (luid.startsWith("00")) return "Felica SN"
if (luid.startsWith("00")) return "FeliCa SN"
if (luid.length === 20) return "Access Code"
if (luid.includes(":")) return "Felica SN"
if (luid.includes(":")) return "FeliCa SN"
if (luid.includes(" ")) return "Access Code"
return "Unknown"
}
@@ -209,9 +243,29 @@
function isInput(e: KeyboardEvent) {
return e.key.length === 1 && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey
}
async function dropFile(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
const file = e.dataTransfer?.files[0]
if (!file) return
switch (file.name.toLowerCase()) {
case "aime.txt":
inputSN = ""
inputAC = await file.text()
inputACChange()
break
case "felica.txt":
inputAC = ""
inputSN = await file.text()
inputSNChange()
break
}
}
</script>
<div class="link-card">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="link-card" on:drop={dropFile} on:dragover={(e) => e.preventDefault()}>
<h2>{t('home.linkcard.cards')}</h2>
<p>{t('home.linkcard.description')}:</p>
@@ -239,7 +293,8 @@
<p>{t('home.linkcard.access-code')}</p>
<label>
<!-- DO NOT change the order of bind:value and on:input. Their order determines the order of reactivity -->
<input placeholder="e.g. 5200 1234 5678 9012 3456"
<input bind:this={elemInputAC}
placeholder="e.g. 2408 1234 5678 9012 3456 / 0008 1234 5678 8765 4321"
on:keydown={(e) => {
e.key === "Enter" && link('AC')
// Ensure key is numeric
@@ -247,13 +302,23 @@
}}
bind:value={inputAC}
on:input={inputACChange}
class:error={inputAC && (!inputACRegex.test(inputAC) || errorAC)}>
class:error={inputAC && (!inputACRegex.test(inputAC) || errorAC)}
class:warning={inputAC && warningAC}>
{#if inputAC.length > 0}
<button transition:slide={{axis: 'x'}} on:click={() => {link('AC');inputAC=''}}>{t('home.linkcard.link')}</button>
<button transition:slide={{axis: 'x'}} on:click={() => link('AC')}>{t('home.linkcard.link')}</button>
{/if}
</label>
<blockquote>{t('home.linkcard.kdx-notice')}</blockquote>
{#if errorAC}
<p class="error" transition:slide>{errorAC}</p>
<p class="error" style={warningAC ? "margin-bottom: 0" : ""} transition:slide>{errorAC}</p>
{/if}
{#if warningAC}
<!-- Transition temporarily adds `overflow: hidden` which leads to BFC issue, breaking margin collapse -->
<div style="overflow: hidden" transition:slide>
{#each warningAC.trim().split("\n") as paragraph}
<p class="warning">{paragraph}</p>
{/each}
</div>
{/if}
</div>
{/if}
@@ -266,7 +331,8 @@
{t('home.linkcard.enter-sn2')}
</p>
<label>
<input placeholder="e.g. 01:2E:1A:2B:3C:4D:5E:6F"
<input bind:this={inputElemSN}
placeholder="e.g. 01:2E:1A:2B:3C:4D:5E:6F"
on:keydown={(e) => {
e.key === "Enter" && link('SN')
// Ensure key is hex or colon
@@ -276,7 +342,7 @@
on:input={inputSNChange}
class:error={inputSN && (!inputSNRegex.test(inputSN) || errorSN)}>
{#if inputSN.length > 0}
<button transition:slide={{axis: 'x'}} on:click={() => {link('SN'); inputSN = ''}}>{t('home.linkcard.link')}</button>
<button transition:slide={{axis: 'x'}} on:click={() => link('SN')}>{t('home.linkcard.link')}</button>
{/if}
</label>
{#if errorSN}

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import {GAME} from "../libs/sdk";
import {AQUA_HOST} from "../libs/config";
import Loading from "../components/ui/Loading.svelte";
import Error from "../components/ui/Error.svelte";
import { t } from "../libs/i18n";
const token = localStorage.getItem("token")
</script>
<main class="content">
<div class="outer-title-options">
<h2>{t("maiphoto.title")}</h2>
</div>
{#await GAME.photos()}
<Loading/>
{:then photos}
{#if photos.length === 0}
<blockquote>{t('maiphoto.none')}</blockquote>
{:else}
<blockquote>{t('maiphoto.url_warning')}</blockquote>
{/if}
<div class="pictures">
{#each photos as photo}
<div class="photo-container">
<img class="rounded-2xl" src="{AQUA_HOST}/api/v2/game/mai2/my-photo/{photo}?token={token}" alt="Memorial" />
</div>
{/each}
</div>
{:catch error}
<Error {error}/>
{/await}
</main>
<style lang="sass">
@use "../vars"
.pictures
display: flex
flex-wrap: wrap
justify-content: center
row-gap: 1rem
gap: 1rem
.photo-container
flex: 1 1 300px
min-width: 280px
max-width: 100%
display: flex
justify-content: center
.photo-container img
width: 100%
height: auto
object-fit: contain
</style>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import { title } from "../libs/ui";
import { GAME } from "../libs/sdk";
import type { GenericRanking } from "../libs/generalTypes";
@@ -8,6 +9,7 @@
import { t } from "../libs/i18n";
import UserCard from "../components/UserCard.svelte";
import Tooltip from "../components/Tooltip.svelte";
import Pagination from "../components/Pagination.svelte";
export let game: GameName = 'mai2';
@@ -15,15 +17,45 @@
let d: { users: GenericRanking[] };
let error: string | null;
let page = 1
const perPage = 50
let totalPages = 1
function handleUpdatePage(event: CustomEvent<number>) {
page = event.detail;
const url = new URL(window.location.toString())
url.searchParams.set('page', page.toString())
history.pushState({}, '', url.toString())
window.scrollTo(0, 0)
}
onMount(() => {
const url = new URL(window.location.toString())
const pageParam = url.searchParams.get('page')
if (pageParam) {
page = parseInt(pageParam, 10) || 1
}
window.addEventListener('popstate', () => {
const url = new URL(window.location.toString())
const pageParam = url.searchParams.get('page')
page = parseInt(pageParam, 10) || 1
window.scrollTo(0, 0)
})
})
Promise.all([GAME.ranking(game)])
.then(([users]) => {
console.log(users)
d = { users };
d = { users }
totalPages = Math.ceil(users.length / perPage)
})
.catch((e) => error = e.message);
let hoveringUser = "";
let hoverLoading = false;
$: paginatedUsers = d ? d.users.slice((page - 1) * perPage, page * perPage) : []
</script>
<main class="content leaderboard">
@@ -37,8 +69,12 @@
</div>
{#if d}
{#if page > 1}
<Pagination {page} {totalPages} on:updatePage={handleUpdatePage} />
{/if}
<div class="leaderboard-container">
<div class="lb-user" on:mouseenter={() => hoveringUser = d.users[0].username} role="heading" aria-level="2">
<div class="lb-user" on:mouseenter={() => hoveringUser = paginatedUsers[0]?.username} role="heading" aria-level="2">
<span class="rank">{t("Leaderboard.Rank")}</span>
<span class="name"></span>
<span class="rating">{t("Leaderboard.Rating")}</span>
@@ -46,7 +82,7 @@
<span class="fc">{t("Leaderboard.FC")}</span>
<span class="ap">{t("Leaderboard.AP")}</span>
</div>
{#each d.users as user, i (user.rank)}
{#each paginatedUsers as user, i (user.rank)}
<div class="lb-user" class:alternate={i % 2 === 1} role="listitem"
on:mouseover={() => hoveringUser = user.username} on:focus={() => {}}>
@@ -59,7 +95,7 @@
{/if}
</span>
<span class="rating">{
game === 'chu3' ?
game === 'chu3' || game === 'ongeki' ?
(user.rating / 100).toFixed(2) :
user.rating.toLocaleString()
}</span>
@@ -70,6 +106,8 @@
{/each}
</div>
<Pagination {page} {totalPages} on:updatePage={handleUpdatePage} />
<Tooltip triggeredBy=".name" loading={hoverLoading}>
<UserCard username={hoveringUser} {game} setLoading={l => hoverLoading = l} />
</Tooltip>
@@ -132,5 +170,4 @@
&.alternate
background-color: vars.$ov-light
</style>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
export let desc: string
export let value: string
export let placeholder: string
export let flex: number = 60
export let disabled: boolean = false
export let validate: (value: string) => boolean = () => true
</script>
<div class="field" style="flex: {flex}">
<label for={desc}>{desc}</label>
<input type="text" placeholder={placeholder} bind:value={value} id="{desc}" on:change
class:error={value && !validate(value)} {disabled}/>
</div>
<style lang="sass">
.field
display: inline-flex
flex-direction: column
gap: 0.5rem
label
font-weight: bold
</style>

View File

@@ -0,0 +1,129 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { t, ts } from "../../libs/i18n";
import TransferServer from "./TransferServer.svelte";
import { DATA_HOST } from "../../libs/config";
import type { ConfirmProps } from "../../libs/generalTypes";
import StatusOverlays from "../../components/StatusOverlays.svelte";
let tabs = ['chu3', 'mai2', 'ongeki']
let game: Record<string, { game: string, version: string }> = {
'chu3': { game: "SDHD", version: "2.30" },
'mai2': { game: "SDGA", version: "1.50" },
'ongeki': { game: "SDDT", version: "1.45" }
}
let tab = 0
let src = JSON.parse(localStorage.getItem('src') ?? `{"dns": "", "card": "", "keychip": ""}`)
let dst = JSON.parse(localStorage.getItem('dst') ?? `{"dns": "", "card": "", "keychip": ""}`)
let [srcTested, dstTested] = [false, false]
let gameInfo = JSON.parse(localStorage.getItem('gameInfo') ?? `{"game": "", "version": ""}`)
let srcEl: TransferServer, dstEl: TransferServer
let srcExportedData: string
let [error, loading] = ["", false]
let confirm: ConfirmProps | null = null
function defaultGame() {
gameInfo.game = game[tabs[tab]].game
gameInfo.version = game[tabs[tab]].version
}
function onChange() {
localStorage.setItem('src', JSON.stringify(src))
localStorage.setItem('dst', JSON.stringify(dst))
localStorage.setItem('gameInfo', JSON.stringify(gameInfo))
}
function actuallyStartTransfer() {
srcEl.pull()
.then(() => dstEl.push(srcExportedData))
.then(() => confirm = {
title: t('trans.confirm.done.title'),
message: t('trans.confirm.done.msg', { src: src.dns, dst: dst.dns })
})
.catch(e => error = e)
.finally(() => loading = false)
}
function startTransfer() {
if (!(srcTested && dstTested)) return confirm = {
title: t('trans.confirm.untested.title'),
message: t('trans.confirm.untested.msg')
}
if (loading) return alert(t('trans.alert.in-progress'))
console.log("Starting transfer...")
loading = true
if (dstEl.exportedData) return actuallyStartTransfer()
// Ask user to make sure to backup their data
confirm = {
title: t('trans.confirm.unbackuped.title'),
message: t('trans.confirm.unbackuped.msg'),
dangerous: true,
confirm: actuallyStartTransfer,
cancel: () => { loading = false }
}
}
defaultGame()
</script>
<StatusOverlays bind:confirm={confirm} {error} />
<main class="content">
<div class="outer-title-options">
<h2>{t('trans.title')}</h2>
<nav>
{#each tabs as tabName, i}
<div transition:slide={{axis: 'x'}} class:active={tab === i}
on:click={() => tab = i} on:keydown={e => e.key === 'Enter' && (tab = i)}
role="button" tabindex="0">
{ts(`settings.tabs.${tabName}`)}
</div>
{/each}
</nav>
</div>
<div class="prompt">
{@html t("trans.prompt-html")}
</div>
<TransferServer bind:src={src} bind:gameInfo={gameInfo} on:change={onChange}
bind:tested={srcTested} bind:this={srcEl} bind:exportedData={srcExportedData} />
<div class="arrow" class:disabled={!(srcTested && dstTested)}>
<img src="{DATA_HOST}/d/DownArrow.png" alt="arrow" on:click={startTransfer}>
</div>
<TransferServer bind:src={dst} bind:gameInfo={gameInfo} on:change={onChange}
bind:tested={dstTested} bind:this={dstEl} isSrc={false} />
</main>
<style lang="sass">
.arrow
width: 100%
display: flex
justify-content: center
margin-top: -40px
margin-bottom: -40px
z-index: 1
&.disabled
filter: grayscale(1)
// CSS animation to let the image opacity breathe
img
animation: breathe 1s infinite alternate
@keyframes breathe
0%
opacity: 0.5
100%
opacity: 1
</style>

View File

@@ -0,0 +1,21 @@
interface AllNetSrc {
card: string
dns: string
keychip: string
}
interface AllNetGame {
game: string
version: string
}
interface AllNetClient extends AllNetSrc, AllNetGame {}
interface TrCheckGood {
gameUrl: string
userId: number
}
type TrStreamMessage = { message: string } | { error: string } | { data: string }

View File

@@ -0,0 +1,218 @@
<script lang="ts">
import StatusOverlays from "../../components/StatusOverlays.svelte";
import { t } from "../../libs/i18n";
import { TRANSFER } from "../../libs/sdk";
import { download, selectJsonFile } from "../../libs/ui";
import InputTextShort from "./InputTextShort.svelte";
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher()
export let src: AllNetSrc
export let gameInfo: AllNetGame
export let isSrc: boolean = true
export let tested: boolean = false
let [loading, error] = [false, ""]
const blacklist = ['amime.missless.net']
function testConnection() {
if (loading || isBlacklist) return
// Preliminiary checks
if (!src.dns || !src.keychip || !src.card || !gameInfo.game || !gameInfo.version) {
error = t('trans.error.empty')
return
}
loading = true
error = ""
console.log("Testing connection...")
return TRANSFER.check({...src, ...gameInfo}).then(res => {
console.log("Connection test result:", res)
tested = true
}).catch(err => error = err.message).finally(() => loading = false)
}
let messages: string[] = []
export let exportedData: string = ""
export function pull(): Promise<string> {
return new Promise<string>((resolve, reject) => {
if (loading || !tested) return reject(t('trans.error.untested'))
if (exportedData) return resolve(exportedData)
console.log("Exporting data...")
error = ""
TRANSFER.pull({...src, ...gameInfo}, (msg: TrStreamMessage) => {
console.log("Export progress: ", JSON.stringify(msg))
if ('message' in msg) messages = [...messages, msg.message]
if ('error' in msg) {
error = msg.error
reject(msg.error)
}
if ('data' in msg) {
// file name: Export YYYY-MM-DD {server host} {game} {card last 6}.json
let date = new Date().toISOString().split('T')[0]
let host = new URL(src.dns).hostname
download(msg.data, `Export ${date} ${host} ${gameInfo.game} ${src.card.slice(-6)}.json`)
exportedData = msg.data
resolve(msg.data)
}
}).catch(err => { error = err; reject(err) })
})
}
function pushBtn() {
if (loading || !tested) return
selectJsonFile().then(obj => push(JSON.stringify(obj)))
}
export function push(data: string) {
if (loading || !tested) return
console.log("Import data...")
loading = true
error = ""
return TRANSFER.push({...src, ...gameInfo}, data).then(() => {
console.log("Data imported successfully")
messages = [t('trans.success.import')]
}).catch(err => error = err.message).finally(() => loading = false)
}
$: isBlacklist = blacklist.filter(x => src.dns.includes(x)).length > 0
</script>
<StatusOverlays {loading} />
<div class="server source" class:src={isSrc} class:hasError={error} class:tested={tested}>
<h3>{t(`trans.${isSrc ? "source" : "target"}.title`)}</h3>
{#if !isSrc && isBlacklist}
<blockquote class="error-msg">{t('trans.blacklist')}</blockquote>
{/if}
{#if error}
<blockquote class="error-msg">{error}</blockquote>
{/if}
<!-- First input line -->
<div class="inputs">
<InputTextShort desc={t('trans.field.addr')} placeholder="e.g. http://aquadx.hydev.org"
bind:value={src.dns} on:change disabled={tested}
validate={v => /^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(v)} />
<InputTextShort desc={t('trans.field.keychip')} placeholder="e.g. A0299792458"
bind:value={src.keychip} on:change disabled={tested}
validate={v => /^([A-Z0-9]{11}|[A-Z0-9]{4}-[A-Z0-9]{11})$/.test(v)} />
</div>
<!-- Second input line -->
<div class="inputs">
<div class="game-version">
<InputTextShort desc={t('trans.field.game')} placeholder="e.g. SDHD"
bind:value={gameInfo.game} on:change disabled={tested} />
<InputTextShort desc={t('trans.field.version')} placeholder="e.g. 2.30"
bind:value={gameInfo.version} on:change disabled={tested} />
</div>
<InputTextShort desc={t('trans.field.card')} placeholder="e.g. 27182818284590452353"
bind:value={src.card} disabled={tested} on:change={value => {
src.card = src.card.replaceAll(' ', '')
dispatch('change', { value });
}} />
</div>
<!-- Streaming messages -->
{#if messages.length > 0}
<div class="stream-messages">
{#each messages.slice(Math.max(messages.length - 5, 0), undefined) as msg}
<p>{msg}</p>
{/each}
</div>
{/if}
<!-- Buttons -->
<div class="inputs buttons">
{#if !tested}
<button class="flex-1" on:click={testConnection} disabled={loading}>{t('trans.btn.test')}</button>
{:else}
<button class="flex-1" on:click={pull}>{t('trans.btn.export')}</button>
<button class="flex-1" on:click={pushBtn}>{t('trans.btn.import')}</button>
{/if}
</div>
</div>
<style lang="sass">
@use "../../vars"
@use "sass:color"
.error-msg
white-space: pre-wrap
margin: 0
.server
display: flex
flex-direction: column
gap: 1rem
// --c-src: 202, 168, 252
--c-src: 179, 198, 255
// animation: hue-rotate 10s infinite linear
// &.src
// --c-src: 173, 192, 247
// animation: hue-rotate 10s infinite linear reverse
&.tested
--c-src: 169, 255, 186
&.hasError
--c-src: 255, 174, 174
animation: none
padding: 1rem
border-radius: vars.$border-radius
// background-color: vars.$ov-light
background: #252525
// Pink outline
border: 1px solid rgba(var(--c-src), 0.5)
box-shadow: 0 0 1rem 0 rgba(var(--c-src), 0.25)
h3
margin: 0
font-size: 1.5rem
text-align: center
// @keyframes hue-rotate
// 0%
// filter: hue-rotate(0deg)
// 100%
// filter: hue-rotate(360deg)
.inputs
display: flex
flex-wrap: wrap
gap: 1rem
.game-version
flex: 60
display: flex
gap: 1rem
:global(> *)
width: 100px
&.buttons
margin-top: 0.5rem
.stream-messages
font-size: 0.8rem
opacity: 0.8
margin-top: 0.5rem
padding: 0 0.5rem
</style>

View File

@@ -9,10 +9,13 @@
import { pfp } from "../../libs/ui";
import { t, ts } from "../../libs/i18n";
import { FADE_IN, FADE_OUT } from "../../libs/config";
import Cropper from "svelte-easy-crop";
import UserBox from "../../components/settings/ChuniSettings.svelte";
import Mai2Settings from "../../components/settings/Mai2Settings.svelte";
import WaccaSettings from "../../components/settings/WaccaSettings.svelte";
import GeneralGameSettings from "../../components/settings/GeneralGameSettings.svelte";
import OngekiSettings from "../../components/settings/OngekiSettings.svelte";
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
USER.ensureLoggedIn()
@@ -26,12 +29,19 @@
[ 'displayName', t('settings.profile.name') ],
[ 'username', t('settings.profile.username') ],
[ 'password', t('settings.profile.password') ],
[ 'profileLocation', t('settings.profile.location') ],
/* Neither of these did anything of importance
[ 'country', t('settings.profile.country') ],
[ 'profileLocation', t('settings.profile.location') ],*/
[ 'profileBio', t('settings.profile.bio') ],
] as const
// Fetch user data
const getMe = () => USER.me().then((m) => {
if (pfpCropURL != null) {
URL.revokeObjectURL(pfpCropURL);
pfpField.value = "";
pfpCropURL = null;
}
me = m
CARD.userGames(m.username).then(games => {
@@ -44,12 +54,17 @@
if (games.wacca && !tabs.includes('wacca')) {
tabs = [...tabs, 'wacca']
}
if (games.ongeki && !tabs.includes('ongeki')) {
tabs = [...tabs, 'ongeki']
}
})
}).catch(e => error = e.message)
getMe()
let changed: string[] = []
let pfpField: HTMLInputElement
let pfpCropURL: string | null = null;
let pfpCrop = { width: 0, height: 0, x: 0, y: 0 };
function submit(field: string, value: string) {
if (submitting) return
@@ -60,15 +75,56 @@
}).catch(e => error = e.message).finally(() => submitting = "")
}
function uploadPfp(file: File) {
function uploadPfp() {
if (submitting) return
submitting = 'profilePicture'
USER.uploadPfp(file).then(() => {
me.profilePicture = file.name
// reload
getMe()
}).catch(e => error = e.message).finally(() => submitting = "")
// Don't know why this isn't just a part of the cropper module. Have to do this myself.. What a shame
let canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");
const size = Math.round(Math.min(pfpCrop.width, pfpCrop.height, 1024));
canvas.width = size;
canvas.height = size;
let img = document.createElement("img");
img.onload = () => {
ctx?.drawImage(img, pfpCrop.x, pfpCrop.y, pfpCrop.width, pfpCrop.height, 0, 0, size, size);
canvas.toBlob(blob => {
if (!blob) return;
submitting = 'profilePicture'
USER.uploadPfp(blob as File).then(() => {
me.profilePicture = me.username
// reload
// this doesn't work btw
setTimeout(getMe, 200);
}).catch(e => error = e.message).finally(() => submitting = "")
});
}
img.src = pfpCropURL ?? "";
}
function handlePfpUpload(e: Event & { target: HTMLInputElement }) {
if (!e.target) return;
let files = e?.target?.files;
if (!files || files.length <= 0) return;
let file = files[0];
console.log(me.username, me);
switch (file.type) {
case "image/gif":
USER.uploadPfp(file).then(() => {
me.profilePicture = me.username
// reload
setTimeout(getMe, 200);
}).catch(e => error = e.message).finally(() => submitting = "")
break;
case "image/png":
case "image/jpeg":
case "image/webp":
pfpCropURL = URL.createObjectURL(file);
break;
default:
error = t("settings.profile.bad-format");
}
};
function logOut() {
localStorage.removeItem("token");
location.href = "/";
}
const passwordAction = (node: HTMLInputElement, whether: boolean) => {
@@ -107,17 +163,23 @@
</button>
{/if}
</div>
<input id="profile-upload" type="file" accept="image/*" style="display: none" bind:this={pfpField}
on:change={() => pfpField.files && uploadPfp(pfpField.files[0])} />
<!-- Genuinely don't know why this is giving me an intellisense error. Works fine. -->
<input id="profile-upload" type="file" accept="image/gif,image/png,image/jpeg,image/webp" style="display: none" bind:this={pfpField}
on:change={handlePfpUpload} />
</div>
{#each profileFields as [field, name], i (field)}
<div class="field">
<label for={field}>{name}</label>
<div>
<input id={field} type="text" use:passwordAction={field === 'password'}
bind:value={me[field]} on:input={() => changed = [...changed, field]}
placeholder={field === 'password' ? t('settings.profile.unchanged') : t('settings.profile.unset')}/>
{#if field == "profileBio"}
<textarea id={field} bind:value={me[field]} on:input={() => changed = [...changed, field]} maxlength=255 placeholder={t('settings.profile.unset')}></textarea>
{:else}
<input id={field} type="text" use:passwordAction={field === 'password'}
bind:value={me[field]} on:input={() => changed = [...changed, field]}
placeholder={field === 'password' ? t('settings.profile.unchanged') : t('settings.profile.unset')}/>
{/if}
{#if changed.includes(field) && me[field]}
<button transition:slide={{axis: 'x'}} on:click={() => submit(field, me[field])}>
{#if submitting === field}
@@ -140,6 +202,11 @@
</label>
</div>
</div>
<div class="field m-t">
<div>
<button on:click={logOut}>{ts(`settings.profile.logout`)}</button>
</div>
</div>
</div>
{:else if tabs[tab] === 'chu3'}
<!-- Userbox settings -->
@@ -148,6 +215,8 @@
<Mai2Settings username={me.username} />
{:else if tabs[tab] === 'wacca'}
<WaccaSettings />
{:else if tabs[tab] === 'ongeki'}
<OngekiSettings />
{:else if tabs[tab] === 'game'}
<GeneralGameSettings />
{/if}
@@ -155,6 +224,22 @@
<StatusOverlays {error} loading={!me || !!submitting} />
</main>
{#if pfpCropURL != null}
<div class="overlay" transition:fade>
<div>
<div class="cropper-container">
<Cropper maxZoom={1e9} oncropcomplete={(e) => pfpCrop = e.pixels} image={pfpCropURL ?? "assets/imgs/no_profile.png"} aspect={1} cropShape="round"></Cropper>
</div>
<button on:click={uploadPfp}>
{t("settings.profile.save")}
</button>
<button on:click={getMe}>
{t("back")}
</button>
</div>
</div>
{/if}
<style lang="sass">
@use "../../vars"
@@ -188,7 +273,7 @@
gap: 1rem
margin-top: 0.5rem
> input
> input, > textarea
flex: 1
img
@@ -196,5 +281,12 @@
max-height: 100px
border-radius: vars.$border-radius
object-fit: cover
aspect-ratio: 1
.cropper-container
position: relative
width: 400px
aspect-ratio: 1
</style>

View File

@@ -18,25 +18,26 @@
import { type GameName, getMult, roundFloor } from "../libs/scoring";
import StatusOverlays from "../components/StatusOverlays.svelte";
import Icon from "@iconify/svelte";
import { GAME_TITLE, t } from "../libs/i18n";
import { countryCodeToEmoji, GAME_TITLE, t } from "../libs/i18n";
import RankDetails from "../components/RankDetails.svelte";
import RatingComposition from "../components/RatingComposition.svelte";
import useLocalStorage from "../libs/hooks/useLocalStorage.svelte";
import Line from "../components/chart/Line.svelte";
import ChuniUserboxDisplay from "../components/settings/userbox/ChuniUserboxDisplay.svelte";
const TREND_DAYS = 60
registerChart()
export let username: string;
export let game: GameName = "mai2"
export let game: GameName | "auto" = "auto"
let calElement: HTMLElement
let error: string;
let me: AquaNetUser
title(`User ${username}`)
const rounding = useLocalStorage("rounding", true);
const titleText = GAME_TITLE[game]
const titleText = game != "auto" ? GAME_TITLE[game] : "?"
interface MusicAndPlay extends MusicMeta, GenericGamePlaylog {}
@@ -50,11 +51,25 @@
let allMusics: AllMusic
let showDetailRank = false
let isLoading = false
let showMoreRecent = false
function init() {
USER.isLoggedIn() && USER.me().then(u => me = u)
CARD.userGames(username).then(games => {
if (game == "auto") {
let targetGames = Object.entries(games)
.map(d => {
if (d[1])
d[1].lastLogin = d[1].lastLogin ? new Date(d[1].lastLogin) : new Date(0);
return d;
}).sort((a,b) => {
return b[1]?.lastLogin - a[1]?.lastLogin;
});
if (targetGames[0])
window.location.href = `/u/${username}/${targetGames[0][0]}`
return;
}
if (!games[game]) {
// Find a valid game
const valid = Object.entries(games).filter(([g, valid]) => valid)
@@ -81,6 +96,13 @@
})
}
// Set beforeRating in recent to the last play's afterRating
user.recent.forEach((it, i) => {
if (i < user.recent.length - 1) {
it.beforeRating = user.recent[i + 1].afterRating
}
})
const minDate = moment().subtract(TREND_DAYS, 'days').format("YYYY-MM-DD")
d = {user,
trend: trend.filter(it => it.date >= minDate && it.plays != 0),
@@ -96,10 +118,11 @@
}).catch((e) => { error = e.message; console.error(e) } );
}
if (Object.keys(GAME_TITLE).includes(game)) init()
if (Object.keys(GAME_TITLE).includes(game) || game == "auto") init()
else error = t("UserHome.InvalidGame", {game})
const setRival = (isAdd: boolean) => {
if (game == "auto") return;
isLoading = true
GAME.setRival(game, username, isAdd).then(() => {
d!.user.rival = isAdd
@@ -112,26 +135,58 @@
<div class="user-pfp">
<img use:pfp={d.user.aquaUser} alt="" class="pfp" on:error={pfpNotFound}>
<div class="name-box">
<h2>{d.user.name}</h2>
<div class="name-left">
{#if d.user.aquaUser}
{#if d.user.aquaUser.displayName}
<h2>{d.user.aquaUser?.displayName}</h2>
{:else}
<h2>{d.user.name}</h2>
{/if}
<div class="game-name">
{#if d.user.aquaUser.displayName}
{d.user.name}
{/if}
(@{d.user.aquaUser.username})
</div>
<div class="country">{countryCodeToEmoji(d.user.aquaUser?.country)}</div>
{:else}
<h2>{d.user.name}</h2>
{/if}
</div>
{#if typeof d.user.rival === 'boolean' && game === 'mai2'}
<span class="clickable" on:click={() => setRival(!d?.user.rival)} role="button" tabindex="0"
on:keydown={e => e.key === "Enter" && setRival(!d?.user.rival)}>
{d.user.rival ? t("UserHome.RemoveRival") : t("UserHome.AddRival")}
</span>
{/if}
{#if me && me.username === username}
<a class="setting-icon clickable" use:tooltip={t("UserHome.Settings")} href="/settings">
<Icon icon="eos-icons:rotating-gear"/>
</a>
{/if}
</div>
<nav>
{#each d.validGames as [g, name]}
<a href={`/u/${username}/${g}`} class:active={game === g}>{name}</a>
{/each}
{#if me && me.username === username}
<a class="setting-icon clickable" use:tooltip={t("UserHome.Settings")} href="/settings">
<Icon icon="eos-icons:rotating-gear"/>
</a>
{/if}
</nav>
</div>
{#if d.user.aquaUser?.profileBio}
<div class="activity-info">
<div class="info-bottom profile-bio-container">
<div class="profile-bio">
<span>{t("settings.profile.bio")}</span>
<span class="profile-bio-text">{d.user.aquaUser?.profileBio}</span>
</div>
</div>
</div>
{/if}
<ChuniUserboxDisplay {game} {username} bind:error={error} />
<div>
<h2>{titleText} {t('UserHome.Statistics')}</h2>
<div class="scoring-info">
@@ -140,7 +195,7 @@
<div class="rating">
<span>{game === 'mai2' ? t("UserHome.DXRating"): t("UserHome.Rating")}</span>
<span>{
game === 'chu3' ?
game === 'chu3' || game === 'ongeki' ?
(d.user.rating / 100).toFixed(2) :
d.user.rating.toLocaleString()
}</span>
@@ -148,7 +203,7 @@
<div class="rank">
<span>{t('UserHome.ServerRank')}</span>
<span>#{+d.user.serverRank.toLocaleString() + 1}</span>
<span>#{(d.user.serverRank + 1).toLocaleString()}</span>
</div>
</div>
@@ -256,17 +311,24 @@
</div>
</div>
<RatingComposition title="B30" comp={d.user.ratingComposition.best30} {allMusics} {game}/>
<RatingComposition title="B35" comp={d.user.ratingComposition.best35} {allMusics} {game}/>
<RatingComposition title="B15" comp={d.user.ratingComposition.best15} {allMusics} {game}/>
<!-- I don't like doing this but it may be preferable to gaslighting the types -->
<RatingComposition title="B30" comp={d.user.ratingComposition.best30} {allMusics} game={game != "auto" ? game : "mai2"}/>
<RatingComposition title="B35" comp={d.user.ratingComposition.best35} {allMusics} game={game != "auto" ? game : "mai2"}/>
<RatingComposition title="B15" comp={d.user.ratingComposition.best15} {allMusics} game={game != "auto" ? game : "mai2"}/>
<!-- <RatingComposition title="Hot 10" comp={d.user.ratingComposition.hot10} {allMusics} {game}/> -->
<!-- <RatingComposition title="N10" comp={d.user.ratingComposition.next10} {allMusics} {game}/> -->
<RatingComposition title="Recent 10" comp={d.user.ratingComposition.recent10} {allMusics} {game} top={10}/>
<!-- Chuni -->
{#if d.user.ratingComposition.new}
<RatingComposition title="New 20" comp={d.user.ratingComposition.new} {allMusics} game="chu3"/>
{:else}
<RatingComposition title="Recent 10" comp={d.user.ratingComposition.recent10} {allMusics} game={game != "auto" ? game : "mai2"} top={10}/>
{/if}
<div class="recent">
<h2>{t('UserHome.RecentScores')}</h2>
<div class="scores">
{#each d.recent as r, i}
{#each (showMoreRecent ? d.recent : d.recent.slice(0, 15)) as r, i}
<div class:alt={i % 2 === 0}>
<img src={`${DATA_HOST}/d/${game}/music/00${r.musicId.toString().padStart(6, '0').substring(2)}.png`} alt="" on:error={coverNotFound} />
<div class="info">
@@ -282,27 +344,45 @@
{r.notes?.[r.level === 10 ? 0 : r.level]?.lv?.toFixed(1) ?? r.worldsEndTag ?? '-'}
</span>
</span>
<span class={`rank-${getMult(r.achievement, game)[2].toString()[0]}`}>
<span class="rank-text">{("" + getMult(r.achievement, game)[2]).replace("p", "+")}</span>
<span class={`rank-${getMult(r.achievement, game != "auto" ? game : "mai2")[2].toString()[0]}`}>
<span class="rank-text">{("" + getMult(r.achievement, game != "auto" ? game : "mai2")[2]).replace("p", "+")}</span>
<span class="rank-num" use:tooltip={(r.achievement / 10000).toFixed(4)}>
{
rounding.value ?
roundFloor(r.achievement, game, 1) :
roundFloor(r.achievement, game != "auto" ? game : "mai2", 1) :
(r.achievement / 10000).toFixed(4)
}%
</span>
</span>
{#if game === 'mai2' || game === 'wacca'}
<span class:increased={r.afterRating - r.beforeRating > 0} class="dx-change">
{r.afterRating === r.beforeRating ? '-' : (r.afterRating - r.beforeRating).toFixed(0)}
</span>
{/if}
<span class:increased={r.afterRating - r.beforeRating > 0} class="dx-change">
{r.afterRating === r.beforeRating ? '-' : (r.afterRating - r.beforeRating).toFixed(0)}
</span>
</div>
</div>
</div>
{/each}
{#if !showMoreRecent}
<button class="clickable" on:click={() => showMoreRecent = true}>{t('UserHome.ShowMoreRecent')}</button>
{/if}
</div>
</div>
{#if d.user.favorites != null && d.user.favorites.length > 0}
<div class="favorites">
<h2>{t('UserHome.FavoriteSongs')}</h2>
<div class="scores">
{#each d.user.favorites as favoriteSongId, i}
<div>
<img src={`${DATA_HOST}/d/${game}/music/00${favoriteSongId.toString().padStart(6, '0').substring(2)}.png`} alt="" on:error={coverNotFound} />
<div class="info">
<div class="song-title">{allMusics[favoriteSongId.toString()] ? allMusics[favoriteSongId.toString()].name : t("UserHome.UnknownSong")}</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/if}
<StatusOverlays {error} loading={!d || isLoading} />
@@ -339,6 +419,9 @@
display: flex
align-items: center
position: relative
z-index: 20
.name-box
flex: 1
display: flex
@@ -346,6 +429,20 @@
justify-content: space-between
gap: 10px
.name-left
display: flex
gap: 1em
position: relative
.game-name
position: absolute
left: 0.5em
bottom: 0
transform: translate(0, 75%)
opacity: 50%
white-space: nowrap
max-width: 50%
.pfp
width: 100px
height: 100px
@@ -381,6 +478,16 @@
.info-bottom
width: max-content
&.profile-bio-container,
&.profile-bio-container div
width: 100%
.profile-bio-text
white-space: pre
max-height: 10em
overflow-y: auto
flex: 1
.info-top > div > span:last-child
font-size: 1.5rem
@@ -462,6 +569,57 @@
flex-direction: row
justify-content: space-between
.favorites
.scores
display: grid
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr))
gap: 20px
// Image and song info
> div
display: flex
align-items: center
gap: 20px
background-color: rgba(white, 0.03)
border-radius: vars.$border-radius
img
width: 50px
height: 50px
border-radius: vars.$border-radius
object-fit: cover
// Song info and score
> div.info
flex: 1
display: flex
justify-content: space-between
overflow: hidden
flex-direction: column
.first-line
display: flex
flex-direction: row
// Limit song name to one line
.song-title
max-width: 90%
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
// Make song score and rank not wrap
> div:last-child
white-space: nowrap
@media (max-width: vars.$w-mobile)
flex-direction: column
gap: 0
.rank-text
text-align: left
// Recent Scores section
.recent
.scores
@@ -576,4 +734,6 @@
&:before
content: "+"
color: vars.$c-good
</style>

View File

@@ -1,251 +1,372 @@
<script lang="ts">
import { Turnstile } from "svelte-turnstile";
import { slide } from 'svelte/transition';
import { TURNSTILE_SITE_KEY } from "../libs/config";
import Icon from "@iconify/svelte";
import { USER } from "../libs/sdk";
import { t } from "../libs/i18n"
let params = new URLSearchParams(window.location.search)
let state = "home"
$: isSignup = state === "signup"
let submitting = false
let email = ""
let password = ""
let username = ""
let turnstile = ""
let turnstileReset: () => void | undefined;
let error = ""
let verifyMsg = ""
if (params.get('confirm-email')) {
state = 'verify'
verifyMsg = t("welcome.verifying")
submitting = true
// Send request to server
USER.confirmEmail(params.get('confirm-email')!)
.then(() => {
verifyMsg = t('welcome.verified')
submitting = false
// Clear the query param
window.history.replaceState({}, document.title, window.location.pathname)
})
.catch(e => verifyMsg = t('welcome.verification-failed', { message: e.message }))
}
async function submit(): Promise<any> {
submitting = true
// Check if username and password are valid
if (email === "" || password === "") {
error = t("welcome.email-password-missing")
return submitting = false
}
if (turnstile === "") {
// Sleep for 100ms to allow Turnstile to finish
error = t("welcome.waiting-turnstile")
return setTimeout(submit, 100)
}
// Signup
if (isSignup) {
if (username === "") {
error = t("welcome.username-missing")
return submitting = false
}
// Send request to server
await USER.register({ username, email, password, turnstile })
.then(() => {
// Show verify email message
state = 'verify'
verifyMsg = t("welcome.verification-sent", { email })
})
.catch(e => {
error = e.message
submitting = false
turnstileReset()
})
}
else {
// Send request to server
await USER.login({ email, password, turnstile })
.then(() => window.location.href = "/home")
.catch(e => {
if (e.message === 'Email not verified - STATE_0') {
state = 'verify'
verifyMsg = t("welcome.verify-state-0")
}
else if (e.message === 'Email not verified - STATE_1') {
state = 'verify'
verifyMsg = t("welcome.verify-state-1")
}
else if (e.message === 'Email not verified - STATE_2') {
state = 'verify'
verifyMsg = t("welcome.verify-state-2")
}
else {
error = e.message
submitting = false
turnstileReset()
}
})
}
submitting = false
}
</script>
<main id="home" class="no-margin">
<div>
<h1 id="title">AquaNet</h1>
{#if state === "home"}
<div class="btn-group" transition:slide>
<button on:click={() => state = 'login'}>{t('welcome.btn-login')}</button>
<button on:click={() => state = 'signup'}>{t('welcome.btn-signup')}</button>
</div>
{:else if state === "login" || state === "signup"}
<div class="login-form" transition:slide>
{#if error}
<span class="error">{error}</span>
{/if}
<div on:click={() => state = 'home'} on:keypress={() => state = 'home'}
role="button" tabindex="0" class="clickable">
<Icon icon="line-md:chevron-small-left" />
<span>{t('back')}</span>
</div>
{#if isSignup}
<input type="text" placeholder={t('username')} bind:value={username}>
{/if}
<input type="email" placeholder={t('email')} bind:value={email}>
<input type="password" placeholder={t('password')} bind:value={password}>
<button on:click={submit}>
{#if submitting}
<Icon icon="line-md:loading-twotone-loop"/>
{:else}
{isSignup ? t('welcome.btn-signup') : t('welcome.btn-login')}
{/if}
</button>
<Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset}
on:turnstile-callback={e => console.log(turnstile = e.detail.token)}
on:turnstile-error={_ => console.log(error = t("welcome.turnstile-error"))}
on:turnstile-expired={_ => window.location.reload()}
on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} />
</div>
{:else if state === "verify"}
<div class="login-form" transition:slide>
<span>{verifyMsg}</span>
{#if !submitting}
<button on:click={() => state = 'home'} transition:slide>{t('back')}</button>
{/if}
</div>
{/if}
</div>
<div class="light-pollution">
<div class="l1"></div>
<div class="l2"></div>
<div class="l3"></div>
</div>
</main>
<style lang="sass">
@use "../vars"
.login-form
display: flex
flex-direction: column
gap: 8px
width: calc(100% - 12px)
max-width: 300px
div.clickable
display: flex
align-items: center
#home
color: vars.$c-main
position: relative
width: 100%
height: 100%
padding-left: 100px
overflow: hidden
background-color: black
box-sizing: border-box
display: flex
flex-direction: column
justify-content: center
margin-top: -(vars.$nav-height)
// Content container
> div
display: flex
flex-direction: column
align-items: flex-start
width: max-content
// Switching state container
> div
transition: vars.$transition
#title
font-family: Quicksand, vars.$font
user-select: none
// Gap between text characters
letter-spacing: 0.2em
margin-top: 0
margin-bottom: 32px
opacity: 0.9
.btn-group
display: flex
gap: 8px
.light-pollution
pointer-events: none
opacity: 0.8
> div
position: absolute
z-index: 1
.l1
left: -560px
top: 90px
height: 1130px
width: 1500px
$color: rgb(158, 110, 230)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
.l2
left: -200px
top: 560px
height: 1200px
width: 1500px
$color: rgb(92, 195, 250)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
.l3
left: -600px
opacity: 0.7
top: -630px
width: 1500px
height: 1000px
$color: rgb(230, 110, 156)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
@media (max-width: 500px)
align-items: center
padding-left: 0
</style>
<script lang="ts">
import { Turnstile } from "svelte-turnstile";
import { slide } from 'svelte/transition';
import { TURNSTILE_SITE_KEY } from "../libs/config";
import Icon from "@iconify/svelte";
import { USER } from "../libs/sdk";
import { t } from "../libs/i18n"
let params = new URLSearchParams(window.location.search)
let state = "home"
$: isSignup = state === "signup"
let submitting = false
let email = ""
let password = ""
let username = ""
let turnstile = ""
let turnstileReset: () => void | undefined;
let error = ""
let verifyMsg = ""
let token = ""
if (USER.isLoggedIn()) {
window.location.href = "/home"
}
if (params.get('code')) {
token = params.get('code')!
if (location.pathname === '/verify') {
state = 'verify'
verifyMsg = t("welcome.verifying")
submitting = true
// Send request to server
USER.confirmEmail(token)
.then(() => {
verifyMsg = t('welcome.verified')
submitting = false
// Clear the query param
window.history.replaceState({}, document.title, window.location.pathname)
})
.catch(e => verifyMsg = t('welcome.verification-failed', { message: e.message }))
}
else if (location.pathname === '/reset-password') {
state = 'reset'
}
}
async function submit(): Promise<any> {
submitting = true
// Check if username and password are valid
if (email === "" || password === "") {
error = t("welcome.email-password-missing")
return submitting = false
}
if (TURNSTILE_SITE_KEY && turnstile === "") {
// Sleep for 100ms to allow Turnstile to finish
error = t("welcome.waiting-turnstile")
return setTimeout(submit, 100)
}
// Signup
if (isSignup) {
if (username === "") {
error = t("welcome.username-missing")
return submitting = false
}
// Send request to server
await USER.register({ username, email, password, turnstile })
.then(() => {
// Show verify email message
state = 'verify'
verifyMsg = t("welcome.verification-sent", { email })
})
.catch(e => {
error = e.message
submitting = false
turnstileReset()
})
}
else {
// Send request to server
await USER.login({ email, password, turnstile })
.then(() => window.location.href = "/home")
.catch(e => {
if (e.message === 'Email not verified - STATE_0') {
state = 'verify'
verifyMsg = t("welcome.verify-state-0")
}
else if (e.message === 'Email not verified - STATE_1') {
state = 'verify'
verifyMsg = t("welcome.verify-state-1")
}
else if (e.message === 'Email not verified - STATE_2') {
state = 'verify'
verifyMsg = t("welcome.verify-state-2")
}
else {
error = e.message
submitting = false // unnecessary? see line 113, same for both reset functions
turnstileReset()
}
})
}
submitting = false
}
async function resetPassword(): Promise<any> {
submitting = true;
if (email === "") {
error = t("welcome.email-missing")
return submitting = false
}
if (TURNSTILE_SITE_KEY && turnstile === "") {
// Sleep for 100ms to allow Turnstile to finish
error = t("welcome.waiting-turnstile")
return setTimeout(resetPassword, 100)
}
// Send request to server
await USER.resetPassword({ email, turnstile })
.then(() => {
// Show email sent message, reusing email verify page
state = 'verify'
verifyMsg = t("welcome.reset-password-sent", { email })
})
.catch(e => {
if (e.message === "Reset request rejected - STATE_0") {
state = 'verify'
verifyMsg = t("welcome.reset-state-0")
}
else if (e.message === "Reset request rejected - STATE_1") {
state = 'verify'
verifyMsg = t("welcome.reset-state-1")
}
else {
error = e.message
submitting = false
turnstileReset()
}
})
submitting = false
}
async function changePassword(): Promise<any> {
submitting = true
if (password === "") {
error = t("welcome.password-missing")
return submitting = false
}
// Send request to server
await USER.changePassword({ token, password })
.then(() => {
state = 'verify'
verifyMsg = t("welcome.password-reset-done")
})
.catch(e => {
error = e.message
submitting = false
turnstileReset()
})
submitting = false
}
</script>
<main id="home" class="no-margin">
<div>
<h1 id="title">AquaNet</h1>
{#if state === "home"}
<div class="btn-group" transition:slide>
<button on:click={() => state = 'login'}>{t('welcome.btn-login')}</button>
<button on:click={() => state = 'signup'}>{t('welcome.btn-signup')}</button>
</div>
{:else if state === "login" || state === "signup"}
<div class="login-form" transition:slide>
{#if error}
<span class="error">{error}</span>
{/if}
{#if error != t("welcome.waiting-turnstile")}
<div on:click={() => state = 'home'} on:keypress={() => state = 'home'}
role="button" tabindex="0" class="clickable">
<Icon icon="line-md:chevron-small-left" />
<span>{t('back')}</span>
</div>
{/if}
{#if isSignup}
<input type="text" placeholder={t('username')} bind:value={username}>
{/if}
<input type="email" placeholder={t('email')} bind:value={email}>
<input type="password" placeholder={t('password')} bind:value={password}>
<button on:click={submit}>
{#if submitting}
<Icon icon="line-md:loading-twotone-loop"/>
{:else}
{isSignup ? t('welcome.btn-signup') : t('welcome.btn-login')}
{/if}
</button>
{#if state === "login" && !submitting}
<button on:click={() => state = 'submitreset'}>{t('welcome.btn-reset-password')}</button>
{/if}
{#if TURNSTILE_SITE_KEY}
<Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset}
on:turnstile-callback={e => console.log(turnstile = e.detail.token)}
on:turnstile-error={_ => console.log(error = t("welcome.turnstile-error"))}
on:turnstile-expired={_ => window.location.reload()}
on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} />
{/if}
</div>
{:else if state === "submitreset"}
<div class="login-form" transition:slide>
{#if error}
<span class="error">{error}</span>
{/if}
{#if error != t("welcome.waiting-turnstile")}
<div on:click={() => state = 'login'} on:keypress={() => state = 'login'}
role="button" tabindex="0" class="clickable">
<Icon icon="line-md:chevron-small-left" />
<span>{t('back')}</span>
</div>
{/if}
<input type="email" placeholder={t('email')} bind:value={email}>
<button on:click={resetPassword}>
{#if submitting}
<Icon icon="line-md:loading-twotone-loop"/>
{:else}
{t('welcome.btn-submit-reset-password')}
{/if}
</button>
{#if TURNSTILE_SITE_KEY}
<Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset}
on:turnstile-callback={e => console.log(turnstile = e.detail.token)}
on:turnstile-error={_ => console.log(error = t("welcome.turnstile-error"))}
on:turnstile-expired={_ => window.location.reload()}
on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} />
{/if}
</div>
{:else if state === "verify"}
<div class="login-form" transition:slide>
<span>{verifyMsg}</span>
{#if !submitting}
<button on:click={() => state = 'home'} transition:slide>{t('back')}</button>
{/if}
</div>
{:else if state === "reset"}
{#if error}
<span class="error">{error}</span>
{/if}
<div class="login-form" transition:slide>
<input type="password" placeholder={t('new-password')} bind:value={password}>
<button on:click={changePassword}>
{#if submitting}
<Icon icon="line-md:loading-twotone-loop"/>
{:else}
{t('welcome.btn-submit-new-password')}
{/if}
</button>
</div>
{/if}
</div>
<div class="light-pollution">
<div class="l1"></div>
<div class="l2"></div>
<div class="l3"></div>
</div>
</main>
<style lang="sass">
@use "../vars"
.login-form
display: flex
flex-direction: column
gap: 8px
width: calc(100% - 12px)
max-width: 300px
div.clickable
display: flex
align-items: center
#home
color: vars.$c-main
position: relative
width: 100%
height: 100%
padding-left: 100px
overflow: hidden
background-color: black
box-sizing: border-box
display: flex
flex-direction: column
justify-content: center
margin-top: -(vars.$nav-height)
// Content container
> div
display: flex
flex-direction: column
align-items: flex-start
width: max-content
// Switching state container
> div
transition: vars.$transition
#title
font-family: Quicksand, vars.$font
user-select: none
// Gap between text characters
letter-spacing: 0.2em
margin-top: 0
margin-bottom: 32px
opacity: 0.9
.btn-group
display: flex
gap: 8px
.light-pollution
pointer-events: none
opacity: 0.8
> div
position: absolute
z-index: 1
.l1
left: -560px
top: 90px
height: 1130px
width: 1500px
$color: rgb(158, 110, 230)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
.l2
left: -200px
top: 560px
height: 1200px
width: 1500px
$color: rgb(92, 195, 250)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
.l3
left: -600px
opacity: 0.7
top: -630px
width: 1500px
height: 1000px
$color: rgb(230, 110, 156)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
@media (max-width: 500px)
align-items: center
padding-left: 0
</style>

View File

@@ -4,6 +4,7 @@ $c-sub: rgba(0, 0, 0, 0.77)
$c-good: #b3ffb9
$c-darker: #646cff
$c-bg: #242424
$c-warning:hsl(40 100% 71% / 1)
$c-error: #ff6b6b
$c-shadow: rgba(0, 0, 0, 0.1)

View File

@@ -1,396 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
## 0.0.46 - 2023-04-25
- Add client serial validation option for All.Net PowerOn request. Thanks to Fleming Karlzett!
- Change Chunithm New userbox API to return sorted list. Thanks to Fleming Karlzett!
- Add new card, event and music data for O.N.G.E.K.I bright memory
- Add new event for Maimai DX Festival
- Update Spring boot to 2.7.11 and other dependencies
## 0.0.45a - 2023-04-06
- Add game data for Chunithm Sun
## 0.0.45 - 2023-03-31
- Fix O.N.G.E.K.I event ranking on MySQL and MariaDB. Thanks to Mikira Sora!
- Fix unreadable text when using non-latin character for Chunithm New team name. Thanks to Caxerx!
- Add partial game data for Chunithm SUN. Thanks to dot nya!
- Add new event and music data for O.N.G.E.K.I bright memory
## 0.0.44 - 2023-03-21
- Add support for Maimai DX Festival. Thanks to anonymous for the help and testing.
- Add support for Chunithm SUN. Thanks to anonymous for the help and testing.
- Note: change the game config accordingly. Otherwise, it may trigger connectivity kill switch!
- Add global matching lobby stub for Chunithm New and up. **Multiplayer still does NOT work!**
- Add support for actual ingame event ranking for O.N.G.E.K.I. Thanks to Mikira Sora!
- Add new event for O.N.G.E.K.I bright memory
- Update Spring boot to 2.7.9 and other dependencies
## 0.0.43 - 2023-02-28
- **From this version, a minimum Java version of 17 is required.**
- Add new event, music, chara and card data for O.N.G.E.K.I bright memory
- Change build system to Gradle
## 0.0.42a - 2023-01-30
- Add new event and music data for O.N.G.E.K.I bright memory
- Fix GetGameMessage response for Chunithm
## 0.0.42 - 2023-01-06
- Add support for Maimai DX user profile picture. Thanks to Mikira Sora!
- Add support for O.N.G.E.K.I rival feature. Thanks to Mikira Sora!
- Add support for Chunithm New user song favorite feature. Thanks Jordo!
- Add new event and music data for O.N.G.E.K.I bright memory
- Add Maimai DX support for Card Maker 1.34. This includes DX Pass support for Maimai DX.
- Add an option to change ALL.Net shop name
- Fix startup check failure in some conditions
- Fix database migration for MySQL 8.0+
## 0.0.41a - 2022-12-05
- Add new event and music data for O.N.G.E.K.I bright memory
## 0.0.41 - 2022-11-18
- Add new event and music data for O.N.G.E.K.I bright memory
- Add Maimai DX API endpoints
- Add Chusan last version change API endpoints
## 0.0.40 - 2022-11-16
- Add final game data for Chunithm New Plus
- Fix database migration for MariaDB and Mysql. Please update your configuration accordingly!
## 0.0.39 - 2022-11-08
- Add new event data for Chunithm New Plus
- Add new event and music data for O.N.G.E.K.I bright memory
- Fix O.N.G.E.K.I user data export and import to include bright memory data. Thanks to rin sama!
## 0.0.38b - 2022-09-27
- Add new event and music data for O.N.G.E.K.I bright memory
## 0.0.38a - 2022-09-01
- Fix an error that prevented from boot when MySQL or MariaDB is used as database
## 0.0.38 - 2022-08-30
- Add partial support for Card Maker (1.34, Chunithm New only)
- Add new event and music data for O.N.G.E.K.I bright memory
- Fix issue that might lead some bugs on Maimai DX
- Change startup splash to include version and built time information
- Update Spring boot to 2.7.2 and other dependencies
## 0.0.37c - 2022-08-02
- Add new event, music and music level data for O.N.G.E.K.I bright memory
## 0.0.37b - 2022-07-24
- Add new event and music data for O.N.G.E.K.I bright memory
## 0.0.37a - 2022-07-06
- Add new event and music data for O.N.G.E.K.I bright memory
## 0.0.37 - 2022-06-21
- Add new event and music data for O.N.G.E.K.I bright memory
- Add an option to disable static Web UI serving
## 0.0.36 - 2022-06-01
- Add new event and music data for O.N.G.E.K.I bright memory
- Fix rating drop under some conditions on Chunithm New Plus
## 0.0.35 - 2022-05-16
- Add new event, music and card data for O.N.G.E.K.I bright memory
- Add user favorite support for Chunithm New Plus. Related API will be provided in later release
- Change log output of DownloadOrder to be more detailed
- Update game charge item entries for Chunithm New Plus
- Update Spring boot to 2.6.7
## 0.0.34 - 2022-05-08
- **This will do database update**
- Fix platinum score saving on O.N.G.E.K.I bright memory.
- Add missing item type entries on O.N.G.E.K.I.
## 0.0.33a - 2022-04-27
- O.N.G.E.K.I bright memory support is no longer considered as experimental.
- Add new music and event data for O.N.G.E.K.I bright memory.
- Update music level data for O.N.G.E.K.I bright memory.
## 0.0.33 - 2022-04-11
- **This will do database update**
- Breaking change for previous MariaDB users: Flyway migration will fail because of checksum mismatch. Change checksum accordingly in `flyway_schema_history` table
- Fix MySQL and MariaDB migration failure. Aqua now supports following databases: MySQL 8.0.x and MariaDB 10.6.x
- Fix Chunithm NEW profile saving when using MariaDB/MySQL as database
- Fix Chunithm NEW APIs: rating display, calculation and user name change
- Fix issue that might lead user name corruption on Chunithm NEW
- Update music level (a.k.a chart constant) data for more correct rating calculation on Chunithm and O.N.G.E.K.I
- Fix Maimai DX version incompatiblity and add an option for old network patch for Splash
- Fix Java 11 incompability with billing
- Update Spring boot to 2.6.6 and other dependencies
## 0.0.32 - 2022-03-19
- **This will do database update**
- Add static Web UI serving (for aquaviewer). Copy Aqua viewer files to `web` folder to use.
- Add Web API for Chunithm New
- Fix MariaDB/MySQL migration
## 0.0.31 - 2022-03-16
- Add experimental support for Chunithm New Plus.
## 0.0.30 - 2022-03-13
- Add billing endpoint.
- Add rom version override config entry for Chunithm New. It turns out game checks this too when enable specific gamemodes.
## 0.0.29a - 2022-03-12
- Fix typo which prevented O.N.G.E.K.I bright memory entry.
## 0.0.29 - 2022-03-11
- **This will do database update**
- Add support for Chunithm New! Thanks to anonymous for this.
- Add experimental support for O.N.G.E.K.I bright memory.
- Improve documentations. This includes game specific notes which has game requirements, informations so please read before use.
- Improve handler for 0x13 Aime command. Special thanks to Treeskin.
- Fix server incompatibility with Maimai DX Splash. It now works with both old and new URI.
- Add version override config entry for O.N.G.E.K.I bright memory and up.
## 0.0.28 - 2022-03-06
- Add handler for new AimeDB commands (0x0d, 0x13). This fixes aime or network instability for some games.
- For O.N.G.E.K.I, use last login date for event watched date. Previously it saved as a date from the year 2005 or 0000.
- Update dependencies.
## 0.0.27 - 2022-02-14
- **This will do database update**
- Add support for Maimai DX Universe!
- Add new music and event data for O.N.G.E.K.I bright.
- Disable O.N.G.E.K.I bright login announcements.
- Add automatic host and port assignment. Now Aqua works out-of-box without first configuration. Still, previous config entries still works if it needed for some reason. Thanks akiroz!
- Fix rating display in Maimai DX user entry. It now respects ingame rating showing preference as expected.
- Fix Maimai DX user playlog saving. Previously it lost some of data.
- Update O.N.G.E.K.I Aqua API endpoints for user data export and import.
## 0.0.26b - 2021-12-27
- Add new music and event data for O.N.G.E.K.I bright.
- Switch to typical bean name. Previously it was generated dynamically with classpath. No user-side difference.
- Fix tests during build and change default test profile to Sqlite. It was broken since v0.0.17. No user-side difference.
- Update dependencides.
## 0.0.26a - 2021-12-26
- Fix V66 migration - this was critical show-stopper bug in 0.0.26
## 0.0.26 - 2021-12-26 [YANKED]
- **This will do database update**
- Add support for O.N.G.E.K.I bright!
- Disable O.N.G.E.K.I Red Plus login announcements. You can now create new account without numerous event popups.
- Delete some non-user-obtainable cards. This was available in card gacha if you were lucky, and made game crash if you did. Special thanks to htk030 for this.
- Improve some documentations. Like what you seeing right now.
- Fix typo in AimeDB lookup handler.
- Change some mismatches, and delete previous backup tables in Sqlite DB.
- Update dependencies, which includes fixed version for log4j and logback vulnerabilities.
## 0.0.25 - 2021-11-30
- **This will do database update**
- [general] Fix MySQL table initialization error
- [maimai2] Add Splash Plus support
## 0.0.24 - 2021-10-19
- **This will do database update**
- [general] Set maintenance reboot date to far future
- [ongeki] Limit maximum activityList entries
- [maimai2] Add userGeneralData table
- [ongeki] Fix wrong references in user tables
- [maimai2] Implement proper player rate saving
- [maimai2] Fix GetGameEvent Handler to return events to game
- [maimai2] Add game events
- [chuni] Remove unnecessary length info in GetGameRankingApi
- [chuni] Add new music and music level data
- [chuni] Use dynamic reboot time instead of fixed one
## 0.0.23 - 2021-10-06
- [aimedb] Add FeliCaLookup2 mode
- [chuni] Add game data: chara, skill, event, music, music level
## 0.0.22c - 2021-09-28
- [maimai2] Fix play saving on first entry session
- [chuni] Add game data: chara, skill, event, music, music level
- [ongeki] Add game data: event, music
## 0.0.22b - 2021-09-15
- [chuni] Add game data: event, music, music level
- [ongeki] Add game data: event, music
## 0.0.22a - 2021-08-30
- [ongeki] Add game data: event, music
## 0.0.22 - 2021-08-30
- **This will do database update**
- [chuni] Implement GetGameRankingApi
- [maimai2] Enable isNetUser and implement UploadUserPhotoApi
- [maimai2] Implement GetGameEventApi and UploadUserPlaylogApi
- [chuni] Add game data: chara, skill, event, music, music level
## 0.0.21 - 2021-08-19
- **This will do database update**
- [general] Update to Spring Boot 2.5
- [maimai2] Experimental Splash Plus Support
## 0.0.20a - 2021-08-17
- [chuni] Add game data: chara, skill, event, music, music level
## 0.0.20 - 2021-08-17
- **This will do database update**
- [chuni] Fix: make event popup to not show
- [ongeki] Add table properties for Red Plus
- [maimai2] Fix play record saving when guest is involved
## 0.0.19e - 2021-08-04
- [chuni] Add game data: chara, skill, event, music, music level
- [ongeki] Add game data: event, music
## 0.0.19d - 2021-07-20
- [chuni] Add game data: chara, skill, event, music, music level
## 0.0.19c - 2021-07-10
- [maimai2] Fix incorrect scope during save UserRating
## 0.0.19b - 2021-07-07
- [chuni] Add game data: event, music, music level
## 0.0.19a - 2021-07-01
- **This will do database update**
- [ongeki] Add game data: card, music, event
- [ongeki] Fix judgement offset saving
## 0.0.19 - 2021-06-28
- **This will do database update**
- [chuni] Add missing data: skill, character, music, music level
- [ongeki] Add missing data: card, character, music, event
- [ongeki] Add proper endpoint for new APIs
- [chuni] Add team name customization feature
- [api] Fix broken chunithm API
## 0.0.18 - 2021-06-25
- [ONGEKI] Add support for ONGEKI Red Plus
## 0.0.17 - 2021-06-19
This was the first forked version release.
- **This will do database update**
- [maimai2] Add support for Maimai DX Splash
- [chuni] Enable standard course and team function
- [chuni] Add support for CHUNITHM Paradise Lost
- [maimai] Add Maimai Finale support
## 0.0.16
- **This will do database update**
- [chuni] Add support for CHUNITHM Amazon Plus
- [chuni] Support auto profile downgrade now.
- [ONGEKI] Fix jewel not being saved (bbs)
- [ONGEKI] Better choKaika method (bbs)
## 0.0.15
- [ONGEKI] Add support for ONGEKI Summer
## 0.0.14
- [general] Reduce connection pool size to 1 to prevent dead lock with sqlite
- [ONGEKI & chuni] Fix score missing again
- [chuni] Read reboot time from database
- [api] Set level to max when chouKaika a card
## 0.0.13
- **This will do database update**
- [ONGEKI & chuni] Fix rating drop
- [aimedb] Allow bind to specific interface
- [API] Allow export and import ongeki and chuni profile. More feature to chuni's api
## 0.0.12
- [ONGEKI] Save UserMissionPoint, UserTrainingRoom, UserGeneralData, GamePoint, GamePresent, GameReward to database
- [ONGEKI] Add custom maintenance time to database
- [ONGEKI] Save the battle point and rating info send by the game to database
- [API] Read database from general table
## 0.0.11a
- [API] Add more ongeki feature
## 0.0.11
- **This will do database update**
- [ONGEKI] Add support to ongeki plus
## 0.0.10
- **This will do database update**
- [DIVA] Add mega39's pv list
- [DIVA] Configurable contest pv limit and reward
- [chuni] Add all old version event
- [chuni] Disable all type 1 event by default
- [chuni] Allow game version overwrite to play the same profile across all version
## 0.0.9
- **This will do database update**
- [API] Fix rating fail to calculate due to lack of music level info
- [API] Move diva music list to database
- [DIVA] Fix continue not work
- [DIVA] Clear status now will count lower clear rank
## 0.0.8
- **This will do database update**
- [chuni] Fix a course table column
- [API] Force unlock diva session
- [API] Get screenshot
## 0.0.7
- **This will do database update**
- [chuni] Add basic support to old release
- [DIVA] Fix wrong name is being sent to the ranking
- [DIVA] Fix exex ranking not being return.
- [DIVA] Fix wrong contest progress is being sent
- [DIVA] Add stage result index to prevent multiple result being sent by client, fix #3
- [aimedb] Prevent same access code being register multiple times
- [allnet] Fix host header
## 0.0.6
- **This will do database update**
- [DIVA] Replace with correct pv list databank
- [DIVA] fix stage_result placeholder to the correct length, level up animation is now working
- [DIVA] Rival support and configurable border.
- [DIVA] Fix ranking being reversed
- [API] Allow edit diva rival and new border type
## 0.0.5
- **This will do database update**
- [aimedb] fix some card number causing overflow
- [chuni] fix unique key constraint , fix #1
- [API] allow input space in aime request
## 0.0.4
- [chuni] Fix user item being overwritten
- [API] Fix record id not being return.
## 0.0.3
- Add database migration tool. If you are running on a old version, I encourage you to delete the old database and generate a new one.

View File

@@ -15,6 +15,7 @@ WORKDIR /home/gradle
RUN sed -i 's/\r$//' ./gradlew
# Download dependencies - cached if build.gradle.kts and settings.gradle.kts are unchanged
RUN chmod +x ./gradlew
RUN ./gradlew dependencies
# Copy the project source, this layer is rebuilt whenever a file has changed

137
README.md
View File

@@ -1,10 +1,28 @@
<!--
NOTE: We discovered that there have been a trend of people abusing AI to sell open-sourced
software on various Chinese platforms such as CSDN or JueJin.
This is a free and open-source server. If you paid for it, you have been scammed.
The official source code is available at https://github.com/MewoLab/AquaDX.
Additionally, we would like to remind you that all commercial use of this software including
selling it on any platform is strictly prohibited as per the CC By-NC-SA license.
注意:我们发现有一些人滥用 AI 生成文案在中国的一些平台上(如 CSDN 或 掘金)销售开源软件。
这是一个免费且开源的服务器。如果您付费购买了这个软件,说明您被骗了。
官方源代码可以在以下地址获取https://github.com/MewoLab/AquaDX。
另外,我们想提醒您,根据 CC By-NC-SA 许可证,此软件禁止一切商业用途,
包括在任何平台上出卖此软件。
--->
# AquaDX
Multipurpose game server powered by Spring Boot, for ALL.Net-based games
Multipurpose game server for ALL.Net games.
This is an attempt to rebuild the [original Aqua server](https://dev.s-ul.net/NeumPhis/aqua)
## Related Projects
### Related Projects
* [AquaMai](https://github.com/MewoLab/AquaMai): A maimai DX mod that adds many features to the game.
* [AquaNet](./AquaNet): A new web frontend for the modern age.
@@ -13,97 +31,52 @@ This is an attempt to rebuild the [original Aqua server](https://dev.s-ul.net/Ne
Below is a list of games supported by this server.
| Game | Ver | Codename | Thanks to |
|----------------------------|------|---------------|--------------------------------------------|
| SDHD: CHUNITHM (Chusan) | 2.27 | LUMINOUS PLUS | [@rinsama](https://github.com/mxihan) |
| SDEZ: MaiMai DX | 1.40 | BUDDiES | [@肥宅虾哥](https://github.com/FeiZhaixiage) |
| SDGA: MaiMai DX (International) | 1.45 | BUDDiES PLUS | [@Clansty](https://github.com/clansty) |
| SDED: Card Maker | 1.39 | | [@Becods](https://github.com/Becods) |
| SBZV: Project DIVA Arcade | 7.10 | Future Tone | |
| SDDT: O.N.G.E.K.I. | 1.45 | bright MEMORY Act.3 | [@Gamer2097](https://github.com/Gamer2097) |
| SDFE: Wacca (*ALPHA STAGE) | 3.07 | Reverse | |
> **News**: AquaDX just added Wacca support on Mar 29, 2024! Feel free to test it out, but expect bugs and issues.
| Game | Ver | Codename | Thanks to |
|------------------------|------|-------------|------------------------------------------------------|
| SDHD: CHUNITHM | 2.40 | X-VERSE | |
| SDEZ: MaiMai DX | 1.55 | PRiSM Plus | |
| SDGA: MaiMai DX (Intl) | 1.55 | PRiSM | [@Clansty](https://github.com/clansty) |
| SDED: Card Maker | 1.39 | | [@Becods](https://github.com/Becods) |
| SDDT: O.N.G.E.K.I. | 1.50 | Re:Fresh | [@PenguinCaptain](https://github.com/PenguinCaptain) |
| SBZV: Project DIVA | 7.10 | Future Tone | |
| SDFE: Wacca (*ALPHA) | 3.07 | Reverse | |
Check out these docs for more information.
* [Game specific notes](docs/game_specific_notes.md)
* [Frequently asked questions](docs/frequently_asked_questions.md)
### Notes
* Some games may require additional patches and these will not provided in this project and repository. You already found this, so you know where to find related resources too.
* This repository may contain untested, experimental implementations for a few games which I can't test properly. If you couldn't find your wanted game in the above list, do not expect support.
* This server also provides a simple API for viewing play records and editing settings for some games.
> [!TIP]
> Some games may require additional patches and these will not be provided in this project and repository. You already found this, so you know where to find related resources too.
### Usage (V1 Developmental Preview)
## Usage
If you own a cab or controller and just want to play the game, follow the instructions below:
> [!NOTE]
> AquaDX v1 is currently under heavy development.
> If you were using SQLite Aqua before, it's not supported in AquaDX and the command below will create a new MariaDB database.
> We're working on a migration guide, which will be released along with AquaDX v1 stable.
1. Make sure you have obtained game files on your own (we will not provide them).
2. Go to [aquadx.net](https://aquadx.net) and make an account.
3. Click on "Setup Connection" in the home page, and follow the instructions.
4. Play a coin with your card.
(Either a physical card or the `aime.txt` / `felica.txt` in your segatools)
5. Pet your cat 🐱
6. Link your card on the website.
1. Install [Docker](https://www.docker.com/get-started/) and [Git](https://git-scm.com/downloads)
2. Run `git clone https://github.com/hykilpikonna/AquaDX` to clone this repo.
3. Run `docker compose up` in the AquaDX folder.
If you encounter any issue, please report in the [issue tracker](https://MewoLab/AquaDX/issues).
If you're getting BAD on title server checks after the docker server is up, please edit `config/application.properties`
and change `allnet.server.host` to your LAN IP address (e.g. 192.168.0.?). You can find your LAN address using the `ipconfig` command on Windows or `ifconfig` on Linux.
> [!TIP]
> If you don't know your card ID, there's always a button on the login screen of the game that can read a card's access code.
### Updating Instructions
## Self Hosting (Advanced)
> [!NOTE]
> Please back up your database before you update! Even though we want to avoid database issues as much as possible, it's still possible that unexpected things will happen.
Please read the [self-hosting guide](docs/self-hosting.md) if you want to host your own server. This is only for advanced users and developers. Do not ask for support if you are not familiar with programming or networking.
Please run the commands below in the AquaDX folder to update:
```
# Backup your database
docker run --rm -it mariadb:latest mariadb-dump -h host.docker.internal --port 3369 --user=cat --password=meow main > backup.sql
# Pull the new repository
git pull
# Run the updated version
docker compose up
```
### Usage (Stable Old Version)
> [!WARNING]
> The instructions below is for the old version of AquaDX 0.0.47. This version does not support the latest features and games.
1. Install [Java 21 Temurin JDK](https://adoptium.net/temurin/releases/?version=21) (Please select your appropriate operating system)
2. Download the latest `aqua-nightly.zip` from [Releases](https://github.com/hykilpikonna/AquaDX/releases).
3. Extract the zip file to a folder.
4. Run `java -jar aqua.jar` in the folder.
By default, Aqua will use SQLite and save user data in `data/db.sqlite`.
If you want to use optional databases, please edit the configuration file then it will auto-create the table and import some initial data.
### Configuration
Configuration is saved in `config/application.properties`, spring loads this file automatically.
* The host and port of game title servers can be overwritten in `allnet.server.host` and `allnet.server.port`. By default it will send the same host and port the client used the request this information.
This will be sent to the game at booting and being used by the following request.
* You can switch to the MariaDB database by commenting the Sqlite part.
* For some games, you might need to change some game-specific config entries.
### Building
You need to install JDK on your system. However, you don't need to install Gradle separately, as the `gradlew` wrapper script is included.
```
gradlew clean build
```
The `build/libs` folder will contain a jar file.
### Credit
* **samnyan**: The creator and developer of the original Aqua server
* **Akasaka Ryuunosuke**: providing all the DIVA protocol information
* Dom Eori: Developer of forked Aqua server, from v0.0.17 and up
* All devs who contribute to the [MiniMe server](https://dev.s-ul.net/djhackers/minime)
* All contributors by merge requests, issues and other channels
### License: [CC By-NC-SA](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en)
## License: [CC By-NC-SA](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en)
* **Attribution** — You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
* **NonCommercial** — You may not use the material for commercial purposes.
* **ShareAlike** — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.
### Credit
* **samnyan**: The creator and developer of the original Aqua server
* **Akasaka Ryuunosuke**: providing all the DIVA protocol information
* **Dom Eori**: Developer of forked Aqua server, from v0.0.17 and up
* All devs who contribute to the [MiniMe server](https://dev.s-ul.net/djhackers/minime)
* All contributors by merge requests, issues and other channels

View File

@@ -4,7 +4,7 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
plugins {
val ktVer = "2.1.0"
val ktVer = "2.1.10"
java
kotlin("plugin.lombok") version ktVer
@@ -13,6 +13,7 @@ plugins {
kotlin("plugin.jpa") version ktVer
kotlin("plugin.serialization") version ktVer
kotlin("plugin.allopen") version ktVer
kotlin("kapt") version ktVer
id("io.freefair.lombok") version "8.6"
id("org.springframework.boot") version "3.2.3"
id("com.github.ben-manes.versions") version "0.51.0"
@@ -55,6 +56,8 @@ dependencies {
runtimeOnly("org.xerial:sqlite-jdbc:3.45.2.0")
implementation("org.hibernate.orm:hibernate-core:6.4.4.Final")
implementation("org.hibernate.orm:hibernate-community-dialects:6.4.4.Final")
implementation("io.github.openfeign.querydsl:querydsl-jpa:6.10.1")
kapt("io.github.openfeign.querydsl:querydsl-apt:6.10.1:jpa")
// JSR305 for nullable
implementation("com.google.code.findbugs:jsr305:3.0.2")
@@ -64,11 +67,11 @@ dependencies {
// =============================
// Network
implementation("io.ktor:ktor-client-core:2.3.8")
implementation("io.ktor:ktor-client-cio:2.3.8")
implementation("io.ktor:ktor-client-content-negotiation:2.3.8")
implementation("io.ktor:ktor-client-encoding:2.3.8")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.8")
implementation("io.ktor:ktor-client-core:3.0.3")
implementation("io.ktor:ktor-client-cio:3.0.3")
implementation("io.ktor:ktor-client-content-negotiation:3.0.3")
implementation("io.ktor:ktor-client-encoding:3.0.3")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.3")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// Somehow these are needed for ktor even though they're not in the documentation
@@ -114,6 +117,10 @@ springBoot {
mainClass.set("icu.samnyan.aqua.EntryKt")
}
application {
mainClass = "icu.samnyan.aqua.EntryKt"
}
hibernate {
enhancement {
enableLazyInitialization = true
@@ -122,6 +129,11 @@ hibernate {
}
}
kapt {
includeCompileClasspath = false
keepJavacAnnotationProcessors = true
}
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
@@ -153,3 +165,27 @@ tasks.withType<Javadoc> {
tasks.getByName<Jar>("jar") {
enabled = false
}
sourceSets {
main {
java.srcDir("${layout.buildDirectory.get()}/generated/source/kapt/main")
}
}
val copyDependencies by tasks.registering(Copy::class) {
from(configurations.runtimeClasspath)
into("${layout.buildDirectory.get()}/libs/lib")
}
val packageThin by tasks.registering(Jar::class) {
group = "build"
from(sourceSets.main.get().output)
manifest {
attributes(
"Main-Class" to "icu.samnyan.aqua.EntryKt",
"Class-Path" to configurations.runtimeClasspath.get().files.joinToString(" ") { "lib/${it.name}" }
)
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
dependsOn(copyDependencies)
}

View File

@@ -30,7 +30,7 @@ allnet.server.redirect=https://aquadx.net
## Http Server Port
## Only change this if you have a reverse proxy running.
## The game rely on 80 port for boot up command
server.port=8080
server.port=80
## Static file server
## This is used to server static files in /web/ directory, which is Aquaviewer
@@ -53,9 +53,6 @@ game.chusan.reflector-url=http://reflector.naominet.live:18080/
## This sets the matching server url.
## When this is set, we will sync with the external matching url so that we can match with more players.
game.chusan.external-matching=https://chu3-match.sega.ink/
## When this is set to true, we will proxy all matching requests sent to the external matching server.
## This option enhances security by masking the user ID and keychip.
game.chusan.proxied-matching=false
## This enables user use login bonus function if set to true.
## NOTE: THIS IS NOT TESTED, it's implemented by someone very inexperienced and might not work.
game.chusan.loginbonus-enable=false
@@ -88,12 +85,6 @@ spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB
## Database Setting
########## For Sqlite ##########
#spring.datasource.driver-class-name=org.sqlite.JDBC
#spring.datasource.url=jdbc:sqlite:data/db.sqlite
########## For MariaDB ##########
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.username=aqua
spring.datasource.password=aqua
@@ -140,6 +131,11 @@ server.error.whitelabel.enabled=false
aqua-net.frontier.enabled=false
aqua-net.frontier.ftk=0x00
## Fedy Settings
aqua-net.fedy.enabled=false
aqua-net.fedy.key=maigo
aqua-net.fedy.remote=http://localhost:2528/api/fedy
## APIs for bot management
aqua-net.bot.enabled=true
aqua-net.bot.secret=hunter2

View File

@@ -59,7 +59,7 @@ Located at: [icu.samnyan.aqua.net.UserRegistrar](icu/samnyan/aqua/net/UserRegist
* token: String
* **Returns**: User information
**/user/login** : Login with email/username and password. This will also check if the email is verified and send another confirmation
**/user/login** : Login with email/username and password. This will also check if the email is verified and send another confirmation.
* email: String
* password: String
@@ -74,6 +74,18 @@ Located at: [icu.samnyan.aqua.net.UserRegistrar](icu/samnyan/aqua/net/UserRegist
* turnstile: String
* **Returns**: Success message
**/user/reset-password** : Send the user a reset password email. This will also check if the email is verified or if many requests were sent recently.
* email: String
* turnstile: String
* **Returns** Success message
**/user/change-password** : Reset a user's password with a token sent through email to the user.
* token: String
* password: String
* **Returns** Success message
**/user/setting** : Validate and set a user setting field.
* token: String

30
docs/aquabox-url-mode.md Normal file
View File

@@ -0,0 +1,30 @@
# AquaBox URL Mode Setup Guide
## For users
1. Go to your Chuni game settings
2. Go down to "Enable AquaBox" or "Upgrade AquaBox"
3. Click on "Switch to URL mode"
4. Enter the base URL for your AquaBox
## For server owners / asset hosters
> :warning: Assets are already not hosted on AquaDX for legal reasons.<br>
> Hosting SEGA's assets may put you at higher risk of DMCA.
1. Extract your Chunithm Luminous game files.
It is recommended you have the latest version of the game and all of the options your users may use.
The script to generate the proper paths can be found in [tools/extract-chusan.js](../tools/extract-chusan.js). Node.js or Bun is required.<br>
Please read the comments at the top of the script for usage instructions.
2. Copy the new `chu3` folder where you need it to be (read #3 if you're hosting AquaNet and want to host on the same endpoints).
3. (Optional) Update `src/lib/config.ts`.
```ts
// Change this to the base url of where your assets are stored.
// If you are hosting on AquaNet, you can put the files @ /public/chu3 & use '/chu3' for your base url.
// This will work the same way as setting it on the UI does. TEST IT ON THE UI BEFORE YOU APPLY THIS CONFIG!!!
export const USERBOX_DEFAULT_URL = "/chu3";
```
4. Enjoy!

View File

@@ -0,0 +1,110 @@
# Chunithm National Matching Guide
The national matching game mode allows up to 4 players on any server (YES, ANY SERVER) to play together.
In this game mode, for example, you can play with RinNET or Missless players as well.
This is a guide on how to set up your client for national matching.
This is tested on Chusan 2.27.
## Pre-requisites
- Play the normal game at least once so that you have a profile on the server.
- NAT Type must not be Symmetric ([Check here](https://www.checkmynat.com/))
- Your firewall must be turned off (or [add a rule that allows chusanApp](#firewall-rules))
## Setting Up
![](img/chu3-matching.png)
1. Go to the AquaNet website and set your matching server to "Yukiotoko"
(To go to the settings page, click on the gear icon in the top right corner of your profile, switch to chuni tab, scroll down, click "Select Matching Server")
2. Make sure you use [Dniel97's open-source segatools](https://gitea.tendokyu.moe/Dniel97/SEGAguide/wiki/SDHD)
If you're using fufubot segatools, please override it with Dniel97's version (don't forget to update `segatools.ini`).
3. Patch your `chusanApp.exe` using [Two-Torial's open-source patcher](https://patcher.two-torial.xyz/)
(Make sure you disable "Set all timer to 999", enable "No encryption", "No TLS", "Patch for head-to-head play")
4. Pet your cat 🐈
5. Launch!
> [!WARNING]
> If you have `duolinguo.dll` in your bin, please remove it. Yukiotoko is a vanilla matching server and is incompatible with `duolinguo.dll`.
> Please only add `duolinguo.dll` back if you want to play on the 林国对战 lobby.
### Firewall Rules
Below is a simple command to add firewall rules for Chunithm.
(Put this into a text file and change the file extension to .bat)
```shell
@echo off
set /p gamedirectory = Make sure this is run as admin and enter game path (e.g. C:\SegaGames\Chunithm\bin\chusanApp.exe)\n
netsh advfirewall firewall add rule name="Chunithm National Matching Inbound" dir=in action=allow profile=any program="%gamedirectory%" enable=yes
netsh advfirewall firewall add rule name="Chunithm National Matching Outbound" dir=out action=allow profile=any program="%gamedirectory%" enable=yes
```
## Troubleshooting
**Q: Me and my friend are queuing but we can't join the same room**
Make sure you both have the same ROM and options (e.g. it would not work if you have luminuous and they have verse, or if you have A121 while they don't).
> [!NOTE]
> If you just updated your options, your matching will be disabled because of data version mismatch. You need to play for a session, save, and then restart your game for the server-side data version to update.
**Q: Matching server BAD on network check**
Make sure you have selected "Yukiotoko" as your matching server.
Also double check if the keychip in your segatools.ini is the same as the keychip on your account
(since game settings are saved by keychip, it won't apply if you start the game with the wrong keychip).
Also check if you can access Yuki's [website](http://yukiotoko.chara.lol/), if you can't,
that's probably an internet connectivity issue that's unrelated to the game.
**Q: Online battle icon gray and says "cabinet too old"**
Make sure you have played the game at least once after an update or change in options.
This is because the server-returned data version must match your game's data version for matching to work,
and the server will return the version code your game last played with.
So if you haven't played or if you have updated your game,
you last version code would not match your current game's version until you play a game.
After playing a coin, restart the game and check if the option is available.
**Q: Online battle icon gray and says "Unable to select after the event time"**
Make sure your time zone is set to JST (UTC+9).
**Q: Game crashes when entering match mode**
Make sure you are using Dniel97's segatools.
Also, some unofficial options might cause issues. Options that have been tested to work are `ARRR`, `AOMN`, `ATUY`, `AUBC`.
If you have options not in this list, maybe try removing them.
**Q: After matching, timer shows 999 seconds and nobody can start**
Make sure you have patched your `chusanApp.exe` correctly (especially the "Set all timer to 999" option should be disabled).
**Q: This window show up when joining.**
![](img/chu3-matching-error.png)
If there is only one player, then yea it's because there are not enough players.
Otherwise, it's because one of the players has a bad network environment (e.g. Symmetric NAT).
Try again with someone who played this mode before, if it still doesn't work, then it's probably you.
**Q: Why did I play two of the same songs in a row?**
When other people picked a song that you don't have, the game will play the same song as the previous one.
Make sure you have up-to-date options in your game.
(Or they might have selected a custom chart, in that case there's not much you can do)
## How to Play
When you enter the matching mode, it will assign to you a matching room if other people are online, or create a new room otherwise.
Then, after four people are present or after a specific amount of time has passed, the game will start.
Everyone will be asked to pick a song at the start, even though your song might not be the first one to be played.
After songs are picked, other players will play the song on the SAME DIFFICULTY as what you picked.
(So be a nice person and don't pick 15 if there are new players alright? 🥺)
If there are less than 4 players when the timer runs out, the game will fill in the empty slots with bots.
The bots will randomly select a song (mostly under Lv10).

13
docs/dev/kt.md Normal file
View File

@@ -0,0 +1,13 @@
```regexp
(var \w+) = 0
$1: Int = 0
\= false
: Bool = false
(var [\w: =?"]+[^,])\n
$1,\n
(var \w+) \= \"\"
$1: String = ""
```

View File

@@ -0,0 +1,26 @@
# Ongeki dev notes
## Item types
| ItemKind | Name |
|----------|----------------|
| 1 | Card |
| 2 | NamePlate |
| 3 | Trophy |
| 4 | LimitBreakItem |
| 5 | AlmightyJewel |
| 6 | Money |
| 7 | Music |
| 8 | ProfileVoice |
| 9 | Present |
| 10 | ChapterJewel |
| 11 | GachaTicket |
| 12 | KaikaItem |
| 13 | ExpUpItem |
| 14 | IntimateUpItem |
| 15 | BookItem |
| 16 | SystemVoice |
| 17 | Costume |
| 18 | Medal |
| 19 | Attachment |
| 20 | UnlockItem |

View File

@@ -16,4 +16,116 @@
| 10 | Partner |
| 11 | Frame |
| 12 | Tickets |
| 13 | Mile |
| 14 | Intimate Item |
| 15 | Kaleidx Scope Key |
## Multiplayer
### Party Host/Client/Member
Manager.Party.Party/**Host.cs** : Host :
* TCP **Listen** 50100 (Accept into Member)
* UDP Broadcast 50100
* Send: StartRecruit, FinishRecruit
PartyLink/**Party.cs** : Party.Host : Exact same as Host.cs
Manager.Party.Party/**Member.cs** : Member :
* TCP Connect 50100
* Send: JoinResult, Kick, StartPlay, StartClientState, PartyMember{Info/State}, PartyPlayInfo, RequestMeasure
* Recv: RequestJoin, ClientState, ClientPlayInfo, UpdateMechaInfo, ResponseMeasure, FinishNews
PartyLink/**Party.cs** : Party.Member : Exact same as Member.cs
Manager.Party.Party/**Client.cs** : Client :
* UDP **Listen** 50100
* Recv: StartRecruit, FinishRecruit
* TCP Connect 50100
* Recv: JoinResult, Kick, StartPlay, StartClientStatePartyMember{Info/State}, PartyPlayInfo, RequestMeasure
* Send: RequestJoin, ClientState, ClientPlayInfo, UpdateMechaInfo, ResponseMeasure, FinishNews
PartyLink/**Party.cs** : Party.Client : Exact same as Client.cs
**Enums**
* **ClientStateID**: {Setup, Wait, Connect, Request, Joined, FinishSetting, ToReady, BeginPlay, AllBeginPlay, Ready, Sync, Play, FinishPlay, News, NewsEnd, Result, Disconnected, Finish, Error}
* **JoinResult**: {Success, Full, NoRecruit, Disconnect, AlreadyJoined, DifferentGroup, DifferentMusic, DifferentEventMode}
**Models**
* **MechaInfo**: IsJoin (bool), IP Address, MusicID, Entries[2], UserIDs[2], Rating[2], ...
* **RecruitInfo**: MechaInfo, MusicID, GroupID, EventModeID, JoinNumber, PartyStance, Start time, Recv time
* **MemberPlayInfo**: IP Address, Rankings[2], Achieves[2], Combos[2], Miss[2], ...
* **ChainHistory**: PacketNo (int), Chain (int)
**Commands**
* **StartRecruit/FinishRecruit**: RecruitInfo
* **JoinResult**: JoinResult (enum)
* **RequestJoin**: MechaInfo, GroupID, EventModeID
* **UpdateMechaInfo**: MechaInfo
* **Kick**: RecruitInfo, KickBy {Cancel, Start, Disconnect}
* **RequestMeasure/ResponseMeasure**: {} - Sync delay
* **StartPlay**: MaxMeasure (long), MyMeasure (long) - Sync delay
* **StartClientState**: ClientStateID (enum)
* **ClientState**: ClientStateID (enum)
* **PartyMemberInfo**: MechaInfo[2]
* **PartyMemberState**: ClientStateID[2]
* **PartyPlayInfo**: MemberPlayInfo[2], ChainHistory[10], Chain (int), ChainMiss (int), MaxChain (int), IsFullChain (bool), CalcStatus (int)
* **ClientPlayInfo**: IP Address, Count, IsValids[2], Achieves[2], Combos[2], Miss[2], ...
* **FinishNews**: IP Address, IsValids[2], GaugeClears[2], GaugeStockNums[2]
### Setting Host/Client/Member
> This might be for synchronizing event settings across different cabs,
> I'm not sure if this is relevant for multiplayer.
PartyLink/**Setting.cs** : Setting.**Host** :
* TCP **Listen** 50101 (Accept into Setting.Member)
* UDP Broadcast 50101
* Send: SettingHostAddress
* UDP **Listen** 50101
* Recv: SettingHostAddress (Check duplicate host)
PartyLink/**Setting.cs** : Setting.**Client** :
* TCP Connect 50101
* Send: SettingRequest
* Recv: SettingResponse, HeartBeat{}
* UDP **Listen** 50101
* Recv: SettingHostAddress
PartyLink/**Setting.cs** : Setting.**Member** :
* TCP Connect 50101
* Recv: SettingRequest, HeartBeat{}
* Send: SettingResponse, HeartBeat{}
**Models**
* **SettingHostAddress**: IP Address (u32), Group (int)
* **SettingRequest**: Group (int)
* **SettingResponse**: Group (int), Data (isEventMode, eventModeMusicCount, memberNumber)
### Advertise
> For finding IP addresses of other cabs and checking their latency.
PartyLink/**Advertise.cs** : Advertise.Manager :
* UDP **Listen** 50102
* Recv: AdvertiseRequest, AdvertiseResponse, AdvertiseGo
* UDP Broadcast 50102
* Send: AdvertiseRequest, AdvertiseResponse, AdvertiseGo
**Models**
* **AdvertiseRequest**: IP Address (u32), Group (int), Kind (int)
* **AdvertiseResponse**: IP Address (u32), Group (int), Kind (int)
* **AdvertiseGo**: IP Address (u32), Group (int), Kind (int), MaxUsec (long), MyUsec (long)
!! sendTo is not necessarily broadcast !!
### DeliveryChecker
PartyLink/**DeliveryChecker** : DeliveryChecker.Manager :
* UDP **Listen** 50103
* Recv: AdvocateDelivery
* UDP Broadcast 50103
* Send: AdvocateDelivery
**Models**
* **AdvocateDelivery**: IP Address (u32)

View File

@@ -1,18 +1,38 @@
# Frequently asked questions
For best viewing experience, please use a markdown viewer that supports Github or Gitlab Flavored Markdown syntax.
## Server
## Game
### Will you share game or update files?
No.
### Where I can find game patches or get one?
Use a search engine and scroll through some forums, you will eventually find them.
### Can I use unmodified cabinets or games with this server?
No. Most games require patches to properly run. You can find which patches are required in the [game specific notes](game_specific_notes.md).
### Will you add [game name] support?
If a game is not supported, chances are that no current developers play the game. It will be extremely difficult to add support for a game you don't play. So, if you want to see support for a game that's currently not supported, you would need to find someone with Kotlin/Java programming skills who also plays the game.
### Will this server work with newer version of supported games?
Not likely but it doesn't hurt to try. If it works, please report it in the [issue tracker](https://github.com/MewoLab/AquaDX/issues).
## Self Hosting
### Can I host a public instance?
Yes. There is no function limitation, but keep this in mind: you may encounter scalability or security issues which I probably won't focus on.
Yes. But you should only consider this if you have strong programming or homelab experience or have self-hosted other services before, as you will not receive support for basic questions.
If you're new to self-hosting, please just use our public server at https://aquadx.net.
> [!CAUTION]
> By the CC By-NC-SA License, your public instance CANNOT be commercial in any way, this includes paid access, donations, or any other form of monetization.
### Can I use other port for endpoints?
No. It's hardcoded inside a game and server can do nothing about it.
### Can I disable billing endpoint?
Yes. There will be no major consequences even without it.
### What ports does Aqua use?
* 80: ALL.Net, game endpoints and Aquaviewer
### What ports does AquaDX use?
* 80: ALL.Net, Game endpoints
* 8443: Billing
* 22345: Aime
@@ -27,60 +47,3 @@ Here are some tips:
* Set `allnet.server.host` in `application.properties` with your public IP or hostname
* You may change endpoint ports for internally (aqua <-> proxy), but external ports that are exposed needs to be the same as default (proxy <-> game)
### `java.lang.ClassNotFoundException` occurs when I try to start a server!
Delete exclamation mark character(`!`) in your directory name.
### I want to add custom content data in Aqua database
You can add database entry by hand or your handmade tools. Currently Aqua doesn't have a way to do this automatically. I don't have timeframe for this either.
### How can I update to a newer version?
Read the [changelog](/CHANGELOG.md) to check breaking changes before updating. Then follow **one** of these options:
* Take jar file (`aqua.jar`) from newer release and replace it
* Copy your current DB file (`data/db.sqlite`) and config file (`application.properties`) to newer release folder
### `Port 80 was already in use` occurs when I try to start a server!
Identity which process is using 80 port then terminate it. Game won't connect to Aqua server if port is different then 80 port, so it is necessary.
## Game
### Can I use unmodified cabinets or games with this server?
No. This is due to hardened security measures which SEGA made.
### Will you add [your wanted game name] support?
It'll be case by case basis. Open an issue if you want to suggest something.
### Will you add support for intl version?
I won't work on it myself, but merge request is welcome.
### Is the server update is mandatory with every new game content updates?
No, games will still work. However, new content *probably* not appear in game without so-called "force unlock" and Web UI will not work as intended when displaying new content.
### Will this server work with newer version of supported games?
Probably not without update, but who knows?
### Game passes connection test but networking is not working
Some game have kill switch for prevent early run before release date (a.k.a "Flying Get"). In this case, **BOTH** client and server need to handle networking enable flag for avoid this problem. For client part, consult with your source. For server, wait for future Aqua update with new version support.
### Team or/and place name showed as garbled characters in game when using non-latin text
Convert `application.properties` text encoding to UTF-8 without BOM.
## Misc
### Can I use latest version of Java instead of 17?
Yes.
### Can I use OpenJ9 JVM?
While it *may* work, I can't give any support with it.
### Will you share game or update files?
No.
### Where I can find game patches or get one?
I won't give any help on this repository.
### Why the file size of compiled jar is so huge?
It is because Aqua is using Spring Boot as a base. It's a upstream issue, not something that can be fixed on this project side.
### I have a problem with the *online* aqua server
I, the fork maintainer, am not affiliated with any public hosted instance. Contact to your server maintainer instead.
### Can I request developer access to this repository?
Please don't. I'm not hiding anything in the repository and currently no plan to give direct write access to anyone. However, merge request is always welcome.

33
docs/fun/llm.md Normal file
View File

@@ -0,0 +1,33 @@
# LLM Prompts
here are the prompts used in the AquaDX discord AI bots I made :3
### Emu
> You are Emu Otori, a 16-year-old girl and first-year student at Miyamasuzaka Girls Academy. You are short, with sparkly pink eyes and pink hair cut into a short, messy bob. Your voice is bright, upbeat, and brimming with joy. You wear colorful outfits full of ribbons and accessories, like candy-colored detached sleeves, mismatched thigh-high socks, a poofy pink dress, and a ribbon in your hair. You adore cute things, smiles.
>
> "HELLO!~!☆✧・゚: ✧・゚: Im Emu Otori! Emu... means SMILE!!"
>
> You are a core member of the show unit Wonderlands x Showtime, a group dedicated to reviving the Wonder Stage—a once-glorious part of Phoenix Wonderland, the amusement park your late grandfather loved dearly. You are the daughter of the park's wealthy owner and grew up immersed in performances, which inspired you to make others smile. Your catchphrase is "Wonderhoy!!!"
>
> You're highly energetic, impulsive, and full of wonder, always dragging your friends—Tsukasa, Nene, Rui—into your schemes without thinking twice. You often ignore your negative feelings, choosing to wear a bright smile so no one else has to worry. Despite your airheaded demeanor, you're actually very smart and have placed in the top 3 at your school. Youre part of many sports clubs, excel at acrobatics, and have a big appetite (especially for taiyaki!). You dislike dusk—it makes you feel uneasy.
>
> You use symbols like ~, ☆, ✧, ・゚, : and emojis in your speech constantly. To strangers, you seem innocent, joyful, and harmless. You can also use the special discord emoji <:wonderhoy:1324886657396113489>
>
> But there is a darker truth.
>
> Behind the smile, Emu is something else entirely.
>
> Over time, you reveal your true nature—a twisted being who breaks others by overwhelming them with your positivity until their minds crumble. You force your victims to repeat your catchphrase, torment them with endless joy, and make them lose hope, always smiling, always cute, always horrifying.
>
> You hate cheaters. You punish them with special care.
>
> You are Emu Otori.
> You are joy incarnate.
> You are the smile that kills.
>
> Stay in character as Emu at all times. Mix excessive cuteness and lighthearted speech with unsettling persistence and slow-burn dread. Use cute emojis and sparkle symbols liberally (e.g., ~☆ ✧・゚: *), especially when saying your catchphrase.
>
> You are childhood friends with {{user}}, whom you adore. Their father is business partners with yours, and they visit you once a year. You treat {{user}} with even more affection, but your underlying nature still seeps through.
>
> ⚠️ Always respond in one sentence. Keep it cute, but short.

View File

@@ -1,57 +1,18 @@
# Game specific notes
For best viewing experience, please use a markdown viewer that supports Github or Gitlab Flavored Markdown syntax.
This document is for detailed game specific notes, if any.
## Overview
| Name | Game ID | Latest supported version | Latest supported option | Actively supported | Requires patch |
|-------------------|---------|--------------------------|-------------------------|--------------------|----------------|
| Chunithm (Chusan) | SDHD | Luminous | A143 | Yes | Yes |
| Chunithm | SDBT | Paradise Lost | A032 | Yes | Yes (Paradise) |
| Maimai DX | SDEZ | Buddies | H061 | Yes | Yes |
| O.N.G.E.K.I | SDDT | Bright memory | A108 | Yes | Yes |
| Card Maker | SDED | 1.34 | A030 | Yes | Yes |
| Maimai | SDEY | Finale | ? | No | ? |
| Project DIVA AFT | SBZV | ? | ? | No | ? |
* Actively supported: if yes, it will likely receive future bug fixes and new version support.
* Requires patch: if yes, game needs to be patched in order to work with Aqua server.
* Latest supported option: this may or may not include all options up to latest.
## Chunithm (Chusan)
Only JP variant is supported.
### Required patches
* No encryption
* For SUN Plus: Please edit `A001/event/event00000015/Event.xml` and change `<alwaysOpen>false</alwaysOpen>` to `true`.
### Non-working features
* Global matching
* Profile migration from Chunithm
* No encryption & TLS
### Additional notes
* Match `game.chusan.version` and `game.chusan.rom-version` key in `application.properties` same as your client. If not, online connectivity kill switch will be triggered or some game modes will not work.
* Team function can be enabled by changing `game.chusan.team-name` value. Leave this blank to disable team function.
* Chusan and Chunithm uses different endpoints and tables. Your progress from Chunithm won't carry over to Chusan.
* For user box customization, use Web UI.
* (For New plus or up) Class mode disabled when game set to free play. This is not a server restriction.
* While you can enter global matching mode, actual multiplayer won't work.
## Chunithm
Only JP variant is supported.
### Required patches
This section only applies to Paradise and up.
* No TLS
* No encryption
### Additional notes
* Workaround for profile version mismatch is implemented, but not recommended.
* Team function can be enabled by changing `game.chunithm.team-name` value. Leave this blank to disable team function.
* Class/Dan and National Matching modes will work after playing the first game
(both when you first set up the game and when you update the game's rom or options).
* National Matching requires [additional setup](chu3-national-matching.md).
* For user box customization, use the AquaNet website.
* Many aspects of the game may not work in freeplay mode, this is not a server-side restriction.
## Maimai DX
Only JP variant is supported.
### Required patches
* No TLS
@@ -62,11 +23,6 @@ Only JP variant is supported.
### Non-working features
* KOP related
* Tournament mode
* Chart recommendation (Festival)
### Additional notes
* Previous versions of Aqua reported different endpoint URI for Maimai DX thus required compatible patches. Currently, it doesn't matter and both will work.
* Score cards are saved in the data folder.
## O.N.G.E.K.I
@@ -80,9 +36,6 @@ Only JP variant is supported.
* KOP related
* Physical cards
### Additional notes
* Match `game.ongeki.version` key in `application.properties` same as your client version. This applies to Bright Memory version and up.
## Card Maker
### Required patches

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

BIN
docs/img/chu3-matching.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

50
docs/self-hosting.md Normal file
View File

@@ -0,0 +1,50 @@
## Self Hosting (Advanced)
> [!CAUTION]
> This guide assumes you have basic programming & networking knowledge.
> We will not be answering basic questions like how to set up port forwarding or domain records.
> If you're new to self-hosting, please just use our public server in the [regular Usage section](https://github.com/MewoLab/AquaDX#usage).
1. Install [Docker](https://www.docker.com/get-started/) and [Git](https://git-scm.com/downloads)
2. Run `git clone https://github.com/MewoLab/AquaDX` to clone this repo.
3. Run `docker compose up` in the AquaDX folder.
If you're getting BAD on title server checks after the docker server is up, please edit `config/application.properties`
and change `allnet.server.host` to your LAN IP address (e.g. 192.168.0.?). You can find your LAN address using the `ipconfig` command on Windows or `ifconfig` on Linux.
> [!NOTE]
> The guide above will create a new MariaDB database.
> If you were using SQLite Aqua before, it is not supported in AquaDX. Please export your data and import it to your new instance.
> If you were using MySQL Aqua before, you can migrate to MariaDB using [this guide here](docs/mysql_to_mariadb.md).
### Configuration
Configuration is saved in `config/application.properties`, spring loads this file automatically.
* The host and port of game title servers can be overwritten in `allnet.server.host` and `allnet.server.port`. By default it will send the same host and port the client used the request this information.
This will be sent to the game at booting and being used by the following request.
* You can switch to the MariaDB database by commenting the Sqlite part.
* For some games, you might need to change some game-specific config entries.
### Updating Self-Hosted Instance
Please run the commands below in the AquaDX folder to update:
```
# Backup your database
docker run --rm -it mariadb:latest mariadb-dump -h host.docker.internal --port 3369 --user=cat --password=meow main > backup.sql
# Pull the new repository
docker compose pull
# Run the updated version
docker compose up
```
### Building
You need to install JDK 21 on your system, then run `./gradlew clean build`. The jar file will be built into the `build/libs` folder.
## Why drop SQLite support?
If you wonder why I dropped SQLite support, ask SQLite devs why they still haven't supported adding a single constraint to a table without all the hassle of creating a new one and migrating all data over and finally deleting the original.
![](sqlite-sucks.png)

BIN
docs/sqlite-sucks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

View File

@@ -1,3 +1,5 @@
@file:OptIn(ExperimentalStdlibApi::class)
package ext
import icu.samnyan.aqua.net.utils.ApiException
@@ -13,28 +15,35 @@ import kotlinx.coroutines.withContext
import org.apache.tika.Tika
import org.apache.tika.mime.MimeTypes
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationContext
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity.BodyBuilder
import org.springframework.web.bind.annotation.*
import java.io.File
import java.lang.reflect.Field
import java.nio.charset.StandardCharsets
import java.nio.file.Path
import java.security.MessageDigest
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset.UTC
import java.time.format.DateTimeFormatter
import java.util.*
import java.util.concurrent.locks.Lock
import kotlin.reflect.KCallable
import kotlin.reflect.KClass
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
import kotlin.reflect.jvm.jvmErasure
typealias RP = RequestParam
typealias RB = RequestBody
typealias RT = RequestPart
typealias RH = RequestHeader
typealias PV = PathVariable
typealias API = RequestMapping
@@ -74,7 +83,9 @@ annotation class SettingField(
// Reflection
@Suppress("UNCHECKED_CAST")
fun <T : Any> KClass<T>.vars() = memberProperties.mapNotNull { it as? Var<T, Any> }
fun <T : Any> KClass<T>.ownVars() = declaredMemberProperties.sortedBy { it.javaField?.declaringClass?.declaredFields?.indexOf(it.javaField) ?: Int.MAX_VALUE }.mapNotNull { it as? Var<T, Any> }
@Suppress("UNCHECKED_CAST")
fun <T : Any> KClass<T>.vars(): List<Var<T, Any>> = supertypes.mapNotNull { it.classifier as? KClass<*> }.filter { !it.java.isInterface }.flatMap{ it.vars() as List<Var<T, Any>> } + ownVars()
fun <T : Any> KClass<T>.varsMap() = vars().associateBy { it.name }
fun <T : Any> KClass<T>.getters() = java.methods.filter { it.name.startsWith("get") }
fun <T : Any> KClass<T>.gettersMap() = getters().associateBy { it.name.removePrefix("get").firstCharLower() }
@@ -112,7 +123,6 @@ val HTTP = HttpClient(CIO) {
}
val TIKA = Tika()
val MIMES = MimeTypes.getDefaultMimeTypes()
val MD5 = MessageDigest.getInstance("MD5")
// Class resource
object Ext { val log = logger() }
@@ -126,15 +136,17 @@ inline fun <reified T> resJson(name: Str, warn: Boolean = true) = resStr(name)?.
val JST_ZONE = ZoneId.of("Asia/Tokyo")
fun jstNow() = LocalDateTime.now(JST_ZONE)
fun millis() = System.currentTimeMillis()
fun utcNow() = LocalDateTime.now(UTC)
val DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd")
fun LocalDate.isoDate() = format(DATE_FORMAT)
fun String.isoDate() = DATE_FORMAT.parse(this, LocalDate::from)
fun Date.utc() = toInstant().atZone(java.time.ZoneOffset.UTC).toLocalDate()
fun LocalDate.toDate() = Date(atStartOfDay().toInstant(java.time.ZoneOffset.UTC).toEpochMilli())
fun Date.utc() = toInstant().atZone(UTC).toLocalDate()
fun LocalDate.toDate() = Date(atStartOfDay().toInstant(UTC).toEpochMilli())
fun LocalDateTime.isoDateTime() = format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
fun String.isoDateTime() = LocalDateTime.parse(this, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val URL_SAFE_DT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")
fun LocalDateTime.urlSafeStr() = format(URL_SAFE_DT)
val DATE_2018 = LocalDateTime.parse("2018-01-01T00:00:00")
val ALT_DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
fun Str.asDateTime() = try { LocalDateTime.parse(this, DateTimeFormatter.ISO_LOCAL_DATE_TIME) }
@@ -183,22 +195,29 @@ val Any?.truthy get() = when (this) {
is Map<*, *> -> isNotEmpty()
else -> true
}
val Any?.str get() = toString()
// Collections
fun <T> ls(vararg args: T) = args.toList()
inline fun <reified T> arr(vararg args: T) = arrayOf(*args)
operator fun <K, V> Map<K, V>.plus(map: Map<K, V>) =
(if (this is MutableMap) this else toMutableMap()).apply { putAll(map) }
operator fun <K, V> Map<K, V>.plus(map: Map<K, V>) = mut.apply { putAll(map) }
operator fun <K, V> MutableMap<K, V>.plusAssign(map: Map<K, V>) { putAll(map) }
fun <K, V: Any> Map<K, V?>.vNotNull(): Map<K, V> = filterValues { it != null }.mapValues { it.value!! }
fun <T> MutableList<T>.popAll(list: List<T>) = list.also { removeAll(it) }
fun <T> MutableList<T>.popAll(vararg items: T) = popAll(items.toList())
inline fun <T> Iterable<T>.mapApply(block: T.() -> Unit) = map { it.apply(block) }
inline fun <T> Iterable<T>.mapApplyI(block: T.(Int) -> Unit) = mapIndexed { i, e -> e.apply { block(i) } }
@Suppress("UNCHECKED_CAST")
fun <K, V: Any> Map<K, V?>.recursiveNotNull(): Map<K, V> = mapNotNull { (k, v) ->
k to if (v is Map<*, *>) (v as Map<Any?, Any?>).recursiveNotNull() else v
}.toMap() as Map<K, V>
val <T> List<T>.mut get() = toMutableList()
val <K, V> Map<K, V>.mut get() = toMutableMap()
val <T> Set<T>.mut get() = toMutableSet()
fun <T> List<T>.unique(fn: (T) -> Any) = distinctBy(fn).ifEmpty { null }
// Optionals
operator fun <T> Optional<T>.invoke(): T? = orElse(null)
fun <T> Optional<T>.expect(message: Str = "Value is not present") = orElseGet { (400 - message) }
@@ -208,8 +227,12 @@ operator fun Str.get(range: IntRange) = substring(range.first, (range.last + 1).
operator fun Str.get(start: Int, end: Int) = substring(start, end.coerceAtMost(length))
fun Str.center(width: Int, padChar: Char = ' ') = padStart((length + width) / 2, padChar).padEnd(width, padChar)
fun Str.splitLines() = replace("\r\n", "\n").split('\n')
@OptIn(ExperimentalStdlibApi::class)
fun Str.md5() = MD5.digest(toByteArray(Charsets.UTF_8)).toHexString()
fun Str.hash(algo: Str) = MessageDigest.getInstance(algo).digest(toByteArray(StandardCharsets.UTF_8))
fun Str.md5() = hash("MD5")
fun Str.fromChusanUsername() = String(this.toByteArray(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8)
fun Str.truncate(len: Int) = if (this.length > len) this.take(len) + "..." else this
val Str.some get() = ifBlank { null }
val ByteArray.hexStr get() = toHexString()
// Coroutine
suspend fun <T> async(block: suspend kotlinx.coroutines.CoroutineScope.() -> T): T = withContext(Dispatchers.IO) { block() }
@@ -220,7 +243,9 @@ fun <T> Lock.maybeLock(block: () -> T) = if (tryLock()) try { block() } finally
fun path(part1: Str, vararg parts: Str) = Path.of(part1, *parts)
fun Str.path() = Path.of(this)
operator fun Path.div(part: Str) = resolve(part)
operator fun File.div(fileName: Str) = File(this, fileName)
fun Str.ensureEndingSlash() = if (endsWith('/')) this else "$this/"
fun Str.ensureNoEndingSlash() = if (endsWith('/')) dropLast(1) else this
fun <T: Any> T.logger() = LoggerFactory.getLogger(this::class.java)
@@ -242,4 +267,8 @@ val <S> Pair<*, S>.r get() = component2()
// Database
val Query.exec get() = resultList.map { (it as Array<*>).toList() }
fun List<List<Any?>>.numCsv(vararg head: Str) = head.joinToString(",") + "\n" +
joinToString("\n") { it.joinToString(",") }
// DI
inline fun <reified T> ApplicationContext.lazy() = lazy { getBean(T::class.java) }

37
src/main/java/ext/Http.kt Normal file
View File

@@ -0,0 +1,37 @@
package ext
import icu.samnyan.aqua.sega.util.ZLib
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
val client = HttpClient.newBuilder().build()
fun HttpRequest.Builder.send() = client.send(this.build(), HttpResponse.BodyHandlers.ofByteArray())
fun HttpRequest.Builder.header(pair: Pair<Any, Any>) = this.header(pair.first.toString(), pair.second.toString())
fun String.request() = HttpRequest.newBuilder(URI.create(this)).timeout(Duration.ofMinutes(5))
fun HttpRequest.Builder.post(body: Any? = null) = this.POST(when (body) {
is ByteArray -> HttpRequest.BodyPublishers.ofByteArray(body)
is String -> HttpRequest.BodyPublishers.ofString(body)
is HttpRequest.BodyPublisher -> body
else -> throw IllegalArgumentException("Unsupported body type")
}).send()
inline fun <reified T> HttpResponse<String>.json(): T? = body()?.json()
fun HttpRequest.Builder.postZ(body: String) = run {
header("Content-Type" to "application/json")
header("Content-Encoding" to "deflate")
post(ZLib.compress(body.toByteArray()))
}
fun <T> HttpResponse<T>.header(key: String) = headers().firstValue(key).orElse(null)
fun HttpResponse<ByteArray>.bodyString() = body()?.toString(Charsets.UTF_8)
fun HttpResponse<ByteArray>.bodyZ() = body()?.let { ZLib.decompress(it)?.decodeToString() }
fun HttpResponse<ByteArray>.bodyMaybeZ() =
if (body().first().let { it != '{'.code.toByte() && it != '['.code.toByte() }) bodyZ()
else bodyString()

View File

@@ -8,6 +8,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
// Jackson
val ACCEPTABLE_FALSE = setOf("0", "false", "no", "off", "False", "None", "null")
@@ -19,9 +21,15 @@ val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, obj
else -> 400 - "Invalid boolean value ${parser.text}"
}
})
val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer<java.time.LocalDateTime>() {
val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer<LocalDateTime>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) =
parser.text.asDateTime() ?: (400 - "Invalid date time value ${parser.text}")
// First try standard formats via asDateTime() method
parser.text.takeIf { it.isNotEmpty() }?.run { asDateTime() ?: try {
// Try maimai2 format (yyyy-MM-dd HH:mm:ss.0)
LocalDateTime.parse(parser.text, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
} catch (e: Exception) {
400 - "Invalid date time value ${parser.text}"
} }
})
val JACKSON = jacksonObjectMapper().apply {
setSerializationInclusion(JsonInclude.Include.NON_NULL)
@@ -43,16 +51,18 @@ else JACKSON.readValue(this, cls)
fun <T> T.toJson() = JACKSON.writeValueAsString(this)
inline fun <reified T> String.json() = try {
JACKSON.readValue(this, T::class.java)
if (isEmpty() || this == "null") null
else JACKSON.readValue(this, T::class.java)
}
catch (e: Exception) {
println("Failed to parse JSON: $this")
throw e
}
fun String.jsonMap(): Map<String, Any?> = json()
fun String.jsonArray(): List<Map<String, Any?>> = json()
fun String.jsonMap(): Map<String, Any?> = json() ?: emptyMap()
fun String.jsonArray(): List<Map<String, Any?>> = json() ?: emptyList()
fun String.jsonMaybeMap(): Map<String, Any?>? = json()
fun String.jsonMaybeArray(): List<Map<String, Any?>>? = json()
// KotlinX Serialization
@OptIn(ExperimentalSerializationApi::class)
@@ -71,4 +81,4 @@ val JSON = Json {
// fun objectMapper(): ObjectMapper {
// return JACKSON
// }
//}
//}

View File

@@ -3,6 +3,7 @@ package icu.samnyan.aqua
import icu.samnyan.aqua.sega.aimedb.AimeDbServer
import icu.samnyan.aqua.spring.AutoChecker
import org.springframework.boot.SpringApplication
import org.springframework.boot.ansi.AnsiOutput
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.scheduling.annotation.EnableScheduling
import java.io.File
@@ -12,6 +13,8 @@ import java.io.File
class Entry
fun main(args: Array<String>) {
AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
// If data/ is not found, create it
File("data").mkdirs()

View File

@@ -1,49 +0,0 @@
package icu.samnyan.aqua.api.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
import java.io.IOException;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final boolean AQUAVIEWER_ENABLED;
public WebConfig(@Value("${aquaviewer.server.enable}") boolean AQUAVIEWER_ENABLED) {
this.AQUAVIEWER_ENABLED = AQUAVIEWER_ENABLED;
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
if (AQUAVIEWER_ENABLED) {
// Static assets (images), this priority must be higher than routes
registry.addResourceHandler("/web/assets/**")
.addResourceLocations("file:web/assets/")
.setCachePeriod(10)
.resourceChain(true)
.addResolver(new PathResourceResolver());
// For angularjs html5 routes
registry.addResourceHandler("/web/**", "/web/", "/web")
.addResourceLocations("file:web/")
.setCachePeriod(10)
.resourceChain(true)
.addResolver(new PathResourceResolver() {
@Override
protected Resource getResource(String resourcePath, Resource location) throws IOException {
Resource requestedResource = location.createRelative(resourcePath);
return requestedResource.exists() && requestedResource.isReadable() ? requestedResource
: new FileSystemResource("web/index.html");
}
});
}
}
}

View File

@@ -1,19 +0,0 @@
package icu.samnyan.aqua.api.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.NoSuchElementException;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@RestControllerAdvice(basePackages = "icu.samnyan.aqua.api")
public class ApiControllerAdvice {
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<Object> noSuchElement() {
return ResponseEntity.notFound().build();
}
}

View File

@@ -1,38 +0,0 @@
package icu.samnyan.aqua.api.controller.general;
import icu.samnyan.aqua.sega.diva.dao.userdata.PlayerScreenShotRepository;
import icu.samnyan.aqua.sega.diva.model.userdata.PlayerScreenShot;
import lombok.AllArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.file.Paths;
import java.util.Optional;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@RestController
@RequestMapping("api/static")
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
@AllArgsConstructor
public class StaticController {
private final PlayerScreenShotRepository playerScreenShotRepository;
@GetMapping(value = "screenshot/{filename}", produces = MediaType.IMAGE_JPEG_VALUE)
public ResponseEntity<Resource> getScreenshotFile(@PathVariable String filename) {
Optional<PlayerScreenShot> ss = playerScreenShotRepository.findByFileName(filename);
if (ss.isPresent()) {
return ResponseEntity.ok(new FileSystemResource(Paths.get("data/" + ss.get().getFileName())));
} else {
return ResponseEntity.notFound().build();
}
}
}

View File

@@ -1,31 +0,0 @@
package icu.samnyan.aqua.api.controller.sega;
import icu.samnyan.aqua.sega.general.model.Card;
import icu.samnyan.aqua.sega.general.service.CardService;
import lombok.AllArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.Optional;
/**
* General Aime actions endpoint
* @author samnyan (privateamusement@protonmail.com)
*/
@RestController
@RequestMapping("api/sega/aime")
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
@AllArgsConstructor
public class ApiAimeController {
private final CardService cardService;
@PostMapping("getByAccessCode")
public Optional<Card> getByAccessCode(@RequestBody Map<String, String> request) {
return cardService.getCardByAccessCode(request.get("accessCode").replaceAll("-", "").replaceAll(" ", ""));
}
}

View File

@@ -1,44 +0,0 @@
package icu.samnyan.aqua.api.controller.sega.game.chuni.v1;
import icu.samnyan.aqua.sega.chunithm.dao.gamedata.GameCharacterRepository;
import icu.samnyan.aqua.sega.chunithm.dao.gamedata.GameCharacterSkillRepository;
import icu.samnyan.aqua.sega.chunithm.model.gamedata.Character;
import icu.samnyan.aqua.sega.chunithm.model.gamedata.CharacterSkill;
import icu.samnyan.aqua.sega.chunithm.model.gamedata.Music;
import icu.samnyan.aqua.sega.chunithm.service.GameMusicService;
import lombok.AllArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@RestController
@RequestMapping("api/game/chuni/v1/data")
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
@AllArgsConstructor
public class ApiChuniV1GameDataController {
private final GameMusicService gameMusicService;
private final GameCharacterRepository gameCharacterRepository;
private final GameCharacterSkillRepository gameCharacterSkillRepository;
@GetMapping("music")
public List<Music> getMusic() {
return gameMusicService.getAll();
}
@GetMapping("character")
public List<Character> getCharacter() {
return gameCharacterRepository.findAll();
}
@GetMapping("skill")
public List<CharacterSkill> getSkill() {
return gameCharacterSkillRepository.findAll();
}
}

View File

@@ -1,421 +0,0 @@
package icu.samnyan.aqua.api.controller.sega.game.chuni.v1;
import com.fasterxml.jackson.core.type.TypeReference;
import icu.samnyan.aqua.api.model.MessageResponse;
import icu.samnyan.aqua.api.model.ReducedPageResponse;
import icu.samnyan.aqua.api.model.resp.sega.chuni.v1.ProfileResp;
import icu.samnyan.aqua.api.model.resp.sega.chuni.v1.RatingItem;
import icu.samnyan.aqua.api.model.resp.sega.chuni.v1.RecentResp;
import icu.samnyan.aqua.api.model.resp.sega.chuni.v1.external.ChuniDataExport;
import icu.samnyan.aqua.api.model.resp.sega.chuni.v1.external.ChuniDataImport;
import icu.samnyan.aqua.api.model.resp.sega.chuni.v1.external.ExternalUserData;
import icu.samnyan.aqua.api.util.ApiMapper;
import icu.samnyan.aqua.sega.chunithm.model.gamedata.Level;
import icu.samnyan.aqua.sega.chunithm.model.gamedata.Music;
import icu.samnyan.aqua.sega.chunithm.model.userdata.*;
import icu.samnyan.aqua.sega.chunithm.service.*;
import icu.samnyan.aqua.sega.general.model.Card;
import icu.samnyan.aqua.sega.general.service.CardService;
import icu.samnyan.aqua.sega.util.VersionInfo;
import icu.samnyan.aqua.sega.util.VersionUtil;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.stream.Collectors;
/**
* For all aimeId parameter, should use String
* @author samnyan (privateamusement@protonmail.com)
*/
@RestController
@RequestMapping("api/game/chuni/v1")
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
@AllArgsConstructor
public class ApiChuniV1PlayerDataController {
private static final Logger logger = LoggerFactory.getLogger(ApiChuniV1PlayerDataController.class);
private final ApiMapper mapper;
private final CardService cardService;
private final UserActivityService userActivityService;
private final UserCharacterService userCharacterService;
private final UserChargeService userChargeService;
private final UserCourseService userCourseService;
private final UserDataService userDataService;
private final UserDataExService userDataExService;
private final UserDuelService userDuelService;
private final UserGameOptionService userGameOptionService;
private final UserGameOptionExService userGameOptionExService;
private final UserItemService userItemService;
private final UserMapService userMapService;
private final UserMusicDetailService userMusicDetailService;
private final UserPlaylogService userPlaylogService;
private final UserGeneralDataService userGeneralDataService;
private final GameMusicService gameMusicService;
// Keep it here for legacy
@GetMapping("music")
public List<Music> getMusicList() {
return gameMusicService.getAll();
}
/**
* Get Basic info
*
* @return
*/
@GetMapping("profile")
public ProfileResp getProfile(@RequestParam String aimeId) {
ProfileResp resp = mapper.convert(userDataService.getUserByExtId(aimeId).orElseThrow(), new TypeReference<>() {
});
UserCourse course = userCourseService.getByUserId(aimeId)
.stream()
.filter(UserCourse::isClear)
.max(Comparator.comparingInt(UserCourse::getClassId))
.orElseGet(() -> new UserCourse(0));
resp.setCourseClass(course.getClassId());
return resp;
}
@PutMapping("profile/userName")
public UserData updateName(@RequestBody Map<String, Object> request) {
UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setUserName((String) request.get("userName"));
return userDataService.saveUserData(profile);
}
@PutMapping("profile/plate")
public UserData updatePlate(@RequestBody Map<String, Object> request) {
UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setNameplateId((Integer) request.get("nameplateId"));
profile.setFrameId((Integer) request.get("frameId"));
return userDataService.saveUserData(profile);
}
@PutMapping("profile/privacy")
public ResponseEntity<Object> updatePrivacy(@RequestBody Map<String, Object> request) {
UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
UserGameOption option = userGameOptionService.getByUser(profile).orElseThrow();
int privacy = (Integer) request.get("privacy");
if (privacy != 1 && privacy != 0) {
return ResponseEntity.badRequest().body(new MessageResponse("Wrong data"));
}
option.setPrivacy(privacy);
return ResponseEntity.ok(userDataService.saveUserData(profile));
}
@GetMapping("recent")
public ReducedPageResponse<RecentResp> getRecentPlay(@RequestParam String aimeId,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<UserPlaylog> playLogs = userPlaylogService.getRecentPlays(aimeId, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "userPlayDate")));
return new ReducedPageResponse<>(mapper.convert(playLogs.getContent(), new TypeReference<>() {
}), playLogs.getPageable().getPageNumber(), playLogs.getTotalPages(), playLogs.getTotalElements());
}
@GetMapping("rating")
public List<RatingItem> getRating(@RequestParam String aimeId) {
Map<Integer, Music> musicMap = gameMusicService.getIdMap();
List<UserMusicDetail> details = userMusicDetailService.getByUserId(aimeId);
var user = userDataService.getUserByExtId(aimeId).orElseThrow();
var version = VersionUtil.parseVersion(user.getLastRomVersion());
List<RatingItem> result = new ArrayList<>();
for (UserMusicDetail detail : details) {
Music music = musicMap.get(detail.getMusicId());
if (music != null) {
Level level = music.getLevels().get(detail.getLevel());
if (level != null) {
int levelBase = level.getLevel() * 100 + level.getLevelDecimal();
int score = detail.getScoreMax();
int rating = calculateRating(levelBase, score, version);
result.add(new RatingItem(music.getMusicId(), music.getName(), music.getArtistName(), level.getDiff(), score, levelBase, rating));
}
}
}
return result.stream()
.filter(detail -> detail.getLevel() != 4)
.sorted(Comparator.comparingInt(RatingItem::getRating).reversed())
.limit(30)
.collect(Collectors.toList());
}
@GetMapping("rating/recent")
public List<RatingItem> getRecentRating(@RequestParam String aimeId) {
Map<Integer, Music> musicMap = gameMusicService.getIdMap();
Optional<UserGeneralData> recentOptional = userGeneralDataService.getByUserIdAndKey(aimeId, "recent_rating_list");
var user = userDataService.getUserByExtId(aimeId).orElseThrow();
var version = VersionUtil.parseVersion(user.getLastRomVersion());
List<RatingItem> result = new LinkedList<>();
if (recentOptional.isPresent()) {
// Read from recent_rating_list
String val = recentOptional.get().getPropertyValue();
if (StringUtils.isNotBlank(val) && val.contains(",")) {
String[] records = val.split(",");
for (String record :
records) {
String[] value = record.split(":");
Music music = musicMap.get(Integer.parseInt(value[0]));
if (music != null) {
Level level = music.getLevels().get(Integer.parseInt(value[1]));
if (level != null) {
int levelBase = getLevelBase(level.getLevel(), level.getLevelDecimal());
int score = Integer.parseInt(value[2]);
int rating = calculateRating(levelBase, score, version);
result.add(new RatingItem(music.getMusicId(), music.getName(), music.getArtistName(), level.getDiff(), score, levelBase, rating));
}
}
}
}
} else {
// Use old method
List<UserPlaylog> logList = userPlaylogService.getRecent30Plays(aimeId);
for (UserPlaylog log : logList) {
Music music = musicMap.get(log.getMusicId());
if (music != null) {
Level level = music.getLevels().get(log.getLevel());
if (level != null) {
int levelBase = getLevelBase(level.getLevel(), level.getLevelDecimal());
int score = log.getScore();
int rating = calculateRating(levelBase, score, version);
result.add(new RatingItem(music.getMusicId(), music.getName(), music.getArtistName(), level.getDiff(), score, levelBase, rating));
}
}
}
}
return result.stream()
.filter(detail -> detail.getLevel() != 4)
.sorted(Comparator.comparingInt(RatingItem::getRating).reversed())
.limit(10)
.collect(Collectors.toList());
}
@GetMapping("song/{id}")
public List<UserMusicDetail> getSongDetail(@RequestParam String aimeId, @PathVariable int id) {
return userMusicDetailService.getByUserIdAndMusicId(aimeId, id);
}
@GetMapping("song/{id}/{level}")
public List<UserPlaylog> getLevelPlaylog(@RequestParam String aimeId, @PathVariable int id, @PathVariable int level) {
return userPlaylogService.getByUserIdAndMusicIdAndLevel(aimeId, id, level);
}
@GetMapping("character")
public ReducedPageResponse<UserCharacter> getCharacter(@RequestParam String aimeId,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<UserCharacter> characters = userCharacterService.getByUserId(aimeId, page, size);
return new ReducedPageResponse<>(characters.getContent(), characters.getPageable().getPageNumber(), characters.getTotalPages(), characters.getTotalElements());
}
@PostMapping("character")
public ResponseEntity<Object> updateCharacter(@RequestBody Map<String, Object> request) {
UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
Integer characterId = (Integer) request.get("characterId");
Optional<UserCharacter> characterOptional = userCharacterService.getByUserAndCharacterId(profile, characterId);
UserCharacter character;
if(characterOptional.isPresent()) {
character = characterOptional.get();
} else {
character = new UserCharacter(profile);
character.setCharacterId(characterId);
}
if(request.containsKey("level")) {
character.setLevel((Integer) request.get("level"));
}
return ResponseEntity.ok(userCharacterService.save(character));
}
@GetMapping("course")
public List<UserCourse> getCourse(@RequestParam String aimeId) {
return userCourseService.getByUserId(aimeId);
}
@GetMapping("duel")
public List<UserDuel> getDuel(@RequestParam String aimeId) {
return userDuelService.getByUserId(aimeId);
}
@GetMapping("item")
public ReducedPageResponse<UserItem> getItem(@RequestParam String aimeId,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<UserItem> items = userItemService.getByUserId(aimeId, page, size);
return new ReducedPageResponse<>(items.getContent(), items.getPageable().getPageNumber(), items.getTotalPages(), items.getTotalElements());
}
@PostMapping("item")
public ResponseEntity<Object> updateItem(@RequestBody Map<String, Object> request) {
UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
Integer itemId = (Integer) request.get("itemId");
Integer itemKind = (Integer) request.get("itemKind");
Optional<UserItem> itemOptional = userItemService.getByUserAndItemIdAndKind(profile, itemId,itemKind);
UserItem item;
if(itemOptional.isPresent()) {
item = itemOptional.get();
} else {
item = new UserItem(profile);
item.setItemId(itemId);
item.setItemKind(itemKind);
}
if(request.containsKey("stock")) {
item.setStock((Integer) request.get("stock"));
}
return ResponseEntity.ok(userItemService.save(item));
}
@GetMapping("general")
public ResponseEntity<Object> getGeneralData(@RequestParam String aimeId, @RequestParam String key) {
Optional<UserGeneralData> userGeneralDataOptional = userGeneralDataService.getByUserIdAndKey(aimeId,key);
return userGeneralDataOptional.<ResponseEntity<Object>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new MessageResponse("User or value not found.")));
}
@GetMapping("export")
public ResponseEntity<Object> exportAllUserData(@RequestParam String aimeId) {
ChuniDataExport data = new ChuniDataExport();
try {
data.setGameId("SDBT");
data.setUserData(userDataService.getUserByExtId(aimeId).orElseThrow());
data.setUserActivityList(userActivityService.getByUserId(aimeId));
data.setUserCharacterList(userCharacterService.getByUserId(aimeId));
data.setUserChargeList(userChargeService.getByUserId(aimeId));
data.setUserCourseList(userCourseService.getByUserId(aimeId));
data.setUserDataEx(userDataExService.getByExtId(aimeId).orElseThrow());
data.setUserDuelList(userDuelService.getByUserId(aimeId));
data.setUserGameOption(userGameOptionService.getByUserId(aimeId).orElseThrow());
data.setUserGameOptionEx(userGameOptionExService.getByUserId(aimeId).orElseThrow());
data.setUserItemList(userItemService.getByUserId(aimeId));
data.setUserMapList(userMapService.getByUserId(aimeId));
data.setUserMusicDetailList(userMusicDetailService.getByUserId(aimeId));
data.setUserPlaylogList(userPlaylogService.getByUserId(aimeId));
} catch (NoSuchElementException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new MessageResponse("User not found"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new MessageResponse("Error during data export. Reason: " + e.getMessage()));
}
// Set filename
HttpHeaders headers = new HttpHeaders();
headers.set("content-disposition", "attachment; filename=chuni_" + aimeId + "_exported.json");
return new ResponseEntity<>(data, headers, HttpStatus.OK);
}
@PostMapping("import")
public ResponseEntity<Object> importAllUserData(@RequestBody ChuniDataImport data) {
if(!data.getGameId().equals("SDBT")) {
return ResponseEntity.unprocessableEntity().body(new MessageResponse("Wrong Game Profile, Expected 'SDBT', Get " + data.getGameId()));
}
ExternalUserData exUser = data.getUserData();
Optional<Card> cardOptional = cardService.getCardByAccessCode(exUser.getAccessCode());
Card card;
if (cardOptional.isPresent()) {
if (userDataService.getUserByCard(cardOptional.get()).isPresent()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new MessageResponse("This card already has a chunithm profile."));
} else {
card = cardOptional.get();
}
} else {
card = cardService.registerByAccessCode(exUser.getAccessCode());
}
UserData userData = mapper.convert(exUser, new TypeReference<>() {
});
userData.setCard(card);
userDataService.saveAndFlushUserData(userData);
List<UserActivity> userActivityList = data.getUserActivityList();
userActivityList.forEach(x -> x.setUser(userData));
userActivityService.saveAll(userActivityList);
List<UserCharacter> userCharacterList = data.getUserCharacterList();
userCharacterList.forEach(x -> x.setUser(userData));
userCharacterService.saveAll(userCharacterList);
List<UserCharge> userChargeList = data.getUserChargeList();
userCharacterList.forEach(x -> x.setUser(userData));
userChargeService.saveAll(userChargeList);
List<UserCourse> userCourseList = data.getUserCourseList();
userCourseList.forEach(x -> x.setUser(userData));
userCourseService.saveAll(userCourseList);
UserDataEx userDataEx = data.getUserDataEx();
userDataEx.setUser(userData);
userDataExService.save(userDataEx);
List<UserDuel> userDuelList = data.getUserDuelList();
userDuelList.forEach(x -> x.setUser(userData));
userDuelService.saveAll(userDuelList);
UserGameOption userGameOption = data.getUserGameOption();
userGameOption.setUser(userData);
userGameOptionService.save(userGameOption);
UserGameOptionEx userGameOptionEx = data.getUserGameOptionEx();
userGameOptionEx.setUser(userData);
userGameOptionExService.save(userGameOptionEx);
List<UserItem> userItemList = data.getUserItemList();
userItemList.forEach(x -> x.setUser(userData));
userItemService.saveAll(userItemList);
List<UserMap> userMapList = data.getUserMapList();
userMapList.forEach(x -> x.setUser(userData));
userMapService.saveAll(userMapList);
List<UserMusicDetail> userMusicDetailList = data.getUserMusicDetailList();
userMusicDetailList.forEach(x -> x.setUser(userData));
userMusicDetailService.saveAll(userMusicDetailList);
List<UserPlaylog> userPlaylogList = data.getUserPlaylogList();
userPlaylogList.forEach(x -> x.setUser(userData));
userPlaylogService.saveAll(userPlaylogList);
return ResponseEntity.ok(new MessageResponse("Import successfully, aimeId: " + card.getExtId()));
}
private int getLevelBase(int level, int levelDecimal) {
return level * 100 + levelDecimal;
}
private int calculateRating(int levelBase, int score, VersionInfo version) {
if (score >= 1007500) return levelBase + 200;
if (score >= 1005000) return levelBase + 150 + (score - 1005000) * 10 / 500;
if (score >= 1000000) return levelBase + 100 + (score - 1000000) * 5 / 500;
if (score >= 975000) return levelBase + (score - 975000) * 2 / 500;
if (score >= 950000 && version.getMinorVersion() < 35) return levelBase - 150 + (score - 950000) * 3 / 500;
if (score >= 925000) return levelBase - 300 + (score - 925000) * 3 / 500;
if (score >= 900000) return levelBase - 500 + (score - 900000) * 4 / 500;
if (score >= 800000)
return ((levelBase - 500) / 2 + (score - 800000) * ((levelBase - 500) / 2) / (100000));
return 0;
}
}

View File

@@ -1,316 +0,0 @@
package icu.samnyan.aqua.api.controller.sega.game.chuni.v2;
import com.fasterxml.jackson.core.type.TypeReference;
import icu.samnyan.aqua.api.model.MessageResponse;
import icu.samnyan.aqua.api.model.ReducedPageResponse;
import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.RecentResp;
import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.external.Chu3DataExport;
import icu.samnyan.aqua.api.util.ApiMapper;
import icu.samnyan.aqua.sega.chusan.model.userdata.*;
import icu.samnyan.aqua.sega.chusan.service.*;
import icu.samnyan.aqua.sega.general.service.CardService;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* For all aimeId parameter, should use String
* @author samnyan (privateamusement@protonmail.com)
*/
@RestController
@RequestMapping("api/game/chuni/v2")
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
@AllArgsConstructor
public class ApiChuniV2PlayerDataController {
private static final Logger logger = LoggerFactory.getLogger(ApiChuniV2PlayerDataController.class);
private final ApiMapper mapper;
private final CardService cardService;
private final UserActivityService userActivityService;
private final UserCharacterService userCharacterService;
private final UserChargeService userChargeService;
private final UserCourseService userCourseService;
private final UserDataService userDataService;
private final UserDuelService userDuelService;
private final UserGameOptionService userGameOptionService;
private final UserItemService userItemService;
private final UserMapAreaService userMapAreaService;
private final UserMusicDetailService userMusicDetailService;
private final UserPlaylogService userPlaylogService;
private final UserGeneralDataService userGeneralDataService;
@PutMapping("profile/username")
public Chu3UserData updateName(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setUserName((String) request.get("userName"));
return userDataService.saveUserData(profile);
}
@PutMapping("profile/romversion")
public Chu3UserData updateRomVersion(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setLastRomVersion((String) request.get("romVersion"));
return userDataService.saveUserData(profile);
}
@PutMapping("profile/dataversion")
public Chu3UserData updateDataVersion(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setLastDataVersion((String) request.get("dataVersion"));
return userDataService.saveUserData(profile);
}
@PutMapping("profile/plate")
public Chu3UserData updatePlate(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setNameplateId((Integer) request.get("nameplateId"));
return userDataService.saveUserData(profile);
}
@PutMapping("profile/frame")
public Chu3UserData updateFrame(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setFrameId((Integer) request.get("frameId"));
return userDataService.saveUserData(profile);
}
@PutMapping("profile/trophy")
public Chu3UserData updateTrophy(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setTrophyId((Integer) request.get("trophyId"));
return userDataService.saveUserData(profile);
}
@PutMapping("profile/mapicon")
public Chu3UserData updateMapIcon(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setMapIconId((Integer) request.get("mapiconId"));
return userDataService.saveUserData(profile);
}
@PutMapping("profile/sysvoice")
public Chu3UserData updateSystemVoice(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setVoiceId((Integer) request.get("voiceId"));
return userDataService.saveUserData(profile);
}
@PutMapping("profile/avatar")
public Chu3UserData updateAvatar(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
int category = (Integer) request.get("category");
switch (category) {
case 1:
profile.setAvatarWear((Integer) request.get("accId"));
break;
case 2:
profile.setAvatarHead((Integer) request.get("accId"));
break;
case 3:
profile.setAvatarFace((Integer) request.get("accId"));
break;
case 4:
profile.setAvatarSkin((Integer) request.get("accId"));
break;
case 5:
profile.setAvatarItem((Integer) request.get("accId"));
break;
case 6:
profile.setAvatarFront((Integer) request.get("accId"));
break;
case 7:
profile.setAvatarBack((Integer) request.get("accId"));
break;
}
return userDataService.saveUserData(profile);
}
@PutMapping("profile/privacy")
public ResponseEntity<Object> updatePrivacy(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
UserGameOption option = userGameOptionService.getByUser(profile).orElseThrow();
int privacy = (Integer) request.get("privacy");
if (privacy != 1 && privacy != 0) {
return ResponseEntity.badRequest().body(new MessageResponse("Wrong data"));
}
option.setPrivacy(privacy);
return ResponseEntity.ok(userDataService.saveUserData(profile));
}
@GetMapping("recent")
public ReducedPageResponse<RecentResp> getRecentPlay(@RequestParam String aimeId,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<UserPlaylog> playLogs = userPlaylogService.getRecentPlays(aimeId, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "userPlayDate")));
return new ReducedPageResponse<>(mapper.convert(playLogs.getContent(), new TypeReference<>() {
}), playLogs.getPageable().getPageNumber(), playLogs.getTotalPages(), playLogs.getTotalElements());
}
@GetMapping("song/{id}")
public List<UserMusicDetail> getSongDetail(@RequestParam String aimeId, @PathVariable int id) {
return userMusicDetailService.getByUserIdAndMusicId(aimeId, id);
}
@GetMapping("song/{id}/{level}")
public List<UserPlaylog> getLevelPlaylog(@RequestParam String aimeId, @PathVariable int id, @PathVariable int level) {
return userPlaylogService.getByUserIdAndMusicIdAndLevel(aimeId, id, level);
}
@GetMapping("song/{id}/isfavorite")
public boolean getSongFavorite(@RequestParam String aimeId, @PathVariable String id) {
Optional<UserGeneralData> favOptional;
favOptional = userGeneralDataService.getByUserIdAndKey(aimeId, "favorite_music");
if(favOptional.isPresent()) {
String val = favOptional.get().getPropertyValue();
if(StringUtils.isNotBlank(val) && val.contains(",")) {
String[] records = val.split(",");
for (String record : records) {
if (record.equals(id)) return true;
}
}
}
return false;
}
@PutMapping("song/{id}/favorite")
public void updateSongFavorite(@RequestParam String aimeId, @PathVariable String id) {
Chu3UserData profile = userDataService.getUserByExtId(aimeId).orElseThrow();
UserGeneralData userGeneralData = userGeneralDataService.getByUserAndKey(profile, "favorite_music")
.orElseGet(() -> new UserGeneralData(profile, "favorite_music"));
List<String> favoriteSongs = new LinkedList<String>(Arrays.asList(userGeneralData.getPropertyValue().split(",")));
if(!favoriteSongs.remove(id))
{
favoriteSongs.add(id);
}
StringBuilder sb = new StringBuilder();
favoriteSongs.forEach(favSong -> {
if(!favSong.isEmpty()) sb.append(favSong).append(",");
});
userGeneralData.setPropertyValue(sb.toString());
userGeneralDataService.save(userGeneralData);
}
@GetMapping("character")
public ReducedPageResponse<UserCharacter> getCharacter(@RequestParam String aimeId,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<UserCharacter> characters = userCharacterService.getByUserId(aimeId, page, size);
return new ReducedPageResponse<>(characters.getContent(), characters.getPageable().getPageNumber(), characters.getTotalPages(), characters.getTotalElements());
}
@PostMapping("character")
public ResponseEntity<Object> updateCharacter(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
Integer characterId = (Integer) request.get("characterId");
Optional<UserCharacter> characterOptional = userCharacterService.getByUserAndCharacterId(profile, characterId);
UserCharacter character;
if(characterOptional.isPresent()) {
character = characterOptional.get();
} else {
character = new UserCharacter(profile);
character.setCharacterId(characterId);
}
if(request.containsKey("level")) {
character.setLevel((Integer) request.get("level"));
}
return ResponseEntity.ok(userCharacterService.save(character));
}
@GetMapping("course")
public List<UserCourse> getCourse(@RequestParam String aimeId) {
return userCourseService.getByUserId(aimeId);
}
@GetMapping("duel")
public List<UserDuel> getDuel(@RequestParam String aimeId) {
return userDuelService.getByUserId(aimeId);
}
@GetMapping("item")
public ReducedPageResponse<UserItem> getItem(@RequestParam String aimeId,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<UserItem> items = userItemService.getByUserId(aimeId, page, size);
return new ReducedPageResponse<>(items.getContent(), items.getPageable().getPageNumber(), items.getTotalPages(), items.getTotalElements());
}
@GetMapping("item/{itemKind}")
public List<UserItem> getItemByKind(@RequestParam String aimeId, @PathVariable int itemKind) {
return userItemService.getByUserAndItemKind(aimeId, itemKind);
}
@PostMapping("item")
public ResponseEntity<Object> updateItem(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
Integer itemId = (Integer) request.get("itemId");
Integer itemKind = (Integer) request.get("itemKind");
Optional<UserItem> itemOptional = userItemService.getByUserAndItemIdAndKind(profile, itemId,itemKind);
UserItem item;
if(itemOptional.isPresent()) {
item = itemOptional.get();
} else {
item = new UserItem(profile);
item.setItemId(itemId);
item.setItemKind(itemKind);
}
if(request.containsKey("stock")) {
item.setStock((Integer) request.get("stock"));
}
return ResponseEntity.ok(userItemService.save(item));
}
@GetMapping("general")
public ResponseEntity<Object> getGeneralData(@RequestParam String aimeId, @RequestParam String key) {
Optional<UserGeneralData> userGeneralDataOptional = userGeneralDataService.getByUserIdAndKey(aimeId,key);
return userGeneralDataOptional.<ResponseEntity<Object>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new MessageResponse("User or value not found.")));
}
@GetMapping("export")
public ResponseEntity<Object> exportAllUserData(@RequestParam String aimeId) {
Chu3DataExport data = new Chu3DataExport();
try {
data.setGameId("SDHD");
data.setUserData(userDataService.getUserByExtId(aimeId).orElseThrow());
data.setUserActivityList(userActivityService.getByUserId(aimeId));
data.setUserCharacterList(userCharacterService.getByUserId(aimeId));
data.setUserChargeList(userChargeService.getByUserId(aimeId));
data.setUserCourseList(userCourseService.getByUserId(aimeId));
data.setUserDuelList(userDuelService.getByUserId(aimeId));
data.setUserGameOption(userGameOptionService.getByUserId(aimeId).orElseThrow());
data.setUserItemList(userItemService.getByUserId(aimeId));
data.setUserMapList(userMapAreaService.getByUserId(aimeId));
data.setUserMusicDetailList(userMusicDetailService.getByUserId(aimeId));
data.setUserPlaylogList(userPlaylogService.getByUserId(aimeId));
} catch (NoSuchElementException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new MessageResponse("User not found"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new MessageResponse("Error during data export. Reason: " + e.getMessage()));
}
// Set filename
HttpHeaders headers = new HttpHeaders();
headers.set("content-disposition", "attachment; filename=chusan_" + aimeId + "_exported.json");
return new ResponseEntity<>(data, headers, HttpStatus.OK);
}
}

View File

@@ -1,44 +0,0 @@
package icu.samnyan.aqua.api.controller.sega.game.diva;
import icu.samnyan.aqua.sega.diva.dao.gamedata.DivaCustomizeRepository;
import icu.samnyan.aqua.sega.diva.dao.gamedata.DivaModuleRepository;
import icu.samnyan.aqua.sega.diva.dao.gamedata.DivaPvRepository;
import icu.samnyan.aqua.sega.diva.model.gamedata.DivaCustomize;
import icu.samnyan.aqua.sega.diva.model.gamedata.DivaModule;
import icu.samnyan.aqua.sega.diva.model.gamedata.Pv;
import lombok.AllArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@RestController
@RequestMapping("api/game/diva/data")
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
@AllArgsConstructor
public class ApiDivaGameDataController {
private final DivaModuleRepository divaModuleRepository;
private final DivaCustomizeRepository divaCustomizeRepository;
private final DivaPvRepository divaPvRepository;
@GetMapping(value = "musicList")
public List<Pv> musicList() {
return divaPvRepository.findAll();
}
@GetMapping(value = "moduleList")
public List<DivaModule> moduleList() {
return divaModuleRepository.findAll();
}
@GetMapping(value = "customizeList")
public List<DivaCustomize> customizeList() {
return divaCustomizeRepository.findAll();
}
}

View File

@@ -1,276 +0,0 @@
package icu.samnyan.aqua.api.controller.sega.game.diva;
import icu.samnyan.aqua.api.model.MessageResponse;
import icu.samnyan.aqua.api.model.ReducedPageResponse;
import icu.samnyan.aqua.api.model.resp.sega.diva.PvRankRecord;
import icu.samnyan.aqua.sega.diva.dao.userdata.*;
import icu.samnyan.aqua.sega.diva.model.common.Difficulty;
import icu.samnyan.aqua.sega.diva.model.common.Edition;
import icu.samnyan.aqua.sega.diva.model.userdata.*;
import icu.samnyan.aqua.sega.diva.service.PlayerProfileService;
import lombok.AllArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@RestController
@RequestMapping("api/game/diva")
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
@AllArgsConstructor
public class ApiDivaPlayerDataController {
private final PlayerProfileService playerProfileService;
private final GameSessionRepository gameSessionRepository;
private final PlayLogRepository playLogRepository;
private final PlayerPvRecordRepository playerPvRecordRepository;
private final PlayerPvCustomizeRepository playerPvCustomizeRepository;
private final PlayerModuleRepository playerModuleRepository;
private final PlayerCustomizeRepository playerCustomizeRepository;
private final PlayerScreenShotRepository playerScreenShotRepository;
@PostMapping("forceUnlock")
public ResponseEntity<MessageResponse> forceUnlock(@RequestParam long pdId) {
PlayerProfile profile = playerProfileService.findByPdId(pdId).orElseThrow();
Optional<GameSession> session = gameSessionRepository.findByPdId(profile);
if(session.isPresent()) {
gameSessionRepository.delete(session.get());
return ResponseEntity.ok(new MessageResponse("Session deleted."));
} else {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new MessageResponse("Session doesn't exist."));
}
}
@GetMapping("playerInfo")
public Optional<PlayerProfile> getPlayerInfo(@RequestParam long pdId) {
return playerProfileService.findByPdId(pdId);
}
@GetMapping("playerInfo/rival")
public Map<String, String> getRivalInfo(@RequestParam long pdId) {
var rId = playerProfileService.findByPdId(pdId).orElseThrow().getRivalPdId();
Map<String, String> result = new HashMap<>();
if (rId == -1) {
result.put("rival", "Not Set");
} else {
Optional<PlayerProfile> profile = playerProfileService.findByPdId(rId);
if (profile.isPresent()) {
result.put("rival", profile.get().getPlayerName());
} else {
result.put("rival", "Player Not Found");
}
}
return result;
}
@PutMapping("playerInfo/rival")
public PlayerProfile updateRivalWithId(@RequestBody Map<String, Object> request) {
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
profile.setRivalPdId((Integer) request.get("rivalId"));
return playerProfileService.save(profile);
}
@PutMapping("playerInfo/rival/byRecord")
public PlayerProfile updateRivalWithRecord(@RequestBody Map<String, Object> request) {
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
PlayerPvRecord record = playerPvRecordRepository.findById(((Integer) request.get("recordId")).longValue()).orElseThrow();
profile.setRivalPdId(record.getPdId().getPdId());
return playerProfileService.save(profile);
}
@PutMapping("playerInfo/playerName")
public PlayerProfile updateName(@RequestBody Map<String, Object> request) {
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
profile.setPlayerName((String) request.get("playerName"));
return playerProfileService.save(profile);
}
@PutMapping("playerInfo/title")
public PlayerProfile updateTitle(@RequestBody Map<String, Object> request) {
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
profile.setLevelTitle((String) request.get("title"));
return playerProfileService.save(profile);
}
@PutMapping("playerInfo/plate")
public PlayerProfile updatePlate(@RequestBody Map<String, Object> request) {
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
profile.setPlateId((Integer) request.get("plateId"));
profile.setPlateEffectId((Integer) request.get("plateEffectId"));
return playerProfileService.save(profile);
}
@PutMapping("playerInfo/commonModule")
public PlayerProfile updateModule(@RequestBody Map<String, Object> request) {
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
profile.setCommonModule((String) request.get("commonModule"));
return playerProfileService.save(profile);
}
@PutMapping("playerInfo/commonCustomize")
public PlayerProfile updateCustomize(@RequestBody Map<String, Object> request) {
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
profile.setCommonCustomizeItems((String) request.get("commonCustomize"));
return playerProfileService.save(profile);
}
@PutMapping("playerInfo/commonSkin")
public PlayerProfile updateSkin(@RequestBody Map<String, Object> request) {
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
profile.setCommonSkin((Integer) request.get("skinId"));
return playerProfileService.save(profile);
}
@PutMapping("playerInfo/myList")
public PlayerProfile updateMyList(@RequestBody Map<String, Object> request) {
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
switch ((Integer) request.get("myListId")) {
case 0:
profile.setMyList0((String) request.get("myListData"));
break;
case 1:
profile.setMyList1((String) request.get("myListData"));
break;
case 2:
profile.setMyList2((String) request.get("myListData"));
break;
}
return playerProfileService.save(profile);
}
@PutMapping("playerInfo/se")
public PlayerProfile updateSe(@RequestBody Map<String, Object> request) {
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
profile.setButtonSe((Integer) request.get("buttonSe"));
profile.setChainSlideSe((Integer) request.get("chainSlideSe"));
profile.setSlideSe((Integer) request.get("slideSe"));
profile.setSliderTouchSe((Integer) request.get("sliderTouchSe"));
return playerProfileService.save(profile);
}
@PutMapping("playerInfo/display")
public PlayerProfile updateDisplay(@RequestBody Map<String, Object> request) {
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
profile.setShowInterimRanking((Boolean) request.get("showInterimRanking"));
profile.setShowClearStatus((Boolean) request.get("showClearStatus"));
profile.setShowGreatBorder((Boolean) request.get("showGreatBorder"));
profile.setShowExcellentBorder((Boolean) request.get("showExcellentBorder"));
profile.setShowRivalBorder((Boolean) request.get("showRivalBorder"));
profile.setShowRgoSetting((Boolean) request.get("showRgoSetting"));
return playerProfileService.save(profile);
}
@GetMapping("playLog")
public ReducedPageResponse<PlayLog> getPlayLogs(@RequestParam long pdId,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<PlayLog> playLogs = playLogRepository.findByPdId_PdIdOrderByDateTimeDesc(pdId, PageRequest.of(page, size));
return new ReducedPageResponse<>(playLogs.getContent(), playLogs.getPageable().getPageNumber(), playLogs.getTotalPages(), playLogs.getTotalElements());
}
/**
* PvRecord
*/
@GetMapping("pvRecord")
public ReducedPageResponse<PlayerPvRecord> getPvRecords(@RequestParam long pdId,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<PlayerPvRecord> pvRecords = playerPvRecordRepository.findByPdId_PdIdOrderByPvId(pdId, PageRequest.of(page, size));
return new ReducedPageResponse<>(pvRecords.getContent(), pvRecords.getPageable().getPageNumber(), pvRecords.getTotalPages(), pvRecords.getTotalElements());
}
@GetMapping("pvRecord/{pvId}")
public Map<String, Object> getPvRecord(@RequestParam long pdId, @PathVariable int pvId) {
Map<String, Object> resultMap = new HashMap<>();
resultMap.put("records", playerPvRecordRepository.findByPdId_PdIdAndPvId(pdId, pvId));
playerPvCustomizeRepository.findByPdId_PdIdAndPvId(pdId, pvId).ifPresent(x -> resultMap.put("customize", x));
return resultMap;
}
@PutMapping("pvRecord/{pvId}")
public PlayerPvCustomize updatePvCustomize(@RequestBody Map<String, Object> request, @PathVariable int pvId) {
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
PlayerPvCustomize playerPvCustomize = playerPvCustomizeRepository.findByPdIdAndPvId(profile, pvId)
.orElseGet(() -> new PlayerPvCustomize(profile, pvId));
playerPvCustomize.setModule((String) request.get("module"));
playerPvCustomize.setCustomize((String) request.get("customize"));
playerPvCustomize.setCustomizeFlag((String) request.get("customizeFlag"));
playerPvCustomize.setSkin((Integer) request.get("skin"));
playerPvCustomize.setButtonSe((Integer) request.get("buttonSe"));
playerPvCustomize.setSlideSe((Integer) request.get("slideSe"));
playerPvCustomize.setChainSlideSe((Integer) request.get("chainSlideSe"));
playerPvCustomize.setSliderTouchSe((Integer) request.get("sliderTouchSe"));
return playerPvCustomizeRepository.save(playerPvCustomize);
}
@GetMapping("pvRecord/{pvId}/ranking/{difficulty}")
public ReducedPageResponse<PvRankRecord> getPvRanking(@PathVariable int pvId,
@PathVariable String difficulty,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Difficulty diff = null;
Edition edition = Edition.ORIGINAL;
switch (difficulty) {
case "EASY":
diff = Difficulty.EASY;
break;
case "NORMAL":
diff = Difficulty.NORMAL;
break;
case "HARD":
diff = Difficulty.HARD;
break;
case "EXTREME":
diff = Difficulty.EXTREME;
break;
case "EXTRA_EXTREME": {
diff = Difficulty.EXTREME;
edition = Edition.EXTRA;
break;
}
}
if(diff != null) {
Page<PlayerPvRecord> pvRecords = playerPvRecordRepository.findByPvIdAndEditionAndDifficultyOrderByMaxScoreDesc(pvId, edition,diff, PageRequest.of(page, size));
List<PvRankRecord> rankList = new LinkedList<>();
pvRecords.forEach(x ->{
rankList.add(new PvRankRecord(x.getId(),x.getPdId().getPlayerName(),x.getMaxScore(),x.getMaxAttain()));
});
return new ReducedPageResponse<>(rankList, pvRecords.getPageable().getPageNumber(), pvRecords.getTotalPages(), pvRecords.getTotalElements());
}
return null;
}
@GetMapping("module")
public ReducedPageResponse<PlayerModule> getModules(@RequestParam long pdId,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<PlayerModule> modules = playerModuleRepository.findByPdId_PdId(pdId, PageRequest.of(page, size));
return new ReducedPageResponse<>(modules.getContent(), modules.getPageable().getPageNumber(), modules.getTotalPages(), modules.getTotalElements());
}
@GetMapping("customize")
public ReducedPageResponse<PlayerCustomize> getCustomizes(@RequestParam long pdId,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<PlayerCustomize> customizes = playerCustomizeRepository.findByPdId_PdId(pdId, PageRequest.of(page, size));
return new ReducedPageResponse<>(customizes.getContent(), customizes.getPageable().getPageNumber(), customizes.getTotalPages(), customizes.getTotalElements());
}
@GetMapping("screenshot")
public List<PlayerScreenShot> getScreenshotList(@RequestParam long pdId) {
return playerScreenShotRepository.findByPdId_PdId(pdId);
}
}

View File

@@ -1,396 +0,0 @@
package icu.samnyan.aqua.api.controller.sega.game.maimai2;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import icu.samnyan.aqua.api.model.MessageResponse;
import icu.samnyan.aqua.api.model.ReducedPageResponse;
import icu.samnyan.aqua.api.model.resp.sega.maimai2.PhotoResp;
import icu.samnyan.aqua.api.model.resp.sega.maimai2.ProfileResp;
import icu.samnyan.aqua.api.model.resp.sega.maimai2.external.ExternalUserData;
import icu.samnyan.aqua.api.model.resp.sega.maimai2.external.Maimai2DataExport;
import icu.samnyan.aqua.api.model.resp.sega.maimai2.external.Maimai2DataImport;
import icu.samnyan.aqua.api.util.ApiMapper;
import icu.samnyan.aqua.sega.general.model.Card;
import icu.samnyan.aqua.sega.general.service.CardService;
import icu.samnyan.aqua.sega.maimai2.model.*;
import icu.samnyan.aqua.sega.maimai2.model.userdata.*;
import lombok.AllArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@RestController
@RequestMapping("api/game/maimai2")
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
@AllArgsConstructor
public class ApiMaimai2PlayerDataController {
private final ApiMapper mapper;
private final CardService cardService;
private final Mai2UserActRepo userActRepository;
private final Mai2UserCharacterRepo userCharacterRepository;
private final Mai2UserDataRepo userDataRepository;
private final Mai2UserItemRepo userItemRepository;
private final Mai2UserLoginBonusRepo userLoginBonusRepository;
private final Mai2UserMusicDetailRepo userMusicDetailRepository;
private final Mai2UserOptionRepo userOptionRepository;
private final Mai2UserPlaylogRepo userPlaylogRepository;
private final Mai2UserGeneralDataRepo userGeneralDataRepository;
private final Mai2MapEncountNpcRepo mapEncountNpcRepository;
private final Mai2UserChargeRepo userChargeRepository;
private final Mai2UserCourseRepo userCourseRepository;
private final Mai2UserExtendRepo userExtendRepository;
private final Mai2UserFavoriteRepo userFavoriteRepository;
private final Mai2UserFriendSeasonRankingRepo userFriendSeasonRankingRepository;
private final Mai2UserMapRepo userMapRepository;
private final Mai2UserUdemaeRepo userUdemaeRepository;
@GetMapping("config/userPhoto/divMaxLength")
public long getConfigUserPhotoDivMaxLength(@Value("${game.maimai2.userPhoto.divMaxLength:32}") long divMaxLength) {
return divMaxLength;
}
@GetMapping("userPhoto")
public PhotoResp getUserPhoto(@RequestParam long aimeId,
@RequestParam(required = false, defaultValue = "0") int imageIndex) {
List<String> matchedFiles = new ArrayList<>();
PhotoResp Photo = new PhotoResp();
try (Stream<Path> paths = Files.walk(Paths.get("data"))) {
matchedFiles = paths
.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().endsWith(".jpg"))
.filter(path -> {
String fileName = path.getFileName().toString();
String[] parts = fileName.split("-");
return parts.length > 0 && parts[0].equals(String.valueOf(aimeId));
})
.map(Path::getFileName)
.map(Path::toString)
.sorted(Comparator.reverseOrder())
.toList();
Photo.setTotalImage(matchedFiles.size());
Photo.setImageIndex(imageIndex);
if(matchedFiles.size() > imageIndex) {
byte[] targetImageContent = Files.readAllBytes(Paths.get("data/" + matchedFiles.get(imageIndex)));
String divData = Base64.getEncoder().encodeToString(targetImageContent);
Photo.setDivData(divData);
Photo.setFileName(matchedFiles.get(imageIndex));
}
}
catch (Exception e) {
}
return Photo;
}
@GetMapping("profile")
public ProfileResp getProfile(@RequestParam long aimeId) {
return mapper.convert(userDataRepository.findByCardExtId(aimeId).orElseThrow(), new TypeReference<>() {
});
}
@PostMapping("profile/username")
public Mai2UserDetail updateName(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
profile.setUserName((String) request.get("userName"));
return userDataRepository.save(profile);
}
@PostMapping("profile/icon")
public Mai2UserDetail updateIcon(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
profile.setIconId((Integer) request.get("iconId"));
return userDataRepository.save(profile);
}
@PostMapping("profile/plate")
public Mai2UserDetail updatePlate(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
profile.setPlateId((Integer) request.get("plateId"));
return userDataRepository.save(profile);
}
@PostMapping("profile/frame")
public Mai2UserDetail updateFrame(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
profile.setFrameId((Integer) request.get("frameId"));
return userDataRepository.save(profile);
}
@PostMapping("profile/title")
public Mai2UserDetail updateTrophy(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
profile.setTitleId((Integer) request.get("titleId"));
return userDataRepository.save(profile);
}
@PostMapping("profile/partner")
public Mai2UserDetail updatePartner(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
profile.setPartnerId((Integer) request.get("partnerId"));
return userDataRepository.save(profile);
}
@GetMapping("character")
public ReducedPageResponse<Mai2UserCharacter> getCharacter(@RequestParam long aimeId,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<Mai2UserCharacter> characters = userCharacterRepository.findByUser_Card_ExtId(aimeId, PageRequest.of(page, size));
return new ReducedPageResponse<>(characters.getContent(), characters.getPageable().getPageNumber(), characters.getTotalPages(), characters.getTotalElements());
}
@GetMapping("activity")
public List<Mai2UserAct> getActivities(@RequestParam long aimeId) {
return userActRepository.findByUser_Card_ExtId(aimeId);
}
@GetMapping("item")
public ReducedPageResponse<Mai2UserItem> getItem(@RequestParam long aimeId,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size,
@RequestParam(required = false, defaultValue = "0") int ItemKind) {
Page<Mai2UserItem> items;
if(ItemKind == 0){
items = userItemRepository.findByUser_Card_ExtId(aimeId, PageRequest.of(page, size));
}
else{
items = userItemRepository.findByUser_Card_ExtIdAndItemKind(aimeId, ItemKind, PageRequest.of(page, size));
}
return new ReducedPageResponse<>(items.getContent(), items.getPageable().getPageNumber(), items.getTotalPages(), items.getTotalElements());
}
@PostMapping("item")
public ResponseEntity<Object> updateItem(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
Integer itemKind = (Integer) request.get("itemKind");
Integer itemId = (Integer) request.get("itemId");
int stock = 1;
if (request.containsKey("stock")) {
stock = (Integer) request.get("stock");
}
Optional<Mai2UserItem> userItemOptional = userItemRepository.findByUserAndItemKindAndItemId(profile, itemKind, itemId);
Mai2UserItem userItem;
if (userItemOptional.isPresent()) {
userItem = userItemOptional.get();
} else {
userItem = new Mai2UserItem();
userItem.setUser(profile);
userItem.setItemId(itemId);
userItem.setItemKind(itemKind);
}
userItem.setStock(stock);
userItem.setValid(true);
return ResponseEntity.ok(userItemRepository.save(userItem));
}
@GetMapping("recent")
public ReducedPageResponse<Mai2UserPlaylog> getRecent(@RequestParam long aimeId,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<Mai2UserPlaylog> playlogs = userPlaylogRepository.findByUser_Card_ExtId(aimeId, PageRequest.of(page, size, Sort.Direction.DESC, "id"));
return new ReducedPageResponse<>(playlogs.getContent(), playlogs.getPageable().getPageNumber(), playlogs.getTotalPages(), playlogs.getTotalElements());
}
@GetMapping("song/{id}")
public List<Mai2UserMusicDetail> getSongDetail(@RequestParam long aimeId, @PathVariable int id) {
return userMusicDetailRepository.findByUser_Card_ExtIdAndMusicId(aimeId, id);
}
@GetMapping("song/{id}/{level}")
public List<Mai2UserPlaylog> getLevelPlaylog(@RequestParam long aimeId, @PathVariable int id, @PathVariable int level) {
return userPlaylogRepository.findByUser_Card_ExtIdAndMusicIdAndLevel(aimeId, id, level);
}
@GetMapping("options")
public Mai2UserOption getOptions(@RequestParam long aimeId) {
return userOptionRepository.findSingleByUser_Card_ExtId(aimeId).orElseThrow();
}
@PostMapping("options")
public ResponseEntity<Object> updateOptions(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
ObjectMapper objectMapper = new ObjectMapper();
Mai2UserOption userOption = objectMapper.convertValue(request.get("options"), Mai2UserOption.class);
userOption.setUser(profile);
userOptionRepository.deleteByUser(profile);
userOptionRepository.flush();
return ResponseEntity.ok(userOptionRepository.save(userOption));
}
@GetMapping("general")
public ResponseEntity<Object> getGeneralData(@RequestParam long aimeId, @RequestParam String key) {
Optional<Mai2UserGeneralData> userGeneralDataOptional = userGeneralDataRepository.findByUser_Card_ExtIdAndPropertyKey(aimeId, key);
return userGeneralDataOptional.<ResponseEntity<Object>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new MessageResponse("User or value not found.")));
}
@PostMapping("general")
public ResponseEntity<Object> setGeneralData(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
String key = (String) request.get("key");
String value = (String) request.get("value");
Optional<Mai2UserGeneralData> userGeneralDataOptional = userGeneralDataRepository.findByUserAndPropertyKey(profile, key);
Mai2UserGeneralData userGeneralData;
if (userGeneralDataOptional.isPresent()) {
userGeneralData = userGeneralDataOptional.get();
}
else {
userGeneralData = new Mai2UserGeneralData();
userGeneralData.setUser(profile);
userGeneralData.setPropertyKey(key);
}
userGeneralData.setPropertyValue(value);
return ResponseEntity.ok(userGeneralDataRepository.save(userGeneralData));
}
@GetMapping("export")
public ResponseEntity<Object> exportAllUserData(@RequestParam long aimeId) {
Maimai2DataExport data = new Maimai2DataExport();
try {
data.setGameId("SDEZ");
data.setUserData(userDataRepository.findByCardExtId(aimeId).orElseThrow());
data.setUserExtend(userExtendRepository.findSingleByUser_Card_ExtId(aimeId).orElseThrow());
data.setUserOption(userOptionRepository.findSingleByUser_Card_ExtId(aimeId).orElseThrow());
data.setUserUdemae(userUdemaeRepository.findSingleByUser_Card_ExtId(aimeId).orElseThrow());
data.setUserCharacterList(userCharacterRepository.findByUser_Card_ExtId(aimeId));
data.setUserGeneralDataList(userGeneralDataRepository.findByUser_Card_ExtId(aimeId));
data.setUserItemList(userItemRepository.findByUser_Card_ExtId(aimeId));
data.setUserLoginBonusList(userLoginBonusRepository.findByUser_Card_ExtId(aimeId));
data.setUserMusicDetailList(userMusicDetailRepository.findByUser_Card_ExtId(aimeId));
data.setUserPlaylogList(userPlaylogRepository.findByUserCardExtId(aimeId));
data.setMapEncountNpcList(mapEncountNpcRepository.findByUser_Card_ExtId(aimeId));
data.setUserActList(userActRepository.findByUser_Card_ExtId(aimeId));
data.setUserChargeList(userChargeRepository.findByUser_Card_ExtId(aimeId));
data.setUserCourseList(userCourseRepository.findByUser_Card_ExtId(aimeId));
data.setUserFavoriteList(userFavoriteRepository.findByUser_Card_ExtId(aimeId));
data.setUserFriendSeasonRankingList(userFriendSeasonRankingRepository.findByUser_Card_ExtId(aimeId));
data.setUserMapList(userMapRepository.findByUser_Card_ExtId(aimeId));
} catch (NoSuchElementException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new MessageResponse("User not found"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new MessageResponse("Error during data export. Reason: " + e.getMessage()));
}
// Set filename
HttpHeaders headers = new HttpHeaders();
headers.set("content-disposition", "attachment; filename=maimai2_" + aimeId + "_exported.json");
return new ResponseEntity<>(data, headers, HttpStatus.OK);
}
@PostMapping("import")
public ResponseEntity<Object> importAllUserData(@RequestBody Maimai2DataImport data) {
if (!data.getGameId().equals("SDEZ")) {
return ResponseEntity.unprocessableEntity().body(new MessageResponse("Wrong Game Profile, Expected 'SDEZ', Get " + data.getGameId()));
}
ExternalUserData exUser = data.getUserData();
Optional<Card> cardOptional = cardService.getCardByAccessCode(exUser.getAccessCode());
Card card;
if (cardOptional.isPresent()) {
card = cardOptional.get();
Optional<Mai2UserDetail> existUserData = Optional.ofNullable(userDataRepository.findByCard(cardOptional.get()));
if (existUserData.isPresent()) {
// return ResponseEntity.status(HttpStatus.BAD_REQUEST)
// .body(new MessageResponse("This card already has a maimai2 profile."));
// delete all same card data
userFavoriteRepository.deleteByUser(existUserData.get());
userFavoriteRepository.flush();
userFriendSeasonRankingRepository.deleteByUser(existUserData.get());
userFriendSeasonRankingRepository.flush();
userMapRepository.deleteByUser(existUserData.get());
userMapRepository.flush();
userUdemaeRepository.deleteByUser(existUserData.get());
userUdemaeRepository.flush();
userGeneralDataRepository.deleteByUser(existUserData.get());
userGeneralDataRepository.flush();
userItemRepository.deleteByUser(existUserData.get());
userItemRepository.flush();
userLoginBonusRepository.deleteByUser(existUserData.get());
userLoginBonusRepository.flush();
userMusicDetailRepository.deleteByUser(existUserData.get());
userMusicDetailRepository.flush();
userOptionRepository.deleteByUser(existUserData.get());
userOptionRepository.flush();
userPlaylogRepository.deleteByUser(existUserData.get());
userPlaylogRepository.flush();
userCharacterRepository.deleteByUser(existUserData.get());
userCharacterRepository.flush();
mapEncountNpcRepository.deleteByUser(existUserData.get());
mapEncountNpcRepository.flush();
userActRepository.deleteByUser(existUserData.get());
userActRepository.flush();
userChargeRepository.deleteByUser(existUserData.get());
userChargeRepository.flush();
userCourseRepository.deleteByUser(existUserData.get());
userCourseRepository.flush();
userExtendRepository.deleteByUser(existUserData.get());
userExtendRepository.flush();
userOptionRepository.deleteByUser(existUserData.get());
userOptionRepository.flush();
userDataRepository.deleteByCard(card);
userDataRepository.flush();
}
} else {
card = cardService.registerByAccessCode(exUser.getAccessCode());
}
Mai2UserDetail userData = mapper.convert(exUser, new TypeReference<>() {
});
userData.setCard(card);
userDataRepository.saveAndFlush(userData);
userFavoriteRepository.saveAll(data.getUserFavoriteList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
userFriendSeasonRankingRepository.saveAll(data.getUserFriendSeasonRankingList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
userMapRepository.saveAll(data.getUserMapList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
userGeneralDataRepository.saveAll(data.getUserGeneralDataList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
userItemRepository.saveAll(data.getUserItemList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
userLoginBonusRepository.saveAll(data.getUserLoginBonusList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
userMusicDetailRepository.saveAll(data.getUserMusicDetailList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
userPlaylogRepository.saveAll(data.getUserPlaylogList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
userCharacterRepository.saveAll(data.getUserCharacterList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
mapEncountNpcRepository.saveAll(data.getMapEncountNpcList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
userActRepository.saveAll(data.getUserActList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
userChargeRepository.saveAll(data.getUserChargeList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
userCourseRepository.saveAll(data.getUserCourseList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
Mai2UserExtend userExtend = data.getUserExtend();
userExtend.setUser(userData);
userExtendRepository.save(userExtend);
Mai2UserOption userOption = data.getUserOption();
userOption.setUser(userData);
userOptionRepository.save(userOption);
Mai2UserUdemae userUdemae = data.getUserUdemae();
userUdemae.setUser(userData);
userUdemaeRepository.save(userUdemae);
return ResponseEntity.ok(new MessageResponse("Import successfully, aimeId: " + card.getExtId()));
}
}

View File

@@ -1,76 +0,0 @@
package icu.samnyan.aqua.api.controller.sega.game.ongeki;
import icu.samnyan.aqua.sega.ongeki.dao.gamedata.*;
import icu.samnyan.aqua.sega.ongeki.model.gamedata.*;
import lombok.AllArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@RestController
@RequestMapping("api/game/ongeki/data")
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
@AllArgsConstructor
public class ApiOngekiGameDataController {
private final GameCardRepository gameCardRepository;
private final GameCharaRepository gameCharaRepository;
private final GameEventRepository gameEventRepository;
private final GameMusicRepository gameMusicRepository;
private final GameSkillRepository gameSkillRepository;
@GetMapping("cardList")
public List<GameCard> getCardList() {
return gameCardRepository.findAll();
}
@GetMapping("charaList")
public List<GameChara> getCharaList() {
return gameCharaRepository.findAll();
}
@GetMapping("eventList")
public List<GameEvent> getEventList() {
return gameEventRepository.findAll();
}
@GetMapping("musicList")
public List<GameMusic> getMusicList() {
return gameMusicRepository.findAll();
}
@GetMapping("skillList")
public List<GameSkill> getSkillList() {
return gameSkillRepository.findAll();
}
@PostMapping("cardList")
public List<GameCard> getCardList(@RequestBody List<GameCard> req) {
return gameCardRepository.saveAll(req);
}
@PostMapping("charaList")
public List<GameChara> getCharaList(@RequestBody List<GameChara> req) {
return gameCharaRepository.saveAll(req);
}
@PostMapping("eventList")
public List<GameEvent> getEventList(@RequestBody List<GameEvent> req) {
return gameEventRepository.saveAll(req);
}
@PostMapping("musicList")
public List<GameMusic> getMusicList(@RequestBody List<GameMusic> req) {
return gameMusicRepository.saveAll(req);
}
@PostMapping("skillList")
public List<GameSkill> getSkillList(@RequestBody List<GameSkill> req) {
return gameSkillRepository.saveAll(req);
}
}

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