106 Commits
Author SHA1 Message Date
asterisk727 4a0254ec19 chore: update readme for ongeki 2026-09-25 12:58:18 -05:00
crxmsxnandRaymond c3661d3c94 feat: ongeki 1.60 support (#240)
Co-authored-by: Raymond <101374892+raymonable@users.noreply.github.com>
2026-09-24 23:17:01 -04:00
topty bcbc770ca7 feat(mai2): stub MAGiCAL API responses (#246) 2026-09-22 16:54:23 -04:00
RandomEwok 55b47a3c02 fix: various bug fixes for wacca (#245) 2026-09-20 15:55:59 -04:00
Azalea 97743f55ef [+] Add pagination to maimai photo gallery (#244) 2026-09-16 09:35:07 +08:00
Azalea 11c5617abc [F] Fix 101% appearing as 100.9% (#243) 2026-09-11 01:55:59 +08:00
Raymond cf89e59af8 fix: don't set a timer on email confirmation tokens 2026-09-09 21:44:57 -04:00
TechnicalVessel 587557177e fix: assign ap+ lamp correctly for batch manual export 2026-09-09 21:06:01 -04:00
Azalea 6285bd3fd4 [-] Remove SDGA 1.65 nonce dedup 2026-08-12 05:43:59 +00:00
Raymond 7ba95f0b90 fix: do it correctly and not like a bum 2026-08-06 13:55:21 -04:00
Raymond 13aa48982f fix: shift ini down to lowercase for compat
i don't care anymore
2026-08-06 13:54:41 -04:00
Raymond 0a5c97d985 fix: shit 2026-08-05 20:28:32 -04:00
Raymond c1b6c3e414 fix: add trim 2026-08-05 19:03:52 -04:00
Azalea 5e6aaace8c [F] Fix SDGA 1.65 / SDGB 1.53 playlog, add nonce to UpsertUserAll (#238) 2026-08-06 04:44:41 +08:00
Azalea 3be81520ce [F] Fix import/export disparity, fix jackson3 export isSomething serialization bug, add tests (#237) 2026-08-06 03:21:20 +08:00
Azalea 30139cae8d [F] Fix export import missing fields and database errors (#236) 2026-08-04 12:29:03 +08:00
Azalea 96570a97ff [O] Ranking optimizations (#235) 2026-08-04 11:45:47 +08:00
Raymond 7eab79e48d fix: return fc+ on batch manual correctly 2026-08-03 16:08:38 -04:00
Azalea 05e613d667 [+] Delete account (#234) 2026-08-02 22:16:42 +08:00
Azalea d34aab37b2 [U] Java 21 -> Java 25 (#232) 2026-08-02 04:57:41 +00:00
Raymond 4aba8d8642 chore: 🐈‍⬛ 2026-07-28 23:24:15 -04:00
Raymond 0d70471bd6 revert: generate keychip on /keychip api
fixes regression causing 3,000+ users to not be assigned a keychip
2026-07-08 19:42:49 -04:00
Raymond 7ea5add407 fix: import/export fixes for chu3 & mai2 2026-07-08 12:00:02 -04:00
Raymond 41edddcaec fix: username insensitivity 2026-07-07 23:25:41 -04:00
Teio 1d36998967 [+] Mate (#231) 2026-07-07 01:58:52 -04:00
Raymond 3178c4c957 chore: revamp CONTRIBUTING.md 2026-07-04 19:59:18 -04:00
Raymond 48eb8a9c07 chore: update game-specific notes
Updated notes for Chunithm, Maimai DX, O.N.G.E.K.I, and Card Maker, including required patches and additional notes.
2026-07-04 19:36:38 -04:00
Raymond fcee9fb278 feat: chusan bs 2026-06-28 19:43:50 -04:00
Raymond 853abc8867 feat: use mai/chuni/ongeki-encoding when available (#230) 2026-06-20 15:57:20 -04:00
Raymond c099a8954e chore: 🐈‍⬛ 2026-06-18 14:06:39 -04:00
Raymond 96a894aa70 fix: add tls config prop (#228) 2026-06-16 19:54:56 -04:00
Raymond 0b3d52d31d fix: 60fps label 2026-06-15 22:10:07 -04:00
Azalea 27685087be Reapply "[F] Fix aimedb memory leak"
This reverts commit fec3480963.
2026-06-16 01:08:58 +00:00
Azalea fec3480963 Revert "[F] Fix aimedb memory leak"
This reverts commit 536734746b.
2026-06-16 00:38:25 +00:00
Azalea 536734746b [F] Fix aimedb memory leak 2026-06-15 21:15:25 +00:00
Raymond b352576358 fix: sort by correct rating value 2026-06-13 23:44:04 -04:00
Raymond f2ea015ef3 fix: don't not show non-aquanet users 2026-06-13 23:37:28 -04:00
Raymond 253c7a73d6 fix: don't show rank section at all if not available 2026-06-13 23:22:09 -04:00
Raymond e62115e6ac fix: improve ongeki ux, improve ranking backend (#227) 2026-06-14 11:12:37 +08:00
Raymond 08bf69c723 fix: display S+ and SS+ for chu3 on AquaNet 2026-06-13 21:20:31 -04:00
Raymond 3e2c198204 feat: reject invalid icf / v0.00 2026-06-11 22:54:44 -04:00
Raymond c3f75e6428 chore; UPDATE THE README 2026-06-08 22:46:56 -04:00
Raymond 4a6003ded3 chore: update chinese tls (ML) 2026-06-08 12:30:24 -04:00
Raymond b114adf940 fix: mistake in import 2026-06-08 12:15:40 -04:00
Raymond 7c31dfd4ab feat: add candidate display option to global game options 2026-06-08 12:11:30 -04:00
Raymond faabd45b7a fix: use correct class for exports 2026-06-08 12:07:35 -04:00
Raymond d7254913cd fix: [ongeki] use decimal instead of comma 2026-06-08 11:39:22 -04:00
RaymondandAzalea 237ba1464a feat: display ongeki refresh data on web ui
Co-authored-by: Azalea <noreply@aza.moe>
2026-06-07 21:05:09 -04:00
Raymond d5d50d687c feat: add general data list to chu3 2026-06-07 19:23:17 -04:00
crxmsxn 2c28758ae0 fix: allow more rfc5322 special characters in email (#225) 2026-06-07 00:29:05 -04:00
Raymond f025465f40 fix: mistake 2026-06-07 00:21:34 -04:00
Raymond 4b5eeadc2b feat: hide country toggle 2026-06-07 00:18:20 -04:00
Raymond 898939485c fix: enforce fullwidth 2026-06-07 00:00:11 -04:00
Raymond 593783866a fix: [ongeki] update constraints to cascade 2026-06-06 23:40:39 -04:00
Raymond 9b3871706f fix: [ongeki] more fixes, should work now 2026-06-06 23:30:19 -04:00
Raymond 5be6d8c80b fix: [ongeki] fix field matching in importer 2026-06-06 23:07:27 -04:00
Raymond f750cc6596 fix: [ongeki] mistake in importer 2026-06-06 22:54:06 -04:00
Raymond f67bbb822d fix: settings tab shouldn't show unplayed games 2026-06-06 22:49:26 -04:00
Raymond 819c058517 feat: [ongeki] username setting from webui 2026-06-06 22:43:41 -04:00
Raymond b2feb9a130 feat: [ongeki] data export/import 2026-06-06 22:19:12 -04:00
crxmsxn 6a10570fa6 feat: add chusan force unlock 2967 2999 ultima option (#224) 2026-05-31 14:23:49 -04:00
Yonle 6243ee7205 chore: [AquaNet] update README, move userbox config to .env, and add check before build (#223)
Signed-off-by: Yonle <yonle@proton.me>
2026-05-31 12:15:10 -04:00
Raymond 72c1f48bdb fix: rounding issue breaking encryption 2026-05-31 00:11:51 -04:00
raymonable 06eec89dfe fix: make minor rom versions always use the major rom key 2026-05-28 14:51:41 -04:00
Yonle 367a26c109 fix: adjust path example to avoid confusion (#222) 2026-05-28 14:43:12 -04:00
Yonle 2552568ac1 feat: improved announcement management, custom qr access code handler, fixes 2026-05-22 21:01:50 -04:00
Raymond 512a791aa4 chore: [🐈‍⬛] meow 2026-05-17 20:52:30 -04:00
Raymond 9bdc84418a fix: [mai2] change hash from md5 to sha256 2026-05-16 14:02:14 -04:00
Raymond 243a9b2ddc fix: 🛡️ don't leak extid & token in mai photos 2026-05-16 00:30:42 -04:00
Raymond f313f090f8 fix: default to user's last played game for rankings in navbar 2026-05-15 23:57:36 -04:00
Raymond 2e55247de0 fix: [chu3] import functional, improve error response for importing 2026-05-15 23:47:09 -04:00
Yonle 4895456bb0 fix: display error in a friendlier way on rankings
migrate from status overlays to displaying within container
2026-05-15 22:39:54 -04:00
Raymond aec0fdac8b fix: add new option for lv and bump correctly 2026-05-12 18:27:59 -04:00
Azalea eb600f1715 [F] Fix kaledx 2026-05-12 03:59:09 +00:00
Raymond bd6a641336 chore: bump data 2026-05-11 19:28:52 -04:00
Raymond aeebf069c2 fix: 🚑 fix CM title servers 2026-05-04 17:08:52 -04:00
Azalea a77111b2ad [F] Fix diva null? 2026-04-28 18:47:11 -04:00
Azalea 031f9e4663 Merge branch 'v1-dev' of https://github.com/MewoLab/AquaDX into v1-dev 2026-04-28 18:46:56 -04:00
Azalea 1181707b43 [WTF] Fix firefox favicon wtf 2026-04-24 12:36:44 +08:00
Azalea 927fa56862 [F] Fix svelte warnings & errors (#218) 2026-04-24 12:27:23 +08:00
Raymond d17073297c fix: 🚑 typo 2026-04-23 19:47:15 -04:00
Raymond 34a3de1209 fix: add new lv 2026-04-23 19:00:45 -04:00
raymonable 3a42435dc5 fix: increase maximum linked verses 2026-04-23 14:15:30 -04:00
Azalea 7865f7cfa7 [F] Fix svelte warnings & errors 2026-04-21 03:49:04 -04:00
Raymond e711921725 fix: 🚑 make data version fallback to v0 instead of v1 2026-04-16 21:29:49 -04:00
asterisk727 d0110d7f47 fix: 🚑 bad migration 2026-04-15 23:06:16 +09:00
asterisk727 26748719a0 chore: remove duplicate migration 2026-04-14 22:31:03 -04:00
asterisk727 b20fe6fa62 Merge branch 'v1-dev' of https://github.com/MewoLab/AquaDX into v1-dev 2026-04-14 22:28:03 -04:00
asterisk727 9aec50d901 chore: update chusan event db 2026-04-14 22:27:56 -04:00
asterisk727 c31c1a0524 chore: update chusan event db 2026-04-14 22:20:23 -04:00
Azalea c989c273c4 [F] Fix wacca: Path variable 2026-04-14 02:34:41 -04:00
Azalea c4c550b239 [F] Fix wacca: Should not compress 2026-04-14 02:27:41 -04:00
Azalea 22334de8a7 [F] Fix wacca trailing slash 2026-04-14 01:59:11 -04:00
Raymond 50cb970828 fix: logging typo 2026-04-13 20:56:53 -04:00
Azalea 80c088626e fix: 🚑 ketchup pattern 2026-04-13 20:42:22 -04:00
Raymond 2d5fb11dc0 fix: 🚑 illiteracy at it's finest 2026-04-13 18:45:49 -04:00
Raymond 708532d18b fix: 🚑 chusan json fix 2026-04-13 18:42:02 -04:00
Raymond dad66401cc fix: 🚑 aimedb hotfix 2026-04-13 17:33:57 -04:00
Raymond 1bc89c46f1 chore: make readme a little prettier 2026-04-13 14:56:35 -04:00
Raymond 1d3ddfc06c feat: Title Server Encryption (#216)
i still have a few things i'd like to do but i want to get this merged in and deployed asap
2026-04-13 14:54:07 -04:00
34cfa279dc feat: support multiple keychips per user (#215)
Co-authored-by: hykilpikonna <22280294+hykilpikonna@users.noreply.github.com>
Co-authored-by: asterisk727 <59166650+asterisk727@users.noreply.github.com>
Co-authored-by: Raymond <101374892+raymonable@users.noreply.github.com>
2026-04-13 00:21:50 -04:00
Raymond 2e21926189 User Options Web UI (#214) 2026-04-06 10:04:03 +08:00
Azalea 7a946708c3 [F] Fix data 2026-03-31 17:16:24 +00:00
Azalea fcf7c46cad [F] 2099 > 2029??? 2026-03-29 19:30:16 -04:00
Azalea 28063912ce Reapply "Remove events from db (#213)"
This reverts commit beacca12c3.
2026-03-29 19:29:12 -04:00
Azalea beacca12c3 Revert "Remove events from db (#213)"
This reverts commit fe0a2e738d.
2026-03-29 19:16:38 -04:00
206 changed files with 22039 additions and 9194 deletions
+8
View File
@@ -0,0 +1,8 @@
*
!gradlew
!gradle/
!gradle/**
!build.gradle.kts
!settings.gradle.kts
!src/
!src/**
+2 -2
View File
@@ -12,9 +12,9 @@ jobs:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
java-version: '21'
java-version: '25'
distribution: 'temurin'
- name: Build with Gradle
+2 -2
View File
@@ -17,9 +17,9 @@ jobs:
fetch-depth: '10'
- name: Set up JDK
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
java-version: '17'
java-version: '25'
distribution: 'temurin'
server-id: github
+3
View File
@@ -84,3 +84,6 @@ src/main/resources/meta/*/*.json
test-diff
htmlReport
docs/logs
# Sensitive keys
game_encryption.json
-10
View File
@@ -1,10 +0,0 @@
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
VITE_GITHUB_REPOSITORY=https://github.com/MewoLab/AquaDX
+26
View File
@@ -0,0 +1,26 @@
# This should reference your AquaDX instance's :80 port.
# Sometimes issues with CORS may appear, the easiest fix is to use a reverse proxy like Caddy to put AquaDX on the /aqua path,
# and set this to match the /aqua path (see example below)
VITE_AQUA_HOST=https://aquadx.net/aqua
# Link to converted game data (ex: all_music.json, all_items.json, etc.)
# Profile pictures are also served through this path, you may want to disable profile pictures if using the primary instance for data fetching
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
VITE_GITHUB_REPOSITORY=https://github.com/MewoLab/AquaDX
# 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
VITE_USERBOX_DEFAULT_URL=
# the following is used at some case where custom access code QR code URL has been done.
# example:
# https://aquadx.net/cards/#accesskey=....
VITE_ACCESSCODE_QKEY=accesskey
VITE_DEFAULT_GAME=mai2
+3 -1
View File
@@ -35,4 +35,6 @@ dist-ssr
public/chu3
# local env file
*.local
*.local
.env
+18 -5
View File
@@ -9,12 +9,25 @@ This project uses Svelte (NOT SvelteKit) + TypeScript + Sass, built using Vite.
### Running locally
First, you would need to install Node.js and bun.
Then, you would need to start your testing AquaDX server and configure the `aqua_host` in `src/libs/config.ts` to use your URL.
Please leave `data_host` unchanged if you're not sure what it is.
Finally, run:
You will need to have Node.js (or equivalent) and `pnpm` installed, Start by installing dependencies by running:
```
pnpm install
```
```shell
Then, you would need to start your testing AquaDX server and configure the `VITE_AQUA_HOST` in `.env` (copy `.env.example` file to `.env`) to use your URL.
Please leave `VITE_DATA_HOST` unchanged if you're not sure what it is.
Finally, run:
```
pnpm run dev
```
### Running locally (with bun)
Alternatively, If you prefer [Bun](https://bun.sh), You can do the same as above.
Start by configuring the `VITE_AQUA_HOST` in `.env` (copy `.env.example` file to `.env`) to use your URL.
and finally:
```
bun install
bun run dev
```
+9
View File
@@ -0,0 +1,9 @@
import { existsSync } from "fs"
if (!existsSync(".env")) {
console.error(" I did not see .env being here. You sure it was there?")
console.error(" Copy .env.example to .env, edit .env, and try again.\n")
process.exit(1)
}
process.exit(0)
-1
View File
@@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AquaNet</title>
+2 -2
View File
@@ -4,8 +4,8 @@
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"dev": "node buildcheck.js && vite",
"build": "node buildcheck.js && vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"lint": "eslint . --ext ts,tsx,svelte --max-warnings 0 --fix"
View File
+13 -4
View File
@@ -9,7 +9,8 @@
import Settings from "./pages/User/Settings.svelte";
import MaiPhoto from "./pages/MaiPhoto.svelte";
import { pfp, tooltip } from "./libs/ui"
import { ANNOUNCEMENT } from "./libs/config";
import { ANNOUNCEMENT } from "./libs/announcement";
import { DEFAULT_GAME } from "./libs/config"
import { t } from "./libs/i18n";
import Transfer from "./pages/Transfer/Transfer.svelte";
import { link } from "d3";
@@ -34,12 +35,19 @@
let me: AquaNetUser
let playedMai = false
let recentGame: string = DEFAULT_GAME;
if (USER.isLoggedIn())
{
USER.me().then(m => {
me = m
CARD.userGames(me.username).then(game => {
playedMai = !!game.mai2
recentGame = Object.keys(game)
.filter(k => !!game[k])
.sort((a, b) => {
return (new Date(game[b].lastLogin)) - (new Date(game[a].lastLogin))
})[0] ?? "mai2"
})
}).catch(e => console.error(e))
}
@@ -53,15 +61,16 @@
<span>AquaNet</span>
</a>
{/if}
{#if ANNOUNCEMENT}
{#if $ANNOUNCEMENT}
<div class="announcement">
<strong>{t('navigation.notice')}</strong>: {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>
<!-- kill me -->
<a href={`/ranking/${recentGame}`}>{t('navigation.rankings').toLowerCase()}</a>
{#if playedMai}
<a href="/pictures">photo</a>
{/if}
+1 -1
View File
@@ -27,7 +27,7 @@
display: flex
gap: 1rem
div
a
&.active
color: vars.$c-main
</style>
+7 -2
View File
@@ -9,8 +9,13 @@
let editing = false
let inputPage: number
function focus(node: HTMLInputElement) {
node.focus()
}
function updatePage(newPage: number) {
if (newPage > 0 && newPage <= totalPages) dispatch('updatePage', newPage)
const p = Math.floor(Number(newPage))
if (!isNaN(p) && p > 0 && p <= totalPages) dispatch('updatePage', p)
}
function startEditing() {
@@ -33,7 +38,7 @@
<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/>
<input type="number" bind:value={inputPage} on:blur={finishEditing} on:keydown={handleKeydown} min="1" max={totalPages} use:focus/>
{:else}
<span on:click={startEditing} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && startEditing()}>
Page {page} of {totalPages}
+55 -10
View File
@@ -16,19 +16,25 @@
if (top) split = split?.toSorted((a, b) => b.score - a.score).slice(0, top)
if (split) console.log("Split", split)
let mustResize = split && split.length > 12
</script>
{#if split && comp}
<div>
<h2>{title}</h2>
<div class="rating-composition">
{#each split as p}
<div>
<RatingCompSong {p} {game}/>
</div>
{/each}
{#if split && split.length > 0 && comp}
<div class="rating-composition-container" class:must-resize={mustResize}>
{#if mustResize}
<input type="checkbox">
{/if}
<h2>{title}</h2>
<div class="rating-composition">
{#each split as p}
<div>
<RatingCompSong {p} {game}/>
</div>
{/each}
</div>
</div>
</div>
{/if}
<style lang="sass">
@@ -39,4 +45,43 @@
// 3 columns
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr))
gap: vars.$gap
overflow: hidden
transition: max-height 250ms
.rating-composition-container
position: relative
input
width: 100%
height: 100%
position: absolute
z-index: 10
border: none
border-radius: 0
opacity: 0
transition: opacity 250ms
.rating-composition-container.must-resize
input
& ~ .rating-composition
max-height: 250px
& ~ h2::after
content: "▾"
margin-left: 0.5em
&:checked ~ h2::after
content: "▴"
&:checked
opacity: 0
&:checked ~ .rating-composition
max-height: 3000px
opacity: 1
background: linear-gradient(#0000 250px, rgb(28.35, 28.35, 28.35) 100%) /* for some reason the color.adjust doesn't work here so whatever */
</style>
+4 -4
View File
@@ -27,15 +27,15 @@
})
})
function mouseOver(event: MouseEvent) {
function mouseOver(event: Event) {
console.log('over')
isHovered = true
updatePosition(event)
updatePosition(event as MouseEvent)
}
function mouseMove(event: MouseEvent) {
function mouseMove(event: Event) {
console.log('move')
updatePosition(event)
updatePosition(event as MouseEvent)
}
function updatePosition(event: MouseEvent) {
+1 -1
View File
@@ -14,7 +14,7 @@
let error = ""
setLoading(true)
GAME.userSummary(username, game).then(d => data = d).catch(e => error = e).finally(_ => setLoading(false))
GAME.userSummary(username, game).then(d => data = d).catch(e => error = e).finally(() => setLoading(false))
</script>
{#if !data}
@@ -16,7 +16,7 @@
let changed: string[] = [];
let symbols: Record<number, number> = {};
let allItems: Record<string, Record<string, { name: string }>> = {}
let submitting: string | undefined | null;
let submitting: string | undefined | null;
let settings: Record<string, GameOption> = {};
let existingUrl = "";
@@ -101,12 +101,13 @@
{#if linkedVerseAvailable}
<GameSettingFields game="chu3-linked-verse" />
{#if settings["chusanLvDifficulty"]}
<a target="_blank" href="https://silentblue.remywiki.com/CHUNITHM:Linked_VERSE">{t(`userbox.lv.help`)}</a>
<div class="field">
<label for={`chusanLvDifficulty`}>{ts(`userbox.lv.difficulty`)}</label>
<div>
<select bind:value={settings["chusanLvDifficulty"].value} id={`chusanLvDifficulty`} on:change={() => {changed = [...changed, `chusanLvDifficulty`];}}>
{#each {length: 5}, i}
<option value={i + 1}>{t(`userbox.lv.difficulty.${i + 1}` as keyof typeof EN_REF)}</option>
{#each {length: 6}, i}
<option value={i}>{t(`userbox.lv.difficulty.${i}` as keyof typeof EN_REF)}</option>
{/each}
</select>
{#if changed.includes(`chusanLvDifficulty`)}
@@ -3,10 +3,11 @@
<script lang="ts">
import {
type AquaNetUser,
type GameUserOption,
type UserBox,
type UserItem,
} from "../../libs/generalTypes";
import { DATA, USER, USERBOX, GAME } from "../../libs/sdk";
import { DATA, USER, USERBOX, GAME, SETTING } from "../../libs/sdk";
import { t, ts } from "../../libs/i18n";
import { FADE_IN, FADE_OUT, USERBOX_DEFAULT_URL } from "../../libs/config";
import { fade, slide } from "svelte/transition";
@@ -23,11 +24,15 @@
import { DDS } from "../../libs/userbox/dds";
import ChuniMatchingSettings from "./ChuniMatchingSettings.svelte";
import InputField from "../ui/InputField.svelte";
import UserOptionSlider from "./UserOptionSlider.svelte";
let user: AquaNetUser
let [loading, error, submitting, preview] = [true, "", "", ""]
let changed: string[] = [];
let userOptions: GameUserOption = {};
let userIsUsingPreset = false;
// Available (unlocked) options for each kind of item
// In allItems: 'namePlate', 'frame', 'trophy', 'mapIcon', 'systemVoice', 'avatarAccessory'
let allItems: Record<string, Record<string, { name: string }>> = {}
@@ -89,6 +94,13 @@
console.log("All items", allItems)
console.log("Userbox", userbox)
userOptions = await SETTING.optionGet('chu3').catch(_ => {
loading = false
error = t("userbox.error.nodata")
}) as GameUserOption
if (userOptions["optionSet"] != 3)
userIsUsingPreset = true;
loading = false
}
@@ -167,7 +179,7 @@
try {
data = await GAME.export('chu3')
}
catch (e) {
catch (e: any) {
error = e.message
submitting = ""
return
@@ -312,6 +324,39 @@
<InputField bind:field={userNameField}
callback={() => USERBOX.setUserBox({ field: "userName", value: userNameField.value })}/>
<!-- User Options -->
{#if userIsUsingPreset}
<blockquote class="info">
{t('settings.options.all.preset-warning')}
</blockquote>
{/if}
<div class="fields-ranges">
<UserOptionSlider
game="chu3" type="headphone"
minValue={0} maxValue={60}
defaultValue={userOptions["headphone"]}
getTextFunction={(value: number) => `${((value / 60) * 100).toFixed(0)}%`}
/>
<UserOptionSlider
game="chu3" type="trackSkip"
minValue={0} maxValue={7}
defaultValue={userOptions["trackSkip"]}
getTextFunction={(value: number) => [t('settings.options.all.none'), `S`, `S+`, `SS`, `SS+`, `SSS`, `SSS+`, t('settings.options.all.personal-best')][value]}
/>
<UserOptionSlider
game="chu3" type="speed"
minValue={0} maxValue={61}
defaultValue={userOptions["speed"]}
getTextFunction={(value: number) => value >= 61 ? "SONIC" : value < 55 ? `${(value / 4) + 1}` : `${value - 40}`}
/>
<UserOptionSlider
game="chu3" type="speed_120"
minValue={0} maxValue={61}
defaultValue={userOptions["speed_120"]}
getTextFunction={(value: number) => value >= 61 ? "SONIC" : value < 55 ? `${(value / 4) + 1}` : `${value - 40}`}
/>
</div>
</div>
<h2>{t("userbox.header.userbox")}</h2>
{#if !USERBOX_ENABLED.value || !USERBOX_INSTALLED}
@@ -394,6 +439,7 @@
{/each}
</div>
{/if}
{#if USERBOX_INSTALLED}
<!-- god this is a mess but idgaf atp -->
<div class="field boolean" style:margin-top="1em">
@@ -435,7 +481,7 @@
<span>{USERBOX_SETUP_MODE ? t('userbox.new.url_warning') : USERBOX_SETUP_TEXT}</span>
<div class="actions">
{#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">
<input type="text" on:keyup={e => {if (e.key == "Enter") { userboxHandleInput((e.target as HTMLInputElement).value) }}} class="add-margin" placeholder="Base URL">
{:else}
{#if USERBOX_PROGRESS != 0}
<div class="progress">
@@ -512,36 +558,6 @@ p.notice
height: 100%
opacity: 0
.preview
margin-top: 32px
display: flex
flex-wrap: wrap
justify-content: space-between
gap: 32px
> div
position: relative
width: 100px
height: 100px
overflow: hidden
background: vars.$ov-lighter
border-radius: vars.$border-radius
span
position: absolute
bottom: 0
width: 100%
text-align: center
z-index: 10
background: rgba(0, 0, 0, 0.2)
backdrop-filter: blur(2px)
img
position: absolute
inset: 0
width: 100%
height: 100%
object-fit: contain
.fields
display: flex
@@ -594,6 +610,18 @@ p.notice
.desc
opacity: 0.6
.fields-ranges
display: flex
flex-wrap: wrap
margin: 0.5rem 0
gap: 0 1rem
justify-content: center
:global(.field)
flex: calc(50% - 1rem)
width: 50%
/* AquaBox */
.chuni-userbox-row
@@ -1,32 +1,67 @@
<script>
<script lang="ts">
import { fade } from "svelte/transition";
import { FADE_IN, FADE_OUT } from "../../libs/config";
import { t, ts } from "../../libs/i18n";
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
import RegionSelector from "./RegionSelector.svelte";
import { USER } from "../../libs/sdk";
import type { AquaNetUser } from "../../libs/generalTypes";
import StatusOverlays from "../StatusOverlays.svelte";
const rounding = useLocalStorage("rounding", true);
let me: AquaNetUser;
let submitting = "";
let error = "";
let loading = true;
USER.me().then((m) => {
me = m;
loading = false;
}).catch(e => error = e.message)
function submit(field: string, value: string) {
if (submitting) return
submitting = field
USER.setting(field, value).then(() => {
}).catch(e => error = e.message).finally(() => submitting = "")
}
</script>
<div class="fields">
<blockquote class="info">
{ts("settings.siteNotice")}
</blockquote>
<div class="field">
<div class="bool">
<input id="rounding" type="checkbox" bind:checked={rounding.value}/>
<label for="rounding">
<span class="name">{ts(`settings.fields.rounding.name`)}</span>
<span class="desc">{ts(`settings.fields.rounding.desc`)}</span>
</label>
{#if !loading}
<div class="fields">
<blockquote class="info">
{ts("settings.siteNotice")}
</blockquote>
<div class="field">
<div class="bool">
<input id="rounding" type="checkbox" bind:checked={rounding.value}/>
<label for="rounding">
<span class="name">{ts(`settings.fields.rounding.name`)}</span>
<span class="desc">{ts(`settings.fields.rounding.desc`)}</span>
</label>
</div>
</div>
<div class="field m-t">
<div class="bool">
<input id="displayCandidates" type="checkbox" bind:checked={me.displayCandidates}
on:change={() => submit('displayCandidates', me.displayCandidates.toString())}/>
<label for="displayCandidates">
<span class="name">{ts(`settings.fields.displayCandidates.name`)}</span>
<span class="desc">{ts(`settings.fields.displayCandidates.desc`)}</span>
</label>
</div>
</div>
<div class="divider"></div>
<blockquote class="info">
{ts("settings.regionNotice")}
</blockquote>
<RegionSelector/>
</div>
<div class="divider"></div>
<blockquote class="info">
{ts("settings.regionNotice")}
</blockquote>
<RegionSelector/>
</div>
{/if}
<StatusOverlays {error} {loading} />
<style lang="sass">
@use "../../vars"
@@ -4,37 +4,36 @@
import { t } from "../../libs/i18n.js";
import Icon from "@iconify/svelte";
import StatusOverlays from "../StatusOverlays.svelte";
import { GAME, USER } from "../../libs/sdk";
import { GAME, SETTING, USER } from "../../libs/sdk";
import GameSettingFields from "./GameSettingFields.svelte";
import { download } from "../../libs/ui";
import UserOptionSlider from "./UserOptionSlider.svelte";
import type { GameUserOption } from "../../libs/generalTypes";
import InputField from "../ui/InputField.svelte";
const profileFields = [
['name', t('settings.mai2.name')],
]
let userNameField: any
let error: string
let submitting = ""
let values = Array(profileFields.length).fill('')
let loading = true
let changed: string[] = []
USER.me().then(me =>
GAME.userSummary(me.username, 'mai2').then(({name}) => {
values = [name]
let userOptions: GameUserOption = {}
let userIsUsingPreset = false
USER.me().then(me =>
GAME.userSummary(me.username, 'mai2').then(async ({name}) => {
userNameField = {key: "gameUsername", value: name, type: "String"}
userOptions = await SETTING.optionGet('mai2').catch(_ => {
error = t("userbox.error.nodata")
loading = true
}) as GameUserOption
if (userOptions["optionKind"] != 3)
userIsUsingPreset = true
loading = false
}).catch(e => error = e.message));
function submit(field: string, value: string) {
if (submitting) return
submitting = field
switch (field) {
case 'name':
GAME.changeName('mai2', value).then(({newName}) => {
changed = changed.filter(c => c !== field)
values = [newName]
}).catch(e => error = e.message).finally(() => submitting = "")
break
}
}
async function exportBatchManual() {
submitting = "batchExport"
const DIFFICULTY_MAP: Record<number, string> = {
@@ -156,12 +155,12 @@
let lamp = null;
if (score.isAllPerfect) {
lamp = "ALL PERFECT";
if (score.percent == 101.0) {
if (percent >= 101.0) {
lamp = "ALL PERFECT+";
}
} else if (score.isFullCombo) {
lamp = "FULL COMBO";
if (good == 0 && great == 0) {
if (!good) {
lamp = "FULL COMBO+";
}
} else if (score.isClear) {
@@ -195,39 +194,72 @@
if(data.userData.classRank in CLASS_MAP){
output.classes["matchingClass"] = CLASS_MAP[data.userData.classRank]
}
download(JSON.stringify(output), `AquaDX_maimai2_BatchManualExport_${values[0]}.json`)
download(JSON.stringify(output), `AquaDX_maimai2_BatchManualExport_${userNameField.value}.json`)
submitting = ""
}
function exportData() {
submitting = "export"
GAME.export('mai2')
.then(data => download(JSON.stringify(data), `AquaDX_maimai2_export_${values[0]}.json`))
.then(data => download(JSON.stringify(data), `AquaDX_maimai2_export_${userNameField.value}.json`))
.catch(e => error = e.message)
.finally(() => submitting = "")
}
</script>
{#if !loading}
<div class="fields">
{#each profileFields as [field, name], i (field)}
<div class="field">
<label for={field}>{name}</label>
<div>
<input id={field} type="text"
bind:value={values[i]} on:input={() => changed = [...changed, field]}
placeholder={field === 'password' ? t('settings.profile.unchanged') : t('settings.profile.unset')}/>
{#if changed.includes(field) && values[i]}
<button transition:slide={{axis: 'x'}} on:click={() => submit(field, values[i])}>
{#if submitting === field}
<Icon icon="line-md:loading-twotone-loop"/>
{:else}
{t('settings.profile.save')}
{/if}
</button>
{/if}
</div>
</div>
{/each}
<InputField bind:field={userNameField}
callback={() => GAME.changeName('mai2', userNameField.value)}/>
{#if userIsUsingPreset}
<blockquote class="info">
{t('settings.options.all.preset-warning')}
</blockquote>
{/if}
<div class="fields-ranges">
<!-- TODO: determine min & max -->
<UserOptionSlider
game="mai2" type="noteSpeed"
minValue={0} maxValue={37}
defaultValue={userOptions["noteSpeed"]}
getTextFunction={(value: number) => value >= 37 ? "SONIC" : `${(value / 4) + 1}`}
/>
<UserOptionSlider
game="mai2" type="touchSpeed"
minValue={0} maxValue={37}
defaultValue={userOptions["touchSpeed"]}
getTextFunction={(value: number) => value >= 37 ? "SONIC" : `${(value / 4) + 1}`}
/>
<UserOptionSlider
game="mai2" type="slideSpeed"
minValue={0} maxValue={20}
defaultValue={userOptions["slideSpeed"]}
getTextFunction={(value: number) => value == 10 ? "Normal" : `${value > 10 ? "Late" : "Fast"} ${(Math.abs(value - 10) / 10).toFixed(1)}`}
/>
<UserOptionSlider
game="mai2" type="headPhoneVolume"
minValue={0} maxValue={19}
defaultValue={userOptions["headPhoneVolume"]}
getTextFunction={(value: number) => `${((value / 19) * 100).toFixed(0)}%`}
/>
<UserOptionSlider
game="mai2" type="trackSkip"
minValue={0} maxValue={11}
defaultValue={userOptions["trackSkip"]}
getTextFunction={(value: number) => [
t('settings.options.all.none'),
t('settings.options.all.push'),
`S`, `SS`, `SSS`,
t('settings.options.all.personal-best'),
t('settings.options.all.rival-score'),
`${t('settings.options.all.life')} 300`,
`${t('settings.options.all.life')} 100`,
`${t('settings.options.all.life')} 50`,
`${t('settings.options.all.life')} 10`,
`${t('settings.options.all.life')} 1`
][value]}
/>
</div>
<GameSettingFields game="mai2"/>
<button class="exportButton" on:click={exportData}>
<Icon icon="bxs:file-export"/>
@@ -238,8 +270,9 @@
{t('settings.batchManualExport')}
</button>
</div>
{/if}
<StatusOverlays {error} loading={!values[0] || !!submitting}/>
<StatusOverlays {error} loading={!userNameField || !!submitting || loading}/>
<style lang="sass">
.fields
@@ -263,6 +296,17 @@
> input
flex: 1
.fields-ranges
display: flex
flex-wrap: wrap
margin: 0.5rem 0
gap: 0 1rem
justify-content: center
:global(.field)
flex: calc(50% - 1rem)
width: 50%
.exportButton
display: flex
justify-content: center
@@ -1,9 +1,55 @@
<script>
<script lang="ts">
import { fade } from "svelte/transition";
import { FADE_IN, FADE_OUT } from "../../libs/config";
import GameSettingFields from "./GameSettingFields.svelte";
import { GAME, SETTING, USER } from "../../libs/sdk";
import { download } from "../../libs/ui";
import InputField from "../ui/InputField.svelte";
import Icon from "@iconify/svelte";
import { t } from "../../libs/i18n";
import StatusOverlays from "../StatusOverlays.svelte";
let userNameField: any
let submitting = "";
let error: string
let loading = true;
USER.me().then(me =>
GAME.userSummary(me.username, 'ongeki').then(async ({name}) => {
userNameField = {key: "gameUsername", value: name, type: "String"}
loading = false;
}))
function exportData() {
submitting = "export"
GAME.export('ongeki')
.then(data => download(JSON.stringify(data), `AquaDX_ongeki_export_${userNameField.value}.json`))
.catch(e => error = e.message)
.finally(() => submitting = "")
}
</script>
<div>
<GameSettingFields game="ongeki"/>
</div>
{#if !loading}
<div>
<div class="fields">
<InputField bind:field={userNameField}
callback={() => SETTING.detailSet('ongeki', 'userName', userNameField.value)}/>
<GameSettingFields game="ongeki"/>
<button class="exportButton" on:click={exportData}>
<Icon icon="bxs:file-export"/>
{t('settings.export')}
</button>
</div>
</div>
{/if}
<StatusOverlays {error} loading={!userNameField || !!submitting || loading}/>
<style lang="sass">
.fields
display: flex
flex-direction: column
gap: 12px
</style>
@@ -3,8 +3,8 @@
import { ts } from "../../libs/i18n";
import StatusOverlays from "../StatusOverlays.svelte";
let regionId = $state(0);
let submitting = ""
let error: string;
let submitting = $state("")
let error: string = $state("");
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"]
@@ -31,7 +31,7 @@
<span class="name">{ts(`settings.regionSelector.title`)}</span>
<span class="desc">{ts(`settings.regionSelector.desc`)}</span>
</label>
<select bind:value={regionId} on:change={saveNewRegion}>
<select bind:value={regionId} onchange={saveNewRegion}>
<option value={0} disabled selected>{ts("settings.regionSelector.select")}</option>
{#each prefectures.slice(1) as prefecture, index}
<option value={index}>{prefecture}</option>
@@ -0,0 +1,86 @@
<script lang="ts">
import { t } from "../../libs/i18n";
import { EN_REF } from "../../libs/i18n/en_ref";
import type { GameName } from "../../libs/scoring";
import { SETTING } from "../../libs/sdk";
export let getTextFunction: (value: number) => string
export let defaultValue: number = 0
export let minValue: number = 0
export let maxValue: number = 0
export let type: string = "speed";
export let game: GameName;
export let disabled: boolean = false;
let value = defaultValue;
let rationalizedValue = defaultValue / maxValue;
async function submit() {
await SETTING.optionSet(game, type, value);
}
</script>
<div class="field range">
<div class="range-label">
{t(`settings.options.${game}.${type}` as keyof typeof EN_REF)} <!-- TODO: i18n -->
</div>
<div class="range-value">
<input
type="range" min={minValue} {disabled}
on:change={submit} max={maxValue} bind:value={value}
>
<div>
{getTextFunction(value)}
</div>
</div>
</div>
<style lang="sass">
@use "../../vars"
.field
width: 100%
.range-value
display: flex
align-items: center
div
min-width: 4rem
white-space: nowrap
display: flex
justify-content: center
input
flex: 100%
input
-webkit-appearance: none
width: 100%
height: 5px !important
outline: none
transition: opacity 200ms
padding: 4px
border-radius: 4px
opacity: 80%
background: linear-gradient(to right, transparent 0%, #caf1 10%, #caf1 90%, transparent 100%)
margin: 16px 0
&:hover
opacity: 100%
*:focus
outline: none
input::-webkit-slider-thumb
-webkit-appearance: none
appearance: none
width: 8px
height: 30px
background: vars.$c-main
border-radius: 8px
cursor: pointer
box-shadow: 0 0 2px 0 vars.$c-main
</style>
@@ -1,10 +1,10 @@
<script lang="ts">
import { slide, fade } from "svelte/transition";
import type { AquaNetUser } from "../../libs/generalTypes";
import { CARD, USER } from "../../libs/sdk";
import type { AquaNetUser, ConfirmProps } from "../../libs/generalTypes";
import { CARD, GAME, USER, type ExportGameName } from "../../libs/sdk";
import StatusOverlays from "../../components/StatusOverlays.svelte";
import Icon from "@iconify/svelte";
import { pfp } from "../../libs/ui";
import { download, pfp } from "../../libs/ui";
import { t, ts } from "../../libs/i18n";
import Cropper from "svelte-easy-crop";
@@ -12,6 +12,7 @@
let error: string;
let submitting = ""
let loading = false;
let confirm: ConfirmProps | null = null;
const profileFields = [
[ 'displayName', t('settings.profile.name') ],
@@ -97,11 +98,48 @@
location.href = "/";
}
function requestAccountDeletion() {
if (submitting) return;
confirm = {
title: t('settings.profile.delete-confirm-title'),
message: t('settings.profile.delete-confirm-message'),
dangerous: true,
cancel: () => confirm = null,
confirm: deleteAccount,
};
}
async function deleteAccount() {
if (submitting) return;
submitting = "deleteAccount";
try {
const gameSummary = await CARD.userGames(me.username);
const games = (['mai2', 'chu3', 'ongeki', 'wacca', 'diva'] as ExportGameName[])
.filter(game => gameSummary[game] !== null);
const exports = await Promise.all(games.map(async game => ({
game,
data: await GAME.export(game),
})));
exports.forEach(({game, data}) => download(
JSON.stringify(data, null, 2),
`AquaDX_${game}_export_${me.username}.json`,
));
await USER.deleteAccount();
localStorage.removeItem("token");
location.href = "/";
} catch (e) {
error = (e as Error).message;
submitting = "";
}
}
const passwordAction = (node: HTMLInputElement, whether: boolean) => {
if (whether) node.type = 'password'
}
</script>
<StatusOverlays {error} loading={!!submitting || loading} />
<StatusOverlays bind:confirm={confirm} {error} loading={!!submitting || loading} />
{#if !submitting && !error && me}
<div>
<div class="fields">
@@ -156,11 +194,31 @@
</label>
</div>
</div>
<div class="field m-t">
<div class="bool">
<input id="hideCountry" type="checkbox" bind:checked={me.hideCountry}
on:change={() => submit('hideCountry', me.hideCountry.toString())}/>
<label for="hideCountry">
<span class="name">{ts(`settings.fields.hideCountry.name`)}</span>
<span class="desc">{ts(`settings.fields.hideCountry.desc`)}</span>
</label>
</div>
</div>
<div class="field m-t">
<div>
<button on:click={logOut}>{ts(`settings.profile.logout`)}</button>
</div>
</div>
<div class="field danger-zone m-t">
<span class="name">{t('settings.profile.delete-title')}</span>
<span class="desc">{t('settings.profile.delete-description')}</span>
<div>
<button class="error" on:click={requestAccountDeletion}>
<Icon icon="tabler:trash-x-filled" />
{t('settings.profile.delete')}
</button>
</div>
</div>
</div>
</div>
{/if}
@@ -224,6 +282,19 @@
object-fit: cover
aspect-ratio: 1
.danger-zone
margin-top: 1.5rem
padding-top: 1.5rem
border-top: 1px solid vars.$ov-lighter
.desc
opacity: 0.6
button
display: flex
align-items: center
gap: 0.5rem
.cropper-container
-1
View File
@@ -4,7 +4,6 @@
import { DISCORD_INVITE } from "../../libs/config";
export let error: string;
export let expected: boolean = false;
</script>
<div class="overlay" transition:fade>
+35
View File
@@ -0,0 +1,35 @@
// card handler. for dealing user QR scan.
// this will assumes that the link of the generated QR code in [access code]
// page has been altered by modder to link to custom
import { ACCESSCODE_QKEY } from "./config";
const query = new URLSearchParams(location.hash.slice(1));
const lsKey = "acurl"
export const url_has = (): boolean => query.has(ACCESSCODE_QKEY);
export const get = (): string => localStorage.getItem(lsKey) || "";
export const has = (): boolean => !!localStorage.getItem(lsKey);
export const clear = (): void => localStorage.removeItem(lsKey);
export function check() {
if (!url_has()) return
console.log("Found access code in URL. Saving.")
localStorage.setItem(lsKey, query.get(ACCESSCODE_QKEY) || "")
}
export function preview() {
const card = get()
const masked =
card.slice(0, 4) +
"x".repeat(card.length - 4);
return masked.match(/.{1,4}/g)?.join(" ");
}
export function previewR() {
const card = get()
return card.match(/.{1,4}/g)?.join(" ");
}
+11
View File
@@ -0,0 +1,11 @@
import { writable } from "svelte/store"
export const ANNOUNCEMENT = writable('')
// This task must not bother the existing one. Keep it async in the background instead
void (async () => {
let r = await fetch("/announcement.txt")
if (!r.ok) return;
ANNOUNCEMENT.set(await r.text())
})()
+5 -4
View File
@@ -17,14 +17,15 @@ export const FADE_OUT = { duration: 200 }
export const FADE_IN = { delay: 400 }
export const DEFAULT_PFP = '/assets/imgs/no_profile.png'
export const ANNOUNCEMENT = '' // If set, will add an announcement to the top bar. Keep it short.
// Key name for access key scanned from QR code
export const ACCESSCODE_QKEY = import.meta.env.VITE_ACCESSCODE_QKEY || "accesskey"
// 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 USERBOX_DEFAULT_URL = import.meta.env.VITE_USERBOX_DEFAULT_URL || ""
export const HAS_USERBOX_ASSETS = true
export const DEFAULT_GAME = import.meta.env.VITE_DEFAULT_GAME || "mai2"
// Meow meow meow
// Matching servers
+8 -2
View File
@@ -31,6 +31,9 @@ export interface AquaNetUser {
computedName: string,
password: string,
optOutOfLeaderboard: boolean,
canModifyKeychips: boolean,
hideCountry: boolean,
displayCandidates: boolean
}
export interface CardSummaryGame {
@@ -90,10 +93,11 @@ export interface GenericGameSummary {
name: string
iconId: number
aquaUser?: AquaNetUser
serverRank: number
serverRank: string
accuracy: number
rating: number
ratingHighest: number
ratingHighest: number,
ratingNotGeneric: boolean,
ranks: RankCount[]
detailedRanks: { [key: number]: { [key: string]: number } }
maxCombo: number
@@ -137,6 +141,8 @@ export interface GameOption {
changed?: boolean
}
export type GameUserOption = Record<string, number>
export interface UserItem { itemKind: number, itemId: number, stock: number }
export interface UserBox {
userName: string,
+71 -15
View File
@@ -2,6 +2,8 @@ export const EN_REF_USER = {
'UserHome.ServerRank': 'Server Rank',
'UserHome.DXRating': 'DX Rating',
'UserHome.Rating': 'Rating',
'UserHome.HighestDXRating': 'Peak DX Rating',
'UserHome.HighestRating': 'Peak Rating',
'UserHome.Statistics': 'Statistics',
'UserHome.Accuracy': 'Accuracy',
'UserHome.MaxCombo': 'Max Combo',
@@ -24,6 +26,13 @@ export const EN_REF_USER = {
'UserHome.RankDetail.Title': 'Achievement Details',
'UserHome.RankDetail.Level': 'Level',
'UserHome.B50': 'B50',
'UserHome.RatingComposition.Best': 'Best ${n}',
'UserHome.RatingComposition.New': 'New ${n}',
'UserHome.RatingComposition.BestCandidates': 'Best ${n} Candidates',
'UserHome.RatingComposition.NewCandidates': 'New ${n} Candidates',
'UserHome.RatingComposition.Platinum': 'Platinum ${n}',
'UserHome.RatingComposition.PlatinumCandidates': 'Platinum ${n} Candidates',
'UserHome.RatingComposition.Recent': 'Recent ${n}',
'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.",
@@ -60,12 +69,15 @@ export const EN_REF_Welcome = {
'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.',
'welcome.login_link': 'Login / register to link your card.'
}
export const EN_REF_LEADERBOARD = {
'Leaderboard.Title': 'Server Leaderboard',
'Leaderboard.Rank': 'Rank',
'Leaderboard.Rating': 'Rating',
'Leaderboard.LegacyRating': 'Legacy Rating',
'Leaderboard.ModernRating': 'Modern Rating',
'Leaderboard.Accuracy': 'Accuracy',
'Leaderboard.FC': 'FC',
'Leaderboard.AP': 'AP',
@@ -119,6 +131,7 @@ export const EN_REF_HOME = {
'home.linkcard.enter-info': 'Please enter either an Access Code or a Serial Number in the following boxes. Dragging and dropping aime.txt or felica.txt is also supported.',
'home.linkcard.access-code': 'If you have an Access Code, enter it below. Access Codes can be obtained from the back of your card or from the title screen of your game (press the Access Code button and card in). If you do not have an Access Code, you may generate one.',
'home.linkcard.enter-sn': 'If you have a Serial Number, enter it below. Serial Numbers can be obtained via most smartphones (NFC Tools: <a href="https://play.google.com/store/apps/details?id=com.wakdev.wdnfc">Android</a> / <a href="https://apps.apple.com/us/app/nfc-tools/id1252962749">Apple</a>) or a card reader.',
'home.linkcard.kdx-notice': 'If you are playing KanadeDX, please enter the virtual card number here (can be found in Settings > Card Number).',
'home.linkcard.link': 'Link',
'home.linkcard.data-conflict': 'Data Conflict',
'home.linkcard.name': 'Name',
@@ -142,12 +155,11 @@ export const EN_REF_HOME = {
export const EN_REF_SETUP = {
'setup.welcome': `Welcome! If you have a game set up, please follow the instructions below to set up the connection with AquaDX.`,
'setup.keychip-warning': `Your keychip is linked to your account and should be kept secure. Do not give others your keychip.`,
'setup.keychip-warning': `Your keychip(s) are linked to your account and should be kept secure.`,
'setup.steps.one': `Pick a method of setting up network communications. Some browsers may not be able to do automatic setup.`,
'setup.steps.two': `Ensure your game has encryption disabled as AquaDX does not support encryption. This may be via a patch or setting.`,
'setup.steps.three': `Link your Aime card to your AquaDX account using the <a href="/cards">Cards</a> page via it's access code or serial number.`,
'setup.steps.four': `Start the game. Upon reaching the title screen, the network icon in the corner should now show green instead of grey.`,
'setup.support-info': `If you need assistance, feel free to make an inquiry in a <a href="/support">support channel</a>.`,
'setup.steps.two': `Link your Aime card to your AquaDX account using the <a href="/cards">Cards</a> page via it's access code or serial number.`,
'setup.steps.three': `Start the game. Upon reaching the title screen, the network icon in the corner should now show green instead of grey.`,
'setup.support-info': `If you need additional assistance, feel free to make an inquiry in a <a href="/support">support channel</a>.`,
'setup.reveal-keychip': `Reveal keychip`,
'setup.type.automatic': `Automatic Setup`,
'setup.type.manual': `Manual Setup`,
@@ -155,7 +167,21 @@ export const EN_REF_SETUP = {
'setup.automatic': `Select your segatools.ini below to autofill the network information.`,
'setup.automatic.success': `Success, data has been updated`,
'setup.automatic.failure': `Failed to update information, please ensure access is not blocked and try again.`,
'setup.automatic.select': `Pick file`
'setup.automatic.select': `Pick file`,
'setup.troubleshooting.header': 'Setup Troubleshooting (FAQ)',
'setup.troubleshooting.items.one': `» It displays DNS BAD on Network Test<br>
You likely misconfigured your segatools.ini. Ensure it's pointing to the DNS shown above (under Manual Setup).`,
'setup.troubleshooting.items.two': `» It displays タイトル サーバー BAD (Title Server BAD) on Network Test<br>
You should check your segatools.ini to ensure your keychip is set correctly.<br>
Additionally, Card Maker users and players using a game version higher than what's listed may need to disable encryption
<a target="_blank" href="https://gitea.tendokyu.moe/Dniel97/SEGAguide/wiki/SDED#patching-assemblycsharpdll">(instructions for Card Maker)</a>`,
'setup.troubleshooting.items.three': `» I can't scan my card<br>
The built-in emulated card reader, by default, is configured to use the Enter / Return key to scan in (hold the key).`,
'setup.keychip': 'Keychip Management',
'setup.keychip.warning': 'Keychips can be manually set here for cabinet owners with physical keychips trying to connect to AquaDX. Selecting a keychip will autofill it for the setup below.',
'setup.keychip.add': `Add keychip`,
'setup.keychip.delete': `Delete`,
'setup.keychip.placeholder': `New Keychip ID`
}
export const EN_REF_SETTINGS = {
@@ -167,6 +193,24 @@ export const EN_REF_SETTINGS = {
'settings.tabs.mai2': 'Mai',
'settings.tabs.ongeki': 'Ongeki',
'settings.tabs.wacca': 'Wacca',
'settings.options.all.preset-warning': 'You are using a preset option set. Options may be reflected incorrectly.',
'settings.options.all.personal-best': 'Record',
'settings.options.all.rival-score': 'Rival',
'settings.options.all.life': 'Life',
'settings.options.all.push': 'Push',
'settings.options.all.none': 'None',
// Chuni User Options
'settings.options.chu3.speed': 'Lane Speed (60fps)',
'settings.options.chu3.speed_120': 'Lane Speed (120fps)',
'settings.options.chu3.trackSkip': 'Track Skip Type',
'settings.options.chu3.headphone': 'Headphone Volume',
// Maimai User Options
'settings.options.mai2.noteSpeed': 'Note Speed',
'settings.options.mai2.touchSpeed': 'Touch Speed',
'settings.options.mai2.slideSpeed': 'Slide Speed',
'settings.options.mai2.headPhoneVolume': 'Headphone Volume',
'settings.options.mai2.trackSkip': 'Track Skip Type',
'settings.fields.mai2UnlockMusic.name': 'Unlock All Music',
'settings.fields.mai2UnlockMusic.desc': 'Unlock all music and master difficulty.',
'settings.fields.mai2UnlockChara.name': 'Unlock All Characters',
@@ -195,6 +239,8 @@ export const EN_REF_SETTINGS = {
'settings.fields.chusanTeamName.desc': 'Customize the text displayed on the top of your profile.',
'settings.fields.chusanInfinitePenguins.name': 'Infinite Penguins',
'settings.fields.chusanInfinitePenguins.desc': 'Set penguin statues for character level prompting to 999.',
'settings.fields.chusanUnlock29672999Ultima.name': 'Unlock 2967, 2999 Ultima',
'settings.fields.chusanUnlock29672999Ultima.desc': 'Unlocks Ultima difficulty ONLY for musicIds 2967, 2999. Do not use if you don\'t know why you need this.',
'settings.fields.chusanLvUnlockAll.name': 'Unlock Linked Gates',
'settings.fields.chusanLvUnlockAll.desc': 'Incurs a long animated sequence, cannot be undone',
'settings.fields.chusanMatchingReflector.name': 'Matching Server Reflector',
@@ -205,13 +251,16 @@ export const EN_REF_SETTINGS = {
'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.name': 'Player Name',
'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.optOutOfLeaderboard.desc': 'It may take some time for this to take effect',
'settings.fields.hideCountry.name': 'Hide Country',
'settings.fields.hideCountry.desc': 'Your country of registration will not be visible on your profile',
'settings.fields.displayCandidates.name': 'Display Candidates',
'settings.fields.displayCandidates.desc': 'Shows upcoming songs that could improve your rating on your profile (when available)',
'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.',
@@ -225,6 +274,11 @@ export const EN_REF_SETTINGS = {
'settings.profile.unset': 'Unset',
'settings.profile.logout': 'Log out',
'settings.profile.unchanged': 'Unchanged',
'settings.profile.delete-title': 'Delete account',
'settings.profile.delete-description': 'Download a backup, then permanently delete your account and all associated game data.',
'settings.profile.delete': 'Delete account',
'settings.profile.delete-confirm-title': 'Permanently delete your account?',
'settings.profile.delete-confirm-message': 'A backup of all game data will be downloaded first. After that, your account and all associated game data will be permanently deleted. This cannot be undone.',
'settings.export': 'Export Player Data',
'settings.batchManualExport': "Export in Batch Manual (for Tachi)",
'settings.cabNotice': "These settings only apply for your keychip. If you're playing on someone else's setup, please ask them to change these settings.",
@@ -270,13 +324,15 @@ export const EN_REF_USERBOX = {
'userbox.matching.symbolChat': 'Message Choice',
'userbox.matching.symbolChat.default': 'Default',
'userbox.lv.diffnotice': 'In Linked Verse, you will be matched separately based on your difficulty.',
'userbox.lv.help': 'Information about Linked Verse Difficulty',
'userbox.lv.diffnotice': 'In Linked Verse, you will be matched separately based on your difficulty.',
'userbox.lv.difficulty': 'Linked Verse Difficulty',
'userbox.lv.difficulty.1': 'LEVEL V (1000 life, MASTER only)',
'userbox.lv.difficulty.2': 'LEVEL IV (3000 life, MASTER only)',
'userbox.lv.difficulty.3': 'LEVEL III (5000 life, MASTER only)',
'userbox.lv.difficulty.4': 'LEVEL II (5000 life, MASTER & EXPERT only)',
'userbox.lv.difficulty.5': 'LEVEL I (5000 life, ALL difficulties)',
'userbox.lv.difficulty.0': 'LEVEL ∞ (Only for supported gates)',
'userbox.lv.difficulty.1': 'LEVEL V',
'userbox.lv.difficulty.2': 'LEVEL IV',
'userbox.lv.difficulty.3': 'LEVEL III',
'userbox.lv.difficulty.4': 'LEVEL II',
'userbox.lv.difficulty.5': 'LEVEL I',
'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.',
+128 -42
View File
@@ -5,6 +5,7 @@ import {
EN_REF_LEADERBOARD,
EN_REF_MAI_PHOTO,
EN_REF_SETTINGS,
EN_REF_SETUP,
EN_REF_USER,
EN_REF_USERBOX,
type EN_REF_Welcome
@@ -13,7 +14,9 @@ import {
const zhUser: typeof EN_REF_USER = {
'UserHome.ServerRank': '服务器排名',
'UserHome.DXRating': 'DX B50',
'UserHome.Rating': '评分',
'UserHome.Rating': '最高评分',
'UserHome.HighestDXRating': 'DX B50',
'UserHome.HighestRating': '最高评分',
'UserHome.Statistics': '统计数据',
'UserHome.Accuracy': '准确率',
'UserHome.MaxCombo': '最大连击',
@@ -36,6 +39,13 @@ const zhUser: typeof EN_REF_USER = {
'UserHome.RankDetail.Title': '评分详细',
'UserHome.RankDetail.Level': "等级",
'UserHome.B50': "B50",
'UserHome.RatingComposition.Best': '最佳 ${n}',
'UserHome.RatingComposition.New': '新曲 ${n}',
'UserHome.RatingComposition.BestCandidates': '最佳 ${n} 候选 ',
'UserHome.RatingComposition.NewCandidates': '新曲 ${n} 候选 ',
'UserHome.RatingComposition.PlatinumCandidates': '白金分 ${n} 候选 ',
'UserHome.RatingComposition.Platinum': '白金分 ${n}',
'UserHome.RatingComposition.Recent': '最近 ${n}',
'UserHome.AddRival': "添加劲敌",
'UserHome.RemoveRival': "移除劲敌",
'UserHome.InvalidGame': "游戏 ${game} 还不支持网页端查看。我们目前只支持舞萌、中二、华卡和音击。",
@@ -59,25 +69,28 @@ const zhWelcome: typeof EN_REF_Welcome = {
'welcome.username-missing': '用户名/邮箱必须填哦',
'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-2': '您还没有验证邮箱哦!我们刚刚又发送了一封验证邮件,请翻翻收件箱',
'welcome.reset-state-0': '重置邮件刚刚发送到你的邮箱啦,请翻翻收件箱!',
'welcome.reset-state-1': '邮件发送次数过多,暂时不会再发送新的重置邮件了',
'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-2': '您还没有验证邮箱哦!我们刚刚又发送了一封验证邮件,请翻翻收件箱',
'welcome.reset-state-0': '重置邮件刚刚发送到你的邮箱啦,请翻翻收件箱!',
'welcome.reset-state-1': '邮件发送次数过多,暂时不会再发送新的重置邮件了',
'welcome.verifying': '正在验证邮箱…请稍等',
'welcome.verified': '您的邮箱已经验证成功!您现在可以登录了',
'welcome.verification-failed': '验证失败:${message}。请重试',
'welcome.password-reset-done': '您的密码已更新!请重新登录',
'welcome.login_link': '登录或注册以绑定卡片。',
}
const zhLeaderboard: typeof EN_REF_LEADERBOARD = {
'Leaderboard.Title': '排行榜',
'Leaderboard.Rank': '排名',
'Leaderboard.Rating': '评分',
'Leaderboard.LegacyRating': '旧评分',
'Leaderboard.ModernRating': '新评分',
'Leaderboard.Accuracy': '准确率',
'Leaderboard.FC': 'FC',
'Leaderboard.AP': 'AP',
@@ -90,43 +103,48 @@ const zhGeneral: typeof EN_REF_GENERAL = {
'game.ongeki': "音击",
'game.wacca': "华卡",
"status.error": "发生错误",
"status.error.hint": "出了一些问题,请稍后刷新重试或者",
"status.error.hint.link": "加我们的 Discord 群问一问",
"status.error.hint": "支持 (Discord)",
"status.detail": "详细信息:${detail}",
"action.refresh": "刷新",
"action.cancel": "取消",
"action.confirm": "确认",
"action.home": "主页",
'navigation.profile': '个人资料',
'navigation.maps': '地图',
'navigation.home': '首页',
'navigation.rankings': '排行榜',
'navigation.notice': '公告'
'navigation.notice': '公告',
'loading': '正在加载...',
'404': '找不到页面 (${pathname})。如果您认为这是个错误,请通过适当的支持渠道报告。'
}
const zhHome: typeof EN_REF_HOME = {
'home.nav.portal': "主页",
'home.nav.link-card': "绑卡",
'home.nav.game-setup': "连接设置",
'home.nav.support': "支持",
'home.user-profile': "个人资料",
'home.rankings': "排行榜",
'home.settings': "设置",
'home.manage-cards': '管理游戏卡',
'home.manage-cards-description': '绑定、解绑、管理游戏数据卡',
'home.link-card': '绑定游戏卡',
'home.link-cards-description':'绑定游戏数据卡(Amusement IC 或 Aime 卡)后才可以访问游戏存档哦',
'home.join-community': '加入群组',
'home.join-community-description': '加入我们的聊天群组,与其他玩家聊天、获取帮助',
'home.join-community-description': '加入我们的聊天群组,与其他玩家聊天、获取帮助',
'home.setup': '连接到 AquaDX',
'home.setup-description': '如果您有街机框体或者手台,点击这里设置服务器的连接',
'home.setup-description': '如果您有街机框体或者手台,点击这里设置服务器的连接',
'home.import': '导入玩家数据',
'home.import-description': '如果你来自其他在线服,可以点击这里导入从其他服务器导出的数据',
'home.import-description': '如果你来自其他在线服,可以点击这里导入从其他服务器导出的数据',
'home.linkcard.cards': "已绑卡片",
'home.linkcard.description': "这些是您绑定到帐户的卡",
'home.linkcard.account-card': "账户卡",
'home.linkcard.registered': "注册于",
'home.linkcard.lastused': "上次使用",
'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.enter-info': "请输入以下信息,或将 aime.txt / felica.txt 文件拖放到此区域",
'home.linkcard.access-code': "卡背面的 20 位卡号(如果提示找不到卡,请尝试使用游戏内置的显示卡号功能,输入游戏读取到的卡号)",
'home.linkcard.enter-sn': "在您的手机上下载 NFC Tools 并扫描您的卡。然后输入显示的 SN 号。",
'home.linkcard.kdx-notice': "如果你在玩 KanadeDX,请在这里输入虚拟卡号(可以在设置 > 卡号中找到卡号)",
'home.linkcard.link': "绑定",
'home.linkcard.data-conflict': "卡号冲突",
'home.linkcard.name': "名称",
@@ -137,31 +155,75 @@ const zhHome: typeof EN_REF_HOME = {
'home.linkcard.notfound': "找不到卡",
'home.linkcard.unlink': "取消链接",
'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.support': "以获取支持",
'home.setup.keychip-tips': "这是你的狗号,不要与任何人分享",
'home.linkcard.felica-ac-warning': "该 Access Code 是一张 FeliCa AIC 卡。\n如果你使用实体卡(而非 aime.txt 模拟)刷卡登录游戏,与官方服务器不同,你需要绑定该卡的 FeliCa SN(或与之对应的,游戏界面中查看得到的 00 开头的卡号)而非此号码。\n如果你使用 aime.txt 模拟登录,请忽略本警告继续绑定。",
'home.community.discord': 'Discord',
'home.community.telegram': 'Telegram (中文)',
'home.community.qq': 'QQ (中文)',
'home.community.github': 'GitHub 仓库',
'home.linkcard.card-security-warning': "卡片信息决定了您可以访问的所有玩家数据。请不要与任何人分享您的卡号。",
'home.import.unknown-game': '未知游戏类型 (目前导入只支持舞萌和中二)',
'home.import.new-data': '要导入的数据',
'home.import.data-conflict': '继续导入将覆盖现有数据',
}
const zhSetup: typeof EN_REF_SETUP = {
'setup.welcome': `欢迎!如果您已准备好游戏,请按照以下说明设置与 AquaDX 的连接。`,
'setup.keychip-warning': `您的密钥芯片已与您的账户绑定,请妥善保管。`,
'setup.steps.one': `选择一种网络通信设置方法。某些浏览器可能无法进行自动设置。`,
'setup.steps.two': `请通过 <a href="/cards">卡片</a> 页面,使用访问码或序列号将您的 Aime 卡与 AquaDX 账户绑定。`,
'setup.steps.three': `启动游戏。进入标题画面后,角落处的网络图标应变为绿色,而非灰色。`,
'setup.support-info': `如需进一步协助,欢迎在 <a href="/support">支持频道</a> 中咨询。`,
'setup.reveal-keychip': `显示密钥芯片`,
'setup.type.automatic': `自动设置`,
'setup.type.manual': `手动设置`,
'setup.manual': `请使用以下信息修改您的 segatools.ini 文件。`,
'setup.automatic': `请在下方的 segatools.ini 文件中选择,以自动填写网络信息。`,
'setup.automatic.select': `选择文件`,
'setup.automatic.success': `成功,数据已更新`,
'setup.automatic.failure': `更新信息失败,请确保访问未被阻止,然后重试。`,
'setup.troubleshooting.header': '设置故障排除(常见问题解答)',
'setup.troubleshooting.items.one': `» 网络测试显示 DNS BAD<br>
您可能配置了错误的 segatools.ini。请确保其指向上述(手动设置部分)显示的 DNS。`,
'setup.troubleshooting.items.two': `» 网络测试显示 タイトル サーバー BAD (Title Server BAD)<br>
请检查您的 segatools.ini 文件,确保密钥芯片设置正确。<br>
此外, Card Maker 用户以及使用高于列表中版本的游戏版本的玩家,可能需要禁用加密
<a target="_blank" href="https://gitea.tendokyu.moe/Dniel97/SEGAguide/wiki/SDED#patching-assemblycsharpdll">(Card Maker 操作指南)</a>`,
'setup.troubleshooting.items.three': `» 无法扫描卡片<br>
内置的模拟读卡器默认配置为使用回车键进行扫描(长按该键)。`,
'setup.keychip': '钥匙芯片管理',
'setup.keychip.warning': '对于拥有实体钥匙芯片并试图连接到 AquaDX 的机柜所有者,可在此手动设置钥匙芯片。选择钥匙芯片后,下方设置项将自动填充。',
'setup.keychip.add': `添加钥匙芯片`,
'setup.keychip.delete': `删除`,
'setup.keychip.placeholder': `新钥匙芯片 ID`
}
const zhSettings: typeof EN_REF_SETTINGS = {
'settings.title': '用户设置',
'settings.page-title': '${page} 设置',
'settings.tabs.profile': '个人资料',
'settings.tabs.global': '全局',
'settings.tabs.chu3': '中二',
'settings.tabs.mai2': '舞萌',
'settings.tabs.ongeki': '音击',
'settings.tabs.wacca': '华卡',
"settings.options.all.preset-warning": "您正在使用预设选项配置,部分选项可能显示不正确。",
"settings.options.all.personal-best": "记录",
"settings.options.all.rival-score": "对手",
"settings.options.all.life": "生命值",
"settings.options.all.push": "按压",
"settings.options.all.none": "无",
"settings.options.chu3.speed": "轨道速度",
"settings.options.chu3.speed_120": "轨道速度(120帧)",
"settings.options.chu3.trackSkip": "跳过曲目类型",
"settings.options.chu3.headphone": "耳机音量",
"settings.options.mai2.noteSpeed": "音符速度",
"settings.options.mai2.touchSpeed": "触控速度",
"settings.options.mai2.slideSpeed": "滑动速度",
"settings.options.mai2.headPhoneVolume": "耳机音量",
"settings.options.mai2.trackSkip": "跳过曲目类型",
'settings.fields.mai2UnlockMusic.name': '解锁谱面',
'settings.fields.mai2UnlockMusic.desc': '解锁所有曲目和大师难度谱面。',
'settings.fields.mai2UnlockChara.name': '解锁角色',
@@ -190,6 +252,10 @@ const zhSettings: typeof EN_REF_SETTINGS = {
'settings.fields.chusanTeamName.desc': '自定义显示在个人资料顶部的文本。',
'settings.fields.chusanInfinitePenguins.name': '我是桐谷遥',
'settings.fields.chusanInfinitePenguins.desc': '将角色界限突破的企鹅雕像数量设置为 999。',
'settings.fields.chusanUnlock29672999Ultima.name': '解锁 2967、2999 Ultima',
'settings.fields.chusanUnlock29672999Ultima.desc': '仅解锁曲目 2967、2999 的 Ultima 难度。不清楚用途请勿使用。',
'settings.fields.chusanLvUnlockAll.name': '解锁所有 Linked Gate',
'settings.fields.chusanLvUnlockAll.desc': '会触发一个很长的动画序列,且无法撤销',
'settings.fields.chusanMatchingReflector.name': '全国对战 Reflector',
'settings.fields.chusanMatchingReflector.desc': '全国对战服务器的 UDP 反射服务器的 URL',
'settings.fields.chusanMatchingServer.name': '全国对战服务器',
@@ -202,12 +268,15 @@ const zhSettings: typeof EN_REF_SETTINGS = {
'settings.fields.gameUsername.desc': '在游戏中显示的用户名',
'settings.fields.optOutOfLeaderboard.name': '不参与排行榜',
'settings.fields.optOutOfLeaderboard.desc': '登录之后还是可以在排行榜上看到自己',
"settings.fields.hideCountry.name": "隐藏注册国家",
"settings.fields.hideCountry.desc": "您的注册国家不会在个人资料中显示",
"settings.fields.displayCandidates.name": "显示推荐曲目",
"settings.fields.displayCandidates.desc": "在您的个人资料中显示可能提升您评分的曲目(如有)",
'settings.fields.enableMusicRank.name': '在你的机台上启用「推荐乐曲排行榜」',
'settings.fields.enableMusicRank.desc': '如果你自己设计了排行榜的话,可以关闭这个(会影响你自己的机器)。',
'settings.mai2.name': '玩家名字',
'settings.fields.enableMusicRank.desc': '如果你自己设计了排行榜的话,可以关闭这个(会影响你自己的机器)。',
'settings.profile.picture': '头像',
'settings.profile.upload-new': '上传',
'settings.profile.bad-format': '无效的图片格式,支持的格式有 PNG、JPG、JPEG、WEBP 和 GIF。',
'settings.profile.bad-format': '无效的图片格式,支持的格式有 PNG、JPG、JPEG、WEBP 和 GIF。',
'settings.profile.save': '保存',
'settings.profile.name': '昵称',
'settings.profile.username': '用户名',
@@ -218,9 +287,14 @@ const zhSettings: typeof EN_REF_SETTINGS = {
'settings.profile.unset': '未设置',
'settings.profile.logout': '登出',
'settings.profile.unchanged': '未更改',
'settings.profile.delete-title': '删除账户',
'settings.profile.delete-description': '下载备份后,永久删除您的账户及所有关联游戏数据。',
'settings.profile.delete': '删除账户',
'settings.profile.delete-confirm-title': '永久删除您的账户?',
'settings.profile.delete-confirm-message': '系统会先下载所有游戏数据的备份,然后永久删除您的账户及所有关联游戏数据。此操作无法撤销。',
'settings.export': '导出玩家数据',
'settings.batchManualExport': "导出 Batch Manual 格式(用于 Tachi)",
'settings.cabNotice': '注意:下面这些设置只会影响你自己的机器,如果你是在其他人的机器上玩的话,请联系机主来改设置',
'settings.cabNotice': '注意:下面这些设置只会影响你自己的机器,如果你是在其他人的机器上玩的话,请联系机主来改设置',
// AI
'settings.siteNotice': "这些设置仅适用于网站。",
// AI
@@ -237,6 +311,7 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
'userbox.header.general': '游戏设置',
'userbox.header.matching': '全国对战',
'userbox.header.matching.symbolChat': '全国对战聊天表情',
'userbox.header.linkedVerse': 'Linked Verse',
'userbox.header.userbox': 'UserBox 设置',
'userbox.header.preview': 'UserBox 预览',
'userbox.nameplateId': '名牌',
@@ -246,6 +321,7 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
'userbox.trophyIdSub2': '称号3',
'userbox.mapIconId': '地图图标',
'userbox.voiceId': '系统语音',
'userbox.stageId': '舞台',
'userbox.avatarWear': '企鹅服饰',
'userbox.avatarHead': '企鹅头饰',
'userbox.avatarFace': '企鹅面部',
@@ -266,8 +342,18 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
'userbox.matching.symbolChat': '表情选择',
'userbox.matching.symbolChat.default': '默认',
'userbox.lv.help': '关于 Linked Verse Difficulty 的信息',
'userbox.lv.diffnotice': '在 Linked Verse 中,你将根据难度分别进行匹配。',
'userbox.lv.difficulty': 'Linked Verse 难度',
'userbox.lv.difficulty.0': 'LEVEL ∞ (并非总是可用)',
'userbox.lv.difficulty.1': 'LEVEL V',
'userbox.lv.difficulty.2': 'LEVEL IV',
'userbox.lv.difficulty.3': 'LEVEL III',
'userbox.lv.difficulty.4': 'LEVEL II',
'userbox.lv.difficulty.5': 'LEVEL I',
'userbox.new.name': 'AquaBox',
'userbox.new.setup': '将中二(Lumi 或更高版本)的游戏文件夹拖放到下方区域,以显示带有名牌和头像的 UserBox。所有文件都在浏览器中处理。',
'userbox.new.setup': '将中二(Lumi 或更高版本)的游戏文件夹拖放到下方区域,以显示带有名牌和头像的 UserBox。所有文件都在浏览器中处理。',
'userbox.new.setup.notice': '选择包含游戏数据的最外层文件夹。',
'userbox.new.setup.processing_file': '正在处理文件',
'userbox.new.setup.finalizing': '正在保存到内部存储',
@@ -281,20 +367,20 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
'userbox.new.activate_desc': '启用后可显示带有名牌和头像的 UserBox',
'userbox.new.activate_profile': '在用户页面启用 AquaBox',
'userbox.new.activate_profile_desc': '启用后可在个人页面显示带有名牌和头像的 UserBox',
'userbox.new.error.invalidFolder': '所选文件夹无效。请确认游戏版本为 Lumi 或更新,并且包含 “A000” 选项包。',
'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.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.unbackuped.msg': '似乎还没有备份目标服务器的数据,真的要继续吗?(推荐先备份一下,因为迁移的时候会覆盖数据)',
'trans.confirm.untested.title': '不太聪明喵',
'trans.confirm.untested.msg': '在两个服务器上都测试完连接之后才能进行数据迁移哦!',
'trans.confirm.done.title': '完成!',
@@ -302,8 +388,8 @@ export const zhAquaTrans: typeof EN_REF_AQUATRANS = {
'trans.alert.in-progress': '在迁移了在迁移了',
'trans.prompt-html': `
<p>👋 欢迎使用 AquaTrans™ 服务器游玩数据迁移工具!</p>
<p>这个工具可以导出任意服务器的数据,并使用连接凭证(卡号、服务器地址和 Keychip ID)将数据导入任何其他服务器。</p>
<p>我将模拟游戏客户端,从源服务器拉取游戏数据并推送到目标服务器。</p>
<p>这个工具可以导出任意服务器的数据,并使用连接凭证(卡号、服务器地址和 Keychip ID)将数据导入任何其他服务器。</p>
<p>我将模拟游戏客户端,从源服务器拉取游戏数据并推送到目标服务器。</p>
<p>填写下面的表格开始迁移吧!</p>
`,
'trans.error.empty': '请填写所有字段。',
@@ -319,11 +405,11 @@ export const zhAquaTrans: typeof EN_REF_AQUATRANS = {
'trans.btn.test': '测试连接',
'trans.btn.export': '导出数据',
'trans.btn.import': '导入数据',
'trans.blacklist': "这个服务器的服主把这个导出工具 ban 了,所以不能从这里导出",
'trans.blacklist': "这个服务器的服主把这个导出工具 ban 了,所以不能从这里导出",
}
export const ZH = { ...zhUser, ...zhWelcome, ...zhGeneral,
...zhLeaderboard, ...zhHome, ...zhSettings, ...zhUserbox, ...zhMaiPhoto,
...zhAquaTrans
...zhAquaTrans, ...zhSetup
}
+6 -4
View File
@@ -29,7 +29,9 @@ const multTable = {
'chu3': [
[ 100.9, 215, 'SSS+' ],
[ 100.75, 200, 'SSS' ],
[ 100.50, 0, 'SS+' ],
[ 100.0, 0, 'SS' ],
[ 99.0, 0, 'S+' ],
[ 97.5, 0, 'S' ],
[ 95.0, 0, 'AAA' ],
[ 92.5, 0, 'AA' ],
@@ -80,11 +82,11 @@ export function getMult(achievement: number, game: GameName) {
}
export function roundFloor(achievement: number, game: GameName, digits = 2) {
// Round, but if the rounded number reaches the next rank, use floor instead
// Round, but if the rounded number reaches the next rank (or fake 101% AP+), use floor instead
const mult = getMult(achievement, game);
achievement /= 10000
const rounded = achievement.toFixed(digits);
if (getMult(+rounded * 10000, game)[2] === mult[2] && rounded !== '101.0') return rounded;
const original = achievement / 10000;
const rounded = original.toFixed(digits);
if (getMult(+rounded * 10000, game)[2] === mult[2] && (+rounded < 101 || original >= 101)) return rounded;
return (+rounded - Math.pow(10, -digits)).toFixed(digits);
}
+30 -6
View File
@@ -9,10 +9,21 @@ import type {
AquaNetUser, GameOption,
UserBox,
UserItem,
Dict
Dict,
GameUserOption
} from './generalTypes'
import type { GameName } from './scoring'
export type ExportGameName = GameName | 'diva'
export interface PhotoPage {
photos: string[]
page: number
pageSize: number
total: number
totalPages: number
}
interface ExtReqInit extends RequestInit {
params?: { [index: string]: string }
json?: any
@@ -25,7 +36,8 @@ interface ExtReqInit extends RequestInit {
* @param callback Callback for modification
*/
export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) => URL | void): RequestInfo | URL {
let u = new URL((input instanceof Request) ? input.url : input)
const base = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
let u = new URL((input instanceof Request) ? input.url : input, base)
const result = callback(u)
if (result) u = result
if (input instanceof Request) {
@@ -185,8 +197,12 @@ export const USER = {
ensureLoggedIn()
return post('/api/v2/user/me', {})
},
keychip: (): Promise<string> =>
post('/api/v2/user/keychip', {}).then(it => it.keychip),
keychips: (): Promise<string[]> =>
post('/api/v2/user/keychip', {}).then(it => it.keychips),
addKeychip: (keychipId: string): Promise<string> =>
post('/api/v2/user/keychip/add', { keychipId }).then(it => it.keychipId),
deleteKeychip: (keychipId: string) =>
post('/api/v2/user/keychip/delete', { keychipId }),
setting: (key: string, value: string) =>
post('/api/v2/user/setting', { key: key === 'password' ? 'pwHash' : key, value }),
uploadPfp: (file: File) => {
@@ -198,6 +214,8 @@ export const USER = {
ensureLoggedIn,
changeRegion: (regionId: number) =>
post('/api/v2/user/change-region', { regionId }),
deleteAccount: () =>
post('/api/v2/user/delete-account'),
}
export const USERBOX = {
@@ -223,7 +241,9 @@ export const CARD = {
export const GAME = {
trend: (username: string, game: GameName): Promise<TrendEntry[]> =>
post(`/api/v2/game/${game}/trend`, { username }),
photos: (): Promise<string[]> =>
photos: (page: number = 1, size: number = 12): Promise<PhotoPage> =>
post(`/api/v2/game/mai2/my-photo`, { page, size }),
allPhotos: (): Promise<string[]> =>
post(`/api/v2/game/mai2/my-photo`, { }),
userSummary: (username: string, game: GameName): Promise<GenericGameSummary> =>
post(`/api/v2/game/${game}/user-summary`, { username }),
@@ -231,7 +251,7 @@ export const GAME = {
post(`/api/v2/game/${game}/ranking`, typeof page === "number" ? { page } : {}),
changeName: (game: GameName, newName: string): Promise<{ newName: string }> =>
post(`/api/v2/game/${game}/change-name`, { newName }),
export: (game: GameName): Promise<Record<string, any>> =>
export: (game: ExportGameName): 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`, {}, { json: data }),
@@ -255,6 +275,10 @@ export const SETTING = {
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 }),
optionGet: (game: string): Promise<GameUserOption> =>
post(`/api/v2/game/${game}/user-option`),
optionSet: (game: string, field: string, value: number): Promise<void> =>
post(`/api/v2/game/${game}/user-option-set`, { field, value }),
}
export const TRANSFER = {
+4 -4
View File
@@ -24,10 +24,10 @@ function addOrUpdateItem(ini: string[], section: string, key: string, value: str
idx, keys: {}
};
} else {
let key = item.split("=")[0];
let key = item.split("=")[0].toLowerCase();
let value = item.split("=")[1];
if (!key || !value)
if (!key)
continue;
if (activeSection && sections[activeSection])
@@ -48,7 +48,7 @@ function addOrUpdateItem(ini: string[], section: string, key: string, value: str
}
export function injectNetworkData(baseIni: string, networkData: NetworkData): string {
let ini: string[] = baseIni.split("\n").map(i => i.replaceAll("\r", ""));
let ini: string[] = baseIni.split("\n").map(i => i.trim().replaceAll("\r", ""));
if (networkData.dns)
addOrUpdateItem(ini, "dns", "default", networkData.dns);
@@ -80,4 +80,4 @@ export async function patchUserSegatools(networkData: NetworkData): Promise<bool
} catch(err) {
return false;
}
}
}
+5 -5
View File
@@ -35,7 +35,7 @@ export function registerChart() {
)
}
const dayTemplate = (DateHelper) => {
const dayTemplate = (DateHelper: any) => {
const ROWS_COUNT = 7
const ALLOWED_DOMAIN_TYPE = ['month']
@@ -43,7 +43,7 @@ const dayTemplate = (DateHelper) => {
name: 'ghDayFix',
allowedDomainType: ALLOWED_DOMAIN_TYPE,
rowsCount: () => ROWS_COUNT,
columnsCount: (ts) => {
columnsCount: (ts: any) => {
let count = DateHelper.getWeeksCountInMonth(ts)
const endOfMonth = moment().endOf('month').toDate()
const clampEnd = DateHelper.getFirstWeekOfMonth(endOfMonth).toDate()
@@ -53,7 +53,7 @@ const dayTemplate = (DateHelper) => {
}
return count
},
mapping: (startTimestamp, endTimestamp) => {
mapping: (startTimestamp: any, endTimestamp: any) => {
const clampStart = DateHelper.getFirstWeekOfMonth(startTimestamp)
let clampEnd = DateHelper.getFirstWeekOfMonth(endTimestamp)
@@ -64,7 +64,7 @@ const dayTemplate = (DateHelper) => {
let x = -1
const pivotDay = clampStart.weekday()
return DateHelper.intervals('day', clampStart, clampEnd).map((ts) => {
return DateHelper.intervals('day', clampStart, clampEnd).map((ts: any) => {
const weekday = DateHelper.date(ts).weekday()
if (weekday === pivotDay) {
x += 1
@@ -77,7 +77,7 @@ const dayTemplate = (DateHelper) => {
}
})
},
extractUnit: (ts) => DateHelper.date(ts).startOf('day').valueOf(),
extractUnit: (ts: any) => DateHelper.date(ts).startOf('day').valueOf(),
}
}
+5 -7
View File
@@ -6,6 +6,7 @@
import { t } from "../libs/i18n";
import ImportDataAction from "./Home/ImportDataAction.svelte";
import DashboardTabs from "../components/DashboardTabs.svelte";
import * as acUrl from "../libs/acUrl";
USER.ensureLoggedIn();
@@ -13,6 +14,10 @@
let error = "";
USER.me().then((m) => me = m).catch(e => error = e.message)
if (acUrl.has()) {
location.href = "/cards"
}
</script>
<main class="content">
@@ -63,13 +68,6 @@
<style lang="sass">
@use "../vars"
.tabs
display: flex
gap: 1rem
div
&.active
color: vars.$c-main
h3
font-size: 1.3rem
@@ -65,10 +65,13 @@
const getGameByCode = (code: string) => {
switch (code?.toUpperCase()) {
case 'SDGA':
case 'SDEZ':
return 'mai2';
case 'SDHD':
return 'chu3';
case 'SDDT':
return 'ongeki';
default:
throw new Error(t('home.import.unknown-game'));
}
+19 -4
View File
@@ -9,6 +9,8 @@
import StatusOverlays from "../../components/StatusOverlays.svelte"
import { t } from "../../libs/i18n"
import DashboardTabs from "../../components/DashboardTabs.svelte";
import * as acUrl from "../../libs/acUrl"
import { ACCESSCODE_QKEY } from "../../libs/config";
// State
let state: 'ready' | 'linking-AC' | 'linking-SN' | 'loading' = "loading"
@@ -27,7 +29,10 @@
// Always put the ghost card at the top
m.cards.sort((a, b) => a.isGhost ? -1 : 1)
state = "ready"
linkQR()
}).catch(e => error = e.message)
acUrl.check()
updateMe()
// Data conflict overlay
@@ -49,12 +54,12 @@
if (linkingType === 'AC') inputAC = ""
if (linkingType === 'SN') inputSN = ""
} catch (e) {
setError(e.message, linkingType)
setError((e as any).message, linkingType!)
}
state = "ready"
}
let linkingType: 'AC' | 'SN' = null
let linkingType: 'AC' | 'SN' | null = null
async function link(type: 'AC' | 'SN') {
if (state !== 'ready' || accountCardSummary === null) return
state = "linking-" + type
@@ -192,7 +197,7 @@
function inputACChange() {
// Add spaces to the input
const cursorIndex = cursorPositionToCursorIndex(inputAC, elemInputAC.selectionStart, /\d/)
const cursorIndex = cursorPositionToCursorIndex(inputAC, elemInputAC.selectionStart ?? 0, /\d/)
inputAC = inputAC.replace(/\D/g, '').replace(/(.{4})/g, '$1 ').replace(/ $/, '')
const cursorPosition = cursorIndexToCursorPosition(inputAC, cursorIndex, /\d/)
setTimeout(() => elemInputAC.selectionStart = elemInputAC.selectionEnd = cursorPosition, 0)
@@ -212,7 +217,7 @@
function inputSNChange() {
// Add colons to the input
inputSN = inputSN.toUpperCase()
const cursorIndex = cursorPositionToCursorIndex(inputSN, inputElemSN.selectionStart, /[0-9A-F]/)
const cursorIndex = cursorPositionToCursorIndex(inputSN, inputElemSN.selectionStart ?? 0, /[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)
@@ -273,6 +278,16 @@
};
inputACChange();
}
function linkQR() {
if (!acUrl.has()) return;
inputAC = acUrl.get()
if (inputAC.length !== 20) return inputAC = "";
inputACChange()
acUrl.clear()
}
</script>
<main class="content">
+220 -48
View File
@@ -11,34 +11,100 @@
import { patchUserSegatools } from "../../libs/setup";
let user: AquaNetUser
let keychip: string;
let keychips: string[] = [];
let selectedKeychip: string = "";
let keychipCode: string;
let exposeKeychip = false;
let automaticSetupStatus: "none" | "success" | "failure" = "none";
let isLoading = true;
let isAdding = false;
let newKeychip = "";
let addKeychipError = "";
USER.me().then((u) => {
user = u;
USER.keychip().then(k => {
keychip = `${k.slice(0, 4)}-${k.slice(4)}1337`;
codeToHtml(`
function formatKeychipDisplay(k: string): string {
return `${k.slice(0, 4)}-${k.slice(4)}`;
}
function buildManualKeychipLines(): string {
return `id=${formatKeychipDisplay(selectedKeychip)}`;
}
async function buildKeychipCode() {
exposeKeychip = false;
const keychipLines = buildManualKeychipLines();
keychipCode = await codeToHtml(`
[dns]
default=${AQUA_CONNECTION}
[keychip]
enable=1
id=${keychip}`.trim(), {
lang: 'ini',
theme: 'rose-pine',
transformers: []
}).then((html) => {
keychipCode = html;
});
${keychipLines}`.trim(), {
lang: 'ini',
theme: 'rose-pine',
transformers: []
});
}
async function loadKeychips() {
isLoading = true;
keychips = await USER.keychips();
if (keychips.length > 0) {
selectedKeychip = keychips[0];
await buildKeychipCode();
} else {
selectedKeychip = "";
await buildKeychipCode();
}
isLoading = false;
}
USER.me().then((u) => {
user = u;
loadKeychips();
});
async function selectKeychip(k: string) {
selectedKeychip = k;
await buildKeychipCode();
}
async function addKeychip() {
const rawKeychipId = newKeychip.trim().toUpperCase();
const validRawFormat = /^([A-Z\d]{4}-[A-Z\d]{11}|[A-Z\d]{15})$/.test(rawKeychipId);
if (!validRawFormat) {
addKeychipError = "Invalid keychip format. Use 15 characters (with optional dash): A12345678901234 or A123-12345678901.";
return;
}
const keychipId = rawKeychipId.replace("-", "");
addKeychipError = "";
isAdding = true;
try {
const newId = await USER.addKeychip(keychipId);
keychips = [...keychips, newId];
selectedKeychip = newId;
newKeychip = "";
await buildKeychipCode();
} catch (error) {
addKeychipError = error instanceof Error ? error.message : "Failed to add keychip.";
} finally {
isAdding = false;
}
}
async function deleteKeychip(k: string) {
await USER.deleteKeychip(k);
keychips = keychips.filter(id => id !== k);
if (selectedKeychip === k) {
selectedKeychip = keychips[0] ?? "";
await buildKeychipCode();
}
}
async function patchSegatools() {
automaticSetupStatus = await patchUserSegatools({ keychip, dns: AQUA_CONNECTION }) ? "success" : "failure";
automaticSetupStatus = await patchUserSegatools({ keychip: formatKeychipDisplay(selectedKeychip), dns: AQUA_CONNECTION }) ? "success" : "failure";
}
</script>
@@ -47,45 +113,87 @@ id=${keychip}`.trim(), {
<div class="setup-instructions">
<h2>{t('home.setup')}</h2>
{#if keychip}
{#if isLoading}
<p>{t('loading')}</p>
{:else}
<div class="setup-step">
1. <div>{@html t('setup.steps.one')}</div>
</div>
<blockquote class="info">
{t('setup.keychip-warning')}
</blockquote>
{#if !!window.showOpenFilePicker}
{#if user.canModifyKeychips}
<details>
<summary>{t('setup.type.automatic')}</summary>
{@html t('setup.automatic')}
{#if automaticSetupStatus != "none"}
<blockquote class={`keychip-status ${automaticSetupStatus}`}>
{t(`setup.automatic.${automaticSetupStatus}`)}
</blockquote>
{/if}
<div class="setup-btn">
<button on:click={patchSegatools}>{t('setup.automatic.select')}</button>
<summary>{t('setup.keychip')}</summary>
<p>
{t('setup.keychip.warning')}
</p>
<div class="keychip-list">
{#each keychips as k}
<div class="keychip-item" class:selected={k === selectedKeychip}>
<button class="keychip-select" on:click={() => selectKeychip(k)}>
{formatKeychipDisplay(k)}
</button>
<button class="keychip-delete danger" on:click={() => deleteKeychip(k)}>
{t('setup.keychip.delete')}
</button>
</div>
{/each}
<form class="add-keychip-form" on:submit|preventDefault={addKeychip}>
<input
type="text"
placeholder={t('setup.keychip.placeholder')}
maxlength="16"
bind:value={newKeychip}
required
/>
<button class="add-keychip" type="submit" disabled={isAdding}>
{isAdding ? t('loading') : t('setup.keychip.add')}
</button>
</form>
{#if addKeychipError}
<p class="danger">{addKeychipError}</p>
{/if}
</div>
</details>
<div class="divider"></div>
{/if}
<details>
<summary>{t('setup.type.manual')}</summary>
{@html t('setup.manual')}
<div class="code-container">
<div class="code" class:revealed={exposeKeychip}>
{@html keychipCode}
{#if selectedKeychip}
{#if !!window.showOpenFilePicker}
<details>
<summary>{t('setup.type.automatic')}</summary>
{@html t('setup.automatic')}
{#if automaticSetupStatus != "none"}
<blockquote class={`keychip-status ${automaticSetupStatus}`}>
{t(`setup.automatic.${automaticSetupStatus}`)}
</blockquote>
{/if}
<div class="setup-btn">
<button on:click={patchSegatools}>{t('setup.automatic.select')}</button>
</div>
</details>
{/if}
<details>
<summary>{t('setup.type.manual')}</summary>
{@html t('setup.manual')}
<div class="code-container">
<div class="code" class:revealed={exposeKeychip}>
{@html keychipCode}
</div>
{#if !exposeKeychip}
<button class="reveal-btn" on:click={() => exposeKeychip = true}>
{t('setup.reveal-keychip')}
</button>
{/if}
</div>
{#if !exposeKeychip}
<button class="reveal-btn" on:click={() => exposeKeychip = true}>
{t('setup.reveal-keychip')}
</button>
{/if}
</div>
</details>
<br>
</details>
<br>
{/if}
<div class="setup-step">
2. <div>{@html t('setup.steps.two')}</div>
@@ -93,15 +201,21 @@ id=${keychip}`.trim(), {
<div class="setup-step">
3. <div>{@html t('setup.steps.three')}</div>
</div>
<div class="setup-step">
4. <div>{@html t('setup.steps.four')}</div>
</div>
<h2>{t('setup.troubleshooting.header')}</h2>
<ul>
<li>
{@html t('setup.troubleshooting.items.one')}
</li>
<li>
{@html t('setup.troubleshooting.items.two')}
</li>
<li>
{@html t('setup.troubleshooting.items.three')}
</li>
</ul>
<p>
{@html t('setup.support-info')}
</p>
{:else}
<p>{t('loading')}</p>
{/if}
</div>
</main>
@@ -111,6 +225,21 @@ id=${keychip}`.trim(), {
.code
overflow-x: auto
ul
li
margin: 0.75em 0
.divider
width: 90%
height: 1px
background: #fff3
margin: 1em 0
position: relative
left: 50%
transform: translate(-50%, 0)
:global(pre.shiki)
background-color: transparent !important
@@ -167,5 +296,48 @@ id=${keychip}`.trim(), {
top: 50%
left: 50%
transform: translate(-50%, -50%)
.keychip-list
display: flex
flex-direction: column
gap: 0.5em
margin: 1em 0
.keychip-item
display: flex
align-items: center
gap: 0.5em
padding: 0.25em 0.5em
border-radius: 4px
&.selected
background: vars.$c-shadow
.keychip-select
font-family: monospace
flex: 1
text-align: left
.add-keychip
align-self: flex-start
margin-top: 0.25em
.add-keychip-form
display: flex
flex-wrap: wrap
gap: 0.5em
align-items: center
margin-top: 0.25em
input
flex: 1 1 16rem
max-width: 16rem
button
flex: 1 1 10rem
max-width: 8rem
.danger
color: vars.$c-error
</style>
+98 -18
View File
@@ -1,11 +1,80 @@
<script lang="ts">
import {GAME} from "../libs/sdk";
import {AQUA_HOST} from "../libs/config";
<script lang="ts">
import { onMount } from "svelte";
import { GAME } from "../libs/sdk";
import type { PhotoPage } from "../libs/sdk";
import { AQUA_HOST } from "../libs/config";
import Loading from "../components/ui/Loading.svelte";
import Error from "../components/ui/Error.svelte";
import Pagination from "../components/Pagination.svelte";
import { t } from "../libs/i18n";
const token = localStorage.getItem("token")
let page = 1;
const pageSize = 12;
let photoData: PhotoPage | null = null;
let loading = true;
let error: any = null;
// Monotonic token so a slow response can never overwrite a newer one
let requestSeq = 0;
function readPageFromUrl(): number {
const raw = new URL(window.location.toString()).searchParams.get("page");
return raw ? parseInt(raw, 10) || 1 : 1;
}
/**
* Keep the url in sync with the page the server actually served, since
* out-of-range pages are clamped server-side.
*/
function syncUrl(effectivePage: number) {
const url = new URL(window.location.toString());
const current = url.searchParams.get("page");
const target = effectivePage > 1 ? effectivePage.toString() : null;
if (current === target) return;
if (target === null) url.searchParams.delete("page");
else url.searchParams.set("page", target);
history.replaceState({}, "", url.toString());
}
async function loadPhotos(targetPage: number) {
const seq = ++requestSeq;
// Apply the target immediately so the pager and further clicks act on it
// instead of on the page that is still being fetched
page = targetPage;
loading = true;
error = null;
try {
const data = await GAME.photos(targetPage, pageSize);
if (seq !== requestSeq) return;
photoData = data;
page = data.page;
syncUrl(data.page);
} catch (e) {
if (seq !== requestSeq) return;
error = e;
} finally {
if (seq === requestSeq) loading = false;
}
}
function handleUpdatePage(event: CustomEvent<number>) {
const newPage = event.detail;
if (newPage === page) return;
const url = new URL(window.location.toString());
url.searchParams.set("page", newPage.toString());
history.pushState({}, "", url.toString());
loadPhotos(newPage);
window.scrollTo({ top: 0, behavior: "smooth" });
}
onMount(() => {
loadPhotos(readPageFromUrl());
const onPopState = () => loadPhotos(readPageFromUrl());
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
});
</script>
<main class="content">
@@ -13,24 +82,31 @@
<h2>{t("maiphoto.title")}</h2>
</div>
{#await GAME.photos()}
{#if loading && !photoData}
<Loading/>
{:then photos}
{#if photos.length === 0}
{:else if error}
<Error {error}/>
{:else if photoData}
{#if photoData.total === 0}
<blockquote class="info">{t('maiphoto.none')}</blockquote>
{:else}
<blockquote class="info">{t('maiphoto.url_warning')}</blockquote>
{#if photoData.totalPages > 1}
<Pagination {page} totalPages={photoData.totalPages} on:updatePage={handleUpdatePage} />
{/if}
<div class="pictures" class:loading>
{#each photoData.photos as photo (photo)}
<div class="photo-container">
<img class="rounded-2xl" loading="lazy" src="{AQUA_HOST}/api/v2/game/mai2/my-photo/{photo}" alt="Mai Memorial Photo" />
</div>
{/each}
</div>
{#if photoData.totalPages > 1}
<Pagination {page} totalPages={photoData.totalPages} on:updatePage={handleUpdatePage} />
{/if}
{/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}
{/if}
</main>
<style lang="sass">
@@ -42,6 +118,10 @@
justify-content: center
row-gap: 1rem
gap: 1rem
transition: opacity 0.2s ease-in-out
&.loading
opacity: 0.5
.photo-container
flex: 1 1 300px
+24 -9
View File
@@ -3,15 +3,15 @@
import { title } from "../libs/ui";
import { GAME } from "../libs/sdk";
import type { GenericRanking } from "../libs/generalTypes";
import StatusOverlays from "../components/StatusOverlays.svelte";
import type { GameName } from "../libs/scoring";
import { GAME_TITLE } from "../libs/i18n";
import { t } from "../libs/i18n";
import UserCard from "../components/UserCard.svelte";
import Tooltip from "../components/Tooltip.svelte";
import Cap from "./Ranking/Cap.svelte";
import { DEFAULT_GAME } from "../libs/config";
export let game: GameName = 'mai2';
export let game: GameName = DEFAULT_GAME;
title(`Ranking`);
@@ -70,11 +70,18 @@
<span>{user.name}</span>
{/if}
</span>
<span class="rating">{
game === 'chu3' || game === 'ongeki' ?
(user.rating / 100).toFixed(2) :
user.rating.toLocaleString()
}</span>
{#if game == 'ongeki'}
<span class="rating">{
(user.rating / 1000).toFixed(3)
}</span>
{:else}
<span class="rating">{
game === 'chu3' ?
(user.rating / 100).toFixed(2) :
user.rating.toLocaleString()
}</span>
{/if}
<span class="accuracy">{(+user.accuracy).toFixed(2)}%</span>
<span class="fc">{user.fullCombo}</span>
<span class="ap">{user.allPerfect}</span>
@@ -86,9 +93,9 @@
<Tooltip triggeredBy=".name" loading={hoverLoading}>
<UserCard username={hoveringUser} {game} setLoading={l => hoverLoading = l} />
</Tooltip>
{:else}
<p>{error}</p>
{/if}
<StatusOverlays error={error} loading={!loadedPages} />
</main>
<style lang="sass">
@@ -141,6 +148,14 @@
.accuracy
display: none
.modern-rating
display: none
.legacy-rating
/* this is officially the worst css i've ever written dear god */
white-space: nowrap
overflow: hidden
text-overflow: clip
min-width: 3.5em !important
&.alternate
background-color: vars.$ov-light
+12 -1
View File
@@ -95,7 +95,9 @@
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}>
<button class="arrow-btn" on:click={startTransfer}>
<img src="{DATA_HOST}/d/DownArrow.png" alt="arrow">
</button>
</div>
<TransferServer bind:src={dst} bind:gameInfo={gameInfo} on:change={onChange}
@@ -115,6 +117,15 @@
&.disabled
filter: grayscale(1)
.arrow-btn
background: none
border: none
padding: 0
cursor: pointer
&:disabled
cursor: not-allowed
// CSS animation to let the image opacity breathe
img
animation: breathe 1s infinite alternate
@@ -107,7 +107,7 @@
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)} />
validate={v => /^([A-Z\d]{15}|[A-Z\d]{4}-[A-Z\d]{11})$/.test(v)} />
</div>
<!-- Second input line -->
+10 -2
View File
@@ -14,6 +14,7 @@
import UserSettings from "../../components/settings/UserSettings.svelte";
import type { Component } from "svelte";
import { EN_REF } from "../../libs/i18n/en_ref";
import type { GameName } from "../../libs/scoring";
USER.ensureLoggedIn()
@@ -30,15 +31,22 @@
if (!pages[page] && page)
error = t("404", {pathname: new URL(location.href).pathname});
USER.me().then(m => me = m)
let userGames: GameName[] = [];
USER.me().then(m => {
me = m
CARD.userGames(m.username).then(cards => userGames = Object.keys(cards).filter(k => !!cards[k as GameName]) as GameName[])
})
.catch(e => error = e.message)
</script>
<main class="content">
<div class="outer-title-options">
<h2>{t('settings.title')}</h2>
<nav>
{#each Object.entries(pages) as tab}
{#each Object.entries(pages).filter(v => v[0] == "profile" || v[0] == "global" || userGames.includes(v[0] as GameName)) as tab}
<a href={`/settings/${tab[0] != "profile" ? tab[0] : ""}`} transition:slide={{axis: 'x'}}
class:active={tab[0] == page || (tab[0] == "profile" && !page)} role="button" tabindex="0">
{ts(`settings.tabs.${tab[0]}`)}
+65 -34
View File
@@ -29,12 +29,11 @@
registerChart()
export let username: string;
export let game: GameName;
export let username: string = "";
export let game: GameName = "" as GameName;
let calElement: HTMLElement
let error: string;
let me: AquaNetUser
title(`User ${username}`)
const rounding = useLocalStorage("rounding", true);
interface MusicAndPlay extends MusicMeta, GenericGamePlaylog {}
@@ -51,6 +50,7 @@
let isLoading = false
let showMoreRecent = false
function init() {
USER.isLoggedIn() && USER.me().then(u => me = u)
@@ -77,11 +77,13 @@
GAME.userSummary(username, game),
GAME.trend(username, game),
DATA.allMusic(game),
]).then(([user, trend, music]) => {
]).then(async ([user, trend, music]) => {
console.log(user)
console.log(trend)
console.log(games)
title(` ${user.name} (@${username}) ・ ${GAME_TITLE[game]}`)
// If game is wacca, divide all ratings by 10
if (game === 'wacca') {
user.rating /= 10
@@ -161,7 +163,7 @@
<a href={`/u/${username}/${g}`} class:active={game === g}>{name}</a>
{/each}
{#if me && me.username === username}
{#if me && me.username.toLowerCase() === username.toLowerCase()}
<a class="setting-icon clickable" use:tooltip={t("UserHome.Settings")} href={`/settings/${game}`}>
<Icon icon="eos-icons:rotating-gear"/>
</a>
@@ -187,19 +189,31 @@
<div class="scoring-info">
<div class="chart">
<div class="info-top">
<div class="rating">
<span>{game === 'mai2' ? t("UserHome.DXRating"): t("UserHome.Rating")}</span>
<span>{
game === 'chu3' || game === 'ongeki' ?
(d.user.rating / 100).toFixed(2) :
d.user.rating.toLocaleString()
}</span>
</div>
<div class="rank">
<span>{t('UserHome.ServerRank')}</span>
<span>#{(d.user.serverRank + 1).toLocaleString()}</span>
</div>
{#if game == "ongeki" && d.user.ratingNotGeneric}
<div class="rating">
<span>{t("UserHome.Rating")}</span>
<span>{(d.user.rating / 1000).toFixed(3)}</span>
</div>
{:else}
<div class="rating">
<span>{game === 'mai2' ? t("UserHome.DXRating"): t("UserHome.Rating")}</span>
<!-- NOTE: this is for legacy ongeki display, so 1.45 profiles are at least usable -->
<span>{
game === 'chu3' || game === 'ongeki' ?
(d.user.rating / 100).toFixed(2) :
d.user.rating.toLocaleString()
}</span>
</div>
{/if}
{#if !isNaN(parseInt(d.user.serverRank))}
<div class="rank">
<span>{t('UserHome.ServerRank')}</span>
<span>#{(d.user.serverRank).toLocaleString()}</span>
</div>
{/if}
</div>
<div class="trend">
@@ -247,6 +261,23 @@
<span>{(d.user.accuracy).toFixed(2)}%</span>
</div>
{#if game == "ongeki" && d.user.ratingNotGeneric}
<div class="rating">
<span>{t("UserHome.HighestRating")}</span>
<span>{(d.user.ratingHighest / 1000).toFixed(3)}</span>
</div>
{:else}
<div class="rating">
<span>{game === 'mai2' ? t("UserHome.HighestDXRating"): t("UserHome.HighestRating")}</span>
<!-- NOTE: this is for legacy ongeki display, so 1.45 profiles are at least usable -->
<span>{
game === 'chu3' || game === 'ongeki' ?
(d.user.ratingHighest / 100).toFixed(2) :
d.user.ratingHighest.toLocaleString()
}</span>
</div>
{/if}
<div class="max-combo">
<span>{t("UserHome.MaxCombo")}</span>
<span>{d.user.maxCombo}</span>
@@ -306,18 +337,25 @@
</div>
</div>
<!-- I don't like doing this but it may be preferable to gaslighting the types -->
<RatingComposition title={t("UserHome.RatingComposition.Best", {n: 50})} comp={d.user.ratingComposition.best50} {allMusics} {game}/>
<RatingComposition title={t("UserHome.RatingComposition.Best", {n: 35})} comp={d.user.ratingComposition.best35} {allMusics} {game}/>
<RatingComposition title={t("UserHome.RatingComposition.Best", {n: 30})} comp={d.user.ratingComposition.best30} {allMusics} {game}/>
<RatingComposition title={t("UserHome.RatingComposition.Best", {n: 15})} comp={d.user.ratingComposition.best15} {allMusics} {game}/>
<RatingComposition title={t("UserHome.RatingComposition.New", {n : 15})} comp={d.user.ratingComposition.new15} {allMusics} {game}/>
<RatingComposition title={t("UserHome.RatingComposition.New", {n: 10})} comp={d.user.ratingComposition.new10} {allMusics} {game}/>
<RatingComposition title={t("UserHome.RatingComposition.Platinum", {n: 50})} comp={d.user.ratingComposition.pscore50} {allMusics} {game}/>
{#if me && me.displayCandidates && d.user.aquaUser && me.username == d.user.aquaUser.username}
<RatingComposition title={t("UserHome.RatingComposition.BestCandidates", {n: 20})} comp={d.user.ratingComposition.best20_candidates} {allMusics} {game}/>
<RatingComposition title={t("UserHome.RatingComposition.NewCandidates", {n: 10})} comp={d.user.ratingComposition.new10_candidates} {allMusics} {game}/>
<RatingComposition title={t("UserHome.RatingComposition.PlatinumCandidates", {n: 20})} comp={d.user.ratingComposition.pscore20_candidates} {allMusics} {game}/>
{/if}
<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}/>
<!-- <RatingComposition title="Hot 10" comp={d.user.ratingComposition.hot10} {allMusics} {game}/> -->
<!-- <RatingComposition title="N10" comp={d.user.ratingComposition.next10} {allMusics} {game}/> -->
<!-- Chuni -->
{#if d.user.ratingComposition.new}
<RatingComposition title="New 20" comp={d.user.ratingComposition.new} {allMusics} game="chu3"/>
<RatingComposition title={t("UserHome.RatingComposition.New", {n: 20})} comp={d.user.ratingComposition.new} {allMusics} game="chu3"/>
{:else}
<RatingComposition title="Recent 10" comp={d.user.ratingComposition.recent10} {allMusics} {game} top={10}/>
<RatingComposition title={t("UserHome.RatingComposition.Recent", {n: 10})} comp={d.user.ratingComposition.recent10} {allMusics} {game} top={10}/>
{/if}
<div class="recent">
@@ -494,7 +532,7 @@
.scoring-info
display: flex
gap: vars.$gap
max-height: 250px
max-height: 300px
.chart
flex: 0 1 790px
@@ -575,7 +613,7 @@
display: flex
align-items: center
gap: 20px
background-color: rgba(white, 0.03)
border-radius: vars.$border-radius
@@ -593,10 +631,6 @@
overflow: hidden
flex-direction: column
.first-line
display: flex
flex-direction: row
// Limit song name to one line
.song-title
max-width: 90%
@@ -612,9 +646,6 @@
flex-direction: column
gap: 0
.rank-text
text-align: left
// Recent Scores section
.recent
.scores
+18
View File
@@ -6,6 +6,14 @@
import { USER } from "../libs/sdk";
import { t } from "../libs/i18n"
import * as acUrl from "../libs/acUrl"
const hasAc = acUrl.has()
let previewAc = ""
if (hasAc) {
previewAc = acUrl.preview() || ""
}
let params = new URLSearchParams(window.location.search)
let state = "home"
@@ -178,6 +186,13 @@
<main id="home" class="no-margin">
<div>
<h1 id="title">AquaNet</h1>
<div id="welcome">
{#if hasAc}
<span>{t('welcome.login_link')}:</span><br>
<small>{previewAc}</small>
{/if}
</div>
{#if state === "home"}
<div class="btn-group" transition:slide>
<button on:click={() => state = 'login'}>{t('welcome.btn-login')}</button>
@@ -329,6 +344,9 @@
margin-bottom: 32px
opacity: 0.9
#welcome
margin-bottom: 32px
.btn-group
display: flex
gap: 8px
+14 -32
View File
@@ -1,37 +1,19 @@
# CONTRIBUTING
Thanks for your interest in improving Aqua server, and huge welcome! This document will guide you how to get started.
# Contribution
# Before you start
Thank you for your interest in contributing to the AquaDX server and/or AquaNet web interface implementation.
Please look over the following guidelines before creating an issue or pull request.
* We (or at least I, the fork maintainer) will not care where you come from or what kind of scene you involve with as long as you are in good faith.
* This fork might have n-0 or even n+1 support from time to time. If you can't agree with this, please refrain from get involve with this project.
* We **will not help you to get game itself**. This includes sharing a patched or original binary. Please don't ask or give guidance about it within this repository. This is bare minimum for our safety and long run possibility of this project.
- We try to support game versions that are n-0 (and occasionally n+1).<br>
By using this server and/or contributing source, you understand some features may be incomplete due to restrictions (whether it's technical or due to it being n-0).
# Getting started
- Issues should describe a problem or feature request for either AquaDX or AquaNet. <br>
Please do not ask for game dumps, ask for assistance with setting up a game dump or send links to game dumps.
* Please make sure you read README.md and documents in /docs folder.
* Make sure you have account for this gitlab instance.
- Pull requests should have meaningful descriptions and changes should match the rest of our codebase to be accepted.<br>
Commits can be formatted in any way preferable as long as they're easily digestable and meaningful.<br>
All pull requests must be tested and ensured functional.<br>
If contributing to the server (AquaDX), the server should be capable of booting to the colorful logo & affected clients should be able to connect and make use of your contribution(s).<br>
If contributing to the web interface (AquaNet), it should be capable of building, shouldn't report any errors and should make use of your contribution(s).
# Steps to follow
* Open a issue: if you found a bug or want to add or suggest new feature, try open a issue first so we can know about the problem and start discussion.
* Include some kind of information (for example: error log if a bug; what you trying to achieve if a new feature)
* This step is not essential but will make thing much easier later on. (PR)
* If you are end user (not a developer), it ends in here. Thanks!
* Fork it and make a new branch where you start working on it.
* Please make a branch per PR if you are thinking about making multiple PRs. Still, we can cherry-pick even it's not but..
* Start working
* We don't have any code formatting guide or rule. As long as code runs fine and readable enough, good to go.
* Make commits of logical and atomic units. Please DO NOT make a commit message too general like "WIP - 1" or "Work".
* Test if it works
* Database changes: default database testing is sqlite only. If you are not sure or can't test with MariaDB or MySQL, just leave a message about it in the PR.
* Game endpoint changes: Although aqua have testing process while building and packaging, this probably won't do much for the game so our recommendation is run through a game at least once (from entry to saving).
* Server API / feature / bug fix: Just test at least once the way you want.
* Make a PR
* Thanks! We will get you know when it's ready.
We appreciate your time. Contribution is what makes open source project interesting and keep working.
- AquaDX makes use of the [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en) license.<br>
By contributing, you agree your contributions can be placed under this license.
+7 -4
View File
@@ -1,7 +1,6 @@
# Use a multi-stage build to keep the image size small
# Start with a Gradle image for building the project
#FROM gradle:jdk21-alpine as builder
FROM gradle:8.8.0-jdk21 as builder
# Pin the builder so ARM64 deployments cannot reuse an older floating jdk25 image.
FROM gradle:9.6.1-jdk25 AS builder
# Copy the Gradle wrapper and configuration files separately to leverage Docker cache
COPY --chown=gradle:gradle gradlew /home/gradle/
@@ -16,16 +15,20 @@ RUN sed -i 's/\r$//' ./gradlew
# Download dependencies - cached if build.gradle.kts and settings.gradle.kts are unchanged
RUN chmod +x ./gradlew
RUN java -version
RUN ./gradlew dependencies
# Copy the project source, this layer is rebuilt whenever a file has changed
COPY --chown=gradle:gradle src /home/gradle/src
# Download game metadata (music/items lists)
RUN bash /home/gradle/src/main/resources/meta/update.sh
# Build the application
RUN ./gradlew build -x test
# Start with a fresh image for the runtime
FROM eclipse-temurin:21-jre-alpine
FROM eclipse-temurin:25-jre-alpine
# Set the deployment directory
WORKDIR /app
+10 -9
View File
@@ -32,21 +32,22 @@ Multipurpose game server for ALL.Net games.
> [!WARNING]
> CHUNITHM pre-NEW!! and maimai pre-DX are no longer supported after February 6th, 2026 and all associated data will be removed.
| Game | Latest Ver. | Initial Ver. | Notes | Web UI | Data Import |
|------------------------|---------------------|---------------------|-------------------------------------------------------------|--------|-------------|
| SDHD: CHUNITHM | 2.45 (X-VERSE-X) | 2.00 (NEW) | | ✅ | ❗ |
| SDEZ: maimai DX | 1.65 (CiRCLE PLUS) | 1.00 (DX) | Missing circle (teams) support | ✅ | ✅ |
| SDGA: maimai DX (Intl) | 1.60 (CiRCLE) | 1.00 (DX) | Thanks [@Clansty](https://github.com/clansty) | ✅ | ✅ |
| SDED: Card Maker | 1.39 | N/A | Thanks [@Becods](https://github.com/Becods) | ❌ | ❌ |
| SDDT: O.N.G.E.K.I. | 1.50 (Re:Fresh) | N/A | Thanks [@PenguinCaptain](https://github.com/PenguinCaptain) | ✅ | ❌ |
| SBZV: Project DIVA | 7.10 | N/A | Minimal support | ❌ | ❌ |
| SDFE: Wacca | 3.07 (Reverse) | N/A | Later versions are EOS patches, network will not work | ✅ | ❌ |
| Game | Latest Ver. | Initial Ver. | Notes | Web UI | Import | AES |
|------------------------|-----------------------|---------------------|-------------------------------------------------------------|--------|-------------|-----|
| SDHD: CHUNITHM | 2.50 (Mate) | 2.00 (NEW) | | ✅ | ✅ | ✅ |
| SDEZ: maimai DX | 1.65 (CiRCLE PLUS) | 1.00 (DX) | Thanks [@Menci](https://github.com/Menci) | ✅ | ✅ | ✅ |
| SDGA: maimai DX (Intl) | 1.60 (CiRCLE) | 1.00 (DX) | Thanks [@Clansty](https://github.com/clansty) | ✅ | ✅ | ✅ |
| SDED: Card Maker | 1.39 | N/A | Thanks [@Becods](https://github.com/Becods) | ❌ | ❌ | ❌ |
| SDDT: O.N.G.E.K.I. | 1.60 (Re:Fresh Act 2) | N/A | Thanks [@PenguinCaptain](https://github.com/PenguinCaptain) | ✅ | ✅ | ❌ |
| SBZV: Project DIVA | 7.10 | N/A | Minimal support | ❌ | ❌ | 🔓 |
| SDFE: Wacca | 3.07 (REVERSE) | N/A | Later versions are EOS patches | ✅ | ❌ | 🔓 |
<!-- A majority of them have been left as N/A for initial version as they do not appear to have restrictions -->
Check out these docs for more information.
* [Game specific notes](docs/game_specific_notes.md)
* [Frequently asked questions](docs/frequently_asked_questions.md)
* [Encryption notes](docs/encryption.md)
## Usage
+66 -54
View File
@@ -2,9 +2,10 @@
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
val ktVer = "2.1.10"
val ktVer = "2.4.0"
java
kotlin("jvm") version ktVer
@@ -13,9 +14,9 @@ plugins {
kotlin("plugin.serialization") version ktVer
kotlin("plugin.allopen") version ktVer
kotlin("kapt") version ktVer
id("org.springframework.boot") version "3.2.3"
id("com.github.ben-manes.versions") version "0.51.0"
id("org.hibernate.orm") version "6.4.4.Final"
id("org.springframework.boot") version "4.1.0"
id("com.github.ben-manes.versions") version "0.54.0"
id("org.hibernate.orm") version "7.4.4.Final"
application
}
@@ -26,6 +27,9 @@ repositories {
mavenCentral()
}
extra["netty.version"] = "4.2.16.Final"
extra["kotlin-coroutines.version"] = "1.11.0"
dependencies {
// Spring boot
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
@@ -34,28 +38,30 @@ dependencies {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-tomcat")
}
implementation("org.springframework.boot:spring-boot-starter-jetty")
implementation("io.netty:netty-all")
implementation("org.apache.commons:commons-lang3:3.14.0")
implementation("org.apache.httpcomponents.client5:httpclient5")
implementation("org.flywaydb:flyway-core:10.10.0")
implementation("org.flywaydb:flyway-mysql:10.10.0")
implementation(enforcedPlatform("io.netty:netty-bom:4.2.16.Final"))
implementation("io.netty:netty-all:4.2.16.Final")
implementation("org.apache.commons:commons-lang3:3.20.0")
implementation("org.apache.httpcomponents.client5:httpclient5:5.6.2")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-core:12.10.0")
implementation("org.flywaydb:flyway-mysql:12.10.0")
testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
}
testImplementation("org.springframework.security:spring-security-test")
implementation("net.logstash.logback:logstash-logback-encoder:7.4")
implementation("net.logstash.logback:logstash-logback-encoder:9.0")
// Metrics
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")
// Database
runtimeOnly("org.mariadb.jdbc:mariadb-java-client:3.3.3")
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")
runtimeOnly("org.mariadb.jdbc:mariadb-java-client:3.5.9")
runtimeOnly("org.xerial:sqlite-jdbc:3.53.2.0")
implementation("org.hibernate.orm:hibernate-core:7.4.4.Final")
implementation("org.hibernate.orm:hibernate-community-dialects:7.4.4.Final")
implementation("io.github.openfeign.querydsl:querydsl-jpa:7.4.0")
kapt("io.github.openfeign.querydsl:querydsl-apt:7.4.0:jpa")
// JSR305 for nullable
implementation("com.google.code.findbugs:jsr305:3.0.2")
@@ -65,50 +71,57 @@ dependencies {
// =============================
// Network
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("io.ktor:ktor-client-core:3.5.1")
implementation("io.ktor:ktor-client-cio:3.5.1")
implementation("io.ktor:ktor-client-content-negotiation:3.5.1")
implementation("io.ktor:ktor-client-encoding:3.5.1")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.5.1")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// Somehow these are needed for ktor even though they're not in the documentation
runtimeOnly("org.reactivestreams:reactive-streams:1.0.4")
runtimeOnly("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.0")
runtimeOnly("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.11.0")
// Email
implementation("org.simplejavamail:simple-java-mail:8.6.3")
implementation("org.simplejavamail:spring-module:8.6.3")
implementation("org.simplejavamail:simple-java-mail:9.0.1")
implementation("org.simplejavamail:spring-module:9.0.1")
// GeoIP
implementation("com.maxmind.geoip2:geoip2:4.2.0")
implementation("com.maxmind.geoip2:geoip2:5.1.0")
// JWT Authentication
implementation("io.jsonwebtoken:jjwt-api:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5")
implementation("io.jsonwebtoken:jjwt-api:0.13.0")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0")
// Content validation
implementation("org.apache.tika:tika-core:2.9.1")
implementation("org.apache.tika:tika-core:3.3.1")
// Import: DateTime Parsing
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0")
// Serialization
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// Serialization (Jackson 3; JSR-310 support is built into jackson-databind 3.x)
implementation("tools.jackson.module:jackson-module-kotlin:3.2.1")
// Testing
testImplementation("io.kotest:kotest-runner-junit5-jvm:5.8.1")
testImplementation("io.kotest:kotest-assertions-core")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.2.2")
testImplementation("io.kotest:kotest-assertions-core:6.2.2")
}
group = "icu.samnya"
version = "1.0.0"
description = "AquaDX Arcade Server"
java.sourceCompatibility = JavaVersion.VERSION_21
java {
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
toolchain {
languageVersion.set(JavaLanguageVersion.of(25))
}
}
kotlin {
jvmToolchain(21)
jvmToolchain(25)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_25)
}
}
springBoot {
@@ -119,14 +132,6 @@ application {
mainClass = "icu.samnyan.aqua.EntryKt"
}
hibernate {
enhancement {
enableLazyInitialization = true
enableAssociationManagement = false
enableExtendedEnhancement = false
}
}
kapt {
includeCompileClasspath = false
keepJavacAnnotationProcessors = true
@@ -138,29 +143,36 @@ allOpen {
annotation("jakarta.persistence.Embeddable")
}
val buildTime: String by extra(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(ZoneId.of("UTC")).format(Instant.now()))
val buildTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(ZoneId.of("UTC")).format(Instant.now())
val projectVersion = version.toString()
extra["buildTime"] = buildTime
tasks.processResources {
filesMatching("**/application.properties") {
expand(project.properties)
expand(
mapOf(
"version" to projectVersion,
"ext" to mapOf("buildTime" to buildTime),
)
)
}
}
tasks.test {
enabled = project.hasProperty("runTests")
enabled = providers.gradleProperty("runTests").isPresent
useJUnitPlatform()
jvmArgs("-Dkotest.assertions.collection.print.size=100")
}
tasks.withType<JavaCompile> {
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
}
tasks.withType<Javadoc> {
tasks.withType<Javadoc>().configureEach {
options.encoding = "UTF-8"
}
tasks.getByName<Jar>("jar") {
tasks.named<Jar>("jar") {
enabled = false
}
@@ -170,12 +182,12 @@ sourceSets {
}
}
val copyDependencies by tasks.registering(Copy::class) {
val copyDependencies = tasks.register<Copy>("copyDependencies") {
from(configurations.runtimeClasspath)
into("${layout.buildDirectory.get()}/libs/lib")
}
val packageThin by tasks.registering(Jar::class) {
tasks.register<Jar>("packageThin") {
group = "build"
from(sourceSets.main.get().output)
manifest {
+2 -1
View File
@@ -97,6 +97,7 @@ spring.datasource.hikari.maximum-pool-size=10
## Link card limit
aqua-net.link-card-limit=10
aqua-net.keychip-limit=20
## CloudFlare Turnstile Captcha
## This enables captcha for user registration.
@@ -141,4 +142,4 @@ aqua-net.bot.enabled=true
aqua-net.bot.secret=hunter2
## OpenAI Settings (For content moderation)
aqua-net.openai.api-key=sk-1234567890abcdef1234567890abcdef
aqua-net.openai.api-key=sk-1234567890abcdef1234567890abcdef
+1 -1
View File
@@ -1,7 +1,7 @@
FROM archlinux:latest
RUN pacman -Syu --noconfirm \
&& pacman -S --noconfirm openssh sudo jdk21-openjdk wget which procps-ng zsh git curlie micro ripgrep python3 exa \
&& pacman -S --noconfirm openssh sudo jdk25-openjdk wget which procps-ng zsh git curlie micro ripgrep python3 exa \
&& rm -rf /var/cache/pacman/pkg/* \
&& mkdir -p /var/run/sshd \
&& chsh -s /bin/zsh root \
+1
View File
@@ -1,5 +1,6 @@
services:
app:
build: .
image: hykilpikonna/aquadx:latest
ports:
- "80:80"
+40 -4
View File
@@ -17,10 +17,46 @@
| 11 | Frame |
| 12 | Tickets |
| 13 | Mile |
| 14 | Intimate Item |
| 15 | Kaleidx Scope Key |
## Multiplayer
| 14 | Intimate Item |
| 15 | Kaleidx Scope Key |
## Kaleidx Scope gates
Kaleidx Scope gate visibility is controlled by three server responses:
* `GetGameEventApi` must return open event rows for the gate/course event IDs used by the game's static data.
* `GetGameKaleidxScopeApi` must return a `gameKaleidxScopeList` row for each gate the user can see. Current known gate IDs are `1..6` for the original gates, `7` for Prism/Center Tower, `8` for the post-Prism boss gate, `9` for Hope Gate, and `10` for the final challenge.
* `GetUserKaleidxScopeApi` must return a `userKaleidxScopeList` row for the same `gateId`.
The client hides a gate when the user row is missing, the event is closed, or the row has `isGateFound = false`. A visible selectable gate needs at least:
```json
{
"gateId": 8,
"isGateFound": true,
"isKeyFound": true,
"isClear": false
}
```
The unlock announcement can still appear when `isGateFound = true`, `isKeyFound = true`, and `isInfoWatched = false`; that announcement alone does not prove that the gate will be listed in the menu on the next download.
For progression past Prism Tower, `UpsertUserAllApi` must persist `upsertUserAll.userKaleidxScopeList` by `(userId, gateId)`. AquaDX unlocks Prism/Center Tower gate `7` after gate `6` is clear. After gate `7` is clear, the next `GetUserKaleidxScopeApi` response must include gate `7` as clear and a gate `8` row with both found/key true. The same pattern applies for gates `9` and `10` after their preceding gates are clear.
Fixed AquaDX.v1 issue: older code advertised gates `1..10` from `GetGameKaleidxScopeApi` and saved uploaded Kaleidx rows in `UpsertUserAllApi`, but `GetUserKaleidxScopeApi` only backfilled missing rows for gates `1..6`. It also forced every persisted row to `isKeyFound = true` before returning it. Existing users without usable rows for gate `7` or later could therefore receive an unlock-style message but still have no visible Prism Tower or post-Prism gate in the Kaleidx Scope menu.
To confirm the failure for a user, inspect their rows:
```sql
SELECT gate_id, is_gate_found, is_key_found, is_clear, is_info_watched
FROM maimai2_user_kaleidx
WHERE user_id = <internal_user_id>
ORDER BY gate_id;
```
If gate `7` is clear and gate `8` is missing, or if any target gate row has `is_gate_found = 0`, the menu will not show the next gate.
## Multiplayer
### Party Host/Client/Member
+35
View File
@@ -0,0 +1,35 @@
# Encryption Support
AquaDX supports encryption for Chunithm, Maimai and Ongeki.<br>
It is not required to connect and keys are NOT provided by default.
Card Maker has encryption features in the client, but they are not supported due to it's complexity.
## Adding new keys to AquaDX
> [!IMPORTANT]
> Ensure you're using AquaDX from source. Docker will work, as long as you use `docker-compose up --build`
1. Create a new file at `src/main/resources/data/game/<game name>/game_encryption.json`
2. Fill it out using the following schema:
```json
{
"code": "SDHD",
"versions": [215],
"key": "DECAFBADDECAFBADDECAFBADDECAFBADDECAFBADDECAFBADDECAFBADDECAFBAD",
"iv": "DECAFBADDECAFBADDECAFBADDECAFBAD",
"salt": "DECAFBADDECAFBAD",
"iterations": 36
},
```
**`key`, `salt`, and `iv` MUST be hex strings. Do NOT insert raw ASCII strings.**<br>
Replace `code` with the correct code for your game (`SDHD` for Chunithm JP, `SDEZ` for Maimai JP, etc.).<br>
Set `versions` to be an array of game versions without the decimal (215 for 2.15, 150 for 1.50, etc.).<br>
You must include the rom major version. Minor versions will automatically round down to use the major key.
- Maimai DX does NOT use iterations, you do not need to include it
- Ongeki always uses 64 iterations (you must include it)
- If using a specific Python script to rip Chunithm keys, focus on the `=>` entries.
- `key` is 32 bytes long. `iv` is 16 bytes long. `salt` is 8 bytes long. (hex strings should be twice as long as they are in bytes)
+16 -21
View File
@@ -3,34 +3,35 @@
## Chunithm (Chusan)
### Required patches
* No encryption & TLS
* None
<sub>※ No encryption & No TLS is required if you do not have encryption keys set for your game version (supported on public instance)</sub>
### Additional notes
* Class/Dan and National Matching modes will work after playing the first game
* Class/Dan, National Matching and LINKED VERSE 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.
* National Matching is supported out of the box. Services provided by [Yukiotoko](https://gitea.tendokyu.moe/CharaLol/Yukiotoko)
* For user box and LINKED VERSE customization, use the AquaNet website.
* All LINKED VERSE gates are available by default and can be played sequentially. Original condition flags are disregarded due to implementation complexity (and low overall interest).
* Many aspects of the game may not work in freeplay mode, this is not a server-side restriction.
## Maimai DX
### Required patches
* No TLS
* No certificate pinning
* No URI obfuscation
* No encryption
### Non-working features
* KOP related
* Tournament mode
<sub>※ No encryption & No TLS is required if you do not have encryption keys set for your game version (supported on public instance)</sub>
### Additional notes
* KOP related features and tournament mode do not work
* All KALEID×SCOPE gates are intended to be open but may be broken
## O.N.G.E.K.I
### Required patches
* No TLS
* No certificate pinning
* No URI obfuscation
* No encryption
<sub>※ No encryption & No TLS is required if you do not have encryption keys set for your game version (supported on public instance)</sub>
### Non-working features
* KOP related
@@ -39,16 +40,10 @@
## Card Maker
### Required patches
* No TLS
* No encryption
### Support status
* Chunithm New: Yes, New (2.00)
* Maimai DX: Yes, Universe (1.20)
* O.N.G.E.K.I: No
* No TLS & Encryption (Not implemented as it has three (four?) sets of keys across several games, not worth the effort)
### Additional notes
* Only stated version above are supported.
* Server does not consider gacha rarity and probability weight during card draw.
* Server returns same hard-coded serial for each cards. This is intentional behavior.
* Due to its high correlation with every game endpoints, this may cease to work after major game version up.
* Not priority and may be out of date, broken or even removed in the future due to low overall usage (by player count on public instance)
+2 -2
View File
@@ -41,10 +41,10 @@ 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.
You need to install JDK 25 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)
![](sqlite-sucks.png)
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+4
View File
@@ -2,4 +2,8 @@
* This file was generated by the Gradle 'init' task.
*/
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "AquaDX"
+9 -2
View File
@@ -32,6 +32,7 @@ import java.time.ZoneOffset.UTC
import java.time.format.DateTimeFormatter
import java.util.*
import java.util.concurrent.locks.Lock
import kotlin.math.floor
import kotlin.reflect.KCallable
import kotlin.reflect.KClass
import kotlin.reflect.KMutableProperty1
@@ -111,8 +112,10 @@ catch (e: Exception) { 400 - e.message.toString() }
fun BodyBuilder.headers(vararg pairs: Pair<String, String>) = headers(HttpHeaders().apply { pairs.forEach { (k, v) -> set(k, v) } })
// Email validation
// Modified from:
// https://www.baeldung.com/java-email-validation-regex
val emailRegex = "^(?=.{1,64}@)[\\p{L}0-9_-]+(\\.[\\p{L}0-9_-]+)*@[^-][\\p{L}0-9-]+(\\.[\\p{L}0-9-]+)*(\\.[\\p{L}]{2,})$".toRegex()
// To include more special characters
val emailRegex = "^(?=.{1,64}@)[\\p{L}0-9_!#$%&*+/=?^`{}~-]+(\\.[\\p{L}0-9_!#$%&*+/=?^`{}~-]+)*@[^-][\\p{L}0-9-]+(\\.[\\p{L}0-9-]+)*(\\.[\\p{L}]{2,})$".toRegex()
fun Str.isValidEmail(): Bool = emailRegex.matches(this)
// Global Tools
@@ -197,6 +200,10 @@ val Any?.truthy get() = when (this) {
}
val Any?.str get() = toString()
fun truncateVersion(version: Number): Number {
return (floor(version.toFloat() / 5.0) * 5).toInt();
}
// Collections
fun <T> ls(vararg args: T) = args.toList()
inline fun <reified T> arr(vararg args: T) = arrayOf(*args)
@@ -275,4 +282,4 @@ 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) }
inline fun <reified T : Any> ApplicationContext.lazy() = kotlin.lazy { getBean(T::class.java) }
+88 -31
View File
@@ -1,45 +1,95 @@
package ext
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.*
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import tools.jackson.core.JsonParser
import tools.jackson.core.JsonToken
import tools.jackson.databind.DeserializationContext
import tools.jackson.databind.DeserializationFeature
import tools.jackson.databind.ObjectMapper
import tools.jackson.databind.PropertyNamingStrategies
import tools.jackson.databind.ValueDeserializer
import tools.jackson.databind.ext.javatime.ser.LocalDateTimeSerializer
import tools.jackson.databind.json.JsonMapper
import tools.jackson.databind.module.SimpleModule
import tools.jackson.module.kotlin.KotlinModule
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
// Jackson
val ACCEPTABLE_FALSE = setOf("0", "false", "no", "off", "False", "None", "null")
val ACCEPTABLE_TRUE = setOf("1", "true", "yes", "on", "True")
val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, object : JsonDeserializer<Boolean>() {
val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, object : ValueDeserializer<Boolean>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) = when(parser.text) {
in ACCEPTABLE_FALSE -> false
in ACCEPTABLE_TRUE -> true
else -> 400 - "Invalid boolean value ${parser.text}"
}
})
val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer<LocalDateTime>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) =
// 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)
setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL)
findAndRegisterModules()
registerModule(JSON_FUZZY_BOOLEAN)
registerModule(JSON_DATETIME)
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
propertyNamingStrategy = PropertyNamingStrategies.LOWER_CAMEL_CASE;
val JSON_DATETIME = SimpleModule()
.addSerializer(LocalDateTime::class.java, LocalDateTimeSerializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
.addDeserializer(LocalDateTime::class.java, object : ValueDeserializer<LocalDateTime>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext): LocalDateTime? {
parser.readLocalDateTimeArray(context)?.let { return it }
// First try standard formats via asDateTime() method
return 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}"
} }
}
})
fun JsonParser.readLocalDateTimeArray(context: DeserializationContext): LocalDateTime? {
if (!isExpectedStartArrayToken) return null
val parts = mutableListOf<Int>()
while (nextToken() != JsonToken.END_ARRAY) {
if (currentToken() != JsonToken.VALUE_NUMBER_INT) {
return context.reportInputMismatch(
LocalDateTime::class.java,
"Invalid legacy date-time array",
)
}
parts += intValue
}
if (parts.size !in 5..7) {
return context.reportInputMismatch(
LocalDateTime::class.java,
"Invalid legacy date-time array; expected 5 to 7 integer components",
)
}
return try {
LocalDateTime.of(
parts[0], parts[1], parts[2], parts[3], parts[4],
parts.getOrElse(5) { 0 }, parts.getOrElse(6) { 0 },
)
} catch (e: Exception) {
context.reportInputMismatch(
LocalDateTime::class.java,
"Invalid legacy date-time array: ${e.message}",
)
}
}
val JACKSON: JsonMapper = JsonMapper.builder()
.addModule(KotlinModule.Builder().build())
.findAndAddModules()
.addModule(JSON_FUZZY_BOOLEAN)
.addModule(JSON_DATETIME)
.changeDefaultPropertyInclusion {
JsonInclude.Value.construct(JsonInclude.Include.NON_NULL, JsonInclude.Include.NON_NULL)
}
.propertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build()
inline fun <reified T> ObjectMapper.parse(str: Str) = readValue(str, T::class.java)
inline fun <reified T> ObjectMapper.parse(map: Map<*, *>) = convertValue(map, T::class.java)
// TODO: https://stackoverflow.com/q/78197784/7346633
@@ -74,11 +124,18 @@ val JSON = Json {
coerceInputValues = true
}
// Bean for default jackson object mapper
//@Configuration
//class JacksonConfig {
// @Bean
// fun objectMapper(): ObjectMapper {
// return JACKSON
// }
//}
/**
* Expose the shared Jackson 3 mapper as the application JsonMapper.
*
* Spring Boot 4 auto-configures a tools.jackson JsonMapper WITHOUT the Kotlin module
* (Jackson2ObjectMapperBuilder's well-known-module registration no longer exists),
* which silently renamed all `is*` boolean fields in @RestController responses
* (e.g. isClear -> clear) and ignored Jackson 2 databind annotations.
* Providing our own JsonMapper bean makes Boot's JacksonAutoConfiguration back off
* (@ConditionalOnMissingBean), so the HTTP message converters use this mapper.
*/
@org.springframework.context.annotation.Configuration
class JacksonConfig {
@org.springframework.context.annotation.Bean
fun jsonMapper(): JsonMapper = JACKSON
}
+1 -1
View File
@@ -116,7 +116,7 @@ class BotController(
val gamesDict = listOfNotNull(chu3, mai2).map {
// Find the keychip owner
val keychip = it.lastClientId
val keychipOwner = keychip?.let { us.userRepo.findByKeychip(it) }
val keychipOwner = keychip?.let { k -> us.userKeychipRepo.findByKeychipId(k)?.user }
mapOf(
"userData" to it,
@@ -68,7 +68,7 @@ class CardController(
*/
@API("/link")
@Doc("Bind a card to the user. This action will migrate selected data from the card to the user's ghost card.", "Success message")
suspend fun link(@RP token: Str, @RP cardId: Str, @RP migrate: Str) = jwt.auth(token) { u ->
suspend fun link(@RP token: Str, @RP cardId: Str, @RP migrate: Str): Any = jwt.auth(token) { u ->
// Check if the user's card limit is reached
if (u.cards.size >= props.linkCardLimit) 400 - "Card limit reached"
+1 -1
View File
@@ -154,7 +154,7 @@ class Fedy(
forEach { (k, v) -> v?.let { GAME_OPTIONS_FIELDS[k]?.set(options, it) } }
}
us.userRepo.save(ru)
if (fields.containsKey("pwHash") ?: false) { us.clearAllSessions(ru) }
if (fields.containsKey("pwHash")) { us.clearAllSessions(ru) }
UserUpdateRes(user = ru.fedyBasicInfo())
} caught { UserUpdateRes(error = it) }
}
@@ -37,8 +37,10 @@ class SettingsApi(
@Doc("Set a field in the game options")
fun setField(@RP token: String, @RP key: String, @RP value: String) = us.jwt.auth(token) { u ->
val field = fieldMap[key] ?: (400 - "Invalid field $key")
val options = u.gameOptions ?: AquaGameOptions().also {
userRepo.save(u.apply { gameOptions = it })
var options = u.gameOptions
if (options == null) {
options = goRepo.save(AquaGameOptions())
userRepo.save(u.apply { gameOptions = options })
}
// Check field type
field.setCast(options, value)
@@ -2,20 +2,39 @@ package icu.samnyan.aqua.net
import ext.*
import icu.samnyan.aqua.net.components.*
import icu.samnyan.aqua.net.db.AquaNetUser
import icu.samnyan.aqua.net.db.AquaNetUserRepo
import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.net.db.EmailConfirmationRepo
import icu.samnyan.aqua.net.db.ResetPasswordRepo
import icu.samnyan.aqua.net.utils.AquaNetProps
import icu.samnyan.aqua.net.utils.PathProps
import icu.samnyan.aqua.net.utils.SUCCESS
import icu.samnyan.aqua.sega.allnet.UserKeychip
import icu.samnyan.aqua.sega.allnet.UserKeychipRepo
import icu.samnyan.aqua.sega.chusan.model.Chu3Repos
import icu.samnyan.aqua.sega.diva.DivaRepos
import icu.samnyan.aqua.sega.diva.model.db.userdata.PlayerProfile
import icu.samnyan.aqua.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.general.model.Card
import icu.samnyan.aqua.sega.general.model.CardStatus
import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos
import icu.samnyan.aqua.sega.ongeki.OngekiUserRepos
import icu.samnyan.aqua.sega.wacca.model.db.WaccaRepos
import jakarta.servlet.http.HttpServletRequest
import jakarta.transaction.Transactional
import org.slf4j.LoggerFactory
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import java.nio.file.Files
import java.nio.file.Path
import java.time.Instant
import kotlin.io.path.Path
import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists
import kotlin.io.path.name
import kotlin.io.path.writeBytes
@RestController
@@ -33,7 +52,10 @@ class UserRegistrar(
val cardRepo: CardRepository,
val validator: AquaUserServices,
val emailProps: EmailProperties,
final val paths: PathProps
val userKeychipRepo: UserKeychipRepo,
val aquaNetProps: AquaNetProps,
final val paths: PathProps,
val accountDeletion: AccountDeletionService,
) {
val portraitPath = paths.aquaNetPortrait.path()
@@ -195,9 +217,6 @@ class UserRegistrar(
// Check if the token is valid
if (confirmation == null) 400 - "Invalid token"
// Check if the token is expired
if (confirmation.createdAt.plusSeconds(60 * 60 * 24).isBefore(Instant.now())) 400 - "Token expired"
// Check if the email is already confirmed
val u = confirmation.aquaNetUser
if (u.emailConfirmed) 400 - "Email already confirmed"
@@ -235,20 +254,65 @@ class UserRegistrar(
SUCCESS
}
val keychipPattern = Regex("^([A-Z\\d]{4}-[A-Z\\d]{11}|[A-Z\\d]{15})$")
val keychipRange = 1e9.toULong()..1e10.toULong() - 1UL
private fun generateKeychipId(): String {
// 1337 is retained for backwards compatibility, it's no longer required for cabinet keychips
var keychip = "A" + keychipRange.random() + "1337"
while ( userKeychipRepo.existsByKeychipId(keychip) )
keychip = "A" + keychipRange.random() + "1337"
return keychip
}
private fun ensureCanModifyKeychips(u: AquaNetUser) {
if (!u.canModifyKeychips) 403 - "You don't have permission to modify keychips"
}
private fun validateCustomKeychip(keychipId: Str): Str {
val raw = keychipId.trim().uppercase()
if (!keychipPattern.matches(raw))
400 - "Invalid keychip format. Expected 11 or 15 characters (with optional dash)"
return raw.replace("-", "")
}
@API("/keychip")
@Doc("Get a Keychip ID so that the user can connect to the server.", "Success message")
suspend fun setupConnection(@RP token: Str) = jwt.auth(token) { u ->
u.keychip?.let { return mapOf("keychip" to it) }
log.info("Net: /user/keychip setup: ${u.auId} for ${u.username}")
@Doc("List all keychip IDs associated with the current user's account.", "List of keychip IDs")
suspend fun listKeychips(@RP token: Str) = jwt.auth(token) { u ->
val keychips = async { userKeychipRepo.findAllByUserAuId(u.auId) }
if (keychips.isEmpty() && !u.canModifyKeychips) {
val keychip = UserKeychip(0, u, generateKeychipId())
userKeychipRepo.save(keychip)
log.info("Net: Assigned keychip ${keychip.keychipId} to ${u.auId}")
}
mapOf("keychips" to keychips.map { it.keychipId })
}
// Generate a keychip id with 10 digits (e.g. A1234567890)
var new = "A" + keychipRange.random()
while (async { userRepo.findByKeychip(new) != null }) new = "A" + keychipRange.random()
async { userRepo.save(u.apply { keychip = new }) }
@API("/keychip/add")
@Doc("Add a custom keychip ID for the user.", "The newly added keychip ID")
suspend fun addKeychip(@RP token: Str, @RP keychipId: Str) = jwt.auth(token) { u ->
ensureCanModifyKeychips(u)
log.info("Net: /user/keychip/add: ${u.auId} for ${u.username}")
val validated = validateCustomKeychip(keychipId)
mapOf("keychip" to new)
if (async { userKeychipRepo.existsByKeychipId(validated) })
400 - "Keychip already exists"
if (userKeychipRepo.findAllByUserAuId(u.auId).size >= aquaNetProps.keychipLimit)
400 - "Exceeds maximum keychip count"
async { userKeychipRepo.save(UserKeychip(user = u, keychipId = validated)) }
mapOf("keychipId" to validated)
}
@API("/keychip/delete")
@Doc("Remove a keychip ID from the user's account.", "Success message")
suspend fun deleteKeychip(@RP token: Str, @RP keychipId: Str) = jwt.auth(token) { u ->
ensureCanModifyKeychips(u)
val deleted = async { userKeychipRepo.deleteByKeychipIdAndUserAuId(keychipId, u.auId) }
if (deleted == 0L) 404 - "Keychip not found"
SUCCESS
}
@API("/upload-pfp", consumes = ["multipart/form-data"])
@@ -285,5 +349,96 @@ class UserRegistrar(
}
SUCCESS
}
@API("/delete-account")
@Doc("Permanently delete the current user's account and all associated game data.", "Success message")
fun deleteAccount(@RP token: Str) = jwt.auth(token) { user ->
val cleanup = accountDeletion.deleteDatabaseData(user.auId)
accountDeletion.deleteUploadedFiles(cleanup)
SUCCESS
}
}
data class AccountFileCleanup(
val profilePicture: String?,
val gameUserId: Long,
val divaScreenshots: List<String>,
)
@Service
class AccountDeletionService(
val userRepo: AquaNetUserRepo,
val cardRepo: CardRepository,
val mai2: Mai2Repos,
val chu3: Chu3Repos,
val ongeki: OngekiUserRepos,
val wacca: WaccaRepos,
val diva: DivaRepos,
val paths: PathProps,
) {
companion object {
val log = logger()
}
@Transactional
fun deleteDatabaseData(auId: Long): AccountFileCleanup {
val user = userRepo.findByAuId(auId) ?: (404 - "User not found")
val ghostCard = user.ghostCard
val divaScreenshots = deleteDiva(ghostCard)
mai2.userData.findByCard(ghostCard)?.let { mai2.userData.delete(it) }
chu3.userData.findByCard(ghostCard)?.let { chu3.userData.delete(it) }
ongeki.data.findByCard(ghostCard)?.let { ongeki.data.delete(it) }
wacca.user.findByCard(ghostCard)?.let { wacca.user.delete(it) }
chu3.userLoginBonus.deleteAll(chu3.userLoginBonus.findByUser(ghostCard.extId.toInt()))
// Keep Hibernate's managed state consistent with the database-level ON DELETE SET NULL.
// Hibernate 7 rejects the flush if a managed card still references the deleted user.
val linkedCards = cardRepo.findAllByAquaUserAuId(auId)
linkedCards.forEach { it.aquaUser = null }
cardRepo.saveAllAndFlush(linkedCards)
userRepo.delete(user)
userRepo.flush()
cardRepo.delete(ghostCard)
cardRepo.flush()
log.info("Deleted account and game data for user $auId")
return AccountFileCleanup(user.profilePicture, ghostCard.extId, divaScreenshots)
}
private fun deleteDiva(card: Card): List<String> {
val profile: PlayerProfile = diva.profile.findByPdId(card.extId).orElse(null) ?: return emptyList()
val screenshots = diva.screenShot.findByPdId(profile)
diva.profile.delete(profile)
return screenshots.map { it.fileName }
}
fun deleteUploadedFiles(cleanup: AccountFileCleanup) {
try {
cleanup.profilePicture
?.takeIf { it.isNotBlank() && Path(it).name == it }
?.let { (Path(paths.aquaNetPortrait) / it).deleteIfExists() }
deleteFilesStartingWith(Path(paths.mai2Portrait), "${cleanup.gameUserId}-")
deleteFilesStartingWith(Path(paths.mai2Plays), "${cleanup.gameUserId}-")
deleteFilesStartingWith(Path("data/tmp"), "${cleanup.gameUserId}-")
cleanup.divaScreenshots
.filter { Path(it).name == it }
.forEach { (Path("data") / it).deleteIfExists() }
} catch (e: Exception) {
log.error("Failed to delete one or more uploaded files for deleted user ${cleanup.gameUserId}", e)
}
}
private fun deleteFilesStartingWith(directory: Path, prefix: String) {
if (!directory.exists()) return
Files.list(directory).use { files ->
files.filter { it.fileName.toString().startsWith(prefix) }
.forEach { it.deleteIfExists() }
}
}
}
@@ -1,12 +1,16 @@
package icu.samnyan.aqua.net.components
import jakarta.mail.Message
import ext.Bool
import ext.Str
import ext.logger
import icu.samnyan.aqua.net.db.*
import org.simplejavamail.api.email.Recipient
import org.simplejavamail.api.mailer.Mailer
import org.simplejavamail.email.EmailBuilder
import org.simplejavamail.springsupport.SimpleJavaMailSpringSupport
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.event.ApplicationStartedEvent
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Configuration
@@ -25,15 +29,19 @@ class EmailProperties {
var webHost: Str = "aquadx.net"
}
@Configuration
@ConditionalOnProperty(prefix = "aqua-net.email", name = ["enable"], havingValue = "true")
@Import(SimpleJavaMailSpringSupport::class)
class EmailMailerConfiguration
/**
* Email service. All email related operations should be placed here.
*
* Library Documentation: https://www.simplejavamail.org/
*/
@Service
@Import(SimpleJavaMailSpringSupport::class)
class EmailService(
val mailer: Mailer,
val mailerProvider: ObjectProvider<Mailer>,
val props: EmailProperties,
val confirmationRepo: EmailConfirmationRepo,
val resetPasswordRepo: ResetPasswordRepo,
@@ -44,13 +52,16 @@ class EmailService(
val resetTemplate: Str = this::class.java.getResource("/email/reset.html")?.readText()
?: throw Exception("Password Reset Template Not Found")
private fun mailer() = mailerProvider.getIfAvailable()
?: throw IllegalStateException("Email is enabled, but Simple Java Mail is not configured")
@Async
@EventListener(ApplicationStartedEvent::class)
fun test() {
if (!props.enable) return
try {
mailer.testConnection()
mailer().testConnection()
log.info("Email Service Connected")
} catch (e: Exception) {
log.error("Email Service Connection Failed", e)
@@ -71,9 +82,9 @@ class EmailService(
// Send email
log.info("Sending verification email to ${user.email}")
mailer.sendMail(EmailBuilder.startingBlank()
mailer().sendMail(EmailBuilder.startingBlank()
.from(props.senderName, props.senderAddr)
.to(user.computedName, user.email)
.withRecipients(Recipient(user.computedName, user.email, Message.RecipientType.TO, null))
.withSubject("Verify Your Email Address for AquaNet")
.withHTMLText(confirmTemplate
.replace("{{name}}", user.computedName)
@@ -94,9 +105,9 @@ class EmailService(
// Send email
log.info("Sending reset password email to ${user.email}")
mailer.sendMail(EmailBuilder.startingBlank()
mailer().sendMail(EmailBuilder.startingBlank()
.from(props.senderName, props.senderAddr)
.to(user.computedName, user.email)
.withRecipients(Recipient(user.computedName, user.email, Message.RecipientType.TO, null))
.withSubject("Reset Your Password for AquaNet")
.withHTMLText(resetTemplate
.replace("{{name}}", user.computedName)
@@ -108,9 +119,9 @@ class EmailService(
if (!props.enable) return
log.info("Sending test email to $addr")
mailer.sendMail(EmailBuilder.startingBlank()
mailer().sendMail(EmailBuilder.startingBlank()
.from(props.senderName, props.senderAddr)
.to(name, addr)
.withRecipients(Recipient(name, addr, Message.RecipientType.TO, null))
.withSubject("Test Email")
.withPlainText("This is a test email to check if AquaNet Email Works").buildEmail()).thenRun {
log.info("Test email sent to $addr")
@@ -77,7 +77,7 @@ class GeoIP(
{
return try {
val ipa = InetAddress.getByName(ip)
geoLite.country(ipa)?.country?.isoCode ?: ""
geoLite.country(ipa)?.country()?.isoCode() ?: ""
}
catch (e: AddressNotFoundException) { "" }
catch (e: Exception) {
@@ -85,4 +85,4 @@ class GeoIP(
""
}
}
}
}
@@ -1,7 +1,9 @@
package icu.samnyan.aqua.net.db
import jakarta.persistence.*
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.JpaRepository
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.springframework.stereotype.Repository
import java.io.Serializable
import java.time.Instant
@@ -20,9 +22,10 @@ class EmailConfirmation(
@Column(nullable = false)
var createdAt: Instant = Instant.now(),
// Linking to the AquaNetUser
@ManyToOne
@JoinColumn(name = "auId", referencedColumnName = "auId")
// Linking to the AquaNetUser
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "auId", referencedColumnName = "auId")
var aquaNetUser: AquaNetUser = AquaNetUser()
) : Serializable
@@ -30,4 +33,4 @@ class EmailConfirmation(
interface EmailConfirmationRepo : JpaRepository<EmailConfirmation, Long> {
fun findByToken(token: String): EmailConfirmation?
fun findByAquaNetUserAuId(auId: Long): List<EmailConfirmation>
}
}
@@ -2,6 +2,8 @@ package icu.samnyan.aqua.net.db
import jakarta.persistence.*
import org.springframework.data.jpa.repository.JpaRepository
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.springframework.stereotype.Repository
import java.io.Serializable
import java.time.Instant
@@ -22,6 +24,7 @@ class ResetPassword(
// Linking to the AquaNetUser
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "auId", referencedColumnName = "auId")
var aquaNetUser: AquaNetUser = AquaNetUser()
) : Serializable
@@ -30,4 +33,4 @@ class ResetPassword(
interface ResetPasswordRepo : JpaRepository<ResetPassword, Long> {
fun findByToken(token: String): ResetPassword?
fun findByAquaNetUserAuId(auId: Long): List<ResetPassword>
}
}
@@ -43,6 +43,9 @@ class AquaGameOptions(
@SettingField("chu3")
var chusanInfinitePenguins: Boolean = false,
@SettingField("chu3") @Column(name = "chusan_unlock_2967_2999_ultima")
var chusanUnlock29672999Ultima: Boolean = false,
@SettingField("chu3-matching")
var chusanMatchingServer: String = "",
@@ -2,6 +2,8 @@ package icu.samnyan.aqua.net.db
import jakarta.persistence.*
import org.springframework.data.jpa.repository.JpaRepository
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.springframework.stereotype.Repository
import java.io.Serializable
import java.time.Instant
@@ -22,6 +24,7 @@ class SessionToken(
// Linking to the AquaNetUser
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "auId", referencedColumnName = "auId")
var aquaNetUser: AquaNetUser = AquaNetUser()
) : Serializable
@@ -6,8 +6,9 @@ import icu.samnyan.aqua.net.UserRegistrar.Companion.cardExtIdEnd
import icu.samnyan.aqua.net.UserRegistrar.Companion.cardExtIdStart
import icu.samnyan.aqua.net.components.JWT
import icu.samnyan.aqua.sega.allnet.AllNetProps
import icu.samnyan.aqua.sega.allnet.KeyChipRepo
import icu.samnyan.aqua.sega.allnet.KeychipSession
import icu.samnyan.aqua.sega.allnet.UserKeychip
import icu.samnyan.aqua.sega.allnet.UserKeychipRepo
import icu.samnyan.aqua.sega.general.GameMusicPopularity
import icu.samnyan.aqua.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.general.model.Card
@@ -62,27 +63,32 @@ class AquaNetUser(
var profileBio: String? = "",
var profilePicture: String? = "",
var optOutOfLeaderboard: Boolean = false,
var hideCountry: Boolean = false,
var displayCandidates: Boolean = false,
// Email confirmation
var emailConfirmed: Boolean = false,
@OneToOne(cascade = [CascadeType.ALL])
@OneToOne(cascade = [CascadeType.PERSIST, CascadeType.MERGE])
@JoinColumn(name = "ghostCard", unique = true, nullable = false)
var ghostCard: Card = Card(),
// One user can have multiple cards
@OneToMany(mappedBy = "aquaUser", cascade = [CascadeType.ALL])
@OneToMany(mappedBy = "aquaUser", cascade = [CascadeType.PERSIST, CascadeType.MERGE])
var cards: MutableList<Card> = mutableListOf(),
// Each user can have one keychip (if the user owns a cabinet)
// Each user can have multiple keychips (if the user owns cabinets)
@JsonIgnore
@Column(nullable = true, length = 32, unique = true)
var keychip: Str? = null,
@OneToMany(mappedBy = "user", cascade = [CascadeType.PERSIST, CascadeType.MERGE])
var keychips: MutableList<UserKeychip> = mutableListOf(),
// Each user's keychip can have multiple sessions
@JsonIgnore
@OneToMany(mappedBy = "user", cascade = [CascadeType.ALL])
var keychipSessions: MutableList<KeychipSession> = mutableListOf(),
@OneToMany(mappedBy = "user", cascade = [CascadeType.PERSIST, CascadeType.MERGE])
var keychipSessions: MutableList<KeychipSession> = mutableListOf(),
@Column(nullable = false)
var canModifyKeychips: Boolean = false,
@OneToOne(cascade = [CascadeType.ALL])
@JoinColumn(name = "gameOptions", unique = true, nullable = true)
@@ -93,7 +99,7 @@ class AquaNetUser(
val publicFields get() = mapOf(
"username" to username,
"displayName" to displayName,
"country" to country,
"country" to if (hideCountry) "" else country,
"regTime" to regTime,
"profileLocation" to profileLocation,
"profileBio" to profileBio,
@@ -105,7 +111,6 @@ interface AquaNetUserRepo : JpaRepository<AquaNetUser, Long> {
fun findByAuId(auId: Long): AquaNetUser?
fun findByEmailIgnoreCase(email: String): AquaNetUser?
fun findByUsernameIgnoreCase(username: String): AquaNetUser?
fun findByKeychip(keychip: String): AquaNetUser?
fun findByGhostCardExtId(extId: Long): AquaNetUser?
}
@@ -124,7 +129,7 @@ class AquaUserServices(
val userRepo: AquaNetUserRepo,
val cardRepo: CardRepository,
val hasher: PasswordEncoder,
val keyChipRepo: KeyChipRepo,
val userKeychipRepo: UserKeychipRepo,
val allNetProps: AllNetProps,
val jwt: JWT,
val em: EntityManager,
@@ -144,7 +149,7 @@ class AquaUserServices(
fun create(username: Str, email: Str, password: Str, country: Str, emailConfirmed: Boolean = false): AquaNetUser {
// Create user
val u = AquaNetUser(
val user = AquaNetUser(
username = checkUsername(username),
email = validateEmail(email),
pwHash = checkPwHash(password),
@@ -158,16 +163,16 @@ class AquaUserServices(
luid = extId.toString()
registerTime = LocalDateTime.now()
accessTime = registerTime
aquaUser = u
aquaUser = user
isGhost = true
}
u.ghostCard = card
user.ghostCard = card
// Save the user
userRepo.save(u)
userRepo.save(user)
cardRepo.save(card)
return u
return user
}
fun update(user: AquaNetUser, key: Str, value: Str) {
@@ -192,7 +197,7 @@ class AquaUserServices(
fun validKeychip(keychipId: Str): Bool {
if (!allNetProps.checkKeychip) return true
if (keychipId.isBlank()) return false
if (userRepo.findByKeychip(keychipId) != null || keyChipRepo.existsByKeychipId(keychipId)) return true
if (userKeychipRepo.findByKeychipIdStartingWith(keychipId).truthy) return true
return false
}
@@ -216,21 +221,23 @@ class AquaUserServices(
400 - "User with username `$this` already exists"
}
fun validateEmail(email: Str) = email.apply {
// Check if email is valid
if (!isValidEmail()) 400 - "Invalid email"
// Check if user with the same email exists
if (userRepo.findByEmailIgnoreCase(email) != null)
400 - "User with email `$email` already exists"
}
fun checkPwHash(password: Str) = password.run {
// Validate password
if (length < 8) 400 - "Password must be at least 8 characters"
hasher.encode(this)
}
fun validateEmail(email: Str): Str {
// Check if email is valid
if (!email.isValidEmail()) 400 - "Invalid email"
// Check if user with the same email exists
if (userRepo.findByEmailIgnoreCase(email) != null)
400 - "User with email `$email` already exists"
return email
}
fun checkPwHash(password: Str): Str {
// Validate password
if (password.length < 8) 400 - "Password must be at least 8 characters"
return hasher.encode(password) ?: (500 - "Failed to hash password")
}
fun checkDisplayName(displayName: Str) = displayName.apply {
// Check if display name is valid
@@ -248,4 +255,6 @@ class AquaUserServices(
}
fun checkOptOutOfLeaderboard(optOutOfLeaderboard: Str) = optOutOfLeaderboard.toBoolean()
fun checkHideCountry(hideCountry: Str) = hideCountry.toBoolean()
fun checkDisplayCandidates(displayCandidates: Str) = displayCandidates.toBoolean()
}
@@ -4,6 +4,7 @@ import ext.*
import icu.samnyan.aqua.net.BotProps
import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.net.utils.SUCCESS
import icu.samnyan.aqua.sega.allnet.UserKeychipRepo
import icu.samnyan.aqua.sega.general.model.Card
import icu.samnyan.aqua.sega.general.service.CardService
import jakarta.annotation.PostConstruct
@@ -32,6 +33,7 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
open val gettableFields: Set<String> = setOf()
@Autowired lateinit var cardService: CardService
@Autowired lateinit var userKeychipRepo: UserKeychipRepo
@API("trend")
abstract suspend fun trend(@RP username: String): List<TrendOut>
@@ -44,10 +46,10 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
}
// List<Pair<should_hide, player>>>
private var rankingCache: List<Pair<Bool, GenericRankingPlayer>> = emptyList()
private var rankingCache: List<GenericRankingPlayer> = emptyList()
private var rankingCacheLock = ReentrantLock()
// Sorted index List<Rating> = Rank
private var rankingSortedIndex: List<Int> = emptyList()
private var rankingLookupCache: Map<Long, GenericRankingPlayer> = emptyMap()
private val pageSize = 100
@API("ranking")
@@ -55,34 +57,30 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
val time = millis()
// Check cache validity
if (rankingCache.isEmpty()) (500 - "Ranking is computing... please wait")
val reqUser = token?.let { us.jwt.auth(it) }?.let { u ->
// Optimization: If the user is not banned, we don't need to process user information
if (!u.ghostCard.rankingBanned && !u.cards.any { it.rankingBanned } && u.ghostCard.status.isNormal) null
else u
}
// Read from cache if we just computed it less than duration ago
// Shadow-ban: Do not show banned cards in the ranking except for the user who owns the card
val v = rankingCache.filter { !it.l || it.r.username == reqUser?.username }
.mapIndexed { i, it -> it.r.apply { rank = i + 1 } }
.also { logger.info("Ranking returned in ${millis() - time}ms") }
if (rankingCache.isEmpty()) (500 - "Rank is empty or is currently computing.")
return page?.let {
if (it < 0) (400 - "Invalid page number")
v.drop(it * pageSize).take(pageSize)
} ?: v
rankingCache.drop(it * pageSize).take(pageSize)
} ?: rankingCache
}
@PostConstruct
fun rakingCacheInit() = thread { rankingCacheRun() }
fun rankingCacheInit() = thread { rankingCacheRun() }
// Every 20 minutes
@Scheduled(fixedRate = 20, timeUnit = TimeUnit.MINUTES)
@Scheduled(fixedRate = 20, initialDelay = 20, timeUnit = TimeUnit.MINUTES)
fun rankingCacheRun() = rankingCacheLock.maybeLock { rankingCacheCompute() }
private val tableName = when (name) { "mai2" -> "maimai2"; "chu3" -> "chusan"; else -> name }
private val userDataTable = when (name) {
"mai2" -> "maimai2_user_detail"
"chu3" -> "chusan_user_data"
"ongeki" -> "ongeki_user_data"
else -> "wacca_user"
}
private val userCardColumn = if (name == "chu3") "card_id" else "aime_card_id"
fun rankingCacheCompute() {
val time = millis()
rankingCache = us.em.createNativeQuery(
@@ -90,23 +88,26 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
SELECT
c.id,
u.user_name,
u.player_rating,
${if (name == "ongeki") "u.new_player_rating" else "u.player_rating"} AS rating,
u.last_play_date,
AVG(p.achievement) / 10000.0 AS acc,
SUM(p.is_full_combo) AS fc,
SUM(p.is_all_perfect) AS ap,
c.ranking_banned or a.opt_out_of_leaderboard or c.status = 12 AS hide,
r.achievement_sum / NULLIF(r.play_count, 0) / 10000.0 AS acc,
r.full_combo_count AS fc,
r.all_perfect_count AS ap,
c.ranking_banned or COALESCE(a.opt_out_of_leaderboard, 0) or c.status = 12 AS hide,
a.username
FROM ${tableName}_user_playlog_view p
JOIN ${tableName}_user_data_view u ON p.user_id = u.id
JOIN sega_card c ON u.aime_card_id = c.id
FROM ${tableName}_user_ranking_cache r
JOIN $userDataTable u ON r.user_id = u.id
JOIN sega_card c ON u.$userCardColumn = c.id
LEFT JOIN aqua_net_user a ON c.net_user_id = a.au_id
GROUP BY p.user_id, u.player_rating
ORDER BY u.player_rating DESC;
WHERE r.play_count > 0
AND NOT (c.ranking_banned or COALESCE(a.opt_out_of_leaderboard, 0) or c.status = 12)
${if (name == "ongeki") "AND u.new_player_rating > 0" else "" /* Hide users on Ongeki 1.45 and below */}
ORDER BY rating DESC;
"""
).exec.mapIndexed { i, it ->
it[7].truthy to GenericRankingPlayer(
).setHint("jakarta.persistence.query.timeout", 60_000).exec.mapIndexed { i, it ->
GenericRankingPlayer(
rank = i + 1,
id = it[0]!!.long,
name = it[1].toString(),
rating = it[2]!!.int,
lastSeen = it[3].toString(),
@@ -116,7 +117,7 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
username = it[8]?.toString() ?: "user${it[0]}"
)
}
rankingSortedIndex = rankingCache.filter { !it.l }.map { it.r.rating }.reversed()
rankingLookupCache = rankingCache.associateBy { it.id }
logger.info("Ranking for $name computed in ${millis() - time}ms")
}
@@ -155,6 +156,10 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
userMusicRepo.findByUser_Card_ExtIdAndMusicIdIn(card.extId, musicList)
}
open fun getRating(user: T, isHighest: Bool): Int {
return if (isHighest) user.highestRating else user.playerRating;
}
fun genericUserSummary(card: Card, ratingComp: Map<String, String>, rival: Boolean? = null, favorites: List<Int>? = null): GenericGameSummary {
// Summary values: total plays, player rating, server-wide ranking
// number of each rank, max combo, number of full combo, number of all perfect
@@ -180,18 +185,14 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
}
}
// Find serverRank by binary-searching in the rankingSortedIndex to find the minimal index that
// is greater than or equal to the user's rating
var serverRank = rankingSortedIndex.binarySearch(user.playerRating).let { if (it < 0) -it - 1 else it + 1 }
serverRank = rankingSortedIndex.size - serverRank
return GenericGameSummary(
name = user.userName,
aquaUser = card.aquaUser?.publicFields,
serverRank = serverRank.long,
serverRank = rankingLookupCache[user.card!!.id]?.rank?.str ?: "-",
accuracy = plays.acc(),
rating = user.playerRating,
ratingHighest = user.highestRating,
rating = getRating(user, false),
ratingHighest = getRating(user, true),
ratingNotGeneric = getRating(user, false) != user.playerRating,
ranks = ranks.map { (k, v) -> RankCount(k, v) },
detailedRanks = detailedRanks,
maxCombo = plays.maxOfOrNull { it.maxCombo } ?: 0,
@@ -205,7 +206,7 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
lastVersion = user.lastRomVersion,
ratingComposition = ratingComp,
recent = plays.sortedBy { it.userPlayDate.toString() }.takeLast(100).reversed(),
lastPlayedHost = user.lastClientId?.let { us.userRepo.findByKeychip(it)?.username },
lastPlayedHost = user.lastClientId?.let { userKeychipRepo.findByKeychipId(it)?.user?.username },
rival = rival,
favorites = favorites
)
@@ -1,8 +1,6 @@
package icu.samnyan.aqua.net.games
import ext.isoDate
import ext.minus
import ext.mut
import ext.*
import java.time.LocalDate
const val LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
@@ -17,10 +15,10 @@ const val WACCA_USERNAME_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
"0123456789" +
"~|?!=()[]{},.:;\"@/_-+#*&%$"
fun usernameCheck(chars: String): (IUserData, String) -> Unit = { u, v ->
u.userName = v
fun usernameCheck(isWacca: Bool): (IUserData, String) -> Unit = { u, v ->
if (v.isBlank()) { 400 - "Username cannot be blank" }
if (v.length > 8) { 400 - "Username too long" }
u.userName = if (!isWacca) toFullWidth(v) else v
// v.find { it !in chars }?.let { 400 - "Invalid character '$it' in username" }
}
@@ -1,15 +1,18 @@
package icu.samnyan.aqua.net.games
import tools.jackson.core.JacksonException
import ext.*
import icu.samnyan.aqua.net.Fedy
import icu.samnyan.aqua.net.db.AquaNetUser
import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.net.utils.ApiException
import icu.samnyan.aqua.net.utils.AquaNetProps
import icu.samnyan.aqua.net.utils.SUCCESS
import icu.samnyan.aqua.sega.general.model.Card
import icu.samnyan.aqua.sega.general.service.CardService
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Lazy
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.repository.NoRepositoryBean
import org.springframework.transaction.PlatformTransactionManager
@@ -41,7 +44,7 @@ interface IExportClass<UserModel: IUserData> {
}
@NoRepositoryBean
interface IUserRepo<UserModel, ThisModel>: JpaRepository<ThisModel, Long> {
interface IUserRepo<UserModel : IUserData, ThisModel : Any>: JpaRepository<ThisModel, Long> {
fun findByUser(user: UserModel): List<ThisModel>
fun findSingleByUser(user: UserModel): ThisModel?
}
@@ -104,55 +107,87 @@ abstract class ImportController<ExportModel: IExportClass<UserModel>, UserModel:
export(u)
}
internal fun replaceInTransaction(existingUserData: UserModel?, auId: Long, insert: () -> Unit) {
trans.execute {
existingUserData?.also { gu ->
// After migration v1000.7, all user-linked entities have ON DELETE CASCADE.
log.info("$game Import: Replacing old data for user $auId")
userDataRepo.delete(gu)
userDataRepo.flush()
}
insert()
}
}
private fun parseImport(json: String): ExportModel = try {
json.parseJackson(exportClass.java)
} catch (e: Exception) {
val jsonError = generateSequence<Throwable>(e) { it.cause }
.filterIsInstance<JacksonException>()
.firstOrNull()
?: throw e
log.warn("Rejected invalid $game import: ${jsonError.message}")
400 - "Invalid import data: ${jsonError.originalMessage}"
}
@Suppress("UNCHECKED_CAST")
@API("import")
fun importUserData(@RP token: Str, @RB json: Str) = us.jwt.auth(token) { u ->
val export = json.parseJackson(exportClass.java)
if (!export.gameId.equals(game, true)) 400 - "Invalid game ID"
try {
val export = parseImport(json)
if (!export.gameId.equals(game, true)) 400 - "Invalid game ID"
val lists = listRepos.toList().associate { (f, r) -> r to f.get(export) as List<IUserEntity<UserModel>> }.vNotNull()
val singles = singleRepos.toList().associate { (f, r) -> r to f.get(export) as IUserEntity<UserModel> }.vNotNull()
var repoFieldMap = exportRepos.toList().associate { (f, r) -> r to f }
val lists = listRepos.toList().associate { (f, r) -> r to f.get(export) as List<IUserEntity<UserModel>> }.vNotNull()
val singles = singleRepos.toList().associate { (f, r) -> r to f.get(export) as IUserEntity<UserModel> }.vNotNull()
var repoFieldMap = exportRepos.toList().associate { (f, r) -> r to f }
// Validate new user data
// Check that all ids are 0 (this should be true since all ids are @JsonIgnore)
if (export.userData.id != 0L) 400 - "User ID must be 0"
lists.values.flatten().forEach { if (it.id != 0L) 400 - "ID must be 0" }
singles.values.forEach { if (it.id != 0L) 400 - "ID must be 0" }
// Validate new user data
// Check that all ids are 0 (this should be true since all ids are @JsonIgnore)
if (export.userData.id != 0L) 400 - "User ID must be 0"
lists.values.flatten().forEach { if (it.id != 0L) 400 - "ID must be 0" }
singles.values.forEach { if (it.id != 0L) 400 - "ID must be 0" }
// Set user card
export.userData.card = u.ghostCard
// Set user card
export.userData.card = u.ghostCard
// Check existing data
userDataRepo.findByCard(u.ghostCard)?.also { gu ->
// Store a backup of the old data
val fl = "${game}-backup-${u.auId}-${LocalDateTime.now().urlSafeStr()}.json"
(Path(netProps.importBackupPath) / fl).writeText(export(u).toJson())
// Delete the old data (After migration v1000.7, all user-linked entities have ON DELETE CASCADE)
log.info("$game Import: Deleting old data for user ${u.auId}")
userDataRepo.delete(gu)
userDataRepo.flush()
}
trans.execute {
// Insert new data
val nu = userDataRepo.save(export.userData)
// Set user fields
lists.values.flatten().forEach { it.user = nu }
singles.values.forEach { it.user = nu }
// Save new data
singles.forEach { (repo, single) -> (repo as IUserRepo<UserModel, Any>).save(single) }
lists.forEach { (repo, list) -> (repo as IUserRepo<UserModel, Any>).saveAll(list) }
// Handle custom importers
customImporters.forEach { (field, importer) ->
importer(export, nu)
// Back up existing data before starting the replacement transaction.
val existingUserData = userDataRepo.findByCard(u.ghostCard)
existingUserData?.also {
// Store a backup of the old data
val fl = "${game}-backup-${u.auId}-${LocalDateTime.now().urlSafeStr()}.json"
(Path(netProps.importBackupPath) / fl).writeText(export(u).toJson())
}
replaceInTransaction(existingUserData, u.auId) {
// Insert new data
val nu = userDataRepo.save(export.userData)
// Set user fields
lists.values.flatten().forEach { it.user = nu }
singles.values.forEach { it.user = nu }
// Save new data
singles.forEach { (repo, single) -> (repo as IUserRepo<UserModel, Any>).save(single) }
lists.forEach { (repo, list) -> (repo as IUserRepo<UserModel, Any>).saveAll(list) }
// Handle custom importers
customImporters.forEach { (field, importer) ->
importer(export, nu)
}
}
cardService.updateCardTimestamp(u.ghostCard, gameName, resetCreatedAt = true)
SUCCESS
} catch(e: Exception) {
if (e is ApiException) throw e
if (e is DataIntegrityViolationException) {
log.warn("Rejected conflicting $game import: ${e.message}")
400 - "Invalid import data: duplicate or conflicting records"
}
log.error(e.message, e)
500 - "Failed to import user data. More information can be found in the server logs (or contact an administrator for help if you do not have access)."
}
cardService.updateCardTimestamp(u.ghostCard, gameName, resetCreatedAt = true)
SUCCESS
}
/**
@@ -1,9 +1,9 @@
package icu.samnyan.aqua.net.games
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.module.SimpleModule
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import tools.jackson.databind.ValueDeserializer
import tools.jackson.databind.module.SimpleModule
import ext.JACKSON
import ext.minus
@@ -31,7 +31,7 @@ fun String.asSqlInsert(): SqlInsert {
}
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "UNCHECKED_CAST")
val JSON_INT_LIST_STR = SimpleModule().addDeserializer(List::class.java, object : JsonDeserializer<List<Integer>>() {
val JSON_INT_LIST_STR = SimpleModule().addDeserializer(List::class.java, object : ValueDeserializer<List<Integer>>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) =
try {
val text = parser.text.trim('[', ']')
@@ -41,6 +41,4 @@ val JSON_INT_LIST_STR = SimpleModule().addDeserializer(List::class.java, object
400 - "Invalid list value ${parser.text}: $e" }
})
val JACKSON_ARTEMIS = JACKSON.copy().apply {
registerModule(JSON_INT_LIST_STR)
}
val JACKSON_ARTEMIS = JACKSON.rebuild().addModule(JSON_INT_LIST_STR).build()
@@ -1,6 +1,7 @@
package icu.samnyan.aqua.net.games
import com.fasterxml.jackson.annotation.JsonIgnore
import ext.Bool
import ext.JACKSON
import ext.JavaSerializable
import icu.samnyan.aqua.sega.general.model.Card
@@ -25,10 +26,11 @@ data class GenericGameSummary(
val aquaUser: Map<String, Any?>?,
val serverRank: Long,
val serverRank: String,
val accuracy: Double,
val rating: Int,
val ratingHighest: Int,
val ratingNotGeneric: Bool,
val ranks: List<RankCount>,
val detailedRanks: Map<Int, Map<String, Int>>,
val maxCombo: Int,
@@ -59,7 +61,10 @@ data class GenericRankingPlayer(
val rating: Int,
val allPerfect: Int,
val fullCombo: Int,
val lastSeen: String
val lastSeen: String,
@JsonIgnore
val id: Long
)
@Serializable
@@ -3,6 +3,7 @@ package icu.samnyan.aqua.net.games.chu3
import ext.API
import ext.returns
import ext.vars
import icu.samnyan.aqua.net.games.ExportOptions
import icu.samnyan.aqua.net.games.IExportClass
import icu.samnyan.aqua.net.games.ImportClass
import icu.samnyan.aqua.net.games.ImportController
@@ -12,6 +13,7 @@ import icu.samnyan.aqua.sega.chusan.model.userdata.*
import org.springframework.web.bind.annotation.RestController
import kotlin.reflect.full.declaredMembers
@Suppress("UNCHECKED_CAST")
@RestController
@API("api/v2/game/chu3")
class Chu3Import(
@@ -22,7 +24,7 @@ class Chu3Import(
it.name.replace("List", "").lowercase()
},
exportRepos = Chu3DataExport::class.vars()
.filter { f -> f.name !in setOf("gameId", "userData") }
.filter { f -> f.name !in setOf("gameId", "userData", "userLoginBonusList") }
.associateWith { Chu3Repos::class.declaredMembers
.filter { f -> f returns Chu3UserLinked::class }
.firstOrNull { f -> f.name == it.name || f.name == it.name.replace("List", "") }
@@ -44,7 +46,22 @@ class Chu3Import(
// "chuni_profile_emoney" to ImportClass(UserEmoney::class),
// "chuni_profile_overpower" to ImportClass(UserOverpower::class),
// "chuni_profile_recent_rating" to ImportClass(UserRecentRating::class),
)
),
customExporters = mapOf(
Chu3DataExport::userLoginBonusList to { user: Chu3UserData, _: ExportOptions ->
repos.userLoginBonus.findByUser(user.card!!.extId.toInt())
},
) as Map<kotlin.reflect.KMutableProperty1<Chu3DataExport, Any>, (Chu3UserData, ExportOptions) -> Any?>,
customImporters = mapOf(
Chu3DataExport::userLoginBonusList to { export: Chu3DataExport, user: Chu3UserData ->
val userId = user.card!!.extId.toInt()
repos.userLoginBonus.deleteAll(repos.userLoginBonus.findByUser(userId))
repos.userLoginBonus.saveAll(export.userLoginBonusList.map { it.apply {
id = 0
this.user = userId
} })
},
) as Map<kotlin.reflect.KMutableProperty1<Chu3DataExport, Any>, (Chu3DataExport, Chu3UserData) -> Unit>,
) {
override fun createEmpty() = Chu3DataExport()
override val userDataRepo = repos.userData
@@ -64,7 +81,20 @@ data class Chu3DataExport(
var userMapList: List<UserMap>,
var userMusicDetailList: List<UserMusicDetail>,
var userPlaylogList: List<UserPlaylog>,
var userMateList: List<UserMate> = emptyList(),
var userGeneralDataList: List<UserGeneralData> = emptyList(),
var userMiscList: Chu3UserMisc = Chu3UserMisc(),
var userCardPrintStateList: List<UserCardPrintState> = emptyList(),
var userGachaList: List<UserGacha> = emptyList(),
var userRegionsList: List<UserRegions> = emptyList(),
var userCMissionList: List<UserCMission> = emptyList(),
var userCMissionProgressList: List<UserCMissionProgress> = emptyList(),
var netBattleLogList: List<Chu3NetBattleLog> = emptyList(),
var userChallengeList: List<Chu3UserChallenge> = emptyList(),
var userLinkedVerseList: List<Chu3UserLinkedVerse> = emptyList(),
var userVoteList: List<UserVote> = emptyList(),
var userLoginBonusList: List<UserLoginBonus> = emptyList(),
): IExportClass<Chu3UserData> {
constructor() : this("SDHD",
Chu3UserData(), UserGameOption(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList())
Chu3UserData(), UserGameOption(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), Chu3UserMisc())
}
@@ -33,7 +33,7 @@ class Chusan(
// Only show > AAA rank
override val shownRanks = chu3Scores.filter { it.first >= 95 * 10000 }
override val settableFields: Map<String, (Chu3UserData, String) -> Unit> by lazy { mapOf(
"userName" to usernameCheck(SEGA_USERNAME_CHARS),
"userName" to usernameCheck(false),
"nameplateId" to { u, v -> u.nameplateId = v.int },
"frameId" to { u, v -> u.frameId = v.int },
"trophyId" to { u, v -> u.trophyId = v.int },
@@ -0,0 +1,33 @@
package icu.samnyan.aqua.net.games.diva
import ext.*
import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.sega.diva.DivaRepos
import org.springframework.web.bind.annotation.RestController
@RestController
@API("api/v2/game/diva")
class DivaExport(
val us: AquaUserServices,
val repos: DivaRepos,
) {
@API("export")
fun exportUserData(@RP token: Str) = us.jwt.auth(token) { account ->
val profile = repos.profile.findByPdId(account.ghostCard.extId).orElse(null)
?: (404 - "User not found")
linkedMapOf(
"gameId" to "SBZV",
"userData" to profile,
"gameSession" to repos.gameSession.findByPdId(profile).orElse(null),
"playLogList" to repos.playLog.findByPdId(profile),
"playerContestList" to repos.contest.findByPdId(profile),
"playerCustomizeList" to repos.customize.findByPdId(profile),
"playerInventoryList" to repos.inventory.findByPdId(profile),
"playerModuleList" to repos.module.findByPdId(profile),
"playerPvCustomizeList" to repos.pvCustomize.findByPdId(profile),
"playerPvRecordList" to repos.pvRecord.findByPdId(profile),
"playerScreenShotList" to repos.screenShot.findByPdId(profile),
)
}
}
@@ -68,7 +68,7 @@ class Mai2Import(
?.split(",")
?.mapIndexed { index, id -> Mai2UserFavoriteItem().apply { orderId = index; this.id = id.toInt() } }
?: emptyList()
}
},
) as Map<kotlin.reflect.KMutableProperty1<Maimai2DataExport, Any>, (Mai2UserDetail, ExportOptions) -> Any?>,
customImporters = mapOf(
Maimai2DataExport::userPlaylogList to { export: Maimai2DataExport, user: Mai2UserDetail ->
@@ -85,7 +85,7 @@ class Mai2Import(
propertyValue = favoriteMusicList.sortedBy { it.orderId }.map { it.id }.joinToString(",")
})
}
}
},
) as Map<kotlin.reflect.KMutableProperty1<Maimai2DataExport, Any>, (Maimai2DataExport, Mai2UserDetail) -> Unit>
) {
override fun createEmpty() = Maimai2DataExport()
@@ -113,6 +113,9 @@ data class Maimai2DataExport(
var userFavoriteMusicList: List<Mai2UserFavoriteItem> = mutableListOf(),
var userKaleidxScopeList: List<Mai2UserKaleidx> = mutableListOf(),
var userPlaylogList: List<Mai2UserPlaylog> = mutableListOf(),
var userCardList: List<Mai2UserCard> = mutableListOf(),
var userPrintDetailList: List<Mai2UserPrintDetail> = mutableListOf(),
var userRegionsList: List<UserRegions> = mutableListOf(),
// Not supported yet:
// var userWeeklyData
// var userMissionDataList
@@ -14,9 +14,13 @@ import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserDetail
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserGeneralData
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserLoginBonus
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserOption
import jakarta.annotation.PostConstruct
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import kotlin.text.Charsets.UTF_8
import kotlin.jvm.optionals.getOrNull
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.full.memberProperties
@@ -38,7 +42,7 @@ class Maimai2(
// Only show > S rank
override val shownRanks = mai2Scores.filter { it.first >= 97 * 10000 }
override val settableFields: Map<String, (Mai2UserDetail, String) -> Unit> by lazy { mapOf(
"userName" to usernameCheck(SEGA_USERNAME_CHARS),
"userName" to usernameCheck(false),
"iconId" to { u, v -> u.iconId = v.int() },
"plateId" to { u, v -> u.plateId = v.int() },
"titleId" to { u, v -> u.titleId = v.int() },
@@ -58,7 +62,7 @@ class Maimai2(
val ratingComposition = mapOf(
"best35" to (extra["recent_rating"] ?: ""),
"best15" to (extra["recent_rating_new"] ?: "")
"new15" to (extra["recent_rating_new"] ?: "")
)
// if isLogin than boolean or null
@@ -207,23 +211,69 @@ class Maimai2(
}
val photoDir = UploadUserPhotoHandler.uploadDir.toFile().canonicalFile
val photoHashMap = ConcurrentHashMap<String, String>()
@API("my-photo")
suspend fun myPhoto(@RP token: Str) = us.jwt.auth(token) { u ->
val find = "${u.ghostCard.extId}-"
// creating a ton of SHA256 hashes every launch *probably* isn't ideal but it's better than exposing token AND extid...
@OptIn(ExperimentalStdlibApi::class)
fun myPhotoGetHash(value: Str): Str {
return MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(UTF_8)).toHexString()
}
@PostConstruct
fun myPhotoInit() = thread {
photoDir.listFiles()
?.map { it.name }
?.map {
// generate hash of photo filename as to not expose details
photoHashMap[it] = myPhotoGetHash(it)
}
}
@API("my-photo")
suspend fun myPhoto(
@RP token: Str,
@RP(required = false) page: Int?,
@RP(required = false) size: Int?
): Any = us.jwt.auth(token) { u ->
val find = "${u.ghostCard.extId}-"
val files = photoDir.listFiles()
?.map { it.name }
?.filter { it.startsWith(find) }
?.sorted()
?.sortedDescending()
?: emptyList()
if (page == null) {
files.map {
photoHashMap.computeIfAbsent(it) { f -> myPhotoGetHash(f) }
}
} else {
val pageSize = (size ?: 12).coerceIn(1, 100)
val total = files.size
val totalPages = if (total == 0) 0 else (total + pageSize - 1) / pageSize
// Clamp to the nearest valid page: out-of-range pages return the closest real page
// instead of an empty grid, and the offset can no longer overflow Int
val pageNum = page.coerceIn(1, maxOf(1, totalPages))
val pagedFiles = files.drop((pageNum - 1) * pageSize).take(pageSize)
val photos = pagedFiles.map {
photoHashMap.computeIfAbsent(it) { f -> myPhotoGetHash(f) }
}
mapOf(
"photos" to photos,
"page" to pageNum,
"pageSize" to pageSize,
"total" to total,
"totalPages" to totalPages
)
}
}
@API("my-photo/{fileName}", produces = [MediaType.IMAGE_JPEG_VALUE])
suspend fun myPhoto(@RP token: Str, @PV fileName: Str) = us.jwt.auth(token) { u ->
val f = (photoDir / fileName)
if (!f.canonicalFile.startsWith(photoDir)) (403 - "Never gonna give you up")
if (!f.name.startsWith("${u.ghostCard.extId}-")) (403 - "Not your photo")
if (!f.exists()) (404 - "Photo not found")
f.readBytes()
suspend fun accessMyPhoto(@PV fileName: Str): ByteArray {
val trueFileName = photoHashMap.entries.firstOrNull { it.value == fileName }?.key
if (trueFileName.isNullOrEmpty()) 404 - "Photo does not exist"
val file = (photoDir / trueFileName)
return file.readBytes()
}
}
@@ -1,6 +1,7 @@
package icu.samnyan.aqua.net.games.ongeki
import ext.API
import ext.Bool
import ext.RP
import ext.minus
import icu.samnyan.aqua.net.db.AquaUserServices
@@ -35,23 +36,39 @@ class Ongeki(
override val shownRanks = ongekiScores.filter { it.first >= 950000 }
override val settableFields: Map<String, (UserData, String) -> Unit> by lazy { mapOf(
"userName" to usernameCheck(SEGA_USERNAME_CHARS),
"userName" to usernameCheck(false),
"lastRomVersion" to { u, v -> u.lastRomVersion = v },
"lastDataVersion" to { u, v -> u.lastDataVersion = v },
) }
override suspend fun userSummary(username: String, token: String?) = us.cardByName(username) { card ->
val user = userDataRepo.findByCard_ExtId(card.extId) ?: (404 - "User not found")
val extra = userGeneralDataRepository.findByUser_Card_ExtId(card.extId)
.associate { it.propertyKey to it.propertyValue }
val ratingComposition = mapOf(
"best30" to (extra["rating_base_best"] ?: ""),
"best15" to (extra["rating_base_new_best"] ?: ""),
"recent10" to (extra["rating_base_hot_best"] ?: "")
)
val isRefresh = user.newHighestRating > 0
if (isRefresh)
genericUserSummary(card, mapOf(
"best50" to (extra["new_rating_base_best"] ?: ""),
"new10" to (extra["new_rating_base_new_best"] ?: ""),
"pscore50" to (extra["new_rating_base_pscore"] ?: ""),
"best20_candidates" to (extra["new_rating_base_next_best"] ?: ""),
"new10_candidates" to (extra["new_rating_base_new_next_best"] ?: ""),
"pscore20_candidates" to (extra["new_rating_base_next_pscore"] ?: "")
))
else
genericUserSummary(card, mapOf(
"best30" to (extra["rating_base_best"] ?: ""),
"new15" to (extra["rating_base_new_best"] ?: ""),
"recent10" to (extra["rating_base_hot_best"] ?: "")
))
}
genericUserSummary(card, ratingComposition)
override fun getRating(user: UserData, isHighest: Bool): Int {
return if (user.newHighestRating > 0) {
if (isHighest) user.newHighestRating else user.newPlayerRating
} else if (isHighest) user.highestRating else user.playerRating
}
@API("user-option")
@@ -0,0 +1,76 @@
package icu.samnyan.aqua.net.games.ongeki
import ext.API
import ext.returns
import ext.vars
import icu.samnyan.aqua.net.games.IExportClass
import icu.samnyan.aqua.net.games.ImportClass
import icu.samnyan.aqua.net.games.ImportController
import icu.samnyan.aqua.sega.ongeki.model.*
import icu.samnyan.aqua.sega.ongeki.OngekiRepos
import icu.samnyan.aqua.sega.ongeki.OngekiUserLinked
import icu.samnyan.aqua.sega.ongeki.OngekiUserRepos
import org.springframework.web.bind.annotation.RestController
import kotlin.reflect.full.declaredMembers
@RestController
@API("api/v2/game/ongeki")
class OngekiImport(
val repos: OngekiUserRepos,
) : ImportController<OngekiDataExport, UserData>(
"SDDT", "ongeki", OngekiDataExport::class,
exportFields = OngekiDataExport::class.vars().associateBy {
it.name.replace("List", "").lowercase()
},
exportRepos = OngekiDataExport::class.vars()
.filter { f -> f.name !in setOf("gameId", "userData") }
.associateWith { OngekiUserRepos::class.declaredMembers
.filter { f -> f returns OngekiUserLinked::class }
.firstOrNull { f -> f.name == it.name
|| f.name == (it.name.substring(4, 5).lowercase() + it.name.substring(5)).replace("List", "") // strip user
|| f.name == it.name.replace("List", "")
}
?.call(repos) as OngekiUserLinked<*>? ?: error("No matching field found for ${it.name}")
},
artemisRenames = mapOf() // TODO (almost nobody uses this so it's very low priority)
) {
override fun createEmpty() = OngekiDataExport()
override val userDataRepo = repos.data
}
data class OngekiDataExport(
override var gameId: String = "SDDT",
override var userData: UserData,
var userActivityList: List<UserActivity>,
var userBossList: List<UserBoss>,
var userCardList: List<UserCard>,
var userChapterList: List<UserChapter>,
var userCharacterList: List<UserCharacter>,
var userDeckList: List<UserDeck>,
var userEventMusicList: List<UserEventMusic>,
var userEventPointList: List<UserEventPoint>,
var userGeneralDataList: List<UserGeneralData>,
var userItemList: List<UserItem>,
var userKopList: List<UserKop>,
var userLoginBonusList: List<UserLoginBonus>,
var userMemoryChapterList: List<UserMemoryChapter>,
var userMissionPointList: List<UserMissionPoint>,
var userMusicDetailList: List<UserMusicDetail>,
var userMusicItemList: List<UserMusicItem>,
var userOption: UserOption,
var userPlaylogList: List<UserPlaylog>,
var userRivalList: List<UserRival>,
var userScenarioList: List<UserScenario>,
var userStoryList: List<UserStory>,
var userTechCountList: List<UserTechCount>,
var userTechEventList: List<UserTechEvent>,
var userTradeItemList: List<UserTradeItem>,
var userTrainingRoomList: List<UserTrainingRoom>,
var userEventMapList: List<UserEventMap>,
var userSkinList: List<UserSkin>,
var userRegionsList: List<UserRegions>,
var userGachaList: List<UserGacha>,
): IExportClass<UserData> {
constructor() : this("SDDT", UserData(), ArrayList(), ArrayList(), ArrayList(), ArrayList(),ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(),ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(),ArrayList(), ArrayList(), UserOption(), ArrayList(), ArrayList(),ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(),ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList())
}
@@ -1,16 +1,10 @@
package icu.samnyan.aqua.net.games.wacca
import ext.API
import ext.RP
import ext.isoDate
import ext.utc
import ext.*
import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.net.games.*
import icu.samnyan.aqua.net.utils.waccaScores
import icu.samnyan.aqua.sega.wacca.model.db.WaccaUser
import icu.samnyan.aqua.sega.wacca.model.db.WcUserBestScoreRepo
import icu.samnyan.aqua.sega.wacca.model.db.WcUserPlayLogRepo
import icu.samnyan.aqua.sega.wacca.model.db.WcUserRepo
import icu.samnyan.aqua.sega.wacca.model.db.*
import org.springframework.web.bind.annotation.RestController
@RestController
@@ -20,9 +14,10 @@ class Wacca(
override val playlogRepo: WcUserPlayLogRepo,
override val userDataRepo: WcUserRepo,
override val userMusicRepo: WcUserBestScoreRepo,
val repos: WaccaRepos,
): GameApiController<WaccaUser>("wacca", WaccaUser::class) {
override val settableFields: Map<String, (WaccaUser, String) -> Unit> by lazy { mapOf(
"userName" to usernameCheck(WACCA_USERNAME_CHARS),
"userName" to usernameCheck(true),
"lastRomVersion" to { u, v -> u.lastRomVersion = v },
) }
@@ -40,5 +35,38 @@ class Wacca(
genericUserSummary(card, mapOf(), null, data?.favoriteSongs)
}
@API("export")
fun exportUserData(@RP token: Str) = us.jwt.auth(token) { account ->
val user = repos.user.findByCard(account.ghostCard) ?: (404 - "User not found")
WaccaDataExport(
userData = user,
userOptionList = repos.option.findByUser(user),
userBingoList = repos.bingo.findByUser(user),
userFriendList = repos.friend.findByUser(user).map {
WaccaFriendExport(it.with.id, it.isAccepted)
},
userGateList = repos.gate.findByUser(user),
userItemList = repos.item.findByUser(user),
userBestScoreList = repos.bestScore.findByUser(user),
userPlaylogList = repos.playLog.findByUser(user),
userStageUpList = repos.stageUp.findByUser(user),
)
}
override val shownRanks: List<Pair<Int, String>> = waccaScores.filter { it.first > 85 * 10000 }
}
data class WaccaFriendExport(val withUserId: Long, val isAccepted: Boolean)
data class WaccaDataExport(
val gameId: String = "SDFE",
val userData: WaccaUser,
val userOptionList: List<WcUserOption>,
val userBingoList: List<WcUserBingo>,
val userFriendList: List<WaccaFriendExport>,
val userGateList: List<WcUserGate>,
val userItemList: List<WcUserItem>,
val userBestScoreList: List<WcUserScore>,
val userPlaylogList: List<WcUserPlayLog>,
val userStageUpList: List<WcUserStageUp>,
)
@@ -8,7 +8,7 @@ import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder
import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder.decodeAllNetResp
import java.net.URI
val keychipPattern = Regex("([A-Z\\d]{4}-[A-Z\\d]{11}|[A-Z\\d]{11})")
val keychipPattern = Regex("([A-Z\\d]{4}-[A-Z\\d]{11}|[A-Z\\d]{15})")
class AllNetClient(val dns: String, val keychip: String, val game: String, val version: String, val card: String) {
init {
@@ -10,6 +10,7 @@ import kotlin.io.path.createDirectories
@ConfigurationProperties(prefix = "aqua-net")
class AquaNetProps {
var linkCardLimit: Int = 10
var keychipLimit: Int = 20
var importBackupPath = "data/import-backups"
@PostConstruct
@@ -46,7 +46,7 @@ class AimeDB(
keychipId = readPaddedString(12u) // 14 12b
)
fun ByteBuf.readPaddedString(maxLen: UInt) = readBytes(maxLen.toInt()).toString(US_ASCII).trimEnd('\u0000')
fun ByteBuf.readPaddedString(maxLen: UInt) = readCharSequence(maxLen.toInt(), US_ASCII).toString().trimEnd('\u0000')
data class Handler(val name: String, val fn: (ByteBuf) -> ByteBuf?)
@@ -96,7 +96,6 @@ class AimeDB(
}
}
@Deprecated("Deprecated in Netty 5") // TODO: Move this to ChannelInboundHandler
override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
logger.error("AimeDB: Error", cause)
ctx.close()
@@ -28,8 +28,11 @@ class AimeDbDecoder : ByteToMessageDecoder() {
// Create a byte array to store the encrypted data
val d = input.readBytes(length)
val result = AimeDbEncryption.decrypt(d)
d.release()
val result = try {
AimeDbEncryption.decrypt(d)
} finally {
d.release()
}
val resultMap = mapOf(
"type" to result.getShortLE(0x04).toInt(),
@@ -50,13 +53,16 @@ class AimeDbDecoder : ByteToMessageDecoder() {
private fun getLength(input: ByteBuf): Int = try {
val currentPos = input.readerIndex()
val result = AimeDbEncryption.decrypt(input)
try {
// Check the header
val header = result.getByte(0).toInt()
assert(header == 0x3e) { "AimeDB: Invalid header $header" }
// Check the header
val header = result.getByte(0).toInt()
assert(header == 0x3e) { "AimeDB: Invalid header $header" }
// Read the length from offset 6
result.getShortLE(currentPos + 6).toInt()
// Read the length from offset 6
result.getShortLE(currentPos + 6).toInt()
} finally {
result.release()
}
} catch (e: Exception) {
logger.info("AimeDB: Invalid request received")
logger.debug("AimeDB: Invalid request received: ${input.toString(Charsets.UTF_8)}")
@@ -4,7 +4,8 @@ import ext.logger
import io.netty.bootstrap.ServerBootstrap
import io.netty.channel.ChannelInitializer
import io.netty.channel.ChannelOption
import io.netty.channel.nio.NioEventLoopGroup
import io.netty.channel.MultiThreadIoEventLoopGroup
import io.netty.channel.nio.NioIoHandler
import io.netty.channel.socket.SocketChannel
import io.netty.channel.socket.nio.NioServerSocketChannel
import io.netty.handler.logging.LogLevel
@@ -38,7 +39,10 @@ class AimeDbServer(
if (!props.enable) return logger.info("Aime DB is disabled.")
val bootstrap = ServerBootstrap()
.group(NioEventLoopGroup(), NioEventLoopGroup())
.group(
MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()),
MultiThreadIoEventLoopGroup(NioIoHandler.newFactory())
)
.handler(LoggingHandler(LogLevel.DEBUG))
.channel(NioServerSocketChannel::class.java)
.childHandler(initializer)
@@ -1,7 +1,6 @@
package icu.samnyan.aqua.sega.allnet
import ext.*
import icu.samnyan.aqua.net.db.AquaNetUserRepo
import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder.decodeAllNet
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
@@ -23,6 +22,7 @@ class AllNetProps {
val keychipSesExpire: Long = 172800000 // milliseconds
var checkKeychip: Boolean = false
var keychipPermissiveForTesting: Boolean = false
var tls: Boolean = false
var redirect: String = "web"
var placeName: String = ""
@@ -59,9 +59,8 @@ class AllNetProps {
@Suppress("HttpUrlsUsage")
@RestController
class AllNet(
val userRepo: AquaNetUserRepo,
val userKeychipRepo: UserKeychipRepo,
val keychipSessionService: KeychipSessionService,
val keychipRepo: KeyChipRepo,
val props: AllNetProps
) {
@API("/")
@@ -106,10 +105,16 @@ class AllNet(
var session: String? = null
val gameId = reqMap["game_id"] ?: return "".also { logger.warn("> Rejected: No game_id provided") }
val ver = reqMap["ver"] ?: "1.0"
if ((ver.toDoubleOrNull() ?: 0.0) < 1.0)
return "".also { logger.warn("> Rejected: Version $ver is not allowed (typically bad ICF)") }
// Proper keychip authentication
if (props.checkKeychip) {
// If it's a user keychip, it should be in user database
val u = userRepo.findByKeychip(serial)
val u = findUserByIncomingSerial(serial)
if (u != null) {
// Create a new session for the user
logger.info("> Keychip authenticated: ${u.auId} ${u.computedName}")
@@ -120,11 +125,6 @@ class AllNet(
session = keychipSessionService.new(u, reqMap["game_id"] ?: "").token
}
// Check if it's a whitelisted keychip
else if (!serial.isEmpty() && keychipRepo.existsByKeychipId(serial)) {
session = keychipSessionService.new(null, reqMap["game_id"] ?: "").token
}
else if (props.keychipPermissiveForTesting) {
logger.warn("> Accepted invalid keychip $serial in permissive mode")
session = keychipSessionService.new(null, reqMap["game_id"] ?: "").token
@@ -136,8 +136,6 @@ class AllNet(
}
}
val gameId = reqMap["game_id"] ?: return "".also { logger.warn("> Rejected: No game_id provided") }
val ver = reqMap["ver"] ?: "1.0"
val formatVer = reqMap["format_ver"] ?: ""
val resp = props.map.mut + mapOf(
@@ -175,29 +173,32 @@ class AllNet(
return resp.toUrl() + "\n"
}
private fun findUserByIncomingSerial(serial: String) = when (serial.length) {
FULL_KEYCHIP_LENGTH -> userKeychipRepo.findByKeychipId(serial)?.user
SHORT_KEYCHIP_LENGTH -> {
// segatools only sends the first 11 characters of the keychip
// First, try to find it by suffixing AquaDX's generated suffix, then fall back to matching without the suffixed 4 digits
userKeychipRepo.findByKeychipId(serial + KEYCHIP_SUFFIX)?.user
?: userKeychipRepo.findByKeychipIdStartingWith(serial)?.user
}
else -> userKeychipRepo.findByKeychipId(serial)?.user
}
private fun switchUri(hereAddr: Str, localPort: Str, gameId: Str, ver: Str, session: Str?): Str {
val addr = hereAddr + (if (props.hidePort) "" else ":${props.port ?: localPort}")
// If keychip authentication is enabled, the game URLs will be set to /gs/{token}/{game}/...
val base = if (session != null) "gs/$session" else "g"
val protocol = if (props.tls) "https" else "http"
return "http://$addr/$base/" + when (gameId) {
"SDBT" -> "chu2/$ver/$session/"
"SDHD" -> "chu3/$ver/"
"SDGS" -> "chu3/$ver/" // International (c3exp)
"SBZV" -> "diva/"
"SDDT" -> "ongeki/$ver/"
"SDEY" -> "mai/"
"SDGA" -> "mai2/" // International (Exp)
"SDGB" -> "mai2/" // International (China) - TODO: Test it
"SDEZ" -> "mai2/"
"SDFE" -> "wacca" // Note: Wacca must not end with a trailing slash
"SDED" -> "card/"
else -> ""
}
val url = "$protocol://$addr/$base/$gameId/$ver"
return url + (if (gameId == "SDFE") "" else "/") // Wacca must not end with trailing slash
}
companion object {
const val SHORT_KEYCHIP_LENGTH = 11
const val FULL_KEYCHIP_LENGTH = 15
const val KEYCHIP_SUFFIX = "1337"
val logger = logger()
}
}
@@ -25,7 +25,6 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Component
@ConditionalOnBean(AllNetSecureInit::class)
class TokenChecker(
val keyChipRepo: KeyChipRepo,
val keychipSessionService: KeychipSessionService,
val frontierProps: FrontierProps,
val geoip: GeoIP
@@ -56,7 +55,7 @@ class TokenChecker(
// The token can either be a keychip id (old method) or a session id (new method)
// Or the frontier token
val session = keychipSessionService.find(token)
if (token.isNotBlank() && (keyChipRepo.existsByKeychipId(token) || session != null
if (token.isNotBlank() && (session != null
|| (frontierProps.enabled && frontierProps.ftk == token)))
{
currentSession.set(session)
@@ -1,32 +0,0 @@
package icu.samnyan.aqua.sega.allnet
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.io.Serializable
/**
* This is the old method of securing requests - a keychip whitelist,
* it's kept here only for backwards compatibility.
*/
@Entity
@Table(name = "allnet_keychips")
class Keychip(
@Id
val id: Long = 0,
@Column(unique = true, nullable = false)
val keychipId: String = "",
) : Serializable {
companion object {
const val serialVersionUID = 1L
}
}
@Repository("KeyChipRepository")
interface KeyChipRepo : JpaRepository<Keychip?, Long?> {
fun existsByKeychipId(keychipId: String?): Boolean
}
@@ -5,7 +5,9 @@ import icu.samnyan.aqua.net.db.AquaNetUser
import jakarta.persistence.*
import jakarta.transaction.Transactional
import org.slf4j.LoggerFactory
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.JpaRepository
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
@@ -18,9 +20,10 @@ import java.security.SecureRandom
@Table(name = "allnet_keychip_sessions", indexes = [
Index(name = "idx_last_use", columnList = "lastUse")
])
class KeychipSession(
@ManyToOne
@JoinColumn(name = "au_id")
class KeychipSession(
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "au_id")
var user: AquaNetUser? = null,
@Column(length = 4)
@@ -45,12 +48,12 @@ fun genUrlSafeToken(length: Int): String {
}
@Repository("KeychipSessionRepo")
interface KeychipSessionRepo : JpaRepository<KeychipSession, String> {
fun findByToken(token: String): KeychipSession?
@Transactional
fun deleteAllByLastUseBefore(expire: Long)
}
interface KeychipSessionRepo : JpaRepository<KeychipSession, String> {
fun findByToken(token: String): KeychipSession?
@Transactional
fun deleteAllByLastUseBefore(expire: Long)
}
@Service
class KeychipSessionService(
@@ -0,0 +1,35 @@
package icu.samnyan.aqua.sega.allnet
import icu.samnyan.aqua.net.db.AquaNetUser
import jakarta.persistence.*
import jakarta.transaction.Transactional
import org.springframework.data.jpa.repository.JpaRepository
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.springframework.stereotype.Repository
@Entity
@Table(name = "user_keychip")
class UserKeychip(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "au_id", nullable = false)
var user: AquaNetUser,
@Column(unique = true, nullable = false, length = 32)
val keychipId: String,
)
@Repository
interface UserKeychipRepo : JpaRepository<UserKeychip, Long> {
fun findByKeychipId(keychipId: String): UserKeychip?
fun findByKeychipIdStartingWith(keychipIdPrefix: String): UserKeychip?
fun existsByKeychipId(keychipId: String): Boolean
fun findAllByUserAuId(auId: Long): List<UserKeychip>
@Transactional
fun deleteByKeychipIdAndUserAuId(keychipId: String, auId: Long): Long
}
@@ -11,8 +11,8 @@ import org.eclipse.jetty.util.resource.URLResourceFactory
import org.eclipse.jetty.util.ssl.SslContextFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.web.embedded.jetty.JettyServerCustomizer
import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory
import org.springframework.boot.jetty.JettyServerCustomizer
import org.springframework.boot.jetty.servlet.JettyServletWebServerFactory
import org.springframework.boot.web.server.WebServerFactoryCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

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