mirror of
https://github.com/MewoLab/AquaDX.git
synced 2026-10-06 21:57:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a0254ec19 | ||
|
|
c3661d3c94 | ||
|
|
bcbc770ca7 | ||
|
|
55b47a3c02 | ||
|
|
97743f55ef | ||
|
|
11c5617abc | ||
|
|
cf89e59af8 | ||
|
|
587557177e | ||
|
|
6285bd3fd4 | ||
|
|
7ba95f0b90 | ||
|
|
13aa48982f | ||
|
|
0a5c97d985 | ||
|
|
c1b6c3e414 | ||
|
|
5e6aaace8c | ||
|
|
3be81520ce | ||
|
|
30139cae8d | ||
|
|
96570a97ff | ||
|
|
7eab79e48d | ||
|
|
05e613d667 | ||
|
|
d34aab37b2 | ||
|
|
4aba8d8642 | ||
|
|
0d70471bd6 | ||
|
|
7ea5add407 | ||
|
|
41edddcaec | ||
|
|
1d36998967 | ||
|
|
3178c4c957 | ||
|
|
48eb8a9c07 | ||
|
|
fcee9fb278 | ||
|
|
853abc8867 | ||
|
|
c099a8954e | ||
|
|
96a894aa70 | ||
|
|
0b3d52d31d | ||
|
|
27685087be | ||
|
|
fec3480963 | ||
|
|
536734746b | ||
|
|
b352576358 | ||
|
|
f2ea015ef3 | ||
|
|
253c7a73d6 | ||
|
|
e62115e6ac | ||
|
|
08bf69c723 | ||
|
|
3e2c198204 | ||
|
|
c3f75e6428 | ||
|
|
4a6003ded3 | ||
|
|
b114adf940 | ||
|
|
7c31dfd4ab | ||
|
|
faabd45b7a | ||
|
|
d7254913cd | ||
|
|
237ba1464a | ||
|
|
d5d50d687c | ||
|
|
2c28758ae0 | ||
|
|
f025465f40 | ||
|
|
4b5eeadc2b | ||
|
|
898939485c | ||
|
|
593783866a | ||
|
|
9b3871706f | ||
|
|
5be6d8c80b | ||
|
|
f750cc6596 | ||
|
|
f67bbb822d | ||
|
|
819c058517 | ||
|
|
b2feb9a130 | ||
|
|
6a10570fa6 | ||
|
|
6243ee7205 | ||
|
|
72c1f48bdb | ||
|
|
06eec89dfe | ||
|
|
367a26c109 | ||
|
|
2552568ac1 | ||
|
|
512a791aa4 | ||
|
|
9bdc84418a | ||
|
|
243a9b2ddc | ||
|
|
f313f090f8 | ||
|
|
2e55247de0 | ||
|
|
4895456bb0 | ||
|
|
aec0fdac8b | ||
|
|
eb600f1715 | ||
|
|
bd6a641336 | ||
|
|
aeebf069c2 | ||
|
|
a77111b2ad | ||
|
|
031f9e4663 | ||
|
|
1181707b43 | ||
|
|
927fa56862 | ||
|
|
d17073297c | ||
|
|
34a3de1209 | ||
|
|
3a42435dc5 | ||
|
|
7865f7cfa7 | ||
|
|
e711921725 | ||
|
|
d0110d7f47 | ||
|
|
26748719a0 | ||
|
|
b20fe6fa62 | ||
|
|
9aec50d901 | ||
|
|
c31c1a0524 | ||
|
|
c989c273c4 | ||
|
|
c4c550b239 | ||
|
|
22334de8a7 | ||
|
|
50cb970828 | ||
|
|
80c088626e | ||
|
|
2d5fb11dc0 | ||
|
|
708532d18b | ||
|
|
dad66401cc | ||
|
|
1bc89c46f1 | ||
|
|
1d3ddfc06c | ||
|
|
34cfa279dc | ||
|
|
2e21926189 | ||
|
|
7a946708c3 | ||
|
|
fcf7c46cad | ||
|
|
28063912ce | ||
|
|
beacca12c3 |
@@ -0,0 +1,8 @@
|
||||
*
|
||||
!gradlew
|
||||
!gradle/
|
||||
!gradle/**
|
||||
!build.gradle.kts
|
||||
!settings.gradle.kts
|
||||
!src/
|
||||
!src/**
|
||||
@@ -12,9 +12,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
java-version: '25'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Build with Gradle
|
||||
|
||||
@@ -17,9 +17,9 @@ jobs:
|
||||
fetch-depth: '10'
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
java-version: '25'
|
||||
distribution: 'temurin'
|
||||
server-id: github
|
||||
|
||||
|
||||
@@ -84,3 +84,6 @@ src/main/resources/meta/*/*.json
|
||||
test-diff
|
||||
htmlReport
|
||||
docs/logs
|
||||
|
||||
# Sensitive keys
|
||||
game_encryption.json
|
||||
@@ -1,10 +0,0 @@
|
||||
VITE_AQUA_HOST=https://aquadx.net/aqua
|
||||
VITE_DATA_HOST=https://aquadx.net
|
||||
|
||||
VITE_AQUA_CONNECTION=aquadx.hydev.org
|
||||
|
||||
VITE_TURNSTILE_SITE_KEY=0x4AAAAAAASGA2KQEIelo9P9
|
||||
VITE_DISCORD_INVITE=https://discord.gg/FNgveqFF7s
|
||||
VITE_TELEGRAM_INVITE=https://t.me/+zBL4RZdyfvUzZGU1
|
||||
VITE_QQ_INVITE=https://qm.qq.com/q/dpYmGoVHnG
|
||||
VITE_GITHUB_REPOSITORY=https://github.com/MewoLab/AquaDX
|
||||
@@ -0,0 +1,26 @@
|
||||
# This should reference your AquaDX instance's :80 port.
|
||||
# Sometimes issues with CORS may appear, the easiest fix is to use a reverse proxy like Caddy to put AquaDX on the /aqua path,
|
||||
# and set this to match the /aqua path (see example below)
|
||||
VITE_AQUA_HOST=https://aquadx.net/aqua
|
||||
|
||||
# Link to converted game data (ex: all_music.json, all_items.json, etc.)
|
||||
# Profile pictures are also served through this path, you may want to disable profile pictures if using the primary instance for data fetching
|
||||
VITE_DATA_HOST=https://aquadx.net
|
||||
|
||||
VITE_AQUA_CONNECTION=aquadx.hydev.org
|
||||
VITE_TURNSTILE_SITE_KEY=0x4AAAAAAASGA2KQEIelo9P9
|
||||
VITE_DISCORD_INVITE=https://discord.gg/FNgveqFF7s
|
||||
VITE_TELEGRAM_INVITE=https://t.me/+zBL4RZdyfvUzZGU1
|
||||
VITE_QQ_INVITE=https://qm.qq.com/q/dpYmGoVHnG
|
||||
VITE_GITHUB_REPOSITORY=https://github.com/MewoLab/AquaDX
|
||||
|
||||
# Documentation for Userbox mode can be found in `docs/aquabox-url-mode.md`
|
||||
# Please note that if this is set, it must be manually unset by users in Chuni Settings -> Update Userbox -> Switch to URL mode -> (empty value) -> Enter key
|
||||
VITE_USERBOX_DEFAULT_URL=
|
||||
|
||||
# the following is used at some case where custom access code QR code URL has been done.
|
||||
# example:
|
||||
# https://aquadx.net/cards/#accesskey=....
|
||||
VITE_ACCESSCODE_QKEY=accesskey
|
||||
|
||||
VITE_DEFAULT_GAME=mai2
|
||||
+3
-1
@@ -35,4 +35,6 @@ dist-ssr
|
||||
public/chu3
|
||||
|
||||
# local env file
|
||||
*.local
|
||||
*.local
|
||||
|
||||
.env
|
||||
|
||||
+18
-5
@@ -9,12 +9,25 @@ This project uses Svelte (NOT SvelteKit) + TypeScript + Sass, built using Vite.
|
||||
|
||||
### Running locally
|
||||
|
||||
First, you would need to install Node.js and bun.
|
||||
Then, you would need to start your testing AquaDX server and configure the `aqua_host` in `src/libs/config.ts` to use your URL.
|
||||
Please leave `data_host` unchanged if you're not sure what it is.
|
||||
Finally, run:
|
||||
You will need to have Node.js (or equivalent) and `pnpm` installed, Start by installing dependencies by running:
|
||||
```
|
||||
pnpm install
|
||||
```
|
||||
|
||||
```shell
|
||||
Then, you would need to start your testing AquaDX server and configure the `VITE_AQUA_HOST` in `.env` (copy `.env.example` file to `.env`) to use your URL.
|
||||
Please leave `VITE_DATA_HOST` unchanged if you're not sure what it is.
|
||||
|
||||
Finally, run:
|
||||
```
|
||||
pnpm run dev
|
||||
```
|
||||
### Running locally (with bun)
|
||||
|
||||
Alternatively, If you prefer [Bun](https://bun.sh), You can do the same as above.
|
||||
Start by configuring the `VITE_AQUA_HOST` in `.env` (copy `.env.example` file to `.env`) to use your URL.
|
||||
|
||||
and finally:
|
||||
```
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { existsSync } from "fs"
|
||||
|
||||
if (!existsSync(".env")) {
|
||||
console.error(" I did not see .env being here. You sure it was there?")
|
||||
console.error(" Copy .env.example to .env, edit .env, and try again.\n")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
@@ -2,7 +2,6 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AquaNet</title>
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "node buildcheck.js && vite",
|
||||
"build": "node buildcheck.js && vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"lint": "eslint . --ext ts,tsx,svelte --max-warnings 0 --fix"
|
||||
|
||||
+13
-4
@@ -9,7 +9,8 @@
|
||||
import Settings from "./pages/User/Settings.svelte";
|
||||
import MaiPhoto from "./pages/MaiPhoto.svelte";
|
||||
import { pfp, tooltip } from "./libs/ui"
|
||||
import { ANNOUNCEMENT } from "./libs/config";
|
||||
import { ANNOUNCEMENT } from "./libs/announcement";
|
||||
import { DEFAULT_GAME } from "./libs/config"
|
||||
import { t } from "./libs/i18n";
|
||||
import Transfer from "./pages/Transfer/Transfer.svelte";
|
||||
import { link } from "d3";
|
||||
@@ -34,12 +35,19 @@
|
||||
let me: AquaNetUser
|
||||
let playedMai = false
|
||||
|
||||
let recentGame: string = DEFAULT_GAME;
|
||||
|
||||
if (USER.isLoggedIn())
|
||||
{
|
||||
USER.me().then(m => {
|
||||
me = m
|
||||
CARD.userGames(me.username).then(game => {
|
||||
playedMai = !!game.mai2
|
||||
recentGame = Object.keys(game)
|
||||
.filter(k => !!game[k])
|
||||
.sort((a, b) => {
|
||||
return (new Date(game[b].lastLogin)) - (new Date(game[a].lastLogin))
|
||||
})[0] ?? "mai2"
|
||||
})
|
||||
}).catch(e => console.error(e))
|
||||
}
|
||||
@@ -53,15 +61,16 @@
|
||||
<span>AquaNet</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if ANNOUNCEMENT}
|
||||
{#if $ANNOUNCEMENT}
|
||||
<div class="announcement">
|
||||
<strong>{t('navigation.notice')}</strong>: {ANNOUNCEMENT}
|
||||
<strong>{t('navigation.notice')}</strong>: {$ANNOUNCEMENT}
|
||||
</div>
|
||||
{/if}
|
||||
<a href="/home">{t('navigation.home').toLowerCase()}</a>
|
||||
<!-- <div on:click={() => alert("Coming soon™")} on:keydown={e => e.key === "Enter" && alert("Coming soon™")}
|
||||
role="button" tabindex="0">{t('navigation.maps').toLowerCase()}</div> -->
|
||||
<a href="/ranking">{t('navigation.rankings').toLowerCase()}</a>
|
||||
<!-- kill me -->
|
||||
<a href={`/ranking/${recentGame}`}>{t('navigation.rankings').toLowerCase()}</a>
|
||||
{#if playedMai}
|
||||
<a href="/pictures">photo</a>
|
||||
{/if}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
display: flex
|
||||
gap: 1rem
|
||||
|
||||
div
|
||||
a
|
||||
&.active
|
||||
color: vars.$c-main
|
||||
</style>
|
||||
@@ -9,8 +9,13 @@
|
||||
let editing = false
|
||||
let inputPage: number
|
||||
|
||||
function focus(node: HTMLInputElement) {
|
||||
node.focus()
|
||||
}
|
||||
|
||||
function updatePage(newPage: number) {
|
||||
if (newPage > 0 && newPage <= totalPages) dispatch('updatePage', newPage)
|
||||
const p = Math.floor(Number(newPage))
|
||||
if (!isNaN(p) && p > 0 && p <= totalPages) dispatch('updatePage', p)
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
@@ -33,7 +38,7 @@
|
||||
<button on:click={() => updatePage(page - 1)} disabled={page <= 1}>Previous</button>
|
||||
|
||||
{#if editing}
|
||||
<input bind:value={inputPage} on:blur={finishEditing} on:keydown={handleKeydown} min="1" max={totalPages} autofocus/>
|
||||
<input type="number" bind:value={inputPage} on:blur={finishEditing} on:keydown={handleKeydown} min="1" max={totalPages} use:focus/>
|
||||
{:else}
|
||||
<span on:click={startEditing} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && startEditing()}>
|
||||
Page {page} of {totalPages}
|
||||
|
||||
@@ -16,19 +16,25 @@
|
||||
|
||||
if (top) split = split?.toSorted((a, b) => b.score - a.score).slice(0, top)
|
||||
if (split) console.log("Split", split)
|
||||
|
||||
let mustResize = split && split.length > 12
|
||||
</script>
|
||||
|
||||
{#if split && comp}
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<div class="rating-composition">
|
||||
{#each split as p}
|
||||
<div>
|
||||
<RatingCompSong {p} {game}/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if split && split.length > 0 && comp}
|
||||
<div class="rating-composition-container" class:must-resize={mustResize}>
|
||||
{#if mustResize}
|
||||
<input type="checkbox">
|
||||
{/if}
|
||||
|
||||
<h2>{title}</h2>
|
||||
<div class="rating-composition">
|
||||
{#each split as p}
|
||||
<div>
|
||||
<RatingCompSong {p} {game}/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
@@ -39,4 +45,43 @@
|
||||
// 3 columns
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr))
|
||||
gap: vars.$gap
|
||||
|
||||
overflow: hidden
|
||||
transition: max-height 250ms
|
||||
|
||||
.rating-composition-container
|
||||
position: relative
|
||||
|
||||
input
|
||||
width: 100%
|
||||
height: 100%
|
||||
position: absolute
|
||||
z-index: 10
|
||||
|
||||
border: none
|
||||
border-radius: 0
|
||||
opacity: 0
|
||||
|
||||
transition: opacity 250ms
|
||||
|
||||
.rating-composition-container.must-resize
|
||||
input
|
||||
& ~ .rating-composition
|
||||
max-height: 250px
|
||||
|
||||
& ~ h2::after
|
||||
content: "▾"
|
||||
margin-left: 0.5em
|
||||
&:checked ~ h2::after
|
||||
content: "▴"
|
||||
|
||||
&:checked
|
||||
opacity: 0
|
||||
&:checked ~ .rating-composition
|
||||
max-height: 3000px
|
||||
|
||||
opacity: 1
|
||||
background: linear-gradient(#0000 250px, rgb(28.35, 28.35, 28.35) 100%) /* for some reason the color.adjust doesn't work here so whatever */
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
@@ -27,15 +27,15 @@
|
||||
})
|
||||
})
|
||||
|
||||
function mouseOver(event: MouseEvent) {
|
||||
function mouseOver(event: Event) {
|
||||
console.log('over')
|
||||
isHovered = true
|
||||
updatePosition(event)
|
||||
updatePosition(event as MouseEvent)
|
||||
}
|
||||
|
||||
function mouseMove(event: MouseEvent) {
|
||||
function mouseMove(event: Event) {
|
||||
console.log('move')
|
||||
updatePosition(event)
|
||||
updatePosition(event as MouseEvent)
|
||||
}
|
||||
|
||||
function updatePosition(event: MouseEvent) {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
let error = ""
|
||||
|
||||
setLoading(true)
|
||||
GAME.userSummary(username, game).then(d => data = d).catch(e => error = e).finally(_ => setLoading(false))
|
||||
GAME.userSummary(username, game).then(d => data = d).catch(e => error = e).finally(() => setLoading(false))
|
||||
</script>
|
||||
|
||||
{#if !data}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
let changed: string[] = [];
|
||||
let symbols: Record<number, number> = {};
|
||||
let allItems: Record<string, Record<string, { name: string }>> = {}
|
||||
let submitting: string | undefined | null;
|
||||
let submitting: string | undefined | null;
|
||||
let settings: Record<string, GameOption> = {};
|
||||
|
||||
let existingUrl = "";
|
||||
@@ -101,12 +101,13 @@
|
||||
{#if linkedVerseAvailable}
|
||||
<GameSettingFields game="chu3-linked-verse" />
|
||||
{#if settings["chusanLvDifficulty"]}
|
||||
<a target="_blank" href="https://silentblue.remywiki.com/CHUNITHM:Linked_VERSE">{t(`userbox.lv.help`)}</a>
|
||||
<div class="field">
|
||||
<label for={`chusanLvDifficulty`}>{ts(`userbox.lv.difficulty`)}</label>
|
||||
<div>
|
||||
<select bind:value={settings["chusanLvDifficulty"].value} id={`chusanLvDifficulty`} on:change={() => {changed = [...changed, `chusanLvDifficulty`];}}>
|
||||
{#each {length: 5}, i}
|
||||
<option value={i + 1}>{t(`userbox.lv.difficulty.${i + 1}` as keyof typeof EN_REF)}</option>
|
||||
{#each {length: 6}, i}
|
||||
<option value={i}>{t(`userbox.lv.difficulty.${i}` as keyof typeof EN_REF)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if changed.includes(`chusanLvDifficulty`)}
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type AquaNetUser,
|
||||
type GameUserOption,
|
||||
type UserBox,
|
||||
type UserItem,
|
||||
} from "../../libs/generalTypes";
|
||||
import { DATA, USER, USERBOX, GAME } from "../../libs/sdk";
|
||||
import { DATA, USER, USERBOX, GAME, SETTING } from "../../libs/sdk";
|
||||
import { t, ts } from "../../libs/i18n";
|
||||
import { FADE_IN, FADE_OUT, USERBOX_DEFAULT_URL } from "../../libs/config";
|
||||
import { fade, slide } from "svelte/transition";
|
||||
@@ -23,11 +24,15 @@
|
||||
import { DDS } from "../../libs/userbox/dds";
|
||||
import ChuniMatchingSettings from "./ChuniMatchingSettings.svelte";
|
||||
import InputField from "../ui/InputField.svelte";
|
||||
import UserOptionSlider from "./UserOptionSlider.svelte";
|
||||
|
||||
let user: AquaNetUser
|
||||
let [loading, error, submitting, preview] = [true, "", "", ""]
|
||||
let changed: string[] = [];
|
||||
|
||||
let userOptions: GameUserOption = {};
|
||||
let userIsUsingPreset = false;
|
||||
|
||||
// Available (unlocked) options for each kind of item
|
||||
// In allItems: 'namePlate', 'frame', 'trophy', 'mapIcon', 'systemVoice', 'avatarAccessory'
|
||||
let allItems: Record<string, Record<string, { name: string }>> = {}
|
||||
@@ -89,6 +94,13 @@
|
||||
console.log("All items", allItems)
|
||||
console.log("Userbox", userbox)
|
||||
|
||||
userOptions = await SETTING.optionGet('chu3').catch(_ => {
|
||||
loading = false
|
||||
error = t("userbox.error.nodata")
|
||||
}) as GameUserOption
|
||||
if (userOptions["optionSet"] != 3)
|
||||
userIsUsingPreset = true;
|
||||
|
||||
loading = false
|
||||
}
|
||||
|
||||
@@ -167,7 +179,7 @@
|
||||
try {
|
||||
data = await GAME.export('chu3')
|
||||
}
|
||||
catch (e) {
|
||||
catch (e: any) {
|
||||
error = e.message
|
||||
submitting = ""
|
||||
return
|
||||
@@ -312,6 +324,39 @@
|
||||
|
||||
<InputField bind:field={userNameField}
|
||||
callback={() => USERBOX.setUserBox({ field: "userName", value: userNameField.value })}/>
|
||||
|
||||
<!-- User Options -->
|
||||
{#if userIsUsingPreset}
|
||||
<blockquote class="info">
|
||||
{t('settings.options.all.preset-warning')}
|
||||
</blockquote>
|
||||
{/if}
|
||||
<div class="fields-ranges">
|
||||
<UserOptionSlider
|
||||
game="chu3" type="headphone"
|
||||
minValue={0} maxValue={60}
|
||||
defaultValue={userOptions["headphone"]}
|
||||
getTextFunction={(value: number) => `${((value / 60) * 100).toFixed(0)}%`}
|
||||
/>
|
||||
<UserOptionSlider
|
||||
game="chu3" type="trackSkip"
|
||||
minValue={0} maxValue={7}
|
||||
defaultValue={userOptions["trackSkip"]}
|
||||
getTextFunction={(value: number) => [t('settings.options.all.none'), `S`, `S+`, `SS`, `SS+`, `SSS`, `SSS+`, t('settings.options.all.personal-best')][value]}
|
||||
/>
|
||||
<UserOptionSlider
|
||||
game="chu3" type="speed"
|
||||
minValue={0} maxValue={61}
|
||||
defaultValue={userOptions["speed"]}
|
||||
getTextFunction={(value: number) => value >= 61 ? "SONIC" : value < 55 ? `${(value / 4) + 1}` : `${value - 40}`}
|
||||
/>
|
||||
<UserOptionSlider
|
||||
game="chu3" type="speed_120"
|
||||
minValue={0} maxValue={61}
|
||||
defaultValue={userOptions["speed_120"]}
|
||||
getTextFunction={(value: number) => value >= 61 ? "SONIC" : value < 55 ? `${(value / 4) + 1}` : `${value - 40}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h2>{t("userbox.header.userbox")}</h2>
|
||||
{#if !USERBOX_ENABLED.value || !USERBOX_INSTALLED}
|
||||
@@ -394,6 +439,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if USERBOX_INSTALLED}
|
||||
<!-- god this is a mess but idgaf atp -->
|
||||
<div class="field boolean" style:margin-top="1em">
|
||||
@@ -435,7 +481,7 @@
|
||||
<span>{USERBOX_SETUP_MODE ? t('userbox.new.url_warning') : USERBOX_SETUP_TEXT}</span>
|
||||
<div class="actions">
|
||||
{#if USERBOX_SETUP_MODE}
|
||||
<input type="text" on:keyup={e => {if (e.key == "Enter") { userboxHandleInput((e.target as HTMLInputElement).value) } else g(e.currentTarget.value)}} class="add-margin" placeholder="Base URL">
|
||||
<input type="text" on:keyup={e => {if (e.key == "Enter") { userboxHandleInput((e.target as HTMLInputElement).value) }}} class="add-margin" placeholder="Base URL">
|
||||
{:else}
|
||||
{#if USERBOX_PROGRESS != 0}
|
||||
<div class="progress">
|
||||
@@ -512,36 +558,6 @@ p.notice
|
||||
height: 100%
|
||||
opacity: 0
|
||||
|
||||
.preview
|
||||
margin-top: 32px
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
justify-content: space-between
|
||||
gap: 32px
|
||||
|
||||
> div
|
||||
position: relative
|
||||
width: 100px
|
||||
height: 100px
|
||||
overflow: hidden
|
||||
background: vars.$ov-lighter
|
||||
border-radius: vars.$border-radius
|
||||
|
||||
span
|
||||
position: absolute
|
||||
bottom: 0
|
||||
width: 100%
|
||||
text-align: center
|
||||
z-index: 10
|
||||
background: rgba(0, 0, 0, 0.2)
|
||||
backdrop-filter: blur(2px)
|
||||
|
||||
img
|
||||
position: absolute
|
||||
inset: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
object-fit: contain
|
||||
|
||||
.fields
|
||||
display: flex
|
||||
@@ -594,6 +610,18 @@ p.notice
|
||||
.desc
|
||||
opacity: 0.6
|
||||
|
||||
|
||||
.fields-ranges
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
margin: 0.5rem 0
|
||||
gap: 0 1rem
|
||||
justify-content: center
|
||||
|
||||
:global(.field)
|
||||
flex: calc(50% - 1rem)
|
||||
width: 50%
|
||||
|
||||
/* AquaBox */
|
||||
|
||||
.chuni-userbox-row
|
||||
|
||||
@@ -1,32 +1,67 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import { FADE_IN, FADE_OUT } from "../../libs/config";
|
||||
import { t, ts } from "../../libs/i18n";
|
||||
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
|
||||
import RegionSelector from "./RegionSelector.svelte";
|
||||
import { USER } from "../../libs/sdk";
|
||||
import type { AquaNetUser } from "../../libs/generalTypes";
|
||||
import StatusOverlays from "../StatusOverlays.svelte";
|
||||
|
||||
const rounding = useLocalStorage("rounding", true);
|
||||
|
||||
let me: AquaNetUser;
|
||||
let submitting = "";
|
||||
let error = "";
|
||||
let loading = true;
|
||||
|
||||
USER.me().then((m) => {
|
||||
me = m;
|
||||
loading = false;
|
||||
}).catch(e => error = e.message)
|
||||
|
||||
function submit(field: string, value: string) {
|
||||
if (submitting) return
|
||||
submitting = field
|
||||
|
||||
USER.setting(field, value).then(() => {
|
||||
}).catch(e => error = e.message).finally(() => submitting = "")
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fields">
|
||||
<blockquote class="info">
|
||||
{ts("settings.siteNotice")}
|
||||
</blockquote>
|
||||
<div class="field">
|
||||
<div class="bool">
|
||||
<input id="rounding" type="checkbox" bind:checked={rounding.value}/>
|
||||
<label for="rounding">
|
||||
<span class="name">{ts(`settings.fields.rounding.name`)}</span>
|
||||
<span class="desc">{ts(`settings.fields.rounding.desc`)}</span>
|
||||
</label>
|
||||
{#if !loading}
|
||||
<div class="fields">
|
||||
<blockquote class="info">
|
||||
{ts("settings.siteNotice")}
|
||||
</blockquote>
|
||||
<div class="field">
|
||||
<div class="bool">
|
||||
<input id="rounding" type="checkbox" bind:checked={rounding.value}/>
|
||||
<label for="rounding">
|
||||
<span class="name">{ts(`settings.fields.rounding.name`)}</span>
|
||||
<span class="desc">{ts(`settings.fields.rounding.desc`)}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field m-t">
|
||||
<div class="bool">
|
||||
<input id="displayCandidates" type="checkbox" bind:checked={me.displayCandidates}
|
||||
on:change={() => submit('displayCandidates', me.displayCandidates.toString())}/>
|
||||
<label for="displayCandidates">
|
||||
<span class="name">{ts(`settings.fields.displayCandidates.name`)}</span>
|
||||
<span class="desc">{ts(`settings.fields.displayCandidates.desc`)}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<blockquote class="info">
|
||||
{ts("settings.regionNotice")}
|
||||
</blockquote>
|
||||
<RegionSelector/>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<blockquote class="info">
|
||||
{ts("settings.regionNotice")}
|
||||
</blockquote>
|
||||
<RegionSelector/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<StatusOverlays {error} {loading} />
|
||||
|
||||
<style lang="sass">
|
||||
@use "../../vars"
|
||||
|
||||
@@ -4,37 +4,36 @@
|
||||
import { t } from "../../libs/i18n.js";
|
||||
import Icon from "@iconify/svelte";
|
||||
import StatusOverlays from "../StatusOverlays.svelte";
|
||||
import { GAME, USER } from "../../libs/sdk";
|
||||
import { GAME, SETTING, USER } from "../../libs/sdk";
|
||||
import GameSettingFields from "./GameSettingFields.svelte";
|
||||
import { download } from "../../libs/ui";
|
||||
import UserOptionSlider from "./UserOptionSlider.svelte";
|
||||
import type { GameUserOption } from "../../libs/generalTypes";
|
||||
import InputField from "../ui/InputField.svelte";
|
||||
|
||||
const profileFields = [
|
||||
['name', t('settings.mai2.name')],
|
||||
]
|
||||
let userNameField: any
|
||||
|
||||
let error: string
|
||||
let submitting = ""
|
||||
let values = Array(profileFields.length).fill('')
|
||||
let loading = true
|
||||
let changed: string[] = []
|
||||
|
||||
USER.me().then(me =>
|
||||
GAME.userSummary(me.username, 'mai2').then(({name}) => {
|
||||
values = [name]
|
||||
let userOptions: GameUserOption = {}
|
||||
let userIsUsingPreset = false
|
||||
|
||||
USER.me().then(me =>
|
||||
GAME.userSummary(me.username, 'mai2').then(async ({name}) => {
|
||||
userNameField = {key: "gameUsername", value: name, type: "String"}
|
||||
|
||||
userOptions = await SETTING.optionGet('mai2').catch(_ => {
|
||||
error = t("userbox.error.nodata")
|
||||
loading = true
|
||||
}) as GameUserOption
|
||||
if (userOptions["optionKind"] != 3)
|
||||
userIsUsingPreset = true
|
||||
loading = false
|
||||
}).catch(e => error = e.message));
|
||||
|
||||
function submit(field: string, value: string) {
|
||||
if (submitting) return
|
||||
submitting = field
|
||||
|
||||
switch (field) {
|
||||
case 'name':
|
||||
GAME.changeName('mai2', value).then(({newName}) => {
|
||||
changed = changed.filter(c => c !== field)
|
||||
values = [newName]
|
||||
}).catch(e => error = e.message).finally(() => submitting = "")
|
||||
break
|
||||
}
|
||||
}
|
||||
async function exportBatchManual() {
|
||||
submitting = "batchExport"
|
||||
const DIFFICULTY_MAP: Record<number, string> = {
|
||||
@@ -156,12 +155,12 @@
|
||||
let lamp = null;
|
||||
if (score.isAllPerfect) {
|
||||
lamp = "ALL PERFECT";
|
||||
if (score.percent == 101.0) {
|
||||
if (percent >= 101.0) {
|
||||
lamp = "ALL PERFECT+";
|
||||
}
|
||||
} else if (score.isFullCombo) {
|
||||
lamp = "FULL COMBO";
|
||||
if (good == 0 && great == 0) {
|
||||
if (!good) {
|
||||
lamp = "FULL COMBO+";
|
||||
}
|
||||
} else if (score.isClear) {
|
||||
@@ -195,39 +194,72 @@
|
||||
if(data.userData.classRank in CLASS_MAP){
|
||||
output.classes["matchingClass"] = CLASS_MAP[data.userData.classRank]
|
||||
}
|
||||
download(JSON.stringify(output), `AquaDX_maimai2_BatchManualExport_${values[0]}.json`)
|
||||
download(JSON.stringify(output), `AquaDX_maimai2_BatchManualExport_${userNameField.value}.json`)
|
||||
submitting = ""
|
||||
}
|
||||
|
||||
function exportData() {
|
||||
submitting = "export"
|
||||
GAME.export('mai2')
|
||||
.then(data => download(JSON.stringify(data), `AquaDX_maimai2_export_${values[0]}.json`))
|
||||
.then(data => download(JSON.stringify(data), `AquaDX_maimai2_export_${userNameField.value}.json`))
|
||||
.catch(e => error = e.message)
|
||||
.finally(() => submitting = "")
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !loading}
|
||||
<div class="fields">
|
||||
{#each profileFields as [field, name], i (field)}
|
||||
<div class="field">
|
||||
<label for={field}>{name}</label>
|
||||
<div>
|
||||
<input id={field} type="text"
|
||||
bind:value={values[i]} on:input={() => changed = [...changed, field]}
|
||||
placeholder={field === 'password' ? t('settings.profile.unchanged') : t('settings.profile.unset')}/>
|
||||
{#if changed.includes(field) && values[i]}
|
||||
<button transition:slide={{axis: 'x'}} on:click={() => submit(field, values[i])}>
|
||||
{#if submitting === field}
|
||||
<Icon icon="line-md:loading-twotone-loop"/>
|
||||
{:else}
|
||||
{t('settings.profile.save')}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<InputField bind:field={userNameField}
|
||||
callback={() => GAME.changeName('mai2', userNameField.value)}/>
|
||||
{#if userIsUsingPreset}
|
||||
<blockquote class="info">
|
||||
{t('settings.options.all.preset-warning')}
|
||||
</blockquote>
|
||||
{/if}
|
||||
<div class="fields-ranges">
|
||||
<!-- TODO: determine min & max -->
|
||||
<UserOptionSlider
|
||||
game="mai2" type="noteSpeed"
|
||||
minValue={0} maxValue={37}
|
||||
defaultValue={userOptions["noteSpeed"]}
|
||||
getTextFunction={(value: number) => value >= 37 ? "SONIC" : `${(value / 4) + 1}`}
|
||||
/>
|
||||
<UserOptionSlider
|
||||
game="mai2" type="touchSpeed"
|
||||
minValue={0} maxValue={37}
|
||||
defaultValue={userOptions["touchSpeed"]}
|
||||
getTextFunction={(value: number) => value >= 37 ? "SONIC" : `${(value / 4) + 1}`}
|
||||
/>
|
||||
<UserOptionSlider
|
||||
game="mai2" type="slideSpeed"
|
||||
minValue={0} maxValue={20}
|
||||
defaultValue={userOptions["slideSpeed"]}
|
||||
getTextFunction={(value: number) => value == 10 ? "Normal" : `${value > 10 ? "Late" : "Fast"} ${(Math.abs(value - 10) / 10).toFixed(1)}`}
|
||||
/>
|
||||
<UserOptionSlider
|
||||
game="mai2" type="headPhoneVolume"
|
||||
minValue={0} maxValue={19}
|
||||
defaultValue={userOptions["headPhoneVolume"]}
|
||||
getTextFunction={(value: number) => `${((value / 19) * 100).toFixed(0)}%`}
|
||||
/>
|
||||
<UserOptionSlider
|
||||
game="mai2" type="trackSkip"
|
||||
minValue={0} maxValue={11}
|
||||
defaultValue={userOptions["trackSkip"]}
|
||||
getTextFunction={(value: number) => [
|
||||
t('settings.options.all.none'),
|
||||
t('settings.options.all.push'),
|
||||
`S`, `SS`, `SSS`,
|
||||
t('settings.options.all.personal-best'),
|
||||
t('settings.options.all.rival-score'),
|
||||
`${t('settings.options.all.life')} 300`,
|
||||
`${t('settings.options.all.life')} 100`,
|
||||
`${t('settings.options.all.life')} 50`,
|
||||
`${t('settings.options.all.life')} 10`,
|
||||
`${t('settings.options.all.life')} 1`
|
||||
][value]}
|
||||
/>
|
||||
</div>
|
||||
<GameSettingFields game="mai2"/>
|
||||
<button class="exportButton" on:click={exportData}>
|
||||
<Icon icon="bxs:file-export"/>
|
||||
@@ -238,8 +270,9 @@
|
||||
{t('settings.batchManualExport')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<StatusOverlays {error} loading={!values[0] || !!submitting}/>
|
||||
<StatusOverlays {error} loading={!userNameField || !!submitting || loading}/>
|
||||
|
||||
<style lang="sass">
|
||||
.fields
|
||||
@@ -263,6 +296,17 @@
|
||||
> input
|
||||
flex: 1
|
||||
|
||||
.fields-ranges
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
margin: 0.5rem 0
|
||||
gap: 0 1rem
|
||||
justify-content: center
|
||||
|
||||
:global(.field)
|
||||
flex: calc(50% - 1rem)
|
||||
width: 50%
|
||||
|
||||
.exportButton
|
||||
display: flex
|
||||
justify-content: center
|
||||
|
||||
@@ -1,9 +1,55 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import { FADE_IN, FADE_OUT } from "../../libs/config";
|
||||
import GameSettingFields from "./GameSettingFields.svelte";
|
||||
import { GAME, SETTING, USER } from "../../libs/sdk";
|
||||
import { download } from "../../libs/ui";
|
||||
import InputField from "../ui/InputField.svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { t } from "../../libs/i18n";
|
||||
import StatusOverlays from "../StatusOverlays.svelte";
|
||||
|
||||
let userNameField: any
|
||||
|
||||
let submitting = "";
|
||||
let error: string
|
||||
let loading = true;
|
||||
|
||||
USER.me().then(me =>
|
||||
GAME.userSummary(me.username, 'ongeki').then(async ({name}) => {
|
||||
userNameField = {key: "gameUsername", value: name, type: "String"}
|
||||
loading = false;
|
||||
}))
|
||||
|
||||
|
||||
function exportData() {
|
||||
submitting = "export"
|
||||
GAME.export('ongeki')
|
||||
.then(data => download(JSON.stringify(data), `AquaDX_ongeki_export_${userNameField.value}.json`))
|
||||
.catch(e => error = e.message)
|
||||
.finally(() => submitting = "")
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<GameSettingFields game="ongeki"/>
|
||||
</div>
|
||||
{#if !loading}
|
||||
<div>
|
||||
<div class="fields">
|
||||
<InputField bind:field={userNameField}
|
||||
callback={() => SETTING.detailSet('ongeki', 'userName', userNameField.value)}/>
|
||||
<GameSettingFields game="ongeki"/>
|
||||
<button class="exportButton" on:click={exportData}>
|
||||
<Icon icon="bxs:file-export"/>
|
||||
{t('settings.export')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<StatusOverlays {error} loading={!userNameField || !!submitting || loading}/>
|
||||
|
||||
<style lang="sass">
|
||||
.fields
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 12px
|
||||
</style>
|
||||
@@ -3,8 +3,8 @@
|
||||
import { ts } from "../../libs/i18n";
|
||||
import StatusOverlays from "../StatusOverlays.svelte";
|
||||
let regionId = $state(0);
|
||||
let submitting = ""
|
||||
let error: string;
|
||||
let submitting = $state("")
|
||||
let error: string = $state("");
|
||||
|
||||
const prefectures = ["None","Aichi","Aomori","Akita","Ishikawa","Ibaraki","Iwate","Ehime","Oita","Osaka","Okayama","Okinawa","Kagawa","Kagoshima","Kanagawa","Gifu","Kyoto","Kumamoto","Gunma","Kochi","Saitama","Saga","Shiga","Shizuoka","Shimane","Chiba","Tokyo","Tokushima","Tochigi","Tottori","Toyama","Nagasaki","Nagano","Nara","Niigata","Hyogo","Hiroshima","Fukui","Fukuoka","Fukushima","Hokkaido","Mie","Miyagi","Miyazaki","Yamagata","Yamaguchi","Yamanashi","Wakayama"]
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<span class="name">{ts(`settings.regionSelector.title`)}</span>
|
||||
<span class="desc">{ts(`settings.regionSelector.desc`)}</span>
|
||||
</label>
|
||||
<select bind:value={regionId} on:change={saveNewRegion}>
|
||||
<select bind:value={regionId} onchange={saveNewRegion}>
|
||||
<option value={0} disabled selected>{ts("settings.regionSelector.select")}</option>
|
||||
{#each prefectures.slice(1) as prefecture, index}
|
||||
<option value={index}>{prefecture}</option>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { t } from "../../libs/i18n";
|
||||
import { EN_REF } from "../../libs/i18n/en_ref";
|
||||
import type { GameName } from "../../libs/scoring";
|
||||
import { SETTING } from "../../libs/sdk";
|
||||
|
||||
export let getTextFunction: (value: number) => string
|
||||
export let defaultValue: number = 0
|
||||
|
||||
export let minValue: number = 0
|
||||
export let maxValue: number = 0
|
||||
export let type: string = "speed";
|
||||
export let game: GameName;
|
||||
|
||||
export let disabled: boolean = false;
|
||||
|
||||
let value = defaultValue;
|
||||
let rationalizedValue = defaultValue / maxValue;
|
||||
|
||||
async function submit() {
|
||||
await SETTING.optionSet(game, type, value);
|
||||
}
|
||||
</script>
|
||||
<div class="field range">
|
||||
<div class="range-label">
|
||||
{t(`settings.options.${game}.${type}` as keyof typeof EN_REF)} <!-- TODO: i18n -->
|
||||
</div>
|
||||
<div class="range-value">
|
||||
<input
|
||||
type="range" min={minValue} {disabled}
|
||||
on:change={submit} max={maxValue} bind:value={value}
|
||||
>
|
||||
<div>
|
||||
{getTextFunction(value)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style lang="sass">
|
||||
@use "../../vars"
|
||||
|
||||
.field
|
||||
width: 100%
|
||||
|
||||
.range-value
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
div
|
||||
min-width: 4rem
|
||||
white-space: nowrap
|
||||
display: flex
|
||||
justify-content: center
|
||||
|
||||
input
|
||||
flex: 100%
|
||||
|
||||
input
|
||||
-webkit-appearance: none
|
||||
width: 100%
|
||||
height: 5px !important
|
||||
outline: none
|
||||
transition: opacity 200ms
|
||||
padding: 4px
|
||||
border-radius: 4px
|
||||
opacity: 80%
|
||||
background: linear-gradient(to right, transparent 0%, #caf1 10%, #caf1 90%, transparent 100%)
|
||||
|
||||
margin: 16px 0
|
||||
|
||||
&:hover
|
||||
opacity: 100%
|
||||
|
||||
*:focus
|
||||
outline: none
|
||||
|
||||
input::-webkit-slider-thumb
|
||||
-webkit-appearance: none
|
||||
appearance: none
|
||||
width: 8px
|
||||
height: 30px
|
||||
background: vars.$c-main
|
||||
border-radius: 8px
|
||||
cursor: pointer
|
||||
box-shadow: 0 0 2px 0 vars.$c-main
|
||||
|
||||
</style>
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { slide, fade } from "svelte/transition";
|
||||
import type { AquaNetUser } from "../../libs/generalTypes";
|
||||
import { CARD, USER } from "../../libs/sdk";
|
||||
import type { AquaNetUser, ConfirmProps } from "../../libs/generalTypes";
|
||||
import { CARD, GAME, USER, type ExportGameName } from "../../libs/sdk";
|
||||
import StatusOverlays from "../../components/StatusOverlays.svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { pfp } from "../../libs/ui";
|
||||
import { download, pfp } from "../../libs/ui";
|
||||
import { t, ts } from "../../libs/i18n";
|
||||
import Cropper from "svelte-easy-crop";
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
let error: string;
|
||||
let submitting = ""
|
||||
let loading = false;
|
||||
let confirm: ConfirmProps | null = null;
|
||||
|
||||
const profileFields = [
|
||||
[ 'displayName', t('settings.profile.name') ],
|
||||
@@ -97,11 +98,48 @@
|
||||
location.href = "/";
|
||||
}
|
||||
|
||||
function requestAccountDeletion() {
|
||||
if (submitting) return;
|
||||
confirm = {
|
||||
title: t('settings.profile.delete-confirm-title'),
|
||||
message: t('settings.profile.delete-confirm-message'),
|
||||
dangerous: true,
|
||||
cancel: () => confirm = null,
|
||||
confirm: deleteAccount,
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
if (submitting) return;
|
||||
submitting = "deleteAccount";
|
||||
|
||||
try {
|
||||
const gameSummary = await CARD.userGames(me.username);
|
||||
const games = (['mai2', 'chu3', 'ongeki', 'wacca', 'diva'] as ExportGameName[])
|
||||
.filter(game => gameSummary[game] !== null);
|
||||
const exports = await Promise.all(games.map(async game => ({
|
||||
game,
|
||||
data: await GAME.export(game),
|
||||
})));
|
||||
|
||||
exports.forEach(({game, data}) => download(
|
||||
JSON.stringify(data, null, 2),
|
||||
`AquaDX_${game}_export_${me.username}.json`,
|
||||
));
|
||||
await USER.deleteAccount();
|
||||
localStorage.removeItem("token");
|
||||
location.href = "/";
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
submitting = "";
|
||||
}
|
||||
}
|
||||
|
||||
const passwordAction = (node: HTMLInputElement, whether: boolean) => {
|
||||
if (whether) node.type = 'password'
|
||||
}
|
||||
</script>
|
||||
<StatusOverlays {error} loading={!!submitting || loading} />
|
||||
<StatusOverlays bind:confirm={confirm} {error} loading={!!submitting || loading} />
|
||||
{#if !submitting && !error && me}
|
||||
<div>
|
||||
<div class="fields">
|
||||
@@ -156,11 +194,31 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field m-t">
|
||||
<div class="bool">
|
||||
<input id="hideCountry" type="checkbox" bind:checked={me.hideCountry}
|
||||
on:change={() => submit('hideCountry', me.hideCountry.toString())}/>
|
||||
<label for="hideCountry">
|
||||
<span class="name">{ts(`settings.fields.hideCountry.name`)}</span>
|
||||
<span class="desc">{ts(`settings.fields.hideCountry.desc`)}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field m-t">
|
||||
<div>
|
||||
<button on:click={logOut}>{ts(`settings.profile.logout`)}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field danger-zone m-t">
|
||||
<span class="name">{t('settings.profile.delete-title')}</span>
|
||||
<span class="desc">{t('settings.profile.delete-description')}</span>
|
||||
<div>
|
||||
<button class="error" on:click={requestAccountDeletion}>
|
||||
<Icon icon="tabler:trash-x-filled" />
|
||||
{t('settings.profile.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -224,6 +282,19 @@
|
||||
object-fit: cover
|
||||
aspect-ratio: 1
|
||||
|
||||
.danger-zone
|
||||
margin-top: 1.5rem
|
||||
padding-top: 1.5rem
|
||||
border-top: 1px solid vars.$ov-lighter
|
||||
|
||||
.desc
|
||||
opacity: 0.6
|
||||
|
||||
button
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.5rem
|
||||
|
||||
|
||||
|
||||
.cropper-container
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { DISCORD_INVITE } from "../../libs/config";
|
||||
|
||||
export let error: string;
|
||||
export let expected: boolean = false;
|
||||
</script>
|
||||
|
||||
<div class="overlay" transition:fade>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// card handler. for dealing user QR scan.
|
||||
// this will assumes that the link of the generated QR code in [access code]
|
||||
// page has been altered by modder to link to custom
|
||||
|
||||
import { ACCESSCODE_QKEY } from "./config";
|
||||
|
||||
const query = new URLSearchParams(location.hash.slice(1));
|
||||
const lsKey = "acurl"
|
||||
|
||||
export const url_has = (): boolean => query.has(ACCESSCODE_QKEY);
|
||||
export const get = (): string => localStorage.getItem(lsKey) || "";
|
||||
export const has = (): boolean => !!localStorage.getItem(lsKey);
|
||||
export const clear = (): void => localStorage.removeItem(lsKey);
|
||||
|
||||
export function check() {
|
||||
if (!url_has()) return
|
||||
|
||||
console.log("Found access code in URL. Saving.")
|
||||
localStorage.setItem(lsKey, query.get(ACCESSCODE_QKEY) || "")
|
||||
}
|
||||
|
||||
export function preview() {
|
||||
const card = get()
|
||||
const masked =
|
||||
card.slice(0, 4) +
|
||||
"x".repeat(card.length - 4);
|
||||
|
||||
return masked.match(/.{1,4}/g)?.join(" ");
|
||||
}
|
||||
|
||||
export function previewR() {
|
||||
const card = get()
|
||||
|
||||
return card.match(/.{1,4}/g)?.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { writable } from "svelte/store"
|
||||
|
||||
export const ANNOUNCEMENT = writable('')
|
||||
|
||||
// This task must not bother the existing one. Keep it async in the background instead
|
||||
void (async () => {
|
||||
let r = await fetch("/announcement.txt")
|
||||
if (!r.ok) return;
|
||||
|
||||
ANNOUNCEMENT.set(await r.text())
|
||||
})()
|
||||
@@ -17,14 +17,15 @@ export const FADE_OUT = { duration: 200 }
|
||||
export const FADE_IN = { delay: 400 }
|
||||
export const DEFAULT_PFP = '/assets/imgs/no_profile.png'
|
||||
|
||||
export const ANNOUNCEMENT = '' // If set, will add an announcement to the top bar. Keep it short.
|
||||
// Key name for access key scanned from QR code
|
||||
export const ACCESSCODE_QKEY = import.meta.env.VITE_ACCESSCODE_QKEY || "accesskey"
|
||||
|
||||
// Documentation for Userbox mode can be found in `docs/aquabox-url-mode.md`
|
||||
// Please note that if this is set, it must be manually unset by users in Chuni Settings -> Update Userbox -> Switch to URL mode -> (empty value) -> Enter key
|
||||
export const USERBOX_DEFAULT_URL = ""
|
||||
export const USERBOX_DEFAULT_URL = import.meta.env.VITE_USERBOX_DEFAULT_URL || ""
|
||||
|
||||
export const HAS_USERBOX_ASSETS = true
|
||||
|
||||
export const DEFAULT_GAME = import.meta.env.VITE_DEFAULT_GAME || "mai2"
|
||||
|
||||
// Meow meow meow
|
||||
|
||||
// Matching servers
|
||||
|
||||
@@ -31,6 +31,9 @@ export interface AquaNetUser {
|
||||
computedName: string,
|
||||
password: string,
|
||||
optOutOfLeaderboard: boolean,
|
||||
canModifyKeychips: boolean,
|
||||
hideCountry: boolean,
|
||||
displayCandidates: boolean
|
||||
}
|
||||
|
||||
export interface CardSummaryGame {
|
||||
@@ -90,10 +93,11 @@ export interface GenericGameSummary {
|
||||
name: string
|
||||
iconId: number
|
||||
aquaUser?: AquaNetUser
|
||||
serverRank: number
|
||||
serverRank: string
|
||||
accuracy: number
|
||||
rating: number
|
||||
ratingHighest: number
|
||||
ratingHighest: number,
|
||||
ratingNotGeneric: boolean,
|
||||
ranks: RankCount[]
|
||||
detailedRanks: { [key: number]: { [key: string]: number } }
|
||||
maxCombo: number
|
||||
@@ -137,6 +141,8 @@ export interface GameOption {
|
||||
changed?: boolean
|
||||
}
|
||||
|
||||
export type GameUserOption = Record<string, number>
|
||||
|
||||
export interface UserItem { itemKind: number, itemId: number, stock: number }
|
||||
export interface UserBox {
|
||||
userName: string,
|
||||
|
||||
@@ -2,6 +2,8 @@ export const EN_REF_USER = {
|
||||
'UserHome.ServerRank': 'Server Rank',
|
||||
'UserHome.DXRating': 'DX Rating',
|
||||
'UserHome.Rating': 'Rating',
|
||||
'UserHome.HighestDXRating': 'Peak DX Rating',
|
||||
'UserHome.HighestRating': 'Peak Rating',
|
||||
'UserHome.Statistics': 'Statistics',
|
||||
'UserHome.Accuracy': 'Accuracy',
|
||||
'UserHome.MaxCombo': 'Max Combo',
|
||||
@@ -24,6 +26,13 @@ export const EN_REF_USER = {
|
||||
'UserHome.RankDetail.Title': 'Achievement Details',
|
||||
'UserHome.RankDetail.Level': 'Level',
|
||||
'UserHome.B50': 'B50',
|
||||
'UserHome.RatingComposition.Best': 'Best ${n}',
|
||||
'UserHome.RatingComposition.New': 'New ${n}',
|
||||
'UserHome.RatingComposition.BestCandidates': 'Best ${n} Candidates',
|
||||
'UserHome.RatingComposition.NewCandidates': 'New ${n} Candidates',
|
||||
'UserHome.RatingComposition.Platinum': 'Platinum ${n}',
|
||||
'UserHome.RatingComposition.PlatinumCandidates': 'Platinum ${n} Candidates',
|
||||
'UserHome.RatingComposition.Recent': 'Recent ${n}',
|
||||
'UserHome.AddRival': "Add to Rival",
|
||||
'UserHome.RemoveRival': "Remove from Rival",
|
||||
'UserHome.InvalidGame': "Game ${game} is not supported on the web UI yet. We only support maimai, chunithm, wacca, and ongeki for now.",
|
||||
@@ -60,12 +69,15 @@ export const EN_REF_Welcome = {
|
||||
'welcome.verified': 'Your email has been verified! You can now log in now.',
|
||||
'welcome.verification-failed': 'Verification failed: ${message}. Please try again.',
|
||||
'welcome.password-reset-done': 'Your password has been updated! Please log back in.',
|
||||
'welcome.login_link': 'Login / register to link your card.'
|
||||
}
|
||||
|
||||
export const EN_REF_LEADERBOARD = {
|
||||
'Leaderboard.Title': 'Server Leaderboard',
|
||||
'Leaderboard.Rank': 'Rank',
|
||||
'Leaderboard.Rating': 'Rating',
|
||||
'Leaderboard.LegacyRating': 'Legacy Rating',
|
||||
'Leaderboard.ModernRating': 'Modern Rating',
|
||||
'Leaderboard.Accuracy': 'Accuracy',
|
||||
'Leaderboard.FC': 'FC',
|
||||
'Leaderboard.AP': 'AP',
|
||||
@@ -119,6 +131,7 @@ export const EN_REF_HOME = {
|
||||
'home.linkcard.enter-info': 'Please enter either an Access Code or a Serial Number in the following boxes. Dragging and dropping aime.txt or felica.txt is also supported.',
|
||||
'home.linkcard.access-code': 'If you have an Access Code, enter it below. Access Codes can be obtained from the back of your card or from the title screen of your game (press the Access Code button and card in). If you do not have an Access Code, you may generate one.',
|
||||
'home.linkcard.enter-sn': 'If you have a Serial Number, enter it below. Serial Numbers can be obtained via most smartphones (NFC Tools: <a href="https://play.google.com/store/apps/details?id=com.wakdev.wdnfc">Android</a> / <a href="https://apps.apple.com/us/app/nfc-tools/id1252962749">Apple</a>) or a card reader.',
|
||||
'home.linkcard.kdx-notice': 'If you are playing KanadeDX, please enter the virtual card number here (can be found in Settings > Card Number).',
|
||||
'home.linkcard.link': 'Link',
|
||||
'home.linkcard.data-conflict': 'Data Conflict',
|
||||
'home.linkcard.name': 'Name',
|
||||
@@ -142,12 +155,11 @@ export const EN_REF_HOME = {
|
||||
|
||||
export const EN_REF_SETUP = {
|
||||
'setup.welcome': `Welcome! If you have a game set up, please follow the instructions below to set up the connection with AquaDX.`,
|
||||
'setup.keychip-warning': `Your keychip is linked to your account and should be kept secure. Do not give others your keychip.`,
|
||||
'setup.keychip-warning': `Your keychip(s) are linked to your account and should be kept secure.`,
|
||||
'setup.steps.one': `Pick a method of setting up network communications. Some browsers may not be able to do automatic setup.`,
|
||||
'setup.steps.two': `Ensure your game has encryption disabled as AquaDX does not support encryption. This may be via a patch or setting.`,
|
||||
'setup.steps.three': `Link your Aime card to your AquaDX account using the <a href="/cards">Cards</a> page via it's access code or serial number.`,
|
||||
'setup.steps.four': `Start the game. Upon reaching the title screen, the network icon in the corner should now show green instead of grey.`,
|
||||
'setup.support-info': `If you need assistance, feel free to make an inquiry in a <a href="/support">support channel</a>.`,
|
||||
'setup.steps.two': `Link your Aime card to your AquaDX account using the <a href="/cards">Cards</a> page via it's access code or serial number.`,
|
||||
'setup.steps.three': `Start the game. Upon reaching the title screen, the network icon in the corner should now show green instead of grey.`,
|
||||
'setup.support-info': `If you need additional assistance, feel free to make an inquiry in a <a href="/support">support channel</a>.`,
|
||||
'setup.reveal-keychip': `Reveal keychip`,
|
||||
'setup.type.automatic': `Automatic Setup`,
|
||||
'setup.type.manual': `Manual Setup`,
|
||||
@@ -155,7 +167,21 @@ export const EN_REF_SETUP = {
|
||||
'setup.automatic': `Select your segatools.ini below to autofill the network information.`,
|
||||
'setup.automatic.success': `Success, data has been updated`,
|
||||
'setup.automatic.failure': `Failed to update information, please ensure access is not blocked and try again.`,
|
||||
'setup.automatic.select': `Pick file`
|
||||
'setup.automatic.select': `Pick file`,
|
||||
'setup.troubleshooting.header': 'Setup Troubleshooting (FAQ)',
|
||||
'setup.troubleshooting.items.one': `» It displays DNS BAD on Network Test<br>
|
||||
You likely misconfigured your segatools.ini. Ensure it's pointing to the DNS shown above (under Manual Setup).`,
|
||||
'setup.troubleshooting.items.two': `» It displays タイトル サーバー BAD (Title Server BAD) on Network Test<br>
|
||||
You should check your segatools.ini to ensure your keychip is set correctly.<br>
|
||||
Additionally, Card Maker users and players using a game version higher than what's listed may need to disable encryption
|
||||
<a target="_blank" href="https://gitea.tendokyu.moe/Dniel97/SEGAguide/wiki/SDED#patching-assemblycsharpdll">(instructions for Card Maker)</a>`,
|
||||
'setup.troubleshooting.items.three': `» I can't scan my card<br>
|
||||
The built-in emulated card reader, by default, is configured to use the Enter / Return key to scan in (hold the key).`,
|
||||
'setup.keychip': 'Keychip Management',
|
||||
'setup.keychip.warning': 'Keychips can be manually set here for cabinet owners with physical keychips trying to connect to AquaDX. Selecting a keychip will autofill it for the setup below.',
|
||||
'setup.keychip.add': `Add keychip`,
|
||||
'setup.keychip.delete': `Delete`,
|
||||
'setup.keychip.placeholder': `New Keychip ID`
|
||||
}
|
||||
|
||||
export const EN_REF_SETTINGS = {
|
||||
@@ -167,6 +193,24 @@ export const EN_REF_SETTINGS = {
|
||||
'settings.tabs.mai2': 'Mai',
|
||||
'settings.tabs.ongeki': 'Ongeki',
|
||||
'settings.tabs.wacca': 'Wacca',
|
||||
'settings.options.all.preset-warning': 'You are using a preset option set. Options may be reflected incorrectly.',
|
||||
'settings.options.all.personal-best': 'Record',
|
||||
'settings.options.all.rival-score': 'Rival',
|
||||
'settings.options.all.life': 'Life',
|
||||
'settings.options.all.push': 'Push',
|
||||
'settings.options.all.none': 'None',
|
||||
// Chuni User Options
|
||||
'settings.options.chu3.speed': 'Lane Speed (60fps)',
|
||||
'settings.options.chu3.speed_120': 'Lane Speed (120fps)',
|
||||
'settings.options.chu3.trackSkip': 'Track Skip Type',
|
||||
'settings.options.chu3.headphone': 'Headphone Volume',
|
||||
// Maimai User Options
|
||||
'settings.options.mai2.noteSpeed': 'Note Speed',
|
||||
'settings.options.mai2.touchSpeed': 'Touch Speed',
|
||||
'settings.options.mai2.slideSpeed': 'Slide Speed',
|
||||
'settings.options.mai2.headPhoneVolume': 'Headphone Volume',
|
||||
'settings.options.mai2.trackSkip': 'Track Skip Type',
|
||||
|
||||
'settings.fields.mai2UnlockMusic.name': 'Unlock All Music',
|
||||
'settings.fields.mai2UnlockMusic.desc': 'Unlock all music and master difficulty.',
|
||||
'settings.fields.mai2UnlockChara.name': 'Unlock All Characters',
|
||||
@@ -195,6 +239,8 @@ export const EN_REF_SETTINGS = {
|
||||
'settings.fields.chusanTeamName.desc': 'Customize the text displayed on the top of your profile.',
|
||||
'settings.fields.chusanInfinitePenguins.name': 'Infinite Penguins',
|
||||
'settings.fields.chusanInfinitePenguins.desc': 'Set penguin statues for character level prompting to 999.',
|
||||
'settings.fields.chusanUnlock29672999Ultima.name': 'Unlock 2967, 2999 Ultima',
|
||||
'settings.fields.chusanUnlock29672999Ultima.desc': 'Unlocks Ultima difficulty ONLY for musicIds 2967, 2999. Do not use if you don\'t know why you need this.',
|
||||
'settings.fields.chusanLvUnlockAll.name': 'Unlock Linked Gates',
|
||||
'settings.fields.chusanLvUnlockAll.desc': 'Incurs a long animated sequence, cannot be undone',
|
||||
'settings.fields.chusanMatchingReflector.name': 'Matching Server Reflector',
|
||||
@@ -205,13 +251,16 @@ export const EN_REF_SETTINGS = {
|
||||
'settings.fields.ongekiInfiniteKaika.desc': 'Set Kaika to 999',
|
||||
'settings.fields.rounding.name': 'Score Rounding',
|
||||
'settings.fields.rounding.desc': 'Round the score to one decimal place',
|
||||
'settings.fields.gameUsername.name': 'In-Game Username',
|
||||
'settings.fields.gameUsername.name': 'Player Name',
|
||||
'settings.fields.gameUsername.desc': 'Your name shown in game',
|
||||
'settings.fields.optOutOfLeaderboard.name': 'Opt Out of Leaderboard',
|
||||
'settings.fields.optOutOfLeaderboard.desc': 'You will still be able to see yourself on the leaderboard after logging in',
|
||||
'settings.fields.optOutOfLeaderboard.desc': 'It may take some time for this to take effect',
|
||||
'settings.fields.hideCountry.name': 'Hide Country',
|
||||
'settings.fields.hideCountry.desc': 'Your country of registration will not be visible on your profile',
|
||||
'settings.fields.displayCandidates.name': 'Display Candidates',
|
||||
'settings.fields.displayCandidates.desc': 'Shows upcoming songs that could improve your rating on your profile (when available)',
|
||||
'settings.fields.enableMusicRank.name': 'Enable Recommended Music Rank on Your Machine',
|
||||
'settings.fields.enableMusicRank.desc': 'If you have your own ranking, you can turn this off. It only affects your own machine',
|
||||
'settings.mai2.name': 'Player Name',
|
||||
'settings.profile.picture': 'Profile Picture',
|
||||
'settings.profile.upload-new': 'Upload New',
|
||||
'settings.profile.bad-format': 'Invalid image format. Supported types are PNG, JPG, JPEG, WEBP & GIF.',
|
||||
@@ -225,6 +274,11 @@ export const EN_REF_SETTINGS = {
|
||||
'settings.profile.unset': 'Unset',
|
||||
'settings.profile.logout': 'Log out',
|
||||
'settings.profile.unchanged': 'Unchanged',
|
||||
'settings.profile.delete-title': 'Delete account',
|
||||
'settings.profile.delete-description': 'Download a backup, then permanently delete your account and all associated game data.',
|
||||
'settings.profile.delete': 'Delete account',
|
||||
'settings.profile.delete-confirm-title': 'Permanently delete your account?',
|
||||
'settings.profile.delete-confirm-message': 'A backup of all game data will be downloaded first. After that, your account and all associated game data will be permanently deleted. This cannot be undone.',
|
||||
'settings.export': 'Export Player Data',
|
||||
'settings.batchManualExport': "Export in Batch Manual (for Tachi)",
|
||||
'settings.cabNotice': "These settings only apply for your keychip. If you're playing on someone else's setup, please ask them to change these settings.",
|
||||
@@ -270,13 +324,15 @@ export const EN_REF_USERBOX = {
|
||||
'userbox.matching.symbolChat': 'Message Choice',
|
||||
'userbox.matching.symbolChat.default': 'Default',
|
||||
|
||||
'userbox.lv.diffnotice': 'In Linked Verse, you will be matched separately based on your difficulty.',
|
||||
'userbox.lv.help': 'Information about Linked Verse Difficulty',
|
||||
'userbox.lv.diffnotice': 'In Linked Verse, you will be matched separately based on your difficulty.',
|
||||
'userbox.lv.difficulty': 'Linked Verse Difficulty',
|
||||
'userbox.lv.difficulty.1': 'LEVEL V (1000 life, MASTER only)',
|
||||
'userbox.lv.difficulty.2': 'LEVEL IV (3000 life, MASTER only)',
|
||||
'userbox.lv.difficulty.3': 'LEVEL III (5000 life, MASTER only)',
|
||||
'userbox.lv.difficulty.4': 'LEVEL II (5000 life, MASTER & EXPERT only)',
|
||||
'userbox.lv.difficulty.5': 'LEVEL I (5000 life, ALL difficulties)',
|
||||
'userbox.lv.difficulty.0': 'LEVEL ∞ (Only for supported gates)',
|
||||
'userbox.lv.difficulty.1': 'LEVEL V',
|
||||
'userbox.lv.difficulty.2': 'LEVEL IV',
|
||||
'userbox.lv.difficulty.3': 'LEVEL III',
|
||||
'userbox.lv.difficulty.4': 'LEVEL II',
|
||||
'userbox.lv.difficulty.5': 'LEVEL I',
|
||||
|
||||
'userbox.new.name': 'AquaBox',
|
||||
'userbox.new.setup': 'Drag and drop your Chuni game folder (Lumi or newer) into the box below to display UserBoxes with their nameplate & avatar. All files are handled in-browser.',
|
||||
|
||||
+128
-42
@@ -5,6 +5,7 @@ import {
|
||||
EN_REF_LEADERBOARD,
|
||||
EN_REF_MAI_PHOTO,
|
||||
EN_REF_SETTINGS,
|
||||
EN_REF_SETUP,
|
||||
EN_REF_USER,
|
||||
EN_REF_USERBOX,
|
||||
type EN_REF_Welcome
|
||||
@@ -13,7 +14,9 @@ import {
|
||||
const zhUser: typeof EN_REF_USER = {
|
||||
'UserHome.ServerRank': '服务器排名',
|
||||
'UserHome.DXRating': 'DX B50',
|
||||
'UserHome.Rating': '评分',
|
||||
'UserHome.Rating': '最高评分',
|
||||
'UserHome.HighestDXRating': 'DX B50',
|
||||
'UserHome.HighestRating': '最高评分',
|
||||
'UserHome.Statistics': '统计数据',
|
||||
'UserHome.Accuracy': '准确率',
|
||||
'UserHome.MaxCombo': '最大连击',
|
||||
@@ -36,6 +39,13 @@ const zhUser: typeof EN_REF_USER = {
|
||||
'UserHome.RankDetail.Title': '评分详细',
|
||||
'UserHome.RankDetail.Level': "等级",
|
||||
'UserHome.B50': "B50",
|
||||
'UserHome.RatingComposition.Best': '最佳 ${n}',
|
||||
'UserHome.RatingComposition.New': '新曲 ${n}',
|
||||
'UserHome.RatingComposition.BestCandidates': '最佳 ${n} 候选 ',
|
||||
'UserHome.RatingComposition.NewCandidates': '新曲 ${n} 候选 ',
|
||||
'UserHome.RatingComposition.PlatinumCandidates': '白金分 ${n} 候选 ',
|
||||
'UserHome.RatingComposition.Platinum': '白金分 ${n}',
|
||||
'UserHome.RatingComposition.Recent': '最近 ${n}',
|
||||
'UserHome.AddRival': "添加劲敌",
|
||||
'UserHome.RemoveRival': "移除劲敌",
|
||||
'UserHome.InvalidGame': "游戏 ${game} 还不支持网页端查看。我们目前只支持舞萌、中二、华卡和音击。",
|
||||
@@ -59,25 +69,28 @@ const zhWelcome: typeof EN_REF_Welcome = {
|
||||
'welcome.username-missing': '用户名/邮箱必须填哦',
|
||||
'welcome.email-password-missing': '邮箱和密码必须填哦',
|
||||
'welcome.waiting-turnstile': '正在验证网络环境…',
|
||||
'welcome.turnstile-error': '验证网络环境出错了,请关闭 VPN 后重试',
|
||||
'welcome.turnstile-timeout': '验证网络环境超时了,请重试',
|
||||
'welcome.verification-sent': '验证邮件已发送至 ${email},请翻翻收件箱',
|
||||
'welcome.reset-password-sent': '重置邮件已发送至 ${email},请翻翻收件箱',
|
||||
'welcome.verify-state-0': '您还没有验证邮箱哦!验证邮件一分钟内刚刚发到您的邮箱,请翻翻收件箱',
|
||||
'welcome.verify-state-1': '您还没有验证邮箱哦!我们在过去的 24 小时内已经发送了 3 封验证邮件,所以我们不会再发送了,请翻翻收件箱',
|
||||
'welcome.verify-state-2': '您还没有验证邮箱哦!我们刚刚又发送了一封验证邮件,请翻翻收件箱',
|
||||
'welcome.reset-state-0': '重置邮件刚刚发送到你的邮箱啦,请翻翻收件箱!',
|
||||
'welcome.reset-state-1': '邮件发送次数过多,暂时不会再发送新的重置邮件了',
|
||||
'welcome.turnstile-error': '验证网络环境出错了,请关闭 VPN 后重试',
|
||||
'welcome.turnstile-timeout': '验证网络环境超时了,请重试',
|
||||
'welcome.verification-sent': '验证邮件已发送至 ${email},请翻翻收件箱',
|
||||
'welcome.reset-password-sent': '重置邮件已发送至 ${email},请翻翻收件箱',
|
||||
'welcome.verify-state-0': '您还没有验证邮箱哦!验证邮件一分钟内刚刚发到您的邮箱,请翻翻收件箱',
|
||||
'welcome.verify-state-1': '您还没有验证邮箱哦!我们在过去的 24 小时内已经发送了 3 封验证邮件,所以我们不会再发送了,请翻翻收件箱',
|
||||
'welcome.verify-state-2': '您还没有验证邮箱哦!我们刚刚又发送了一封验证邮件,请翻翻收件箱',
|
||||
'welcome.reset-state-0': '重置邮件刚刚发送到你的邮箱啦,请翻翻收件箱!',
|
||||
'welcome.reset-state-1': '邮件发送次数过多,暂时不会再发送新的重置邮件了',
|
||||
'welcome.verifying': '正在验证邮箱…请稍等',
|
||||
'welcome.verified': '您的邮箱已经验证成功!您现在可以登录了',
|
||||
'welcome.verification-failed': '验证失败:${message}。请重试',
|
||||
'welcome.password-reset-done': '您的密码已更新!请重新登录',
|
||||
'welcome.login_link': '登录或注册以绑定卡片。',
|
||||
}
|
||||
|
||||
const zhLeaderboard: typeof EN_REF_LEADERBOARD = {
|
||||
'Leaderboard.Title': '排行榜',
|
||||
'Leaderboard.Rank': '排名',
|
||||
'Leaderboard.Rating': '评分',
|
||||
'Leaderboard.LegacyRating': '旧评分',
|
||||
'Leaderboard.ModernRating': '新评分',
|
||||
'Leaderboard.Accuracy': '准确率',
|
||||
'Leaderboard.FC': 'FC',
|
||||
'Leaderboard.AP': 'AP',
|
||||
@@ -90,43 +103,48 @@ const zhGeneral: typeof EN_REF_GENERAL = {
|
||||
'game.ongeki': "音击",
|
||||
'game.wacca': "华卡",
|
||||
"status.error": "发生错误",
|
||||
"status.error.hint": "出了一些问题,请稍后刷新重试或者",
|
||||
"status.error.hint.link": "加我们的 Discord 群问一问",
|
||||
"status.error.hint": "支持 (Discord)",
|
||||
"status.detail": "详细信息:${detail}",
|
||||
"action.refresh": "刷新",
|
||||
"action.cancel": "取消",
|
||||
"action.confirm": "确认",
|
||||
"action.home": "主页",
|
||||
'navigation.profile': '个人资料',
|
||||
'navigation.maps': '地图',
|
||||
'navigation.home': '首页',
|
||||
'navigation.rankings': '排行榜',
|
||||
'navigation.notice': '公告'
|
||||
'navigation.notice': '公告',
|
||||
'loading': '正在加载...',
|
||||
'404': '找不到页面 (${pathname})。如果您认为这是个错误,请通过适当的支持渠道报告。'
|
||||
}
|
||||
|
||||
const zhHome: typeof EN_REF_HOME = {
|
||||
'home.nav.portal': "主页",
|
||||
'home.nav.link-card': "绑卡",
|
||||
'home.nav.game-setup': "连接设置",
|
||||
'home.nav.support': "支持",
|
||||
'home.user-profile': "个人资料",
|
||||
'home.rankings': "排行榜",
|
||||
'home.settings': "设置",
|
||||
'home.manage-cards': '管理游戏卡',
|
||||
'home.manage-cards-description': '绑定、解绑、管理游戏数据卡',
|
||||
'home.link-card': '绑定游戏卡',
|
||||
'home.link-cards-description':'绑定游戏数据卡(Amusement IC 或 Aime 卡)后才可以访问游戏存档哦',
|
||||
'home.join-community': '加入群组',
|
||||
'home.join-community-description': '加入我们的聊天群组,与其他玩家聊天、获取帮助',
|
||||
'home.join-community-description': '加入我们的聊天群组,与其他玩家聊天、获取帮助',
|
||||
'home.setup': '连接到 AquaDX',
|
||||
'home.setup-description': '如果您有街机框体或者手台,点击这里设置服务器的连接',
|
||||
'home.setup-description': '如果您有街机框体或者手台,点击这里设置服务器的连接',
|
||||
'home.import': '导入玩家数据',
|
||||
'home.import-description': '如果你来自其他在线服,可以点击这里导入从其他服务器导出的数据',
|
||||
'home.import-description': '如果你来自其他在线服,可以点击这里导入从其他服务器导出的数据',
|
||||
'home.linkcard.cards': "已绑卡片",
|
||||
'home.linkcard.description': "这些是您绑定到帐户的卡",
|
||||
'home.linkcard.account-card': "账户卡",
|
||||
'home.linkcard.registered': "注册于",
|
||||
'home.linkcard.lastused': "上次使用",
|
||||
'home.linkcard.enter-info': "请输入以下信息,或将 aime.txt / felica.txt 文件拖放到此区域",
|
||||
'home.linkcard.access-code': "卡背面的 20 位卡号(如果提示找不到卡,请尝试使用游戏内置的显示卡号功能,输入游戏读取到的卡号)",
|
||||
'home.linkcard.enter-sn1': "在您的手机",
|
||||
'home.linkcard.enter-sn2': "上下载 NFC Tools 并扫描您的卡。然后输入显示的 SN 号。",
|
||||
'home.linkcard.kdx-notice': "如果你在玩 KanadeDX,请在这里输入虚拟卡号(可以在设置 > 卡号中找到卡号)",
|
||||
'home.linkcard.enter-info': "请输入以下信息,或将 aime.txt / felica.txt 文件拖放到此区域",
|
||||
'home.linkcard.access-code': "卡背面的 20 位卡号(如果提示找不到卡,请尝试使用游戏内置的显示卡号功能,输入游戏读取到的卡号)",
|
||||
'home.linkcard.enter-sn': "在您的手机上下载 NFC Tools 并扫描您的卡。然后输入显示的 SN 号。",
|
||||
'home.linkcard.kdx-notice': "如果你在玩 KanadeDX,请在这里输入虚拟卡号(可以在设置 > 卡号中找到卡号)",
|
||||
'home.linkcard.link': "绑定",
|
||||
'home.linkcard.data-conflict': "卡号冲突",
|
||||
'home.linkcard.name': "名称",
|
||||
@@ -137,31 +155,75 @@ const zhHome: typeof EN_REF_HOME = {
|
||||
'home.linkcard.notfound': "找不到卡",
|
||||
'home.linkcard.unlink': "取消链接",
|
||||
'home.linkcard.unlink-notice': "你确定要取消此卡的链接吗?",
|
||||
'home.linkcard.felica-ac-warning': "该 Access Code 是一张 FeliCa AIC 卡。\n如果你使用实体卡(而非 aime.txt 模拟)刷卡登录游戏,与官方服务器不同,你需要绑定该卡的 FeliCa SN(或与之对应的,游戏界面中查看得到的 00 开头的卡号)而非此号码。\n如果你使用 aime.txt 模拟登录,请忽略本警告继续绑定。",
|
||||
'home.setup.welcome': "欢迎! 如果您有街机框体或者手台,请按照以下说明设置以连接到 AquaDX。",
|
||||
'home.setup.blockquote': "我们假设您已经拥有所需的文件,并且可以启动机台或手台附带的游戏(例如 ROM 和 segatools)。如果没有,请联系您设备的卖家以获取所需的文件,因为出于版权原因,我们不会提供这些文件。",
|
||||
'home.setup.get': "开始",
|
||||
'home.setup.edit': "请打开您的 segatools.ini 文件并修改以下行",
|
||||
'home.setup.test': "在您重新启动游戏后,应该能够连接到 AquaDX。可以验证测试菜单中的网络测试测试连接是否全部良好。",
|
||||
'home.setup.ask': "如果您有任何问题,请加入我们的",
|
||||
'home.setup.support': "以获取支持",
|
||||
'home.setup.keychip-tips': "这是你的狗号,不要与任何人分享",
|
||||
'home.linkcard.felica-ac-warning': "该 Access Code 是一张 FeliCa AIC 卡。\n如果你使用实体卡(而非 aime.txt 模拟)刷卡登录游戏,与官方服务器不同,你需要绑定该卡的 FeliCa SN(或与之对应的,游戏界面中查看得到的 00 开头的卡号)而非此号码。\n如果你使用 aime.txt 模拟登录,请忽略本警告继续绑定。",
|
||||
|
||||
'home.community.discord': 'Discord',
|
||||
'home.community.telegram': 'Telegram (中文)',
|
||||
'home.community.qq': 'QQ (中文)',
|
||||
'home.community.github': 'GitHub 仓库',
|
||||
'home.linkcard.card-security-warning': "卡片信息决定了您可以访问的所有玩家数据。请不要与任何人分享您的卡号。",
|
||||
'home.import.unknown-game': '未知游戏类型 (目前导入只支持舞萌和中二)',
|
||||
'home.import.new-data': '要导入的数据',
|
||||
'home.import.data-conflict': '继续导入将覆盖现有数据',
|
||||
}
|
||||
|
||||
const zhSetup: typeof EN_REF_SETUP = {
|
||||
'setup.welcome': `欢迎!如果您已准备好游戏,请按照以下说明设置与 AquaDX 的连接。`,
|
||||
'setup.keychip-warning': `您的密钥芯片已与您的账户绑定,请妥善保管。`,
|
||||
'setup.steps.one': `选择一种网络通信设置方法。某些浏览器可能无法进行自动设置。`,
|
||||
'setup.steps.two': `请通过 <a href="/cards">卡片</a> 页面,使用访问码或序列号将您的 Aime 卡与 AquaDX 账户绑定。`,
|
||||
'setup.steps.three': `启动游戏。进入标题画面后,角落处的网络图标应变为绿色,而非灰色。`,
|
||||
'setup.support-info': `如需进一步协助,欢迎在 <a href="/support">支持频道</a> 中咨询。`,
|
||||
'setup.reveal-keychip': `显示密钥芯片`,
|
||||
'setup.type.automatic': `自动设置`,
|
||||
'setup.type.manual': `手动设置`,
|
||||
'setup.manual': `请使用以下信息修改您的 segatools.ini 文件。`,
|
||||
'setup.automatic': `请在下方的 segatools.ini 文件中选择,以自动填写网络信息。`,
|
||||
'setup.automatic.select': `选择文件`,
|
||||
'setup.automatic.success': `成功,数据已更新`,
|
||||
'setup.automatic.failure': `更新信息失败,请确保访问未被阻止,然后重试。`,
|
||||
'setup.troubleshooting.header': '设置故障排除(常见问题解答)',
|
||||
'setup.troubleshooting.items.one': `» 网络测试显示 DNS BAD<br>
|
||||
您可能配置了错误的 segatools.ini。请确保其指向上述(手动设置部分)显示的 DNS。`,
|
||||
'setup.troubleshooting.items.two': `» 网络测试显示 タイトル サーバー BAD (Title Server BAD)<br>
|
||||
请检查您的 segatools.ini 文件,确保密钥芯片设置正确。<br>
|
||||
此外, Card Maker 用户以及使用高于列表中版本的游戏版本的玩家,可能需要禁用加密
|
||||
<a target="_blank" href="https://gitea.tendokyu.moe/Dniel97/SEGAguide/wiki/SDED#patching-assemblycsharpdll">(Card Maker 操作指南)</a>`,
|
||||
'setup.troubleshooting.items.three': `» 无法扫描卡片<br>
|
||||
内置的模拟读卡器默认配置为使用回车键进行扫描(长按该键)。`,
|
||||
'setup.keychip': '钥匙芯片管理',
|
||||
'setup.keychip.warning': '对于拥有实体钥匙芯片并试图连接到 AquaDX 的机柜所有者,可在此手动设置钥匙芯片。选择钥匙芯片后,下方设置项将自动填充。',
|
||||
'setup.keychip.add': `添加钥匙芯片`,
|
||||
'setup.keychip.delete': `删除`,
|
||||
'setup.keychip.placeholder': `新钥匙芯片 ID`
|
||||
}
|
||||
|
||||
const zhSettings: typeof EN_REF_SETTINGS = {
|
||||
'settings.title': '用户设置',
|
||||
'settings.page-title': '${page} 设置',
|
||||
'settings.tabs.profile': '个人资料',
|
||||
'settings.tabs.global': '全局',
|
||||
'settings.tabs.chu3': '中二',
|
||||
'settings.tabs.mai2': '舞萌',
|
||||
'settings.tabs.ongeki': '音击',
|
||||
'settings.tabs.wacca': '华卡',
|
||||
|
||||
"settings.options.all.preset-warning": "您正在使用预设选项配置,部分选项可能显示不正确。",
|
||||
"settings.options.all.personal-best": "记录",
|
||||
"settings.options.all.rival-score": "对手",
|
||||
"settings.options.all.life": "生命值",
|
||||
"settings.options.all.push": "按压",
|
||||
"settings.options.all.none": "无",
|
||||
"settings.options.chu3.speed": "轨道速度",
|
||||
"settings.options.chu3.speed_120": "轨道速度(120帧)",
|
||||
"settings.options.chu3.trackSkip": "跳过曲目类型",
|
||||
"settings.options.chu3.headphone": "耳机音量",
|
||||
"settings.options.mai2.noteSpeed": "音符速度",
|
||||
"settings.options.mai2.touchSpeed": "触控速度",
|
||||
"settings.options.mai2.slideSpeed": "滑动速度",
|
||||
"settings.options.mai2.headPhoneVolume": "耳机音量",
|
||||
"settings.options.mai2.trackSkip": "跳过曲目类型",
|
||||
|
||||
'settings.fields.mai2UnlockMusic.name': '解锁谱面',
|
||||
'settings.fields.mai2UnlockMusic.desc': '解锁所有曲目和大师难度谱面。',
|
||||
'settings.fields.mai2UnlockChara.name': '解锁角色',
|
||||
@@ -190,6 +252,10 @@ const zhSettings: typeof EN_REF_SETTINGS = {
|
||||
'settings.fields.chusanTeamName.desc': '自定义显示在个人资料顶部的文本。',
|
||||
'settings.fields.chusanInfinitePenguins.name': '我是桐谷遥',
|
||||
'settings.fields.chusanInfinitePenguins.desc': '将角色界限突破的企鹅雕像数量设置为 999。',
|
||||
'settings.fields.chusanUnlock29672999Ultima.name': '解锁 2967、2999 Ultima',
|
||||
'settings.fields.chusanUnlock29672999Ultima.desc': '仅解锁曲目 2967、2999 的 Ultima 难度。不清楚用途请勿使用。',
|
||||
'settings.fields.chusanLvUnlockAll.name': '解锁所有 Linked Gate',
|
||||
'settings.fields.chusanLvUnlockAll.desc': '会触发一个很长的动画序列,且无法撤销',
|
||||
'settings.fields.chusanMatchingReflector.name': '全国对战 Reflector',
|
||||
'settings.fields.chusanMatchingReflector.desc': '全国对战服务器的 UDP 反射服务器的 URL',
|
||||
'settings.fields.chusanMatchingServer.name': '全国对战服务器',
|
||||
@@ -202,12 +268,15 @@ const zhSettings: typeof EN_REF_SETTINGS = {
|
||||
'settings.fields.gameUsername.desc': '在游戏中显示的用户名',
|
||||
'settings.fields.optOutOfLeaderboard.name': '不参与排行榜',
|
||||
'settings.fields.optOutOfLeaderboard.desc': '登录之后还是可以在排行榜上看到自己',
|
||||
"settings.fields.hideCountry.name": "隐藏注册国家",
|
||||
"settings.fields.hideCountry.desc": "您的注册国家不会在个人资料中显示",
|
||||
"settings.fields.displayCandidates.name": "显示推荐曲目",
|
||||
"settings.fields.displayCandidates.desc": "在您的个人资料中显示可能提升您评分的曲目(如有)",
|
||||
'settings.fields.enableMusicRank.name': '在你的机台上启用「推荐乐曲排行榜」',
|
||||
'settings.fields.enableMusicRank.desc': '如果你自己设计了排行榜的话,可以关闭这个(会影响你自己的机器)。',
|
||||
'settings.mai2.name': '玩家名字',
|
||||
'settings.fields.enableMusicRank.desc': '如果你自己设计了排行榜的话,可以关闭这个(会影响你自己的机器)。',
|
||||
'settings.profile.picture': '头像',
|
||||
'settings.profile.upload-new': '上传',
|
||||
'settings.profile.bad-format': '无效的图片格式,支持的格式有 PNG、JPG、JPEG、WEBP 和 GIF。',
|
||||
'settings.profile.bad-format': '无效的图片格式,支持的格式有 PNG、JPG、JPEG、WEBP 和 GIF。',
|
||||
'settings.profile.save': '保存',
|
||||
'settings.profile.name': '昵称',
|
||||
'settings.profile.username': '用户名',
|
||||
@@ -218,9 +287,14 @@ const zhSettings: typeof EN_REF_SETTINGS = {
|
||||
'settings.profile.unset': '未设置',
|
||||
'settings.profile.logout': '登出',
|
||||
'settings.profile.unchanged': '未更改',
|
||||
'settings.profile.delete-title': '删除账户',
|
||||
'settings.profile.delete-description': '下载备份后,永久删除您的账户及所有关联游戏数据。',
|
||||
'settings.profile.delete': '删除账户',
|
||||
'settings.profile.delete-confirm-title': '永久删除您的账户?',
|
||||
'settings.profile.delete-confirm-message': '系统会先下载所有游戏数据的备份,然后永久删除您的账户及所有关联游戏数据。此操作无法撤销。',
|
||||
'settings.export': '导出玩家数据',
|
||||
'settings.batchManualExport': "导出 Batch Manual 格式(用于 Tachi)",
|
||||
'settings.cabNotice': '注意:下面这些设置只会影响你自己的机器,如果你是在其他人的机器上玩的话,请联系机主来改设置',
|
||||
'settings.cabNotice': '注意:下面这些设置只会影响你自己的机器,如果你是在其他人的机器上玩的话,请联系机主来改设置',
|
||||
// AI
|
||||
'settings.siteNotice': "这些设置仅适用于网站。",
|
||||
// AI
|
||||
@@ -237,6 +311,7 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
|
||||
'userbox.header.general': '游戏设置',
|
||||
'userbox.header.matching': '全国对战',
|
||||
'userbox.header.matching.symbolChat': '全国对战聊天表情',
|
||||
'userbox.header.linkedVerse': 'Linked Verse',
|
||||
'userbox.header.userbox': 'UserBox 设置',
|
||||
'userbox.header.preview': 'UserBox 预览',
|
||||
'userbox.nameplateId': '名牌',
|
||||
@@ -246,6 +321,7 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
|
||||
'userbox.trophyIdSub2': '称号3',
|
||||
'userbox.mapIconId': '地图图标',
|
||||
'userbox.voiceId': '系统语音',
|
||||
'userbox.stageId': '舞台',
|
||||
'userbox.avatarWear': '企鹅服饰',
|
||||
'userbox.avatarHead': '企鹅头饰',
|
||||
'userbox.avatarFace': '企鹅面部',
|
||||
@@ -266,8 +342,18 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
|
||||
'userbox.matching.symbolChat': '表情选择',
|
||||
'userbox.matching.symbolChat.default': '默认',
|
||||
|
||||
'userbox.lv.help': '关于 Linked Verse Difficulty 的信息',
|
||||
'userbox.lv.diffnotice': '在 Linked Verse 中,你将根据难度分别进行匹配。',
|
||||
'userbox.lv.difficulty': 'Linked Verse 难度',
|
||||
'userbox.lv.difficulty.0': 'LEVEL ∞ (并非总是可用)',
|
||||
'userbox.lv.difficulty.1': 'LEVEL V',
|
||||
'userbox.lv.difficulty.2': 'LEVEL IV',
|
||||
'userbox.lv.difficulty.3': 'LEVEL III',
|
||||
'userbox.lv.difficulty.4': 'LEVEL II',
|
||||
'userbox.lv.difficulty.5': 'LEVEL I',
|
||||
|
||||
'userbox.new.name': 'AquaBox',
|
||||
'userbox.new.setup': '将中二(Lumi 或更高版本)的游戏文件夹拖放到下方区域,以显示带有名牌和头像的 UserBox。所有文件都在浏览器中处理。',
|
||||
'userbox.new.setup': '将中二(Lumi 或更高版本)的游戏文件夹拖放到下方区域,以显示带有名牌和头像的 UserBox。所有文件都在浏览器中处理。',
|
||||
'userbox.new.setup.notice': '选择包含游戏数据的最外层文件夹。',
|
||||
'userbox.new.setup.processing_file': '正在处理文件',
|
||||
'userbox.new.setup.finalizing': '正在保存到内部存储',
|
||||
@@ -281,20 +367,20 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
|
||||
'userbox.new.activate_desc': '启用后可显示带有名牌和头像的 UserBox',
|
||||
'userbox.new.activate_profile': '在用户页面启用 AquaBox',
|
||||
'userbox.new.activate_profile_desc': '启用后可在个人页面显示带有名牌和头像的 UserBox',
|
||||
'userbox.new.error.invalidFolder': '所选文件夹无效。请确认游戏版本为 Lumi 或更新,并且包含 “A000” 选项包。',
|
||||
'userbox.new.error.invalidFolder': '所选文件夹无效。请确认游戏版本为 Lumi 或更新,并且包含 “A000” 选项包。',
|
||||
'userbox.new.error.invalidUrl': '输入的 URL 无效。'
|
||||
};
|
||||
|
||||
export const zhMaiPhoto: typeof EN_REF_MAI_PHOTO = {
|
||||
'maiphoto.title': 'Mai 纪念照片库',
|
||||
'maiphoto.url_warning': '注意:如果想与朋友分享图片的话,请先保存照片再发出去。不要复制图片 URL,因为 URL 中包含 AquaDX 账号信息。',
|
||||
'maiphoto.url_warning': '注意:如果想与朋友分享图片的话,请先保存照片再发出去。不要复制图片 URL,因为 URL 中包含 AquaDX 账号信息。',
|
||||
'maiphoto.none': '还没有图片哦~ 可以在每次游戏结束的时候点击上传来上传照片。',
|
||||
}
|
||||
|
||||
export const zhAquaTrans: typeof EN_REF_AQUATRANS = {
|
||||
'trans.title': '🏳️⚧️ AquaTrans™ 数据迁移工具',
|
||||
'trans.confirm.unbackuped.title': '确认迁移',
|
||||
'trans.confirm.unbackuped.msg': '似乎还没有备份目标服务器的数据,真的要继续吗?(推荐先备份一下,因为迁移的时候会覆盖数据)',
|
||||
'trans.confirm.unbackuped.msg': '似乎还没有备份目标服务器的数据,真的要继续吗?(推荐先备份一下,因为迁移的时候会覆盖数据)',
|
||||
'trans.confirm.untested.title': '不太聪明喵',
|
||||
'trans.confirm.untested.msg': '在两个服务器上都测试完连接之后才能进行数据迁移哦!',
|
||||
'trans.confirm.done.title': '完成!',
|
||||
@@ -302,8 +388,8 @@ export const zhAquaTrans: typeof EN_REF_AQUATRANS = {
|
||||
'trans.alert.in-progress': '在迁移了在迁移了',
|
||||
'trans.prompt-html': `
|
||||
<p>👋 欢迎使用 AquaTrans™ 服务器游玩数据迁移工具!</p>
|
||||
<p>这个工具可以导出任意服务器的数据,并使用连接凭证(卡号、服务器地址和 Keychip ID)将数据导入任何其他服务器。</p>
|
||||
<p>我将模拟游戏客户端,从源服务器拉取游戏数据并推送到目标服务器。</p>
|
||||
<p>这个工具可以导出任意服务器的数据,并使用连接凭证(卡号、服务器地址和 Keychip ID)将数据导入任何其他服务器。</p>
|
||||
<p>我将模拟游戏客户端,从源服务器拉取游戏数据并推送到目标服务器。</p>
|
||||
<p>填写下面的表格开始迁移吧!</p>
|
||||
`,
|
||||
'trans.error.empty': '请填写所有字段。',
|
||||
@@ -319,11 +405,11 @@ export const zhAquaTrans: typeof EN_REF_AQUATRANS = {
|
||||
'trans.btn.test': '测试连接',
|
||||
'trans.btn.export': '导出数据',
|
||||
'trans.btn.import': '导入数据',
|
||||
'trans.blacklist': "这个服务器的服主把这个导出工具 ban 了,所以不能从这里导出",
|
||||
'trans.blacklist': "这个服务器的服主把这个导出工具 ban 了,所以不能从这里导出",
|
||||
}
|
||||
|
||||
|
||||
export const ZH = { ...zhUser, ...zhWelcome, ...zhGeneral,
|
||||
...zhLeaderboard, ...zhHome, ...zhSettings, ...zhUserbox, ...zhMaiPhoto,
|
||||
...zhAquaTrans
|
||||
...zhAquaTrans, ...zhSetup
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ const multTable = {
|
||||
'chu3': [
|
||||
[ 100.9, 215, 'SSS+' ],
|
||||
[ 100.75, 200, 'SSS' ],
|
||||
[ 100.50, 0, 'SS+' ],
|
||||
[ 100.0, 0, 'SS' ],
|
||||
[ 99.0, 0, 'S+' ],
|
||||
[ 97.5, 0, 'S' ],
|
||||
[ 95.0, 0, 'AAA' ],
|
||||
[ 92.5, 0, 'AA' ],
|
||||
@@ -80,11 +82,11 @@ export function getMult(achievement: number, game: GameName) {
|
||||
}
|
||||
|
||||
export function roundFloor(achievement: number, game: GameName, digits = 2) {
|
||||
// Round, but if the rounded number reaches the next rank, use floor instead
|
||||
// Round, but if the rounded number reaches the next rank (or fake 101% AP+), use floor instead
|
||||
const mult = getMult(achievement, game);
|
||||
achievement /= 10000
|
||||
const rounded = achievement.toFixed(digits);
|
||||
if (getMult(+rounded * 10000, game)[2] === mult[2] && rounded !== '101.0') return rounded;
|
||||
const original = achievement / 10000;
|
||||
const rounded = original.toFixed(digits);
|
||||
if (getMult(+rounded * 10000, game)[2] === mult[2] && (+rounded < 101 || original >= 101)) return rounded;
|
||||
return (+rounded - Math.pow(10, -digits)).toFixed(digits);
|
||||
}
|
||||
|
||||
|
||||
+30
-6
@@ -9,10 +9,21 @@ import type {
|
||||
AquaNetUser, GameOption,
|
||||
UserBox,
|
||||
UserItem,
|
||||
Dict
|
||||
Dict,
|
||||
GameUserOption
|
||||
} from './generalTypes'
|
||||
import type { GameName } from './scoring'
|
||||
|
||||
export type ExportGameName = GameName | 'diva'
|
||||
|
||||
export interface PhotoPage {
|
||||
photos: string[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
interface ExtReqInit extends RequestInit {
|
||||
params?: { [index: string]: string }
|
||||
json?: any
|
||||
@@ -25,7 +36,8 @@ interface ExtReqInit extends RequestInit {
|
||||
* @param callback Callback for modification
|
||||
*/
|
||||
export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) => URL | void): RequestInfo | URL {
|
||||
let u = new URL((input instanceof Request) ? input.url : input)
|
||||
const base = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
|
||||
let u = new URL((input instanceof Request) ? input.url : input, base)
|
||||
const result = callback(u)
|
||||
if (result) u = result
|
||||
if (input instanceof Request) {
|
||||
@@ -185,8 +197,12 @@ export const USER = {
|
||||
ensureLoggedIn()
|
||||
return post('/api/v2/user/me', {})
|
||||
},
|
||||
keychip: (): Promise<string> =>
|
||||
post('/api/v2/user/keychip', {}).then(it => it.keychip),
|
||||
keychips: (): Promise<string[]> =>
|
||||
post('/api/v2/user/keychip', {}).then(it => it.keychips),
|
||||
addKeychip: (keychipId: string): Promise<string> =>
|
||||
post('/api/v2/user/keychip/add', { keychipId }).then(it => it.keychipId),
|
||||
deleteKeychip: (keychipId: string) =>
|
||||
post('/api/v2/user/keychip/delete', { keychipId }),
|
||||
setting: (key: string, value: string) =>
|
||||
post('/api/v2/user/setting', { key: key === 'password' ? 'pwHash' : key, value }),
|
||||
uploadPfp: (file: File) => {
|
||||
@@ -198,6 +214,8 @@ export const USER = {
|
||||
ensureLoggedIn,
|
||||
changeRegion: (regionId: number) =>
|
||||
post('/api/v2/user/change-region', { regionId }),
|
||||
deleteAccount: () =>
|
||||
post('/api/v2/user/delete-account'),
|
||||
}
|
||||
|
||||
export const USERBOX = {
|
||||
@@ -223,7 +241,9 @@ export const CARD = {
|
||||
export const GAME = {
|
||||
trend: (username: string, game: GameName): Promise<TrendEntry[]> =>
|
||||
post(`/api/v2/game/${game}/trend`, { username }),
|
||||
photos: (): Promise<string[]> =>
|
||||
photos: (page: number = 1, size: number = 12): Promise<PhotoPage> =>
|
||||
post(`/api/v2/game/mai2/my-photo`, { page, size }),
|
||||
allPhotos: (): Promise<string[]> =>
|
||||
post(`/api/v2/game/mai2/my-photo`, { }),
|
||||
userSummary: (username: string, game: GameName): Promise<GenericGameSummary> =>
|
||||
post(`/api/v2/game/${game}/user-summary`, { username }),
|
||||
@@ -231,7 +251,7 @@ export const GAME = {
|
||||
post(`/api/v2/game/${game}/ranking`, typeof page === "number" ? { page } : {}),
|
||||
changeName: (game: GameName, newName: string): Promise<{ newName: string }> =>
|
||||
post(`/api/v2/game/${game}/change-name`, { newName }),
|
||||
export: (game: GameName): Promise<Record<string, any>> =>
|
||||
export: (game: ExportGameName): Promise<Record<string, any>> =>
|
||||
post(`/api/v2/game/${game}/export`),
|
||||
import: (game: GameName, data: any): Promise<Record<string, any>> =>
|
||||
post(`/api/v2/game/${game}/import`, {}, { json: data }),
|
||||
@@ -255,6 +275,10 @@ export const SETTING = {
|
||||
post('/api/v2/settings/set', { key, value: `${value}` }),
|
||||
detailSet: (game: string, field: string, value: any) =>
|
||||
post(`/api/v2/game/${game}/user-detail-set`, { field, value }),
|
||||
optionGet: (game: string): Promise<GameUserOption> =>
|
||||
post(`/api/v2/game/${game}/user-option`),
|
||||
optionSet: (game: string, field: string, value: number): Promise<void> =>
|
||||
post(`/api/v2/game/${game}/user-option-set`, { field, value }),
|
||||
}
|
||||
|
||||
export const TRANSFER = {
|
||||
|
||||
@@ -24,10 +24,10 @@ function addOrUpdateItem(ini: string[], section: string, key: string, value: str
|
||||
idx, keys: {}
|
||||
};
|
||||
} else {
|
||||
let key = item.split("=")[0];
|
||||
let key = item.split("=")[0].toLowerCase();
|
||||
let value = item.split("=")[1];
|
||||
|
||||
if (!key || !value)
|
||||
if (!key)
|
||||
continue;
|
||||
|
||||
if (activeSection && sections[activeSection])
|
||||
@@ -48,7 +48,7 @@ function addOrUpdateItem(ini: string[], section: string, key: string, value: str
|
||||
}
|
||||
|
||||
export function injectNetworkData(baseIni: string, networkData: NetworkData): string {
|
||||
let ini: string[] = baseIni.split("\n").map(i => i.replaceAll("\r", ""));
|
||||
let ini: string[] = baseIni.split("\n").map(i => i.trim().replaceAll("\r", ""));
|
||||
|
||||
if (networkData.dns)
|
||||
addOrUpdateItem(ini, "dns", "default", networkData.dns);
|
||||
@@ -80,4 +80,4 @@ export async function patchUserSegatools(networkData: NetworkData): Promise<bool
|
||||
} catch(err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function registerChart() {
|
||||
)
|
||||
}
|
||||
|
||||
const dayTemplate = (DateHelper) => {
|
||||
const dayTemplate = (DateHelper: any) => {
|
||||
const ROWS_COUNT = 7
|
||||
const ALLOWED_DOMAIN_TYPE = ['month']
|
||||
|
||||
@@ -43,7 +43,7 @@ const dayTemplate = (DateHelper) => {
|
||||
name: 'ghDayFix',
|
||||
allowedDomainType: ALLOWED_DOMAIN_TYPE,
|
||||
rowsCount: () => ROWS_COUNT,
|
||||
columnsCount: (ts) => {
|
||||
columnsCount: (ts: any) => {
|
||||
let count = DateHelper.getWeeksCountInMonth(ts)
|
||||
const endOfMonth = moment().endOf('month').toDate()
|
||||
const clampEnd = DateHelper.getFirstWeekOfMonth(endOfMonth).toDate()
|
||||
@@ -53,7 +53,7 @@ const dayTemplate = (DateHelper) => {
|
||||
}
|
||||
return count
|
||||
},
|
||||
mapping: (startTimestamp, endTimestamp) => {
|
||||
mapping: (startTimestamp: any, endTimestamp: any) => {
|
||||
const clampStart = DateHelper.getFirstWeekOfMonth(startTimestamp)
|
||||
let clampEnd = DateHelper.getFirstWeekOfMonth(endTimestamp)
|
||||
|
||||
@@ -64,7 +64,7 @@ const dayTemplate = (DateHelper) => {
|
||||
let x = -1
|
||||
const pivotDay = clampStart.weekday()
|
||||
|
||||
return DateHelper.intervals('day', clampStart, clampEnd).map((ts) => {
|
||||
return DateHelper.intervals('day', clampStart, clampEnd).map((ts: any) => {
|
||||
const weekday = DateHelper.date(ts).weekday()
|
||||
if (weekday === pivotDay) {
|
||||
x += 1
|
||||
@@ -77,7 +77,7 @@ const dayTemplate = (DateHelper) => {
|
||||
}
|
||||
})
|
||||
},
|
||||
extractUnit: (ts) => DateHelper.date(ts).startOf('day').valueOf(),
|
||||
extractUnit: (ts: any) => DateHelper.date(ts).startOf('day').valueOf(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { t } from "../libs/i18n";
|
||||
import ImportDataAction from "./Home/ImportDataAction.svelte";
|
||||
import DashboardTabs from "../components/DashboardTabs.svelte";
|
||||
import * as acUrl from "../libs/acUrl";
|
||||
|
||||
USER.ensureLoggedIn();
|
||||
|
||||
@@ -13,6 +14,10 @@
|
||||
let error = "";
|
||||
|
||||
USER.me().then((m) => me = m).catch(e => error = e.message)
|
||||
|
||||
if (acUrl.has()) {
|
||||
location.href = "/cards"
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="content">
|
||||
@@ -63,13 +68,6 @@
|
||||
<style lang="sass">
|
||||
@use "../vars"
|
||||
|
||||
.tabs
|
||||
display: flex
|
||||
gap: 1rem
|
||||
|
||||
div
|
||||
&.active
|
||||
color: vars.$c-main
|
||||
|
||||
h3
|
||||
font-size: 1.3rem
|
||||
|
||||
@@ -65,10 +65,13 @@
|
||||
|
||||
const getGameByCode = (code: string) => {
|
||||
switch (code?.toUpperCase()) {
|
||||
case 'SDGA':
|
||||
case 'SDEZ':
|
||||
return 'mai2';
|
||||
case 'SDHD':
|
||||
return 'chu3';
|
||||
case 'SDDT':
|
||||
return 'ongeki';
|
||||
default:
|
||||
throw new Error(t('home.import.unknown-game'));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import StatusOverlays from "../../components/StatusOverlays.svelte"
|
||||
import { t } from "../../libs/i18n"
|
||||
import DashboardTabs from "../../components/DashboardTabs.svelte";
|
||||
import * as acUrl from "../../libs/acUrl"
|
||||
import { ACCESSCODE_QKEY } from "../../libs/config";
|
||||
|
||||
// State
|
||||
let state: 'ready' | 'linking-AC' | 'linking-SN' | 'loading' = "loading"
|
||||
@@ -27,7 +29,10 @@
|
||||
// Always put the ghost card at the top
|
||||
m.cards.sort((a, b) => a.isGhost ? -1 : 1)
|
||||
state = "ready"
|
||||
|
||||
linkQR()
|
||||
}).catch(e => error = e.message)
|
||||
acUrl.check()
|
||||
updateMe()
|
||||
|
||||
// Data conflict overlay
|
||||
@@ -49,12 +54,12 @@
|
||||
if (linkingType === 'AC') inputAC = ""
|
||||
if (linkingType === 'SN') inputSN = ""
|
||||
} catch (e) {
|
||||
setError(e.message, linkingType)
|
||||
setError((e as any).message, linkingType!)
|
||||
}
|
||||
state = "ready"
|
||||
}
|
||||
|
||||
let linkingType: 'AC' | 'SN' = null
|
||||
let linkingType: 'AC' | 'SN' | null = null
|
||||
async function link(type: 'AC' | 'SN') {
|
||||
if (state !== 'ready' || accountCardSummary === null) return
|
||||
state = "linking-" + type
|
||||
@@ -192,7 +197,7 @@
|
||||
|
||||
function inputACChange() {
|
||||
// Add spaces to the input
|
||||
const cursorIndex = cursorPositionToCursorIndex(inputAC, elemInputAC.selectionStart, /\d/)
|
||||
const cursorIndex = cursorPositionToCursorIndex(inputAC, elemInputAC.selectionStart ?? 0, /\d/)
|
||||
inputAC = inputAC.replace(/\D/g, '').replace(/(.{4})/g, '$1 ').replace(/ $/, '')
|
||||
const cursorPosition = cursorIndexToCursorPosition(inputAC, cursorIndex, /\d/)
|
||||
setTimeout(() => elemInputAC.selectionStart = elemInputAC.selectionEnd = cursorPosition, 0)
|
||||
@@ -212,7 +217,7 @@
|
||||
function inputSNChange() {
|
||||
// Add colons to the input
|
||||
inputSN = inputSN.toUpperCase()
|
||||
const cursorIndex = cursorPositionToCursorIndex(inputSN, inputElemSN.selectionStart, /[0-9A-F]/)
|
||||
const cursorIndex = cursorPositionToCursorIndex(inputSN, inputElemSN.selectionStart ?? 0, /[0-9A-F]/)
|
||||
inputSN = inputSN.replace(/[^0-9A-F]/g, '').replace(/(.{2})/g, '$1:').replace(/:$/, '')
|
||||
const cursorPosition = cursorIndexToCursorPosition(inputSN, cursorIndex, /[0-9A-F]/)
|
||||
setTimeout(() => inputElemSN.selectionStart = inputElemSN.selectionEnd = cursorPosition, 0)
|
||||
@@ -273,6 +278,16 @@
|
||||
};
|
||||
inputACChange();
|
||||
}
|
||||
|
||||
function linkQR() {
|
||||
if (!acUrl.has()) return;
|
||||
|
||||
inputAC = acUrl.get()
|
||||
if (inputAC.length !== 20) return inputAC = "";
|
||||
|
||||
inputACChange()
|
||||
acUrl.clear()
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="content">
|
||||
|
||||
@@ -11,34 +11,100 @@
|
||||
import { patchUserSegatools } from "../../libs/setup";
|
||||
|
||||
let user: AquaNetUser
|
||||
let keychip: string;
|
||||
let keychips: string[] = [];
|
||||
let selectedKeychip: string = "";
|
||||
let keychipCode: string;
|
||||
|
||||
let exposeKeychip = false;
|
||||
let automaticSetupStatus: "none" | "success" | "failure" = "none";
|
||||
let isLoading = true;
|
||||
let isAdding = false;
|
||||
let newKeychip = "";
|
||||
let addKeychipError = "";
|
||||
|
||||
USER.me().then((u) => {
|
||||
user = u;
|
||||
USER.keychip().then(k => {
|
||||
keychip = `${k.slice(0, 4)}-${k.slice(4)}1337`;
|
||||
codeToHtml(`
|
||||
function formatKeychipDisplay(k: string): string {
|
||||
return `${k.slice(0, 4)}-${k.slice(4)}`;
|
||||
}
|
||||
|
||||
function buildManualKeychipLines(): string {
|
||||
return `id=${formatKeychipDisplay(selectedKeychip)}`;
|
||||
}
|
||||
|
||||
async function buildKeychipCode() {
|
||||
exposeKeychip = false;
|
||||
const keychipLines = buildManualKeychipLines();
|
||||
keychipCode = await codeToHtml(`
|
||||
[dns]
|
||||
default=${AQUA_CONNECTION}
|
||||
|
||||
[keychip]
|
||||
enable=1
|
||||
id=${keychip}`.trim(), {
|
||||
lang: 'ini',
|
||||
theme: 'rose-pine',
|
||||
transformers: []
|
||||
}).then((html) => {
|
||||
keychipCode = html;
|
||||
});
|
||||
${keychipLines}`.trim(), {
|
||||
lang: 'ini',
|
||||
theme: 'rose-pine',
|
||||
transformers: []
|
||||
});
|
||||
}
|
||||
|
||||
async function loadKeychips() {
|
||||
isLoading = true;
|
||||
keychips = await USER.keychips();
|
||||
if (keychips.length > 0) {
|
||||
selectedKeychip = keychips[0];
|
||||
await buildKeychipCode();
|
||||
} else {
|
||||
selectedKeychip = "";
|
||||
await buildKeychipCode();
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
USER.me().then((u) => {
|
||||
user = u;
|
||||
loadKeychips();
|
||||
});
|
||||
|
||||
async function selectKeychip(k: string) {
|
||||
selectedKeychip = k;
|
||||
await buildKeychipCode();
|
||||
}
|
||||
|
||||
async function addKeychip() {
|
||||
const rawKeychipId = newKeychip.trim().toUpperCase();
|
||||
const validRawFormat = /^([A-Z\d]{4}-[A-Z\d]{11}|[A-Z\d]{15})$/.test(rawKeychipId);
|
||||
if (!validRawFormat) {
|
||||
addKeychipError = "Invalid keychip format. Use 15 characters (with optional dash): A12345678901234 or A123-12345678901.";
|
||||
return;
|
||||
}
|
||||
|
||||
const keychipId = rawKeychipId.replace("-", "");
|
||||
|
||||
addKeychipError = "";
|
||||
isAdding = true;
|
||||
try {
|
||||
const newId = await USER.addKeychip(keychipId);
|
||||
keychips = [...keychips, newId];
|
||||
selectedKeychip = newId;
|
||||
newKeychip = "";
|
||||
await buildKeychipCode();
|
||||
} catch (error) {
|
||||
addKeychipError = error instanceof Error ? error.message : "Failed to add keychip.";
|
||||
} finally {
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKeychip(k: string) {
|
||||
await USER.deleteKeychip(k);
|
||||
keychips = keychips.filter(id => id !== k);
|
||||
if (selectedKeychip === k) {
|
||||
selectedKeychip = keychips[0] ?? "";
|
||||
await buildKeychipCode();
|
||||
}
|
||||
}
|
||||
|
||||
async function patchSegatools() {
|
||||
automaticSetupStatus = await patchUserSegatools({ keychip, dns: AQUA_CONNECTION }) ? "success" : "failure";
|
||||
automaticSetupStatus = await patchUserSegatools({ keychip: formatKeychipDisplay(selectedKeychip), dns: AQUA_CONNECTION }) ? "success" : "failure";
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -47,45 +113,87 @@ id=${keychip}`.trim(), {
|
||||
<div class="setup-instructions">
|
||||
<h2>{t('home.setup')}</h2>
|
||||
|
||||
{#if keychip}
|
||||
{#if isLoading}
|
||||
<p>{t('loading')}</p>
|
||||
{:else}
|
||||
<div class="setup-step">
|
||||
1. <div>{@html t('setup.steps.one')}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<blockquote class="info">
|
||||
{t('setup.keychip-warning')}
|
||||
</blockquote>
|
||||
|
||||
{#if !!window.showOpenFilePicker}
|
||||
|
||||
{#if user.canModifyKeychips}
|
||||
<details>
|
||||
<summary>{t('setup.type.automatic')}</summary>
|
||||
{@html t('setup.automatic')}
|
||||
{#if automaticSetupStatus != "none"}
|
||||
<blockquote class={`keychip-status ${automaticSetupStatus}`}>
|
||||
{t(`setup.automatic.${automaticSetupStatus}`)}
|
||||
</blockquote>
|
||||
{/if}
|
||||
<div class="setup-btn">
|
||||
<button on:click={patchSegatools}>{t('setup.automatic.select')}</button>
|
||||
<summary>{t('setup.keychip')}</summary>
|
||||
<p>
|
||||
{t('setup.keychip.warning')}
|
||||
</p>
|
||||
<div class="keychip-list">
|
||||
{#each keychips as k}
|
||||
<div class="keychip-item" class:selected={k === selectedKeychip}>
|
||||
<button class="keychip-select" on:click={() => selectKeychip(k)}>
|
||||
{formatKeychipDisplay(k)}
|
||||
</button>
|
||||
<button class="keychip-delete danger" on:click={() => deleteKeychip(k)}>
|
||||
{t('setup.keychip.delete')}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<form class="add-keychip-form" on:submit|preventDefault={addKeychip}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('setup.keychip.placeholder')}
|
||||
maxlength="16"
|
||||
bind:value={newKeychip}
|
||||
required
|
||||
/>
|
||||
<button class="add-keychip" type="submit" disabled={isAdding}>
|
||||
{isAdding ? t('loading') : t('setup.keychip.add')}
|
||||
</button>
|
||||
</form>
|
||||
{#if addKeychipError}
|
||||
<p class="danger">{addKeychipError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
<div class="divider"></div>
|
||||
{/if}
|
||||
|
||||
<details>
|
||||
<summary>{t('setup.type.manual')}</summary>
|
||||
{@html t('setup.manual')}
|
||||
<div class="code-container">
|
||||
<div class="code" class:revealed={exposeKeychip}>
|
||||
{@html keychipCode}
|
||||
{#if selectedKeychip}
|
||||
{#if !!window.showOpenFilePicker}
|
||||
<details>
|
||||
<summary>{t('setup.type.automatic')}</summary>
|
||||
{@html t('setup.automatic')}
|
||||
{#if automaticSetupStatus != "none"}
|
||||
<blockquote class={`keychip-status ${automaticSetupStatus}`}>
|
||||
{t(`setup.automatic.${automaticSetupStatus}`)}
|
||||
</blockquote>
|
||||
{/if}
|
||||
<div class="setup-btn">
|
||||
<button on:click={patchSegatools}>{t('setup.automatic.select')}</button>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<details>
|
||||
<summary>{t('setup.type.manual')}</summary>
|
||||
{@html t('setup.manual')}
|
||||
<div class="code-container">
|
||||
<div class="code" class:revealed={exposeKeychip}>
|
||||
{@html keychipCode}
|
||||
</div>
|
||||
{#if !exposeKeychip}
|
||||
<button class="reveal-btn" on:click={() => exposeKeychip = true}>
|
||||
{t('setup.reveal-keychip')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !exposeKeychip}
|
||||
<button class="reveal-btn" on:click={() => exposeKeychip = true}>
|
||||
{t('setup.reveal-keychip')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
<br>
|
||||
</details>
|
||||
<br>
|
||||
{/if}
|
||||
|
||||
<div class="setup-step">
|
||||
2. <div>{@html t('setup.steps.two')}</div>
|
||||
@@ -93,15 +201,21 @@ id=${keychip}`.trim(), {
|
||||
<div class="setup-step">
|
||||
3. <div>{@html t('setup.steps.three')}</div>
|
||||
</div>
|
||||
<div class="setup-step">
|
||||
4. <div>{@html t('setup.steps.four')}</div>
|
||||
</div>
|
||||
|
||||
<h2>{t('setup.troubleshooting.header')}</h2>
|
||||
<ul>
|
||||
<li>
|
||||
{@html t('setup.troubleshooting.items.one')}
|
||||
</li>
|
||||
<li>
|
||||
{@html t('setup.troubleshooting.items.two')}
|
||||
</li>
|
||||
<li>
|
||||
{@html t('setup.troubleshooting.items.three')}
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
{@html t('setup.support-info')}
|
||||
</p>
|
||||
{:else}
|
||||
<p>{t('loading')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
@@ -111,6 +225,21 @@ id=${keychip}`.trim(), {
|
||||
.code
|
||||
overflow-x: auto
|
||||
|
||||
ul
|
||||
li
|
||||
margin: 0.75em 0
|
||||
.divider
|
||||
width: 90%
|
||||
height: 1px
|
||||
|
||||
background: #fff3
|
||||
|
||||
margin: 1em 0
|
||||
|
||||
position: relative
|
||||
left: 50%
|
||||
transform: translate(-50%, 0)
|
||||
|
||||
:global(pre.shiki)
|
||||
background-color: transparent !important
|
||||
|
||||
@@ -167,5 +296,48 @@ id=${keychip}`.trim(), {
|
||||
top: 50%
|
||||
left: 50%
|
||||
transform: translate(-50%, -50%)
|
||||
|
||||
|
||||
.keychip-list
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 0.5em
|
||||
margin: 1em 0
|
||||
|
||||
.keychip-item
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.5em
|
||||
padding: 0.25em 0.5em
|
||||
border-radius: 4px
|
||||
&.selected
|
||||
background: vars.$c-shadow
|
||||
|
||||
.keychip-select
|
||||
font-family: monospace
|
||||
flex: 1
|
||||
text-align: left
|
||||
|
||||
.add-keychip
|
||||
align-self: flex-start
|
||||
margin-top: 0.25em
|
||||
|
||||
.add-keychip-form
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 0.5em
|
||||
align-items: center
|
||||
margin-top: 0.25em
|
||||
|
||||
input
|
||||
flex: 1 1 16rem
|
||||
max-width: 16rem
|
||||
|
||||
button
|
||||
flex: 1 1 10rem
|
||||
max-width: 8rem
|
||||
|
||||
.danger
|
||||
color: vars.$c-error
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,11 +1,80 @@
|
||||
<script lang="ts">
|
||||
import {GAME} from "../libs/sdk";
|
||||
import {AQUA_HOST} from "../libs/config";
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { GAME } from "../libs/sdk";
|
||||
import type { PhotoPage } from "../libs/sdk";
|
||||
import { AQUA_HOST } from "../libs/config";
|
||||
import Loading from "../components/ui/Loading.svelte";
|
||||
import Error from "../components/ui/Error.svelte";
|
||||
import Pagination from "../components/Pagination.svelte";
|
||||
import { t } from "../libs/i18n";
|
||||
|
||||
const token = localStorage.getItem("token")
|
||||
let page = 1;
|
||||
const pageSize = 12;
|
||||
let photoData: PhotoPage | null = null;
|
||||
let loading = true;
|
||||
let error: any = null;
|
||||
|
||||
// Monotonic token so a slow response can never overwrite a newer one
|
||||
let requestSeq = 0;
|
||||
|
||||
function readPageFromUrl(): number {
|
||||
const raw = new URL(window.location.toString()).searchParams.get("page");
|
||||
return raw ? parseInt(raw, 10) || 1 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the url in sync with the page the server actually served, since
|
||||
* out-of-range pages are clamped server-side.
|
||||
*/
|
||||
function syncUrl(effectivePage: number) {
|
||||
const url = new URL(window.location.toString());
|
||||
const current = url.searchParams.get("page");
|
||||
const target = effectivePage > 1 ? effectivePage.toString() : null;
|
||||
if (current === target) return;
|
||||
if (target === null) url.searchParams.delete("page");
|
||||
else url.searchParams.set("page", target);
|
||||
history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
async function loadPhotos(targetPage: number) {
|
||||
const seq = ++requestSeq;
|
||||
// Apply the target immediately so the pager and further clicks act on it
|
||||
// instead of on the page that is still being fetched
|
||||
page = targetPage;
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const data = await GAME.photos(targetPage, pageSize);
|
||||
if (seq !== requestSeq) return;
|
||||
photoData = data;
|
||||
page = data.page;
|
||||
syncUrl(data.page);
|
||||
} catch (e) {
|
||||
if (seq !== requestSeq) return;
|
||||
error = e;
|
||||
} finally {
|
||||
if (seq === requestSeq) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdatePage(event: CustomEvent<number>) {
|
||||
const newPage = event.detail;
|
||||
if (newPage === page) return;
|
||||
const url = new URL(window.location.toString());
|
||||
url.searchParams.set("page", newPage.toString());
|
||||
history.pushState({}, "", url.toString());
|
||||
loadPhotos(newPage);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadPhotos(readPageFromUrl());
|
||||
|
||||
const onPopState = () => loadPhotos(readPageFromUrl());
|
||||
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="content">
|
||||
@@ -13,24 +82,31 @@
|
||||
<h2>{t("maiphoto.title")}</h2>
|
||||
</div>
|
||||
|
||||
{#await GAME.photos()}
|
||||
{#if loading && !photoData}
|
||||
<Loading/>
|
||||
{:then photos}
|
||||
{#if photos.length === 0}
|
||||
{:else if error}
|
||||
<Error {error}/>
|
||||
{:else if photoData}
|
||||
{#if photoData.total === 0}
|
||||
<blockquote class="info">{t('maiphoto.none')}</blockquote>
|
||||
{:else}
|
||||
<blockquote class="info">{t('maiphoto.url_warning')}</blockquote>
|
||||
{#if photoData.totalPages > 1}
|
||||
<Pagination {page} totalPages={photoData.totalPages} on:updatePage={handleUpdatePage} />
|
||||
{/if}
|
||||
|
||||
<div class="pictures" class:loading>
|
||||
{#each photoData.photos as photo (photo)}
|
||||
<div class="photo-container">
|
||||
<img class="rounded-2xl" loading="lazy" src="{AQUA_HOST}/api/v2/game/mai2/my-photo/{photo}" alt="Mai Memorial Photo" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if photoData.totalPages > 1}
|
||||
<Pagination {page} totalPages={photoData.totalPages} on:updatePage={handleUpdatePage} />
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="pictures">
|
||||
{#each photos as photo}
|
||||
<div class="photo-container">
|
||||
<img class="rounded-2xl" src="{AQUA_HOST}/api/v2/game/mai2/my-photo/{photo}?token={token}" alt="Memorial" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:catch error}
|
||||
<Error {error}/>
|
||||
{/await}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style lang="sass">
|
||||
@@ -42,6 +118,10 @@
|
||||
justify-content: center
|
||||
row-gap: 1rem
|
||||
gap: 1rem
|
||||
transition: opacity 0.2s ease-in-out
|
||||
|
||||
&.loading
|
||||
opacity: 0.5
|
||||
|
||||
.photo-container
|
||||
flex: 1 1 300px
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
import { title } from "../libs/ui";
|
||||
import { GAME } from "../libs/sdk";
|
||||
import type { GenericRanking } from "../libs/generalTypes";
|
||||
import StatusOverlays from "../components/StatusOverlays.svelte";
|
||||
import type { GameName } from "../libs/scoring";
|
||||
import { GAME_TITLE } from "../libs/i18n";
|
||||
import { t } from "../libs/i18n";
|
||||
import UserCard from "../components/UserCard.svelte";
|
||||
import Tooltip from "../components/Tooltip.svelte";
|
||||
import Cap from "./Ranking/Cap.svelte";
|
||||
import { DEFAULT_GAME } from "../libs/config";
|
||||
|
||||
export let game: GameName = 'mai2';
|
||||
export let game: GameName = DEFAULT_GAME;
|
||||
|
||||
title(`Ranking`);
|
||||
|
||||
@@ -70,11 +70,18 @@
|
||||
<span>{user.name}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="rating">{
|
||||
game === 'chu3' || game === 'ongeki' ?
|
||||
(user.rating / 100).toFixed(2) :
|
||||
user.rating.toLocaleString()
|
||||
}</span>
|
||||
{#if game == 'ongeki'}
|
||||
<span class="rating">{
|
||||
(user.rating / 1000).toFixed(3)
|
||||
}</span>
|
||||
{:else}
|
||||
<span class="rating">{
|
||||
game === 'chu3' ?
|
||||
(user.rating / 100).toFixed(2) :
|
||||
user.rating.toLocaleString()
|
||||
}</span>
|
||||
{/if}
|
||||
|
||||
<span class="accuracy">{(+user.accuracy).toFixed(2)}%</span>
|
||||
<span class="fc">{user.fullCombo}</span>
|
||||
<span class="ap">{user.allPerfect}</span>
|
||||
@@ -86,9 +93,9 @@
|
||||
<Tooltip triggeredBy=".name" loading={hoverLoading}>
|
||||
<UserCard username={hoveringUser} {game} setLoading={l => hoverLoading = l} />
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<p>{error}</p>
|
||||
{/if}
|
||||
|
||||
<StatusOverlays error={error} loading={!loadedPages} />
|
||||
</main>
|
||||
|
||||
<style lang="sass">
|
||||
@@ -141,6 +148,14 @@
|
||||
|
||||
.accuracy
|
||||
display: none
|
||||
.modern-rating
|
||||
display: none
|
||||
.legacy-rating
|
||||
/* this is officially the worst css i've ever written dear god */
|
||||
white-space: nowrap
|
||||
overflow: hidden
|
||||
text-overflow: clip
|
||||
min-width: 3.5em !important
|
||||
|
||||
&.alternate
|
||||
background-color: vars.$ov-light
|
||||
|
||||
@@ -95,7 +95,9 @@
|
||||
bind:tested={srcTested} bind:this={srcEl} bind:exportedData={srcExportedData} />
|
||||
|
||||
<div class="arrow" class:disabled={!(srcTested && dstTested)}>
|
||||
<img src="{DATA_HOST}/d/DownArrow.png" alt="arrow" on:click={startTransfer}>
|
||||
<button class="arrow-btn" on:click={startTransfer}>
|
||||
<img src="{DATA_HOST}/d/DownArrow.png" alt="arrow">
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TransferServer bind:src={dst} bind:gameInfo={gameInfo} on:change={onChange}
|
||||
@@ -115,6 +117,15 @@
|
||||
&.disabled
|
||||
filter: grayscale(1)
|
||||
|
||||
.arrow-btn
|
||||
background: none
|
||||
border: none
|
||||
padding: 0
|
||||
cursor: pointer
|
||||
|
||||
&:disabled
|
||||
cursor: not-allowed
|
||||
|
||||
// CSS animation to let the image opacity breathe
|
||||
img
|
||||
animation: breathe 1s infinite alternate
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
validate={v => /^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(v)} />
|
||||
<InputTextShort desc={t('trans.field.keychip')} placeholder="e.g. A0299792458"
|
||||
bind:value={src.keychip} on:change disabled={tested}
|
||||
validate={v => /^([A-Z0-9]{11}|[A-Z0-9]{4}-[A-Z0-9]{11})$/.test(v)} />
|
||||
validate={v => /^([A-Z\d]{15}|[A-Z\d]{4}-[A-Z\d]{11})$/.test(v)} />
|
||||
</div>
|
||||
|
||||
<!-- Second input line -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import UserSettings from "../../components/settings/UserSettings.svelte";
|
||||
import type { Component } from "svelte";
|
||||
import { EN_REF } from "../../libs/i18n/en_ref";
|
||||
import type { GameName } from "../../libs/scoring";
|
||||
|
||||
USER.ensureLoggedIn()
|
||||
|
||||
@@ -30,15 +31,22 @@
|
||||
if (!pages[page] && page)
|
||||
error = t("404", {pathname: new URL(location.href).pathname});
|
||||
|
||||
USER.me().then(m => me = m)
|
||||
let userGames: GameName[] = [];
|
||||
|
||||
USER.me().then(m => {
|
||||
me = m
|
||||
CARD.userGames(m.username).then(cards => userGames = Object.keys(cards).filter(k => !!cards[k as GameName]) as GameName[])
|
||||
})
|
||||
.catch(e => error = e.message)
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<main class="content">
|
||||
<div class="outer-title-options">
|
||||
<h2>{t('settings.title')}</h2>
|
||||
<nav>
|
||||
{#each Object.entries(pages) as tab}
|
||||
{#each Object.entries(pages).filter(v => v[0] == "profile" || v[0] == "global" || userGames.includes(v[0] as GameName)) as tab}
|
||||
<a href={`/settings/${tab[0] != "profile" ? tab[0] : ""}`} transition:slide={{axis: 'x'}}
|
||||
class:active={tab[0] == page || (tab[0] == "profile" && !page)} role="button" tabindex="0">
|
||||
{ts(`settings.tabs.${tab[0]}`)}
|
||||
|
||||
@@ -29,12 +29,11 @@
|
||||
|
||||
registerChart()
|
||||
|
||||
export let username: string;
|
||||
export let game: GameName;
|
||||
export let username: string = "";
|
||||
export let game: GameName = "" as GameName;
|
||||
let calElement: HTMLElement
|
||||
let error: string;
|
||||
let me: AquaNetUser
|
||||
title(`User ${username}`)
|
||||
const rounding = useLocalStorage("rounding", true);
|
||||
|
||||
interface MusicAndPlay extends MusicMeta, GenericGamePlaylog {}
|
||||
@@ -51,6 +50,7 @@
|
||||
let isLoading = false
|
||||
let showMoreRecent = false
|
||||
|
||||
|
||||
function init() {
|
||||
USER.isLoggedIn() && USER.me().then(u => me = u)
|
||||
|
||||
@@ -77,11 +77,13 @@
|
||||
GAME.userSummary(username, game),
|
||||
GAME.trend(username, game),
|
||||
DATA.allMusic(game),
|
||||
]).then(([user, trend, music]) => {
|
||||
]).then(async ([user, trend, music]) => {
|
||||
console.log(user)
|
||||
console.log(trend)
|
||||
console.log(games)
|
||||
|
||||
title(` ${user.name} (@${username}) ・ ${GAME_TITLE[game]}`)
|
||||
|
||||
// If game is wacca, divide all ratings by 10
|
||||
if (game === 'wacca') {
|
||||
user.rating /= 10
|
||||
@@ -161,7 +163,7 @@
|
||||
<a href={`/u/${username}/${g}`} class:active={game === g}>{name}</a>
|
||||
{/each}
|
||||
|
||||
{#if me && me.username === username}
|
||||
{#if me && me.username.toLowerCase() === username.toLowerCase()}
|
||||
<a class="setting-icon clickable" use:tooltip={t("UserHome.Settings")} href={`/settings/${game}`}>
|
||||
<Icon icon="eos-icons:rotating-gear"/>
|
||||
</a>
|
||||
@@ -187,19 +189,31 @@
|
||||
<div class="scoring-info">
|
||||
<div class="chart">
|
||||
<div class="info-top">
|
||||
<div class="rating">
|
||||
<span>{game === 'mai2' ? t("UserHome.DXRating"): t("UserHome.Rating")}</span>
|
||||
<span>{
|
||||
game === 'chu3' || game === 'ongeki' ?
|
||||
(d.user.rating / 100).toFixed(2) :
|
||||
d.user.rating.toLocaleString()
|
||||
}</span>
|
||||
</div>
|
||||
|
||||
<div class="rank">
|
||||
<span>{t('UserHome.ServerRank')}</span>
|
||||
<span>#{(d.user.serverRank + 1).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
{#if game == "ongeki" && d.user.ratingNotGeneric}
|
||||
<div class="rating">
|
||||
<span>{t("UserHome.Rating")}</span>
|
||||
<span>{(d.user.rating / 1000).toFixed(3)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rating">
|
||||
<span>{game === 'mai2' ? t("UserHome.DXRating"): t("UserHome.Rating")}</span>
|
||||
<!-- NOTE: this is for legacy ongeki display, so 1.45 profiles are at least usable -->
|
||||
<span>{
|
||||
game === 'chu3' || game === 'ongeki' ?
|
||||
(d.user.rating / 100).toFixed(2) :
|
||||
d.user.rating.toLocaleString()
|
||||
}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isNaN(parseInt(d.user.serverRank))}
|
||||
<div class="rank">
|
||||
<span>{t('UserHome.ServerRank')}</span>
|
||||
<span>#{(d.user.serverRank).toLocaleString()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="trend">
|
||||
@@ -247,6 +261,23 @@
|
||||
<span>{(d.user.accuracy).toFixed(2)}%</span>
|
||||
</div>
|
||||
|
||||
{#if game == "ongeki" && d.user.ratingNotGeneric}
|
||||
<div class="rating">
|
||||
<span>{t("UserHome.HighestRating")}</span>
|
||||
<span>{(d.user.ratingHighest / 1000).toFixed(3)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rating">
|
||||
<span>{game === 'mai2' ? t("UserHome.HighestDXRating"): t("UserHome.HighestRating")}</span>
|
||||
<!-- NOTE: this is for legacy ongeki display, so 1.45 profiles are at least usable -->
|
||||
<span>{
|
||||
game === 'chu3' || game === 'ongeki' ?
|
||||
(d.user.ratingHighest / 100).toFixed(2) :
|
||||
d.user.ratingHighest.toLocaleString()
|
||||
}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="max-combo">
|
||||
<span>{t("UserHome.MaxCombo")}</span>
|
||||
<span>{d.user.maxCombo}</span>
|
||||
@@ -306,18 +337,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- I don't like doing this but it may be preferable to gaslighting the types -->
|
||||
<RatingComposition title={t("UserHome.RatingComposition.Best", {n: 50})} comp={d.user.ratingComposition.best50} {allMusics} {game}/>
|
||||
<RatingComposition title={t("UserHome.RatingComposition.Best", {n: 35})} comp={d.user.ratingComposition.best35} {allMusics} {game}/>
|
||||
<RatingComposition title={t("UserHome.RatingComposition.Best", {n: 30})} comp={d.user.ratingComposition.best30} {allMusics} {game}/>
|
||||
<RatingComposition title={t("UserHome.RatingComposition.Best", {n: 15})} comp={d.user.ratingComposition.best15} {allMusics} {game}/>
|
||||
<RatingComposition title={t("UserHome.RatingComposition.New", {n : 15})} comp={d.user.ratingComposition.new15} {allMusics} {game}/>
|
||||
<RatingComposition title={t("UserHome.RatingComposition.New", {n: 10})} comp={d.user.ratingComposition.new10} {allMusics} {game}/>
|
||||
<RatingComposition title={t("UserHome.RatingComposition.Platinum", {n: 50})} comp={d.user.ratingComposition.pscore50} {allMusics} {game}/>
|
||||
|
||||
{#if me && me.displayCandidates && d.user.aquaUser && me.username == d.user.aquaUser.username}
|
||||
<RatingComposition title={t("UserHome.RatingComposition.BestCandidates", {n: 20})} comp={d.user.ratingComposition.best20_candidates} {allMusics} {game}/>
|
||||
<RatingComposition title={t("UserHome.RatingComposition.NewCandidates", {n: 10})} comp={d.user.ratingComposition.new10_candidates} {allMusics} {game}/>
|
||||
<RatingComposition title={t("UserHome.RatingComposition.PlatinumCandidates", {n: 20})} comp={d.user.ratingComposition.pscore20_candidates} {allMusics} {game}/>
|
||||
{/if}
|
||||
|
||||
<RatingComposition title="B30" comp={d.user.ratingComposition.best30} {allMusics} {game}/>
|
||||
<RatingComposition title="B35" comp={d.user.ratingComposition.best35} {allMusics} {game}/>
|
||||
<RatingComposition title="B15" comp={d.user.ratingComposition.best15} {allMusics} {game}/>
|
||||
<!-- <RatingComposition title="Hot 10" comp={d.user.ratingComposition.hot10} {allMusics} {game}/> -->
|
||||
<!-- <RatingComposition title="N10" comp={d.user.ratingComposition.next10} {allMusics} {game}/> -->
|
||||
<!-- Chuni -->
|
||||
{#if d.user.ratingComposition.new}
|
||||
<RatingComposition title="New 20" comp={d.user.ratingComposition.new} {allMusics} game="chu3"/>
|
||||
<RatingComposition title={t("UserHome.RatingComposition.New", {n: 20})} comp={d.user.ratingComposition.new} {allMusics} game="chu3"/>
|
||||
{:else}
|
||||
<RatingComposition title="Recent 10" comp={d.user.ratingComposition.recent10} {allMusics} {game} top={10}/>
|
||||
<RatingComposition title={t("UserHome.RatingComposition.Recent", {n: 10})} comp={d.user.ratingComposition.recent10} {allMusics} {game} top={10}/>
|
||||
{/if}
|
||||
|
||||
<div class="recent">
|
||||
@@ -494,7 +532,7 @@
|
||||
.scoring-info
|
||||
display: flex
|
||||
gap: vars.$gap
|
||||
max-height: 250px
|
||||
max-height: 300px
|
||||
|
||||
.chart
|
||||
flex: 0 1 790px
|
||||
@@ -575,7 +613,7 @@
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 20px
|
||||
|
||||
|
||||
background-color: rgba(white, 0.03)
|
||||
border-radius: vars.$border-radius
|
||||
|
||||
@@ -593,10 +631,6 @@
|
||||
overflow: hidden
|
||||
flex-direction: column
|
||||
|
||||
.first-line
|
||||
display: flex
|
||||
flex-direction: row
|
||||
|
||||
// Limit song name to one line
|
||||
.song-title
|
||||
max-width: 90%
|
||||
@@ -612,9 +646,6 @@
|
||||
flex-direction: column
|
||||
gap: 0
|
||||
|
||||
.rank-text
|
||||
text-align: left
|
||||
|
||||
// Recent Scores section
|
||||
.recent
|
||||
.scores
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
import { USER } from "../libs/sdk";
|
||||
import { t } from "../libs/i18n"
|
||||
|
||||
import * as acUrl from "../libs/acUrl"
|
||||
const hasAc = acUrl.has()
|
||||
let previewAc = ""
|
||||
|
||||
if (hasAc) {
|
||||
previewAc = acUrl.preview() || ""
|
||||
}
|
||||
|
||||
let params = new URLSearchParams(window.location.search)
|
||||
|
||||
let state = "home"
|
||||
@@ -178,6 +186,13 @@
|
||||
<main id="home" class="no-margin">
|
||||
<div>
|
||||
<h1 id="title">AquaNet</h1>
|
||||
<div id="welcome">
|
||||
{#if hasAc}
|
||||
<span>{t('welcome.login_link')}:</span><br>
|
||||
<small>{previewAc}</small>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if state === "home"}
|
||||
<div class="btn-group" transition:slide>
|
||||
<button on:click={() => state = 'login'}>{t('welcome.btn-login')}</button>
|
||||
@@ -329,6 +344,9 @@
|
||||
margin-bottom: 32px
|
||||
opacity: 0.9
|
||||
|
||||
#welcome
|
||||
margin-bottom: 32px
|
||||
|
||||
.btn-group
|
||||
display: flex
|
||||
gap: 8px
|
||||
|
||||
+14
-32
@@ -1,37 +1,19 @@
|
||||
# CONTRIBUTING
|
||||
Thanks for your interest in improving Aqua server, and huge welcome! This document will guide you how to get started.
|
||||
# Contribution
|
||||
|
||||
# Before you start
|
||||
Thank you for your interest in contributing to the AquaDX server and/or AquaNet web interface implementation.
|
||||
Please look over the following guidelines before creating an issue or pull request.
|
||||
|
||||
* We (or at least I, the fork maintainer) will not care where you come from or what kind of scene you involve with as long as you are in good faith.
|
||||
* This fork might have n-0 or even n+1 support from time to time. If you can't agree with this, please refrain from get involve with this project.
|
||||
* We **will not help you to get game itself**. This includes sharing a patched or original binary. Please don't ask or give guidance about it within this repository. This is bare minimum for our safety and long run possibility of this project.
|
||||
- We try to support game versions that are n-0 (and occasionally n+1).<br>
|
||||
By using this server and/or contributing source, you understand some features may be incomplete due to restrictions (whether it's technical or due to it being n-0).
|
||||
|
||||
# Getting started
|
||||
- Issues should describe a problem or feature request for either AquaDX or AquaNet. <br>
|
||||
Please do not ask for game dumps, ask for assistance with setting up a game dump or send links to game dumps.
|
||||
|
||||
* Please make sure you read README.md and documents in /docs folder.
|
||||
* Make sure you have account for this gitlab instance.
|
||||
- Pull requests should have meaningful descriptions and changes should match the rest of our codebase to be accepted.<br>
|
||||
Commits can be formatted in any way preferable as long as they're easily digestable and meaningful.<br>
|
||||
All pull requests must be tested and ensured functional.<br>
|
||||
If contributing to the server (AquaDX), the server should be capable of booting to the colorful logo & affected clients should be able to connect and make use of your contribution(s).<br>
|
||||
If contributing to the web interface (AquaNet), it should be capable of building, shouldn't report any errors and should make use of your contribution(s).
|
||||
|
||||
# Steps to follow
|
||||
|
||||
* Open a issue: if you found a bug or want to add or suggest new feature, try open a issue first so we can know about the problem and start discussion.
|
||||
* Include some kind of information (for example: error log if a bug; what you trying to achieve if a new feature)
|
||||
* This step is not essential but will make thing much easier later on. (PR)
|
||||
* If you are end user (not a developer), it ends in here. Thanks!
|
||||
|
||||
* Fork it and make a new branch where you start working on it.
|
||||
* Please make a branch per PR if you are thinking about making multiple PRs. Still, we can cherry-pick even it's not but..
|
||||
|
||||
* Start working
|
||||
* We don't have any code formatting guide or rule. As long as code runs fine and readable enough, good to go.
|
||||
* Make commits of logical and atomic units. Please DO NOT make a commit message too general like "WIP - 1" or "Work".
|
||||
|
||||
* Test if it works
|
||||
* Database changes: default database testing is sqlite only. If you are not sure or can't test with MariaDB or MySQL, just leave a message about it in the PR.
|
||||
* Game endpoint changes: Although aqua have testing process while building and packaging, this probably won't do much for the game so our recommendation is run through a game at least once (from entry to saving).
|
||||
* Server API / feature / bug fix: Just test at least once the way you want.
|
||||
|
||||
* Make a PR
|
||||
* Thanks! We will get you know when it's ready.
|
||||
|
||||
We appreciate your time. Contribution is what makes open source project interesting and keep working.
|
||||
- AquaDX makes use of the [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en) license.<br>
|
||||
By contributing, you agree your contributions can be placed under this license.
|
||||
|
||||
+7
-4
@@ -1,7 +1,6 @@
|
||||
# Use a multi-stage build to keep the image size small
|
||||
# Start with a Gradle image for building the project
|
||||
#FROM gradle:jdk21-alpine as builder
|
||||
FROM gradle:8.8.0-jdk21 as builder
|
||||
# Pin the builder so ARM64 deployments cannot reuse an older floating jdk25 image.
|
||||
FROM gradle:9.6.1-jdk25 AS builder
|
||||
|
||||
# Copy the Gradle wrapper and configuration files separately to leverage Docker cache
|
||||
COPY --chown=gradle:gradle gradlew /home/gradle/
|
||||
@@ -16,16 +15,20 @@ RUN sed -i 's/\r$//' ./gradlew
|
||||
|
||||
# Download dependencies - cached if build.gradle.kts and settings.gradle.kts are unchanged
|
||||
RUN chmod +x ./gradlew
|
||||
RUN java -version
|
||||
RUN ./gradlew dependencies
|
||||
|
||||
# Copy the project source, this layer is rebuilt whenever a file has changed
|
||||
COPY --chown=gradle:gradle src /home/gradle/src
|
||||
|
||||
# Download game metadata (music/items lists)
|
||||
RUN bash /home/gradle/src/main/resources/meta/update.sh
|
||||
|
||||
# Build the application
|
||||
RUN ./gradlew build -x test
|
||||
|
||||
# Start with a fresh image for the runtime
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
FROM eclipse-temurin:25-jre-alpine
|
||||
|
||||
# Set the deployment directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -32,21 +32,22 @@ Multipurpose game server for ALL.Net games.
|
||||
> [!WARNING]
|
||||
> CHUNITHM pre-NEW!! and maimai pre-DX are no longer supported after February 6th, 2026 and all associated data will be removed.
|
||||
|
||||
| Game | Latest Ver. | Initial Ver. | Notes | Web UI | Data Import |
|
||||
|------------------------|---------------------|---------------------|-------------------------------------------------------------|--------|-------------|
|
||||
| SDHD: CHUNITHM | 2.45 (X-VERSE-X) | 2.00 (NEW) | | ✅ | ❗ |
|
||||
| SDEZ: maimai DX | 1.65 (CiRCLE PLUS) | 1.00 (DX) | Missing circle (teams) support | ✅ | ✅ |
|
||||
| SDGA: maimai DX (Intl) | 1.60 (CiRCLE) | 1.00 (DX) | Thanks [@Clansty](https://github.com/clansty) | ✅ | ✅ |
|
||||
| SDED: Card Maker | 1.39 | N/A | Thanks [@Becods](https://github.com/Becods) | ❌ | ❌ |
|
||||
| SDDT: O.N.G.E.K.I. | 1.50 (Re:Fresh) | N/A | Thanks [@PenguinCaptain](https://github.com/PenguinCaptain) | ✅ | ❌ |
|
||||
| SBZV: Project DIVA | 7.10 | N/A | Minimal support | ❌ | ❌ |
|
||||
| SDFE: Wacca | 3.07 (Reverse) | N/A | Later versions are EOS patches, network will not work | ✅ | ❌ |
|
||||
| Game | Latest Ver. | Initial Ver. | Notes | Web UI | Import | AES |
|
||||
|------------------------|-----------------------|---------------------|-------------------------------------------------------------|--------|-------------|-----|
|
||||
| SDHD: CHUNITHM | 2.50 (Mate) | 2.00 (NEW) | | ✅ | ✅ | ✅ |
|
||||
| SDEZ: maimai DX | 1.65 (CiRCLE PLUS) | 1.00 (DX) | Thanks [@Menci](https://github.com/Menci) | ✅ | ✅ | ✅ |
|
||||
| SDGA: maimai DX (Intl) | 1.60 (CiRCLE) | 1.00 (DX) | Thanks [@Clansty](https://github.com/clansty) | ✅ | ✅ | ✅ |
|
||||
| SDED: Card Maker | 1.39 | N/A | Thanks [@Becods](https://github.com/Becods) | ❌ | ❌ | ❌ |
|
||||
| SDDT: O.N.G.E.K.I. | 1.60 (Re:Fresh Act 2) | N/A | Thanks [@PenguinCaptain](https://github.com/PenguinCaptain) | ✅ | ✅ | ❌ |
|
||||
| SBZV: Project DIVA | 7.10 | N/A | Minimal support | ❌ | ❌ | 🔓 |
|
||||
| SDFE: Wacca | 3.07 (REVERSE) | N/A | Later versions are EOS patches | ✅ | ❌ | 🔓 |
|
||||
|
||||
<!-- A majority of them have been left as N/A for initial version as they do not appear to have restrictions -->
|
||||
|
||||
Check out these docs for more information.
|
||||
* [Game specific notes](docs/game_specific_notes.md)
|
||||
* [Frequently asked questions](docs/frequently_asked_questions.md)
|
||||
* [Encryption notes](docs/encryption.md)
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
+66
-54
@@ -2,9 +2,10 @@
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
val ktVer = "2.1.10"
|
||||
val ktVer = "2.4.0"
|
||||
|
||||
java
|
||||
kotlin("jvm") version ktVer
|
||||
@@ -13,9 +14,9 @@ plugins {
|
||||
kotlin("plugin.serialization") version ktVer
|
||||
kotlin("plugin.allopen") version ktVer
|
||||
kotlin("kapt") version ktVer
|
||||
id("org.springframework.boot") version "3.2.3"
|
||||
id("com.github.ben-manes.versions") version "0.51.0"
|
||||
id("org.hibernate.orm") version "6.4.4.Final"
|
||||
id("org.springframework.boot") version "4.1.0"
|
||||
id("com.github.ben-manes.versions") version "0.54.0"
|
||||
id("org.hibernate.orm") version "7.4.4.Final"
|
||||
application
|
||||
}
|
||||
|
||||
@@ -26,6 +27,9 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
extra["netty.version"] = "4.2.16.Final"
|
||||
extra["kotlin-coroutines.version"] = "1.11.0"
|
||||
|
||||
dependencies {
|
||||
// Spring boot
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
@@ -34,28 +38,30 @@ dependencies {
|
||||
exclude(group = "org.springframework.boot", module = "spring-boot-starter-tomcat")
|
||||
}
|
||||
implementation("org.springframework.boot:spring-boot-starter-jetty")
|
||||
implementation("io.netty:netty-all")
|
||||
implementation("org.apache.commons:commons-lang3:3.14.0")
|
||||
implementation("org.apache.httpcomponents.client5:httpclient5")
|
||||
implementation("org.flywaydb:flyway-core:10.10.0")
|
||||
implementation("org.flywaydb:flyway-mysql:10.10.0")
|
||||
implementation(enforcedPlatform("io.netty:netty-bom:4.2.16.Final"))
|
||||
implementation("io.netty:netty-all:4.2.16.Final")
|
||||
implementation("org.apache.commons:commons-lang3:3.20.0")
|
||||
implementation("org.apache.httpcomponents.client5:httpclient5:5.6.2")
|
||||
implementation("org.springframework.boot:spring-boot-starter-flyway")
|
||||
implementation("org.flywaydb:flyway-core:12.10.0")
|
||||
implementation("org.flywaydb:flyway-mysql:12.10.0")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test") {
|
||||
exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
|
||||
}
|
||||
testImplementation("org.springframework.security:spring-security-test")
|
||||
implementation("net.logstash.logback:logstash-logback-encoder:7.4")
|
||||
implementation("net.logstash.logback:logstash-logback-encoder:9.0")
|
||||
|
||||
// Metrics
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("io.micrometer:micrometer-registry-prometheus")
|
||||
|
||||
// Database
|
||||
runtimeOnly("org.mariadb.jdbc:mariadb-java-client:3.3.3")
|
||||
runtimeOnly("org.xerial:sqlite-jdbc:3.45.2.0")
|
||||
implementation("org.hibernate.orm:hibernate-core:6.4.4.Final")
|
||||
implementation("org.hibernate.orm:hibernate-community-dialects:6.4.4.Final")
|
||||
implementation("io.github.openfeign.querydsl:querydsl-jpa:6.10.1")
|
||||
kapt("io.github.openfeign.querydsl:querydsl-apt:6.10.1:jpa")
|
||||
runtimeOnly("org.mariadb.jdbc:mariadb-java-client:3.5.9")
|
||||
runtimeOnly("org.xerial:sqlite-jdbc:3.53.2.0")
|
||||
implementation("org.hibernate.orm:hibernate-core:7.4.4.Final")
|
||||
implementation("org.hibernate.orm:hibernate-community-dialects:7.4.4.Final")
|
||||
implementation("io.github.openfeign.querydsl:querydsl-jpa:7.4.0")
|
||||
kapt("io.github.openfeign.querydsl:querydsl-apt:7.4.0:jpa")
|
||||
|
||||
// JSR305 for nullable
|
||||
implementation("com.google.code.findbugs:jsr305:3.0.2")
|
||||
@@ -65,50 +71,57 @@ dependencies {
|
||||
// =============================
|
||||
|
||||
// Network
|
||||
implementation("io.ktor:ktor-client-core:3.0.3")
|
||||
implementation("io.ktor:ktor-client-cio:3.0.3")
|
||||
implementation("io.ktor:ktor-client-content-negotiation:3.0.3")
|
||||
implementation("io.ktor:ktor-client-encoding:3.0.3")
|
||||
implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.3")
|
||||
implementation("io.ktor:ktor-client-core:3.5.1")
|
||||
implementation("io.ktor:ktor-client-cio:3.5.1")
|
||||
implementation("io.ktor:ktor-client-content-negotiation:3.5.1")
|
||||
implementation("io.ktor:ktor-client-encoding:3.5.1")
|
||||
implementation("io.ktor:ktor-serialization-kotlinx-json:3.5.1")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
|
||||
// Somehow these are needed for ktor even though they're not in the documentation
|
||||
runtimeOnly("org.reactivestreams:reactive-streams:1.0.4")
|
||||
runtimeOnly("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.0")
|
||||
runtimeOnly("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.11.0")
|
||||
|
||||
// Email
|
||||
implementation("org.simplejavamail:simple-java-mail:8.6.3")
|
||||
implementation("org.simplejavamail:spring-module:8.6.3")
|
||||
implementation("org.simplejavamail:simple-java-mail:9.0.1")
|
||||
implementation("org.simplejavamail:spring-module:9.0.1")
|
||||
|
||||
// GeoIP
|
||||
implementation("com.maxmind.geoip2:geoip2:4.2.0")
|
||||
implementation("com.maxmind.geoip2:geoip2:5.1.0")
|
||||
|
||||
// JWT Authentication
|
||||
implementation("io.jsonwebtoken:jjwt-api:0.12.5")
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5")
|
||||
implementation("io.jsonwebtoken:jjwt-api:0.13.0")
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0")
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0")
|
||||
|
||||
// Content validation
|
||||
implementation("org.apache.tika:tika-core:2.9.1")
|
||||
implementation("org.apache.tika:tika-core:3.3.1")
|
||||
|
||||
// Import: DateTime Parsing
|
||||
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0")
|
||||
|
||||
// Serialization
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
// Serialization (Jackson 3; JSR-310 support is built into jackson-databind 3.x)
|
||||
implementation("tools.jackson.module:jackson-module-kotlin:3.2.1")
|
||||
|
||||
// Testing
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:5.8.1")
|
||||
testImplementation("io.kotest:kotest-assertions-core")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.2.2")
|
||||
testImplementation("io.kotest:kotest-assertions-core:6.2.2")
|
||||
}
|
||||
|
||||
group = "icu.samnya"
|
||||
version = "1.0.0"
|
||||
description = "AquaDX Arcade Server"
|
||||
java.sourceCompatibility = JavaVersion.VERSION_21
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(25))
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
jvmToolchain(25)
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_25)
|
||||
}
|
||||
}
|
||||
|
||||
springBoot {
|
||||
@@ -119,14 +132,6 @@ application {
|
||||
mainClass = "icu.samnyan.aqua.EntryKt"
|
||||
}
|
||||
|
||||
hibernate {
|
||||
enhancement {
|
||||
enableLazyInitialization = true
|
||||
enableAssociationManagement = false
|
||||
enableExtendedEnhancement = false
|
||||
}
|
||||
}
|
||||
|
||||
kapt {
|
||||
includeCompileClasspath = false
|
||||
keepJavacAnnotationProcessors = true
|
||||
@@ -138,29 +143,36 @@ allOpen {
|
||||
annotation("jakarta.persistence.Embeddable")
|
||||
}
|
||||
|
||||
val buildTime: String by extra(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(ZoneId.of("UTC")).format(Instant.now()))
|
||||
val buildTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(ZoneId.of("UTC")).format(Instant.now())
|
||||
val projectVersion = version.toString()
|
||||
extra["buildTime"] = buildTime
|
||||
|
||||
tasks.processResources {
|
||||
filesMatching("**/application.properties") {
|
||||
expand(project.properties)
|
||||
expand(
|
||||
mapOf(
|
||||
"version" to projectVersion,
|
||||
"ext" to mapOf("buildTime" to buildTime),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
enabled = project.hasProperty("runTests")
|
||||
enabled = providers.gradleProperty("runTests").isPresent
|
||||
useJUnitPlatform()
|
||||
jvmArgs("-Dkotest.assertions.collection.print.size=100")
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile> {
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
tasks.withType<Javadoc> {
|
||||
tasks.withType<Javadoc>().configureEach {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
tasks.getByName<Jar>("jar") {
|
||||
tasks.named<Jar>("jar") {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
@@ -170,12 +182,12 @@ sourceSets {
|
||||
}
|
||||
}
|
||||
|
||||
val copyDependencies by tasks.registering(Copy::class) {
|
||||
val copyDependencies = tasks.register<Copy>("copyDependencies") {
|
||||
from(configurations.runtimeClasspath)
|
||||
into("${layout.buildDirectory.get()}/libs/lib")
|
||||
}
|
||||
|
||||
val packageThin by tasks.registering(Jar::class) {
|
||||
tasks.register<Jar>("packageThin") {
|
||||
group = "build"
|
||||
from(sourceSets.main.get().output)
|
||||
manifest {
|
||||
|
||||
@@ -97,6 +97,7 @@ spring.datasource.hikari.maximum-pool-size=10
|
||||
|
||||
## Link card limit
|
||||
aqua-net.link-card-limit=10
|
||||
aqua-net.keychip-limit=20
|
||||
|
||||
## CloudFlare Turnstile Captcha
|
||||
## This enables captcha for user registration.
|
||||
@@ -141,4 +142,4 @@ aqua-net.bot.enabled=true
|
||||
aqua-net.bot.secret=hunter2
|
||||
|
||||
## OpenAI Settings (For content moderation)
|
||||
aqua-net.openai.api-key=sk-1234567890abcdef1234567890abcdef
|
||||
aqua-net.openai.api-key=sk-1234567890abcdef1234567890abcdef
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM archlinux:latest
|
||||
|
||||
RUN pacman -Syu --noconfirm \
|
||||
&& pacman -S --noconfirm openssh sudo jdk21-openjdk wget which procps-ng zsh git curlie micro ripgrep python3 exa \
|
||||
&& pacman -S --noconfirm openssh sudo jdk25-openjdk wget which procps-ng zsh git curlie micro ripgrep python3 exa \
|
||||
&& rm -rf /var/cache/pacman/pkg/* \
|
||||
&& mkdir -p /var/run/sshd \
|
||||
&& chsh -s /bin/zsh root \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: hykilpikonna/aquadx:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
|
||||
@@ -17,10 +17,46 @@
|
||||
| 11 | Frame |
|
||||
| 12 | Tickets |
|
||||
| 13 | Mile |
|
||||
| 14 | Intimate Item |
|
||||
| 15 | Kaleidx Scope Key |
|
||||
|
||||
## Multiplayer
|
||||
| 14 | Intimate Item |
|
||||
| 15 | Kaleidx Scope Key |
|
||||
|
||||
## Kaleidx Scope gates
|
||||
|
||||
Kaleidx Scope gate visibility is controlled by three server responses:
|
||||
|
||||
* `GetGameEventApi` must return open event rows for the gate/course event IDs used by the game's static data.
|
||||
* `GetGameKaleidxScopeApi` must return a `gameKaleidxScopeList` row for each gate the user can see. Current known gate IDs are `1..6` for the original gates, `7` for Prism/Center Tower, `8` for the post-Prism boss gate, `9` for Hope Gate, and `10` for the final challenge.
|
||||
* `GetUserKaleidxScopeApi` must return a `userKaleidxScopeList` row for the same `gateId`.
|
||||
|
||||
The client hides a gate when the user row is missing, the event is closed, or the row has `isGateFound = false`. A visible selectable gate needs at least:
|
||||
|
||||
```json
|
||||
{
|
||||
"gateId": 8,
|
||||
"isGateFound": true,
|
||||
"isKeyFound": true,
|
||||
"isClear": false
|
||||
}
|
||||
```
|
||||
|
||||
The unlock announcement can still appear when `isGateFound = true`, `isKeyFound = true`, and `isInfoWatched = false`; that announcement alone does not prove that the gate will be listed in the menu on the next download.
|
||||
|
||||
For progression past Prism Tower, `UpsertUserAllApi` must persist `upsertUserAll.userKaleidxScopeList` by `(userId, gateId)`. AquaDX unlocks Prism/Center Tower gate `7` after gate `6` is clear. After gate `7` is clear, the next `GetUserKaleidxScopeApi` response must include gate `7` as clear and a gate `8` row with both found/key true. The same pattern applies for gates `9` and `10` after their preceding gates are clear.
|
||||
|
||||
Fixed AquaDX.v1 issue: older code advertised gates `1..10` from `GetGameKaleidxScopeApi` and saved uploaded Kaleidx rows in `UpsertUserAllApi`, but `GetUserKaleidxScopeApi` only backfilled missing rows for gates `1..6`. It also forced every persisted row to `isKeyFound = true` before returning it. Existing users without usable rows for gate `7` or later could therefore receive an unlock-style message but still have no visible Prism Tower or post-Prism gate in the Kaleidx Scope menu.
|
||||
|
||||
To confirm the failure for a user, inspect their rows:
|
||||
|
||||
```sql
|
||||
SELECT gate_id, is_gate_found, is_key_found, is_clear, is_info_watched
|
||||
FROM maimai2_user_kaleidx
|
||||
WHERE user_id = <internal_user_id>
|
||||
ORDER BY gate_id;
|
||||
```
|
||||
|
||||
If gate `7` is clear and gate `8` is missing, or if any target gate row has `is_gate_found = 0`, the menu will not show the next gate.
|
||||
|
||||
## Multiplayer
|
||||
|
||||
### Party Host/Client/Member
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Encryption Support
|
||||
|
||||
AquaDX supports encryption for Chunithm, Maimai and Ongeki.<br>
|
||||
It is not required to connect and keys are NOT provided by default.
|
||||
|
||||
Card Maker has encryption features in the client, but they are not supported due to it's complexity.
|
||||
|
||||
## Adding new keys to AquaDX
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Ensure you're using AquaDX from source. Docker will work, as long as you use `docker-compose up --build`
|
||||
|
||||
1. Create a new file at `src/main/resources/data/game/<game name>/game_encryption.json`
|
||||
2. Fill it out using the following schema:
|
||||
```json
|
||||
{
|
||||
"code": "SDHD",
|
||||
"versions": [215],
|
||||
"key": "DECAFBADDECAFBADDECAFBADDECAFBADDECAFBADDECAFBADDECAFBADDECAFBAD",
|
||||
"iv": "DECAFBADDECAFBADDECAFBADDECAFBAD",
|
||||
"salt": "DECAFBADDECAFBAD",
|
||||
"iterations": 36
|
||||
},
|
||||
```
|
||||
|
||||
**`key`, `salt`, and `iv` MUST be hex strings. Do NOT insert raw ASCII strings.**<br>
|
||||
|
||||
Replace `code` with the correct code for your game (`SDHD` for Chunithm JP, `SDEZ` for Maimai JP, etc.).<br>
|
||||
Set `versions` to be an array of game versions without the decimal (215 for 2.15, 150 for 1.50, etc.).<br>
|
||||
You must include the rom major version. Minor versions will automatically round down to use the major key.
|
||||
|
||||
- Maimai DX does NOT use iterations, you do not need to include it
|
||||
- Ongeki always uses 64 iterations (you must include it)
|
||||
- If using a specific Python script to rip Chunithm keys, focus on the `=>` entries.
|
||||
- `key` is 32 bytes long. `iv` is 16 bytes long. `salt` is 8 bytes long. (hex strings should be twice as long as they are in bytes)
|
||||
+16
-21
@@ -3,34 +3,35 @@
|
||||
## Chunithm (Chusan)
|
||||
|
||||
### Required patches
|
||||
* No encryption & TLS
|
||||
* None
|
||||
|
||||
<sub>※ No encryption & No TLS is required if you do not have encryption keys set for your game version (supported on public instance)</sub>
|
||||
|
||||
### Additional notes
|
||||
* Class/Dan and National Matching modes will work after playing the first game
|
||||
* Class/Dan, National Matching and LINKED VERSE modes will work after playing the first game
|
||||
(both when you first set up the game and when you update the game's rom or options).
|
||||
* National Matching requires [additional setup](chu3-national-matching.md).
|
||||
* For user box customization, use the AquaNet website.
|
||||
* National Matching is supported out of the box. Services provided by [Yukiotoko](https://gitea.tendokyu.moe/CharaLol/Yukiotoko)
|
||||
* For user box and LINKED VERSE customization, use the AquaNet website.
|
||||
* All LINKED VERSE gates are available by default and can be played sequentially. Original condition flags are disregarded due to implementation complexity (and low overall interest).
|
||||
* Many aspects of the game may not work in freeplay mode, this is not a server-side restriction.
|
||||
|
||||
## Maimai DX
|
||||
|
||||
### Required patches
|
||||
* No TLS
|
||||
* No certificate pinning
|
||||
* No URI obfuscation
|
||||
* No encryption
|
||||
|
||||
### Non-working features
|
||||
* KOP related
|
||||
* Tournament mode
|
||||
<sub>※ No encryption & No TLS is required if you do not have encryption keys set for your game version (supported on public instance)</sub>
|
||||
|
||||
### Additional notes
|
||||
* KOP related features and tournament mode do not work
|
||||
* All KALEID×SCOPE gates are intended to be open but may be broken
|
||||
|
||||
## O.N.G.E.K.I
|
||||
|
||||
### Required patches
|
||||
* No TLS
|
||||
* No certificate pinning
|
||||
* No URI obfuscation
|
||||
* No encryption
|
||||
|
||||
<sub>※ No encryption & No TLS is required if you do not have encryption keys set for your game version (supported on public instance)</sub>
|
||||
|
||||
### Non-working features
|
||||
* KOP related
|
||||
@@ -39,16 +40,10 @@
|
||||
## Card Maker
|
||||
|
||||
### Required patches
|
||||
* No TLS
|
||||
* No encryption
|
||||
|
||||
### Support status
|
||||
* Chunithm New: Yes, New (2.00)
|
||||
* Maimai DX: Yes, Universe (1.20)
|
||||
* O.N.G.E.K.I: No
|
||||
* No TLS & Encryption (Not implemented as it has three (four?) sets of keys across several games, not worth the effort)
|
||||
|
||||
### Additional notes
|
||||
* Only stated version above are supported.
|
||||
* Server does not consider gacha rarity and probability weight during card draw.
|
||||
* Server returns same hard-coded serial for each cards. This is intentional behavior.
|
||||
* Due to its high correlation with every game endpoints, this may cease to work after major game version up.
|
||||
* Not priority and may be out of date, broken or even removed in the future due to low overall usage (by player count on public instance)
|
||||
|
||||
@@ -41,10 +41,10 @@ docker compose up
|
||||
```
|
||||
|
||||
### Building
|
||||
You need to install JDK 21 on your system, then run `./gradlew clean build`. The jar file will be built into the `build/libs` folder.
|
||||
You need to install JDK 25 on your system, then run `./gradlew clean build`. The jar file will be built into the `build/libs` folder.
|
||||
|
||||
## Why drop SQLite support?
|
||||
|
||||
If you wonder why I dropped SQLite support, ask SQLite devs why they still haven't supported adding a single constraint to a table without all the hassle of creating a new one and migrating all data over and finally deleting the original.
|
||||
|
||||

|
||||

|
||||
|
||||
Vendored
BIN
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -2,4 +2,8 @@
|
||||
* This file was generated by the Gradle 'init' task.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
rootProject.name = "AquaDX"
|
||||
|
||||
@@ -32,6 +32,7 @@ import java.time.ZoneOffset.UTC
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.Lock
|
||||
import kotlin.math.floor
|
||||
import kotlin.reflect.KCallable
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KMutableProperty1
|
||||
@@ -111,8 +112,10 @@ catch (e: Exception) { 400 - e.message.toString() }
|
||||
fun BodyBuilder.headers(vararg pairs: Pair<String, String>) = headers(HttpHeaders().apply { pairs.forEach { (k, v) -> set(k, v) } })
|
||||
|
||||
// Email validation
|
||||
// Modified from:
|
||||
// https://www.baeldung.com/java-email-validation-regex
|
||||
val emailRegex = "^(?=.{1,64}@)[\\p{L}0-9_-]+(\\.[\\p{L}0-9_-]+)*@[^-][\\p{L}0-9-]+(\\.[\\p{L}0-9-]+)*(\\.[\\p{L}]{2,})$".toRegex()
|
||||
// To include more special characters
|
||||
val emailRegex = "^(?=.{1,64}@)[\\p{L}0-9_!#$%&*+/=?^`{}~-]+(\\.[\\p{L}0-9_!#$%&*+/=?^`{}~-]+)*@[^-][\\p{L}0-9-]+(\\.[\\p{L}0-9-]+)*(\\.[\\p{L}]{2,})$".toRegex()
|
||||
fun Str.isValidEmail(): Bool = emailRegex.matches(this)
|
||||
|
||||
// Global Tools
|
||||
@@ -197,6 +200,10 @@ val Any?.truthy get() = when (this) {
|
||||
}
|
||||
val Any?.str get() = toString()
|
||||
|
||||
fun truncateVersion(version: Number): Number {
|
||||
return (floor(version.toFloat() / 5.0) * 5).toInt();
|
||||
}
|
||||
|
||||
// Collections
|
||||
fun <T> ls(vararg args: T) = args.toList()
|
||||
inline fun <reified T> arr(vararg args: T) = arrayOf(*args)
|
||||
@@ -275,4 +282,4 @@ fun List<List<Any?>>.numCsv(vararg head: Str) = head.joinToString(",") + "\n" +
|
||||
joinToString("\n") { it.joinToString(",") }
|
||||
|
||||
// DI
|
||||
inline fun <reified T> ApplicationContext.lazy() = lazy { getBean(T::class.java) }
|
||||
inline fun <reified T : Any> ApplicationContext.lazy() = kotlin.lazy { getBean(T::class.java) }
|
||||
|
||||
+88
-31
@@ -1,45 +1,95 @@
|
||||
package ext
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.*
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNamingStrategy
|
||||
import tools.jackson.core.JsonParser
|
||||
import tools.jackson.core.JsonToken
|
||||
import tools.jackson.databind.DeserializationContext
|
||||
import tools.jackson.databind.DeserializationFeature
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import tools.jackson.databind.PropertyNamingStrategies
|
||||
import tools.jackson.databind.ValueDeserializer
|
||||
import tools.jackson.databind.ext.javatime.ser.LocalDateTimeSerializer
|
||||
import tools.jackson.databind.json.JsonMapper
|
||||
import tools.jackson.databind.module.SimpleModule
|
||||
import tools.jackson.module.kotlin.KotlinModule
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
// Jackson
|
||||
val ACCEPTABLE_FALSE = setOf("0", "false", "no", "off", "False", "None", "null")
|
||||
val ACCEPTABLE_TRUE = setOf("1", "true", "yes", "on", "True")
|
||||
val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, object : JsonDeserializer<Boolean>() {
|
||||
val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, object : ValueDeserializer<Boolean>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext) = when(parser.text) {
|
||||
in ACCEPTABLE_FALSE -> false
|
||||
in ACCEPTABLE_TRUE -> true
|
||||
else -> 400 - "Invalid boolean value ${parser.text}"
|
||||
}
|
||||
})
|
||||
val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer<LocalDateTime>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext) =
|
||||
// First try standard formats via asDateTime() method
|
||||
parser.text.takeIf { it.isNotEmpty() }?.run { asDateTime() ?: try {
|
||||
// Try maimai2 format (yyyy-MM-dd HH:mm:ss.0)
|
||||
LocalDateTime.parse(parser.text, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
|
||||
} catch (e: Exception) {
|
||||
400 - "Invalid date time value ${parser.text}"
|
||||
} }
|
||||
})
|
||||
val JACKSON = jacksonObjectMapper().apply {
|
||||
setSerializationInclusion(JsonInclude.Include.NON_NULL)
|
||||
setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL)
|
||||
findAndRegisterModules()
|
||||
registerModule(JSON_FUZZY_BOOLEAN)
|
||||
registerModule(JSON_DATETIME)
|
||||
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
propertyNamingStrategy = PropertyNamingStrategies.LOWER_CAMEL_CASE;
|
||||
val JSON_DATETIME = SimpleModule()
|
||||
.addSerializer(LocalDateTime::class.java, LocalDateTimeSerializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
|
||||
.addDeserializer(LocalDateTime::class.java, object : ValueDeserializer<LocalDateTime>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext): LocalDateTime? {
|
||||
parser.readLocalDateTimeArray(context)?.let { return it }
|
||||
|
||||
// First try standard formats via asDateTime() method
|
||||
return parser.text.takeIf { it.isNotEmpty() }?.run { asDateTime() ?: try {
|
||||
// Try maimai2 format (yyyy-MM-dd HH:mm:ss.0)
|
||||
LocalDateTime.parse(parser.text, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
|
||||
} catch (e: Exception) {
|
||||
400 - "Invalid date time value ${parser.text}"
|
||||
} }
|
||||
}
|
||||
})
|
||||
|
||||
fun JsonParser.readLocalDateTimeArray(context: DeserializationContext): LocalDateTime? {
|
||||
if (!isExpectedStartArrayToken) return null
|
||||
|
||||
val parts = mutableListOf<Int>()
|
||||
while (nextToken() != JsonToken.END_ARRAY) {
|
||||
if (currentToken() != JsonToken.VALUE_NUMBER_INT) {
|
||||
return context.reportInputMismatch(
|
||||
LocalDateTime::class.java,
|
||||
"Invalid legacy date-time array",
|
||||
)
|
||||
}
|
||||
parts += intValue
|
||||
}
|
||||
|
||||
if (parts.size !in 5..7) {
|
||||
return context.reportInputMismatch(
|
||||
LocalDateTime::class.java,
|
||||
"Invalid legacy date-time array; expected 5 to 7 integer components",
|
||||
)
|
||||
}
|
||||
|
||||
return try {
|
||||
LocalDateTime.of(
|
||||
parts[0], parts[1], parts[2], parts[3], parts[4],
|
||||
parts.getOrElse(5) { 0 }, parts.getOrElse(6) { 0 },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
context.reportInputMismatch(
|
||||
LocalDateTime::class.java,
|
||||
"Invalid legacy date-time array: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val JACKSON: JsonMapper = JsonMapper.builder()
|
||||
.addModule(KotlinModule.Builder().build())
|
||||
.findAndAddModules()
|
||||
.addModule(JSON_FUZZY_BOOLEAN)
|
||||
.addModule(JSON_DATETIME)
|
||||
.changeDefaultPropertyInclusion {
|
||||
JsonInclude.Value.construct(JsonInclude.Include.NON_NULL, JsonInclude.Include.NON_NULL)
|
||||
}
|
||||
.propertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE)
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.build()
|
||||
|
||||
inline fun <reified T> ObjectMapper.parse(str: Str) = readValue(str, T::class.java)
|
||||
inline fun <reified T> ObjectMapper.parse(map: Map<*, *>) = convertValue(map, T::class.java)
|
||||
// TODO: https://stackoverflow.com/q/78197784/7346633
|
||||
@@ -74,11 +124,18 @@ val JSON = Json {
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
// Bean for default jackson object mapper
|
||||
//@Configuration
|
||||
//class JacksonConfig {
|
||||
// @Bean
|
||||
// fun objectMapper(): ObjectMapper {
|
||||
// return JACKSON
|
||||
// }
|
||||
//}
|
||||
/**
|
||||
* Expose the shared Jackson 3 mapper as the application JsonMapper.
|
||||
*
|
||||
* Spring Boot 4 auto-configures a tools.jackson JsonMapper WITHOUT the Kotlin module
|
||||
* (Jackson2ObjectMapperBuilder's well-known-module registration no longer exists),
|
||||
* which silently renamed all `is*` boolean fields in @RestController responses
|
||||
* (e.g. isClear -> clear) and ignored Jackson 2 databind annotations.
|
||||
* Providing our own JsonMapper bean makes Boot's JacksonAutoConfiguration back off
|
||||
* (@ConditionalOnMissingBean), so the HTTP message converters use this mapper.
|
||||
*/
|
||||
@org.springframework.context.annotation.Configuration
|
||||
class JacksonConfig {
|
||||
@org.springframework.context.annotation.Bean
|
||||
fun jsonMapper(): JsonMapper = JACKSON
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ class BotController(
|
||||
val gamesDict = listOfNotNull(chu3, mai2).map {
|
||||
// Find the keychip owner
|
||||
val keychip = it.lastClientId
|
||||
val keychipOwner = keychip?.let { us.userRepo.findByKeychip(it) }
|
||||
val keychipOwner = keychip?.let { k -> us.userKeychipRepo.findByKeychipId(k)?.user }
|
||||
|
||||
mapOf(
|
||||
"userData" to it,
|
||||
|
||||
@@ -68,7 +68,7 @@ class CardController(
|
||||
*/
|
||||
@API("/link")
|
||||
@Doc("Bind a card to the user. This action will migrate selected data from the card to the user's ghost card.", "Success message")
|
||||
suspend fun link(@RP token: Str, @RP cardId: Str, @RP migrate: Str) = jwt.auth(token) { u ->
|
||||
suspend fun link(@RP token: Str, @RP cardId: Str, @RP migrate: Str): Any = jwt.auth(token) { u ->
|
||||
// Check if the user's card limit is reached
|
||||
if (u.cards.size >= props.linkCardLimit) 400 - "Card limit reached"
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ class Fedy(
|
||||
forEach { (k, v) -> v?.let { GAME_OPTIONS_FIELDS[k]?.set(options, it) } }
|
||||
}
|
||||
us.userRepo.save(ru)
|
||||
if (fields.containsKey("pwHash") ?: false) { us.clearAllSessions(ru) }
|
||||
if (fields.containsKey("pwHash")) { us.clearAllSessions(ru) }
|
||||
UserUpdateRes(user = ru.fedyBasicInfo())
|
||||
} caught { UserUpdateRes(error = it) }
|
||||
}
|
||||
|
||||
@@ -37,8 +37,10 @@ class SettingsApi(
|
||||
@Doc("Set a field in the game options")
|
||||
fun setField(@RP token: String, @RP key: String, @RP value: String) = us.jwt.auth(token) { u ->
|
||||
val field = fieldMap[key] ?: (400 - "Invalid field $key")
|
||||
val options = u.gameOptions ?: AquaGameOptions().also {
|
||||
userRepo.save(u.apply { gameOptions = it })
|
||||
var options = u.gameOptions
|
||||
if (options == null) {
|
||||
options = goRepo.save(AquaGameOptions())
|
||||
userRepo.save(u.apply { gameOptions = options })
|
||||
}
|
||||
// Check field type
|
||||
field.setCast(options, value)
|
||||
|
||||
@@ -2,20 +2,39 @@ package icu.samnyan.aqua.net
|
||||
|
||||
import ext.*
|
||||
import icu.samnyan.aqua.net.components.*
|
||||
import icu.samnyan.aqua.net.db.AquaNetUser
|
||||
import icu.samnyan.aqua.net.db.AquaNetUserRepo
|
||||
import icu.samnyan.aqua.net.db.AquaUserServices
|
||||
import icu.samnyan.aqua.net.db.EmailConfirmationRepo
|
||||
import icu.samnyan.aqua.net.db.ResetPasswordRepo
|
||||
import icu.samnyan.aqua.net.utils.AquaNetProps
|
||||
import icu.samnyan.aqua.net.utils.PathProps
|
||||
import icu.samnyan.aqua.net.utils.SUCCESS
|
||||
import icu.samnyan.aqua.sega.allnet.UserKeychip
|
||||
import icu.samnyan.aqua.sega.allnet.UserKeychipRepo
|
||||
import icu.samnyan.aqua.sega.chusan.model.Chu3Repos
|
||||
import icu.samnyan.aqua.sega.diva.DivaRepos
|
||||
import icu.samnyan.aqua.sega.diva.model.db.userdata.PlayerProfile
|
||||
import icu.samnyan.aqua.sega.general.dao.CardRepository
|
||||
import icu.samnyan.aqua.sega.general.model.Card
|
||||
import icu.samnyan.aqua.sega.general.model.CardStatus
|
||||
import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos
|
||||
import icu.samnyan.aqua.sega.ongeki.OngekiUserRepos
|
||||
import icu.samnyan.aqua.sega.wacca.model.db.WaccaRepos
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.transaction.Transactional
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.time.Instant
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.deleteIfExists
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.name
|
||||
import kotlin.io.path.writeBytes
|
||||
|
||||
@RestController
|
||||
@@ -33,7 +52,10 @@ class UserRegistrar(
|
||||
val cardRepo: CardRepository,
|
||||
val validator: AquaUserServices,
|
||||
val emailProps: EmailProperties,
|
||||
final val paths: PathProps
|
||||
val userKeychipRepo: UserKeychipRepo,
|
||||
val aquaNetProps: AquaNetProps,
|
||||
final val paths: PathProps,
|
||||
val accountDeletion: AccountDeletionService,
|
||||
) {
|
||||
val portraitPath = paths.aquaNetPortrait.path()
|
||||
|
||||
@@ -195,9 +217,6 @@ class UserRegistrar(
|
||||
// Check if the token is valid
|
||||
if (confirmation == null) 400 - "Invalid token"
|
||||
|
||||
// Check if the token is expired
|
||||
if (confirmation.createdAt.plusSeconds(60 * 60 * 24).isBefore(Instant.now())) 400 - "Token expired"
|
||||
|
||||
// Check if the email is already confirmed
|
||||
val u = confirmation.aquaNetUser
|
||||
if (u.emailConfirmed) 400 - "Email already confirmed"
|
||||
@@ -235,20 +254,65 @@ class UserRegistrar(
|
||||
SUCCESS
|
||||
}
|
||||
|
||||
val keychipPattern = Regex("^([A-Z\\d]{4}-[A-Z\\d]{11}|[A-Z\\d]{15})$")
|
||||
val keychipRange = 1e9.toULong()..1e10.toULong() - 1UL
|
||||
|
||||
private fun generateKeychipId(): String {
|
||||
// 1337 is retained for backwards compatibility, it's no longer required for cabinet keychips
|
||||
var keychip = "A" + keychipRange.random() + "1337"
|
||||
while ( userKeychipRepo.existsByKeychipId(keychip) )
|
||||
keychip = "A" + keychipRange.random() + "1337"
|
||||
return keychip
|
||||
}
|
||||
|
||||
private fun ensureCanModifyKeychips(u: AquaNetUser) {
|
||||
if (!u.canModifyKeychips) 403 - "You don't have permission to modify keychips"
|
||||
}
|
||||
private fun validateCustomKeychip(keychipId: Str): Str {
|
||||
val raw = keychipId.trim().uppercase()
|
||||
|
||||
if (!keychipPattern.matches(raw))
|
||||
400 - "Invalid keychip format. Expected 11 or 15 characters (with optional dash)"
|
||||
|
||||
return raw.replace("-", "")
|
||||
}
|
||||
|
||||
@API("/keychip")
|
||||
@Doc("Get a Keychip ID so that the user can connect to the server.", "Success message")
|
||||
suspend fun setupConnection(@RP token: Str) = jwt.auth(token) { u ->
|
||||
u.keychip?.let { return mapOf("keychip" to it) }
|
||||
log.info("Net: /user/keychip setup: ${u.auId} for ${u.username}")
|
||||
@Doc("List all keychip IDs associated with the current user's account.", "List of keychip IDs")
|
||||
suspend fun listKeychips(@RP token: Str) = jwt.auth(token) { u ->
|
||||
val keychips = async { userKeychipRepo.findAllByUserAuId(u.auId) }
|
||||
if (keychips.isEmpty() && !u.canModifyKeychips) {
|
||||
val keychip = UserKeychip(0, u, generateKeychipId())
|
||||
userKeychipRepo.save(keychip)
|
||||
log.info("Net: Assigned keychip ${keychip.keychipId} to ${u.auId}")
|
||||
}
|
||||
mapOf("keychips" to keychips.map { it.keychipId })
|
||||
}
|
||||
|
||||
// Generate a keychip id with 10 digits (e.g. A1234567890)
|
||||
var new = "A" + keychipRange.random()
|
||||
while (async { userRepo.findByKeychip(new) != null }) new = "A" + keychipRange.random()
|
||||
async { userRepo.save(u.apply { keychip = new }) }
|
||||
@API("/keychip/add")
|
||||
@Doc("Add a custom keychip ID for the user.", "The newly added keychip ID")
|
||||
suspend fun addKeychip(@RP token: Str, @RP keychipId: Str) = jwt.auth(token) { u ->
|
||||
ensureCanModifyKeychips(u)
|
||||
log.info("Net: /user/keychip/add: ${u.auId} for ${u.username}")
|
||||
val validated = validateCustomKeychip(keychipId)
|
||||
|
||||
mapOf("keychip" to new)
|
||||
if (async { userKeychipRepo.existsByKeychipId(validated) })
|
||||
400 - "Keychip already exists"
|
||||
|
||||
if (userKeychipRepo.findAllByUserAuId(u.auId).size >= aquaNetProps.keychipLimit)
|
||||
400 - "Exceeds maximum keychip count"
|
||||
|
||||
async { userKeychipRepo.save(UserKeychip(user = u, keychipId = validated)) }
|
||||
mapOf("keychipId" to validated)
|
||||
}
|
||||
|
||||
@API("/keychip/delete")
|
||||
@Doc("Remove a keychip ID from the user's account.", "Success message")
|
||||
suspend fun deleteKeychip(@RP token: Str, @RP keychipId: Str) = jwt.auth(token) { u ->
|
||||
ensureCanModifyKeychips(u)
|
||||
val deleted = async { userKeychipRepo.deleteByKeychipIdAndUserAuId(keychipId, u.auId) }
|
||||
if (deleted == 0L) 404 - "Keychip not found"
|
||||
SUCCESS
|
||||
}
|
||||
|
||||
@API("/upload-pfp", consumes = ["multipart/form-data"])
|
||||
@@ -285,5 +349,96 @@ class UserRegistrar(
|
||||
}
|
||||
|
||||
SUCCESS
|
||||
}
|
||||
|
||||
@API("/delete-account")
|
||||
@Doc("Permanently delete the current user's account and all associated game data.", "Success message")
|
||||
fun deleteAccount(@RP token: Str) = jwt.auth(token) { user ->
|
||||
val cleanup = accountDeletion.deleteDatabaseData(user.auId)
|
||||
accountDeletion.deleteUploadedFiles(cleanup)
|
||||
SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
data class AccountFileCleanup(
|
||||
val profilePicture: String?,
|
||||
val gameUserId: Long,
|
||||
val divaScreenshots: List<String>,
|
||||
)
|
||||
|
||||
@Service
|
||||
class AccountDeletionService(
|
||||
val userRepo: AquaNetUserRepo,
|
||||
val cardRepo: CardRepository,
|
||||
val mai2: Mai2Repos,
|
||||
val chu3: Chu3Repos,
|
||||
val ongeki: OngekiUserRepos,
|
||||
val wacca: WaccaRepos,
|
||||
val diva: DivaRepos,
|
||||
val paths: PathProps,
|
||||
) {
|
||||
companion object {
|
||||
val log = logger()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteDatabaseData(auId: Long): AccountFileCleanup {
|
||||
val user = userRepo.findByAuId(auId) ?: (404 - "User not found")
|
||||
val ghostCard = user.ghostCard
|
||||
val divaScreenshots = deleteDiva(ghostCard)
|
||||
|
||||
mai2.userData.findByCard(ghostCard)?.let { mai2.userData.delete(it) }
|
||||
chu3.userData.findByCard(ghostCard)?.let { chu3.userData.delete(it) }
|
||||
ongeki.data.findByCard(ghostCard)?.let { ongeki.data.delete(it) }
|
||||
wacca.user.findByCard(ghostCard)?.let { wacca.user.delete(it) }
|
||||
chu3.userLoginBonus.deleteAll(chu3.userLoginBonus.findByUser(ghostCard.extId.toInt()))
|
||||
|
||||
// Keep Hibernate's managed state consistent with the database-level ON DELETE SET NULL.
|
||||
// Hibernate 7 rejects the flush if a managed card still references the deleted user.
|
||||
val linkedCards = cardRepo.findAllByAquaUserAuId(auId)
|
||||
linkedCards.forEach { it.aquaUser = null }
|
||||
cardRepo.saveAllAndFlush(linkedCards)
|
||||
|
||||
userRepo.delete(user)
|
||||
userRepo.flush()
|
||||
cardRepo.delete(ghostCard)
|
||||
cardRepo.flush()
|
||||
|
||||
log.info("Deleted account and game data for user $auId")
|
||||
return AccountFileCleanup(user.profilePicture, ghostCard.extId, divaScreenshots)
|
||||
}
|
||||
|
||||
private fun deleteDiva(card: Card): List<String> {
|
||||
val profile: PlayerProfile = diva.profile.findByPdId(card.extId).orElse(null) ?: return emptyList()
|
||||
val screenshots = diva.screenShot.findByPdId(profile)
|
||||
|
||||
diva.profile.delete(profile)
|
||||
|
||||
return screenshots.map { it.fileName }
|
||||
}
|
||||
|
||||
fun deleteUploadedFiles(cleanup: AccountFileCleanup) {
|
||||
try {
|
||||
cleanup.profilePicture
|
||||
?.takeIf { it.isNotBlank() && Path(it).name == it }
|
||||
?.let { (Path(paths.aquaNetPortrait) / it).deleteIfExists() }
|
||||
|
||||
deleteFilesStartingWith(Path(paths.mai2Portrait), "${cleanup.gameUserId}-")
|
||||
deleteFilesStartingWith(Path(paths.mai2Plays), "${cleanup.gameUserId}-")
|
||||
deleteFilesStartingWith(Path("data/tmp"), "${cleanup.gameUserId}-")
|
||||
cleanup.divaScreenshots
|
||||
.filter { Path(it).name == it }
|
||||
.forEach { (Path("data") / it).deleteIfExists() }
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to delete one or more uploaded files for deleted user ${cleanup.gameUserId}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteFilesStartingWith(directory: Path, prefix: String) {
|
||||
if (!directory.exists()) return
|
||||
Files.list(directory).use { files ->
|
||||
files.filter { it.fileName.toString().startsWith(prefix) }
|
||||
.forEach { it.deleteIfExists() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package icu.samnyan.aqua.net.components
|
||||
|
||||
import jakarta.mail.Message
|
||||
import ext.Bool
|
||||
import ext.Str
|
||||
import ext.logger
|
||||
import icu.samnyan.aqua.net.db.*
|
||||
import org.simplejavamail.api.email.Recipient
|
||||
import org.simplejavamail.api.mailer.Mailer
|
||||
import org.simplejavamail.email.EmailBuilder
|
||||
import org.simplejavamail.springsupport.SimpleJavaMailSpringSupport
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.boot.context.event.ApplicationStartedEvent
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.context.annotation.Configuration
|
||||
@@ -25,15 +29,19 @@ class EmailProperties {
|
||||
var webHost: Str = "aquadx.net"
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "aqua-net.email", name = ["enable"], havingValue = "true")
|
||||
@Import(SimpleJavaMailSpringSupport::class)
|
||||
class EmailMailerConfiguration
|
||||
|
||||
/**
|
||||
* Email service. All email related operations should be placed here.
|
||||
*
|
||||
* Library Documentation: https://www.simplejavamail.org/
|
||||
*/
|
||||
@Service
|
||||
@Import(SimpleJavaMailSpringSupport::class)
|
||||
class EmailService(
|
||||
val mailer: Mailer,
|
||||
val mailerProvider: ObjectProvider<Mailer>,
|
||||
val props: EmailProperties,
|
||||
val confirmationRepo: EmailConfirmationRepo,
|
||||
val resetPasswordRepo: ResetPasswordRepo,
|
||||
@@ -44,13 +52,16 @@ class EmailService(
|
||||
val resetTemplate: Str = this::class.java.getResource("/email/reset.html")?.readText()
|
||||
?: throw Exception("Password Reset Template Not Found")
|
||||
|
||||
private fun mailer() = mailerProvider.getIfAvailable()
|
||||
?: throw IllegalStateException("Email is enabled, but Simple Java Mail is not configured")
|
||||
|
||||
@Async
|
||||
@EventListener(ApplicationStartedEvent::class)
|
||||
fun test() {
|
||||
if (!props.enable) return
|
||||
|
||||
try {
|
||||
mailer.testConnection()
|
||||
mailer().testConnection()
|
||||
log.info("Email Service Connected")
|
||||
} catch (e: Exception) {
|
||||
log.error("Email Service Connection Failed", e)
|
||||
@@ -71,9 +82,9 @@ class EmailService(
|
||||
|
||||
// Send email
|
||||
log.info("Sending verification email to ${user.email}")
|
||||
mailer.sendMail(EmailBuilder.startingBlank()
|
||||
mailer().sendMail(EmailBuilder.startingBlank()
|
||||
.from(props.senderName, props.senderAddr)
|
||||
.to(user.computedName, user.email)
|
||||
.withRecipients(Recipient(user.computedName, user.email, Message.RecipientType.TO, null))
|
||||
.withSubject("Verify Your Email Address for AquaNet")
|
||||
.withHTMLText(confirmTemplate
|
||||
.replace("{{name}}", user.computedName)
|
||||
@@ -94,9 +105,9 @@ class EmailService(
|
||||
|
||||
// Send email
|
||||
log.info("Sending reset password email to ${user.email}")
|
||||
mailer.sendMail(EmailBuilder.startingBlank()
|
||||
mailer().sendMail(EmailBuilder.startingBlank()
|
||||
.from(props.senderName, props.senderAddr)
|
||||
.to(user.computedName, user.email)
|
||||
.withRecipients(Recipient(user.computedName, user.email, Message.RecipientType.TO, null))
|
||||
.withSubject("Reset Your Password for AquaNet")
|
||||
.withHTMLText(resetTemplate
|
||||
.replace("{{name}}", user.computedName)
|
||||
@@ -108,9 +119,9 @@ class EmailService(
|
||||
if (!props.enable) return
|
||||
|
||||
log.info("Sending test email to $addr")
|
||||
mailer.sendMail(EmailBuilder.startingBlank()
|
||||
mailer().sendMail(EmailBuilder.startingBlank()
|
||||
.from(props.senderName, props.senderAddr)
|
||||
.to(name, addr)
|
||||
.withRecipients(Recipient(name, addr, Message.RecipientType.TO, null))
|
||||
.withSubject("Test Email")
|
||||
.withPlainText("This is a test email to check if AquaNet Email Works").buildEmail()).thenRun {
|
||||
log.info("Test email sent to $addr")
|
||||
|
||||
@@ -77,7 +77,7 @@ class GeoIP(
|
||||
{
|
||||
return try {
|
||||
val ipa = InetAddress.getByName(ip)
|
||||
geoLite.country(ipa)?.country?.isoCode ?: ""
|
||||
geoLite.country(ipa)?.country()?.isoCode() ?: ""
|
||||
}
|
||||
catch (e: AddressNotFoundException) { "" }
|
||||
catch (e: Exception) {
|
||||
@@ -85,4 +85,4 @@ class GeoIP(
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package icu.samnyan.aqua.net.db
|
||||
|
||||
import jakarta.persistence.*
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.hibernate.annotations.OnDelete
|
||||
import org.hibernate.annotations.OnDeleteAction
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.io.Serializable
|
||||
import java.time.Instant
|
||||
@@ -20,9 +22,10 @@ class EmailConfirmation(
|
||||
@Column(nullable = false)
|
||||
var createdAt: Instant = Instant.now(),
|
||||
|
||||
// Linking to the AquaNetUser
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "auId", referencedColumnName = "auId")
|
||||
// Linking to the AquaNetUser
|
||||
@ManyToOne
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
@JoinColumn(name = "auId", referencedColumnName = "auId")
|
||||
var aquaNetUser: AquaNetUser = AquaNetUser()
|
||||
) : Serializable
|
||||
|
||||
@@ -30,4 +33,4 @@ class EmailConfirmation(
|
||||
interface EmailConfirmationRepo : JpaRepository<EmailConfirmation, Long> {
|
||||
fun findByToken(token: String): EmailConfirmation?
|
||||
fun findByAquaNetUserAuId(auId: Long): List<EmailConfirmation>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package icu.samnyan.aqua.net.db
|
||||
|
||||
import jakarta.persistence.*
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.hibernate.annotations.OnDelete
|
||||
import org.hibernate.annotations.OnDeleteAction
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.io.Serializable
|
||||
import java.time.Instant
|
||||
@@ -22,6 +24,7 @@ class ResetPassword(
|
||||
|
||||
// Linking to the AquaNetUser
|
||||
@ManyToOne
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
@JoinColumn(name = "auId", referencedColumnName = "auId")
|
||||
var aquaNetUser: AquaNetUser = AquaNetUser()
|
||||
) : Serializable
|
||||
@@ -30,4 +33,4 @@ class ResetPassword(
|
||||
interface ResetPasswordRepo : JpaRepository<ResetPassword, Long> {
|
||||
fun findByToken(token: String): ResetPassword?
|
||||
fun findByAquaNetUserAuId(auId: Long): List<ResetPassword>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ class AquaGameOptions(
|
||||
@SettingField("chu3")
|
||||
var chusanInfinitePenguins: Boolean = false,
|
||||
|
||||
@SettingField("chu3") @Column(name = "chusan_unlock_2967_2999_ultima")
|
||||
var chusanUnlock29672999Ultima: Boolean = false,
|
||||
|
||||
@SettingField("chu3-matching")
|
||||
var chusanMatchingServer: String = "",
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package icu.samnyan.aqua.net.db
|
||||
|
||||
import jakarta.persistence.*
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.hibernate.annotations.OnDelete
|
||||
import org.hibernate.annotations.OnDeleteAction
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.io.Serializable
|
||||
import java.time.Instant
|
||||
@@ -22,6 +24,7 @@ class SessionToken(
|
||||
|
||||
// Linking to the AquaNetUser
|
||||
@ManyToOne
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
@JoinColumn(name = "auId", referencedColumnName = "auId")
|
||||
var aquaNetUser: AquaNetUser = AquaNetUser()
|
||||
) : Serializable
|
||||
|
||||
@@ -6,8 +6,9 @@ import icu.samnyan.aqua.net.UserRegistrar.Companion.cardExtIdEnd
|
||||
import icu.samnyan.aqua.net.UserRegistrar.Companion.cardExtIdStart
|
||||
import icu.samnyan.aqua.net.components.JWT
|
||||
import icu.samnyan.aqua.sega.allnet.AllNetProps
|
||||
import icu.samnyan.aqua.sega.allnet.KeyChipRepo
|
||||
import icu.samnyan.aqua.sega.allnet.KeychipSession
|
||||
import icu.samnyan.aqua.sega.allnet.UserKeychip
|
||||
import icu.samnyan.aqua.sega.allnet.UserKeychipRepo
|
||||
import icu.samnyan.aqua.sega.general.GameMusicPopularity
|
||||
import icu.samnyan.aqua.sega.general.dao.CardRepository
|
||||
import icu.samnyan.aqua.sega.general.model.Card
|
||||
@@ -62,27 +63,32 @@ class AquaNetUser(
|
||||
var profileBio: String? = "",
|
||||
var profilePicture: String? = "",
|
||||
var optOutOfLeaderboard: Boolean = false,
|
||||
var hideCountry: Boolean = false,
|
||||
var displayCandidates: Boolean = false,
|
||||
|
||||
// Email confirmation
|
||||
var emailConfirmed: Boolean = false,
|
||||
|
||||
@OneToOne(cascade = [CascadeType.ALL])
|
||||
@OneToOne(cascade = [CascadeType.PERSIST, CascadeType.MERGE])
|
||||
@JoinColumn(name = "ghostCard", unique = true, nullable = false)
|
||||
var ghostCard: Card = Card(),
|
||||
|
||||
// One user can have multiple cards
|
||||
@OneToMany(mappedBy = "aquaUser", cascade = [CascadeType.ALL])
|
||||
@OneToMany(mappedBy = "aquaUser", cascade = [CascadeType.PERSIST, CascadeType.MERGE])
|
||||
var cards: MutableList<Card> = mutableListOf(),
|
||||
|
||||
// Each user can have one keychip (if the user owns a cabinet)
|
||||
// Each user can have multiple keychips (if the user owns cabinets)
|
||||
@JsonIgnore
|
||||
@Column(nullable = true, length = 32, unique = true)
|
||||
var keychip: Str? = null,
|
||||
@OneToMany(mappedBy = "user", cascade = [CascadeType.PERSIST, CascadeType.MERGE])
|
||||
var keychips: MutableList<UserKeychip> = mutableListOf(),
|
||||
|
||||
// Each user's keychip can have multiple sessions
|
||||
@JsonIgnore
|
||||
@OneToMany(mappedBy = "user", cascade = [CascadeType.ALL])
|
||||
var keychipSessions: MutableList<KeychipSession> = mutableListOf(),
|
||||
@OneToMany(mappedBy = "user", cascade = [CascadeType.PERSIST, CascadeType.MERGE])
|
||||
var keychipSessions: MutableList<KeychipSession> = mutableListOf(),
|
||||
|
||||
@Column(nullable = false)
|
||||
var canModifyKeychips: Boolean = false,
|
||||
|
||||
@OneToOne(cascade = [CascadeType.ALL])
|
||||
@JoinColumn(name = "gameOptions", unique = true, nullable = true)
|
||||
@@ -93,7 +99,7 @@ class AquaNetUser(
|
||||
val publicFields get() = mapOf(
|
||||
"username" to username,
|
||||
"displayName" to displayName,
|
||||
"country" to country,
|
||||
"country" to if (hideCountry) "" else country,
|
||||
"regTime" to regTime,
|
||||
"profileLocation" to profileLocation,
|
||||
"profileBio" to profileBio,
|
||||
@@ -105,7 +111,6 @@ interface AquaNetUserRepo : JpaRepository<AquaNetUser, Long> {
|
||||
fun findByAuId(auId: Long): AquaNetUser?
|
||||
fun findByEmailIgnoreCase(email: String): AquaNetUser?
|
||||
fun findByUsernameIgnoreCase(username: String): AquaNetUser?
|
||||
fun findByKeychip(keychip: String): AquaNetUser?
|
||||
fun findByGhostCardExtId(extId: Long): AquaNetUser?
|
||||
}
|
||||
|
||||
@@ -124,7 +129,7 @@ class AquaUserServices(
|
||||
val userRepo: AquaNetUserRepo,
|
||||
val cardRepo: CardRepository,
|
||||
val hasher: PasswordEncoder,
|
||||
val keyChipRepo: KeyChipRepo,
|
||||
val userKeychipRepo: UserKeychipRepo,
|
||||
val allNetProps: AllNetProps,
|
||||
val jwt: JWT,
|
||||
val em: EntityManager,
|
||||
@@ -144,7 +149,7 @@ class AquaUserServices(
|
||||
|
||||
fun create(username: Str, email: Str, password: Str, country: Str, emailConfirmed: Boolean = false): AquaNetUser {
|
||||
// Create user
|
||||
val u = AquaNetUser(
|
||||
val user = AquaNetUser(
|
||||
username = checkUsername(username),
|
||||
email = validateEmail(email),
|
||||
pwHash = checkPwHash(password),
|
||||
@@ -158,16 +163,16 @@ class AquaUserServices(
|
||||
luid = extId.toString()
|
||||
registerTime = LocalDateTime.now()
|
||||
accessTime = registerTime
|
||||
aquaUser = u
|
||||
aquaUser = user
|
||||
isGhost = true
|
||||
}
|
||||
u.ghostCard = card
|
||||
user.ghostCard = card
|
||||
|
||||
// Save the user
|
||||
userRepo.save(u)
|
||||
userRepo.save(user)
|
||||
cardRepo.save(card)
|
||||
|
||||
return u
|
||||
return user
|
||||
}
|
||||
|
||||
fun update(user: AquaNetUser, key: Str, value: Str) {
|
||||
@@ -192,7 +197,7 @@ class AquaUserServices(
|
||||
fun validKeychip(keychipId: Str): Bool {
|
||||
if (!allNetProps.checkKeychip) return true
|
||||
if (keychipId.isBlank()) return false
|
||||
if (userRepo.findByKeychip(keychipId) != null || keyChipRepo.existsByKeychipId(keychipId)) return true
|
||||
if (userKeychipRepo.findByKeychipIdStartingWith(keychipId).truthy) return true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -216,21 +221,23 @@ class AquaUserServices(
|
||||
400 - "User with username `$this` already exists"
|
||||
}
|
||||
|
||||
fun validateEmail(email: Str) = email.apply {
|
||||
// Check if email is valid
|
||||
if (!isValidEmail()) 400 - "Invalid email"
|
||||
|
||||
// Check if user with the same email exists
|
||||
if (userRepo.findByEmailIgnoreCase(email) != null)
|
||||
400 - "User with email `$email` already exists"
|
||||
}
|
||||
|
||||
fun checkPwHash(password: Str) = password.run {
|
||||
// Validate password
|
||||
if (length < 8) 400 - "Password must be at least 8 characters"
|
||||
|
||||
hasher.encode(this)
|
||||
}
|
||||
fun validateEmail(email: Str): Str {
|
||||
// Check if email is valid
|
||||
if (!email.isValidEmail()) 400 - "Invalid email"
|
||||
|
||||
// Check if user with the same email exists
|
||||
if (userRepo.findByEmailIgnoreCase(email) != null)
|
||||
400 - "User with email `$email` already exists"
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
fun checkPwHash(password: Str): Str {
|
||||
// Validate password
|
||||
if (password.length < 8) 400 - "Password must be at least 8 characters"
|
||||
|
||||
return hasher.encode(password) ?: (500 - "Failed to hash password")
|
||||
}
|
||||
|
||||
fun checkDisplayName(displayName: Str) = displayName.apply {
|
||||
// Check if display name is valid
|
||||
@@ -248,4 +255,6 @@ class AquaUserServices(
|
||||
}
|
||||
|
||||
fun checkOptOutOfLeaderboard(optOutOfLeaderboard: Str) = optOutOfLeaderboard.toBoolean()
|
||||
fun checkHideCountry(hideCountry: Str) = hideCountry.toBoolean()
|
||||
fun checkDisplayCandidates(displayCandidates: Str) = displayCandidates.toBoolean()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import ext.*
|
||||
import icu.samnyan.aqua.net.BotProps
|
||||
import icu.samnyan.aqua.net.db.AquaUserServices
|
||||
import icu.samnyan.aqua.net.utils.SUCCESS
|
||||
import icu.samnyan.aqua.sega.allnet.UserKeychipRepo
|
||||
import icu.samnyan.aqua.sega.general.model.Card
|
||||
import icu.samnyan.aqua.sega.general.service.CardService
|
||||
import jakarta.annotation.PostConstruct
|
||||
@@ -32,6 +33,7 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
|
||||
open val gettableFields: Set<String> = setOf()
|
||||
|
||||
@Autowired lateinit var cardService: CardService
|
||||
@Autowired lateinit var userKeychipRepo: UserKeychipRepo
|
||||
|
||||
@API("trend")
|
||||
abstract suspend fun trend(@RP username: String): List<TrendOut>
|
||||
@@ -44,10 +46,10 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
|
||||
}
|
||||
|
||||
// List<Pair<should_hide, player>>>
|
||||
private var rankingCache: List<Pair<Bool, GenericRankingPlayer>> = emptyList()
|
||||
private var rankingCache: List<GenericRankingPlayer> = emptyList()
|
||||
private var rankingCacheLock = ReentrantLock()
|
||||
// Sorted index List<Rating> = Rank
|
||||
private var rankingSortedIndex: List<Int> = emptyList()
|
||||
private var rankingLookupCache: Map<Long, GenericRankingPlayer> = emptyMap()
|
||||
private val pageSize = 100
|
||||
|
||||
@API("ranking")
|
||||
@@ -55,34 +57,30 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
|
||||
val time = millis()
|
||||
|
||||
// Check cache validity
|
||||
if (rankingCache.isEmpty()) (500 - "Ranking is computing... please wait")
|
||||
|
||||
val reqUser = token?.let { us.jwt.auth(it) }?.let { u ->
|
||||
// Optimization: If the user is not banned, we don't need to process user information
|
||||
if (!u.ghostCard.rankingBanned && !u.cards.any { it.rankingBanned } && u.ghostCard.status.isNormal) null
|
||||
else u
|
||||
}
|
||||
|
||||
// Read from cache if we just computed it less than duration ago
|
||||
// Shadow-ban: Do not show banned cards in the ranking except for the user who owns the card
|
||||
val v = rankingCache.filter { !it.l || it.r.username == reqUser?.username }
|
||||
.mapIndexed { i, it -> it.r.apply { rank = i + 1 } }
|
||||
.also { logger.info("Ranking returned in ${millis() - time}ms") }
|
||||
if (rankingCache.isEmpty()) (500 - "Rank is empty or is currently computing.")
|
||||
|
||||
return page?.let {
|
||||
if (it < 0) (400 - "Invalid page number")
|
||||
v.drop(it * pageSize).take(pageSize)
|
||||
} ?: v
|
||||
rankingCache.drop(it * pageSize).take(pageSize)
|
||||
} ?: rankingCache
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
fun rakingCacheInit() = thread { rankingCacheRun() }
|
||||
fun rankingCacheInit() = thread { rankingCacheRun() }
|
||||
|
||||
// Every 20 minutes
|
||||
@Scheduled(fixedRate = 20, timeUnit = TimeUnit.MINUTES)
|
||||
@Scheduled(fixedRate = 20, initialDelay = 20, timeUnit = TimeUnit.MINUTES)
|
||||
fun rankingCacheRun() = rankingCacheLock.maybeLock { rankingCacheCompute() }
|
||||
|
||||
private val tableName = when (name) { "mai2" -> "maimai2"; "chu3" -> "chusan"; else -> name }
|
||||
private val userDataTable = when (name) {
|
||||
"mai2" -> "maimai2_user_detail"
|
||||
"chu3" -> "chusan_user_data"
|
||||
"ongeki" -> "ongeki_user_data"
|
||||
else -> "wacca_user"
|
||||
}
|
||||
private val userCardColumn = if (name == "chu3") "card_id" else "aime_card_id"
|
||||
|
||||
fun rankingCacheCompute() {
|
||||
val time = millis()
|
||||
rankingCache = us.em.createNativeQuery(
|
||||
@@ -90,23 +88,26 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
|
||||
SELECT
|
||||
c.id,
|
||||
u.user_name,
|
||||
u.player_rating,
|
||||
${if (name == "ongeki") "u.new_player_rating" else "u.player_rating"} AS rating,
|
||||
u.last_play_date,
|
||||
AVG(p.achievement) / 10000.0 AS acc,
|
||||
SUM(p.is_full_combo) AS fc,
|
||||
SUM(p.is_all_perfect) AS ap,
|
||||
c.ranking_banned or a.opt_out_of_leaderboard or c.status = 12 AS hide,
|
||||
r.achievement_sum / NULLIF(r.play_count, 0) / 10000.0 AS acc,
|
||||
r.full_combo_count AS fc,
|
||||
r.all_perfect_count AS ap,
|
||||
c.ranking_banned or COALESCE(a.opt_out_of_leaderboard, 0) or c.status = 12 AS hide,
|
||||
a.username
|
||||
FROM ${tableName}_user_playlog_view p
|
||||
JOIN ${tableName}_user_data_view u ON p.user_id = u.id
|
||||
JOIN sega_card c ON u.aime_card_id = c.id
|
||||
FROM ${tableName}_user_ranking_cache r
|
||||
JOIN $userDataTable u ON r.user_id = u.id
|
||||
JOIN sega_card c ON u.$userCardColumn = c.id
|
||||
LEFT JOIN aqua_net_user a ON c.net_user_id = a.au_id
|
||||
GROUP BY p.user_id, u.player_rating
|
||||
ORDER BY u.player_rating DESC;
|
||||
WHERE r.play_count > 0
|
||||
AND NOT (c.ranking_banned or COALESCE(a.opt_out_of_leaderboard, 0) or c.status = 12)
|
||||
${if (name == "ongeki") "AND u.new_player_rating > 0" else "" /* Hide users on Ongeki 1.45 and below */}
|
||||
ORDER BY rating DESC;
|
||||
"""
|
||||
).exec.mapIndexed { i, it ->
|
||||
it[7].truthy to GenericRankingPlayer(
|
||||
).setHint("jakarta.persistence.query.timeout", 60_000).exec.mapIndexed { i, it ->
|
||||
GenericRankingPlayer(
|
||||
rank = i + 1,
|
||||
id = it[0]!!.long,
|
||||
name = it[1].toString(),
|
||||
rating = it[2]!!.int,
|
||||
lastSeen = it[3].toString(),
|
||||
@@ -116,7 +117,7 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
|
||||
username = it[8]?.toString() ?: "user${it[0]}"
|
||||
)
|
||||
}
|
||||
rankingSortedIndex = rankingCache.filter { !it.l }.map { it.r.rating }.reversed()
|
||||
rankingLookupCache = rankingCache.associateBy { it.id }
|
||||
logger.info("Ranking for $name computed in ${millis() - time}ms")
|
||||
}
|
||||
|
||||
@@ -155,6 +156,10 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
|
||||
userMusicRepo.findByUser_Card_ExtIdAndMusicIdIn(card.extId, musicList)
|
||||
}
|
||||
|
||||
open fun getRating(user: T, isHighest: Bool): Int {
|
||||
return if (isHighest) user.highestRating else user.playerRating;
|
||||
}
|
||||
|
||||
fun genericUserSummary(card: Card, ratingComp: Map<String, String>, rival: Boolean? = null, favorites: List<Int>? = null): GenericGameSummary {
|
||||
// Summary values: total plays, player rating, server-wide ranking
|
||||
// number of each rank, max combo, number of full combo, number of all perfect
|
||||
@@ -180,18 +185,14 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
|
||||
}
|
||||
}
|
||||
|
||||
// Find serverRank by binary-searching in the rankingSortedIndex to find the minimal index that
|
||||
// is greater than or equal to the user's rating
|
||||
var serverRank = rankingSortedIndex.binarySearch(user.playerRating).let { if (it < 0) -it - 1 else it + 1 }
|
||||
serverRank = rankingSortedIndex.size - serverRank
|
||||
|
||||
return GenericGameSummary(
|
||||
name = user.userName,
|
||||
aquaUser = card.aquaUser?.publicFields,
|
||||
serverRank = serverRank.long,
|
||||
serverRank = rankingLookupCache[user.card!!.id]?.rank?.str ?: "-",
|
||||
accuracy = plays.acc(),
|
||||
rating = user.playerRating,
|
||||
ratingHighest = user.highestRating,
|
||||
rating = getRating(user, false),
|
||||
ratingHighest = getRating(user, true),
|
||||
ratingNotGeneric = getRating(user, false) != user.playerRating,
|
||||
ranks = ranks.map { (k, v) -> RankCount(k, v) },
|
||||
detailedRanks = detailedRanks,
|
||||
maxCombo = plays.maxOfOrNull { it.maxCombo } ?: 0,
|
||||
@@ -205,7 +206,7 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
|
||||
lastVersion = user.lastRomVersion,
|
||||
ratingComposition = ratingComp,
|
||||
recent = plays.sortedBy { it.userPlayDate.toString() }.takeLast(100).reversed(),
|
||||
lastPlayedHost = user.lastClientId?.let { us.userRepo.findByKeychip(it)?.username },
|
||||
lastPlayedHost = user.lastClientId?.let { userKeychipRepo.findByKeychipId(it)?.user?.username },
|
||||
rival = rival,
|
||||
favorites = favorites
|
||||
)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package icu.samnyan.aqua.net.games
|
||||
|
||||
import ext.isoDate
|
||||
import ext.minus
|
||||
import ext.mut
|
||||
import ext.*
|
||||
import java.time.LocalDate
|
||||
|
||||
const val LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
|
||||
@@ -17,10 +15,10 @@ const val WACCA_USERNAME_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
|
||||
"0123456789" +
|
||||
"~|?!=()[]{},.:;\"@/_-+#*&%$"
|
||||
|
||||
fun usernameCheck(chars: String): (IUserData, String) -> Unit = { u, v ->
|
||||
u.userName = v
|
||||
fun usernameCheck(isWacca: Bool): (IUserData, String) -> Unit = { u, v ->
|
||||
if (v.isBlank()) { 400 - "Username cannot be blank" }
|
||||
if (v.length > 8) { 400 - "Username too long" }
|
||||
u.userName = if (!isWacca) toFullWidth(v) else v
|
||||
// v.find { it !in chars }?.let { 400 - "Invalid character '$it' in username" }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package icu.samnyan.aqua.net.games
|
||||
|
||||
import tools.jackson.core.JacksonException
|
||||
import ext.*
|
||||
import icu.samnyan.aqua.net.Fedy
|
||||
import icu.samnyan.aqua.net.db.AquaNetUser
|
||||
import icu.samnyan.aqua.net.db.AquaUserServices
|
||||
import icu.samnyan.aqua.net.utils.ApiException
|
||||
import icu.samnyan.aqua.net.utils.AquaNetProps
|
||||
import icu.samnyan.aqua.net.utils.SUCCESS
|
||||
import icu.samnyan.aqua.sega.general.model.Card
|
||||
import icu.samnyan.aqua.sega.general.service.CardService
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.context.annotation.Lazy
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.repository.NoRepositoryBean
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
@@ -41,7 +44,7 @@ interface IExportClass<UserModel: IUserData> {
|
||||
}
|
||||
|
||||
@NoRepositoryBean
|
||||
interface IUserRepo<UserModel, ThisModel>: JpaRepository<ThisModel, Long> {
|
||||
interface IUserRepo<UserModel : IUserData, ThisModel : Any>: JpaRepository<ThisModel, Long> {
|
||||
fun findByUser(user: UserModel): List<ThisModel>
|
||||
fun findSingleByUser(user: UserModel): ThisModel?
|
||||
}
|
||||
@@ -104,55 +107,87 @@ abstract class ImportController<ExportModel: IExportClass<UserModel>, UserModel:
|
||||
export(u)
|
||||
}
|
||||
|
||||
internal fun replaceInTransaction(existingUserData: UserModel?, auId: Long, insert: () -> Unit) {
|
||||
trans.execute {
|
||||
existingUserData?.also { gu ->
|
||||
// After migration v1000.7, all user-linked entities have ON DELETE CASCADE.
|
||||
log.info("$game Import: Replacing old data for user $auId")
|
||||
userDataRepo.delete(gu)
|
||||
userDataRepo.flush()
|
||||
}
|
||||
|
||||
insert()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseImport(json: String): ExportModel = try {
|
||||
json.parseJackson(exportClass.java)
|
||||
} catch (e: Exception) {
|
||||
val jsonError = generateSequence<Throwable>(e) { it.cause }
|
||||
.filterIsInstance<JacksonException>()
|
||||
.firstOrNull()
|
||||
?: throw e
|
||||
|
||||
log.warn("Rejected invalid $game import: ${jsonError.message}")
|
||||
400 - "Invalid import data: ${jsonError.originalMessage}"
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@API("import")
|
||||
fun importUserData(@RP token: Str, @RB json: Str) = us.jwt.auth(token) { u ->
|
||||
val export = json.parseJackson(exportClass.java)
|
||||
if (!export.gameId.equals(game, true)) 400 - "Invalid game ID"
|
||||
try {
|
||||
val export = parseImport(json)
|
||||
if (!export.gameId.equals(game, true)) 400 - "Invalid game ID"
|
||||
|
||||
val lists = listRepos.toList().associate { (f, r) -> r to f.get(export) as List<IUserEntity<UserModel>> }.vNotNull()
|
||||
val singles = singleRepos.toList().associate { (f, r) -> r to f.get(export) as IUserEntity<UserModel> }.vNotNull()
|
||||
var repoFieldMap = exportRepos.toList().associate { (f, r) -> r to f }
|
||||
val lists = listRepos.toList().associate { (f, r) -> r to f.get(export) as List<IUserEntity<UserModel>> }.vNotNull()
|
||||
val singles = singleRepos.toList().associate { (f, r) -> r to f.get(export) as IUserEntity<UserModel> }.vNotNull()
|
||||
var repoFieldMap = exportRepos.toList().associate { (f, r) -> r to f }
|
||||
|
||||
// Validate new user data
|
||||
// Check that all ids are 0 (this should be true since all ids are @JsonIgnore)
|
||||
if (export.userData.id != 0L) 400 - "User ID must be 0"
|
||||
lists.values.flatten().forEach { if (it.id != 0L) 400 - "ID must be 0" }
|
||||
singles.values.forEach { if (it.id != 0L) 400 - "ID must be 0" }
|
||||
// Validate new user data
|
||||
// Check that all ids are 0 (this should be true since all ids are @JsonIgnore)
|
||||
if (export.userData.id != 0L) 400 - "User ID must be 0"
|
||||
lists.values.flatten().forEach { if (it.id != 0L) 400 - "ID must be 0" }
|
||||
singles.values.forEach { if (it.id != 0L) 400 - "ID must be 0" }
|
||||
|
||||
// Set user card
|
||||
export.userData.card = u.ghostCard
|
||||
// Set user card
|
||||
export.userData.card = u.ghostCard
|
||||
|
||||
// Check existing data
|
||||
userDataRepo.findByCard(u.ghostCard)?.also { gu ->
|
||||
// Store a backup of the old data
|
||||
val fl = "${game}-backup-${u.auId}-${LocalDateTime.now().urlSafeStr()}.json"
|
||||
(Path(netProps.importBackupPath) / fl).writeText(export(u).toJson())
|
||||
|
||||
// Delete the old data (After migration v1000.7, all user-linked entities have ON DELETE CASCADE)
|
||||
log.info("$game Import: Deleting old data for user ${u.auId}")
|
||||
userDataRepo.delete(gu)
|
||||
userDataRepo.flush()
|
||||
}
|
||||
|
||||
trans.execute {
|
||||
// Insert new data
|
||||
val nu = userDataRepo.save(export.userData)
|
||||
// Set user fields
|
||||
lists.values.flatten().forEach { it.user = nu }
|
||||
singles.values.forEach { it.user = nu }
|
||||
// Save new data
|
||||
singles.forEach { (repo, single) -> (repo as IUserRepo<UserModel, Any>).save(single) }
|
||||
lists.forEach { (repo, list) -> (repo as IUserRepo<UserModel, Any>).saveAll(list) }
|
||||
// Handle custom importers
|
||||
customImporters.forEach { (field, importer) ->
|
||||
importer(export, nu)
|
||||
// Back up existing data before starting the replacement transaction.
|
||||
val existingUserData = userDataRepo.findByCard(u.ghostCard)
|
||||
existingUserData?.also {
|
||||
// Store a backup of the old data
|
||||
val fl = "${game}-backup-${u.auId}-${LocalDateTime.now().urlSafeStr()}.json"
|
||||
(Path(netProps.importBackupPath) / fl).writeText(export(u).toJson())
|
||||
}
|
||||
|
||||
replaceInTransaction(existingUserData, u.auId) {
|
||||
// Insert new data
|
||||
val nu = userDataRepo.save(export.userData)
|
||||
// Set user fields
|
||||
lists.values.flatten().forEach { it.user = nu }
|
||||
singles.values.forEach { it.user = nu }
|
||||
// Save new data
|
||||
singles.forEach { (repo, single) -> (repo as IUserRepo<UserModel, Any>).save(single) }
|
||||
lists.forEach { (repo, list) -> (repo as IUserRepo<UserModel, Any>).saveAll(list) }
|
||||
// Handle custom importers
|
||||
customImporters.forEach { (field, importer) ->
|
||||
importer(export, nu)
|
||||
}
|
||||
}
|
||||
|
||||
cardService.updateCardTimestamp(u.ghostCard, gameName, resetCreatedAt = true)
|
||||
|
||||
SUCCESS
|
||||
} catch(e: Exception) {
|
||||
if (e is ApiException) throw e
|
||||
if (e is DataIntegrityViolationException) {
|
||||
log.warn("Rejected conflicting $game import: ${e.message}")
|
||||
400 - "Invalid import data: duplicate or conflicting records"
|
||||
}
|
||||
|
||||
log.error(e.message, e)
|
||||
500 - "Failed to import user data. More information can be found in the server logs (or contact an administrator for help if you do not have access)."
|
||||
}
|
||||
|
||||
cardService.updateCardTimestamp(u.ghostCard, gameName, resetCreatedAt = true)
|
||||
|
||||
SUCCESS
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package icu.samnyan.aqua.net.games
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import tools.jackson.core.JsonParser
|
||||
import tools.jackson.databind.DeserializationContext
|
||||
import tools.jackson.databind.ValueDeserializer
|
||||
import tools.jackson.databind.module.SimpleModule
|
||||
import ext.JACKSON
|
||||
import ext.minus
|
||||
|
||||
@@ -31,7 +31,7 @@ fun String.asSqlInsert(): SqlInsert {
|
||||
}
|
||||
|
||||
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "UNCHECKED_CAST")
|
||||
val JSON_INT_LIST_STR = SimpleModule().addDeserializer(List::class.java, object : JsonDeserializer<List<Integer>>() {
|
||||
val JSON_INT_LIST_STR = SimpleModule().addDeserializer(List::class.java, object : ValueDeserializer<List<Integer>>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext) =
|
||||
try {
|
||||
val text = parser.text.trim('[', ']')
|
||||
@@ -41,6 +41,4 @@ val JSON_INT_LIST_STR = SimpleModule().addDeserializer(List::class.java, object
|
||||
400 - "Invalid list value ${parser.text}: $e" }
|
||||
})
|
||||
|
||||
val JACKSON_ARTEMIS = JACKSON.copy().apply {
|
||||
registerModule(JSON_INT_LIST_STR)
|
||||
}
|
||||
val JACKSON_ARTEMIS = JACKSON.rebuild().addModule(JSON_INT_LIST_STR).build()
|
||||
@@ -1,6 +1,7 @@
|
||||
package icu.samnyan.aqua.net.games
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import ext.Bool
|
||||
import ext.JACKSON
|
||||
import ext.JavaSerializable
|
||||
import icu.samnyan.aqua.sega.general.model.Card
|
||||
@@ -25,10 +26,11 @@ data class GenericGameSummary(
|
||||
|
||||
val aquaUser: Map<String, Any?>?,
|
||||
|
||||
val serverRank: Long,
|
||||
val serverRank: String,
|
||||
val accuracy: Double,
|
||||
val rating: Int,
|
||||
val ratingHighest: Int,
|
||||
val ratingNotGeneric: Bool,
|
||||
val ranks: List<RankCount>,
|
||||
val detailedRanks: Map<Int, Map<String, Int>>,
|
||||
val maxCombo: Int,
|
||||
@@ -59,7 +61,10 @@ data class GenericRankingPlayer(
|
||||
val rating: Int,
|
||||
val allPerfect: Int,
|
||||
val fullCombo: Int,
|
||||
val lastSeen: String
|
||||
val lastSeen: String,
|
||||
|
||||
@JsonIgnore
|
||||
val id: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -3,6 +3,7 @@ package icu.samnyan.aqua.net.games.chu3
|
||||
import ext.API
|
||||
import ext.returns
|
||||
import ext.vars
|
||||
import icu.samnyan.aqua.net.games.ExportOptions
|
||||
import icu.samnyan.aqua.net.games.IExportClass
|
||||
import icu.samnyan.aqua.net.games.ImportClass
|
||||
import icu.samnyan.aqua.net.games.ImportController
|
||||
@@ -12,6 +13,7 @@ import icu.samnyan.aqua.sega.chusan.model.userdata.*
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import kotlin.reflect.full.declaredMembers
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@RestController
|
||||
@API("api/v2/game/chu3")
|
||||
class Chu3Import(
|
||||
@@ -22,7 +24,7 @@ class Chu3Import(
|
||||
it.name.replace("List", "").lowercase()
|
||||
},
|
||||
exportRepos = Chu3DataExport::class.vars()
|
||||
.filter { f -> f.name !in setOf("gameId", "userData") }
|
||||
.filter { f -> f.name !in setOf("gameId", "userData", "userLoginBonusList") }
|
||||
.associateWith { Chu3Repos::class.declaredMembers
|
||||
.filter { f -> f returns Chu3UserLinked::class }
|
||||
.firstOrNull { f -> f.name == it.name || f.name == it.name.replace("List", "") }
|
||||
@@ -44,7 +46,22 @@ class Chu3Import(
|
||||
// "chuni_profile_emoney" to ImportClass(UserEmoney::class),
|
||||
// "chuni_profile_overpower" to ImportClass(UserOverpower::class),
|
||||
// "chuni_profile_recent_rating" to ImportClass(UserRecentRating::class),
|
||||
)
|
||||
),
|
||||
customExporters = mapOf(
|
||||
Chu3DataExport::userLoginBonusList to { user: Chu3UserData, _: ExportOptions ->
|
||||
repos.userLoginBonus.findByUser(user.card!!.extId.toInt())
|
||||
},
|
||||
) as Map<kotlin.reflect.KMutableProperty1<Chu3DataExport, Any>, (Chu3UserData, ExportOptions) -> Any?>,
|
||||
customImporters = mapOf(
|
||||
Chu3DataExport::userLoginBonusList to { export: Chu3DataExport, user: Chu3UserData ->
|
||||
val userId = user.card!!.extId.toInt()
|
||||
repos.userLoginBonus.deleteAll(repos.userLoginBonus.findByUser(userId))
|
||||
repos.userLoginBonus.saveAll(export.userLoginBonusList.map { it.apply {
|
||||
id = 0
|
||||
this.user = userId
|
||||
} })
|
||||
},
|
||||
) as Map<kotlin.reflect.KMutableProperty1<Chu3DataExport, Any>, (Chu3DataExport, Chu3UserData) -> Unit>,
|
||||
) {
|
||||
override fun createEmpty() = Chu3DataExport()
|
||||
override val userDataRepo = repos.userData
|
||||
@@ -64,7 +81,20 @@ data class Chu3DataExport(
|
||||
var userMapList: List<UserMap>,
|
||||
var userMusicDetailList: List<UserMusicDetail>,
|
||||
var userPlaylogList: List<UserPlaylog>,
|
||||
var userMateList: List<UserMate> = emptyList(),
|
||||
var userGeneralDataList: List<UserGeneralData> = emptyList(),
|
||||
var userMiscList: Chu3UserMisc = Chu3UserMisc(),
|
||||
var userCardPrintStateList: List<UserCardPrintState> = emptyList(),
|
||||
var userGachaList: List<UserGacha> = emptyList(),
|
||||
var userRegionsList: List<UserRegions> = emptyList(),
|
||||
var userCMissionList: List<UserCMission> = emptyList(),
|
||||
var userCMissionProgressList: List<UserCMissionProgress> = emptyList(),
|
||||
var netBattleLogList: List<Chu3NetBattleLog> = emptyList(),
|
||||
var userChallengeList: List<Chu3UserChallenge> = emptyList(),
|
||||
var userLinkedVerseList: List<Chu3UserLinkedVerse> = emptyList(),
|
||||
var userVoteList: List<UserVote> = emptyList(),
|
||||
var userLoginBonusList: List<UserLoginBonus> = emptyList(),
|
||||
): IExportClass<Chu3UserData> {
|
||||
constructor() : this("SDHD",
|
||||
Chu3UserData(), UserGameOption(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList())
|
||||
Chu3UserData(), UserGameOption(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), Chu3UserMisc())
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class Chusan(
|
||||
// Only show > AAA rank
|
||||
override val shownRanks = chu3Scores.filter { it.first >= 95 * 10000 }
|
||||
override val settableFields: Map<String, (Chu3UserData, String) -> Unit> by lazy { mapOf(
|
||||
"userName" to usernameCheck(SEGA_USERNAME_CHARS),
|
||||
"userName" to usernameCheck(false),
|
||||
"nameplateId" to { u, v -> u.nameplateId = v.int },
|
||||
"frameId" to { u, v -> u.frameId = v.int },
|
||||
"trophyId" to { u, v -> u.trophyId = v.int },
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package icu.samnyan.aqua.net.games.diva
|
||||
|
||||
import ext.*
|
||||
import icu.samnyan.aqua.net.db.AquaUserServices
|
||||
import icu.samnyan.aqua.sega.diva.DivaRepos
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@API("api/v2/game/diva")
|
||||
class DivaExport(
|
||||
val us: AquaUserServices,
|
||||
val repos: DivaRepos,
|
||||
) {
|
||||
@API("export")
|
||||
fun exportUserData(@RP token: Str) = us.jwt.auth(token) { account ->
|
||||
val profile = repos.profile.findByPdId(account.ghostCard.extId).orElse(null)
|
||||
?: (404 - "User not found")
|
||||
|
||||
linkedMapOf(
|
||||
"gameId" to "SBZV",
|
||||
"userData" to profile,
|
||||
"gameSession" to repos.gameSession.findByPdId(profile).orElse(null),
|
||||
"playLogList" to repos.playLog.findByPdId(profile),
|
||||
"playerContestList" to repos.contest.findByPdId(profile),
|
||||
"playerCustomizeList" to repos.customize.findByPdId(profile),
|
||||
"playerInventoryList" to repos.inventory.findByPdId(profile),
|
||||
"playerModuleList" to repos.module.findByPdId(profile),
|
||||
"playerPvCustomizeList" to repos.pvCustomize.findByPdId(profile),
|
||||
"playerPvRecordList" to repos.pvRecord.findByPdId(profile),
|
||||
"playerScreenShotList" to repos.screenShot.findByPdId(profile),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ class Mai2Import(
|
||||
?.split(",")
|
||||
?.mapIndexed { index, id -> Mai2UserFavoriteItem().apply { orderId = index; this.id = id.toInt() } }
|
||||
?: emptyList()
|
||||
}
|
||||
},
|
||||
) as Map<kotlin.reflect.KMutableProperty1<Maimai2DataExport, Any>, (Mai2UserDetail, ExportOptions) -> Any?>,
|
||||
customImporters = mapOf(
|
||||
Maimai2DataExport::userPlaylogList to { export: Maimai2DataExport, user: Mai2UserDetail ->
|
||||
@@ -85,7 +85,7 @@ class Mai2Import(
|
||||
propertyValue = favoriteMusicList.sortedBy { it.orderId }.map { it.id }.joinToString(",")
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
) as Map<kotlin.reflect.KMutableProperty1<Maimai2DataExport, Any>, (Maimai2DataExport, Mai2UserDetail) -> Unit>
|
||||
) {
|
||||
override fun createEmpty() = Maimai2DataExport()
|
||||
@@ -113,6 +113,9 @@ data class Maimai2DataExport(
|
||||
var userFavoriteMusicList: List<Mai2UserFavoriteItem> = mutableListOf(),
|
||||
var userKaleidxScopeList: List<Mai2UserKaleidx> = mutableListOf(),
|
||||
var userPlaylogList: List<Mai2UserPlaylog> = mutableListOf(),
|
||||
var userCardList: List<Mai2UserCard> = mutableListOf(),
|
||||
var userPrintDetailList: List<Mai2UserPrintDetail> = mutableListOf(),
|
||||
var userRegionsList: List<UserRegions> = mutableListOf(),
|
||||
// Not supported yet:
|
||||
// var userWeeklyData
|
||||
// var userMissionDataList
|
||||
|
||||
@@ -14,9 +14,13 @@ import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserDetail
|
||||
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserGeneralData
|
||||
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserLoginBonus
|
||||
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserOption
|
||||
import jakarta.annotation.PostConstruct
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.text.Charsets.UTF_8
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
import kotlin.reflect.KMutableProperty1
|
||||
import kotlin.reflect.full.memberProperties
|
||||
@@ -38,7 +42,7 @@ class Maimai2(
|
||||
// Only show > S rank
|
||||
override val shownRanks = mai2Scores.filter { it.first >= 97 * 10000 }
|
||||
override val settableFields: Map<String, (Mai2UserDetail, String) -> Unit> by lazy { mapOf(
|
||||
"userName" to usernameCheck(SEGA_USERNAME_CHARS),
|
||||
"userName" to usernameCheck(false),
|
||||
"iconId" to { u, v -> u.iconId = v.int() },
|
||||
"plateId" to { u, v -> u.plateId = v.int() },
|
||||
"titleId" to { u, v -> u.titleId = v.int() },
|
||||
@@ -58,7 +62,7 @@ class Maimai2(
|
||||
|
||||
val ratingComposition = mapOf(
|
||||
"best35" to (extra["recent_rating"] ?: ""),
|
||||
"best15" to (extra["recent_rating_new"] ?: "")
|
||||
"new15" to (extra["recent_rating_new"] ?: "")
|
||||
)
|
||||
|
||||
// if isLogin than boolean or null
|
||||
@@ -207,23 +211,69 @@ class Maimai2(
|
||||
}
|
||||
|
||||
val photoDir = UploadUserPhotoHandler.uploadDir.toFile().canonicalFile
|
||||
val photoHashMap = ConcurrentHashMap<String, String>()
|
||||
|
||||
@API("my-photo")
|
||||
suspend fun myPhoto(@RP token: Str) = us.jwt.auth(token) { u ->
|
||||
val find = "${u.ghostCard.extId}-"
|
||||
// creating a ton of SHA256 hashes every launch *probably* isn't ideal but it's better than exposing token AND extid...
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun myPhotoGetHash(value: Str): Str {
|
||||
return MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.toByteArray(UTF_8)).toHexString()
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
fun myPhotoInit() = thread {
|
||||
photoDir.listFiles()
|
||||
?.map { it.name }
|
||||
?.map {
|
||||
// generate hash of photo filename as to not expose details
|
||||
photoHashMap[it] = myPhotoGetHash(it)
|
||||
}
|
||||
}
|
||||
|
||||
@API("my-photo")
|
||||
suspend fun myPhoto(
|
||||
@RP token: Str,
|
||||
@RP(required = false) page: Int?,
|
||||
@RP(required = false) size: Int?
|
||||
): Any = us.jwt.auth(token) { u ->
|
||||
val find = "${u.ghostCard.extId}-"
|
||||
val files = photoDir.listFiles()
|
||||
?.map { it.name }
|
||||
?.filter { it.startsWith(find) }
|
||||
?.sorted()
|
||||
?.sortedDescending()
|
||||
?: emptyList()
|
||||
|
||||
if (page == null) {
|
||||
files.map {
|
||||
photoHashMap.computeIfAbsent(it) { f -> myPhotoGetHash(f) }
|
||||
}
|
||||
} else {
|
||||
val pageSize = (size ?: 12).coerceIn(1, 100)
|
||||
val total = files.size
|
||||
val totalPages = if (total == 0) 0 else (total + pageSize - 1) / pageSize
|
||||
// Clamp to the nearest valid page: out-of-range pages return the closest real page
|
||||
// instead of an empty grid, and the offset can no longer overflow Int
|
||||
val pageNum = page.coerceIn(1, maxOf(1, totalPages))
|
||||
val pagedFiles = files.drop((pageNum - 1) * pageSize).take(pageSize)
|
||||
val photos = pagedFiles.map {
|
||||
photoHashMap.computeIfAbsent(it) { f -> myPhotoGetHash(f) }
|
||||
}
|
||||
mapOf(
|
||||
"photos" to photos,
|
||||
"page" to pageNum,
|
||||
"pageSize" to pageSize,
|
||||
"total" to total,
|
||||
"totalPages" to totalPages
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@API("my-photo/{fileName}", produces = [MediaType.IMAGE_JPEG_VALUE])
|
||||
suspend fun myPhoto(@RP token: Str, @PV fileName: Str) = us.jwt.auth(token) { u ->
|
||||
val f = (photoDir / fileName)
|
||||
if (!f.canonicalFile.startsWith(photoDir)) (403 - "Never gonna give you up")
|
||||
if (!f.name.startsWith("${u.ghostCard.extId}-")) (403 - "Not your photo")
|
||||
if (!f.exists()) (404 - "Photo not found")
|
||||
f.readBytes()
|
||||
suspend fun accessMyPhoto(@PV fileName: Str): ByteArray {
|
||||
val trueFileName = photoHashMap.entries.firstOrNull { it.value == fileName }?.key
|
||||
if (trueFileName.isNullOrEmpty()) 404 - "Photo does not exist"
|
||||
val file = (photoDir / trueFileName)
|
||||
return file.readBytes()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package icu.samnyan.aqua.net.games.ongeki
|
||||
|
||||
import ext.API
|
||||
import ext.Bool
|
||||
import ext.RP
|
||||
import ext.minus
|
||||
import icu.samnyan.aqua.net.db.AquaUserServices
|
||||
@@ -35,23 +36,39 @@ class Ongeki(
|
||||
|
||||
override val shownRanks = ongekiScores.filter { it.first >= 950000 }
|
||||
override val settableFields: Map<String, (UserData, String) -> Unit> by lazy { mapOf(
|
||||
"userName" to usernameCheck(SEGA_USERNAME_CHARS),
|
||||
"userName" to usernameCheck(false),
|
||||
|
||||
"lastRomVersion" to { u, v -> u.lastRomVersion = v },
|
||||
"lastDataVersion" to { u, v -> u.lastDataVersion = v },
|
||||
) }
|
||||
|
||||
override suspend fun userSummary(username: String, token: String?) = us.cardByName(username) { card ->
|
||||
val user = userDataRepo.findByCard_ExtId(card.extId) ?: (404 - "User not found")
|
||||
val extra = userGeneralDataRepository.findByUser_Card_ExtId(card.extId)
|
||||
.associate { it.propertyKey to it.propertyValue }
|
||||
|
||||
val ratingComposition = mapOf(
|
||||
"best30" to (extra["rating_base_best"] ?: ""),
|
||||
"best15" to (extra["rating_base_new_best"] ?: ""),
|
||||
"recent10" to (extra["rating_base_hot_best"] ?: "")
|
||||
)
|
||||
val isRefresh = user.newHighestRating > 0
|
||||
if (isRefresh)
|
||||
genericUserSummary(card, mapOf(
|
||||
"best50" to (extra["new_rating_base_best"] ?: ""),
|
||||
"new10" to (extra["new_rating_base_new_best"] ?: ""),
|
||||
"pscore50" to (extra["new_rating_base_pscore"] ?: ""),
|
||||
"best20_candidates" to (extra["new_rating_base_next_best"] ?: ""),
|
||||
"new10_candidates" to (extra["new_rating_base_new_next_best"] ?: ""),
|
||||
"pscore20_candidates" to (extra["new_rating_base_next_pscore"] ?: "")
|
||||
))
|
||||
else
|
||||
genericUserSummary(card, mapOf(
|
||||
"best30" to (extra["rating_base_best"] ?: ""),
|
||||
"new15" to (extra["rating_base_new_best"] ?: ""),
|
||||
"recent10" to (extra["rating_base_hot_best"] ?: "")
|
||||
))
|
||||
}
|
||||
|
||||
genericUserSummary(card, ratingComposition)
|
||||
override fun getRating(user: UserData, isHighest: Bool): Int {
|
||||
return if (user.newHighestRating > 0) {
|
||||
if (isHighest) user.newHighestRating else user.newPlayerRating
|
||||
} else if (isHighest) user.highestRating else user.playerRating
|
||||
}
|
||||
|
||||
@API("user-option")
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package icu.samnyan.aqua.net.games.ongeki
|
||||
|
||||
import ext.API
|
||||
import ext.returns
|
||||
import ext.vars
|
||||
import icu.samnyan.aqua.net.games.IExportClass
|
||||
import icu.samnyan.aqua.net.games.ImportClass
|
||||
import icu.samnyan.aqua.net.games.ImportController
|
||||
import icu.samnyan.aqua.sega.ongeki.model.*
|
||||
import icu.samnyan.aqua.sega.ongeki.OngekiRepos
|
||||
import icu.samnyan.aqua.sega.ongeki.OngekiUserLinked
|
||||
import icu.samnyan.aqua.sega.ongeki.OngekiUserRepos
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import kotlin.reflect.full.declaredMembers
|
||||
|
||||
@RestController
|
||||
@API("api/v2/game/ongeki")
|
||||
class OngekiImport(
|
||||
val repos: OngekiUserRepos,
|
||||
) : ImportController<OngekiDataExport, UserData>(
|
||||
"SDDT", "ongeki", OngekiDataExport::class,
|
||||
exportFields = OngekiDataExport::class.vars().associateBy {
|
||||
it.name.replace("List", "").lowercase()
|
||||
},
|
||||
exportRepos = OngekiDataExport::class.vars()
|
||||
.filter { f -> f.name !in setOf("gameId", "userData") }
|
||||
.associateWith { OngekiUserRepos::class.declaredMembers
|
||||
.filter { f -> f returns OngekiUserLinked::class }
|
||||
.firstOrNull { f -> f.name == it.name
|
||||
|| f.name == (it.name.substring(4, 5).lowercase() + it.name.substring(5)).replace("List", "") // strip user
|
||||
|| f.name == it.name.replace("List", "")
|
||||
}
|
||||
?.call(repos) as OngekiUserLinked<*>? ?: error("No matching field found for ${it.name}")
|
||||
},
|
||||
artemisRenames = mapOf() // TODO (almost nobody uses this so it's very low priority)
|
||||
) {
|
||||
override fun createEmpty() = OngekiDataExport()
|
||||
override val userDataRepo = repos.data
|
||||
}
|
||||
|
||||
|
||||
data class OngekiDataExport(
|
||||
override var gameId: String = "SDDT",
|
||||
override var userData: UserData,
|
||||
var userActivityList: List<UserActivity>,
|
||||
var userBossList: List<UserBoss>,
|
||||
var userCardList: List<UserCard>,
|
||||
var userChapterList: List<UserChapter>,
|
||||
var userCharacterList: List<UserCharacter>,
|
||||
var userDeckList: List<UserDeck>,
|
||||
var userEventMusicList: List<UserEventMusic>,
|
||||
var userEventPointList: List<UserEventPoint>,
|
||||
var userGeneralDataList: List<UserGeneralData>,
|
||||
var userItemList: List<UserItem>,
|
||||
var userKopList: List<UserKop>,
|
||||
var userLoginBonusList: List<UserLoginBonus>,
|
||||
var userMemoryChapterList: List<UserMemoryChapter>,
|
||||
var userMissionPointList: List<UserMissionPoint>,
|
||||
var userMusicDetailList: List<UserMusicDetail>,
|
||||
var userMusicItemList: List<UserMusicItem>,
|
||||
var userOption: UserOption,
|
||||
var userPlaylogList: List<UserPlaylog>,
|
||||
var userRivalList: List<UserRival>,
|
||||
var userScenarioList: List<UserScenario>,
|
||||
var userStoryList: List<UserStory>,
|
||||
var userTechCountList: List<UserTechCount>,
|
||||
var userTechEventList: List<UserTechEvent>,
|
||||
var userTradeItemList: List<UserTradeItem>,
|
||||
var userTrainingRoomList: List<UserTrainingRoom>,
|
||||
var userEventMapList: List<UserEventMap>,
|
||||
var userSkinList: List<UserSkin>,
|
||||
var userRegionsList: List<UserRegions>,
|
||||
var userGachaList: List<UserGacha>,
|
||||
): IExportClass<UserData> {
|
||||
constructor() : this("SDDT", UserData(), ArrayList(), ArrayList(), ArrayList(), ArrayList(),ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(),ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(),ArrayList(), ArrayList(), UserOption(), ArrayList(), ArrayList(),ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(),ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList())
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
package icu.samnyan.aqua.net.games.wacca
|
||||
|
||||
import ext.API
|
||||
import ext.RP
|
||||
import ext.isoDate
|
||||
import ext.utc
|
||||
import ext.*
|
||||
import icu.samnyan.aqua.net.db.AquaUserServices
|
||||
import icu.samnyan.aqua.net.games.*
|
||||
import icu.samnyan.aqua.net.utils.waccaScores
|
||||
import icu.samnyan.aqua.sega.wacca.model.db.WaccaUser
|
||||
import icu.samnyan.aqua.sega.wacca.model.db.WcUserBestScoreRepo
|
||||
import icu.samnyan.aqua.sega.wacca.model.db.WcUserPlayLogRepo
|
||||
import icu.samnyan.aqua.sega.wacca.model.db.WcUserRepo
|
||||
import icu.samnyan.aqua.sega.wacca.model.db.*
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@@ -20,9 +14,10 @@ class Wacca(
|
||||
override val playlogRepo: WcUserPlayLogRepo,
|
||||
override val userDataRepo: WcUserRepo,
|
||||
override val userMusicRepo: WcUserBestScoreRepo,
|
||||
val repos: WaccaRepos,
|
||||
): GameApiController<WaccaUser>("wacca", WaccaUser::class) {
|
||||
override val settableFields: Map<String, (WaccaUser, String) -> Unit> by lazy { mapOf(
|
||||
"userName" to usernameCheck(WACCA_USERNAME_CHARS),
|
||||
"userName" to usernameCheck(true),
|
||||
|
||||
"lastRomVersion" to { u, v -> u.lastRomVersion = v },
|
||||
) }
|
||||
@@ -40,5 +35,38 @@ class Wacca(
|
||||
genericUserSummary(card, mapOf(), null, data?.favoriteSongs)
|
||||
}
|
||||
|
||||
@API("export")
|
||||
fun exportUserData(@RP token: Str) = us.jwt.auth(token) { account ->
|
||||
val user = repos.user.findByCard(account.ghostCard) ?: (404 - "User not found")
|
||||
WaccaDataExport(
|
||||
userData = user,
|
||||
userOptionList = repos.option.findByUser(user),
|
||||
userBingoList = repos.bingo.findByUser(user),
|
||||
userFriendList = repos.friend.findByUser(user).map {
|
||||
WaccaFriendExport(it.with.id, it.isAccepted)
|
||||
},
|
||||
userGateList = repos.gate.findByUser(user),
|
||||
userItemList = repos.item.findByUser(user),
|
||||
userBestScoreList = repos.bestScore.findByUser(user),
|
||||
userPlaylogList = repos.playLog.findByUser(user),
|
||||
userStageUpList = repos.stageUp.findByUser(user),
|
||||
)
|
||||
}
|
||||
|
||||
override val shownRanks: List<Pair<Int, String>> = waccaScores.filter { it.first > 85 * 10000 }
|
||||
}
|
||||
|
||||
data class WaccaFriendExport(val withUserId: Long, val isAccepted: Boolean)
|
||||
|
||||
data class WaccaDataExport(
|
||||
val gameId: String = "SDFE",
|
||||
val userData: WaccaUser,
|
||||
val userOptionList: List<WcUserOption>,
|
||||
val userBingoList: List<WcUserBingo>,
|
||||
val userFriendList: List<WaccaFriendExport>,
|
||||
val userGateList: List<WcUserGate>,
|
||||
val userItemList: List<WcUserItem>,
|
||||
val userBestScoreList: List<WcUserScore>,
|
||||
val userPlaylogList: List<WcUserPlayLog>,
|
||||
val userStageUpList: List<WcUserStageUp>,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder
|
||||
import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder.decodeAllNetResp
|
||||
import java.net.URI
|
||||
|
||||
val keychipPattern = Regex("([A-Z\\d]{4}-[A-Z\\d]{11}|[A-Z\\d]{11})")
|
||||
val keychipPattern = Regex("([A-Z\\d]{4}-[A-Z\\d]{11}|[A-Z\\d]{15})")
|
||||
|
||||
class AllNetClient(val dns: String, val keychip: String, val game: String, val version: String, val card: String) {
|
||||
init {
|
||||
|
||||
@@ -10,6 +10,7 @@ import kotlin.io.path.createDirectories
|
||||
@ConfigurationProperties(prefix = "aqua-net")
|
||||
class AquaNetProps {
|
||||
var linkCardLimit: Int = 10
|
||||
var keychipLimit: Int = 20
|
||||
var importBackupPath = "data/import-backups"
|
||||
|
||||
@PostConstruct
|
||||
|
||||
@@ -46,7 +46,7 @@ class AimeDB(
|
||||
keychipId = readPaddedString(12u) // 14 12b
|
||||
)
|
||||
|
||||
fun ByteBuf.readPaddedString(maxLen: UInt) = readBytes(maxLen.toInt()).toString(US_ASCII).trimEnd('\u0000')
|
||||
fun ByteBuf.readPaddedString(maxLen: UInt) = readCharSequence(maxLen.toInt(), US_ASCII).toString().trimEnd('\u0000')
|
||||
|
||||
data class Handler(val name: String, val fn: (ByteBuf) -> ByteBuf?)
|
||||
|
||||
@@ -96,7 +96,6 @@ class AimeDB(
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Netty 5") // TODO: Move this to ChannelInboundHandler
|
||||
override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
|
||||
logger.error("AimeDB: Error", cause)
|
||||
ctx.close()
|
||||
|
||||
@@ -28,8 +28,11 @@ class AimeDbDecoder : ByteToMessageDecoder() {
|
||||
|
||||
// Create a byte array to store the encrypted data
|
||||
val d = input.readBytes(length)
|
||||
val result = AimeDbEncryption.decrypt(d)
|
||||
d.release()
|
||||
val result = try {
|
||||
AimeDbEncryption.decrypt(d)
|
||||
} finally {
|
||||
d.release()
|
||||
}
|
||||
|
||||
val resultMap = mapOf(
|
||||
"type" to result.getShortLE(0x04).toInt(),
|
||||
@@ -50,13 +53,16 @@ class AimeDbDecoder : ByteToMessageDecoder() {
|
||||
private fun getLength(input: ByteBuf): Int = try {
|
||||
val currentPos = input.readerIndex()
|
||||
val result = AimeDbEncryption.decrypt(input)
|
||||
try {
|
||||
// Check the header
|
||||
val header = result.getByte(0).toInt()
|
||||
assert(header == 0x3e) { "AimeDB: Invalid header $header" }
|
||||
|
||||
// Check the header
|
||||
val header = result.getByte(0).toInt()
|
||||
assert(header == 0x3e) { "AimeDB: Invalid header $header" }
|
||||
|
||||
// Read the length from offset 6
|
||||
result.getShortLE(currentPos + 6).toInt()
|
||||
// Read the length from offset 6
|
||||
result.getShortLE(currentPos + 6).toInt()
|
||||
} finally {
|
||||
result.release()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.info("AimeDB: Invalid request received")
|
||||
logger.debug("AimeDB: Invalid request received: ${input.toString(Charsets.UTF_8)}")
|
||||
|
||||
@@ -4,7 +4,8 @@ import ext.logger
|
||||
import io.netty.bootstrap.ServerBootstrap
|
||||
import io.netty.channel.ChannelInitializer
|
||||
import io.netty.channel.ChannelOption
|
||||
import io.netty.channel.nio.NioEventLoopGroup
|
||||
import io.netty.channel.MultiThreadIoEventLoopGroup
|
||||
import io.netty.channel.nio.NioIoHandler
|
||||
import io.netty.channel.socket.SocketChannel
|
||||
import io.netty.channel.socket.nio.NioServerSocketChannel
|
||||
import io.netty.handler.logging.LogLevel
|
||||
@@ -38,7 +39,10 @@ class AimeDbServer(
|
||||
if (!props.enable) return logger.info("Aime DB is disabled.")
|
||||
|
||||
val bootstrap = ServerBootstrap()
|
||||
.group(NioEventLoopGroup(), NioEventLoopGroup())
|
||||
.group(
|
||||
MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()),
|
||||
MultiThreadIoEventLoopGroup(NioIoHandler.newFactory())
|
||||
)
|
||||
.handler(LoggingHandler(LogLevel.DEBUG))
|
||||
.channel(NioServerSocketChannel::class.java)
|
||||
.childHandler(initializer)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package icu.samnyan.aqua.sega.allnet
|
||||
|
||||
import ext.*
|
||||
import icu.samnyan.aqua.net.db.AquaNetUserRepo
|
||||
import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder.decodeAllNet
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
@@ -23,6 +22,7 @@ class AllNetProps {
|
||||
val keychipSesExpire: Long = 172800000 // milliseconds
|
||||
var checkKeychip: Boolean = false
|
||||
var keychipPermissiveForTesting: Boolean = false
|
||||
var tls: Boolean = false
|
||||
var redirect: String = "web"
|
||||
|
||||
var placeName: String = ""
|
||||
@@ -59,9 +59,8 @@ class AllNetProps {
|
||||
@Suppress("HttpUrlsUsage")
|
||||
@RestController
|
||||
class AllNet(
|
||||
val userRepo: AquaNetUserRepo,
|
||||
val userKeychipRepo: UserKeychipRepo,
|
||||
val keychipSessionService: KeychipSessionService,
|
||||
val keychipRepo: KeyChipRepo,
|
||||
val props: AllNetProps
|
||||
) {
|
||||
@API("/")
|
||||
@@ -106,10 +105,16 @@ class AllNet(
|
||||
|
||||
var session: String? = null
|
||||
|
||||
val gameId = reqMap["game_id"] ?: return "".also { logger.warn("> Rejected: No game_id provided") }
|
||||
val ver = reqMap["ver"] ?: "1.0"
|
||||
|
||||
if ((ver.toDoubleOrNull() ?: 0.0) < 1.0)
|
||||
return "".also { logger.warn("> Rejected: Version $ver is not allowed (typically bad ICF)") }
|
||||
|
||||
// Proper keychip authentication
|
||||
if (props.checkKeychip) {
|
||||
// If it's a user keychip, it should be in user database
|
||||
val u = userRepo.findByKeychip(serial)
|
||||
val u = findUserByIncomingSerial(serial)
|
||||
if (u != null) {
|
||||
// Create a new session for the user
|
||||
logger.info("> Keychip authenticated: ${u.auId} ${u.computedName}")
|
||||
@@ -120,11 +125,6 @@ class AllNet(
|
||||
session = keychipSessionService.new(u, reqMap["game_id"] ?: "").token
|
||||
}
|
||||
|
||||
// Check if it's a whitelisted keychip
|
||||
else if (!serial.isEmpty() && keychipRepo.existsByKeychipId(serial)) {
|
||||
session = keychipSessionService.new(null, reqMap["game_id"] ?: "").token
|
||||
}
|
||||
|
||||
else if (props.keychipPermissiveForTesting) {
|
||||
logger.warn("> Accepted invalid keychip $serial in permissive mode")
|
||||
session = keychipSessionService.new(null, reqMap["game_id"] ?: "").token
|
||||
@@ -136,8 +136,6 @@ class AllNet(
|
||||
}
|
||||
}
|
||||
|
||||
val gameId = reqMap["game_id"] ?: return "".also { logger.warn("> Rejected: No game_id provided") }
|
||||
val ver = reqMap["ver"] ?: "1.0"
|
||||
|
||||
val formatVer = reqMap["format_ver"] ?: ""
|
||||
val resp = props.map.mut + mapOf(
|
||||
@@ -175,29 +173,32 @@ class AllNet(
|
||||
return resp.toUrl() + "\n"
|
||||
}
|
||||
|
||||
private fun findUserByIncomingSerial(serial: String) = when (serial.length) {
|
||||
FULL_KEYCHIP_LENGTH -> userKeychipRepo.findByKeychipId(serial)?.user
|
||||
SHORT_KEYCHIP_LENGTH -> {
|
||||
// segatools only sends the first 11 characters of the keychip
|
||||
// First, try to find it by suffixing AquaDX's generated suffix, then fall back to matching without the suffixed 4 digits
|
||||
userKeychipRepo.findByKeychipId(serial + KEYCHIP_SUFFIX)?.user
|
||||
?: userKeychipRepo.findByKeychipIdStartingWith(serial)?.user
|
||||
}
|
||||
else -> userKeychipRepo.findByKeychipId(serial)?.user
|
||||
}
|
||||
|
||||
private fun switchUri(hereAddr: Str, localPort: Str, gameId: Str, ver: Str, session: Str?): Str {
|
||||
val addr = hereAddr + (if (props.hidePort) "" else ":${props.port ?: localPort}")
|
||||
|
||||
// If keychip authentication is enabled, the game URLs will be set to /gs/{token}/{game}/...
|
||||
val base = if (session != null) "gs/$session" else "g"
|
||||
val protocol = if (props.tls) "https" else "http"
|
||||
|
||||
return "http://$addr/$base/" + when (gameId) {
|
||||
"SDBT" -> "chu2/$ver/$session/"
|
||||
"SDHD" -> "chu3/$ver/"
|
||||
"SDGS" -> "chu3/$ver/" // International (c3exp)
|
||||
"SBZV" -> "diva/"
|
||||
"SDDT" -> "ongeki/$ver/"
|
||||
"SDEY" -> "mai/"
|
||||
"SDGA" -> "mai2/" // International (Exp)
|
||||
"SDGB" -> "mai2/" // International (China) - TODO: Test it
|
||||
"SDEZ" -> "mai2/"
|
||||
"SDFE" -> "wacca" // Note: Wacca must not end with a trailing slash
|
||||
"SDED" -> "card/"
|
||||
else -> ""
|
||||
}
|
||||
val url = "$protocol://$addr/$base/$gameId/$ver"
|
||||
return url + (if (gameId == "SDFE") "" else "/") // Wacca must not end with trailing slash
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SHORT_KEYCHIP_LENGTH = 11
|
||||
const val FULL_KEYCHIP_LENGTH = 15
|
||||
const val KEYCHIP_SUFFIX = "1337"
|
||||
val logger = logger()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||
@Component
|
||||
@ConditionalOnBean(AllNetSecureInit::class)
|
||||
class TokenChecker(
|
||||
val keyChipRepo: KeyChipRepo,
|
||||
val keychipSessionService: KeychipSessionService,
|
||||
val frontierProps: FrontierProps,
|
||||
val geoip: GeoIP
|
||||
@@ -56,7 +55,7 @@ class TokenChecker(
|
||||
// The token can either be a keychip id (old method) or a session id (new method)
|
||||
// Or the frontier token
|
||||
val session = keychipSessionService.find(token)
|
||||
if (token.isNotBlank() && (keyChipRepo.existsByKeychipId(token) || session != null
|
||||
if (token.isNotBlank() && (session != null
|
||||
|| (frontierProps.enabled && frontierProps.ftk == token)))
|
||||
{
|
||||
currentSession.set(session)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package icu.samnyan.aqua.sega.allnet
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* This is the old method of securing requests - a keychip whitelist,
|
||||
* it's kept here only for backwards compatibility.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "allnet_keychips")
|
||||
class Keychip(
|
||||
@Id
|
||||
val id: Long = 0,
|
||||
|
||||
@Column(unique = true, nullable = false)
|
||||
val keychipId: String = "",
|
||||
) : Serializable {
|
||||
companion object {
|
||||
const val serialVersionUID = 1L
|
||||
}
|
||||
}
|
||||
|
||||
@Repository("KeyChipRepository")
|
||||
interface KeyChipRepo : JpaRepository<Keychip?, Long?> {
|
||||
fun existsByKeychipId(keychipId: String?): Boolean
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import icu.samnyan.aqua.net.db.AquaNetUser
|
||||
import jakarta.persistence.*
|
||||
import jakarta.transaction.Transactional
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.hibernate.annotations.OnDelete
|
||||
import org.hibernate.annotations.OnDeleteAction
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Repository
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -18,9 +20,10 @@ import java.security.SecureRandom
|
||||
@Table(name = "allnet_keychip_sessions", indexes = [
|
||||
Index(name = "idx_last_use", columnList = "lastUse")
|
||||
])
|
||||
class KeychipSession(
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "au_id")
|
||||
class KeychipSession(
|
||||
@ManyToOne
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
@JoinColumn(name = "au_id")
|
||||
var user: AquaNetUser? = null,
|
||||
|
||||
@Column(length = 4)
|
||||
@@ -45,12 +48,12 @@ fun genUrlSafeToken(length: Int): String {
|
||||
}
|
||||
|
||||
@Repository("KeychipSessionRepo")
|
||||
interface KeychipSessionRepo : JpaRepository<KeychipSession, String> {
|
||||
fun findByToken(token: String): KeychipSession?
|
||||
|
||||
@Transactional
|
||||
fun deleteAllByLastUseBefore(expire: Long)
|
||||
}
|
||||
interface KeychipSessionRepo : JpaRepository<KeychipSession, String> {
|
||||
fun findByToken(token: String): KeychipSession?
|
||||
|
||||
@Transactional
|
||||
fun deleteAllByLastUseBefore(expire: Long)
|
||||
}
|
||||
|
||||
@Service
|
||||
class KeychipSessionService(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package icu.samnyan.aqua.sega.allnet
|
||||
|
||||
import icu.samnyan.aqua.net.db.AquaNetUser
|
||||
import jakarta.persistence.*
|
||||
import jakarta.transaction.Transactional
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.hibernate.annotations.OnDelete
|
||||
import org.hibernate.annotations.OnDeleteAction
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_keychip")
|
||||
class UserKeychip(
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long = 0,
|
||||
|
||||
@ManyToOne
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
@JoinColumn(name = "au_id", nullable = false)
|
||||
var user: AquaNetUser,
|
||||
|
||||
@Column(unique = true, nullable = false, length = 32)
|
||||
val keychipId: String,
|
||||
)
|
||||
|
||||
@Repository
|
||||
interface UserKeychipRepo : JpaRepository<UserKeychip, Long> {
|
||||
fun findByKeychipId(keychipId: String): UserKeychip?
|
||||
fun findByKeychipIdStartingWith(keychipIdPrefix: String): UserKeychip?
|
||||
fun existsByKeychipId(keychipId: String): Boolean
|
||||
fun findAllByUserAuId(auId: Long): List<UserKeychip>
|
||||
|
||||
@Transactional
|
||||
fun deleteByKeychipIdAndUserAuId(keychipId: String, auId: Long): Long
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import org.eclipse.jetty.util.resource.URLResourceFactory
|
||||
import org.eclipse.jetty.util.ssl.SslContextFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.boot.web.embedded.jetty.JettyServerCustomizer
|
||||
import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory
|
||||
import org.springframework.boot.jetty.JettyServerCustomizer
|
||||
import org.springframework.boot.jetty.servlet.JettyServletWebServerFactory
|
||||
import org.springframework.boot.web.server.WebServerFactoryCustomizer
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user