18 Commits
Author SHA1 Message Date
Azalea f082f4190d chore: update gradlew wrapper to latest version 2026-03-29 22:09:04 +00:00
Azalea 205f3cc973 Merge branch 'cosmos' of https://github.com/MewoLab/AquaDX into cosmos 2026-03-29 22:08:27 +00:00
Azalea e86a7b59fd Merge branch 'v1-dev' and resolve all conflicts 2026-03-29 21:21:28 +00:00
Azalea a5a1d61050 Merge branch 'v1-dev' and resolve all conflicts 2026-03-29 21:17:58 +00:00
Azalea 48efafdc44 [-] QueryDSL 2026-03-29 13:42:36 -04:00
Azalea 8d4a027d48 [M] Move package name 2026-03-29 13:24:20 -04:00
Azalea 0985f73753 [M] Move common models to shared 2026-03-29 13:08:12 -04:00
Azalea 3dbb0095bf [M] Move core extensions to shared 2026-03-28 13:50:38 -04:00
Azalea adaa255dd3 [O] Split extensions 2026-03-28 12:53:30 -04:00
Azalea fb5ea51d92 [M] Move existing code 2026-03-28 12:46:09 -04:00
Azalea 3497dd2329 [+] Subrepos 2026-03-28 12:45:53 -04:00
Azalea 7e5d75a221 [F] Fix run 2026-03-28 06:05:26 -04:00
Azalea afa9564392 [+] Use data jsons instead of db 2026-03-28 05:49:56 -04:00
Azalea 78b48c0f5c [+] Data jsons 2026-03-28 05:43:49 -04:00
Azalea fa068b7172 [+] Drop tables 2026-03-28 04:59:29 -04:00
Azalea a6fad6aa46 [F] Fix devtainer 2026-03-28 04:59:07 -04:00
Azalea af136b906d [F] Fix devtainer 2026-03-28 03:47:42 -04:00
Azalea e334ada5b4 [+] devtainer 2026-03-28 03:44:05 -04:00
637 changed files with 82202 additions and 26157 deletions
+5
View File
@@ -0,0 +1,5 @@
---
trigger: always_on
---
Make sure `./gradlew build` compiles before you say you're done.
-8
View File
@@ -1,8 +0,0 @@
*
!gradlew
!gradle/
!gradle/**
!build.gradle.kts
!settings.gradle.kts
!src/
!src/**
+2 -2
View File
@@ -12,9 +12,9 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v4 uses: actions/setup-java@v3
with: with:
java-version: '25' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
- name: Build with Gradle - name: Build with Gradle
+2 -2
View File
@@ -17,9 +17,9 @@ jobs:
fetch-depth: '10' fetch-depth: '10'
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v4 uses: actions/setup-java@v3
with: with:
java-version: '25' java-version: '17'
distribution: 'temurin' distribution: 'temurin'
server-id: github server-id: github
-3
View File
@@ -84,6 +84,3 @@ src/main/resources/meta/*/*.json
test-diff test-diff
htmlReport htmlReport
docs/logs docs/logs
# Sensitive keys
game_encryption.json
+10
View File
@@ -0,0 +1,10 @@
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
@@ -1,26 +0,0 @@
# 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
+1 -3
View File
@@ -35,6 +35,4 @@ dist-ssr
public/chu3 public/chu3
# local env file # local env file
*.local *.local
.env
+4 -17
View File
@@ -9,25 +9,12 @@ This project uses Svelte (NOT SvelteKit) + TypeScript + Sass, built using Vite.
### Running locally ### Running locally
You will need to have Node.js (or equivalent) and `pnpm` installed, Start by installing dependencies by running: 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.
pnpm install Please leave `data_host` unchanged if you're not sure what it is.
```
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: Finally, run:
```
pnpm run dev
```
### Running locally (with bun)
Alternatively, If you prefer [Bun](https://bun.sh), You can do the same as above. ```shell
Start by configuring the `VITE_AQUA_HOST` in `.env` (copy `.env.example` file to `.env`) to use your URL.
and finally:
```
bun install bun install
bun run dev bun run dev
``` ```
-9
View File
@@ -1,9 +0,0 @@
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)
Executable → Regular
View File
+1
View File
@@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AquaNet</title> <title>AquaNet</title>
+2 -2
View File
@@ -4,8 +4,8 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node buildcheck.js && vite", "dev": "vite",
"build": "node buildcheck.js && vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json", "check": "svelte-check --tsconfig ./tsconfig.json",
"lint": "eslint . --ext ts,tsx,svelte --max-warnings 0 --fix" "lint": "eslint . --ext ts,tsx,svelte --max-warnings 0 --fix"
View File
+4 -13
View File
@@ -9,8 +9,7 @@
import Settings from "./pages/User/Settings.svelte"; import Settings from "./pages/User/Settings.svelte";
import MaiPhoto from "./pages/MaiPhoto.svelte"; import MaiPhoto from "./pages/MaiPhoto.svelte";
import { pfp, tooltip } from "./libs/ui" import { pfp, tooltip } from "./libs/ui"
import { ANNOUNCEMENT } from "./libs/announcement"; import { ANNOUNCEMENT } from "./libs/config";
import { DEFAULT_GAME } from "./libs/config"
import { t } from "./libs/i18n"; import { t } from "./libs/i18n";
import Transfer from "./pages/Transfer/Transfer.svelte"; import Transfer from "./pages/Transfer/Transfer.svelte";
import { link } from "d3"; import { link } from "d3";
@@ -35,19 +34,12 @@
let me: AquaNetUser let me: AquaNetUser
let playedMai = false let playedMai = false
let recentGame: string = DEFAULT_GAME;
if (USER.isLoggedIn()) if (USER.isLoggedIn())
{ {
USER.me().then(m => { USER.me().then(m => {
me = m me = m
CARD.userGames(me.username).then(game => { CARD.userGames(me.username).then(game => {
playedMai = !!game.mai2 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)) }).catch(e => console.error(e))
} }
@@ -61,16 +53,15 @@
<span>AquaNet</span> <span>AquaNet</span>
</a> </a>
{/if} {/if}
{#if $ANNOUNCEMENT} {#if ANNOUNCEMENT}
<div class="announcement"> <div class="announcement">
<strong>{t('navigation.notice')}</strong>: {$ANNOUNCEMENT} <strong>{t('navigation.notice')}</strong>: {ANNOUNCEMENT}
</div> </div>
{/if} {/if}
<a href="/home">{t('navigation.home').toLowerCase()}</a> <a href="/home">{t('navigation.home').toLowerCase()}</a>
<!-- <div on:click={() => alert("Coming soon™")} on:keydown={e => e.key === "Enter" && alert("Coming soon™")} <!-- <div on:click={() => alert("Coming soon™")} on:keydown={e => e.key === "Enter" && alert("Coming soon™")}
role="button" tabindex="0">{t('navigation.maps').toLowerCase()}</div> --> role="button" tabindex="0">{t('navigation.maps').toLowerCase()}</div> -->
<!-- kill me --> <a href="/ranking">{t('navigation.rankings').toLowerCase()}</a>
<a href={`/ranking/${recentGame}`}>{t('navigation.rankings').toLowerCase()}</a>
{#if playedMai} {#if playedMai}
<a href="/pictures">photo</a> <a href="/pictures">photo</a>
{/if} {/if}
+1 -1
View File
@@ -27,7 +27,7 @@
display: flex display: flex
gap: 1rem gap: 1rem
a div
&.active &.active
color: vars.$c-main color: vars.$c-main
</style> </style>
+2 -7
View File
@@ -9,13 +9,8 @@
let editing = false let editing = false
let inputPage: number let inputPage: number
function focus(node: HTMLInputElement) {
node.focus()
}
function updatePage(newPage: number) { function updatePage(newPage: number) {
const p = Math.floor(Number(newPage)) if (newPage > 0 && newPage <= totalPages) dispatch('updatePage', newPage)
if (!isNaN(p) && p > 0 && p <= totalPages) dispatch('updatePage', p)
} }
function startEditing() { function startEditing() {
@@ -38,7 +33,7 @@
<button on:click={() => updatePage(page - 1)} disabled={page <= 1}>Previous</button> <button on:click={() => updatePage(page - 1)} disabled={page <= 1}>Previous</button>
{#if editing} {#if editing}
<input type="number" bind:value={inputPage} on:blur={finishEditing} on:keydown={handleKeydown} min="1" max={totalPages} use:focus/> <input bind:value={inputPage} on:blur={finishEditing} on:keydown={handleKeydown} min="1" max={totalPages} autofocus/>
{:else} {:else}
<span on:click={startEditing} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && startEditing()}> <span on:click={startEditing} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && startEditing()}>
Page {page} of {totalPages} Page {page} of {totalPages}
+10 -55
View File
@@ -16,25 +16,19 @@
if (top) split = split?.toSorted((a, b) => b.score - a.score).slice(0, top) if (top) split = split?.toSorted((a, b) => b.score - a.score).slice(0, top)
if (split) console.log("Split", split) if (split) console.log("Split", split)
let mustResize = split && split.length > 12
</script> </script>
{#if split && split.length > 0 && comp} {#if split && comp}
<div class="rating-composition-container" class:must-resize={mustResize}> <div>
{#if mustResize} <h2>{title}</h2>
<input type="checkbox"> <div class="rating-composition">
{/if} {#each split as p}
<div>
<h2>{title}</h2> <RatingCompSong {p} {game}/>
<div class="rating-composition"> </div>
{#each split as p} {/each}
<div>
<RatingCompSong {p} {game}/>
</div>
{/each}
</div>
</div> </div>
</div>
{/if} {/if}
<style lang="sass"> <style lang="sass">
@@ -45,43 +39,4 @@
// 3 columns // 3 columns
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)) grid-template-columns: repeat(auto-fill, minmax(260px, 1fr))
gap: vars.$gap 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> </style>
+4 -4
View File
@@ -27,15 +27,15 @@
}) })
}) })
function mouseOver(event: Event) { function mouseOver(event: MouseEvent) {
console.log('over') console.log('over')
isHovered = true isHovered = true
updatePosition(event as MouseEvent) updatePosition(event)
} }
function mouseMove(event: Event) { function mouseMove(event: MouseEvent) {
console.log('move') console.log('move')
updatePosition(event as MouseEvent) updatePosition(event)
} }
function updatePosition(event: MouseEvent) { function updatePosition(event: MouseEvent) {
+1 -1
View File
@@ -14,7 +14,7 @@
let error = "" let error = ""
setLoading(true) 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> </script>
{#if !data} {#if !data}
@@ -16,7 +16,7 @@
let changed: string[] = []; let changed: string[] = [];
let symbols: Record<number, number> = {}; let symbols: Record<number, number> = {};
let allItems: Record<string, Record<string, { name: string }>> = {} let allItems: Record<string, Record<string, { name: string }>> = {}
let submitting: string | undefined | null; let submitting: string | undefined | null;
let settings: Record<string, GameOption> = {}; let settings: Record<string, GameOption> = {};
let existingUrl = ""; let existingUrl = "";
@@ -101,13 +101,12 @@
{#if linkedVerseAvailable} {#if linkedVerseAvailable}
<GameSettingFields game="chu3-linked-verse" /> <GameSettingFields game="chu3-linked-verse" />
{#if settings["chusanLvDifficulty"]} {#if settings["chusanLvDifficulty"]}
<a target="_blank" href="https://silentblue.remywiki.com/CHUNITHM:Linked_VERSE">{t(`userbox.lv.help`)}</a>
<div class="field"> <div class="field">
<label for={`chusanLvDifficulty`}>{ts(`userbox.lv.difficulty`)}</label> <label for={`chusanLvDifficulty`}>{ts(`userbox.lv.difficulty`)}</label>
<div> <div>
<select bind:value={settings["chusanLvDifficulty"].value} id={`chusanLvDifficulty`} on:change={() => {changed = [...changed, `chusanLvDifficulty`];}}> <select bind:value={settings["chusanLvDifficulty"].value} id={`chusanLvDifficulty`} on:change={() => {changed = [...changed, `chusanLvDifficulty`];}}>
{#each {length: 6}, i} {#each {length: 5}, i}
<option value={i}>{t(`userbox.lv.difficulty.${i}` as keyof typeof EN_REF)}</option> <option value={i + 1}>{t(`userbox.lv.difficulty.${i + 1}` as keyof typeof EN_REF)}</option>
{/each} {/each}
</select> </select>
{#if changed.includes(`chusanLvDifficulty`)} {#if changed.includes(`chusanLvDifficulty`)}
@@ -3,11 +3,10 @@
<script lang="ts"> <script lang="ts">
import { import {
type AquaNetUser, type AquaNetUser,
type GameUserOption,
type UserBox, type UserBox,
type UserItem, type UserItem,
} from "../../libs/generalTypes"; } from "../../libs/generalTypes";
import { DATA, USER, USERBOX, GAME, SETTING } from "../../libs/sdk"; import { DATA, USER, USERBOX, GAME } from "../../libs/sdk";
import { t, ts } from "../../libs/i18n"; import { t, ts } from "../../libs/i18n";
import { FADE_IN, FADE_OUT, USERBOX_DEFAULT_URL } from "../../libs/config"; import { FADE_IN, FADE_OUT, USERBOX_DEFAULT_URL } from "../../libs/config";
import { fade, slide } from "svelte/transition"; import { fade, slide } from "svelte/transition";
@@ -24,15 +23,11 @@
import { DDS } from "../../libs/userbox/dds"; import { DDS } from "../../libs/userbox/dds";
import ChuniMatchingSettings from "./ChuniMatchingSettings.svelte"; import ChuniMatchingSettings from "./ChuniMatchingSettings.svelte";
import InputField from "../ui/InputField.svelte"; import InputField from "../ui/InputField.svelte";
import UserOptionSlider from "./UserOptionSlider.svelte";
let user: AquaNetUser let user: AquaNetUser
let [loading, error, submitting, preview] = [true, "", "", ""] let [loading, error, submitting, preview] = [true, "", "", ""]
let changed: string[] = []; let changed: string[] = [];
let userOptions: GameUserOption = {};
let userIsUsingPreset = false;
// Available (unlocked) options for each kind of item // Available (unlocked) options for each kind of item
// In allItems: 'namePlate', 'frame', 'trophy', 'mapIcon', 'systemVoice', 'avatarAccessory' // In allItems: 'namePlate', 'frame', 'trophy', 'mapIcon', 'systemVoice', 'avatarAccessory'
let allItems: Record<string, Record<string, { name: string }>> = {} let allItems: Record<string, Record<string, { name: string }>> = {}
@@ -94,13 +89,6 @@
console.log("All items", allItems) console.log("All items", allItems)
console.log("Userbox", userbox) 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 loading = false
} }
@@ -179,7 +167,7 @@
try { try {
data = await GAME.export('chu3') data = await GAME.export('chu3')
} }
catch (e: any) { catch (e) {
error = e.message error = e.message
submitting = "" submitting = ""
return return
@@ -324,39 +312,6 @@
<InputField bind:field={userNameField} <InputField bind:field={userNameField}
callback={() => USERBOX.setUserBox({ field: "userName", value: userNameField.value })}/> 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> </div>
<h2>{t("userbox.header.userbox")}</h2> <h2>{t("userbox.header.userbox")}</h2>
{#if !USERBOX_ENABLED.value || !USERBOX_INSTALLED} {#if !USERBOX_ENABLED.value || !USERBOX_INSTALLED}
@@ -439,7 +394,6 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if USERBOX_INSTALLED} {#if USERBOX_INSTALLED}
<!-- god this is a mess but idgaf atp --> <!-- god this is a mess but idgaf atp -->
<div class="field boolean" style:margin-top="1em"> <div class="field boolean" style:margin-top="1em">
@@ -481,7 +435,7 @@
<span>{USERBOX_SETUP_MODE ? t('userbox.new.url_warning') : USERBOX_SETUP_TEXT}</span> <span>{USERBOX_SETUP_MODE ? t('userbox.new.url_warning') : USERBOX_SETUP_TEXT}</span>
<div class="actions"> <div class="actions">
{#if USERBOX_SETUP_MODE} {#if USERBOX_SETUP_MODE}
<input type="text" on:keyup={e => {if (e.key == "Enter") { userboxHandleInput((e.target as HTMLInputElement).value) }}} class="add-margin" placeholder="Base URL"> <input type="text" on:keyup={e => {if (e.key == "Enter") { userboxHandleInput((e.target as HTMLInputElement).value) } else g(e.currentTarget.value)}} class="add-margin" placeholder="Base URL">
{:else} {:else}
{#if USERBOX_PROGRESS != 0} {#if USERBOX_PROGRESS != 0}
<div class="progress"> <div class="progress">
@@ -558,6 +512,36 @@ p.notice
height: 100% height: 100%
opacity: 0 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 .fields
display: flex display: flex
@@ -610,18 +594,6 @@ p.notice
.desc .desc
opacity: 0.6 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 */ /* AquaBox */
.chuni-userbox-row .chuni-userbox-row
@@ -1,67 +1,32 @@
<script lang="ts"> <script>
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { FADE_IN, FADE_OUT } from "../../libs/config"; import { FADE_IN, FADE_OUT } from "../../libs/config";
import { t, ts } from "../../libs/i18n"; import { t, ts } from "../../libs/i18n";
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte"; import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
import RegionSelector from "./RegionSelector.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); 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> </script>
{#if !loading} <div class="fields">
<div class="fields"> <blockquote class="info">
<blockquote class="info"> {ts("settings.siteNotice")}
{ts("settings.siteNotice")} </blockquote>
</blockquote> <div class="field">
<div class="field"> <div class="bool">
<div class="bool"> <input id="rounding" type="checkbox" bind:checked={rounding.value}/>
<input id="rounding" type="checkbox" bind:checked={rounding.value}/> <label for="rounding">
<label for="rounding"> <span class="name">{ts(`settings.fields.rounding.name`)}</span>
<span class="name">{ts(`settings.fields.rounding.name`)}</span> <span class="desc">{ts(`settings.fields.rounding.desc`)}</span>
<span class="desc">{ts(`settings.fields.rounding.desc`)}</span> </label>
</label>
</div>
</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>
{/if} <div class="divider"></div>
<blockquote class="info">
<StatusOverlays {error} {loading} /> {ts("settings.regionNotice")}
</blockquote>
<RegionSelector/>
</div>
<style lang="sass"> <style lang="sass">
@use "../../vars" @use "../../vars"
@@ -4,36 +4,37 @@
import { t } from "../../libs/i18n.js"; import { t } from "../../libs/i18n.js";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import StatusOverlays from "../StatusOverlays.svelte"; import StatusOverlays from "../StatusOverlays.svelte";
import { GAME, SETTING, USER } from "../../libs/sdk"; import { GAME, USER } from "../../libs/sdk";
import GameSettingFields from "./GameSettingFields.svelte"; import GameSettingFields from "./GameSettingFields.svelte";
import { download } from "../../libs/ui"; import { download } from "../../libs/ui";
import UserOptionSlider from "./UserOptionSlider.svelte";
import type { GameUserOption } from "../../libs/generalTypes";
import InputField from "../ui/InputField.svelte";
let userNameField: any const profileFields = [
['name', t('settings.mai2.name')],
]
let error: string let error: string
let submitting = "" let submitting = ""
let loading = true let values = Array(profileFields.length).fill('')
let changed: string[] = [] let changed: string[] = []
let userOptions: GameUserOption = {} USER.me().then(me =>
let userIsUsingPreset = false GAME.userSummary(me.username, 'mai2').then(({name}) => {
values = [name]
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)); }).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() { async function exportBatchManual() {
submitting = "batchExport" submitting = "batchExport"
const DIFFICULTY_MAP: Record<number, string> = { const DIFFICULTY_MAP: Record<number, string> = {
@@ -155,12 +156,12 @@
let lamp = null; let lamp = null;
if (score.isAllPerfect) { if (score.isAllPerfect) {
lamp = "ALL PERFECT"; lamp = "ALL PERFECT";
if (percent >= 101.0) { if (score.percent == 101.0) {
lamp = "ALL PERFECT+"; lamp = "ALL PERFECT+";
} }
} else if (score.isFullCombo) { } else if (score.isFullCombo) {
lamp = "FULL COMBO"; lamp = "FULL COMBO";
if (!good) { if (good == 0 && great == 0) {
lamp = "FULL COMBO+"; lamp = "FULL COMBO+";
} }
} else if (score.isClear) { } else if (score.isClear) {
@@ -194,72 +195,39 @@
if(data.userData.classRank in CLASS_MAP){ if(data.userData.classRank in CLASS_MAP){
output.classes["matchingClass"] = CLASS_MAP[data.userData.classRank] output.classes["matchingClass"] = CLASS_MAP[data.userData.classRank]
} }
download(JSON.stringify(output), `AquaDX_maimai2_BatchManualExport_${userNameField.value}.json`) download(JSON.stringify(output), `AquaDX_maimai2_BatchManualExport_${values[0]}.json`)
submitting = "" submitting = ""
} }
function exportData() { function exportData() {
submitting = "export" submitting = "export"
GAME.export('mai2') GAME.export('mai2')
.then(data => download(JSON.stringify(data), `AquaDX_maimai2_export_${userNameField.value}.json`)) .then(data => download(JSON.stringify(data), `AquaDX_maimai2_export_${values[0]}.json`))
.catch(e => error = e.message) .catch(e => error = e.message)
.finally(() => submitting = "") .finally(() => submitting = "")
} }
</script> </script>
{#if !loading}
<div class="fields"> <div class="fields">
<InputField bind:field={userNameField} {#each profileFields as [field, name], i (field)}
callback={() => GAME.changeName('mai2', userNameField.value)}/> <div class="field">
{#if userIsUsingPreset} <label for={field}>{name}</label>
<blockquote class="info"> <div>
{t('settings.options.all.preset-warning')} <input id={field} type="text"
</blockquote> bind:value={values[i]} on:input={() => changed = [...changed, field]}
{/if} placeholder={field === 'password' ? t('settings.profile.unchanged') : t('settings.profile.unset')}/>
<div class="fields-ranges"> {#if changed.includes(field) && values[i]}
<!-- TODO: determine min & max --> <button transition:slide={{axis: 'x'}} on:click={() => submit(field, values[i])}>
<UserOptionSlider {#if submitting === field}
game="mai2" type="noteSpeed" <Icon icon="line-md:loading-twotone-loop"/>
minValue={0} maxValue={37} {:else}
defaultValue={userOptions["noteSpeed"]} {t('settings.profile.save')}
getTextFunction={(value: number) => value >= 37 ? "SONIC" : `${(value / 4) + 1}`} {/if}
/> </button>
<UserOptionSlider {/if}
game="mai2" type="touchSpeed" </div>
minValue={0} maxValue={37} </div>
defaultValue={userOptions["touchSpeed"]} {/each}
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"/> <GameSettingFields game="mai2"/>
<button class="exportButton" on:click={exportData}> <button class="exportButton" on:click={exportData}>
<Icon icon="bxs:file-export"/> <Icon icon="bxs:file-export"/>
@@ -270,9 +238,8 @@
{t('settings.batchManualExport')} {t('settings.batchManualExport')}
</button> </button>
</div> </div>
{/if}
<StatusOverlays {error} loading={!userNameField || !!submitting || loading}/> <StatusOverlays {error} loading={!values[0] || !!submitting}/>
<style lang="sass"> <style lang="sass">
.fields .fields
@@ -296,17 +263,6 @@
> input > input
flex: 1 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 .exportButton
display: flex display: flex
justify-content: center justify-content: center
@@ -1,55 +1,9 @@
<script lang="ts"> <script>
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { FADE_IN, FADE_OUT } from "../../libs/config"; import { FADE_IN, FADE_OUT } from "../../libs/config";
import GameSettingFields from "./GameSettingFields.svelte"; 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> </script>
{#if !loading} <div>
<div> <GameSettingFields game="ongeki"/>
<div class="fields"> </div>
<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 { ts } from "../../libs/i18n";
import StatusOverlays from "../StatusOverlays.svelte"; import StatusOverlays from "../StatusOverlays.svelte";
let regionId = $state(0); let regionId = $state(0);
let submitting = $state("") let submitting = ""
let error: string = $state(""); let error: string;
const prefectures = ["None","Aichi","Aomori","Akita","Ishikawa","Ibaraki","Iwate","Ehime","Oita","Osaka","Okayama","Okinawa","Kagawa","Kagoshima","Kanagawa","Gifu","Kyoto","Kumamoto","Gunma","Kochi","Saitama","Saga","Shiga","Shizuoka","Shimane","Chiba","Tokyo","Tokushima","Tochigi","Tottori","Toyama","Nagasaki","Nagano","Nara","Niigata","Hyogo","Hiroshima","Fukui","Fukuoka","Fukushima","Hokkaido","Mie","Miyagi","Miyazaki","Yamagata","Yamaguchi","Yamanashi","Wakayama"] 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="name">{ts(`settings.regionSelector.title`)}</span>
<span class="desc">{ts(`settings.regionSelector.desc`)}</span> <span class="desc">{ts(`settings.regionSelector.desc`)}</span>
</label> </label>
<select bind:value={regionId} onchange={saveNewRegion}> <select bind:value={regionId} on:change={saveNewRegion}>
<option value={0} disabled selected>{ts("settings.regionSelector.select")}</option> <option value={0} disabled selected>{ts("settings.regionSelector.select")}</option>
{#each prefectures.slice(1) as prefecture, index} {#each prefectures.slice(1) as prefecture, index}
<option value={index}>{prefecture}</option> <option value={index}>{prefecture}</option>
@@ -1,86 +0,0 @@
<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"> <script lang="ts">
import { slide, fade } from "svelte/transition"; import { slide, fade } from "svelte/transition";
import type { AquaNetUser, ConfirmProps } from "../../libs/generalTypes"; import type { AquaNetUser } from "../../libs/generalTypes";
import { CARD, GAME, USER, type ExportGameName } from "../../libs/sdk"; import { CARD, USER } from "../../libs/sdk";
import StatusOverlays from "../../components/StatusOverlays.svelte"; import StatusOverlays from "../../components/StatusOverlays.svelte";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import { download, pfp } from "../../libs/ui"; import { pfp } from "../../libs/ui";
import { t, ts } from "../../libs/i18n"; import { t, ts } from "../../libs/i18n";
import Cropper from "svelte-easy-crop"; import Cropper from "svelte-easy-crop";
@@ -12,7 +12,6 @@
let error: string; let error: string;
let submitting = "" let submitting = ""
let loading = false; let loading = false;
let confirm: ConfirmProps | null = null;
const profileFields = [ const profileFields = [
[ 'displayName', t('settings.profile.name') ], [ 'displayName', t('settings.profile.name') ],
@@ -98,48 +97,11 @@
location.href = "/"; 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) => { const passwordAction = (node: HTMLInputElement, whether: boolean) => {
if (whether) node.type = 'password' if (whether) node.type = 'password'
} }
</script> </script>
<StatusOverlays bind:confirm={confirm} {error} loading={!!submitting || loading} /> <StatusOverlays {error} loading={!!submitting || loading} />
{#if !submitting && !error && me} {#if !submitting && !error && me}
<div> <div>
<div class="fields"> <div class="fields">
@@ -194,31 +156,11 @@
</label> </label>
</div> </div>
</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 class="field m-t">
<div> <div>
<button on:click={logOut}>{ts(`settings.profile.logout`)}</button> <button on:click={logOut}>{ts(`settings.profile.logout`)}</button>
</div> </div>
</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>
</div> </div>
{/if} {/if}
@@ -282,19 +224,6 @@
object-fit: cover object-fit: cover
aspect-ratio: 1 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 .cropper-container
+1
View File
@@ -4,6 +4,7 @@
import { DISCORD_INVITE } from "../../libs/config"; import { DISCORD_INVITE } from "../../libs/config";
export let error: string; export let error: string;
export let expected: boolean = false;
</script> </script>
<div class="overlay" transition:fade> <div class="overlay" transition:fade>
-35
View File
@@ -1,35 +0,0 @@
// 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
@@ -1,11 +0,0 @@
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())
})()
+4 -5
View File
@@ -17,15 +17,14 @@ export const FADE_OUT = { duration: 200 }
export const FADE_IN = { delay: 400 } export const FADE_IN = { delay: 400 }
export const DEFAULT_PFP = '/assets/imgs/no_profile.png' export const DEFAULT_PFP = '/assets/imgs/no_profile.png'
// Key name for access key scanned from QR code export const ANNOUNCEMENT = '' // If set, will add an announcement to the top bar. Keep it short.
export const ACCESSCODE_QKEY = import.meta.env.VITE_ACCESSCODE_QKEY || "accesskey"
export const USERBOX_DEFAULT_URL = import.meta.env.VITE_USERBOX_DEFAULT_URL || "" // Documentation for Userbox mode can be found in `docs/aquabox-url-mode.md`
// Please note that if this is set, it must be manually unset by users in Chuni Settings -> Update Userbox -> Switch to URL mode -> (empty value) -> Enter key
export const USERBOX_DEFAULT_URL = ""
export const HAS_USERBOX_ASSETS = true export const HAS_USERBOX_ASSETS = true
export const DEFAULT_GAME = import.meta.env.VITE_DEFAULT_GAME || "mai2"
// Meow meow meow // Meow meow meow
// Matching servers // Matching servers
+2 -8
View File
@@ -31,9 +31,6 @@ export interface AquaNetUser {
computedName: string, computedName: string,
password: string, password: string,
optOutOfLeaderboard: boolean, optOutOfLeaderboard: boolean,
canModifyKeychips: boolean,
hideCountry: boolean,
displayCandidates: boolean
} }
export interface CardSummaryGame { export interface CardSummaryGame {
@@ -93,11 +90,10 @@ export interface GenericGameSummary {
name: string name: string
iconId: number iconId: number
aquaUser?: AquaNetUser aquaUser?: AquaNetUser
serverRank: string serverRank: number
accuracy: number accuracy: number
rating: number rating: number
ratingHighest: number, ratingHighest: number
ratingNotGeneric: boolean,
ranks: RankCount[] ranks: RankCount[]
detailedRanks: { [key: number]: { [key: string]: number } } detailedRanks: { [key: number]: { [key: string]: number } }
maxCombo: number maxCombo: number
@@ -141,8 +137,6 @@ export interface GameOption {
changed?: boolean changed?: boolean
} }
export type GameUserOption = Record<string, number>
export interface UserItem { itemKind: number, itemId: number, stock: number } export interface UserItem { itemKind: number, itemId: number, stock: number }
export interface UserBox { export interface UserBox {
userName: string, userName: string,
+15 -71
View File
@@ -2,8 +2,6 @@ export const EN_REF_USER = {
'UserHome.ServerRank': 'Server Rank', 'UserHome.ServerRank': 'Server Rank',
'UserHome.DXRating': 'DX Rating', 'UserHome.DXRating': 'DX Rating',
'UserHome.Rating': 'Rating', 'UserHome.Rating': 'Rating',
'UserHome.HighestDXRating': 'Peak DX Rating',
'UserHome.HighestRating': 'Peak Rating',
'UserHome.Statistics': 'Statistics', 'UserHome.Statistics': 'Statistics',
'UserHome.Accuracy': 'Accuracy', 'UserHome.Accuracy': 'Accuracy',
'UserHome.MaxCombo': 'Max Combo', 'UserHome.MaxCombo': 'Max Combo',
@@ -26,13 +24,6 @@ export const EN_REF_USER = {
'UserHome.RankDetail.Title': 'Achievement Details', 'UserHome.RankDetail.Title': 'Achievement Details',
'UserHome.RankDetail.Level': 'Level', 'UserHome.RankDetail.Level': 'Level',
'UserHome.B50': 'B50', '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.AddRival': "Add to Rival",
'UserHome.RemoveRival': "Remove from Rival", 'UserHome.RemoveRival': "Remove from Rival",
'UserHome.InvalidGame': "Game ${game} is not supported on the web UI yet. We only support maimai, chunithm, wacca, and ongeki for now.", 'UserHome.InvalidGame': "Game ${game} is not supported on the web UI yet. We only support maimai, chunithm, wacca, and ongeki for now.",
@@ -69,15 +60,12 @@ export const EN_REF_Welcome = {
'welcome.verified': 'Your email has been verified! You can now log in now.', 'welcome.verified': 'Your email has been verified! You can now log in now.',
'welcome.verification-failed': 'Verification failed: ${message}. Please try again.', 'welcome.verification-failed': 'Verification failed: ${message}. Please try again.',
'welcome.password-reset-done': 'Your password has been updated! Please log back in.', '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 = { export const EN_REF_LEADERBOARD = {
'Leaderboard.Title': 'Server Leaderboard', 'Leaderboard.Title': 'Server Leaderboard',
'Leaderboard.Rank': 'Rank', 'Leaderboard.Rank': 'Rank',
'Leaderboard.Rating': 'Rating', 'Leaderboard.Rating': 'Rating',
'Leaderboard.LegacyRating': 'Legacy Rating',
'Leaderboard.ModernRating': 'Modern Rating',
'Leaderboard.Accuracy': 'Accuracy', 'Leaderboard.Accuracy': 'Accuracy',
'Leaderboard.FC': 'FC', 'Leaderboard.FC': 'FC',
'Leaderboard.AP': 'AP', 'Leaderboard.AP': 'AP',
@@ -131,7 +119,6 @@ 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.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.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.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.link': 'Link',
'home.linkcard.data-conflict': 'Data Conflict', 'home.linkcard.data-conflict': 'Data Conflict',
'home.linkcard.name': 'Name', 'home.linkcard.name': 'Name',
@@ -155,11 +142,12 @@ export const EN_REF_HOME = {
export const EN_REF_SETUP = { 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.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(s) are linked to your account and should be kept secure.`, 'setup.keychip-warning': `Your keychip is linked to your account and should be kept secure. Do not give others your keychip.`,
'setup.steps.one': `Pick a method of setting up network communications. Some browsers may not be able to do automatic setup.`, 'setup.steps.one': `Pick a method of setting up network communications. Some browsers may not be able to do automatic setup.`,
'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.two': `Ensure your game has encryption disabled as AquaDX does not support encryption. This may be via a patch or setting.`,
'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.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.support-info': `If you need additional assistance, feel free to make an inquiry in a <a href="/support">support channel</a>.`, '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.reveal-keychip': `Reveal keychip`, 'setup.reveal-keychip': `Reveal keychip`,
'setup.type.automatic': `Automatic Setup`, 'setup.type.automatic': `Automatic Setup`,
'setup.type.manual': `Manual Setup`, 'setup.type.manual': `Manual Setup`,
@@ -167,21 +155,7 @@ export const EN_REF_SETUP = {
'setup.automatic': `Select your segatools.ini below to autofill the network information.`, 'setup.automatic': `Select your segatools.ini below to autofill the network information.`,
'setup.automatic.success': `Success, data has been updated`, '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.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 = { export const EN_REF_SETTINGS = {
@@ -193,24 +167,6 @@ export const EN_REF_SETTINGS = {
'settings.tabs.mai2': 'Mai', 'settings.tabs.mai2': 'Mai',
'settings.tabs.ongeki': 'Ongeki', 'settings.tabs.ongeki': 'Ongeki',
'settings.tabs.wacca': 'Wacca', '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.name': 'Unlock All Music',
'settings.fields.mai2UnlockMusic.desc': 'Unlock all music and master difficulty.', 'settings.fields.mai2UnlockMusic.desc': 'Unlock all music and master difficulty.',
'settings.fields.mai2UnlockChara.name': 'Unlock All Characters', 'settings.fields.mai2UnlockChara.name': 'Unlock All Characters',
@@ -239,8 +195,6 @@ export const EN_REF_SETTINGS = {
'settings.fields.chusanTeamName.desc': 'Customize the text displayed on the top of your profile.', 'settings.fields.chusanTeamName.desc': 'Customize the text displayed on the top of your profile.',
'settings.fields.chusanInfinitePenguins.name': 'Infinite Penguins', 'settings.fields.chusanInfinitePenguins.name': 'Infinite Penguins',
'settings.fields.chusanInfinitePenguins.desc': 'Set penguin statues for character level prompting to 999.', '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.name': 'Unlock Linked Gates',
'settings.fields.chusanLvUnlockAll.desc': 'Incurs a long animated sequence, cannot be undone', 'settings.fields.chusanLvUnlockAll.desc': 'Incurs a long animated sequence, cannot be undone',
'settings.fields.chusanMatchingReflector.name': 'Matching Server Reflector', 'settings.fields.chusanMatchingReflector.name': 'Matching Server Reflector',
@@ -251,16 +205,13 @@ export const EN_REF_SETTINGS = {
'settings.fields.ongekiInfiniteKaika.desc': 'Set Kaika to 999', 'settings.fields.ongekiInfiniteKaika.desc': 'Set Kaika to 999',
'settings.fields.rounding.name': 'Score Rounding', 'settings.fields.rounding.name': 'Score Rounding',
'settings.fields.rounding.desc': 'Round the score to one decimal place', 'settings.fields.rounding.desc': 'Round the score to one decimal place',
'settings.fields.gameUsername.name': 'Player Name', 'settings.fields.gameUsername.name': 'In-Game Username',
'settings.fields.gameUsername.desc': 'Your name shown in game', 'settings.fields.gameUsername.desc': 'Your name shown in game',
'settings.fields.optOutOfLeaderboard.name': 'Opt Out of Leaderboard', 'settings.fields.optOutOfLeaderboard.name': 'Opt Out of Leaderboard',
'settings.fields.optOutOfLeaderboard.desc': 'It may take some time for this to take effect', 'settings.fields.optOutOfLeaderboard.desc': 'You will still be able to see yourself on the leaderboard after logging in',
'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.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.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.picture': 'Profile Picture',
'settings.profile.upload-new': 'Upload New', 'settings.profile.upload-new': 'Upload New',
'settings.profile.bad-format': 'Invalid image format. Supported types are PNG, JPG, JPEG, WEBP & GIF.', 'settings.profile.bad-format': 'Invalid image format. Supported types are PNG, JPG, JPEG, WEBP & GIF.',
@@ -274,11 +225,6 @@ export const EN_REF_SETTINGS = {
'settings.profile.unset': 'Unset', 'settings.profile.unset': 'Unset',
'settings.profile.logout': 'Log out', 'settings.profile.logout': 'Log out',
'settings.profile.unchanged': 'Unchanged', '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.export': 'Export Player Data',
'settings.batchManualExport': "Export in Batch Manual (for Tachi)", '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.", '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.",
@@ -324,15 +270,13 @@ export const EN_REF_USERBOX = {
'userbox.matching.symbolChat': 'Message Choice', 'userbox.matching.symbolChat': 'Message Choice',
'userbox.matching.symbolChat.default': 'Default', 'userbox.matching.symbolChat.default': 'Default',
'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.diffnotice': 'In Linked Verse, you will be matched separately based on your difficulty.',
'userbox.lv.difficulty': 'Linked Verse Difficulty', 'userbox.lv.difficulty': 'Linked Verse Difficulty',
'userbox.lv.difficulty.0': 'LEVEL ∞ (Only for supported gates)', 'userbox.lv.difficulty.1': 'LEVEL V (1000 life, MASTER only)',
'userbox.lv.difficulty.1': 'LEVEL V', 'userbox.lv.difficulty.2': 'LEVEL IV (3000 life, MASTER only)',
'userbox.lv.difficulty.2': 'LEVEL IV', 'userbox.lv.difficulty.3': 'LEVEL III (5000 life, MASTER only)',
'userbox.lv.difficulty.3': 'LEVEL III', 'userbox.lv.difficulty.4': 'LEVEL II (5000 life, MASTER & EXPERT only)',
'userbox.lv.difficulty.4': 'LEVEL II', 'userbox.lv.difficulty.5': 'LEVEL I (5000 life, ALL difficulties)',
'userbox.lv.difficulty.5': 'LEVEL I',
'userbox.new.name': 'AquaBox', 'userbox.new.name': 'AquaBox',
'userbox.new.setup': 'Drag and drop your Chuni game folder (Lumi or newer) into the box below to display UserBoxes with their nameplate & avatar. All files are handled in-browser.', 'userbox.new.setup': '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.',
+42 -128
View File
@@ -5,7 +5,6 @@ import {
EN_REF_LEADERBOARD, EN_REF_LEADERBOARD,
EN_REF_MAI_PHOTO, EN_REF_MAI_PHOTO,
EN_REF_SETTINGS, EN_REF_SETTINGS,
EN_REF_SETUP,
EN_REF_USER, EN_REF_USER,
EN_REF_USERBOX, EN_REF_USERBOX,
type EN_REF_Welcome type EN_REF_Welcome
@@ -14,9 +13,7 @@ import {
const zhUser: typeof EN_REF_USER = { const zhUser: typeof EN_REF_USER = {
'UserHome.ServerRank': '服务器排名', 'UserHome.ServerRank': '服务器排名',
'UserHome.DXRating': 'DX B50', 'UserHome.DXRating': 'DX B50',
'UserHome.Rating': '最高评分', 'UserHome.Rating': '评分',
'UserHome.HighestDXRating': 'DX B50',
'UserHome.HighestRating': '最高评分',
'UserHome.Statistics': '统计数据', 'UserHome.Statistics': '统计数据',
'UserHome.Accuracy': '准确率', 'UserHome.Accuracy': '准确率',
'UserHome.MaxCombo': '最大连击', 'UserHome.MaxCombo': '最大连击',
@@ -39,13 +36,6 @@ const zhUser: typeof EN_REF_USER = {
'UserHome.RankDetail.Title': '评分详细', 'UserHome.RankDetail.Title': '评分详细',
'UserHome.RankDetail.Level': "等级", 'UserHome.RankDetail.Level': "等级",
'UserHome.B50': "B50", '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.AddRival': "添加劲敌",
'UserHome.RemoveRival': "移除劲敌", 'UserHome.RemoveRival': "移除劲敌",
'UserHome.InvalidGame': "游戏 ${game} 还不支持网页端查看。我们目前只支持舞萌、中二、华卡和音击。", 'UserHome.InvalidGame': "游戏 ${game} 还不支持网页端查看。我们目前只支持舞萌、中二、华卡和音击。",
@@ -69,28 +59,25 @@ const zhWelcome: typeof EN_REF_Welcome = {
'welcome.username-missing': '用户名/邮箱必须填哦', 'welcome.username-missing': '用户名/邮箱必须填哦',
'welcome.email-password-missing': '邮箱和密码必须填哦', 'welcome.email-password-missing': '邮箱和密码必须填哦',
'welcome.waiting-turnstile': '正在验证网络环境…', 'welcome.waiting-turnstile': '正在验证网络环境…',
'welcome.turnstile-error': '验证网络环境出错了,请关闭 VPN 后重试', 'welcome.turnstile-error': '验证网络环境出错了,请关闭 VPN 后重试',
'welcome.turnstile-timeout': '验证网络环境超时了,请重试', 'welcome.turnstile-timeout': '验证网络环境超时了,请重试',
'welcome.verification-sent': '验证邮件已发送至 ${email},请翻翻收件箱', 'welcome.verification-sent': '验证邮件已发送至 ${email},请翻翻收件箱',
'welcome.reset-password-sent': '重置邮件已发送至 ${email},请翻翻收件箱', 'welcome.reset-password-sent': '重置邮件已发送至 ${email},请翻翻收件箱',
'welcome.verify-state-0': '您还没有验证邮箱哦!验证邮件一分钟内刚刚发到您的邮箱,请翻翻收件箱', 'welcome.verify-state-0': '您还没有验证邮箱哦!验证邮件一分钟内刚刚发到您的邮箱,请翻翻收件箱',
'welcome.verify-state-1': '您还没有验证邮箱哦!我们在过去的 24 小时内已经发送了 3 封验证邮件,所以我们不会再发送了,请翻翻收件箱', 'welcome.verify-state-1': '您还没有验证邮箱哦!我们在过去的 24 小时内已经发送了 3 封验证邮件,所以我们不会再发送了,请翻翻收件箱',
'welcome.verify-state-2': '您还没有验证邮箱哦!我们刚刚又发送了一封验证邮件,请翻翻收件箱', 'welcome.verify-state-2': '您还没有验证邮箱哦!我们刚刚又发送了一封验证邮件,请翻翻收件箱',
'welcome.reset-state-0': '重置邮件刚刚发送到你的邮箱啦,请翻翻收件箱!', 'welcome.reset-state-0': '重置邮件刚刚发送到你的邮箱啦,请翻翻收件箱!',
'welcome.reset-state-1': '邮件发送次数过多,暂时不会再发送新的重置邮件了', 'welcome.reset-state-1': '邮件发送次数过多,暂时不会再发送新的重置邮件了',
'welcome.verifying': '正在验证邮箱…请稍等', 'welcome.verifying': '正在验证邮箱…请稍等',
'welcome.verified': '您的邮箱已经验证成功!您现在可以登录了', 'welcome.verified': '您的邮箱已经验证成功!您现在可以登录了',
'welcome.verification-failed': '验证失败:${message}。请重试', 'welcome.verification-failed': '验证失败:${message}。请重试',
'welcome.password-reset-done': '您的密码已更新!请重新登录', 'welcome.password-reset-done': '您的密码已更新!请重新登录',
'welcome.login_link': '登录或注册以绑定卡片。',
} }
const zhLeaderboard: typeof EN_REF_LEADERBOARD = { const zhLeaderboard: typeof EN_REF_LEADERBOARD = {
'Leaderboard.Title': '排行榜', 'Leaderboard.Title': '排行榜',
'Leaderboard.Rank': '排名', 'Leaderboard.Rank': '排名',
'Leaderboard.Rating': '评分', 'Leaderboard.Rating': '评分',
'Leaderboard.LegacyRating': '旧评分',
'Leaderboard.ModernRating': '新评分',
'Leaderboard.Accuracy': '准确率', 'Leaderboard.Accuracy': '准确率',
'Leaderboard.FC': 'FC', 'Leaderboard.FC': 'FC',
'Leaderboard.AP': 'AP', 'Leaderboard.AP': 'AP',
@@ -103,48 +90,43 @@ const zhGeneral: typeof EN_REF_GENERAL = {
'game.ongeki': "音击", 'game.ongeki': "音击",
'game.wacca': "华卡", 'game.wacca': "华卡",
"status.error": "发生错误", "status.error": "发生错误",
"status.error.hint": "支持 (Discord)", "status.error.hint": "出了一些问题,请稍后刷新重试或者",
"status.error.hint.link": "加我们的 Discord 群问一问",
"status.detail": "详细信息:${detail}", "status.detail": "详细信息:${detail}",
"action.refresh": "刷新", "action.refresh": "刷新",
"action.cancel": "取消", "action.cancel": "取消",
"action.confirm": "确认", "action.confirm": "确认",
"action.home": "主页",
'navigation.profile': '个人资料', 'navigation.profile': '个人资料',
'navigation.maps': '地图', 'navigation.maps': '地图',
'navigation.home': '首页', 'navigation.home': '首页',
'navigation.rankings': '排行榜', 'navigation.rankings': '排行榜',
'navigation.notice': '公告', 'navigation.notice': '公告'
'loading': '正在加载...',
'404': '找不到页面 (${pathname})。如果您认为这是个错误,请通过适当的支持渠道报告。'
} }
const zhHome: typeof EN_REF_HOME = { const zhHome: typeof EN_REF_HOME = {
'home.nav.portal': "主页", 'home.nav.portal': "主页",
'home.nav.link-card': "绑卡", 'home.nav.link-card': "绑卡",
'home.nav.game-setup': "连接设置", 'home.nav.game-setup': "连接设置",
'home.nav.support': "支持",
'home.user-profile': "个人资料",
'home.rankings': "排行榜",
'home.settings': "设置",
'home.manage-cards': '管理游戏卡', 'home.manage-cards': '管理游戏卡',
'home.manage-cards-description': '绑定、解绑、管理游戏数据卡', 'home.manage-cards-description': '绑定、解绑、管理游戏数据卡',
'home.link-card': '绑定游戏卡', 'home.link-card': '绑定游戏卡',
'home.link-cards-description':'绑定游戏数据卡(Amusement IC 或 Aime 卡)后才可以访问游戏存档哦', 'home.link-cards-description':'绑定游戏数据卡(Amusement IC 或 Aime 卡)后才可以访问游戏存档哦',
'home.join-community': '加入群组', 'home.join-community': '加入群组',
'home.join-community-description': '加入我们的聊天群组,与其他玩家聊天、获取帮助', 'home.join-community-description': '加入我们的聊天群组,与其他玩家聊天、获取帮助',
'home.setup': '连接到 AquaDX', 'home.setup': '连接到 AquaDX',
'home.setup-description': '如果您有街机框体或者手台,点击这里设置服务器的连接', 'home.setup-description': '如果您有街机框体或者手台,点击这里设置服务器的连接',
'home.import': '导入玩家数据', 'home.import': '导入玩家数据',
'home.import-description': '如果你来自其他在线服,可以点击这里导入从其他服务器导出的数据', 'home.import-description': '如果你来自其他在线服,可以点击这里导入从其他服务器导出的数据',
'home.linkcard.cards': "已绑卡片", 'home.linkcard.cards': "已绑卡片",
'home.linkcard.description': "这些是您绑定到帐户的卡", 'home.linkcard.description': "这些是您绑定到帐户的卡",
'home.linkcard.account-card': "账户卡", 'home.linkcard.account-card': "账户卡",
'home.linkcard.registered': "注册于", 'home.linkcard.registered': "注册于",
'home.linkcard.lastused': "上次使用", 'home.linkcard.lastused': "上次使用",
'home.linkcard.enter-info': "请输入以下信息,或将 aime.txt / felica.txt 文件拖放到此区域", 'home.linkcard.enter-info': "请输入以下信息,或将 aime.txt / felica.txt 文件拖放到此区域",
'home.linkcard.access-code': "卡背面的 20 位卡号(如果提示找不到卡,请尝试使用游戏内置的显示卡号功能,输入游戏读取到的卡号)", 'home.linkcard.access-code': "卡背面的 20 位卡号(如果提示找不到卡,请尝试使用游戏内置的显示卡号功能,输入游戏读取到的卡号)",
'home.linkcard.enter-sn': "在您的手机上下载 NFC Tools 并扫描您的卡。然后输入显示的 SN 号。", 'home.linkcard.enter-sn1': "在您的手机",
'home.linkcard.kdx-notice': "如果你在玩 KanadeDX,请在这里输入虚拟卡号(可以在设置 > 卡号中找到卡号)", 'home.linkcard.enter-sn2': "上下载 NFC Tools 并扫描您的卡。然后输入显示的 SN 号。",
'home.linkcard.kdx-notice': "如果你在玩 KanadeDX,请在这里输入虚拟卡号(可以在设置 > 卡号中找到卡号)",
'home.linkcard.link': "绑定", 'home.linkcard.link': "绑定",
'home.linkcard.data-conflict': "卡号冲突", 'home.linkcard.data-conflict': "卡号冲突",
'home.linkcard.name': "名称", 'home.linkcard.name': "名称",
@@ -155,75 +137,31 @@ const zhHome: typeof EN_REF_HOME = {
'home.linkcard.notfound': "找不到卡", 'home.linkcard.notfound': "找不到卡",
'home.linkcard.unlink': "取消链接", 'home.linkcard.unlink': "取消链接",
'home.linkcard.unlink-notice': "你确定要取消此卡的链接吗?", 'home.linkcard.unlink-notice': "你确定要取消此卡的链接吗?",
'home.linkcard.felica-ac-warning': "该 Access Code 是一张 FeliCa AIC 卡。\n如果你使用实体卡(而非 aime.txt 模拟)刷卡登录游戏,与官方服务器不同,你需要绑定该卡的 FeliCa SN(或与之对应的,游戏界面中查看得到的 00 开头的卡号)而非此号码。\n如果你使用 aime.txt 模拟登录,请忽略本警告继续绑定。", '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.community.discord': 'Discord', 'home.community.discord': 'Discord',
'home.community.telegram': 'Telegram (中文)', 'home.community.telegram': 'Telegram (中文)',
'home.community.qq': 'QQ (中文)', 'home.community.qq': 'QQ (中文)',
'home.community.github': 'GitHub 仓库',
'home.linkcard.card-security-warning': "卡片信息决定了您可以访问的所有玩家数据。请不要与任何人分享您的卡号。",
'home.import.unknown-game': '未知游戏类型 (目前导入只支持舞萌和中二)', 'home.import.unknown-game': '未知游戏类型 (目前导入只支持舞萌和中二)',
'home.import.new-data': '要导入的数据', 'home.import.new-data': '要导入的数据',
'home.import.data-conflict': '继续导入将覆盖现有数据', '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 = { const zhSettings: typeof EN_REF_SETTINGS = {
'settings.title': '用户设置', 'settings.title': '用户设置',
'settings.page-title': '${page} 设置',
'settings.tabs.profile': '个人资料', 'settings.tabs.profile': '个人资料',
'settings.tabs.global': '全局', 'settings.tabs.global': '全局',
'settings.tabs.chu3': '中二', 'settings.tabs.chu3': '中二',
'settings.tabs.mai2': '舞萌', 'settings.tabs.mai2': '舞萌',
'settings.tabs.ongeki': '音击', 'settings.tabs.ongeki': '音击',
'settings.tabs.wacca': '华卡', '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.name': '解锁谱面',
'settings.fields.mai2UnlockMusic.desc': '解锁所有曲目和大师难度谱面。', 'settings.fields.mai2UnlockMusic.desc': '解锁所有曲目和大师难度谱面。',
'settings.fields.mai2UnlockChara.name': '解锁角色', 'settings.fields.mai2UnlockChara.name': '解锁角色',
@@ -252,10 +190,6 @@ const zhSettings: typeof EN_REF_SETTINGS = {
'settings.fields.chusanTeamName.desc': '自定义显示在个人资料顶部的文本。', 'settings.fields.chusanTeamName.desc': '自定义显示在个人资料顶部的文本。',
'settings.fields.chusanInfinitePenguins.name': '我是桐谷遥', 'settings.fields.chusanInfinitePenguins.name': '我是桐谷遥',
'settings.fields.chusanInfinitePenguins.desc': '将角色界限突破的企鹅雕像数量设置为 999。', '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.name': '全国对战 Reflector',
'settings.fields.chusanMatchingReflector.desc': '全国对战服务器的 UDP 反射服务器的 URL', 'settings.fields.chusanMatchingReflector.desc': '全国对战服务器的 UDP 反射服务器的 URL',
'settings.fields.chusanMatchingServer.name': '全国对战服务器', 'settings.fields.chusanMatchingServer.name': '全国对战服务器',
@@ -268,15 +202,12 @@ const zhSettings: typeof EN_REF_SETTINGS = {
'settings.fields.gameUsername.desc': '在游戏中显示的用户名', 'settings.fields.gameUsername.desc': '在游戏中显示的用户名',
'settings.fields.optOutOfLeaderboard.name': '不参与排行榜', 'settings.fields.optOutOfLeaderboard.name': '不参与排行榜',
'settings.fields.optOutOfLeaderboard.desc': '登录之后还是可以在排行榜上看到自己', '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.name': '在你的机台上启用「推荐乐曲排行榜」',
'settings.fields.enableMusicRank.desc': '如果你自己设计了排行榜的话,可以关闭这个(会影响你自己的机器)。', 'settings.fields.enableMusicRank.desc': '如果你自己设计了排行榜的话,可以关闭这个(会影响你自己的机器)。',
'settings.mai2.name': '玩家名字',
'settings.profile.picture': '头像', 'settings.profile.picture': '头像',
'settings.profile.upload-new': '上传', '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.save': '保存',
'settings.profile.name': '昵称', 'settings.profile.name': '昵称',
'settings.profile.username': '用户名', 'settings.profile.username': '用户名',
@@ -287,14 +218,9 @@ const zhSettings: typeof EN_REF_SETTINGS = {
'settings.profile.unset': '未设置', 'settings.profile.unset': '未设置',
'settings.profile.logout': '登出', 'settings.profile.logout': '登出',
'settings.profile.unchanged': '未更改', '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.export': '导出玩家数据',
'settings.batchManualExport': "导出 Batch Manual 格式(用于 Tachi)", 'settings.batchManualExport': "导出 Batch Manual 格式(用于 Tachi)",
'settings.cabNotice': '注意:下面这些设置只会影响你自己的机器,如果你是在其他人的机器上玩的话,请联系机主来改设置', 'settings.cabNotice': '注意:下面这些设置只会影响你自己的机器,如果你是在其他人的机器上玩的话,请联系机主来改设置',
// AI // AI
'settings.siteNotice': "这些设置仅适用于网站。", 'settings.siteNotice': "这些设置仅适用于网站。",
// AI // AI
@@ -311,7 +237,6 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
'userbox.header.general': '游戏设置', 'userbox.header.general': '游戏设置',
'userbox.header.matching': '全国对战', 'userbox.header.matching': '全国对战',
'userbox.header.matching.symbolChat': '全国对战聊天表情', 'userbox.header.matching.symbolChat': '全国对战聊天表情',
'userbox.header.linkedVerse': 'Linked Verse',
'userbox.header.userbox': 'UserBox 设置', 'userbox.header.userbox': 'UserBox 设置',
'userbox.header.preview': 'UserBox 预览', 'userbox.header.preview': 'UserBox 预览',
'userbox.nameplateId': '名牌', 'userbox.nameplateId': '名牌',
@@ -321,7 +246,6 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
'userbox.trophyIdSub2': '称号3', 'userbox.trophyIdSub2': '称号3',
'userbox.mapIconId': '地图图标', 'userbox.mapIconId': '地图图标',
'userbox.voiceId': '系统语音', 'userbox.voiceId': '系统语音',
'userbox.stageId': '舞台',
'userbox.avatarWear': '企鹅服饰', 'userbox.avatarWear': '企鹅服饰',
'userbox.avatarHead': '企鹅头饰', 'userbox.avatarHead': '企鹅头饰',
'userbox.avatarFace': '企鹅面部', 'userbox.avatarFace': '企鹅面部',
@@ -342,18 +266,8 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
'userbox.matching.symbolChat': '表情选择', 'userbox.matching.symbolChat': '表情选择',
'userbox.matching.symbolChat.default': '默认', '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.name': 'AquaBox',
'userbox.new.setup': '将中二(Lumi 或更高版本)的游戏文件夹拖放到下方区域,以显示带有名牌和头像的 UserBox。所有文件都在浏览器中处理。', 'userbox.new.setup': '将中二(Lumi 或更高版本)的游戏文件夹拖放到下方区域,以显示带有名牌和头像的 UserBox。所有文件都在浏览器中处理。',
'userbox.new.setup.notice': '选择包含游戏数据的最外层文件夹。', 'userbox.new.setup.notice': '选择包含游戏数据的最外层文件夹。',
'userbox.new.setup.processing_file': '正在处理文件', 'userbox.new.setup.processing_file': '正在处理文件',
'userbox.new.setup.finalizing': '正在保存到内部存储', 'userbox.new.setup.finalizing': '正在保存到内部存储',
@@ -367,20 +281,20 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
'userbox.new.activate_desc': '启用后可显示带有名牌和头像的 UserBox', 'userbox.new.activate_desc': '启用后可显示带有名牌和头像的 UserBox',
'userbox.new.activate_profile': '在用户页面启用 AquaBox', 'userbox.new.activate_profile': '在用户页面启用 AquaBox',
'userbox.new.activate_profile_desc': '启用后可在个人页面显示带有名牌和头像的 UserBox', 'userbox.new.activate_profile_desc': '启用后可在个人页面显示带有名牌和头像的 UserBox',
'userbox.new.error.invalidFolder': '所选文件夹无效。请确认游戏版本为 Lumi 或更新,并且包含 “A000” 选项包。', 'userbox.new.error.invalidFolder': '所选文件夹无效。请确认游戏版本为 Lumi 或更新,并且包含 “A000” 选项包。',
'userbox.new.error.invalidUrl': '输入的 URL 无效。' 'userbox.new.error.invalidUrl': '输入的 URL 无效。'
}; };
export const zhMaiPhoto: typeof EN_REF_MAI_PHOTO = { export const zhMaiPhoto: typeof EN_REF_MAI_PHOTO = {
'maiphoto.title': 'Mai 纪念照片库', 'maiphoto.title': 'Mai 纪念照片库',
'maiphoto.url_warning': '注意:如果想与朋友分享图片的话,请先保存照片再发出去。不要复制图片 URL,因为 URL 中包含 AquaDX 账号信息。', 'maiphoto.url_warning': '注意:如果想与朋友分享图片的话,请先保存照片再发出去。不要复制图片 URL,因为 URL 中包含 AquaDX 账号信息。',
'maiphoto.none': '还没有图片哦~ 可以在每次游戏结束的时候点击上传来上传照片。', 'maiphoto.none': '还没有图片哦~ 可以在每次游戏结束的时候点击上传来上传照片。',
} }
export const zhAquaTrans: typeof EN_REF_AQUATRANS = { export const zhAquaTrans: typeof EN_REF_AQUATRANS = {
'trans.title': '🏳️‍⚧️ AquaTrans™ 数据迁移工具', 'trans.title': '🏳️‍⚧️ AquaTrans™ 数据迁移工具',
'trans.confirm.unbackuped.title': '确认迁移', 'trans.confirm.unbackuped.title': '确认迁移',
'trans.confirm.unbackuped.msg': '似乎还没有备份目标服务器的数据,真的要继续吗?(推荐先备份一下,因为迁移的时候会覆盖数据)', 'trans.confirm.unbackuped.msg': '似乎还没有备份目标服务器的数据,真的要继续吗?(推荐先备份一下,因为迁移的时候会覆盖数据)',
'trans.confirm.untested.title': '不太聪明喵', 'trans.confirm.untested.title': '不太聪明喵',
'trans.confirm.untested.msg': '在两个服务器上都测试完连接之后才能进行数据迁移哦!', 'trans.confirm.untested.msg': '在两个服务器上都测试完连接之后才能进行数据迁移哦!',
'trans.confirm.done.title': '完成!', 'trans.confirm.done.title': '完成!',
@@ -388,8 +302,8 @@ export const zhAquaTrans: typeof EN_REF_AQUATRANS = {
'trans.alert.in-progress': '在迁移了在迁移了', 'trans.alert.in-progress': '在迁移了在迁移了',
'trans.prompt-html': ` 'trans.prompt-html': `
<p>👋 欢迎使用 AquaTrans™ 服务器游玩数据迁移工具!</p> <p>👋 欢迎使用 AquaTrans™ 服务器游玩数据迁移工具!</p>
<p>这个工具可以导出任意服务器的数据,并使用连接凭证(卡号、服务器地址和 Keychip ID)将数据导入任何其他服务器。</p> <p>这个工具可以导出任意服务器的数据,并使用连接凭证(卡号、服务器地址和 Keychip ID)将数据导入任何其他服务器。</p>
<p>我将模拟游戏客户端,从源服务器拉取游戏数据并推送到目标服务器。</p> <p>我将模拟游戏客户端,从源服务器拉取游戏数据并推送到目标服务器。</p>
<p>填写下面的表格开始迁移吧!</p> <p>填写下面的表格开始迁移吧!</p>
`, `,
'trans.error.empty': '请填写所有字段。', 'trans.error.empty': '请填写所有字段。',
@@ -405,11 +319,11 @@ export const zhAquaTrans: typeof EN_REF_AQUATRANS = {
'trans.btn.test': '测试连接', 'trans.btn.test': '测试连接',
'trans.btn.export': '导出数据', 'trans.btn.export': '导出数据',
'trans.btn.import': '导入数据', 'trans.btn.import': '导入数据',
'trans.blacklist': "这个服务器的服主把这个导出工具 ban 了,所以不能从这里导出", 'trans.blacklist': "这个服务器的服主把这个导出工具 ban 了,所以不能从这里导出",
} }
export const ZH = { ...zhUser, ...zhWelcome, ...zhGeneral, export const ZH = { ...zhUser, ...zhWelcome, ...zhGeneral,
...zhLeaderboard, ...zhHome, ...zhSettings, ...zhUserbox, ...zhMaiPhoto, ...zhLeaderboard, ...zhHome, ...zhSettings, ...zhUserbox, ...zhMaiPhoto,
...zhAquaTrans, ...zhSetup ...zhAquaTrans
} }
+4 -6
View File
@@ -29,9 +29,7 @@ const multTable = {
'chu3': [ 'chu3': [
[ 100.9, 215, 'SSS+' ], [ 100.9, 215, 'SSS+' ],
[ 100.75, 200, 'SSS' ], [ 100.75, 200, 'SSS' ],
[ 100.50, 0, 'SS+' ],
[ 100.0, 0, 'SS' ], [ 100.0, 0, 'SS' ],
[ 99.0, 0, 'S+' ],
[ 97.5, 0, 'S' ], [ 97.5, 0, 'S' ],
[ 95.0, 0, 'AAA' ], [ 95.0, 0, 'AAA' ],
[ 92.5, 0, 'AA' ], [ 92.5, 0, 'AA' ],
@@ -82,11 +80,11 @@ export function getMult(achievement: number, game: GameName) {
} }
export function roundFloor(achievement: number, game: GameName, digits = 2) { export function roundFloor(achievement: number, game: GameName, digits = 2) {
// Round, but if the rounded number reaches the next rank (or fake 101% AP+), use floor instead // Round, but if the rounded number reaches the next rank, use floor instead
const mult = getMult(achievement, game); const mult = getMult(achievement, game);
const original = achievement / 10000; achievement /= 10000
const rounded = original.toFixed(digits); const rounded = achievement.toFixed(digits);
if (getMult(+rounded * 10000, game)[2] === mult[2] && (+rounded < 101 || original >= 101)) return rounded; if (getMult(+rounded * 10000, game)[2] === mult[2] && rounded !== '101.0') return rounded;
return (+rounded - Math.pow(10, -digits)).toFixed(digits); return (+rounded - Math.pow(10, -digits)).toFixed(digits);
} }
+6 -30
View File
@@ -9,21 +9,10 @@ import type {
AquaNetUser, GameOption, AquaNetUser, GameOption,
UserBox, UserBox,
UserItem, UserItem,
Dict, Dict
GameUserOption
} from './generalTypes' } from './generalTypes'
import type { GameName } from './scoring' 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 { interface ExtReqInit extends RequestInit {
params?: { [index: string]: string } params?: { [index: string]: string }
json?: any json?: any
@@ -36,8 +25,7 @@ interface ExtReqInit extends RequestInit {
* @param callback Callback for modification * @param callback Callback for modification
*/ */
export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) => URL | void): RequestInfo | URL { export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) => URL | void): RequestInfo | URL {
const base = typeof window !== 'undefined' ? window.location.origin : 'http://localhost' let u = new URL((input instanceof Request) ? input.url : input)
let u = new URL((input instanceof Request) ? input.url : input, base)
const result = callback(u) const result = callback(u)
if (result) u = result if (result) u = result
if (input instanceof Request) { if (input instanceof Request) {
@@ -197,12 +185,8 @@ export const USER = {
ensureLoggedIn() ensureLoggedIn()
return post('/api/v2/user/me', {}) return post('/api/v2/user/me', {})
}, },
keychips: (): Promise<string[]> => keychip: (): Promise<string> =>
post('/api/v2/user/keychip', {}).then(it => it.keychips), post('/api/v2/user/keychip', {}).then(it => it.keychip),
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) => setting: (key: string, value: string) =>
post('/api/v2/user/setting', { key: key === 'password' ? 'pwHash' : key, value }), post('/api/v2/user/setting', { key: key === 'password' ? 'pwHash' : key, value }),
uploadPfp: (file: File) => { uploadPfp: (file: File) => {
@@ -214,8 +198,6 @@ export const USER = {
ensureLoggedIn, ensureLoggedIn,
changeRegion: (regionId: number) => changeRegion: (regionId: number) =>
post('/api/v2/user/change-region', { regionId }), post('/api/v2/user/change-region', { regionId }),
deleteAccount: () =>
post('/api/v2/user/delete-account'),
} }
export const USERBOX = { export const USERBOX = {
@@ -241,9 +223,7 @@ export const CARD = {
export const GAME = { export const GAME = {
trend: (username: string, game: GameName): Promise<TrendEntry[]> => trend: (username: string, game: GameName): Promise<TrendEntry[]> =>
post(`/api/v2/game/${game}/trend`, { username }), post(`/api/v2/game/${game}/trend`, { username }),
photos: (page: number = 1, size: number = 12): Promise<PhotoPage> => photos: (): Promise<string[]> =>
post(`/api/v2/game/mai2/my-photo`, { page, size }),
allPhotos: (): Promise<string[]> =>
post(`/api/v2/game/mai2/my-photo`, { }), post(`/api/v2/game/mai2/my-photo`, { }),
userSummary: (username: string, game: GameName): Promise<GenericGameSummary> => userSummary: (username: string, game: GameName): Promise<GenericGameSummary> =>
post(`/api/v2/game/${game}/user-summary`, { username }), post(`/api/v2/game/${game}/user-summary`, { username }),
@@ -251,7 +231,7 @@ export const GAME = {
post(`/api/v2/game/${game}/ranking`, typeof page === "number" ? { page } : {}), post(`/api/v2/game/${game}/ranking`, typeof page === "number" ? { page } : {}),
changeName: (game: GameName, newName: string): Promise<{ newName: string }> => changeName: (game: GameName, newName: string): Promise<{ newName: string }> =>
post(`/api/v2/game/${game}/change-name`, { newName }), post(`/api/v2/game/${game}/change-name`, { newName }),
export: (game: ExportGameName): Promise<Record<string, any>> => export: (game: GameName): Promise<Record<string, any>> =>
post(`/api/v2/game/${game}/export`), post(`/api/v2/game/${game}/export`),
import: (game: GameName, data: any): Promise<Record<string, any>> => import: (game: GameName, data: any): Promise<Record<string, any>> =>
post(`/api/v2/game/${game}/import`, {}, { json: data }), post(`/api/v2/game/${game}/import`, {}, { json: data }),
@@ -275,10 +255,6 @@ export const SETTING = {
post('/api/v2/settings/set', { key, value: `${value}` }), post('/api/v2/settings/set', { key, value: `${value}` }),
detailSet: (game: string, field: string, value: any) => detailSet: (game: string, field: string, value: any) =>
post(`/api/v2/game/${game}/user-detail-set`, { field, value }), 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 = { export const TRANSFER = {
+4 -4
View File
@@ -24,10 +24,10 @@ function addOrUpdateItem(ini: string[], section: string, key: string, value: str
idx, keys: {} idx, keys: {}
}; };
} else { } else {
let key = item.split("=")[0].toLowerCase(); let key = item.split("=")[0];
let value = item.split("=")[1]; let value = item.split("=")[1];
if (!key) if (!key || !value)
continue; continue;
if (activeSection && sections[activeSection]) 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 { export function injectNetworkData(baseIni: string, networkData: NetworkData): string {
let ini: string[] = baseIni.split("\n").map(i => i.trim().replaceAll("\r", "")); let ini: string[] = baseIni.split("\n").map(i => i.replaceAll("\r", ""));
if (networkData.dns) if (networkData.dns)
addOrUpdateItem(ini, "dns", "default", networkData.dns); addOrUpdateItem(ini, "dns", "default", networkData.dns);
@@ -80,4 +80,4 @@ export async function patchUserSegatools(networkData: NetworkData): Promise<bool
} catch(err) { } catch(err) {
return false; return false;
} }
} }
+5 -5
View File
@@ -35,7 +35,7 @@ export function registerChart() {
) )
} }
const dayTemplate = (DateHelper: any) => { const dayTemplate = (DateHelper) => {
const ROWS_COUNT = 7 const ROWS_COUNT = 7
const ALLOWED_DOMAIN_TYPE = ['month'] const ALLOWED_DOMAIN_TYPE = ['month']
@@ -43,7 +43,7 @@ const dayTemplate = (DateHelper: any) => {
name: 'ghDayFix', name: 'ghDayFix',
allowedDomainType: ALLOWED_DOMAIN_TYPE, allowedDomainType: ALLOWED_DOMAIN_TYPE,
rowsCount: () => ROWS_COUNT, rowsCount: () => ROWS_COUNT,
columnsCount: (ts: any) => { columnsCount: (ts) => {
let count = DateHelper.getWeeksCountInMonth(ts) let count = DateHelper.getWeeksCountInMonth(ts)
const endOfMonth = moment().endOf('month').toDate() const endOfMonth = moment().endOf('month').toDate()
const clampEnd = DateHelper.getFirstWeekOfMonth(endOfMonth).toDate() const clampEnd = DateHelper.getFirstWeekOfMonth(endOfMonth).toDate()
@@ -53,7 +53,7 @@ const dayTemplate = (DateHelper: any) => {
} }
return count return count
}, },
mapping: (startTimestamp: any, endTimestamp: any) => { mapping: (startTimestamp, endTimestamp) => {
const clampStart = DateHelper.getFirstWeekOfMonth(startTimestamp) const clampStart = DateHelper.getFirstWeekOfMonth(startTimestamp)
let clampEnd = DateHelper.getFirstWeekOfMonth(endTimestamp) let clampEnd = DateHelper.getFirstWeekOfMonth(endTimestamp)
@@ -64,7 +64,7 @@ const dayTemplate = (DateHelper: any) => {
let x = -1 let x = -1
const pivotDay = clampStart.weekday() const pivotDay = clampStart.weekday()
return DateHelper.intervals('day', clampStart, clampEnd).map((ts: any) => { return DateHelper.intervals('day', clampStart, clampEnd).map((ts) => {
const weekday = DateHelper.date(ts).weekday() const weekday = DateHelper.date(ts).weekday()
if (weekday === pivotDay) { if (weekday === pivotDay) {
x += 1 x += 1
@@ -77,7 +77,7 @@ const dayTemplate = (DateHelper: any) => {
} }
}) })
}, },
extractUnit: (ts: any) => DateHelper.date(ts).startOf('day').valueOf(), extractUnit: (ts) => DateHelper.date(ts).startOf('day').valueOf(),
} }
} }
+7 -5
View File
@@ -6,7 +6,6 @@
import { t } from "../libs/i18n"; import { t } from "../libs/i18n";
import ImportDataAction from "./Home/ImportDataAction.svelte"; import ImportDataAction from "./Home/ImportDataAction.svelte";
import DashboardTabs from "../components/DashboardTabs.svelte"; import DashboardTabs from "../components/DashboardTabs.svelte";
import * as acUrl from "../libs/acUrl";
USER.ensureLoggedIn(); USER.ensureLoggedIn();
@@ -14,10 +13,6 @@
let error = ""; let error = "";
USER.me().then((m) => me = m).catch(e => error = e.message) USER.me().then((m) => me = m).catch(e => error = e.message)
if (acUrl.has()) {
location.href = "/cards"
}
</script> </script>
<main class="content"> <main class="content">
@@ -68,6 +63,13 @@
<style lang="sass"> <style lang="sass">
@use "../vars" @use "../vars"
.tabs
display: flex
gap: 1rem
div
&.active
color: vars.$c-main
h3 h3
font-size: 1.3rem font-size: 1.3rem
@@ -65,13 +65,10 @@
const getGameByCode = (code: string) => { const getGameByCode = (code: string) => {
switch (code?.toUpperCase()) { switch (code?.toUpperCase()) {
case 'SDGA':
case 'SDEZ': case 'SDEZ':
return 'mai2'; return 'mai2';
case 'SDHD': case 'SDHD':
return 'chu3'; return 'chu3';
case 'SDDT':
return 'ongeki';
default: default:
throw new Error(t('home.import.unknown-game')); throw new Error(t('home.import.unknown-game'));
} }
+4 -19
View File
@@ -9,8 +9,6 @@
import StatusOverlays from "../../components/StatusOverlays.svelte" import StatusOverlays from "../../components/StatusOverlays.svelte"
import { t } from "../../libs/i18n" import { t } from "../../libs/i18n"
import DashboardTabs from "../../components/DashboardTabs.svelte"; import DashboardTabs from "../../components/DashboardTabs.svelte";
import * as acUrl from "../../libs/acUrl"
import { ACCESSCODE_QKEY } from "../../libs/config";
// State // State
let state: 'ready' | 'linking-AC' | 'linking-SN' | 'loading' = "loading" let state: 'ready' | 'linking-AC' | 'linking-SN' | 'loading' = "loading"
@@ -29,10 +27,7 @@
// Always put the ghost card at the top // Always put the ghost card at the top
m.cards.sort((a, b) => a.isGhost ? -1 : 1) m.cards.sort((a, b) => a.isGhost ? -1 : 1)
state = "ready" state = "ready"
linkQR()
}).catch(e => error = e.message) }).catch(e => error = e.message)
acUrl.check()
updateMe() updateMe()
// Data conflict overlay // Data conflict overlay
@@ -54,12 +49,12 @@
if (linkingType === 'AC') inputAC = "" if (linkingType === 'AC') inputAC = ""
if (linkingType === 'SN') inputSN = "" if (linkingType === 'SN') inputSN = ""
} catch (e) { } catch (e) {
setError((e as any).message, linkingType!) setError(e.message, linkingType)
} }
state = "ready" state = "ready"
} }
let linkingType: 'AC' | 'SN' | null = null let linkingType: 'AC' | 'SN' = null
async function link(type: 'AC' | 'SN') { async function link(type: 'AC' | 'SN') {
if (state !== 'ready' || accountCardSummary === null) return if (state !== 'ready' || accountCardSummary === null) return
state = "linking-" + type state = "linking-" + type
@@ -197,7 +192,7 @@
function inputACChange() { function inputACChange() {
// Add spaces to the input // Add spaces to the input
const cursorIndex = cursorPositionToCursorIndex(inputAC, elemInputAC.selectionStart ?? 0, /\d/) const cursorIndex = cursorPositionToCursorIndex(inputAC, elemInputAC.selectionStart, /\d/)
inputAC = inputAC.replace(/\D/g, '').replace(/(.{4})/g, '$1 ').replace(/ $/, '') inputAC = inputAC.replace(/\D/g, '').replace(/(.{4})/g, '$1 ').replace(/ $/, '')
const cursorPosition = cursorIndexToCursorPosition(inputAC, cursorIndex, /\d/) const cursorPosition = cursorIndexToCursorPosition(inputAC, cursorIndex, /\d/)
setTimeout(() => elemInputAC.selectionStart = elemInputAC.selectionEnd = cursorPosition, 0) setTimeout(() => elemInputAC.selectionStart = elemInputAC.selectionEnd = cursorPosition, 0)
@@ -217,7 +212,7 @@
function inputSNChange() { function inputSNChange() {
// Add colons to the input // Add colons to the input
inputSN = inputSN.toUpperCase() inputSN = inputSN.toUpperCase()
const cursorIndex = cursorPositionToCursorIndex(inputSN, inputElemSN.selectionStart ?? 0, /[0-9A-F]/) const cursorIndex = cursorPositionToCursorIndex(inputSN, inputElemSN.selectionStart, /[0-9A-F]/)
inputSN = inputSN.replace(/[^0-9A-F]/g, '').replace(/(.{2})/g, '$1:').replace(/:$/, '') inputSN = inputSN.replace(/[^0-9A-F]/g, '').replace(/(.{2})/g, '$1:').replace(/:$/, '')
const cursorPosition = cursorIndexToCursorPosition(inputSN, cursorIndex, /[0-9A-F]/) const cursorPosition = cursorIndexToCursorPosition(inputSN, cursorIndex, /[0-9A-F]/)
setTimeout(() => inputElemSN.selectionStart = inputElemSN.selectionEnd = cursorPosition, 0) setTimeout(() => inputElemSN.selectionStart = inputElemSN.selectionEnd = cursorPosition, 0)
@@ -278,16 +273,6 @@
}; };
inputACChange(); inputACChange();
} }
function linkQR() {
if (!acUrl.has()) return;
inputAC = acUrl.get()
if (inputAC.length !== 20) return inputAC = "";
inputACChange()
acUrl.clear()
}
</script> </script>
<main class="content"> <main class="content">
+48 -220
View File
@@ -11,100 +11,34 @@
import { patchUserSegatools } from "../../libs/setup"; import { patchUserSegatools } from "../../libs/setup";
let user: AquaNetUser let user: AquaNetUser
let keychips: string[] = []; let keychip: string;
let selectedKeychip: string = "";
let keychipCode: string; let keychipCode: string;
let exposeKeychip = false; let exposeKeychip = false;
let automaticSetupStatus: "none" | "success" | "failure" = "none"; let automaticSetupStatus: "none" | "success" | "failure" = "none";
let isLoading = true;
let isAdding = false;
let newKeychip = "";
let addKeychipError = "";
function formatKeychipDisplay(k: string): string { USER.me().then((u) => {
return `${k.slice(0, 4)}-${k.slice(4)}`; user = u;
} USER.keychip().then(k => {
keychip = `${k.slice(0, 4)}-${k.slice(4)}1337`;
function buildManualKeychipLines(): string { codeToHtml(`
return `id=${formatKeychipDisplay(selectedKeychip)}`;
}
async function buildKeychipCode() {
exposeKeychip = false;
const keychipLines = buildManualKeychipLines();
keychipCode = await codeToHtml(`
[dns] [dns]
default=${AQUA_CONNECTION} default=${AQUA_CONNECTION}
[keychip] [keychip]
enable=1 enable=1
${keychipLines}`.trim(), { id=${keychip}`.trim(), {
lang: 'ini', lang: 'ini',
theme: 'rose-pine', theme: 'rose-pine',
transformers: [] transformers: []
}).then((html) => {
keychipCode = html;
});
}); });
}
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() { async function patchSegatools() {
automaticSetupStatus = await patchUserSegatools({ keychip: formatKeychipDisplay(selectedKeychip), dns: AQUA_CONNECTION }) ? "success" : "failure"; automaticSetupStatus = await patchUserSegatools({ keychip, dns: AQUA_CONNECTION }) ? "success" : "failure";
} }
</script> </script>
@@ -113,87 +47,45 @@ ${keychipLines}`.trim(), {
<div class="setup-instructions"> <div class="setup-instructions">
<h2>{t('home.setup')}</h2> <h2>{t('home.setup')}</h2>
{#if isLoading} {#if keychip}
<p>{t('loading')}</p>
{:else}
<div class="setup-step"> <div class="setup-step">
1. <div>{@html t('setup.steps.one')}</div> 1. <div>{@html t('setup.steps.one')}</div>
</div> </div>
<blockquote class="info"> <blockquote class="info">
{t('setup.keychip-warning')} {t('setup.keychip-warning')}
</blockquote> </blockquote>
{#if user.canModifyKeychips} {#if !!window.showOpenFilePicker}
<details> <details>
<summary>{t('setup.keychip')}</summary> <summary>{t('setup.type.automatic')}</summary>
<p> {@html t('setup.automatic')}
{t('setup.keychip.warning')} {#if automaticSetupStatus != "none"}
</p> <blockquote class={`keychip-status ${automaticSetupStatus}`}>
<div class="keychip-list"> {t(`setup.automatic.${automaticSetupStatus}`)}
{#each keychips as k} </blockquote>
<div class="keychip-item" class:selected={k === selectedKeychip}> {/if}
<button class="keychip-select" on:click={() => selectKeychip(k)}> <div class="setup-btn">
{formatKeychipDisplay(k)} <button on:click={patchSegatools}>{t('setup.automatic.select')}</button>
</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> </div>
</details> </details>
<div class="divider"></div>
{/if} {/if}
{#if selectedKeychip} <details>
{#if !!window.showOpenFilePicker} <summary>{t('setup.type.manual')}</summary>
<details> {@html t('setup.manual')}
<summary>{t('setup.type.automatic')}</summary> <div class="code-container">
{@html t('setup.automatic')} <div class="code" class:revealed={exposeKeychip}>
{#if automaticSetupStatus != "none"} {@html keychipCode}
<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> </div>
</details> {#if !exposeKeychip}
<br> <button class="reveal-btn" on:click={() => exposeKeychip = true}>
{/if} {t('setup.reveal-keychip')}
</button>
{/if}
</div>
</details>
<br>
<div class="setup-step"> <div class="setup-step">
2. <div>{@html t('setup.steps.two')}</div> 2. <div>{@html t('setup.steps.two')}</div>
@@ -201,21 +93,15 @@ ${keychipLines}`.trim(), {
<div class="setup-step"> <div class="setup-step">
3. <div>{@html t('setup.steps.three')}</div> 3. <div>{@html t('setup.steps.three')}</div>
</div> </div>
<h2>{t('setup.troubleshooting.header')}</h2> <div class="setup-step">
<ul> 4. <div>{@html t('setup.steps.four')}</div>
<li> </div>
{@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> <p>
{@html t('setup.support-info')} {@html t('setup.support-info')}
</p> </p>
{:else}
<p>{t('loading')}</p>
{/if} {/if}
</div> </div>
</main> </main>
@@ -225,21 +111,6 @@ ${keychipLines}`.trim(), {
.code .code
overflow-x: auto 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) :global(pre.shiki)
background-color: transparent !important background-color: transparent !important
@@ -296,48 +167,5 @@ ${keychipLines}`.trim(), {
top: 50% top: 50%
left: 50% left: 50%
transform: translate(-50%, -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> </style>
+18 -98
View File
@@ -1,80 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import {GAME} from "../libs/sdk";
import { GAME } from "../libs/sdk"; import {AQUA_HOST} from "../libs/config";
import type { PhotoPage } from "../libs/sdk";
import { AQUA_HOST } from "../libs/config";
import Loading from "../components/ui/Loading.svelte"; import Loading from "../components/ui/Loading.svelte";
import Error from "../components/ui/Error.svelte"; import Error from "../components/ui/Error.svelte";
import Pagination from "../components/Pagination.svelte";
import { t } from "../libs/i18n"; import { t } from "../libs/i18n";
let page = 1; const token = localStorage.getItem("token")
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> </script>
<main class="content"> <main class="content">
@@ -82,31 +13,24 @@
<h2>{t("maiphoto.title")}</h2> <h2>{t("maiphoto.title")}</h2>
</div> </div>
{#if loading && !photoData} {#await GAME.photos()}
<Loading/> <Loading/>
{:else if error} {:then photos}
<Error {error}/> {#if photos.length === 0}
{:else if photoData}
{#if photoData.total === 0}
<blockquote class="info">{t('maiphoto.none')}</blockquote> <blockquote class="info">{t('maiphoto.none')}</blockquote>
{:else} {:else}
{#if photoData.totalPages > 1} <blockquote class="info">{t('maiphoto.url_warning')}</blockquote>
<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} {/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}
</main> </main>
<style lang="sass"> <style lang="sass">
@@ -118,10 +42,6 @@
justify-content: center justify-content: center
row-gap: 1rem row-gap: 1rem
gap: 1rem gap: 1rem
transition: opacity 0.2s ease-in-out
&.loading
opacity: 0.5
.photo-container .photo-container
flex: 1 1 300px flex: 1 1 300px
+9 -24
View File
@@ -3,15 +3,15 @@
import { title } from "../libs/ui"; import { title } from "../libs/ui";
import { GAME } from "../libs/sdk"; import { GAME } from "../libs/sdk";
import type { GenericRanking } from "../libs/generalTypes"; import type { GenericRanking } from "../libs/generalTypes";
import StatusOverlays from "../components/StatusOverlays.svelte";
import type { GameName } from "../libs/scoring"; import type { GameName } from "../libs/scoring";
import { GAME_TITLE } from "../libs/i18n"; import { GAME_TITLE } from "../libs/i18n";
import { t } from "../libs/i18n"; import { t } from "../libs/i18n";
import UserCard from "../components/UserCard.svelte"; import UserCard from "../components/UserCard.svelte";
import Tooltip from "../components/Tooltip.svelte"; import Tooltip from "../components/Tooltip.svelte";
import Cap from "./Ranking/Cap.svelte"; import Cap from "./Ranking/Cap.svelte";
import { DEFAULT_GAME } from "../libs/config";
export let game: GameName = DEFAULT_GAME; export let game: GameName = 'mai2';
title(`Ranking`); title(`Ranking`);
@@ -70,18 +70,11 @@
<span>{user.name}</span> <span>{user.name}</span>
{/if} {/if}
</span> </span>
{#if game == 'ongeki'} <span class="rating">{
<span class="rating">{ game === 'chu3' || game === 'ongeki' ?
(user.rating / 1000).toFixed(3) (user.rating / 100).toFixed(2) :
}</span> user.rating.toLocaleString()
{:else} }</span>
<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="accuracy">{(+user.accuracy).toFixed(2)}%</span>
<span class="fc">{user.fullCombo}</span> <span class="fc">{user.fullCombo}</span>
<span class="ap">{user.allPerfect}</span> <span class="ap">{user.allPerfect}</span>
@@ -93,9 +86,9 @@
<Tooltip triggeredBy=".name" loading={hoverLoading}> <Tooltip triggeredBy=".name" loading={hoverLoading}>
<UserCard username={hoveringUser} {game} setLoading={l => hoverLoading = l} /> <UserCard username={hoveringUser} {game} setLoading={l => hoverLoading = l} />
</Tooltip> </Tooltip>
{:else}
<p>{error}</p>
{/if} {/if}
<StatusOverlays error={error} loading={!loadedPages} />
</main> </main>
<style lang="sass"> <style lang="sass">
@@ -148,14 +141,6 @@
.accuracy .accuracy
display: none 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 &.alternate
background-color: vars.$ov-light background-color: vars.$ov-light
+1 -12
View File
@@ -95,9 +95,7 @@
bind:tested={srcTested} bind:this={srcEl} bind:exportedData={srcExportedData} /> bind:tested={srcTested} bind:this={srcEl} bind:exportedData={srcExportedData} />
<div class="arrow" class:disabled={!(srcTested && dstTested)}> <div class="arrow" class:disabled={!(srcTested && dstTested)}>
<button class="arrow-btn" on:click={startTransfer}> <img src="{DATA_HOST}/d/DownArrow.png" alt="arrow" on:click={startTransfer}>
<img src="{DATA_HOST}/d/DownArrow.png" alt="arrow">
</button>
</div> </div>
<TransferServer bind:src={dst} bind:gameInfo={gameInfo} on:change={onChange} <TransferServer bind:src={dst} bind:gameInfo={gameInfo} on:change={onChange}
@@ -117,15 +115,6 @@
&.disabled &.disabled
filter: grayscale(1) 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 // CSS animation to let the image opacity breathe
img img
animation: breathe 1s infinite alternate animation: breathe 1s infinite alternate
@@ -107,7 +107,7 @@
validate={v => /^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(v)} /> validate={v => /^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(v)} />
<InputTextShort desc={t('trans.field.keychip')} placeholder="e.g. A0299792458" <InputTextShort desc={t('trans.field.keychip')} placeholder="e.g. A0299792458"
bind:value={src.keychip} on:change disabled={tested} bind:value={src.keychip} on:change disabled={tested}
validate={v => /^([A-Z\d]{15}|[A-Z\d]{4}-[A-Z\d]{11})$/.test(v)} /> validate={v => /^([A-Z0-9]{11}|[A-Z0-9]{4}-[A-Z0-9]{11})$/.test(v)} />
</div> </div>
<!-- Second input line --> <!-- Second input line -->
+2 -10
View File
@@ -14,7 +14,6 @@
import UserSettings from "../../components/settings/UserSettings.svelte"; import UserSettings from "../../components/settings/UserSettings.svelte";
import type { Component } from "svelte"; import type { Component } from "svelte";
import { EN_REF } from "../../libs/i18n/en_ref"; import { EN_REF } from "../../libs/i18n/en_ref";
import type { GameName } from "../../libs/scoring";
USER.ensureLoggedIn() USER.ensureLoggedIn()
@@ -31,22 +30,15 @@
if (!pages[page] && page) if (!pages[page] && page)
error = t("404", {pathname: new URL(location.href).pathname}); error = t("404", {pathname: new URL(location.href).pathname});
let userGames: GameName[] = []; USER.me().then(m => me = m)
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) .catch(e => error = e.message)
</script> </script>
<main class="content"> <main class="content">
<div class="outer-title-options"> <div class="outer-title-options">
<h2>{t('settings.title')}</h2> <h2>{t('settings.title')}</h2>
<nav> <nav>
{#each Object.entries(pages).filter(v => v[0] == "profile" || v[0] == "global" || userGames.includes(v[0] as GameName)) as tab} {#each Object.entries(pages) as tab}
<a href={`/settings/${tab[0] != "profile" ? tab[0] : ""}`} transition:slide={{axis: 'x'}} <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"> class:active={tab[0] == page || (tab[0] == "profile" && !page)} role="button" tabindex="0">
{ts(`settings.tabs.${tab[0]}`)} {ts(`settings.tabs.${tab[0]}`)}
+34 -65
View File
@@ -29,11 +29,12 @@
registerChart() registerChart()
export let username: string = ""; export let username: string;
export let game: GameName = "" as GameName; export let game: GameName;
let calElement: HTMLElement let calElement: HTMLElement
let error: string; let error: string;
let me: AquaNetUser let me: AquaNetUser
title(`User ${username}`)
const rounding = useLocalStorage("rounding", true); const rounding = useLocalStorage("rounding", true);
interface MusicAndPlay extends MusicMeta, GenericGamePlaylog {} interface MusicAndPlay extends MusicMeta, GenericGamePlaylog {}
@@ -50,7 +51,6 @@
let isLoading = false let isLoading = false
let showMoreRecent = false let showMoreRecent = false
function init() { function init() {
USER.isLoggedIn() && USER.me().then(u => me = u) USER.isLoggedIn() && USER.me().then(u => me = u)
@@ -77,13 +77,11 @@
GAME.userSummary(username, game), GAME.userSummary(username, game),
GAME.trend(username, game), GAME.trend(username, game),
DATA.allMusic(game), DATA.allMusic(game),
]).then(async ([user, trend, music]) => { ]).then(([user, trend, music]) => {
console.log(user) console.log(user)
console.log(trend) console.log(trend)
console.log(games) console.log(games)
title(` ${user.name} (@${username}) ・ ${GAME_TITLE[game]}`)
// If game is wacca, divide all ratings by 10 // If game is wacca, divide all ratings by 10
if (game === 'wacca') { if (game === 'wacca') {
user.rating /= 10 user.rating /= 10
@@ -163,7 +161,7 @@
<a href={`/u/${username}/${g}`} class:active={game === g}>{name}</a> <a href={`/u/${username}/${g}`} class:active={game === g}>{name}</a>
{/each} {/each}
{#if me && me.username.toLowerCase() === username.toLowerCase()} {#if me && me.username === username}
<a class="setting-icon clickable" use:tooltip={t("UserHome.Settings")} href={`/settings/${game}`}> <a class="setting-icon clickable" use:tooltip={t("UserHome.Settings")} href={`/settings/${game}`}>
<Icon icon="eos-icons:rotating-gear"/> <Icon icon="eos-icons:rotating-gear"/>
</a> </a>
@@ -189,31 +187,19 @@
<div class="scoring-info"> <div class="scoring-info">
<div class="chart"> <div class="chart">
<div class="info-top"> <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">
{#if game == "ongeki" && d.user.ratingNotGeneric} <span>{t('UserHome.ServerRank')}</span>
<div class="rating"> <span>#{(d.user.serverRank + 1).toLocaleString()}</span>
<span>{t("UserHome.Rating")}</span> </div>
<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>
<div class="trend"> <div class="trend">
@@ -261,23 +247,6 @@
<span>{(d.user.accuracy).toFixed(2)}%</span> <span>{(d.user.accuracy).toFixed(2)}%</span>
</div> </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"> <div class="max-combo">
<span>{t("UserHome.MaxCombo")}</span> <span>{t("UserHome.MaxCombo")}</span>
<span>{d.user.maxCombo}</span> <span>{d.user.maxCombo}</span>
@@ -337,25 +306,18 @@
</div> </div>
</div> </div>
<RatingComposition title={t("UserHome.RatingComposition.Best", {n: 50})} comp={d.user.ratingComposition.best50} {allMusics} {game}/> <!-- I don't like doing this but it may be preferable to gaslighting the types -->
<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 --> <!-- Chuni -->
{#if d.user.ratingComposition.new} {#if d.user.ratingComposition.new}
<RatingComposition title={t("UserHome.RatingComposition.New", {n: 20})} comp={d.user.ratingComposition.new} {allMusics} game="chu3"/> <RatingComposition title="New 20" comp={d.user.ratingComposition.new} {allMusics} game="chu3"/>
{:else} {:else}
<RatingComposition title={t("UserHome.RatingComposition.Recent", {n: 10})} comp={d.user.ratingComposition.recent10} {allMusics} {game} top={10}/> <RatingComposition title="Recent 10" comp={d.user.ratingComposition.recent10} {allMusics} {game} top={10}/>
{/if} {/if}
<div class="recent"> <div class="recent">
@@ -532,7 +494,7 @@
.scoring-info .scoring-info
display: flex display: flex
gap: vars.$gap gap: vars.$gap
max-height: 300px max-height: 250px
.chart .chart
flex: 0 1 790px flex: 0 1 790px
@@ -613,7 +575,7 @@
display: flex display: flex
align-items: center align-items: center
gap: 20px gap: 20px
background-color: rgba(white, 0.03) background-color: rgba(white, 0.03)
border-radius: vars.$border-radius border-radius: vars.$border-radius
@@ -631,6 +593,10 @@
overflow: hidden overflow: hidden
flex-direction: column flex-direction: column
.first-line
display: flex
flex-direction: row
// Limit song name to one line // Limit song name to one line
.song-title .song-title
max-width: 90% max-width: 90%
@@ -646,6 +612,9 @@
flex-direction: column flex-direction: column
gap: 0 gap: 0
.rank-text
text-align: left
// Recent Scores section // Recent Scores section
.recent .recent
.scores .scores
-18
View File
@@ -6,14 +6,6 @@
import { USER } from "../libs/sdk"; import { USER } from "../libs/sdk";
import { t } from "../libs/i18n" 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 params = new URLSearchParams(window.location.search)
let state = "home" let state = "home"
@@ -186,13 +178,6 @@
<main id="home" class="no-margin"> <main id="home" class="no-margin">
<div> <div>
<h1 id="title">AquaNet</h1> <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"} {#if state === "home"}
<div class="btn-group" transition:slide> <div class="btn-group" transition:slide>
<button on:click={() => state = 'login'}>{t('welcome.btn-login')}</button> <button on:click={() => state = 'login'}>{t('welcome.btn-login')}</button>
@@ -344,9 +329,6 @@
margin-bottom: 32px margin-bottom: 32px
opacity: 0.9 opacity: 0.9
#welcome
margin-bottom: 32px
.btn-group .btn-group
display: flex display: flex
gap: 8px gap: 8px
+32 -14
View File
@@ -1,19 +1,37 @@
# Contribution # CONTRIBUTING
Thanks for your interest in improving Aqua server, and huge welcome! This document will guide you how to get started.
Thank you for your interest in contributing to the AquaDX server and/or AquaNet web interface implementation. # Before you start
Please look over the following guidelines before creating an issue or pull request.
- We try to support game versions that are n-0 (and occasionally n+1).<br> * 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.
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). * 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.
- Issues should describe a problem or feature request for either AquaDX or AquaNet. <br> # Getting started
Please do not ask for game dumps, ask for assistance with setting up a game dump or send links to game dumps.
- Pull requests should have meaningful descriptions and changes should match the rest of our codebase to be accepted.<br> * Please make sure you read README.md and documents in /docs folder.
Commits can be formatted in any way preferable as long as they're easily digestable and meaningful.<br> * Make sure you have account for this gitlab instance.
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).
- AquaDX makes use of the [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en) license.<br> # Steps to follow
By contributing, you agree your contributions can be placed under this license.
* 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.
+4 -7
View File
@@ -1,6 +1,7 @@
# Use a multi-stage build to keep the image size small # Use a multi-stage build to keep the image size small
# Pin the builder so ARM64 deployments cannot reuse an older floating jdk25 image. # Start with a Gradle image for building the project
FROM gradle:9.6.1-jdk25 AS builder #FROM gradle:jdk21-alpine as builder
FROM gradle:8.8.0-jdk21 as builder
# Copy the Gradle wrapper and configuration files separately to leverage Docker cache # Copy the Gradle wrapper and configuration files separately to leverage Docker cache
COPY --chown=gradle:gradle gradlew /home/gradle/ COPY --chown=gradle:gradle gradlew /home/gradle/
@@ -15,20 +16,16 @@ RUN sed -i 's/\r$//' ./gradlew
# Download dependencies - cached if build.gradle.kts and settings.gradle.kts are unchanged # Download dependencies - cached if build.gradle.kts and settings.gradle.kts are unchanged
RUN chmod +x ./gradlew RUN chmod +x ./gradlew
RUN java -version
RUN ./gradlew dependencies RUN ./gradlew dependencies
# Copy the project source, this layer is rebuilt whenever a file has changed # Copy the project source, this layer is rebuilt whenever a file has changed
COPY --chown=gradle:gradle src /home/gradle/src 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 # Build the application
RUN ./gradlew build -x test RUN ./gradlew build -x test
# Start with a fresh image for the runtime # Start with a fresh image for the runtime
FROM eclipse-temurin:25-jre-alpine FROM eclipse-temurin:21-jre-alpine
# Set the deployment directory # Set the deployment directory
WORKDIR /app WORKDIR /app
+9 -10
View File
@@ -32,22 +32,21 @@ Multipurpose game server for ALL.Net games.
> [!WARNING] > [!WARNING]
> CHUNITHM pre-NEW!! and maimai pre-DX are no longer supported after February 6th, 2026 and all associated data will be removed. > 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 | Import | AES | | Game | Latest Ver. | Initial Ver. | Notes | Web UI | Data Import |
|------------------------|-----------------------|---------------------|-------------------------------------------------------------|--------|-------------|-----| |------------------------|---------------------|---------------------|-------------------------------------------------------------|--------|-------------|
| SDHD: CHUNITHM | 2.50 (Mate) | 2.00 (NEW) | | ✅ | ✅ | ✅ | | SDHD: CHUNITHM | 2.45 (X-VERSE-X) | 2.00 (NEW) | | ✅ | ❗ |
| SDEZ: maimai DX | 1.65 (CiRCLE PLUS) | 1.00 (DX) | Thanks [@Menci](https://github.com/Menci) | ✅ | ✅ | ✅ | | 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) | ✅ | ✅ | ✅ | | 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) | ❌ | ❌ | ❌ | | 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) | ✅ | ✅ | ❌ | | 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 | ❌ | ❌ | 🔓 | | SBZV: Project DIVA | 7.10 | N/A | Minimal support | ❌ | ❌ |
| SDFE: Wacca | 3.07 (REVERSE) | N/A | Later versions are EOS patches | ✅ | ❌ | 🔓 | | SDFE: Wacca | 3.07 (Reverse) | N/A | Later versions are EOS patches, network will not work | ✅ | ❌ |
<!-- A majority of them have been left as N/A for initial version as they do not appear to have restrictions --> <!-- 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. Check out these docs for more information.
* [Game specific notes](docs/game_specific_notes.md) * [Game specific notes](docs/game_specific_notes.md)
* [Frequently asked questions](docs/frequently_asked_questions.md) * [Frequently asked questions](docs/frequently_asked_questions.md)
* [Encryption notes](docs/encryption.md)
## Usage ## Usage
+27 -192
View File
@@ -1,201 +1,36 @@
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
val ktVer = "2.4.0" kotlin("jvm") version "2.1.10" apply false
kotlin("plugin.spring") version "2.1.10" apply false
java kotlin("plugin.jpa") version "2.1.10" apply false
kotlin("jvm") version ktVer kotlin("plugin.serialization") version "2.1.10" apply false
kotlin("plugin.spring") version ktVer kotlin("plugin.allopen") version "2.1.10" apply false
kotlin("plugin.jpa") version ktVer id("org.springframework.boot") version "3.2.3" apply false
kotlin("plugin.serialization") version ktVer id("io.spring.dependency-management") version "1.1.4" apply false
kotlin("plugin.allopen") version ktVer id("com.github.ben-manes.versions") version "0.51.0"
kotlin("kapt") version ktVer
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
} }
apply(plugin = "io.spring.dependency-management") allprojects {
group = "icu.samnyan.aqua"
version = "1.0.0"
repositories { repositories {
mavenLocal() mavenLocal()
mavenCentral() 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")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web") {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-tomcat")
}
implementation("org.springframework.boot:spring-boot-starter-jetty")
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: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.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")
// =============================
// AquaNet Specific Dependencies
// =============================
// Network
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.11.0")
// Email
implementation("org.simplejavamail:simple-java-mail:9.0.1")
implementation("org.simplejavamail:spring-module:9.0.1")
// GeoIP
implementation("com.maxmind.geoip2:geoip2:5.1.0")
// JWT Authentication
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:3.3.1")
// 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: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_25
targetCompatibility = JavaVersion.VERSION_25
toolchain {
languageVersion.set(JavaLanguageVersion.of(25))
} }
} }
kotlin { subprojects {
jvmToolchain(25) apply(plugin = "org.jetbrains.kotlin.jvm")
compilerOptions {
jvmTarget.set(JvmTarget.JVM_25) // Use configure to access the extension safely in subprojects
configure<org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension> {
jvmToolchain(21)
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
freeCompilerArgs.add("-Xjsr305=strict")
}
} }
} }
springBoot {
mainClass.set("icu.samnyan.aqua.EntryKt")
}
application {
mainClass = "icu.samnyan.aqua.EntryKt"
}
kapt {
includeCompileClasspath = false
keepJavacAnnotationProcessors = true
}
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
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(
mapOf(
"version" to projectVersion,
"ext" to mapOf("buildTime" to buildTime),
)
)
}
}
tasks.test {
enabled = providers.gradleProperty("runTests").isPresent
useJUnitPlatform()
jvmArgs("-Dkotest.assertions.collection.print.size=100")
}
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
}
tasks.withType<Javadoc>().configureEach {
options.encoding = "UTF-8"
}
tasks.named<Jar>("jar") {
enabled = false
}
sourceSets {
main {
java.srcDir("${layout.buildDirectory.get()}/generated/source/kapt/main")
}
}
val copyDependencies = tasks.register<Copy>("copyDependencies") {
from(configurations.runtimeClasspath)
into("${layout.buildDirectory.get()}/libs/lib")
}
tasks.register<Jar>("packageThin") {
group = "build"
from(sourceSets.main.get().output)
manifest {
attributes(
"Main-Class" to "icu.samnyan.aqua.EntryKt",
"Class-Path" to configurations.runtimeClasspath.get().files.joinToString(" ") { "lib/${it.name}" }
)
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
dependsOn(copyDependencies)
}
+26
View File
@@ -0,0 +1,26 @@
plugins {
kotlin("jvm")
kotlin("plugin.serialization")
id("application")
}
dependencies {
implementation(project(":shared"))
// 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-serialization-kotlinx-json:3.0.3")
// Serialization
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0")
// Testing
testImplementation("io.kotest:kotest-runner-junit5-jvm:5.8.1")
}
application {
mainClass = "icu.samnyan.aqua.comet.EntryKt"
}
+1 -2
View File
@@ -97,7 +97,6 @@ spring.datasource.hikari.maximum-pool-size=10
## Link card limit ## Link card limit
aqua-net.link-card-limit=10 aqua-net.link-card-limit=10
aqua-net.keychip-limit=20
## CloudFlare Turnstile Captcha ## CloudFlare Turnstile Captcha
## This enables captcha for user registration. ## This enables captcha for user registration.
@@ -142,4 +141,4 @@ aqua-net.bot.enabled=true
aqua-net.bot.secret=hunter2 aqua-net.bot.secret=hunter2
## OpenAI Settings (For content moderation) ## 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 FROM archlinux:latest
RUN pacman -Syu --noconfirm \ RUN pacman -Syu --noconfirm \
&& pacman -S --noconfirm openssh sudo jdk25-openjdk wget which procps-ng zsh git curlie micro ripgrep python3 exa \ && pacman -S --noconfirm openssh sudo jdk21-openjdk wget which procps-ng zsh git curlie micro ripgrep python3 exa diffutils \
&& rm -rf /var/cache/pacman/pkg/* \ && rm -rf /var/cache/pacman/pkg/* \
&& mkdir -p /var/run/sshd \ && mkdir -p /var/run/sshd \
&& chsh -s /bin/zsh root \ && chsh -s /bin/zsh root \
-1
View File
@@ -1,6 +1,5 @@
services: services:
app: app:
build: .
image: hykilpikonna/aquadx:latest image: hykilpikonna/aquadx:latest
ports: ports:
- "80:80" - "80:80"
+4 -40
View File
@@ -17,46 +17,10 @@
| 11 | Frame | | 11 | Frame |
| 12 | Tickets | | 12 | Tickets |
| 13 | Mile | | 13 | Mile |
| 14 | Intimate Item | | 14 | Intimate Item |
| 15 | Kaleidx Scope Key | | 15 | Kaleidx Scope Key |
## Kaleidx Scope gates ## Multiplayer
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 ### Party Host/Client/Member
-35
View File
@@ -1,35 +0,0 @@
# 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)
+21 -16
View File
@@ -3,35 +3,34 @@
## Chunithm (Chusan) ## Chunithm (Chusan)
### Required patches ### Required patches
* None * No encryption & TLS
<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 ### Additional notes
* Class/Dan, National Matching and LINKED VERSE modes will work after playing the first game * Class/Dan and National Matching modes will work after playing the first game
(both when you first set up the game and when you update the game's rom or options). (both when you first set up the game and when you update the game's rom or options).
* National Matching is supported out of the box. Services provided by [Yukiotoko](https://gitea.tendokyu.moe/CharaLol/Yukiotoko) * National Matching requires [additional setup](chu3-national-matching.md).
* For user box and LINKED VERSE customization, use the AquaNet website. * For user box 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. * Many aspects of the game may not work in freeplay mode, this is not a server-side restriction.
## Maimai DX ## Maimai DX
### Required patches ### Required patches
* No TLS
* No certificate pinning * 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
### Additional notes * Tournament mode
* 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 ## O.N.G.E.K.I
### Required patches ### Required patches
* No TLS
* No certificate pinning * No certificate pinning
* No URI obfuscation
<sub>※ No encryption & No TLS is required if you do not have encryption keys set for your game version (supported on public instance)</sub> * No encryption
### Non-working features ### Non-working features
* KOP related * KOP related
@@ -40,10 +39,16 @@
## Card Maker ## Card Maker
### Required patches ### Required patches
* No TLS & Encryption (Not implemented as it has three (four?) sets of keys across several games, not worth the effort) * 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
### Additional notes ### Additional notes
* Only stated version above are supported.
* Server does not consider gacha rarity and probability weight during card draw. * 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. * 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. * 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 ### Building
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. You need to install JDK 21 on your system, then run `./gradlew clean build`. The jar file will be built into the `build/libs` folder.
## Why drop SQLite support? ## 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. 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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+168
View File
@@ -0,0 +1,168 @@
import java.time.ZonedDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
plugins {
val ktVer = "2.1.10"
java
kotlin("jvm") version ktVer
kotlin("plugin.spring") version ktVer
kotlin("plugin.jpa") version ktVer
kotlin("plugin.serialization") version ktVer
kotlin("plugin.allopen") version ktVer
id("org.springframework.boot") version "3.2.3"
id("io.spring.dependency-management") version "1.1.4"
id("com.github.ben-manes.versions") version "0.51.0"
id("org.hibernate.orm") version "6.4.4.Final"
application
}
dependencies {
implementation(project(":shared"))
// Spring boot
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web") {
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")
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")
// 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")
// JSR305 for nullable
implementation("com.google.code.findbugs:jsr305:3.0.2")
// 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("org.jetbrains.kotlin:kotlin-reflect")
runtimeOnly("org.reactivestreams:reactive-streams:1.0.4")
runtimeOnly("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.0")
// Email
implementation("org.simplejavamail:simple-java-mail:8.6.3")
implementation("org.simplejavamail:spring-module:8.6.3")
// GeoIP
implementation("com.maxmind.geoip2:geoip2:4.2.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")
// Content validation
implementation("org.apache.tika:tika-core:2.9.1")
// Import: DateTime Parsing
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0")
// Serialization
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// Testing
testImplementation("io.kotest:kotest-runner-junit5-jvm:5.8.1")
testImplementation("io.kotest:kotest-assertions-core")
}
group = "icu.samnyan.aqua"
version = "1.0.0"
description = "AquaDX Planet Server"
java.sourceCompatibility = JavaVersion.VERSION_21
kotlin {
jvmToolchain(21)
}
springBoot {
mainClass.set("icu.samnyan.aqua.EntryKt")
}
application {
mainClass = "icu.samnyan.aqua.EntryKt"
}
hibernate {
enhancement {
enableLazyInitialization = true
enableAssociationManagement = false
enableExtendedEnhancement = false
}
}
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
tasks.processResources {
val buildTime = ZonedDateTime.now(ZoneId.of("UTC"))
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z"))
inputs.property("buildTime", buildTime)
inputs.property("version", project.version)
filesMatching("**/application.properties") {
expand(mapOf("project" to project, "buildTime" to buildTime, "version" to project.version, "ext" to mapOf("buildTime" to buildTime)))
}
}
tasks.test {
enabled = false
useJUnitPlatform()
jvmArgs("-Dkotest.assertions.collection.print.size=100")
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
freeCompilerArgs.add("-Xjsr305=strict")
}
}
tasks.getByName<Jar>("jar") {
enabled = false
}
val copyDependencies by tasks.registering(Copy::class) {
from(configurations.runtimeClasspath)
into("${layout.buildDirectory.get()}/libs/lib")
}
val packageThin by tasks.registering(Jar::class) {
group = "build"
from(sourceSets.main.get().output)
manifest {
attributes(
"Main-Class" to "icu.samnyan.aqua.EntryKt",
"Class-Path" to configurations.runtimeClasspath.get().files.joinToString(" ") { "lib/${it.name}" }
)
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
dependsOn(copyDependencies)
}
@@ -1,7 +1,7 @@
package icu.samnyan.aqua package aquadx
import icu.samnyan.aqua.sega.aimedb.AimeDbServer import aquadx.sega.aimedb.AimeDbServer
import icu.samnyan.aqua.spring.AutoChecker import aquadx.spring.AutoChecker
import org.springframework.boot.SpringApplication import org.springframework.boot.SpringApplication
import org.springframework.boot.ansi.AnsiOutput import org.springframework.boot.ansi.AnsiOutput
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
@@ -1,13 +1,13 @@
package icu.samnyan.aqua.net package aquadx.net
import ext.* import ext.*
import icu.samnyan.aqua.net.db.AquaUserServices import aquadx.net.db.AquaUserServices
import icu.samnyan.aqua.net.utils.SUCCESS import aquadx.net.utils.SUCCESS
import icu.samnyan.aqua.sega.chusan.model.Chu3Repos import aquadx.sega.chusan.model.Chu3Repos
import icu.samnyan.aqua.sega.general.model.Card import aquadx.sega.general.model.Card
import icu.samnyan.aqua.sega.general.model.CardStatus import aquadx.sega.general.model.CardStatus
import icu.samnyan.aqua.sega.general.model.sensitiveInfo import aquadx.sega.general.model.sensitiveInfo
import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos import aquadx.sega.maimai2.model.Mai2Repos
import jakarta.transaction.Transactional import jakarta.transaction.Transactional
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties
@@ -116,7 +116,7 @@ class BotController(
val gamesDict = listOfNotNull(chu3, mai2).map { val gamesDict = listOfNotNull(chu3, mai2).map {
// Find the keychip owner // Find the keychip owner
val keychip = it.lastClientId val keychip = it.lastClientId
val keychipOwner = keychip?.let { k -> us.userKeychipRepo.findByKeychipId(k)?.user } val keychipOwner = keychip?.let { us.userRepo.findByKeychip(it) }
mapOf( mapOf(
"userData" to it, "userData" to it,
@@ -1,20 +1,20 @@
package icu.samnyan.aqua.net package aquadx.net
import ext.* import ext.*
import icu.samnyan.aqua.net.components.JWT import aquadx.net.components.JWT
import icu.samnyan.aqua.net.db.AquaUserServices import aquadx.net.db.AquaUserServices
import icu.samnyan.aqua.net.games.GenericUserDataRepo import aquadx.net.games.GenericUserDataRepo
import icu.samnyan.aqua.net.games.IUserData import aquadx.net.games.IUserData
import icu.samnyan.aqua.net.utils.AquaNetProps import aquadx.net.utils.AquaNetProps
import icu.samnyan.aqua.net.utils.SUCCESS import aquadx.net.utils.SUCCESS
import icu.samnyan.aqua.sega.chusan.model.Chu3UserDataRepo import aquadx.sega.chusan.model.Chu3UserDataRepo
import icu.samnyan.aqua.sega.diva.PlayerProfileRepository import aquadx.sega.diva.PlayerProfileRepository
import icu.samnyan.aqua.sega.general.dao.CardRepository import aquadx.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.general.model.Card import aquadx.sega.general.model.Card
import icu.samnyan.aqua.sega.general.service.CardService import aquadx.sega.general.service.CardService
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo import aquadx.sega.maimai2.model.Mai2UserDataRepo
import icu.samnyan.aqua.sega.ongeki.OgkUserDataRepo import aquadx.sega.ongeki.OgkUserDataRepo
import icu.samnyan.aqua.sega.wacca.model.db.WcUserRepo import aquadx.sega.wacca.model.db.WcUserRepo
import jakarta.persistence.EntityManager import jakarta.persistence.EntityManager
import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -68,7 +68,7 @@ class CardController(
*/ */
@API("/link") @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") @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): Any = jwt.auth(token) { u -> suspend fun link(@RP token: Str, @RP cardId: Str, @RP migrate: Str) = jwt.auth(token) { u ->
// Check if the user's card limit is reached // Check if the user's card limit is reached
if (u.cards.size >= props.linkCardLimit) 400 - "Card limit reached" if (u.cards.size >= props.linkCardLimit) 400 - "Card limit reached"
@@ -1,25 +1,25 @@
package icu.samnyan.aqua.net package aquadx.net
import ext.* import ext.*
import icu.samnyan.aqua.net.components.EmailProperties import aquadx.net.components.EmailProperties
import icu.samnyan.aqua.net.components.JWT import aquadx.net.components.JWT
import icu.samnyan.aqua.net.db.AquaGameOptions import aquadx.net.db.AquaGameOptions
import icu.samnyan.aqua.net.db.AquaNetUser import aquadx.net.db.AquaNetUser
import icu.samnyan.aqua.net.db.AquaUserServices import aquadx.net.db.AquaUserServices
import icu.samnyan.aqua.net.games.ExportOptions import aquadx.net.games.ExportOptions
import icu.samnyan.aqua.net.games.GenericUserDataRepo import aquadx.net.games.GenericUserDataRepo
import icu.samnyan.aqua.net.games.IUserData import aquadx.net.games.IUserData
import icu.samnyan.aqua.net.games.mai2.Mai2Import import aquadx.net.games.mai2.Mai2Import
import icu.samnyan.aqua.net.utils.ApiException import aquadx.net.utils.ApiException
import icu.samnyan.aqua.net.utils.PathProps import aquadx.net.utils.PathProps
import icu.samnyan.aqua.net.utils.SUCCESS import aquadx.net.utils.SUCCESS
import icu.samnyan.aqua.sega.chusan.model.Chu3UserDataRepo import aquadx.sega.chusan.model.Chu3UserDataRepo
import icu.samnyan.aqua.sega.general.dao.CardRepository import aquadx.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.general.model.Card import aquadx.sega.general.model.Card
import icu.samnyan.aqua.sega.general.service.CardService import aquadx.sega.general.service.CardService
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo import aquadx.sega.maimai2.model.Mai2UserDataRepo
import icu.samnyan.aqua.sega.ongeki.OgkUserDataRepo import aquadx.sega.ongeki.OgkUserDataRepo
import icu.samnyan.aqua.sega.wacca.model.db.WcUserRepo import aquadx.sega.wacca.model.db.WcUserRepo
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.PlatformTransactionManager
@@ -33,8 +33,8 @@ import java.util.concurrent.CompletableFuture
import kotlin.io.path.getLastModifiedTime import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.isRegularFile import kotlin.io.path.isRegularFile
import kotlin.io.path.writeBytes import kotlin.io.path.writeBytes
import icu.samnyan.aqua.sega.maimai2.handler.UploadUserPlaylogHandler as Mai2UploadUserPlaylogHandler import aquadx.sega.maimai2.handler.UploadUserPlaylogHandler as Mai2UploadUserPlaylogHandler
import icu.samnyan.aqua.sega.maimai2.handler.UpsertUserAllHandler as Mai2UpsertUserAllHandler import aquadx.sega.maimai2.handler.UpsertUserAllHandler as Mai2UpsertUserAllHandler
@Configuration @Configuration
@ConfigurationProperties(prefix = "aqua-net.fedy") @ConfigurationProperties(prefix = "aqua-net.fedy")
@@ -154,7 +154,7 @@ class Fedy(
forEach { (k, v) -> v?.let { GAME_OPTIONS_FIELDS[k]?.set(options, it) } } forEach { (k, v) -> v?.let { GAME_OPTIONS_FIELDS[k]?.set(options, it) } }
} }
us.userRepo.save(ru) us.userRepo.save(ru)
if (fields.containsKey("pwHash")) { us.clearAllSessions(ru) } if (fields.containsKey("pwHash") ?: false) { us.clearAllSessions(ru) }
UserUpdateRes(user = ru.fedyBasicInfo()) UserUpdateRes(user = ru.fedyBasicInfo())
} caught { UserUpdateRes(error = it) } } caught { UserUpdateRes(error = it) }
} }
@@ -179,14 +179,14 @@ class Fedy(
val card = cardRepo.findByExtId(req.extId) val card = cardRepo.findByExtId(req.extId)
?: (404 - "Card with extId ${req.extId} not found") ?: (404 - "Card with extId ${req.extId} not found")
val cardTimestamp = cardService.getCardTimestamp(card, req.game) val cardTimestamp = cardService.getCardTimestamp(card, req.game)
if (cardTimestamp.updatedAt.toEpochMilli() == req.updatedAtMs) return@handleFedy DataPullRes(error = null, result = null) // No changes if (cardTimestamp.updatedAt == req.updatedAtMs) return@handleFedy DataPullRes(error = null, result = null) // No changes
val isRebased = req.createdAtMs > 0 && cardTimestamp.createdAt.toEpochMilli() > req.createdAtMs val isRebased = req.createdAtMs > 0 && cardTimestamp.createdAt > req.createdAtMs
val exportOptions = if (!isRebased) { req.exportOptions } else { req.exportOptions.copy(playlogAfter = null) } val exportOptions = if (!isRebased) { req.exportOptions } else { req.exportOptions.copy(playlogAfter = null) }
{ {
DataPullRes(result = DataPullResult(data = when (req.game) { DataPullRes(result = DataPullResult(data = when (req.game) {
"mai2" -> mai2Import.export(card, exportOptions) "mai2" -> mai2Import.export(card, exportOptions)
else -> 406 - "Unsupported game" else -> 406 - "Unsupported game"
}, createdAtMs = cardTimestamp.createdAt.toEpochMilli(), updatedAtMs = cardTimestamp.updatedAt.toEpochMilli(), isRebased = isRebased)) }, createdAtMs = cardTimestamp.createdAt, updatedAtMs = cardTimestamp.updatedAt, isRebased = isRebased))
} caught { DataPullRes(error = it) } } caught { DataPullRes(error = it) }
} }
@@ -1,7 +1,7 @@
package icu.samnyan.aqua.net package aquadx.net
import ext.* import ext.*
import icu.samnyan.aqua.sega.general.service.CardService import aquadx.sega.general.service.CardService
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
@@ -1,12 +1,12 @@
package icu.samnyan.aqua.net package aquadx.net
import ext.JACKSON import ext.JACKSON
import ext.logger import ext.logger
import ext.parse import ext.parse
import icu.samnyan.aqua.net.db.AquaNetUserRepo import aquadx.net.db.AquaNetUserRepo
import icu.samnyan.aqua.sega.general.dao.CardRepository import aquadx.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.general.dao.PropertyEntryRepository import aquadx.sega.general.dao.PropertyEntryRepository
import icu.samnyan.aqua.sega.general.model.PropertyEntry import aquadx.sega.general.model.PropertyEntry
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -0,0 +1,22 @@
package aquadx.net
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
@Repository
interface SafetyRepo : JpaRepository<Safety, Long> {
fun findBySafetyId(safetyId: String): Safety?
}
@Service
class AquaNetSafetyService(val safetyRepo: SafetyRepo) {
fun isSafe(safetyId: String): Boolean {
val safety = safetyRepo.findBySafetyId(safetyId)
return safety == null || safety.status == 0
}
fun isSafeBatch(safetyIds: List<String>): List<Boolean> {
return safetyIds.map { isSafe(it) }
}
}
@@ -1,10 +1,10 @@
package icu.samnyan.aqua.net package aquadx.net
import ext.* import ext.*
import icu.samnyan.aqua.net.db.AquaGameOptions import aquadx.net.db.AquaGameOptions
import icu.samnyan.aqua.net.db.AquaGameOptionsRepo import aquadx.net.db.AquaGameOptionsRepo
import icu.samnyan.aqua.net.db.AquaNetUserRepo import aquadx.net.db.AquaNetUserRepo
import icu.samnyan.aqua.net.db.AquaUserServices import aquadx.net.db.AquaUserServices
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.findAnnotation
import kotlin.reflect.jvm.jvmErasure import kotlin.reflect.jvm.jvmErasure
@@ -37,10 +37,8 @@ class SettingsApi(
@Doc("Set a field in the game options") @Doc("Set a field in the game options")
fun setField(@RP token: String, @RP key: String, @RP value: String) = us.jwt.auth(token) { u -> 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 field = fieldMap[key] ?: (400 - "Invalid field $key")
var options = u.gameOptions val options = u.gameOptions ?: AquaGameOptions().also {
if (options == null) { userRepo.save(u.apply { gameOptions = it })
options = goRepo.save(AquaGameOptions())
userRepo.save(u.apply { gameOptions = options })
} }
// Check field type // Check field type
field.setCast(options, value) field.setCast(options, value)
@@ -1,40 +1,21 @@
package icu.samnyan.aqua.net package aquadx.net
import ext.* import ext.*
import icu.samnyan.aqua.net.components.* import aquadx.net.components.*
import icu.samnyan.aqua.net.db.AquaNetUser import aquadx.net.db.AquaNetUserRepo
import icu.samnyan.aqua.net.db.AquaNetUserRepo import aquadx.net.db.AquaUserServices
import icu.samnyan.aqua.net.db.AquaUserServices import aquadx.net.db.EmailConfirmationRepo
import icu.samnyan.aqua.net.db.EmailConfirmationRepo import aquadx.net.db.ResetPasswordRepo
import icu.samnyan.aqua.net.db.ResetPasswordRepo import aquadx.net.utils.PathProps
import icu.samnyan.aqua.net.utils.AquaNetProps import aquadx.net.utils.SUCCESS
import icu.samnyan.aqua.net.utils.PathProps import aquadx.sega.general.dao.CardRepository
import icu.samnyan.aqua.net.utils.SUCCESS import aquadx.sega.general.model.CardStatus
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.servlet.http.HttpServletRequest
import jakarta.transaction.Transactional
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.nio.file.Files
import java.nio.file.Path
import java.time.Instant 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 import kotlin.io.path.writeBytes
@RestController @RestController
@@ -52,10 +33,7 @@ class UserRegistrar(
val cardRepo: CardRepository, val cardRepo: CardRepository,
val validator: AquaUserServices, val validator: AquaUserServices,
val emailProps: EmailProperties, val emailProps: EmailProperties,
val userKeychipRepo: UserKeychipRepo, final val paths: PathProps
val aquaNetProps: AquaNetProps,
final val paths: PathProps,
val accountDeletion: AccountDeletionService,
) { ) {
val portraitPath = paths.aquaNetPortrait.path() val portraitPath = paths.aquaNetPortrait.path()
@@ -217,6 +195,9 @@ class UserRegistrar(
// Check if the token is valid // Check if the token is valid
if (confirmation == null) 400 - "Invalid token" 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 // Check if the email is already confirmed
val u = confirmation.aquaNetUser val u = confirmation.aquaNetUser
if (u.emailConfirmed) 400 - "Email already confirmed" if (u.emailConfirmed) 400 - "Email already confirmed"
@@ -254,65 +235,20 @@ class UserRegistrar(
SUCCESS SUCCESS
} }
val keychipPattern = Regex("^([A-Z\\d]{4}-[A-Z\\d]{11}|[A-Z\\d]{15})$")
val keychipRange = 1e9.toULong()..1e10.toULong() - 1UL 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") @API("/keychip")
@Doc("List all keychip IDs associated with the current user's account.", "List of keychip IDs") @Doc("Get a Keychip ID so that the user can connect to the server.", "Success message")
suspend fun listKeychips(@RP token: Str) = jwt.auth(token) { u -> suspend fun setupConnection(@RP token: Str) = jwt.auth(token) { u ->
val keychips = async { userKeychipRepo.findAllByUserAuId(u.auId) } u.keychip?.let { return mapOf("keychip" to it) }
if (keychips.isEmpty() && !u.canModifyKeychips) { log.info("Net: /user/keychip setup: ${u.auId} for ${u.username}")
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 })
}
@API("/keychip/add") // Generate a keychip id with 10 digits (e.g. A1234567890)
@Doc("Add a custom keychip ID for the user.", "The newly added keychip ID") var new = "A" + keychipRange.random()
suspend fun addKeychip(@RP token: Str, @RP keychipId: Str) = jwt.auth(token) { u -> while (async { userRepo.findByKeychip(new) != null }) new = "A" + keychipRange.random()
ensureCanModifyKeychips(u) async { userRepo.save(u.apply { keychip = new }) }
log.info("Net: /user/keychip/add: ${u.auId} for ${u.username}")
val validated = validateCustomKeychip(keychipId)
if (async { userKeychipRepo.existsByKeychipId(validated) }) mapOf("keychip" to new)
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"]) @API("/upload-pfp", consumes = ["multipart/form-data"])
@@ -349,96 +285,5 @@ class UserRegistrar(
} }
SUCCESS 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,16 +1,12 @@
package icu.samnyan.aqua.net.components package aquadx.net.components
import jakarta.mail.Message
import ext.Bool import ext.Bool
import ext.Str import ext.Str
import ext.logger import ext.logger
import icu.samnyan.aqua.net.db.* import aquadx.net.db.*
import org.simplejavamail.api.email.Recipient
import org.simplejavamail.api.mailer.Mailer import org.simplejavamail.api.mailer.Mailer
import org.simplejavamail.email.EmailBuilder import org.simplejavamail.email.EmailBuilder
import org.simplejavamail.springsupport.SimpleJavaMailSpringSupport 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.event.ApplicationStartedEvent
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
@@ -29,19 +25,15 @@ class EmailProperties {
var webHost: Str = "aquadx.net" 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. * Email service. All email related operations should be placed here.
* *
* Library Documentation: https://www.simplejavamail.org/ * Library Documentation: https://www.simplejavamail.org/
*/ */
@Service @Service
@Import(SimpleJavaMailSpringSupport::class)
class EmailService( class EmailService(
val mailerProvider: ObjectProvider<Mailer>, val mailer: Mailer,
val props: EmailProperties, val props: EmailProperties,
val confirmationRepo: EmailConfirmationRepo, val confirmationRepo: EmailConfirmationRepo,
val resetPasswordRepo: ResetPasswordRepo, val resetPasswordRepo: ResetPasswordRepo,
@@ -52,16 +44,13 @@ class EmailService(
val resetTemplate: Str = this::class.java.getResource("/email/reset.html")?.readText() val resetTemplate: Str = this::class.java.getResource("/email/reset.html")?.readText()
?: throw Exception("Password Reset Template Not Found") ?: 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 @Async
@EventListener(ApplicationStartedEvent::class) @EventListener(ApplicationStartedEvent::class)
fun test() { fun test() {
if (!props.enable) return if (!props.enable) return
try { try {
mailer().testConnection() mailer.testConnection()
log.info("Email Service Connected") log.info("Email Service Connected")
} catch (e: Exception) { } catch (e: Exception) {
log.error("Email Service Connection Failed", e) log.error("Email Service Connection Failed", e)
@@ -82,9 +71,9 @@ class EmailService(
// Send email // Send email
log.info("Sending verification email to ${user.email}") log.info("Sending verification email to ${user.email}")
mailer().sendMail(EmailBuilder.startingBlank() mailer.sendMail(EmailBuilder.startingBlank()
.from(props.senderName, props.senderAddr) .from(props.senderName, props.senderAddr)
.withRecipients(Recipient(user.computedName, user.email, Message.RecipientType.TO, null)) .to(user.computedName, user.email)
.withSubject("Verify Your Email Address for AquaNet") .withSubject("Verify Your Email Address for AquaNet")
.withHTMLText(confirmTemplate .withHTMLText(confirmTemplate
.replace("{{name}}", user.computedName) .replace("{{name}}", user.computedName)
@@ -105,9 +94,9 @@ class EmailService(
// Send email // Send email
log.info("Sending reset password email to ${user.email}") log.info("Sending reset password email to ${user.email}")
mailer().sendMail(EmailBuilder.startingBlank() mailer.sendMail(EmailBuilder.startingBlank()
.from(props.senderName, props.senderAddr) .from(props.senderName, props.senderAddr)
.withRecipients(Recipient(user.computedName, user.email, Message.RecipientType.TO, null)) .to(user.computedName, user.email)
.withSubject("Reset Your Password for AquaNet") .withSubject("Reset Your Password for AquaNet")
.withHTMLText(resetTemplate .withHTMLText(resetTemplate
.replace("{{name}}", user.computedName) .replace("{{name}}", user.computedName)
@@ -119,9 +108,9 @@ class EmailService(
if (!props.enable) return if (!props.enable) return
log.info("Sending test email to $addr") log.info("Sending test email to $addr")
mailer().sendMail(EmailBuilder.startingBlank() mailer.sendMail(EmailBuilder.startingBlank()
.from(props.senderName, props.senderAddr) .from(props.senderName, props.senderAddr)
.withRecipients(Recipient(name, addr, Message.RecipientType.TO, null)) .to(name, addr)
.withSubject("Test Email") .withSubject("Test Email")
.withPlainText("This is a test email to check if AquaNet Email Works").buildEmail()).thenRun { .withPlainText("This is a test email to check if AquaNet Email Works").buildEmail()).thenRun {
log.info("Test email sent to $addr") log.info("Test email sent to $addr")
@@ -1,4 +1,4 @@
package icu.samnyan.aqua.net.components package aquadx.net.components
import com.maxmind.geoip2.DatabaseReader import com.maxmind.geoip2.DatabaseReader
import com.maxmind.geoip2.exception.AddressNotFoundException import com.maxmind.geoip2.exception.AddressNotFoundException
@@ -77,7 +77,7 @@ class GeoIP(
{ {
return try { return try {
val ipa = InetAddress.getByName(ip) val ipa = InetAddress.getByName(ip)
geoLite.country(ipa)?.country()?.isoCode() ?: "" geoLite.country(ipa)?.country?.isoCode ?: ""
} }
catch (e: AddressNotFoundException) { "" } catch (e: AddressNotFoundException) { "" }
catch (e: Exception) { catch (e: Exception) {
@@ -85,4 +85,4 @@ class GeoIP(
"" ""
} }
} }
} }
@@ -1,8 +1,8 @@
package icu.samnyan.aqua.net.components package aquadx.net.components
import ext.Str import ext.Str
import ext.minus import ext.minus
import icu.samnyan.aqua.net.db.* import aquadx.net.db.*
import io.jsonwebtoken.JwtParser import io.jsonwebtoken.JwtParser
import io.jsonwebtoken.Jwts import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys import io.jsonwebtoken.security.Keys
@@ -1,4 +1,4 @@
package icu.samnyan.aqua.net.components package aquadx.net.components
import ext.Bool import ext.Bool
import ext.HTTP import ext.HTTP
@@ -0,0 +1,10 @@
package aquadx.net.db
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface EmailConfirmationRepo : JpaRepository<EmailConfirmation, Long> {
fun findByToken(token: String): EmailConfirmation?
fun findByAquaNetUserAuId(auId: Long): List<EmailConfirmation>
}
@@ -0,0 +1,10 @@
package aquadx.net.db
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface ResetPasswordRepo : JpaRepository<ResetPassword, Long> {
fun findByToken(token: String): ResetPassword?
fun findByAquaNetUserAuId(auId: Long): List<ResetPassword>
}
@@ -0,0 +1,7 @@
package aquadx.net.db
import org.springframework.data.jpa.repository.JpaRepository
// Entity AquaGameOptions is now in :shared
interface AquaGameOptionsRepo : JpaRepository<AquaGameOptions, Long>
@@ -0,0 +1,10 @@
package aquadx.net.db
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface SessionTokenRepo : JpaRepository<SessionToken, String> {
fun findByToken(token: String): SessionToken?
fun findByAquaNetUserAuId(auId: Long): List<SessionToken>
}
@@ -1,260 +1,163 @@
package icu.samnyan.aqua.net.db package aquadx.net.db
import com.fasterxml.jackson.annotation.JsonIgnore import ext.*
import ext.* import aquadx.net.UserRegistrar.Companion.cardExtIdEnd
import icu.samnyan.aqua.net.UserRegistrar.Companion.cardExtIdEnd import aquadx.net.UserRegistrar.Companion.cardExtIdStart
import icu.samnyan.aqua.net.UserRegistrar.Companion.cardExtIdStart import aquadx.net.components.JWT
import icu.samnyan.aqua.net.components.JWT import aquadx.sega.allnet.AllNetProps
import icu.samnyan.aqua.sega.allnet.AllNetProps import aquadx.sega.allnet.KeyChipRepo
import icu.samnyan.aqua.sega.allnet.KeychipSession import aquadx.sega.general.GameMusicPopularity
import icu.samnyan.aqua.sega.allnet.UserKeychip import aquadx.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.allnet.UserKeychipRepo import aquadx.sega.general.model.Card
import icu.samnyan.aqua.sega.general.GameMusicPopularity import aquadx.sega.general.service.CardService
import icu.samnyan.aqua.sega.general.dao.CardRepository import jakarta.persistence.*
import icu.samnyan.aqua.sega.general.model.Card import org.springframework.data.jpa.repository.JpaRepository
import icu.samnyan.aqua.sega.general.service.CardService import org.springframework.security.crypto.password.PasswordEncoder
import jakarta.persistence.* import org.springframework.stereotype.Service
import org.springframework.data.jpa.repository.JpaRepository import java.time.LocalDateTime
import org.springframework.security.crypto.password.PasswordEncoder import kotlin.reflect.KMutableProperty
import org.springframework.stereotype.Service import kotlin.reflect.full.functions
import java.io.Serializable
import java.time.LocalDateTime // Entity AquaNetUser and SettingField are now in :shared
import kotlin.jvm.optionals.getOrNull
import kotlin.reflect.KFunction interface AquaNetUserRepo : JpaRepository<AquaNetUser, Long> {
import kotlin.reflect.KMutableProperty fun findByAuId(auId: Long): AquaNetUser?
import kotlin.reflect.full.functions fun findByEmailIgnoreCase(email: String): AquaNetUser?
fun findByUsernameIgnoreCase(username: String): AquaNetUser?
@Entity fun findByKeychip(keychip: String): AquaNetUser?
class AquaNetUser( fun findByGhostCardExtId(extId: Long): AquaNetUser?
@JsonIgnore }
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
var auId: Long = 0, /**
* User services (formerly inside AquaNetUser.kt in the monolithic setup).
@Column(nullable = false, unique = true, length = 32) */
var username: String = "", @Service
class AquaUserServices(
// Login credentials val userRepo: AquaNetUserRepo,
@Column(nullable = false, unique = true) val cardRepo: CardRepository,
var email: String = "", val hasher: PasswordEncoder,
val keyChipRepo: KeyChipRepo,
@JsonIgnore val allNetProps: AllNetProps,
@Column(nullable = false) val jwt: JWT,
var pwHash: String = "", val em: EntityManager,
val pop: GameMusicPopularity,
@Column(nullable = true, length = 32) val cardService: CardService,
var displayName: String = "", val sessionRepo: SessionTokenRepo,
) {
// Country code at most 3 characters companion object {
@Column(length = 3) val SETTING_FIELDS = AquaUserServices::class.functions
var country: String = "", .filter { it.name.startsWith("check") }
.map {
// Region code at most 2 characters val name = it.name.removePrefix("check").replaceFirstChar { c -> c.lowercase() }
@Column(length = 2) val prop = AquaNetUser::class.members.find { m -> m.name == name } as KMutableProperty<*>
var region: String = "", SettingField(name, it, prop.setter)
}
// Last login time }
var lastLogin: Long = 0L,
fun create(username: Str, email: Str, password: Str, country: Str, emailConfirmed: Boolean = false): AquaNetUser {
// Registration time // Create user
var regTime: Long = 0L, val u = AquaNetUser(
username = checkUsername(username),
// Profile fields email = validateEmail(email),
var profileLocation: String? = "", pwHash = checkPwHash(password),
var profileBio: String? = "", regTime = millis(), lastLogin = millis(), country = country,
var profilePicture: String? = "", emailConfirmed = emailConfirmed
var optOutOfLeaderboard: Boolean = false, )
var hideCountry: Boolean = false,
var displayCandidates: Boolean = false, // Create a ghost card
val card = Card().apply {
// Email confirmation extId = cardService.randExtID(cardExtIdStart, cardExtIdEnd)
var emailConfirmed: Boolean = false, luid = extId.toString()
registerTime = LocalDateTime.now()
@OneToOne(cascade = [CascadeType.PERSIST, CascadeType.MERGE]) accessTime = registerTime
@JoinColumn(name = "ghostCard", unique = true, nullable = false) aquaUser = u
var ghostCard: Card = Card(), isGhost = true
}
// One user can have multiple cards u.ghostCard = card
@OneToMany(mappedBy = "aquaUser", cascade = [CascadeType.PERSIST, CascadeType.MERGE])
var cards: MutableList<Card> = mutableListOf(), // Save the user
userRepo.save(u)
// Each user can have multiple keychips (if the user owns cabinets) cardRepo.save(card)
@JsonIgnore
@OneToMany(mappedBy = "user", cascade = [CascadeType.PERSIST, CascadeType.MERGE]) return u
var keychips: MutableList<UserKeychip> = mutableListOf(), }
// Each user's keychip can have multiple sessions fun update(user: AquaNetUser, key: Str, value: Str) {
@JsonIgnore // Check if the key is a settable field
@OneToMany(mappedBy = "user", cascade = [CascadeType.PERSIST, CascadeType.MERGE]) val field = SETTING_FIELDS.find { it.name == key } ?: (400 - "Invalid setting")
var keychipSessions: MutableList<KeychipSession> = mutableListOf(), // Set the validated field
field.setter.call(user, field.checker.call(this, value))
@Column(nullable = false) }
var canModifyKeychips: Boolean = false,
fun clearAllSessions(user: AquaNetUser) = sessionRepo.deleteAll(sessionRepo.findByAquaNetUserAuId(user.auId))
@OneToOne(cascade = [CascadeType.ALL])
@JoinColumn(name = "gameOptions", unique = true, nullable = true) suspend fun <T> byName(username: Str, callback: suspend (AquaNetUser) -> T) =
var gameOptions: AquaGameOptions? = null, async { userRepo.findByUsernameIgnoreCase(username) }?.let { callback(it) } ?: (404 - "User not found")
) : Serializable {
val computedName get() = displayName.ifEmpty { username } suspend fun cardByName(username: Str) =
if (username.startsWith("user")) username.substring(4).toLongOrNull()
val publicFields get() = mapOf( ?.let { cardRepo.findById(it)() } ?: (404 - "Card not found")
"username" to username, else byName(username) { it.ghostCard }
"displayName" to displayName,
"country" to if (hideCountry) "" else country, suspend fun <T> cardByName(username: Str, callback: suspend (Card) -> T) = callback(cardByName(username))
"regTime" to regTime,
"profileLocation" to profileLocation, fun validKeychip(keychipId: Str): Bool {
"profileBio" to profileBio, if (!allNetProps.checkKeychip) return true
"profilePicture" to profilePicture, if (keychipId.isBlank()) return false
) if (userRepo.findByKeychip(keychipId) != null || keyChipRepo.existsByKeychipId(keychipId)) return true
} return false
}
interface AquaNetUserRepo : JpaRepository<AquaNetUser, Long> {
fun findByAuId(auId: Long): AquaNetUser? fun checkUsername(username: Str) = username.apply {
fun findByEmailIgnoreCase(email: String): AquaNetUser? // Check if username is valid
fun findByUsernameIgnoreCase(username: String): AquaNetUser? if (length < 2) 400 - "Username must be at least 2 letters"
fun findByGhostCardExtId(extId: Long): AquaNetUser? if (length > 32) 400 - "Username too long (max 32 letters)"
} if (contains(" ")) 400 - "Username cannot contain spaces"
data class SettingField( // card{id} is a reserved format
val name: Str, if (startsWith("user") && substring(4).toLongOrNull() != null)
val checker: KFunction<*>, 400 - "Username cannot be 'user' + a number. This format is reserved for user IDs."
val setter: KMutableProperty.Setter<*>,
) // Check if username is within A-Za-z0-9_-~.
find { !it.isLetterOrDigit() && it != '_' && it != '-' && it != '~' && it != '.' }?.let {
/** 400 - "Username cannot contain `$it`. Please only use letters (A-Z), numbers (0-9), and `_-~.` characters. You can set a display name later."
* This class is a validator for user fields. It will return the parsed value if the field is valid, or }
* throw an ApiException if the field is invalid.
*/ // Check if user with the same username exists
@Service if (userRepo.findByUsernameIgnoreCase(this) != null)
class AquaUserServices( 400 - "User with username `$this` already exists"
val userRepo: AquaNetUserRepo, }
val cardRepo: CardRepository,
val hasher: PasswordEncoder, fun validateEmail(email: Str) = email.apply {
val userKeychipRepo: UserKeychipRepo,
val allNetProps: AllNetProps,
val jwt: JWT,
val em: EntityManager,
val pop: GameMusicPopularity,
val cardService: CardService,
val sessionRepo: SessionTokenRepo,
) {
companion object {
val SETTING_FIELDS = AquaUserServices::class.functions
.filter { it.name.startsWith("check") }
.map {
val name = it.name.removePrefix("check").replaceFirstChar { c -> c.lowercase() }
val prop = AquaNetUser::class.members.find { m -> m.name == name } as KMutableProperty<*>
SettingField(name, it, prop.setter)
}
}
fun create(username: Str, email: Str, password: Str, country: Str, emailConfirmed: Boolean = false): AquaNetUser {
// Create user
val user = AquaNetUser(
username = checkUsername(username),
email = validateEmail(email),
pwHash = checkPwHash(password),
regTime = millis(), lastLogin = millis(), country = country,
emailConfirmed = emailConfirmed
)
// Create a ghost card
val card = Card().apply {
extId = cardService.randExtID(cardExtIdStart, cardExtIdEnd)
luid = extId.toString()
registerTime = LocalDateTime.now()
accessTime = registerTime
aquaUser = user
isGhost = true
}
user.ghostCard = card
// Save the user
userRepo.save(user)
cardRepo.save(card)
return user
}
fun update(user: AquaNetUser, key: Str, value: Str) {
// Check if the key is a settable field
val field = SETTING_FIELDS.find { it.name == key } ?: (400 - "Invalid setting")
// Set the validated field
field.setter.call(user, field.checker.call(this, value))
}
fun clearAllSessions(user: AquaNetUser) = sessionRepo.deleteAll(sessionRepo.findByAquaNetUserAuId(user.auId))
suspend fun <T> byName(username: Str, callback: suspend (AquaNetUser) -> T) =
async { userRepo.findByUsernameIgnoreCase(username) }?.let { callback(it) } ?: (404 - "User not found")
suspend fun cardByName(username: Str) =
if (username.startsWith("user")) username.substring(4).toLongOrNull()
?.let { cardRepo.findById(it)() } ?: (404 - "Card not found")
else byName(username) { it.ghostCard }
suspend fun <T> cardByName(username: Str, callback: suspend (Card) -> T) = callback(cardByName(username))
fun validKeychip(keychipId: Str): Bool {
if (!allNetProps.checkKeychip) return true
if (keychipId.isBlank()) return false
if (userKeychipRepo.findByKeychipIdStartingWith(keychipId).truthy) return true
return false
}
fun checkUsername(username: Str) = username.apply {
// Check if username is valid
if (length < 2) 400 - "Username must be at least 2 letters"
if (length > 32) 400 - "Username too long (max 32 letters)"
if (contains(" ")) 400 - "Username cannot contain spaces"
// card{id} is a reserved format
if (startsWith("user") && substring(4).toLongOrNull() != null)
400 - "Username cannot be 'user' + a number. This format is reserved for user IDs."
// Check if username is within A-Za-z0-9_-~.
find { !it.isLetterOrDigit() && it != '_' && it != '-' && it != '~' && it != '.' }?.let {
400 - "Username cannot contain `$it`. Please only use letters (A-Z), numbers (0-9), and `_-~.` characters. You can set a display name later."
}
// Check if user with the same username exists
if (userRepo.findByUsernameIgnoreCase(this) != null)
400 - "User with username `$this` already exists"
}
fun validateEmail(email: Str): Str {
// Check if email is valid // Check if email is valid
if (!email.isValidEmail()) 400 - "Invalid email" if (!isValidEmail()) 400 - "Invalid email"
// Check if user with the same email exists // Check if user with the same email exists
if (userRepo.findByEmailIgnoreCase(email) != null) if (userRepo.findByEmailIgnoreCase(email) != null)
400 - "User with email `$email` already exists" 400 - "User with email `$email` already exists"
return email
} }
fun checkPwHash(password: Str): Str { fun checkPwHash(password: Str) = password.run {
// Validate password // Validate password
if (password.length < 8) 400 - "Password must be at least 8 characters" if (length < 8) 400 - "Password must be at least 8 characters"
return hasher.encode(password) ?: (500 - "Failed to hash password") hasher.encode(this)
} }
fun checkDisplayName(displayName: Str) = displayName.apply { fun checkDisplayName(displayName: Str) = displayName.apply {
// Check if display name is valid // Check if display name is valid
if (length > 32) 400 - "Display name too long (max 32 letters)" if (length > 32) 400 - "Display name too long (max 32 letters)"
} }
fun checkProfileLocation(profileLocation: Str) = profileLocation.apply { fun checkProfileLocation(profileLocation: Str) = profileLocation.apply {
// Check if profile location is valid // Check if profile location is valid
if (length > 64) 400 - "Profile location too long (max 64 letters)" if (length > 64) 400 - "Profile location too long (max 64 letters)"
} }
fun checkProfileBio(profileBio: Str) = profileBio.apply { fun checkProfileBio(profileBio: Str) = profileBio.apply {
// Check if profile bio is valid // Check if profile bio is valid
if (length > 255) 400 - "Profile bio too long (max 255 letters)" if (length > 255) 400 - "Profile bio too long (max 255 letters)"
} }
fun checkOptOutOfLeaderboard(optOutOfLeaderboard: Str) = optOutOfLeaderboard.toBoolean() fun checkOptOutOfLeaderboard(optOutOfLeaderboard: Str) = optOutOfLeaderboard.toBoolean()
fun checkHideCountry(hideCountry: Str) = hideCountry.toBoolean() }
fun checkDisplayCandidates(displayCandidates: Str) = displayCandidates.toBoolean()
}
@@ -1,12 +1,11 @@
package icu.samnyan.aqua.net.games package aquadx.net.games
import ext.* import ext.*
import icu.samnyan.aqua.net.BotProps import aquadx.net.BotProps
import icu.samnyan.aqua.net.db.AquaUserServices import aquadx.net.db.AquaUserServices
import icu.samnyan.aqua.net.utils.SUCCESS import aquadx.net.utils.SUCCESS
import icu.samnyan.aqua.sega.allnet.UserKeychipRepo import aquadx.sega.general.model.Card
import icu.samnyan.aqua.sega.general.model.Card import aquadx.sega.general.service.CardService
import icu.samnyan.aqua.sega.general.service.CardService
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
@@ -33,7 +32,6 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
open val gettableFields: Set<String> = setOf() open val gettableFields: Set<String> = setOf()
@Autowired lateinit var cardService: CardService @Autowired lateinit var cardService: CardService
@Autowired lateinit var userKeychipRepo: UserKeychipRepo
@API("trend") @API("trend")
abstract suspend fun trend(@RP username: String): List<TrendOut> abstract suspend fun trend(@RP username: String): List<TrendOut>
@@ -46,10 +44,10 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
} }
// List<Pair<should_hide, player>>> // List<Pair<should_hide, player>>>
private var rankingCache: List<GenericRankingPlayer> = emptyList() private var rankingCache: List<Pair<Bool, GenericRankingPlayer>> = emptyList()
private var rankingCacheLock = ReentrantLock() private var rankingCacheLock = ReentrantLock()
// Sorted index List<Rating> = Rank // Sorted index List<Rating> = Rank
private var rankingLookupCache: Map<Long, GenericRankingPlayer> = emptyMap() private var rankingSortedIndex: List<Int> = emptyList()
private val pageSize = 100 private val pageSize = 100
@API("ranking") @API("ranking")
@@ -57,30 +55,34 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
val time = millis() val time = millis()
// Check cache validity // Check cache validity
if (rankingCache.isEmpty()) (500 - "Rank is empty or is currently computing.") 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") }
return page?.let { return page?.let {
if (it < 0) (400 - "Invalid page number") if (it < 0) (400 - "Invalid page number")
rankingCache.drop(it * pageSize).take(pageSize) v.drop(it * pageSize).take(pageSize)
} ?: rankingCache } ?: v
} }
@PostConstruct @PostConstruct
fun rankingCacheInit() = thread { rankingCacheRun() } fun rakingCacheInit() = thread { rankingCacheRun() }
// Every 20 minutes // Every 20 minutes
@Scheduled(fixedRate = 20, initialDelay = 20, timeUnit = TimeUnit.MINUTES) @Scheduled(fixedRate = 20, timeUnit = TimeUnit.MINUTES)
fun rankingCacheRun() = rankingCacheLock.maybeLock { rankingCacheCompute() } fun rankingCacheRun() = rankingCacheLock.maybeLock { rankingCacheCompute() }
private val tableName = when (name) { "mai2" -> "maimai2"; "chu3" -> "chusan"; else -> name } 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() { fun rankingCacheCompute() {
val time = millis() val time = millis()
rankingCache = us.em.createNativeQuery( rankingCache = us.em.createNativeQuery(
@@ -88,26 +90,23 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
SELECT SELECT
c.id, c.id,
u.user_name, u.user_name,
${if (name == "ongeki") "u.new_player_rating" else "u.player_rating"} AS rating, u.player_rating,
u.last_play_date, u.last_play_date,
r.achievement_sum / NULLIF(r.play_count, 0) / 10000.0 AS acc, AVG(p.achievement) / 10000.0 AS acc,
r.full_combo_count AS fc, SUM(p.is_full_combo) AS fc,
r.all_perfect_count AS ap, SUM(p.is_all_perfect) AS ap,
c.ranking_banned or COALESCE(a.opt_out_of_leaderboard, 0) or c.status = 12 AS hide, c.ranking_banned or a.opt_out_of_leaderboard or c.status = 12 AS hide,
a.username a.username
FROM ${tableName}_user_ranking_cache r FROM ${tableName}_user_playlog_view p
JOIN $userDataTable u ON r.user_id = u.id JOIN ${tableName}_user_data_view u ON p.user_id = u.id
JOIN sega_card c ON u.$userCardColumn = c.id JOIN sega_card c ON u.aime_card_id = c.id
LEFT JOIN aqua_net_user a ON c.net_user_id = a.au_id LEFT JOIN aqua_net_user a ON c.net_user_id = a.au_id
WHERE r.play_count > 0 GROUP BY p.user_id, u.player_rating
AND NOT (c.ranking_banned or COALESCE(a.opt_out_of_leaderboard, 0) or c.status = 12) ORDER BY u.player_rating DESC;
${if (name == "ongeki") "AND u.new_player_rating > 0" else "" /* Hide users on Ongeki 1.45 and below */}
ORDER BY rating DESC;
""" """
).setHint("jakarta.persistence.query.timeout", 60_000).exec.mapIndexed { i, it -> ).exec.mapIndexed { i, it ->
GenericRankingPlayer( it[7].truthy to GenericRankingPlayer(
rank = i + 1, rank = i + 1,
id = it[0]!!.long,
name = it[1].toString(), name = it[1].toString(),
rating = it[2]!!.int, rating = it[2]!!.int,
lastSeen = it[3].toString(), lastSeen = it[3].toString(),
@@ -117,7 +116,7 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
username = it[8]?.toString() ?: "user${it[0]}" username = it[8]?.toString() ?: "user${it[0]}"
) )
} }
rankingLookupCache = rankingCache.associateBy { it.id } rankingSortedIndex = rankingCache.filter { !it.l }.map { it.r.rating }.reversed()
logger.info("Ranking for $name computed in ${millis() - time}ms") logger.info("Ranking for $name computed in ${millis() - time}ms")
} }
@@ -156,10 +155,6 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
userMusicRepo.findByUser_Card_ExtIdAndMusicIdIn(card.extId, musicList) 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 { 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 // Summary values: total plays, player rating, server-wide ranking
// number of each rank, max combo, number of full combo, number of all perfect // number of each rank, max combo, number of full combo, number of all perfect
@@ -185,14 +180,18 @@ 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( return GenericGameSummary(
name = user.userName, name = user.userName,
aquaUser = card.aquaUser?.publicFields, aquaUser = card.aquaUser?.publicFields,
serverRank = rankingLookupCache[user.card!!.id]?.rank?.str ?: "-", serverRank = serverRank.long,
accuracy = plays.acc(), accuracy = plays.acc(),
rating = getRating(user, false), rating = user.playerRating,
ratingHighest = getRating(user, true), ratingHighest = user.highestRating,
ratingNotGeneric = getRating(user, false) != user.playerRating,
ranks = ranks.map { (k, v) -> RankCount(k, v) }, ranks = ranks.map { (k, v) -> RankCount(k, v) },
detailedRanks = detailedRanks, detailedRanks = detailedRanks,
maxCombo = plays.maxOfOrNull { it.maxCombo } ?: 0, maxCombo = plays.maxOfOrNull { it.maxCombo } ?: 0,
@@ -206,7 +205,7 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
lastVersion = user.lastRomVersion, lastVersion = user.lastRomVersion,
ratingComposition = ratingComp, ratingComposition = ratingComp,
recent = plays.sortedBy { it.userPlayDate.toString() }.takeLast(100).reversed(), recent = plays.sortedBy { it.userPlayDate.toString() }.takeLast(100).reversed(),
lastPlayedHost = user.lastClientId?.let { userKeychipRepo.findByKeychipId(it)?.user?.username }, lastPlayedHost = user.lastClientId?.let { us.userRepo.findByKeychip(it)?.username },
rival = rival, rival = rival,
favorites = favorites favorites = favorites
) )
@@ -1,6 +1,8 @@
package icu.samnyan.aqua.net.games package aquadx.net.games
import ext.* import ext.isoDate
import ext.minus
import ext.mut
import java.time.LocalDate import java.time.LocalDate
const val LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + const val LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
@@ -15,10 +17,10 @@ const val WACCA_USERNAME_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
"0123456789" + "0123456789" +
"~|?!=()[]{},.:;\"@/_-+#*&%$" "~|?!=()[]{},.:;\"@/_-+#*&%$"
fun usernameCheck(isWacca: Bool): (IUserData, String) -> Unit = { u, v -> fun usernameCheck(chars: String): (IUserData, String) -> Unit = { u, v ->
u.userName = v
if (v.isBlank()) { 400 - "Username cannot be blank" } if (v.isBlank()) { 400 - "Username cannot be blank" }
if (v.length > 8) { 400 - "Username too long" } 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" } // v.find { it !in chars }?.let { 400 - "Invalid character '$it' in username" }
} }
@@ -1,18 +1,15 @@
package icu.samnyan.aqua.net.games package aquadx.net.games
import tools.jackson.core.JacksonException
import ext.* import ext.*
import icu.samnyan.aqua.net.Fedy import aquadx.net.Fedy
import icu.samnyan.aqua.net.db.AquaNetUser import aquadx.net.db.AquaNetUser
import icu.samnyan.aqua.net.db.AquaUserServices import aquadx.net.db.AquaUserServices
import icu.samnyan.aqua.net.utils.ApiException import aquadx.net.utils.AquaNetProps
import icu.samnyan.aqua.net.utils.AquaNetProps import aquadx.net.utils.SUCCESS
import icu.samnyan.aqua.net.utils.SUCCESS import aquadx.sega.general.model.Card
import icu.samnyan.aqua.sega.general.model.Card import aquadx.sega.general.service.CardService
import icu.samnyan.aqua.sega.general.service.CardService
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Lazy import org.springframework.context.annotation.Lazy
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.repository.NoRepositoryBean import org.springframework.data.repository.NoRepositoryBean
import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.PlatformTransactionManager
@@ -44,7 +41,7 @@ interface IExportClass<UserModel: IUserData> {
} }
@NoRepositoryBean @NoRepositoryBean
interface IUserRepo<UserModel : IUserData, ThisModel : Any>: JpaRepository<ThisModel, Long> { interface IUserRepo<UserModel, ThisModel>: JpaRepository<ThisModel, Long> {
fun findByUser(user: UserModel): List<ThisModel> fun findByUser(user: UserModel): List<ThisModel>
fun findSingleByUser(user: UserModel): ThisModel? fun findSingleByUser(user: UserModel): ThisModel?
} }
@@ -107,87 +104,55 @@ abstract class ImportController<ExportModel: IExportClass<UserModel>, UserModel:
export(u) 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") @Suppress("UNCHECKED_CAST")
@API("import") @API("import")
fun importUserData(@RP token: Str, @RB json: Str) = us.jwt.auth(token) { u -> fun importUserData(@RP token: Str, @RB json: Str) = us.jwt.auth(token) { u ->
try { val export = json.parseJackson(exportClass.java)
val export = parseImport(json) if (!export.gameId.equals(game, true)) 400 - "Invalid game ID"
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 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() 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 } var repoFieldMap = exportRepos.toList().associate { (f, r) -> r to f }
// Validate new user data // Validate new user data
// Check that all ids are 0 (this should be true since all ids are @JsonIgnore) // 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" if (export.userData.id != 0L) 400 - "User ID must be 0"
lists.values.flatten().forEach { if (it.id != 0L) 400 - "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" } singles.values.forEach { if (it.id != 0L) 400 - "ID must be 0" }
// Set user card // Set user card
export.userData.card = u.ghostCard export.userData.card = u.ghostCard
// Back up existing data before starting the replacement transaction. // Check existing data
val existingUserData = userDataRepo.findByCard(u.ghostCard) userDataRepo.findByCard(u.ghostCard)?.also { gu ->
existingUserData?.also { // Store a backup of the old data
// Store a backup of the old data val fl = "${game}-backup-${u.auId}-${LocalDateTime.now().urlSafeStr()}.json"
val fl = "${game}-backup-${u.auId}-${LocalDateTime.now().urlSafeStr()}.json" (Path(netProps.importBackupPath) / fl).writeText(export(u).toJson())
(Path(netProps.importBackupPath) / fl).writeText(export(u).toJson())
}
replaceInTransaction(existingUserData, u.auId) { // Delete the old data (After migration v1000.7, all user-linked entities have ON DELETE CASCADE)
// Insert new data log.info("$game Import: Deleting old data for user ${u.auId}")
val nu = userDataRepo.save(export.userData) userDataRepo.delete(gu)
// Set user fields userDataRepo.flush()
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)."
} }
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)
}
}
cardService.updateCardTimestamp(u.ghostCard, gameName, resetCreatedAt = true)
SUCCESS
} }
/** /**
@@ -1,9 +1,9 @@
package icu.samnyan.aqua.net.games package aquadx.net.games
import tools.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.DeserializationContext
import tools.jackson.databind.ValueDeserializer import com.fasterxml.jackson.databind.JsonDeserializer
import tools.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.module.SimpleModule
import ext.JACKSON import ext.JACKSON
import ext.minus import ext.minus
@@ -31,7 +31,7 @@ fun String.asSqlInsert(): SqlInsert {
} }
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "UNCHECKED_CAST") @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "UNCHECKED_CAST")
val JSON_INT_LIST_STR = SimpleModule().addDeserializer(List::class.java, object : ValueDeserializer<List<Integer>>() { val JSON_INT_LIST_STR = SimpleModule().addDeserializer(List::class.java, object : JsonDeserializer<List<Integer>>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) = override fun deserialize(parser: JsonParser, context: DeserializationContext) =
try { try {
val text = parser.text.trim('[', ']') val text = parser.text.trim('[', ']')
@@ -41,4 +41,6 @@ val JSON_INT_LIST_STR = SimpleModule().addDeserializer(List::class.java, object
400 - "Invalid list value ${parser.text}: $e" } 400 - "Invalid list value ${parser.text}: $e" }
}) })
val JACKSON_ARTEMIS = JACKSON.rebuild().addModule(JSON_INT_LIST_STR).build() val JACKSON_ARTEMIS = JACKSON.copy().apply {
registerModule(JSON_INT_LIST_STR)
}
@@ -0,0 +1,29 @@
package aquadx.net.games
import aquadx.sega.general.model.Card
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.NoRepositoryBean
@NoRepositoryBean
interface GenericUserDataRepo<T : IUserData> : JpaRepository<T, Long> {
fun findByCard(card: Card): T?
fun findByCard_ExtId(extId: Long): T?
@Query("select e from #{'#'}{#entityName} e where e.card.rankingBanned = false")
fun findAllNonBanned(): List<T>
}
@NoRepositoryBean
interface GenericPlaylogRepo<T: IGenericGamePlaylog> : JpaRepository<T, Long> {
fun findByUserCardExtId(extId: Long): List<T>
fun findByUserCardExtId(extId: Long, page: Pageable): Page<T>
}
@NoRepositoryBean
interface GenericUserMusicRepo<T: IGenericUserMusic> : JpaRepository<T, Long> {
fun findByUserCardExtId(extId: Long): List<T>
fun findByUser_Card_ExtIdAndMusicIdIn(userId: Long, musicId: List<Int>): List<T>
}
@@ -1,19 +1,17 @@
package icu.samnyan.aqua.net.games.chu3 package aquadx.net.games.chu3
import ext.API import ext.API
import ext.returns import ext.returns
import ext.vars import ext.vars
import icu.samnyan.aqua.net.games.ExportOptions import aquadx.net.games.IExportClass
import icu.samnyan.aqua.net.games.IExportClass import aquadx.net.games.ImportClass
import icu.samnyan.aqua.net.games.ImportClass import aquadx.net.games.ImportController
import icu.samnyan.aqua.net.games.ImportController import aquadx.sega.chusan.model.Chu3Repos
import icu.samnyan.aqua.sega.chusan.model.Chu3Repos import aquadx.sega.chusan.model.Chu3UserLinked
import icu.samnyan.aqua.sega.chusan.model.Chu3UserLinked import aquadx.sega.chusan.model.userdata.*
import icu.samnyan.aqua.sega.chusan.model.userdata.*
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import kotlin.reflect.full.declaredMembers import kotlin.reflect.full.declaredMembers
@Suppress("UNCHECKED_CAST")
@RestController @RestController
@API("api/v2/game/chu3") @API("api/v2/game/chu3")
class Chu3Import( class Chu3Import(
@@ -24,7 +22,7 @@ class Chu3Import(
it.name.replace("List", "").lowercase() it.name.replace("List", "").lowercase()
}, },
exportRepos = Chu3DataExport::class.vars() exportRepos = Chu3DataExport::class.vars()
.filter { f -> f.name !in setOf("gameId", "userData", "userLoginBonusList") } .filter { f -> f.name !in setOf("gameId", "userData") }
.associateWith { Chu3Repos::class.declaredMembers .associateWith { Chu3Repos::class.declaredMembers
.filter { f -> f returns Chu3UserLinked::class } .filter { f -> f returns Chu3UserLinked::class }
.firstOrNull { f -> f.name == it.name || f.name == it.name.replace("List", "") } .firstOrNull { f -> f.name == it.name || f.name == it.name.replace("List", "") }
@@ -46,22 +44,7 @@ class Chu3Import(
// "chuni_profile_emoney" to ImportClass(UserEmoney::class), // "chuni_profile_emoney" to ImportClass(UserEmoney::class),
// "chuni_profile_overpower" to ImportClass(UserOverpower::class), // "chuni_profile_overpower" to ImportClass(UserOverpower::class),
// "chuni_profile_recent_rating" to ImportClass(UserRecentRating::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 fun createEmpty() = Chu3DataExport()
override val userDataRepo = repos.userData override val userDataRepo = repos.userData
@@ -81,20 +64,7 @@ data class Chu3DataExport(
var userMapList: List<UserMap>, var userMapList: List<UserMap>,
var userMusicDetailList: List<UserMusicDetail>, var userMusicDetailList: List<UserMusicDetail>,
var userPlaylogList: List<UserPlaylog>, 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> { ): IExportClass<Chu3UserData> {
constructor() : this("SDHD", constructor() : this("SDHD",
Chu3UserData(), UserGameOption(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), Chu3UserMisc()) Chu3UserData(), UserGameOption(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList())
} }
@@ -1,15 +1,15 @@
package icu.samnyan.aqua.net.games.chu3 package aquadx.net.games.chu3
import ext.* import ext.*
import icu.samnyan.aqua.net.db.AquaUserServices import aquadx.net.db.AquaUserServices
import icu.samnyan.aqua.net.games.* import aquadx.net.games.*
import icu.samnyan.aqua.net.utils.chu3Scores import aquadx.net.utils.chu3Scores
import icu.samnyan.aqua.sega.chusan.model.Chu3Repos import aquadx.sega.chusan.model.Chu3Repos
import icu.samnyan.aqua.sega.chusan.model.Chu3UserDataRepo import aquadx.sega.chusan.model.Chu3UserDataRepo
import icu.samnyan.aqua.sega.chusan.model.Chu3UserMusicDetailRepo import aquadx.sega.chusan.model.Chu3UserMusicDetailRepo
import icu.samnyan.aqua.sega.chusan.model.Chu3UserPlaylogRepo import aquadx.sega.chusan.model.Chu3UserPlaylogRepo
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserData import aquadx.sega.chusan.model.userdata.Chu3UserData
import icu.samnyan.aqua.sega.chusan.model.userdata.UserGameOption import aquadx.sega.chusan.model.userdata.UserGameOption
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull
import kotlin.reflect.KMutableProperty1 import kotlin.reflect.KMutableProperty1
@@ -33,7 +33,7 @@ class Chusan(
// Only show > AAA rank // Only show > AAA rank
override val shownRanks = chu3Scores.filter { it.first >= 95 * 10000 } override val shownRanks = chu3Scores.filter { it.first >= 95 * 10000 }
override val settableFields: Map<String, (Chu3UserData, String) -> Unit> by lazy { mapOf( override val settableFields: Map<String, (Chu3UserData, String) -> Unit> by lazy { mapOf(
"userName" to usernameCheck(false), "userName" to usernameCheck(SEGA_USERNAME_CHARS),
"nameplateId" to { u, v -> u.nameplateId = v.int }, "nameplateId" to { u, v -> u.nameplateId = v.int },
"frameId" to { u, v -> u.frameId = v.int }, "frameId" to { u, v -> u.frameId = v.int },
"trophyId" to { u, v -> u.trophyId = v.int }, "trophyId" to { u, v -> u.trophyId = v.int },
@@ -1,16 +1,16 @@
package icu.samnyan.aqua.net.games.mai2 package aquadx.net.games.mai2
import ext.API import ext.API
import ext.returns import ext.returns
import ext.vars import ext.vars
import icu.samnyan.aqua.net.games.ExportOptions import aquadx.net.games.ExportOptions
import icu.samnyan.aqua.net.games.IExportClass import aquadx.net.games.IExportClass
import icu.samnyan.aqua.net.games.ImportClass import aquadx.net.games.ImportClass
import icu.samnyan.aqua.net.games.ImportController import aquadx.net.games.ImportController
import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos import aquadx.sega.maimai2.model.Mai2Repos
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserLinked import aquadx.sega.maimai2.model.Mai2UserLinked
import icu.samnyan.aqua.sega.maimai2.model.request.Mai2UserFavoriteItem import aquadx.sega.maimai2.model.request.Mai2UserFavoriteItem
import icu.samnyan.aqua.sega.maimai2.model.userdata.* import aquadx.sega.maimai2.model.userdata.*
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import kotlin.reflect.full.declaredMembers import kotlin.reflect.full.declaredMembers
@@ -68,7 +68,7 @@ class Mai2Import(
?.split(",") ?.split(",")
?.mapIndexed { index, id -> Mai2UserFavoriteItem().apply { orderId = index; this.id = id.toInt() } } ?.mapIndexed { index, id -> Mai2UserFavoriteItem().apply { orderId = index; this.id = id.toInt() } }
?: emptyList() ?: emptyList()
}, }
) as Map<kotlin.reflect.KMutableProperty1<Maimai2DataExport, Any>, (Mai2UserDetail, ExportOptions) -> Any?>, ) as Map<kotlin.reflect.KMutableProperty1<Maimai2DataExport, Any>, (Mai2UserDetail, ExportOptions) -> Any?>,
customImporters = mapOf( customImporters = mapOf(
Maimai2DataExport::userPlaylogList to { export: Maimai2DataExport, user: Mai2UserDetail -> Maimai2DataExport::userPlaylogList to { export: Maimai2DataExport, user: Mai2UserDetail ->
@@ -85,7 +85,7 @@ class Mai2Import(
propertyValue = favoriteMusicList.sortedBy { it.orderId }.map { it.id }.joinToString(",") propertyValue = favoriteMusicList.sortedBy { it.orderId }.map { it.id }.joinToString(",")
}) })
} }
}, }
) as Map<kotlin.reflect.KMutableProperty1<Maimai2DataExport, Any>, (Maimai2DataExport, Mai2UserDetail) -> Unit> ) as Map<kotlin.reflect.KMutableProperty1<Maimai2DataExport, Any>, (Maimai2DataExport, Mai2UserDetail) -> Unit>
) { ) {
override fun createEmpty() = Maimai2DataExport() override fun createEmpty() = Maimai2DataExport()
@@ -113,9 +113,6 @@ data class Maimai2DataExport(
var userFavoriteMusicList: List<Mai2UserFavoriteItem> = mutableListOf(), var userFavoriteMusicList: List<Mai2UserFavoriteItem> = mutableListOf(),
var userKaleidxScopeList: List<Mai2UserKaleidx> = mutableListOf(), var userKaleidxScopeList: List<Mai2UserKaleidx> = mutableListOf(),
var userPlaylogList: List<Mai2UserPlaylog> = mutableListOf(), var userPlaylogList: List<Mai2UserPlaylog> = mutableListOf(),
var userCardList: List<Mai2UserCard> = mutableListOf(),
var userPrintDetailList: List<Mai2UserPrintDetail> = mutableListOf(),
var userRegionsList: List<UserRegions> = mutableListOf(),
// Not supported yet: // Not supported yet:
// var userWeeklyData // var userWeeklyData
// var userMissionDataList // var userMissionDataList
@@ -1,14 +1,14 @@
package icu.samnyan.aqua.net.games.mai2 package aquadx.net.games.mai2
import ext.API import ext.API
import ext.RB import ext.RB
import ext.RP import ext.RP
import ext.minus import ext.minus
import icu.samnyan.aqua.net.db.AquaUserServices import aquadx.net.db.AquaUserServices
import icu.samnyan.aqua.net.utils.SUCCESS import aquadx.net.utils.SUCCESS
import icu.samnyan.aqua.sega.general.service.CardService import aquadx.sega.general.service.CardService
import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos import aquadx.sega.maimai2.model.Mai2Repos
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserMusicDetail import aquadx.sega.maimai2.model.userdata.Mai2UserMusicDetail
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@@ -1,26 +1,22 @@
package icu.samnyan.aqua.net.games.mai2 package aquadx.net.games.mai2
import ext.* import ext.*
import icu.samnyan.aqua.net.db.AquaUserServices import aquadx.net.db.AquaUserServices
import icu.samnyan.aqua.net.games.* import aquadx.net.games.*
import icu.samnyan.aqua.net.utils.SUCCESS import aquadx.net.utils.SUCCESS
import icu.samnyan.aqua.net.utils.mai2Scores import aquadx.net.utils.mai2Scores
import icu.samnyan.aqua.sega.maimai2.handler.UploadUserPhotoHandler import aquadx.sega.maimai2.handler.UploadUserPhotoHandler
import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos import aquadx.sega.maimai2.model.Mai2Repos
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo import aquadx.sega.maimai2.model.Mai2UserDataRepo
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserMusicDetailRepo import aquadx.sega.maimai2.model.Mai2UserMusicDetailRepo
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserPlaylogRepo import aquadx.sega.maimai2.model.Mai2UserPlaylogRepo
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserDetail import aquadx.sega.maimai2.model.userdata.Mai2UserDetail
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserGeneralData import aquadx.sega.maimai2.model.userdata.Mai2UserGeneralData
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserLoginBonus import aquadx.sega.maimai2.model.userdata.Mai2UserLoginBonus
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserOption import aquadx.sega.maimai2.model.userdata.Mai2UserOption
import jakarta.annotation.PostConstruct
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController 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.jvm.optionals.getOrNull
import kotlin.reflect.KMutableProperty1 import kotlin.reflect.KMutableProperty1
import kotlin.reflect.full.memberProperties import kotlin.reflect.full.memberProperties
@@ -42,7 +38,7 @@ class Maimai2(
// Only show > S rank // Only show > S rank
override val shownRanks = mai2Scores.filter { it.first >= 97 * 10000 } override val shownRanks = mai2Scores.filter { it.first >= 97 * 10000 }
override val settableFields: Map<String, (Mai2UserDetail, String) -> Unit> by lazy { mapOf( override val settableFields: Map<String, (Mai2UserDetail, String) -> Unit> by lazy { mapOf(
"userName" to usernameCheck(false), "userName" to usernameCheck(SEGA_USERNAME_CHARS),
"iconId" to { u, v -> u.iconId = v.int() }, "iconId" to { u, v -> u.iconId = v.int() },
"plateId" to { u, v -> u.plateId = v.int() }, "plateId" to { u, v -> u.plateId = v.int() },
"titleId" to { u, v -> u.titleId = v.int() }, "titleId" to { u, v -> u.titleId = v.int() },
@@ -62,7 +58,7 @@ class Maimai2(
val ratingComposition = mapOf( val ratingComposition = mapOf(
"best35" to (extra["recent_rating"] ?: ""), "best35" to (extra["recent_rating"] ?: ""),
"new15" to (extra["recent_rating_new"] ?: "") "best15" to (extra["recent_rating_new"] ?: "")
) )
// if isLogin than boolean or null // if isLogin than boolean or null
@@ -211,69 +207,23 @@ class Maimai2(
} }
val photoDir = UploadUserPhotoHandler.uploadDir.toFile().canonicalFile val photoDir = UploadUserPhotoHandler.uploadDir.toFile().canonicalFile
val photoHashMap = ConcurrentHashMap<String, String>()
// 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") @API("my-photo")
suspend fun myPhoto( suspend fun myPhoto(@RP token: Str) = us.jwt.auth(token) { u ->
@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 find = "${u.ghostCard.extId}-"
val files = photoDir.listFiles() photoDir.listFiles()
?.map { it.name } ?.map { it.name }
?.filter { it.startsWith(find) } ?.filter { it.startsWith(find) }
?.sortedDescending() ?.sorted()
?: emptyList() ?: 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]) @API("my-photo/{fileName}", produces = [MediaType.IMAGE_JPEG_VALUE])
suspend fun accessMyPhoto(@PV fileName: Str): ByteArray { suspend fun myPhoto(@RP token: Str, @PV fileName: Str) = us.jwt.auth(token) { u ->
val trueFileName = photoHashMap.entries.firstOrNull { it.value == fileName }?.key val f = (photoDir / fileName)
if (trueFileName.isNullOrEmpty()) 404 - "Photo does not exist" if (!f.canonicalFile.startsWith(photoDir)) (403 - "Never gonna give you up")
val file = (photoDir / trueFileName) if (!f.name.startsWith("${u.ghostCard.extId}-")) (403 - "Not your photo")
return file.readBytes() if (!f.exists()) (404 - "Photo not found")
f.readBytes()
} }
} }
@@ -1,21 +1,19 @@
package icu.samnyan.aqua.net.games.ongeki package aquadx.net.games.ongeki
import ext.API import ext.API
import ext.Bool
import ext.RP import ext.RP
import ext.minus import ext.minus
import icu.samnyan.aqua.net.db.AquaUserServices import aquadx.net.db.AquaUserServices
import icu.samnyan.aqua.net.games.* import aquadx.net.games.*
import icu.samnyan.aqua.net.utils.ongekiScores import aquadx.net.utils.ongekiScores
import icu.samnyan.aqua.sega.ongeki.OgkUserDataRepo import aquadx.sega.ongeki.OgkUserDataRepo
import icu.samnyan.aqua.sega.ongeki.OgkUserGeneralDataRepo import aquadx.sega.ongeki.OgkUserGeneralDataRepo
import icu.samnyan.aqua.sega.ongeki.OgkUserMusicDetailRepo import aquadx.sega.ongeki.OgkUserMusicDetailRepo
import icu.samnyan.aqua.sega.ongeki.OgkUserOptionRepo import aquadx.sega.ongeki.OgkUserOptionRepo
import icu.samnyan.aqua.sega.ongeki.OgkUserPlaylogRepo import aquadx.sega.ongeki.OgkUserPlaylogRepo
import icu.samnyan.aqua.sega.ongeki.model.UserData import aquadx.sega.ongeki.model.UserData
import icu.samnyan.aqua.sega.ongeki.model.UserOption import aquadx.sega.ongeki.model.UserOption
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import kotlin.jvm.optionals.getOrNull
import kotlin.reflect.KMutableProperty1 import kotlin.reflect.KMutableProperty1
import kotlin.reflect.full.memberProperties import kotlin.reflect.full.memberProperties
@@ -36,39 +34,23 @@ class Ongeki(
override val shownRanks = ongekiScores.filter { it.first >= 950000 } override val shownRanks = ongekiScores.filter { it.first >= 950000 }
override val settableFields: Map<String, (UserData, String) -> Unit> by lazy { mapOf( override val settableFields: Map<String, (UserData, String) -> Unit> by lazy { mapOf(
"userName" to usernameCheck(false), "userName" to usernameCheck(SEGA_USERNAME_CHARS),
"lastRomVersion" to { u, v -> u.lastRomVersion = v }, "lastRomVersion" to { u, v -> u.lastRomVersion = v },
"lastDataVersion" to { u, v -> u.lastDataVersion = v }, "lastDataVersion" to { u, v -> u.lastDataVersion = v },
) } ) }
override suspend fun userSummary(username: String, token: String?) = us.cardByName(username) { card -> 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) val extra = userGeneralDataRepository.findByUser_Card_ExtId(card.extId)
.associate { it.propertyKey to it.propertyValue } .associate { it.propertyKey to it.propertyValue }
val isRefresh = user.newHighestRating > 0 val ratingComposition = mapOf(
if (isRefresh) "best30" to (extra["rating_base_best"] ?: ""),
genericUserSummary(card, mapOf( "best15" to (extra["rating_base_new_best"] ?: ""),
"best50" to (extra["new_rating_base_best"] ?: ""), "recent10" to (extra["rating_base_hot_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"] ?: "")
))
}
override fun getRating(user: UserData, isHighest: Bool): Int { genericUserSummary(card, ratingComposition)
return if (user.newHighestRating > 0) {
if (isHighest) user.newHighestRating else user.newPlayerRating
} else if (isHighest) user.highestRating else user.playerRating
} }
@API("user-option") @API("user-option")
@@ -0,0 +1,44 @@
package aquadx.net.games.wacca
import ext.API
import ext.RP
import ext.isoDate
import ext.utc
import aquadx.net.db.AquaUserServices
import aquadx.net.games.*
import aquadx.net.utils.waccaScores
import aquadx.sega.wacca.model.db.WaccaUser
import aquadx.sega.wacca.model.db.WcUserBestScoreRepo
import aquadx.sega.wacca.model.db.WcUserPlayLogRepo
import aquadx.sega.wacca.model.db.WcUserRepo
import org.springframework.web.bind.annotation.RestController
@RestController
@API("api/v2/game/wacca")
class Wacca(
override val us: AquaUserServices,
override val playlogRepo: WcUserPlayLogRepo,
override val userDataRepo: WcUserRepo,
override val userMusicRepo: WcUserBestScoreRepo,
): GameApiController<WaccaUser>("wacca", WaccaUser::class) {
override val settableFields: Map<String, (WaccaUser, String) -> Unit> by lazy { mapOf(
"userName" to usernameCheck(WACCA_USERNAME_CHARS),
"lastRomVersion" to { u, v -> u.lastRomVersion = v },
) }
override suspend fun trend(@RP username: String) = us.cardByName(username) { card ->
findTrend(playlogRepo.findByUserCardExtId(card.extId)
.map { TrendLog(it.userPlayDate.utc().isoDate(), it.afterRating) })
}
override suspend fun userSummary(@RP username: String, @RP token: String?) = us.cardByName(username) { card ->
// TODO: Rating composition
val data = userDataRepo.findByCard_ExtId(card.extId)
genericUserSummary(card, mapOf(), null, data?.favoriteSongs)
}
override val shownRanks: List<Pair<Int, String>> = waccaScores.filter { it.first > 85 * 10000 }
}
@@ -1,14 +1,14 @@
package icu.samnyan.aqua.net.transfer package aquadx.net.transfer
import ext.header import ext.header
import ext.post import ext.post
import ext.request import ext.request
import icu.samnyan.aqua.sega.aimedb.AimeDbClient import aquadx.sega.aimedb.AimeDbClient
import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder import aquadx.sega.allnet.AllNetBillingDecoder
import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder.decodeAllNetResp import aquadx.sega.allnet.AllNetBillingDecoder.decodeAllNetResp
import java.net.URI import java.net.URI
val keychipPattern = Regex("([A-Z\\d]{4}-[A-Z\\d]{11}|[A-Z\\d]{15})") val keychipPattern = Regex("([A-Z\\d]{4}-[A-Z\\d]{11}|[A-Z\\d]{11})")
class AllNetClient(val dns: String, val keychip: String, val game: String, val version: String, val card: String) { class AllNetClient(val dns: String, val keychip: String, val game: String, val version: String, val card: String) {
init { init {
@@ -1,19 +1,19 @@
package icu.samnyan.aqua.net.transfer package aquadx.net.transfer
import ext.* import ext.*
import icu.samnyan.aqua.sega.chusan.model.request.Chu3UserAll import aquadx.sega.chusan.model.request.Chu3UserAll
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserActivity import aquadx.sega.chusan.model.userdata.Chu3UserActivity
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserItem import aquadx.sega.chusan.model.userdata.Chu3UserItem
import icu.samnyan.aqua.sega.chusan.model.userdata.UserMusicDetail import aquadx.sega.chusan.model.userdata.UserMusicDetail
import icu.samnyan.aqua.sega.maimai2.model.request.Mai2UserAll import aquadx.sega.maimai2.model.request.Mai2UserAll
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserFavorite import aquadx.sega.maimai2.model.userdata.Mai2UserFavorite
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserItem import aquadx.sega.maimai2.model.userdata.Mai2UserItem
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserMusicDetail import aquadx.sega.maimai2.model.userdata.Mai2UserMusicDetail
import icu.samnyan.aqua.sega.ongeki.model.OngekiUpsertUserAll import aquadx.sega.ongeki.model.OngekiUpsertUserAll
import icu.samnyan.aqua.sega.ongeki.model.UserItem import aquadx.sega.ongeki.model.UserItem
import icu.samnyan.aqua.sega.util.BasicMapper import aquadx.sega.util.BasicMapper
import icu.samnyan.aqua.sega.util.IMapper import aquadx.sega.util.IMapper
import icu.samnyan.aqua.sega.util.StringMapper import aquadx.sega.util.StringMapper
abstract class DataBroker( abstract class DataBroker(
@@ -131,7 +131,7 @@ class OngekiDataBroker(allNet: AllNetClient, log: (String) -> Unit): DataBroker(
override val mapper = BasicMapper() override val mapper = BasicMapper()
override val url by lazy { allNet.gameUrl.ensureNoEndingSlash() } override val url by lazy { allNet.gameUrl.ensureNoEndingSlash() }
class UserMusicWrapper(var userMusicDetailList: List<icu.samnyan.aqua.sega.ongeki.model.UserMusicDetail>) class UserMusicWrapper(var userMusicDetailList: List<aquadx.sega.ongeki.model.UserMusicDetail>)
override fun pull(): String { override fun pull(): String {
val (userId, paged) = prePull() val (userId, paged) = prePull()
@@ -1,4 +1,4 @@
package icu.samnyan.aqua.net.transfer package aquadx.net.transfer
import ext.* import ext.*
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
@@ -1,4 +1,4 @@
package icu.samnyan.aqua.net.utils package aquadx.net.utils
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties
@@ -10,7 +10,6 @@ import kotlin.io.path.createDirectories
@ConfigurationProperties(prefix = "aqua-net") @ConfigurationProperties(prefix = "aqua-net")
class AquaNetProps { class AquaNetProps {
var linkCardLimit: Int = 10 var linkCardLimit: Int = 10
var keychipLimit: Int = 20
var importBackupPath = "data/import-backups" var importBackupPath = "data/import-backups"
@PostConstruct @PostConstruct

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