forked from Cookies_Public/AquaDX
Compare commits
No commits in common. "v1-dev" and "matching" have entirely different histories.
@ -1,20 +0,0 @@
|
||||
FROM gradle:8.8.0-jdk21
|
||||
|
||||
ENV NODE_VERSION=22
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y curl && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
npm install -g npm@latest
|
||||
|
||||
RUN npm install -g bun
|
||||
|
||||
RUN apt-get install -y maven
|
||||
|
||||
RUN gradle --version && \
|
||||
node --version && \
|
||||
npm --version && \
|
||||
bun --version
|
||||
|
||||
WORKDIR /workspace
|
||||
@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "AquaDX Dev Container",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"vscjava.vscode-gradle",
|
||||
"vscjava.vscode-java-pack",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"fwcd.kotlin"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
48
.github/workflows/build.yml
vendored
48
.github/workflows/build.yml
vendored
@ -1,48 +0,0 @@
|
||||
name: Gradle Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ v1-dev ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
env:
|
||||
GIT_SSL_NO_VERIFY: true
|
||||
|
||||
steps:
|
||||
- name: Force Git to use HTTP/1.1 (Experimental)
|
||||
run: |
|
||||
git config --global http.version HTTP/1.1
|
||||
git config --global http.sslVerify false
|
||||
git config --global http.sslVerify false
|
||||
git config --global https.sslVerify false
|
||||
git config --global http.proxy 'http://127.0.0.1:7890'
|
||||
git config --global https.proxy 'http://127.0.0.1:7890'
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED='0'
|
||||
|
||||
- name: Disable SSL verification (Temporary Fix)
|
||||
run: git config --global http.sslVerify false
|
||||
|
||||
- name: Checkout repository
|
||||
uses: https://gitee.com/github-actions/checkout@v4
|
||||
|
||||
|
||||
- name: Set up JDK
|
||||
uses: https://gitea.com/actions/setup-java@v3
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Build with Gradle
|
||||
run: |
|
||||
mkdir data
|
||||
bash ./src/main/resources/meta/update.sh
|
||||
chmod +x gradlew
|
||||
./gradlew build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: https://gitee.com/actions-mirror/upload-artifact@v3
|
||||
with:
|
||||
name: AquaDX-1.0.0.jar
|
||||
path: build/libs
|
||||
3
.github/workflows/docker-image.yml
vendored
3
.github/workflows/docker-image.yml
vendored
@ -6,8 +6,6 @@ on:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Runs at midnight UTC every Sunday
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@ -58,6 +56,7 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
hykilpikonna/aquadx:latest
|
||||
ghcr.io/${{ github.repository_owner }}/AquaDX:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
|
||||
10
.github/workflows/gradle.yml
vendored
10
.github/workflows/gradle.yml
vendored
@ -9,17 +9,19 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '21'
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
server-id: github
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2
|
||||
|
||||
- name: Build with Gradle
|
||||
run: |
|
||||
mkdir data
|
||||
bash ./src/main/resources/meta/update.sh
|
||||
chmod +x gradlew
|
||||
./gradlew build
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -83,4 +83,3 @@ src/main/resources/meta/*/*.json
|
||||
*.salive
|
||||
test-diff
|
||||
htmlReport
|
||||
docs/logs
|
||||
|
||||
@ -1,9 +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
|
||||
5
AquaNet/.gitignore
vendored
5
AquaNet/.gitignore
vendored
@ -31,8 +31,3 @@ dist-ssr
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
public/chu3
|
||||
|
||||
# local env file
|
||||
*.local
|
||||
BIN
AquaNet/bun.lockb
Executable file → Normal file
BIN
AquaNet/bun.lockb
Executable file → Normal file
Binary file not shown.
@ -39,7 +39,6 @@
|
||||
"lxgw-wenkai-lite-webfont": "^1.7.0",
|
||||
"modern-normalize": "^3.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"svelte-easy-crop": "^4.0.0",
|
||||
"svelte5-router": "^3.0.1"
|
||||
},
|
||||
"packageManager": "pnpm@9.7.0+sha512.dc09430156b427f5ecfc79888899e1c39d2d690f004be70e05230b72cb173d96839587545d09429b55ac3c429c801b4dc3c0e002f653830a420fa2dd4e3cf9cf"
|
||||
|
||||
2607
AquaNet/pnpm-lock.yaml
generated
2607
AquaNet/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 5.3 KiB |
@ -1,163 +0,0 @@
|
||||
/*
|
||||
|
||||
Happy April Fools!
|
||||
This theme will stay here.
|
||||
Note that I made it with Stylish in mind, it's quite jank.
|
||||
|
||||
*/
|
||||
* {
|
||||
font-family: "ヒラギノ角ゴ Pro W3", "メイリオ", Meiryo, "MS Pゴシック",
|
||||
"MS P Gothic", sans-serif;
|
||||
}
|
||||
nav > a,
|
||||
nav > *.active,
|
||||
.setting-icon path {
|
||||
color: unset !important;
|
||||
}
|
||||
.aqua-tooltip {
|
||||
background: black;
|
||||
}
|
||||
.fw-block {
|
||||
background: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
#app {
|
||||
background: url(/assets/theme/cn/logo.bin),
|
||||
#f9f9db;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50% 4px;
|
||||
max-width: 528px !important;
|
||||
margin: 0 auto;
|
||||
padding: 100px 0 0 0 !important;
|
||||
height: unset !important;
|
||||
box-shadow: -8px 0 0 0 #fdd500, -12px 0 0 0 #f9f9db, 8px 0 0 0 #fdd500,
|
||||
12px 0 0 0 #f9f9db;
|
||||
}
|
||||
nav:has(.logo) {
|
||||
position: absolute !important;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: calc(100% - 96px);
|
||||
}
|
||||
nav {
|
||||
color: black;
|
||||
}
|
||||
.user-pfp {
|
||||
margin-top: -56px !important;
|
||||
}
|
||||
.outer-title-options,
|
||||
.outer-title-options *,
|
||||
nav.tabs {
|
||||
color: white !important;
|
||||
}
|
||||
.outer-title-options {
|
||||
margin-top: 0 !important;
|
||||
display: unset !important;
|
||||
}
|
||||
.outer-title-options h2 {
|
||||
width: 460px;
|
||||
position: relative;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0 0 10px 0 !important;
|
||||
background: url(/assets/theme/cn/header.bin);
|
||||
}
|
||||
.chuni-userbox-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chuni-userbox button {
|
||||
width: calc(100% / 4) !important;
|
||||
font-size: 0px;
|
||||
}
|
||||
.chuni-userbox-row button {
|
||||
width: unset !important;
|
||||
flex: 0 1 calc(100% / 3) !important;
|
||||
}
|
||||
.chuni-userbox-row button img {
|
||||
overflow: hidden;
|
||||
font-size: 10px;
|
||||
}
|
||||
.chuni-nameplate {
|
||||
background: none !important;
|
||||
position: relative !important;
|
||||
left: 20px;
|
||||
}
|
||||
.chuni-userbox {
|
||||
background: none !important;
|
||||
}
|
||||
main {
|
||||
max-width: calc(460px - 40px) !important;
|
||||
margin: 16px auto 0 auto !important;
|
||||
background: #2c4056 !important;
|
||||
border-radius: unset !important;
|
||||
padding: 10px 20px !important;
|
||||
}
|
||||
main:has(.user-pfp) {
|
||||
margin: 64px auto 0 auto !important;
|
||||
}
|
||||
.rating-composition {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 !important;
|
||||
}
|
||||
.rating-composition > div {
|
||||
width: 47.5%;
|
||||
margin: 1.25%;
|
||||
}
|
||||
.map-detail-container {
|
||||
background: none !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.lv {
|
||||
border-radius: 0 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 !important;
|
||||
width: 50px !important;
|
||||
}
|
||||
.rank-text {
|
||||
min-width: 20px !important;
|
||||
}
|
||||
.chuni-userbox-container {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.profile-bio-text {
|
||||
white-space: unset !important;
|
||||
}
|
||||
.chuni-penguin-container {
|
||||
padding: 64px 0;
|
||||
width: 100%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(249, 249, 219, 1) 0%,
|
||||
rgba(249, 249, 219, 1) 69%,
|
||||
rgba(231, 231, 202, 1) 70%,
|
||||
rgba(231, 231, 202, 1) 100%
|
||||
);
|
||||
}
|
||||
body {
|
||||
background: #fdd500 !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
#app {
|
||||
background-position: 50% 60px !important;
|
||||
padding-top: 150px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1028px) {
|
||||
#app {
|
||||
background-size: 90%;
|
||||
}
|
||||
|
||||
.user-pfp {
|
||||
margin-top: -36px !important;
|
||||
}
|
||||
.user-pfp nav {
|
||||
top: -10px !important;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB |
@ -4,15 +4,10 @@
|
||||
import UserHome from "./pages/UserHome.svelte";
|
||||
import Home from "./pages/Home.svelte";
|
||||
import Ranking from "./pages/Ranking.svelte";
|
||||
import { CARD, USER } from "./libs/sdk";
|
||||
import { USER } from "./libs/sdk";
|
||||
import type { AquaNetUser } from "./libs/generalTypes";
|
||||
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 { t } from "./libs/i18n";
|
||||
import Transfer from "./pages/Transfer/Transfer.svelte";
|
||||
import { link } from "d3";
|
||||
import { pfp } from "./libs/ui"
|
||||
|
||||
console.log(`%c
|
||||
┏━┓ ┳━┓━┓┏━
|
||||
@ -28,26 +23,9 @@
|
||||
|
||||
export let url = "";
|
||||
let me: AquaNetUser
|
||||
let playedMai = false
|
||||
|
||||
if (USER.isLoggedIn())
|
||||
{
|
||||
USER.me().then(m => {
|
||||
me = m
|
||||
CARD.userGames(me.username).then(game => {
|
||||
playedMai = !!game.mai2
|
||||
})
|
||||
}).catch(e => console.error(e))
|
||||
if (USER.isLoggedIn()) USER.me().then(m => me = m).catch(e => console.error(e))
|
||||
|
||||
const themeStyle = document.createElement("link");
|
||||
themeStyle.rel = "stylesheet";
|
||||
switch (localStorage.getItem("theme")) {
|
||||
case "cn":
|
||||
themeStyle.href = "/assets/theme/cn.css";
|
||||
};
|
||||
if (themeStyle.href)
|
||||
document.head.appendChild(themeStyle);
|
||||
}
|
||||
let path = window.location.pathname;
|
||||
</script>
|
||||
|
||||
@ -58,20 +36,12 @@
|
||||
<span>AquaNet</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if ANNOUNCEMENT}
|
||||
<div class="announcement">
|
||||
<strong>{t('navigation.notice')}</strong>: {ANNOUNCEMENT}
|
||||
</div>
|
||||
{/if}
|
||||
<a href="/home">{t('navigation.home').toLowerCase()}</a>
|
||||
<!-- <div on:click={() => alert("Coming soon™")} on:keydown={e => e.key === "Enter" && alert("Coming soon™")}
|
||||
role="button" tabindex="0">{t('navigation.maps').toLowerCase()}</div> -->
|
||||
<a href="/ranking">{t('navigation.rankings').toLowerCase()}</a>
|
||||
{#if playedMai}
|
||||
<a href="/pictures">photo</a>
|
||||
{/if}
|
||||
<a href="/home">home</a>
|
||||
<div on:click={() => alert("Coming soon™")} on:keydown={e => e.key === "Enter" && alert("Coming soon™")}
|
||||
role="button" tabindex="0">maps</div>
|
||||
<a href="/ranking">rankings</a>
|
||||
{#if me}
|
||||
<a href="/u/{me.username}" use:tooltip={t('navigation.profile')}>
|
||||
<a href="/u/{me.username}">
|
||||
<img alt="profile" class="pfp" use:pfp={me}/>
|
||||
</a>
|
||||
{/if}
|
||||
@ -85,8 +55,6 @@
|
||||
<Route path="/u/:username" component={UserHome} />
|
||||
<Route path="/u/:username/:game" component={UserHome} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/pictures" component={MaiPhoto} />
|
||||
<Route path="/transfer" component={Transfer} />
|
||||
</Router>
|
||||
|
||||
<style lang="sass">
|
||||
@ -107,25 +75,9 @@
|
||||
img
|
||||
width: 1.5rem
|
||||
height: 1.5rem
|
||||
border-radius: vars.$border-radius
|
||||
border-radius: 50%
|
||||
object-fit: cover
|
||||
|
||||
.announcement
|
||||
position: absolute
|
||||
left: 50%
|
||||
transform: translate(-50%, 0)
|
||||
top: 0
|
||||
width: 50%
|
||||
height: 100%
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-content: center
|
||||
z-index: -1
|
||||
background: linear-gradient(90deg, #6f0f0f00 0%, vars.$c-shadow 50%, #6f0f0f00 100%)
|
||||
font-size: 1.125em
|
||||
text-decoration: none !important
|
||||
color: inherit !important
|
||||
|
||||
.pfp
|
||||
width: 2rem
|
||||
height: 2rem
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
@use "sass:color"
|
||||
@use "vars"
|
||||
@import 'components/font/twemoji-flags.css'
|
||||
@import 'lxgw-wenkai-lite-webfont/style.css'
|
||||
|
||||
html
|
||||
@ -79,7 +78,6 @@ button
|
||||
opacity: 0.9
|
||||
cursor: pointer
|
||||
transition: vars.$transition
|
||||
white-space: nowrap
|
||||
|
||||
button:hover
|
||||
border-color: vars.$c-main
|
||||
@ -128,13 +126,11 @@ button.icon
|
||||
// --lv-color: 239, 242, 225
|
||||
--lv-text-clip: linear-gradient(110deg, #5ac42c, #5ccc22, #959f26, #cc7c23, #c93143, #8f4876, #4c3eb1, #3c3397)
|
||||
|
||||
.warning
|
||||
color: vars.$c-warning
|
||||
|
||||
.error
|
||||
color: vars.$c-error
|
||||
|
||||
input, textarea
|
||||
input
|
||||
border-radius: vars.$border-radius
|
||||
border: 1px solid transparent
|
||||
padding: 0.6em 1.2em
|
||||
@ -144,10 +140,6 @@ input, textarea
|
||||
background-color: vars.$ov-lighter
|
||||
transition: vars.$transition
|
||||
box-sizing: border-box
|
||||
resize: none
|
||||
|
||||
textarea
|
||||
height: 5em
|
||||
|
||||
// Dropdown
|
||||
select
|
||||
@ -190,9 +182,6 @@ input:focus, input:focus-visible
|
||||
border: 1px solid vars.$c-main
|
||||
outline: none
|
||||
|
||||
input.warning
|
||||
border: 1px solid vars.$c-warning
|
||||
|
||||
input.error
|
||||
border: 1px solid vars.$c-error
|
||||
|
||||
@ -318,9 +307,6 @@ main.content
|
||||
|
||||
max-width: 400px
|
||||
|
||||
.aqua-tooltip
|
||||
z-index: 900
|
||||
|
||||
.no-margin
|
||||
margin: 0
|
||||
|
||||
|
||||
@ -1,52 +1,87 @@
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition'
|
||||
import type { ConfirmProps } from "../libs/generalTypes";
|
||||
import { t } from "../libs/i18n"
|
||||
import Loading from './ui/Loading.svelte';
|
||||
import Error from './ui/Error.svelte';
|
||||
|
||||
// Props
|
||||
export let confirm: ConfirmProps | null = null
|
||||
export let error: string | null = null
|
||||
export let loading: boolean = false
|
||||
|
||||
function doConfirm(fn?: () => void) {
|
||||
confirm = null
|
||||
fn && fn()
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if confirm}
|
||||
<div class="overlay" transition:fade>
|
||||
<div>
|
||||
<h2>{confirm.title}</h2>
|
||||
<span>{confirm.message}</span>
|
||||
|
||||
<div class="actions">
|
||||
{#if confirm.cancel}
|
||||
<button on:click={() => doConfirm(confirm?.cancel)}>{t('action.cancel')}</button>
|
||||
{/if}
|
||||
<button on:click={() => doConfirm(confirm?.confirm)} class:error={confirm.dangerous}>{t('action.confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<Error {error}/>
|
||||
{/if}
|
||||
|
||||
{#if loading && !error}
|
||||
<Loading/>
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
.actions
|
||||
display: flex
|
||||
gap: 16px
|
||||
|
||||
button
|
||||
width: 100%
|
||||
</style>
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition'
|
||||
import type { ConfirmProps } from "../libs/generalTypes";
|
||||
import { DISCORD_INVITE } from "../libs/config";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { t } from "../libs/i18n"
|
||||
|
||||
// Props
|
||||
export let confirm: ConfirmProps | null = null
|
||||
export let error: string | null
|
||||
export let loading: boolean = false
|
||||
</script>
|
||||
|
||||
{#if confirm}
|
||||
<div class="overlay" transition:fade>
|
||||
<div>
|
||||
<h2>{confirm.title}</h2>
|
||||
<span>{confirm.message}</span>
|
||||
|
||||
<div class="actions">
|
||||
{#if confirm.cancel}
|
||||
<!-- Svelte LSP is very annoying here -->
|
||||
<button on:click={() => {
|
||||
confirm && confirm.cancel && confirm.cancel()
|
||||
|
||||
// Set to null
|
||||
confirm = null
|
||||
}}>{t('action.cancel')}</button>
|
||||
{/if}
|
||||
<button on:click={() => confirm && confirm.confirm()} class:error={confirm.dangerous}>{t('action.confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="overlay" transition:fade>
|
||||
<div>
|
||||
<h2 class="error">{t('status.error')}</h2>
|
||||
<span>{t('status.error.hint')}<a href={DISCORD_INVITE}>{t('status.error.hint.link')}</a></span>
|
||||
<span>{t('status.detail', { detail: error })}</span>
|
||||
|
||||
<div class="actions">
|
||||
<button on:click={() => location.reload()} class="error">
|
||||
{t('action.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading && !error}
|
||||
<div class="overlay loading" transition:fade>
|
||||
<Icon class="icon" icon="svg-spinners:pulse-2"/>
|
||||
<span><span>LOADING</span></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
.actions
|
||||
display: flex
|
||||
gap: 16px
|
||||
|
||||
button
|
||||
width: 100%
|
||||
|
||||
.loading.overlay
|
||||
font-size: 28rem
|
||||
|
||||
:global(.icon)
|
||||
opacity: 0.5
|
||||
|
||||
> span
|
||||
position: absolute
|
||||
inset: 0
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
background: transparent
|
||||
|
||||
letter-spacing: 20px
|
||||
margin-left: 20px
|
||||
|
||||
font-size: 1.5rem
|
||||
</style>
|
||||
|
||||
@ -60,7 +60,7 @@
|
||||
|
||||
.tooltip
|
||||
position: absolute
|
||||
z-index: 900
|
||||
z-index: 1000
|
||||
background: white
|
||||
padding: 10px 16px
|
||||
border-radius: vars.$border-radius
|
||||
|
||||
Binary file not shown.
@ -1,11 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'TwemojiCountryFlags';
|
||||
src: url('./TwemojiCountryFlags.woff2') format('woff2');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.country {
|
||||
font-family: TwemojiCountryFlags,"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif;
|
||||
font-size: 2em;
|
||||
}
|
||||
@ -1,211 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { fade, slide } from "svelte/transition";
|
||||
import { CHU3_MATCHINGS } from "../../libs/config.js";
|
||||
import type { ChusanMatchingOption, GameOption } from "../../libs/generalTypes.js";
|
||||
import { t, ts } from "../../libs/i18n.js";
|
||||
import { DATA, SETTING } from "../../libs/sdk.js";
|
||||
import StatusOverlays from "../StatusOverlays.svelte";
|
||||
import GameSettingFields from "./GameSettingFields.svelte";
|
||||
|
||||
let custom = false
|
||||
let overlay = false
|
||||
let loading = false
|
||||
let error = ""
|
||||
|
||||
let changed: string[] = [];
|
||||
let symbols: Record<number, number> = {};
|
||||
let allItems: Record<string, Record<string, { name: string }>> = {}
|
||||
let submitting: string | undefined | null;
|
||||
|
||||
let existingUrl = "";
|
||||
SETTING.get().then(s => {
|
||||
existingUrl = s.filter(it => it.key === 'chusanMatchingServer')[0]?.value
|
||||
|
||||
if (existingUrl && !CHU3_MATCHINGS.some(it => it.matching === existingUrl)) {
|
||||
custom = true
|
||||
}
|
||||
|
||||
const symbolKey = "chusanSymbolChat"
|
||||
s.forEach(opt => {
|
||||
if (opt.key.substring(0, symbolKey.length) == symbolKey && opt.value)
|
||||
symbols[parseInt(opt.key.substring(symbolKey.length))] = opt.value;
|
||||
})
|
||||
})
|
||||
|
||||
async function fetchSymbolData() {
|
||||
allItems = await DATA.allItems('chu3').catch(_ => {
|
||||
loading = false
|
||||
error = t("userbox.error.nodata")
|
||||
}) as typeof allItems
|
||||
}
|
||||
|
||||
async function submitSymbol(id: number) {
|
||||
if (submitting) return false
|
||||
const field = `chusanSymbolChat${id + 1}`;
|
||||
submitting = field
|
||||
|
||||
await SETTING.set(field, symbols[id + 1]).catch(e => error = e.message).finally(() => submitting = null);
|
||||
changed = changed.filter(v => v != `chusanSymbolChat${id}`)
|
||||
return true
|
||||
}
|
||||
|
||||
// Click on "Custom" option"
|
||||
function clickCustom() {
|
||||
custom = true
|
||||
overlay = false
|
||||
}
|
||||
|
||||
// Click on a matching option, set the reflector and matching server
|
||||
function clickOption(opt: ChusanMatchingOption) {
|
||||
Promise.all([
|
||||
SETTING.set('chusanMatchingReflector', opt.reflector),
|
||||
SETTING.set('chusanMatchingServer', opt.matching),
|
||||
]).then(() => {
|
||||
overlay = false
|
||||
custom = false
|
||||
existingUrl = opt.matching
|
||||
}).catch(e => error = e.message)
|
||||
}
|
||||
</script>
|
||||
|
||||
<StatusOverlays {error} {loading}/>
|
||||
|
||||
<div class="matching">
|
||||
<h2>{t("userbox.header.matching")}</h2>
|
||||
<p class="notice">{t("settings.cabNotice")}</p>
|
||||
|
||||
<div class="matching-selector">
|
||||
<button on:click={_ => overlay = true}>{t('userbox.matching.select')}</button>
|
||||
</div>
|
||||
|
||||
{#if custom}
|
||||
<GameSettingFields game="chu3-matching"/>
|
||||
{/if}
|
||||
|
||||
<h2>{t("userbox.header.matching.symbolChat")}</h2>
|
||||
{#await fetchSymbolData() then}
|
||||
{#each {length: 4}, i}
|
||||
<div class="field">
|
||||
<label for={`chusanSymbolChat${i}`}>{ts(`userbox.matching.symbolChat`) + ` #${i + 1}`}</label>
|
||||
<div>
|
||||
<select bind:value={symbols[i + 1]} id={`chusanSymbolChat${i}`} on:change={() => {changed = [...changed, `chusanSymbolChat${i}`];}}>
|
||||
<option value={null}>{ts(`userbox.matching.symbolChat.default`)}</option>
|
||||
{#each Object.entries(allItems.symbolChat).filter((f) => parseInt(f[0]) !== 0) as [id, option]}
|
||||
<option value={parseInt(id)}>{option?.name || `(unknown ${id})`}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if changed.includes(`chusanSymbolChat${i}`)}
|
||||
<button transition:slide={{axis: "x"}} disabled={!!submitting} on:click={() => submitSymbol(i)}>
|
||||
{t("settings.profile.save")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
{#if overlay}
|
||||
<div class="overlay" transition:fade>
|
||||
<div>
|
||||
<div>
|
||||
<h2>{t('userbox.header.matching')}</h2>
|
||||
<p>{t('userbox.matching.select.sub')}</p>
|
||||
</div>
|
||||
<div class="options">
|
||||
<!-- Selectable options -->
|
||||
{#each CHU3_MATCHINGS as option}
|
||||
<div class="clickable option" on:click={() => clickOption(option)}
|
||||
role="button" tabindex="0" on:keypress={e => e.key === 'Enter' && clickOption(option)}
|
||||
class:selected={!custom && existingUrl === option.matching}>
|
||||
|
||||
<span class="name">{option.name}</span>
|
||||
<div class="links">
|
||||
<a href={option.ui} target="_blank" rel="noopener">{t('userbox.matching.option.ui')}</a> /
|
||||
<a href={option.guide} target="_blank" rel="noopener">{t('userbox.matching.option.guide')}</a>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="coop">
|
||||
<span>{t('userbox.matching.option.collab')}</span>
|
||||
<div>
|
||||
{#each option.coop as coop}
|
||||
<span>{coop}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Placeholder option for "Custom" -->
|
||||
<div class="clickable option" on:click={clickCustom}
|
||||
role="button" tabindex="0" on:keypress={e => e.key === 'Enter' && clickCustom()}
|
||||
class:selected={custom}>
|
||||
|
||||
<span class="name">{t('userbox.matching.custom.name')}</span>
|
||||
<p class="notice custom">{t('userbox.matching.custom.sub')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
@use "../../vars"
|
||||
|
||||
.matching
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 12px
|
||||
|
||||
h2
|
||||
margin-bottom: 0
|
||||
|
||||
p.notice
|
||||
opacity: 0.6
|
||||
margin: 0
|
||||
|
||||
&.custom
|
||||
font-size: 0.9rem
|
||||
|
||||
.options
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 1rem
|
||||
|
||||
.option
|
||||
flex: 1
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
|
||||
border-radius: vars.$border-radius
|
||||
background: vars.$ov-light
|
||||
padding: 1rem
|
||||
min-width: 150px
|
||||
|
||||
&.selected
|
||||
border: 1px solid vars.$c-main
|
||||
|
||||
.divider
|
||||
width: 100%
|
||||
height: 0.5px
|
||||
background: white
|
||||
opacity: 0.2
|
||||
margin: 0.8rem 0
|
||||
|
||||
.name
|
||||
font-size: 1.1rem
|
||||
font-weight: bold
|
||||
|
||||
.coop
|
||||
text-align: center
|
||||
|
||||
div
|
||||
display: flex
|
||||
flex-direction: column
|
||||
font-size: 0.9rem
|
||||
opacity: 0.6
|
||||
|
||||
</style>
|
||||
@ -6,23 +6,15 @@
|
||||
type UserBox,
|
||||
type UserItem,
|
||||
} from "../../libs/generalTypes";
|
||||
import { DATA, USER, USERBOX, GAME } from "../../libs/sdk";
|
||||
import { DATA, USER, USERBOX } from "../../libs/sdk";
|
||||
import { t, ts } from "../../libs/i18n";
|
||||
import { FADE_IN, FADE_OUT, USERBOX_DEFAULT_URL } from "../../libs/config";
|
||||
import { DATA_HOST, FADE_IN, FADE_OUT, HAS_USERBOX_ASSETS } from "../../libs/config";
|
||||
import { fade, slide } from "svelte/transition";
|
||||
import StatusOverlays from "../StatusOverlays.svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import GameSettingFields from "./GameSettingFields.svelte";
|
||||
|
||||
import { userboxFileProcess, ddsDB, initializeDb } from "../../libs/userbox/userbox"
|
||||
|
||||
import ChuniPenguinComponent from "./userbox/ChuniPenguin.svelte"
|
||||
import ChuniUserplateComponent from "./userbox/ChuniUserplate.svelte";
|
||||
|
||||
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
|
||||
import { DDS } from "../../libs/userbox/dds";
|
||||
import ChuniMatchingSettings from "./ChuniMatchingSettings.svelte";
|
||||
import InputField from "../ui/InputField.svelte";
|
||||
import { filter } from "d3";
|
||||
import { coverNotFound } from "../../libs/ui";
|
||||
|
||||
let user: AquaNetUser
|
||||
let [loading, error, submitting, preview] = [true, "", "", ""]
|
||||
@ -31,13 +23,12 @@
|
||||
// Available (unlocked) options for each kind of item
|
||||
// In allItems: 'namePlate', 'frame', 'trophy', 'mapIcon', 'systemVoice', 'avatarAccessory'
|
||||
let allItems: Record<string, Record<string, { name: string }>> = {}
|
||||
let iKinds = { namePlate: 1, frame: 2, trophy: 3, trophySub1: 4, trophySub2: 5, mapIcon: 8, systemVoice: 9, avatarAccessory: 11 }
|
||||
let iKinds = { namePlate: 1, frame: 2, trophy: 3, mapIcon: 8, systemVoice: 9, avatarAccessory: 11 }
|
||||
// In userbox: 'nameplateId', 'frameId', 'trophyId', 'mapIconId', 'voiceId', 'avatar{Wear/Head/Face/Skin/Item/Front/Back}'
|
||||
let userbox: UserBox
|
||||
let avatarKinds = ['Wear', 'Head', 'Face', 'Skin', 'Item', 'Front', 'Back'] as const
|
||||
let avatarKinds = ['Wear', 'Head', 'Face', 'Skin', 'Item', 'Front', 'Back']
|
||||
// iKey should match allItems keys, and ubKey should match userbox keys
|
||||
let userItems: { iKey: string, ubKey: keyof UserBox, items: UserItem[] }[] = []
|
||||
let userNameField: any
|
||||
|
||||
// Submit changes
|
||||
function submit(field: keyof UserBox) {
|
||||
@ -59,14 +50,9 @@
|
||||
})
|
||||
if (!profile) return
|
||||
userbox = profile.user
|
||||
userNameField = {key: "gameUsername", value: userbox.userName, type: "String"}
|
||||
userItems = Object.entries(iKinds).flatMap(([iKey, iKind]) => {
|
||||
if (iKey != 'avatarAccessory') {
|
||||
let ubKey = `${iKey}Id`
|
||||
if (iKey.slice('trophy'.length, 'trophy'.length + 3) == "Sub") {
|
||||
ubKey = `trophyIdSub${iKey.slice('trophySub'.length, 'trophySub'.length + 1)}`;
|
||||
iKey = `trophy`;
|
||||
}
|
||||
if (ubKey == 'namePlateId') ubKey = 'nameplateId'
|
||||
if (ubKey == 'systemVoiceId') ubKey = 'voiceId'
|
||||
return [{ iKey, ubKey: ubKey as keyof UserBox,
|
||||
@ -97,247 +83,49 @@
|
||||
user = u
|
||||
return fetchData()
|
||||
}).catch((e) => { loading = false; error = e.message });
|
||||
|
||||
function exportData() {
|
||||
submitting = "export"
|
||||
GAME.export('chu3')
|
||||
.then(data => download(JSON.stringify(data), `AquaDX_chu3_export_${userbox.userName}.json`))
|
||||
.catch(e => error = e.message)
|
||||
.finally(() => submitting = "")
|
||||
}
|
||||
|
||||
function download(data: string, filename: string) {
|
||||
const blob = new Blob([data]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
}
|
||||
|
||||
function g(v: string) {
|
||||
if (v != ("\x63\x68\x75\x6E\x69\x74\x68\x6D ").repeat(3).trim()) return;
|
||||
const t = v.substring(5, 6) + v.substring(1, 2) + "eme";
|
||||
if (!localStorage.getItem(t)) {
|
||||
localStorage.setItem(t, v.substring(0, 1) + "\x6E");
|
||||
} else
|
||||
localStorage.removeItem(t);
|
||||
setTimeout(location.reload, 1000); // ?
|
||||
}
|
||||
|
||||
let DDSreader: DDS | undefined;
|
||||
|
||||
let USERBOX_PROGRESS = 0;
|
||||
let USERBOX_SETUP_RUN = false;
|
||||
let USERBOX_SETUP_MODE = false;
|
||||
let USERBOX_SETUP_TEXT = t("userbox.new.setup");
|
||||
|
||||
let USERBOX_ENABLED = useLocalStorage("userboxNew", false);
|
||||
let USERBOX_PROFILE_ENABLED = useLocalStorage("userboxNewProfile", false);
|
||||
let USERBOX_INSTALLED = false;
|
||||
let USERBOX_SUPPORT = "webkitGetAsEntry" in DataTransferItem.prototype;
|
||||
|
||||
type OnlyNumberPropsOf<T extends Record<string, any>> = {[Prop in keyof T as (T[Prop] extends number ? Prop : never)]: T[Prop]}
|
||||
let userboxSelected: keyof OnlyNumberPropsOf<UserBox> = "avatarWear";
|
||||
const userboxNewOptions = ["systemVoice", "frame", "trophy", "mapIcon"]
|
||||
|
||||
async function userboxSafeDrop(event: Event & { currentTarget: EventTarget & HTMLInputElement; }) {
|
||||
if (!event.target) return null;
|
||||
let input = event.target as HTMLInputElement;
|
||||
let folder = input.webkitEntries[0];
|
||||
error = await userboxFileProcess(folder, (progress: number, progressString: string) => {
|
||||
USERBOX_SETUP_TEXT = progressString;
|
||||
USERBOX_PROGRESS = progress;
|
||||
}) ?? "";
|
||||
}
|
||||
|
||||
let USERBOX_URL_STATE = useLocalStorage("userboxURL", USERBOX_DEFAULT_URL);
|
||||
function userboxHandleInput(baseURL: string, isSetByServer: boolean = false) {
|
||||
if (baseURL != "")
|
||||
try {
|
||||
// validate url
|
||||
new URL(baseURL, location.href);
|
||||
} catch(err) {
|
||||
if (isSetByServer)
|
||||
return;
|
||||
return error = t("userbox.new.error.invalidUrl")
|
||||
}
|
||||
USERBOX_URL_STATE.value = baseURL;
|
||||
USERBOX_ENABLED.value = true;
|
||||
USERBOX_PROFILE_ENABLED.value = true;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
if (USERBOX_DEFAULT_URL && !USERBOX_URL_STATE.value)
|
||||
userboxHandleInput(USERBOX_DEFAULT_URL, true);
|
||||
|
||||
indexedDB.databases().then(async (dbi) => {
|
||||
let databaseExists = dbi.some(db => db.name == "userboxChusanDDS");
|
||||
if (USERBOX_URL_STATE.value && databaseExists) {
|
||||
indexedDB.deleteDatabase("userboxChusanDDS")
|
||||
}
|
||||
if (databaseExists) {
|
||||
await initializeDb();
|
||||
}
|
||||
if (databaseExists || USERBOX_URL_STATE.value) {
|
||||
DDSreader = new DDS(ddsDB);
|
||||
USERBOX_INSTALLED = databaseExists || USERBOX_URL_STATE.value != "";
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<StatusOverlays {error} loading={loading || !!submitting} />
|
||||
{#if !loading && !error}
|
||||
<div out:fade={FADE_OUT} in:fade={FADE_IN}>
|
||||
<h2>{t("userbox.header.general")}</h2>
|
||||
<div class="general-options">
|
||||
<GameSettingFields game="chu3"/>
|
||||
|
||||
<InputField bind:field={userNameField}
|
||||
callback={() => USERBOX.setUserBox({ field: "userName", value: userNameField.value })}/>
|
||||
</div>
|
||||
<GameSettingFields game="chu3"/>
|
||||
<h2>{t("userbox.header.userbox")}</h2>
|
||||
{#if !USERBOX_ENABLED.value || !USERBOX_INSTALLED}
|
||||
<div class="fields">
|
||||
{#each userItems as { iKey, ubKey, items }, i}
|
||||
<div class="field">
|
||||
<label for={ubKey}>{ts(`userbox.${ubKey}`)}</label>
|
||||
<div>
|
||||
<select bind:value={userbox[ubKey]} id={ubKey} on:change={() => changed = [...changed, ubKey]}>
|
||||
{#each items as option}
|
||||
<option value={option.itemId}>{allItems[iKey][option.itemId]?.name || `(unknown ${option.itemId})`}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if changed.includes(ubKey)}
|
||||
<button transition:slide={{axis: "x"}} on:click={() => submit(ubKey)} disabled={!!submitting}>
|
||||
{t("settings.profile.save")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="chuni-userbox-container">
|
||||
<ChuniUserplateComponent chuniIsUserbox={true} on:click={() => userboxSelected = "nameplateId"} chuniCharacter={userbox.characterId} chuniLevel={userbox.level.toString()} chuniRating={userbox.playerRating / 100}
|
||||
chuniNameplate={userbox.nameplateId} chuniName={userbox.userName} chuniTrophyName={allItems.trophy[userbox.trophyId].name}></ChuniUserplateComponent>
|
||||
<ChuniPenguinComponent chuniWear={userbox.avatarWear} chuniHead={userbox.avatarHead} chuniBack={userbox.avatarBack}
|
||||
chuniFront={userbox.avatarFront} chuniFace={userbox.avatarFace} chuniItem={userbox.avatarItem}
|
||||
chuniSkin={userbox.avatarSkin}></ChuniPenguinComponent>
|
||||
</div>
|
||||
<div class="chuni-userbox-row">
|
||||
{#each avatarKinds as avatarKind}
|
||||
{#await DDSreader?.getFile(`avatarAccessoryThumbnail:${userbox[`avatar${avatarKind}`].toString().padStart(8, "0")}`) then imageURL}
|
||||
<button on:click={() => userboxSelected = `avatar${avatarKind}`}>
|
||||
<img src={imageURL} class={userboxSelected == `avatar${avatarKind}` ? "focused" : ""} alt={allItems.avatarAccessory[userbox[`avatar${avatarKind}`]].name} title={allItems.avatarAccessory[userbox[`avatar${avatarKind}`]].name}>
|
||||
</button>
|
||||
{/await}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="chuni-userbox">
|
||||
{#if userboxSelected == "nameplateId"}
|
||||
{#each userItems.find(f => f.ubKey == "nameplateId")?.items ?? [] as item}
|
||||
{#await DDSreader?.getFile(`nameplate:${item.itemId.toString().padStart(8, "0")}`) then imageURL}
|
||||
<button class="nameplate" on:click={() => {userbox[userboxSelected] = item.itemId; submit(userboxSelected)}}>
|
||||
<img src={imageURL} alt={allItems.namePlate[item.itemId].name} title={allItems.namePlate[item.itemId].name}>
|
||||
<div class="fields">
|
||||
{#each userItems as { iKey, ubKey, items }, i}
|
||||
<div class="field">
|
||||
<label for={ubKey}>{ts(`userbox.${ubKey}`)}</label>
|
||||
<div>
|
||||
<select bind:value={userbox[ubKey]} id={ubKey} on:change={() => changed = [...changed, ubKey]}>
|
||||
{#each items as option}
|
||||
<option value={option.itemId}>{allItems[iKey][option.itemId]?.name || `(unknown ${option.itemId})`}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if changed.includes(ubKey)}
|
||||
<button transition:slide={{axis: "x"}} on:click={() => submit(ubKey)} disabled={!!submitting}>
|
||||
{t("settings.profile.save")}
|
||||
</button>
|
||||
{/await}
|
||||
{/each}
|
||||
{:else}
|
||||
{#each userItems.find(f => f.ubKey == userboxSelected)?.items ?? [] as item}
|
||||
{#await DDSreader?.getFile(`avatarAccessoryThumbnail:${item.itemId.toString().padStart(8, "0")}`) then imageURL}
|
||||
<button on:click={() => {userbox[userboxSelected] = item.itemId; submit(userboxSelected)}}>
|
||||
<img src={imageURL} alt={allItems.avatarAccessory[item.itemId].name} title={allItems.avatarAccessory[item.itemId].name}>
|
||||
</button>
|
||||
{/await}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="fields">
|
||||
{#each userItems.filter(i => userboxNewOptions.includes(i.iKey)) as { iKey, ubKey, items }, i}
|
||||
<div class="field">
|
||||
<label for={ubKey}>{ts(`userbox.${ubKey}`)}</label>
|
||||
<div>
|
||||
<select bind:value={userbox[ubKey]} id={ubKey} on:change={() => changed = [...changed, ubKey]}>
|
||||
{#each items as option}
|
||||
<option value={option.itemId}>{allItems[iKey][option.itemId]?.name || `(unknown ${option.itemId})`}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if changed.includes(ubKey)}
|
||||
<button transition:slide={{axis: "x"}} on:click={() => submit(ubKey)} disabled={!!submitting}>
|
||||
{t("settings.profile.save")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if USERBOX_INSTALLED}
|
||||
<!-- god this is a mess but idgaf atp -->
|
||||
<div class="field boolean" style:margin-top="1em">
|
||||
<input type="checkbox" bind:checked={USERBOX_ENABLED.value} id="newUserbox">
|
||||
<label for="newUserbox">
|
||||
<span class="name">{t("userbox.new.activate")}</span>
|
||||
<span class="desc">{t(`userbox.new.activate_desc`)}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field boolean" style:margin-top="1em">
|
||||
<input type="checkbox" bind:checked={USERBOX_PROFILE_ENABLED.value} id="newUserboxProfile">
|
||||
<label for="newUserboxProfile">
|
||||
<span class="name">{t("userbox.new.activate_profile")}</span>
|
||||
<span class="desc">{t(`userbox.new.activate_profile_desc`)}</span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
{#if USERBOX_SUPPORT && !USERBOX_DEFAULT_URL}
|
||||
<p>
|
||||
<button on:click={() => USERBOX_SETUP_RUN = !USERBOX_SETUP_RUN}>{t(!USERBOX_INSTALLED ? `userbox.new.activate_first` : `userbox.new.activate_update`)}</button>
|
||||
</p>
|
||||
{/if}
|
||||
<ChuniMatchingSettings/><br>
|
||||
<button class="exportButton" on:click={exportData}>
|
||||
<Icon icon="bxs:file-export"/>
|
||||
{t('settings.export')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if USERBOX_SETUP_RUN && !error}
|
||||
<div class="overlay" transition:fade>
|
||||
<div>
|
||||
<h2>{t('userbox.new.name')}</h2>
|
||||
<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">
|
||||
{:else}
|
||||
{#if USERBOX_PROGRESS != 0}
|
||||
<div class="progress">
|
||||
<div class="progress-bar" style="width: {USERBOX_PROGRESS}%"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="notice add-margin">
|
||||
{t('userbox.new.setup.notice')}
|
||||
</p>
|
||||
<button class="drop-btn">
|
||||
<input type="file" on:input={userboxSafeDrop} on:click={e => e.preventDefault()}>
|
||||
{t('userbox.new.drop')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if USERBOX_PROGRESS == 0}
|
||||
<button on:click={() => USERBOX_SETUP_RUN = false}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button on:click={() => USERBOX_SETUP_MODE = !USERBOX_SETUP_MODE}>
|
||||
{t(USERBOX_SETUP_MODE ? 'userbox.new.switch.to_drop' : 'userbox.new.switch.to_url')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if HAS_USERBOX_ASSETS}
|
||||
<h2>{t("userbox.header.preview")}</h2>
|
||||
<p class="notice">{t("userbox.preview.notice")}</p>
|
||||
<input bind:value={preview} placeholder={t("userbox.preview.url")}/>
|
||||
{#if preview}
|
||||
<div class="preview">
|
||||
{#each userItems.filter(v => v.iKey != 'trophy' && v.iKey != 'systemVoice') as { iKey, ubKey, items }, i}
|
||||
<div>
|
||||
<span>{ts(`userbox.${ubKey}`)}</span>
|
||||
<img src={`${preview}/${iKey}/${userbox[ubKey].toString().padStart(8, '0')}.png`} alt="" on:error={coverNotFound} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
@ -346,52 +134,13 @@
|
||||
input
|
||||
width: 100%
|
||||
|
||||
|
||||
h2
|
||||
margin-bottom: 0.5rem
|
||||
|
||||
.general-options
|
||||
display: flex
|
||||
flex-direction: column
|
||||
flex-wrap: wrap
|
||||
gap: 12px
|
||||
|
||||
p.notice
|
||||
opacity: 0.6
|
||||
margin-top: 0
|
||||
|
||||
.progress
|
||||
width: 100%
|
||||
height: 10px
|
||||
box-shadow: 0 0 1px 1px vars.$ov-lighter
|
||||
border-radius: 25px
|
||||
margin-bottom: 15px
|
||||
overflow: hidden
|
||||
|
||||
.progress-bar
|
||||
background: #b3c6ff
|
||||
height: 100%
|
||||
border-radius: 25px
|
||||
|
||||
|
||||
.add-margin, .drop-btn
|
||||
margin-bottom: 1em
|
||||
|
||||
.drop-btn
|
||||
position: relative
|
||||
width: 100%
|
||||
aspect-ratio: 3
|
||||
background: transparent
|
||||
box-shadow: 0 0 1px 1px vars.$ov-lighter
|
||||
|
||||
> input
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
opacity: 0
|
||||
|
||||
.preview
|
||||
margin-top: 32px
|
||||
display: flex
|
||||
@ -453,84 +202,4 @@ p.notice
|
||||
|
||||
> select
|
||||
flex: 1
|
||||
|
||||
|
||||
.field.boolean
|
||||
display: flex
|
||||
flex-direction: row
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
width: auto
|
||||
|
||||
input
|
||||
width: auto
|
||||
aspect-ratio: 1 / 1
|
||||
|
||||
label
|
||||
display: flex
|
||||
flex-direction: column
|
||||
max-width: max-content
|
||||
|
||||
.desc
|
||||
opacity: 0.6
|
||||
|
||||
/* AquaBox */
|
||||
|
||||
.chuni-userbox-row
|
||||
width: 100%
|
||||
display: flex
|
||||
|
||||
button
|
||||
padding: 0
|
||||
margin: 0
|
||||
width: 100%
|
||||
flex: 0 1 100%
|
||||
background: none
|
||||
aspect-ratio: 1
|
||||
|
||||
img
|
||||
width: 100%
|
||||
filter: brightness(50%)
|
||||
|
||||
&.focused
|
||||
filter: brightness(75%)
|
||||
|
||||
.chuni-userbox
|
||||
width: calc(100% - 20px)
|
||||
height: 350px
|
||||
|
||||
display: flex
|
||||
flex-direction: row
|
||||
flex-wrap: wrap
|
||||
padding: 10px
|
||||
background: vars.$c-bg
|
||||
border-radius: 16px
|
||||
overflow-y: auto
|
||||
margin-bottom: 15px
|
||||
justify-content: center
|
||||
|
||||
button
|
||||
padding: 0
|
||||
margin: 0
|
||||
width: 20%
|
||||
align-self: flex-start
|
||||
background: none
|
||||
aspect-ratio: 1
|
||||
|
||||
img
|
||||
width: 100%
|
||||
|
||||
&.nameplate
|
||||
width: 50%
|
||||
aspect-ratio: unset
|
||||
border: none
|
||||
|
||||
.chuni-userbox-container
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
@media (max-width: 1000px)
|
||||
.chuni-userbox-container
|
||||
flex-wrap: wrap
|
||||
</style>
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
import { ts } from "../../libs/i18n";
|
||||
import StatusOverlays from "../StatusOverlays.svelte";
|
||||
import InputWithButton from "../ui/InputWithButton.svelte";
|
||||
import InputField from "../ui/InputField.svelte";
|
||||
|
||||
export let game: string;
|
||||
let gameFields: GameOption[] = []
|
||||
@ -27,7 +26,23 @@
|
||||
|
||||
<div class="fields">
|
||||
{#each gameFields as field}
|
||||
<InputField field={field} callback={() => submitGameOption(field.key, field.value)}/>
|
||||
<div class="field {field.type.toLowerCase()}">
|
||||
{#if field.type === "Boolean"}
|
||||
<input id={field.key} type="checkbox" bind:checked={field.value}
|
||||
on:change={() => submitGameOption(field.key, field.value)}/>
|
||||
<label for={field.key}>
|
||||
<span class="name">{ts(`settings.fields.${field.key}.name`)}</span>
|
||||
<span class="desc">{ts(`settings.fields.${field.key}.desc`)}</span>
|
||||
</label>
|
||||
{/if}
|
||||
{#if field.type === "String"}
|
||||
<label for={field.key}>
|
||||
<span class="name">{ts(`settings.fields.${field.key}.name`)}</span>
|
||||
<span class="desc">{ts(`settings.fields.${field.key}.desc`)}</span>
|
||||
</label>
|
||||
<InputWithButton bind:field={field} callback={() => submitGameOption(field.key, field.value)}/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@ -38,4 +53,24 @@
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 12px
|
||||
|
||||
.field.string
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
gap: 0.5rem
|
||||
|
||||
.field.boolean
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
|
||||
.field
|
||||
display: flex
|
||||
|
||||
label
|
||||
display: flex
|
||||
flex-direction: column
|
||||
max-width: max-content
|
||||
|
||||
.desc
|
||||
opacity: 0.6
|
||||
</style>
|
||||
|
||||
@ -2,16 +2,13 @@
|
||||
import { fade } from "svelte/transition";
|
||||
import { FADE_IN, FADE_OUT } from "../../libs/config";
|
||||
import GameSettingFields from "./GameSettingFields.svelte";
|
||||
import { t, ts } from "../../libs/i18n";
|
||||
import { ts } from "../../libs/i18n";
|
||||
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
|
||||
|
||||
const rounding = useLocalStorage("rounding", true);
|
||||
</script>
|
||||
|
||||
<div out:fade={FADE_OUT} in:fade={FADE_IN} class="fields">
|
||||
<blockquote>
|
||||
{ts("settings.gameNotice")}
|
||||
</blockquote>
|
||||
<GameSettingFields game="general"/>
|
||||
<div class="field">
|
||||
<div class="bool">
|
||||
@ -25,8 +22,6 @@
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../../vars"
|
||||
|
||||
.fields
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
@ -5,8 +5,6 @@
|
||||
import Icon from "@iconify/svelte";
|
||||
import StatusOverlays from "../StatusOverlays.svelte";
|
||||
import { GAME } from "../../libs/sdk";
|
||||
import GameSettingFields from "./GameSettingFields.svelte";
|
||||
import { download } from "../../libs/ui";
|
||||
|
||||
const profileFields = [
|
||||
['name', t('settings.mai2.name')],
|
||||
@ -43,6 +41,15 @@
|
||||
.catch(e => error = e.message)
|
||||
.finally(() => submitting = "")
|
||||
}
|
||||
|
||||
function download(data: string, filename: string) {
|
||||
const blob = new Blob([data]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fields" out:fade={FADE_OUT} in:fade={FADE_IN}>
|
||||
@ -65,7 +72,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<GameSettingFields game="mai2"/>
|
||||
<button class="exportButton" on:click={exportData}>
|
||||
<Icon icon="bxs:file-export"/>
|
||||
{t('settings.export')}
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
<script>
|
||||
import { fade } from "svelte/transition";
|
||||
import { FADE_IN, FADE_OUT } from "../../libs/config";
|
||||
import GameSettingFields from "./GameSettingFields.svelte";
|
||||
</script>
|
||||
|
||||
<div out:fade={FADE_OUT} in:fade={FADE_IN}>
|
||||
<GameSettingFields game="ongeki"/>
|
||||
</div>
|
||||
@ -1,227 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { removeImg } from "../../../libs/ui";
|
||||
import { DDS } from "../../../libs/userbox/dds"
|
||||
import { ddsDB } from "../../../libs/userbox/userbox"
|
||||
|
||||
const DDSreader = new DDS(ddsDB);
|
||||
|
||||
export var chuniWear = 1100001;
|
||||
export var chuniHead = 1200001;
|
||||
export var chuniFace = 1300001;
|
||||
export var chuniSkin = 1400001;
|
||||
export var chuniItem = 1500001;
|
||||
export var chuniFront = 1600001;
|
||||
export var chuniBack = 1700001;
|
||||
export var classPassthrough: string = ``;
|
||||
</script>
|
||||
<div class="chuni-penguin {classPassthrough}">
|
||||
<div class="chuni-penguin-body">
|
||||
<!-- Body -->
|
||||
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniSkin.toString().padStart(8, "0")}`, 0, 0, 256, 400, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-skin" src={imageURL} alt="Body" on:error={removeImg}>
|
||||
{/await}
|
||||
|
||||
<!-- Face -->
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_face_00.dds", 0, 0, 225, 150, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-eyes chuni-penguin-accessory" src={imageURL} alt="Eyes" on:error={removeImg}>
|
||||
{/await}
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 86, 103, 96, 43, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-beak chuni-penguin-accessory" src={imageURL} alt="Beak" on:error={removeImg}>
|
||||
{/await}
|
||||
|
||||
{#if chuniItem != 1500001}
|
||||
<!-- Arms (straight) -->
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 0, 0, 85, 160, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-arm-left chuni-penguin-arm" src={imageURL} alt="Left Arm" on:error={removeImg}/>
|
||||
<div class="chuni-penguin-arm-left chuni-penguin-arm-type-1 chuni-penguin-arm">
|
||||
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniItem.toString().padStart(8, "0")}`, 0, 0, 200, 544, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-item chuni-penguin-accessory chuni-penguin-item-left" src={imageURL} alt="Item" on:error={removeImg}>
|
||||
{/await}
|
||||
</div>
|
||||
{/await}
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 0, 0, 85, 160, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-arm-right chuni-penguin-arm" src={imageURL} alt="Right Arm" on:error={removeImg}>
|
||||
<div class="chuni-penguin-arm-right chuni-penguin-arm-type-1 chuni-penguin-arm">
|
||||
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniItem.toString().padStart(8, "0")}`, 200, 0, 200, 544, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-item chuni-penguin-accessory chuni-penguin-item-right" src={imageURL} alt="Item" on:error={removeImg}>
|
||||
{/await}
|
||||
</div>
|
||||
{/await}
|
||||
{:else}
|
||||
<!-- Arms (bent) -->
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 80, 0, 110, 100, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-arm-left chuni-penguin-arm chuni-penguin-arm-type-2" src={imageURL} alt="Left Arm" on:error={removeImg}>
|
||||
{/await}
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 80, 0, 110, 100, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-arm-right chuni-penguin-arm chuni-penguin-arm-type-2" src={imageURL} alt="Right Arm" on:error={removeImg}>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<!-- Wear -->
|
||||
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniWear.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01100001`) then imageURL}
|
||||
<img class="chuni-penguin-wear chuni-penguin-accessory" src={imageURL} alt="Wear" on:error={removeImg}>
|
||||
{/await}
|
||||
|
||||
<!-- Head -->
|
||||
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniHead.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01200001`) then imageURL}
|
||||
<img class="chuni-penguin-head chuni-penguin-accessory" src={imageURL} alt="Head" on:error={removeImg}>
|
||||
{/await}
|
||||
{#if chuniHead == 1200001}
|
||||
<!-- If wearing original hat, add the feather -->
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 5, 160, 100, 150, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-head-3 chuni-penguin-accessory" src={imageURL} alt="Head3" on:error={removeImg}>
|
||||
{/await}
|
||||
{/if}
|
||||
<!-- Oops, I realized just now that the thing on it's forehead applies to all hats. My mistake! -->
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 105, 153, 56, 58, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-head-2 chuni-penguin-accessory" src={imageURL} alt="Head2" on:error={removeImg}>
|
||||
{/await}
|
||||
|
||||
<!-- Face (Accessory) -->
|
||||
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniFace.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01300001`) then imageURL}
|
||||
<img class="chuni-penguin-face-accessory chuni-penguin-accessory" src={imageURL} alt="Face (Accessory)" on:error={removeImg}>
|
||||
{/await}
|
||||
|
||||
<!-- Front -->
|
||||
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniFront.toString().padStart(8, "0")}`, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-front chuni-penguin-accessory" src={imageURL} alt="Front" on:error={removeImg}>
|
||||
{/await}
|
||||
|
||||
<!-- Back -->
|
||||
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniBack.toString().padStart(8, "0")}`, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-back chuni-penguin-accessory" src={imageURL} alt="Back" on:error={removeImg}>
|
||||
{/await}
|
||||
</div>
|
||||
<div class="chuni-penguin-feet">
|
||||
<!-- Feet -->
|
||||
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniSkin.toString().padStart(8, "0")}`, 0, 410, 85, 80, 0.75) then imageURL}
|
||||
<img src={imageURL} alt="Foot" on:error={removeImg}>
|
||||
{/await}
|
||||
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniSkin.toString().padStart(8, "0")}`, 85, 410, 85, 80, 0.75) then imageURL}
|
||||
<img src={imageURL} alt="Foot" on:error={removeImg}>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Truly sorry for the horrors below -->
|
||||
<style lang="sass">
|
||||
@keyframes chuniPenguinBodyBob
|
||||
0%
|
||||
transform: translate(-50%, 5px) translate(0%, -50%)
|
||||
50%
|
||||
transform: translate(-50%, 0%) translate(0%, -50%)
|
||||
100%
|
||||
transform: translate(-50%, 5px) translate(0%, -50%)
|
||||
@keyframes chuniPenguinArmLeft
|
||||
0%
|
||||
transform: translate(-50%, 0) rotate(-2deg)
|
||||
50%
|
||||
transform: translate(-50%, 0) rotate(2deg)
|
||||
100%
|
||||
transform: translate(-50%, 0) rotate(-2deg)
|
||||
@keyframes chuniPenguinArmRight
|
||||
0%
|
||||
transform: translate(-50%, 0) scaleX(-1) rotate(-2deg)
|
||||
50%
|
||||
transform: translate(-50%, 0) scaleX(-1) rotate(2deg)
|
||||
100%
|
||||
transform: translate(-50%, 0) scaleX(-1) rotate(-2deg)
|
||||
|
||||
img
|
||||
-webkit-user-drag: none
|
||||
user-select: none
|
||||
|
||||
.chuni-penguin
|
||||
height: 512px
|
||||
aspect-ratio: 1/2
|
||||
position: relative
|
||||
pointer-events: none
|
||||
|
||||
z-index: 1
|
||||
|
||||
&.chuni-penguin-float
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 50%
|
||||
transform: translate(-50%, -50%)
|
||||
|
||||
.chuni-penguin-body, .chuni-penguin-feet
|
||||
transform: translate(-50%, -50%)
|
||||
position: absolute
|
||||
left: 50%
|
||||
|
||||
.chuni-penguin-body
|
||||
top: 50%
|
||||
z-index: 1
|
||||
animation: chuniPenguinBodyBob 1s infinite cubic-bezier(0.45, 0, 0.55, 1)
|
||||
.chuni-penguin-feet
|
||||
top: 80%
|
||||
z-index: 0
|
||||
width: 175px
|
||||
display: flex
|
||||
justify-content: center
|
||||
|
||||
img
|
||||
margin-left: auto
|
||||
margin-right: auto
|
||||
|
||||
.chuni-penguin-arm
|
||||
transform-origin: 90% 10%
|
||||
position: absolute
|
||||
top: 40%
|
||||
z-index: 0
|
||||
&.chuni-penguin-arm-type-1
|
||||
width: calc(85px * 0.75)
|
||||
height: calc(160px * 0.75)
|
||||
z-index: 2
|
||||
&.chuni-penguin-arm-type-2
|
||||
transform-origin: 40% 10%
|
||||
z-index: 2
|
||||
|
||||
&.chuni-penguin-arm-left
|
||||
left: 0%
|
||||
transform: translate(-50%, 0)
|
||||
animation: chuniPenguinArmLeft 1s infinite cubic-bezier(0.45, 0, 0.55, 1)
|
||||
&.chuni-penguin-arm-type-2
|
||||
left: 15%
|
||||
&.chuni-penguin-arm-right
|
||||
left: 72.5%
|
||||
transform: translate(-50%, 0) scaleX(-1)
|
||||
animation: chuniPenguinArmRight 1s infinite cubic-bezier(0.45, 0, 0.55, 1)
|
||||
&.chuni-penguin-arm-type-2
|
||||
left: 95%
|
||||
|
||||
.chuni-penguin-accessory
|
||||
transform: translate(-50%, -50%)
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 50%
|
||||
|
||||
.chuni-penguin-item
|
||||
z-index: 1
|
||||
top: 25%
|
||||
left: 0
|
||||
|
||||
&.chuni-penguin-item-left
|
||||
transform: translate(-50%, -50%) rotate(-15deg)
|
||||
&.chuni-penguin-item-right
|
||||
transform: translate(-50%, -50%) scaleX(-1) rotate(15deg)
|
||||
|
||||
.chuni-penguin-eyes
|
||||
top: 22.5%
|
||||
.chuni-penguin-beak
|
||||
top: 29.5%
|
||||
.chuni-penguin-wear
|
||||
top: 60%
|
||||
.chuni-penguin-head
|
||||
top: 7.5%
|
||||
z-index: 10
|
||||
.chuni-penguin-head-2
|
||||
top: 13.5%
|
||||
.chuni-penguin-head-3
|
||||
top: -12.5%
|
||||
.chuni-penguin-face-accessory
|
||||
top: 27.5%
|
||||
.chuni-penguin-back
|
||||
z-index: -1
|
||||
|
||||
</style>
|
||||
@ -1,70 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { initializeDb } from "../../../libs/userbox/userbox"
|
||||
import ChuniPenguinComponent from "./ChuniPenguin.svelte"
|
||||
import ChuniUserplateComponent from "./ChuniUserplate.svelte"
|
||||
import { type UserBox } from "../../../libs/generalTypes"
|
||||
import { DATA, USERBOX } from "../../../libs/sdk"
|
||||
import useLocalStorage from "../../../libs/hooks/useLocalStorage.svelte"
|
||||
import { t } from "../../../libs/i18n"
|
||||
|
||||
/**
|
||||
* This is a UserBox viewer on the Profile page (UserHome), added by raymond
|
||||
* to view other user's penguins on their profile.
|
||||
*/
|
||||
|
||||
export let game: string
|
||||
export let username: string
|
||||
export let error: string = ""
|
||||
|
||||
let USERBOX_ACTIVE = useLocalStorage("userboxNewProfile", false)
|
||||
let USERBOX_INSTALLED = false
|
||||
|
||||
let userbox: UserBox
|
||||
let allItems: Record<string, Record<string, { name: string }>> = {}
|
||||
|
||||
if (game == "chu3" && USERBOX_ACTIVE.value) {
|
||||
indexedDB.databases().then(async (dbi) => {
|
||||
let databaseExists = dbi.some(db => db.name == "userboxChusanDDS")
|
||||
if (databaseExists) {
|
||||
await initializeDb()
|
||||
const profile = await USERBOX.getUserProfile(username).catch(_ => null)
|
||||
if (!profile) return
|
||||
userbox = profile
|
||||
console.log(userbox)
|
||||
|
||||
allItems = await DATA.allItems('chu3').catch(_ => {
|
||||
error = t("userbox.error.nodata")
|
||||
}) as typeof allItems
|
||||
USERBOX_INSTALLED = databaseExists
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if USERBOX_ACTIVE.value && USERBOX_INSTALLED && game == "chu3"}
|
||||
<div class="chuni-userbox-container">
|
||||
<ChuniUserplateComponent chuniCharacter={userbox.characterId} chuniRating={userbox.playerRating / 100} chuniLevel={userbox.level.toString()}
|
||||
chuniNameplate={userbox.nameplateId} chuniName={userbox.userName} chuniTrophyName={allItems.trophy[userbox.trophyId].name}></ChuniUserplateComponent>
|
||||
<div class="chuni-penguin-container">
|
||||
<ChuniPenguinComponent classPassthrough="chuni-penguin-float" chuniWear={userbox.avatarWear} chuniHead={userbox.avatarHead} chuniBack={userbox.avatarBack}
|
||||
chuniFront={userbox.avatarFront} chuniFace={userbox.avatarFace} chuniItem={userbox.avatarItem}
|
||||
chuniSkin={userbox.avatarSkin}></ChuniPenguinComponent>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
.chuni-userbox-container
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
.chuni-penguin-container
|
||||
height: 256px
|
||||
aspect-ratio: 1
|
||||
position: relative
|
||||
|
||||
@media (max-width: 1000px)
|
||||
.chuni-userbox-container
|
||||
flex-wrap: wrap
|
||||
</style>
|
||||
@ -1,192 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { DDS, type RGB } from "../../../libs/userbox/dds"
|
||||
import { ddsDB } from "../../../libs/userbox/userbox"
|
||||
|
||||
const DDSreader = new DDS(ddsDB);
|
||||
|
||||
export var chuniLevel: string = "╳"
|
||||
export var chuniName: string = "AquaDX"
|
||||
export var chuniRating: number = 1.23
|
||||
export var chuniNameplate: number = 1
|
||||
export var chuniCharacter: number = 0
|
||||
export var chuniTrophyName: string = "NEWCOMER"
|
||||
export var chuniIsUserbox: boolean = false;
|
||||
|
||||
let ratingToString = (rating: number) => {
|
||||
return rating.toFixed(2)
|
||||
}
|
||||
|
||||
interface RatingRange {
|
||||
min: number,
|
||||
offset: number,
|
||||
color?: RGB
|
||||
};
|
||||
// https://en.wikipedia.org/wiki/Chunithm#Rating
|
||||
const ratingColors: RatingRange[] = ([
|
||||
{min: 0.00, offset: 4, color: {r: 0, g: 191, b: 64}},
|
||||
{min: 4.00, offset: 4, color: {r: 255, g: 111, b: 0}},
|
||||
{min: 7.00, offset: 4, color: {r: 255, g: 64, b: 64}},
|
||||
{min: 10.00, offset: 4, color: {r: 147, g: 38, b: 255}},
|
||||
{min: 12.00, offset: 3},
|
||||
{min: 13.25, offset: 2},
|
||||
{min: 14.50, offset: 1},
|
||||
{min: 15.25, offset: 0},
|
||||
{min: 16.00, offset: 5}
|
||||
]).filter(f => f.min <= chuniRating);
|
||||
const ratingDigitOrder = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "."]
|
||||
const ratingColorData = (ratingColors[ratingColors.length - 1] ?? ratingColors[0]);
|
||||
</script>
|
||||
{#await DDSreader?.getFile(`nameplate:${chuniNameplate.toString().padStart(8, "0")}`, `nameplate:00000001`) then nameplateURL}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div on:click class="chuni-nameplate" class:chuni-nameplate-clickable={chuniIsUserbox} style:background={`url(${nameplateURL})`}>
|
||||
{#await DDSreader?.getFile(`characterThumbnail:${chuniCharacter.toString().padStart(6, "0")}`, `characterThumbnail:000000`) then characterThumbnailURL}
|
||||
<img class="chuni-character" src={characterThumbnailURL} alt="Character">
|
||||
{/await}
|
||||
{#await DDSreader?.getFileFromSheet("surfboard:CHU_UI_title_rank_00_v10.dds", 5, 5 + (75 * 2), 595, 64) then trophyURL}
|
||||
<div class="chuni-trophy" title={chuniTrophyName}>
|
||||
{chuniTrophyName}
|
||||
</div>
|
||||
<img src={trophyURL} class="chuni-trophy-bg" alt="Trophy" title={chuniTrophyName}>
|
||||
{/await}
|
||||
<div class="chuni-user-info">
|
||||
<div class="chuni-user-name">
|
||||
<span>
|
||||
Lv.
|
||||
<span class="chuni-user-level">
|
||||
{chuniLevel}
|
||||
</span>
|
||||
</span>
|
||||
<span class="chuni-user-name-text">
|
||||
{chuniName}
|
||||
</span>
|
||||
</div>
|
||||
<div class={`chuni-user-rating color-${ratingColorData.color}`}>
|
||||
|
||||
{#await DDSreader?.getFileFromSheet("surfboard:CHU_UI_Common_01_v11.dds", 485, 5 + (28 * ratingColorData.offset), 62, 15, undefined, ratingColorData.color) then url}
|
||||
{#if url}
|
||||
<img src={url} alt="Rating">
|
||||
<span class="chuni-user-rating-number">
|
||||
{#each ratingToString(chuniRating).split("") as digit}
|
||||
{#await DDSreader?.getFileFromSheet("surfboard:CHU_UI_Common_01_v11.dds", 552 + (24 * (ratingDigitOrder.indexOf(digit) ?? 0)), 1 + (28 * ratingColorData.offset), 16, 20, undefined, ratingColorData.color) then url}
|
||||
<img src={url} alt="Rating Digit">
|
||||
{/await}
|
||||
{/each}
|
||||
</span>
|
||||
{:else}
|
||||
RATING
|
||||
<span class="chuni-user-rating-number">
|
||||
{ratingToString(chuniRating)}
|
||||
</span>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
<style lang="sass">
|
||||
@use "../../../vars"
|
||||
|
||||
@font-face
|
||||
font-family: "Gothic A1"
|
||||
src: url("/assets/fonts/GothicA1.woff2")
|
||||
|
||||
.chuni-nameplate
|
||||
width: 576px
|
||||
height: 228px
|
||||
position: relative
|
||||
font-size: 16px
|
||||
/* Overlap penguin avatar when put side to side */
|
||||
z-index: 1
|
||||
|
||||
&.chuni-nameplate-clickable
|
||||
cursor: pointer
|
||||
|
||||
.chuni-trophy
|
||||
width: 390px
|
||||
height: 45px
|
||||
background-position: center
|
||||
background-size: cover
|
||||
color: black
|
||||
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
position: absolute
|
||||
right: 25px
|
||||
top: 40px
|
||||
|
||||
font-size: 1.15em
|
||||
font-family: "Gothic A1", sans-serif
|
||||
font-weight: bold
|
||||
|
||||
overflow-x: hidden
|
||||
white-space: nowrap
|
||||
text-overflow: ellipsis
|
||||
|
||||
z-index: 1
|
||||
text-shadow: 0 1px white
|
||||
margin: 0 10px
|
||||
|
||||
img.chuni-trophy-bg
|
||||
width: 410px
|
||||
height: 45px
|
||||
position: absolute
|
||||
top: 40px
|
||||
right: 25px
|
||||
z-index: -1
|
||||
|
||||
.chuni-character
|
||||
position: absolute
|
||||
top: 87px
|
||||
right: 25px
|
||||
width: 82px
|
||||
aspect-ratio: 1
|
||||
box-shadow: 0 0 1px 1px white
|
||||
background: #efefef
|
||||
|
||||
.chuni-user-info
|
||||
height: 82px
|
||||
width: 320px
|
||||
position: absolute
|
||||
top: 87px
|
||||
right: 110px
|
||||
background: #fff9
|
||||
border-radius: 1px
|
||||
box-shadow: 0 0 1px 1px #ccc
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.chuni-user-name, .chuni-user-rating
|
||||
margin: 0 4px
|
||||
display: flex
|
||||
align-items: center
|
||||
color: black
|
||||
font-family: "Gothic A1", sans-serif
|
||||
font-weight: bold
|
||||
|
||||
.chuni-user-name
|
||||
flex: 1 0 65%
|
||||
box-shadow: 0 1px 0 #ccc
|
||||
white-space: nowrap
|
||||
text-overflow: ellipsis
|
||||
|
||||
.chuni-user-level
|
||||
font-size: 1.5em
|
||||
margin-left: 10px
|
||||
|
||||
.chuni-user-name-text
|
||||
margin-left: auto
|
||||
font-size: 2em
|
||||
|
||||
.chuni-user-rating
|
||||
flex: 1 0 35%
|
||||
font-size: 0.875em
|
||||
text-shadow: #333 1px 1px, #333 1px -1px, #333 -1px 1px, #333 -1px -1px
|
||||
color: #fff
|
||||
|
||||
.chuni-user-rating-number
|
||||
font-size: 1.5em
|
||||
margin-left: 10px
|
||||
|
||||
</style>
|
||||
@ -1,39 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import { t } from "../../libs/i18n";
|
||||
import { DISCORD_INVITE } from "../../libs/config";
|
||||
|
||||
export let error: string;
|
||||
export let expected: boolean = false;
|
||||
</script>
|
||||
|
||||
<div class="overlay" transition:fade>
|
||||
<div>
|
||||
<h2 class="error">{t('status.error')}</h2>
|
||||
{#if !expected}
|
||||
<span>{t('status.error.hint')}<a href={DISCORD_INVITE}>{t('status.error.hint.link')}</a></span>
|
||||
{/if}
|
||||
<span class="detail">{error}</span>
|
||||
|
||||
<div class="actions">
|
||||
<button on:click={() => location.reload()} class="error">
|
||||
{t('action.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
.actions
|
||||
display: flex
|
||||
gap: 16px
|
||||
|
||||
button
|
||||
width: 100%
|
||||
|
||||
.detail
|
||||
white-space: pre-line
|
||||
font-size: 0.9em
|
||||
line-height: 1.2
|
||||
opacity: 0.8
|
||||
</style>
|
||||
@ -1,47 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
import { ts } from "../../libs/i18n";
|
||||
import InputWithButton from "./InputWithButton.svelte";
|
||||
|
||||
export let field: {key: string, value: any, type: string, changed?: boolean};
|
||||
export let callback: () => Promise<boolean>;
|
||||
</script>
|
||||
|
||||
<div class="field {field.type.toLowerCase()}">
|
||||
{#if field.type.toLowerCase() === "boolean"}
|
||||
<input id={field.key} type="checkbox" bind:checked={field.value} on:change={callback}/>
|
||||
<label for={field.key}>
|
||||
<span class="name">{ts(`settings.fields.${field.key}.name`)}</span>
|
||||
<span class="desc">{ts(`settings.fields.${field.key}.desc`)}</span>
|
||||
</label>
|
||||
{/if}
|
||||
{#if field.type.toLowerCase() === "string"}
|
||||
<label for={field.key}>
|
||||
<span class="name">{ts(`settings.fields.${field.key}.name`)}</span>
|
||||
<span class="desc">{ts(`settings.fields.${field.key}.desc`)}</span>
|
||||
</label>
|
||||
<InputWithButton bind:field={field} callback={callback}/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
.field.string
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
gap: 0.5rem
|
||||
|
||||
.field.boolean
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
|
||||
.field
|
||||
display: flex
|
||||
|
||||
label
|
||||
display: flex
|
||||
flex-direction: column
|
||||
max-width: max-content
|
||||
|
||||
.desc
|
||||
opacity: 0.6
|
||||
</style>
|
||||
@ -1,30 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { fade } from 'svelte/transition'
|
||||
</script>
|
||||
|
||||
<div class="overlay loading" transition:fade>
|
||||
<Icon class="icon" icon="svg-spinners:pulse-2"/>
|
||||
<span><span>LOADING</span></span>
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
.loading.overlay
|
||||
font-size: 28rem
|
||||
|
||||
:global(.icon)
|
||||
opacity: 0.5
|
||||
|
||||
> span
|
||||
position: absolute
|
||||
inset: 0
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
background: transparent
|
||||
|
||||
letter-spacing: 20px
|
||||
margin-left: 20px
|
||||
|
||||
font-size: 1.5rem
|
||||
</style>
|
||||
@ -1,47 +1,19 @@
|
||||
import type { ChusanMatchingOption } from "./generalTypes"
|
||||
|
||||
export const AQUA_HOST = import.meta.env.VITE_AQUA_HOST
|
||||
export const DATA_HOST = import.meta.env.VITE_DATA_HOST
|
||||
export const AQUA_HOST = 'https://aquadx.net/aqua'
|
||||
export const DATA_HOST = 'https://aquadx.net'
|
||||
|
||||
// This will be displayed for users to connect from the client
|
||||
export const AQUA_CONNECTION = import.meta.env.VITE_AQUA_CONNECTION
|
||||
export const AQUA_CONNECTION = 'aquadx.hydev.org'
|
||||
|
||||
export const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY
|
||||
export const DISCORD_INVITE = import.meta.env.VITE_DISCORD_INVITE
|
||||
export const TELEGRAM_INVITE = import.meta.env.VITE_TELEGRAM_INVITE
|
||||
export const QQ_INVITE = import.meta.env.VITE_QQ_INVITE
|
||||
export const TURNSTILE_SITE_KEY = '0x4AAAAAAASGA2KQEIelo9P9'
|
||||
export const DISCORD_INVITE = 'https://discord.gg/FNgveqFF7s'
|
||||
export const TELEGRAM_INVITE = 'https://t.me/+zBL4RZdyfvUzZGU1'
|
||||
export const QQ_INVITE = 'https://qm.qq.com/q/wvNXbXbHbO'
|
||||
|
||||
// UI
|
||||
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.
|
||||
|
||||
// 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 = ""
|
||||
|
||||
// USERBOX_ASSETS
|
||||
export const HAS_USERBOX_ASSETS = true
|
||||
|
||||
// Meow meow meow
|
||||
|
||||
// Matching servers
|
||||
export const CHU3_MATCHINGS: ChusanMatchingOption[] = [
|
||||
{
|
||||
name: "林国对战",
|
||||
ui: "https://chu3-match.sega.ink/rooms",
|
||||
guide: "https://performai.evilleaker.com/manual/games/chunithm/national_battle/",
|
||||
matching: "https://chu3-match.sega.ink/",
|
||||
reflector: "http://reflector.naominet.live:18080/",
|
||||
coop: ["RinNET", "MysteriaNET"],
|
||||
},
|
||||
{
|
||||
name: "Yukiotoko",
|
||||
ui: "https://yukiotoko.metatable.sh/",
|
||||
guide: "https://github.com/MewoLab/AquaDX/blob/v1-dev/docs/chu3-national-matching.md",
|
||||
matching: "http://yukiotoko.chara.lol:9004/",
|
||||
reflector: "http://yukiotoko.chara.lol:50201/",
|
||||
coop: ["Missless", "CozyNet", "GMG"]
|
||||
}
|
||||
]
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
export type Dict = Record<string, any>
|
||||
|
||||
export interface TrendEntry {
|
||||
date: string
|
||||
rating: number
|
||||
@ -50,7 +48,7 @@ export interface CardSummary {
|
||||
export interface ConfirmProps {
|
||||
title: string
|
||||
message: string
|
||||
confirm?: () => void
|
||||
confirm: () => void
|
||||
cancel?: () => void
|
||||
dangerous?: boolean
|
||||
}
|
||||
@ -142,8 +140,6 @@ export interface UserBox {
|
||||
frameId: number,
|
||||
characterId: number,
|
||||
trophyId: number,
|
||||
trophyIdSub1: number,
|
||||
trophyIdSub2: number,
|
||||
mapIconId: number,
|
||||
voiceId: number,
|
||||
avatarWear: number,
|
||||
@ -153,16 +149,4 @@ export interface UserBox {
|
||||
avatarItem: number,
|
||||
avatarFront: number,
|
||||
avatarBack: number,
|
||||
|
||||
level: number
|
||||
playerRating: number
|
||||
}
|
||||
|
||||
export interface ChusanMatchingOption {
|
||||
name: string
|
||||
ui: string
|
||||
guide: string
|
||||
matching: string
|
||||
reflector: string
|
||||
coop: string[]
|
||||
}
|
||||
|
||||
@ -2,9 +2,6 @@ import { EN_REF, type LocalizedMessages } from "./i18n/en_ref";
|
||||
import { ZH } from "./i18n/zh";
|
||||
import type { GameName } from "./scoring";
|
||||
|
||||
import zhCountires from "./i18n/zh_countries.json"
|
||||
import enCountires from "./i18n/en_countries.json"
|
||||
|
||||
type Lang = 'en' | 'zh'
|
||||
|
||||
const msgs: Record<Lang, LocalizedMessages> = {
|
||||
@ -12,10 +9,6 @@ const msgs: Record<Lang, LocalizedMessages> = {
|
||||
zh: ZH
|
||||
}
|
||||
|
||||
const countries: Record<Lang, typeof enCountires> = {
|
||||
en: enCountires,
|
||||
zh: zhCountires
|
||||
}
|
||||
|
||||
let lang: Lang = 'en'
|
||||
|
||||
@ -55,39 +48,5 @@ export function t(key: keyof LocalizedMessages, variables?: { [index: string]: a
|
||||
}
|
||||
Object.assign(window, { t })
|
||||
|
||||
export function getCountryName(code: keyof typeof enCountires) {
|
||||
return countries[lang][code]
|
||||
}
|
||||
|
||||
export const GAME_TITLE: { [key in GameName]: string } =
|
||||
{chu3: t("game.chu3"), mai2: t("game.mai2"), ongeki: t("game.ongeki"), wacca: t("game.wacca")}
|
||||
|
||||
/**
|
||||
* Converts a two-letter country code to its corresponding flag emoji.
|
||||
*
|
||||
* The Unicode flag emoji is represented by two Regional Indicator Symbols.
|
||||
* Each letter in the country code is transformed into a Regional Indicator Symbol
|
||||
* by adding its alphabetical position (A = 0, B = 1, etc.) to the base code point U+1F1E6.
|
||||
*
|
||||
* @param countryCode - A two-letter ISO country code (e.g., "US", "GB").
|
||||
* @returns The corresponding flag emoji if the country code is valid; otherwise, an empty string.
|
||||
*/
|
||||
export function countryCodeToEmoji(countryCode: string): string {
|
||||
if (!countryCode) return ""
|
||||
if (countryCode.length !== 2) return ""
|
||||
|
||||
// Convert the country code to uppercase to standardize it
|
||||
const code = countryCode.toUpperCase();
|
||||
|
||||
// The base code point for Regional Indicator Symbol Letter A is 0x1F1E6.
|
||||
const OFFSET = 0x1F1E6;
|
||||
const firstCharCode = code.charCodeAt(0);
|
||||
const secondCharCode = code.charCodeAt(1);
|
||||
|
||||
// 'A' has a char code of 65.
|
||||
const firstIndicator = OFFSET + (firstCharCode - 65);
|
||||
const secondIndicator = OFFSET + (secondCharCode - 65);
|
||||
|
||||
// Create and return the flag emoji string
|
||||
return String.fromCodePoint(firstIndicator, secondIndicator);
|
||||
}
|
||||
|
||||
@ -1,248 +0,0 @@
|
||||
{
|
||||
"AF": "Afghanistan",
|
||||
"AX": "Aland Islands",
|
||||
"AL": "Albania",
|
||||
"DZ": "Algeria",
|
||||
"AS": "American Samoa",
|
||||
"AD": "Andorra",
|
||||
"AO": "Angola",
|
||||
"AI": "Anguilla",
|
||||
"AQ": "Antarctica",
|
||||
"AG": "Antigua And Barbuda",
|
||||
"AR": "Argentina",
|
||||
"AM": "Armenia",
|
||||
"AW": "Aruba",
|
||||
"AU": "Australia",
|
||||
"AT": "Austria",
|
||||
"AZ": "Azerbaijan",
|
||||
"BS": "Bahamas",
|
||||
"BH": "Bahrain",
|
||||
"BD": "Bangladesh",
|
||||
"BB": "Barbados",
|
||||
"BY": "Belarus",
|
||||
"BE": "Belgium",
|
||||
"BZ": "Belize",
|
||||
"BJ": "Benin",
|
||||
"BM": "Bermuda",
|
||||
"BT": "Bhutan",
|
||||
"BO": "Bolivia",
|
||||
"BA": "Bosnia And Herzegovina",
|
||||
"BW": "Botswana",
|
||||
"BV": "Bouvet Island",
|
||||
"BR": "Brazil",
|
||||
"IO": "British Indian Ocean Territory",
|
||||
"BN": "Brunei Darussalam",
|
||||
"BG": "Bulgaria",
|
||||
"BF": "Burkina Faso",
|
||||
"BI": "Burundi",
|
||||
"KH": "Cambodia",
|
||||
"CM": "Cameroon",
|
||||
"CA": "Canada",
|
||||
"CV": "Cape Verde",
|
||||
"KY": "Cayman Islands",
|
||||
"CF": "Central African Republic",
|
||||
"TD": "Chad",
|
||||
"CL": "Chile",
|
||||
"CN": "China",
|
||||
"CX": "Christmas Island",
|
||||
"CC": "Cocos (Keeling) Islands",
|
||||
"CO": "Colombia",
|
||||
"KM": "Comoros",
|
||||
"CG": "Congo",
|
||||
"CD": "Congo, Democratic Republic",
|
||||
"CK": "Cook Islands",
|
||||
"CR": "Costa Rica",
|
||||
"CI": "Cote D\"Ivoire",
|
||||
"HR": "Croatia",
|
||||
"CU": "Cuba",
|
||||
"CY": "Cyprus",
|
||||
"CZ": "Czech Republic",
|
||||
"DK": "Denmark",
|
||||
"DJ": "Djibouti",
|
||||
"DM": "Dominica",
|
||||
"DO": "Dominican Republic",
|
||||
"EC": "Ecuador",
|
||||
"EG": "Egypt",
|
||||
"SV": "El Salvador",
|
||||
"GQ": "Equatorial Guinea",
|
||||
"ER": "Eritrea",
|
||||
"EE": "Estonia",
|
||||
"ET": "Ethiopia",
|
||||
"FK": "Falkland Islands (Malvinas)",
|
||||
"FO": "Faroe Islands",
|
||||
"FJ": "Fiji",
|
||||
"FI": "Finland",
|
||||
"FR": "France",
|
||||
"GF": "French Guiana",
|
||||
"PF": "French Polynesia",
|
||||
"TF": "French Southern Territories",
|
||||
"GA": "Gabon",
|
||||
"GM": "Gambia",
|
||||
"GE": "Georgia",
|
||||
"DE": "Germany",
|
||||
"GH": "Ghana",
|
||||
"GI": "Gibraltar",
|
||||
"GR": "Greece",
|
||||
"GL": "Greenland",
|
||||
"GD": "Grenada",
|
||||
"GP": "Guadeloupe",
|
||||
"GU": "Guam",
|
||||
"GT": "Guatemala",
|
||||
"GG": "Guernsey",
|
||||
"GN": "Guinea",
|
||||
"GW": "Guinea-Bissau",
|
||||
"GY": "Guyana",
|
||||
"HT": "Haiti",
|
||||
"HM": "Heard Island & Mcdonald Islands",
|
||||
"VA": "Holy See (Vatican City State)",
|
||||
"HN": "Honduras",
|
||||
"HK": "Hong Kong",
|
||||
"HU": "Hungary",
|
||||
"IS": "Iceland",
|
||||
"IN": "India",
|
||||
"ID": "Indonesia",
|
||||
"IR": "Iran, Islamic Republic Of",
|
||||
"IQ": "Iraq",
|
||||
"IE": "Ireland",
|
||||
"IM": "Isle Of Man",
|
||||
"IL": "Israel",
|
||||
"IT": "Italy",
|
||||
"JM": "Jamaica",
|
||||
"JP": "Japan",
|
||||
"JE": "Jersey",
|
||||
"JO": "Jordan",
|
||||
"KZ": "Kazakhstan",
|
||||
"KE": "Kenya",
|
||||
"KI": "Kiribati",
|
||||
"KR": "Korea",
|
||||
"KP": "North Korea",
|
||||
"KW": "Kuwait",
|
||||
"KG": "Kyrgyzstan",
|
||||
"LA": "Lao People\"s Democratic Republic",
|
||||
"LV": "Latvia",
|
||||
"LB": "Lebanon",
|
||||
"LS": "Lesotho",
|
||||
"LR": "Liberia",
|
||||
"LY": "Libyan Arab Jamahiriya",
|
||||
"LI": "Liechtenstein",
|
||||
"LT": "Lithuania",
|
||||
"LU": "Luxembourg",
|
||||
"MO": "Macao",
|
||||
"MK": "Macedonia",
|
||||
"MG": "Madagascar",
|
||||
"MW": "Malawi",
|
||||
"MY": "Malaysia",
|
||||
"MV": "Maldives",
|
||||
"ML": "Mali",
|
||||
"MT": "Malta",
|
||||
"MH": "Marshall Islands",
|
||||
"MQ": "Martinique",
|
||||
"MR": "Mauritania",
|
||||
"MU": "Mauritius",
|
||||
"YT": "Mayotte",
|
||||
"MX": "Mexico",
|
||||
"FM": "Micronesia, Federated States Of",
|
||||
"MD": "Moldova",
|
||||
"MC": "Monaco",
|
||||
"MN": "Mongolia",
|
||||
"ME": "Montenegro",
|
||||
"MS": "Montserrat",
|
||||
"MA": "Morocco",
|
||||
"MZ": "Mozambique",
|
||||
"MM": "Myanmar",
|
||||
"NA": "Namibia",
|
||||
"NR": "Nauru",
|
||||
"NP": "Nepal",
|
||||
"NL": "Netherlands",
|
||||
"AN": "Netherlands Antilles",
|
||||
"NC": "New Caledonia",
|
||||
"NZ": "New Zealand",
|
||||
"NI": "Nicaragua",
|
||||
"NE": "Niger",
|
||||
"NG": "Nigeria",
|
||||
"NU": "Niue",
|
||||
"NF": "Norfolk Island",
|
||||
"MP": "Northern Mariana Islands",
|
||||
"NO": "Norway",
|
||||
"OM": "Oman",
|
||||
"PK": "Pakistan",
|
||||
"PW": "Palau",
|
||||
"PS": "Palestinian Territory, Occupied",
|
||||
"PA": "Panama",
|
||||
"PG": "Papua New Guinea",
|
||||
"PY": "Paraguay",
|
||||
"PE": "Peru",
|
||||
"PH": "Philippines",
|
||||
"PN": "Pitcairn",
|
||||
"PL": "Poland",
|
||||
"PT": "Portugal",
|
||||
"PR": "Puerto Rico",
|
||||
"QA": "Qatar",
|
||||
"RE": "Reunion",
|
||||
"RO": "Romania",
|
||||
"RU": "Russian Federation",
|
||||
"RW": "Rwanda",
|
||||
"BL": "Saint Barthelemy",
|
||||
"SH": "Saint Helena",
|
||||
"KN": "Saint Kitts And Nevis",
|
||||
"LC": "Saint Lucia",
|
||||
"MF": "Saint Martin",
|
||||
"PM": "Saint Pierre And Miquelon",
|
||||
"VC": "Saint Vincent And Grenadines",
|
||||
"WS": "Samoa",
|
||||
"SM": "San Marino",
|
||||
"ST": "Sao Tome And Principe",
|
||||
"SA": "Saudi Arabia",
|
||||
"SN": "Senegal",
|
||||
"RS": "Serbia",
|
||||
"SC": "Seychelles",
|
||||
"SL": "Sierra Leone",
|
||||
"SG": "Singapore",
|
||||
"SK": "Slovakia",
|
||||
"SI": "Slovenia",
|
||||
"SB": "Solomon Islands",
|
||||
"SO": "Somalia",
|
||||
"ZA": "South Africa",
|
||||
"GS": "South Georgia And Sandwich Isl.",
|
||||
"ES": "Spain",
|
||||
"LK": "Sri Lanka",
|
||||
"SD": "Sudan",
|
||||
"SR": "Suriname",
|
||||
"SJ": "Svalbard And Jan Mayen",
|
||||
"SZ": "Swaziland",
|
||||
"SE": "Sweden",
|
||||
"CH": "Switzerland",
|
||||
"SY": "Syrian Arab Republic",
|
||||
"TW": "Taiwan",
|
||||
"TJ": "Tajikistan",
|
||||
"TZ": "Tanzania",
|
||||
"TH": "Thailand",
|
||||
"TL": "Timor-Leste",
|
||||
"TG": "Togo",
|
||||
"TK": "Tokelau",
|
||||
"TO": "Tonga",
|
||||
"TT": "Trinidad And Tobago",
|
||||
"TN": "Tunisia",
|
||||
"TR": "Turkey",
|
||||
"TM": "Turkmenistan",
|
||||
"TC": "Turks And Caicos Islands",
|
||||
"TV": "Tuvalu",
|
||||
"UG": "Uganda",
|
||||
"UA": "Ukraine",
|
||||
"AE": "United Arab Emirates",
|
||||
"GB": "United Kingdom",
|
||||
"US": "United States",
|
||||
"UM": "United States Outlying Islands",
|
||||
"UY": "Uruguay",
|
||||
"UZ": "Uzbekistan",
|
||||
"VU": "Vanuatu",
|
||||
"VE": "Venezuela",
|
||||
"VN": "Vietnam",
|
||||
"VG": "Virgin Islands, British",
|
||||
"VI": "Virgin Islands, U.S.",
|
||||
"WF": "Wallis And Futuna",
|
||||
"EH": "Western Sahara",
|
||||
"YE": "Yemen",
|
||||
"ZM": "Zambia",
|
||||
"ZW": "Zimbabwe"
|
||||
}
|
||||
@ -27,7 +27,6 @@ export const EN_REF_USER = {
|
||||
'UserHome.AddRival': "Add to Rival",
|
||||
'UserHome.RemoveRival': "Remove from Rival",
|
||||
'UserHome.InvalidGame': "Game ${game} is not supported on the web UI yet. We only support maimai, chunithm, wacca, and ongeki for now.",
|
||||
'UserHome.ShowMoreRecent': 'Show more',
|
||||
}
|
||||
|
||||
export const EN_REF_Welcome = {
|
||||
@ -72,11 +71,6 @@ export const EN_REF_GENERAL = {
|
||||
'action.refresh': 'Refresh',
|
||||
'action.cancel': 'Cancel',
|
||||
'action.confirm': 'Confirm',
|
||||
'navigation.profile': 'Profile',
|
||||
'navigation.maps': 'Maps',
|
||||
'navigation.home': 'Home',
|
||||
'navigation.rankings': 'Rankings',
|
||||
'navigation.notice': 'Notice'
|
||||
}
|
||||
|
||||
export const EN_REF_HOME = {
|
||||
@ -98,11 +92,10 @@ export const EN_REF_HOME = {
|
||||
'home.linkcard.account-card': 'Account Card',
|
||||
'home.linkcard.registered': 'Registered',
|
||||
'home.linkcard.lastused': 'Last used',
|
||||
'home.linkcard.enter-info': 'Please enter the following information, or drag and drop your aime.txt / felica.txt file here',
|
||||
'home.linkcard.enter-info': 'Please enter the following information',
|
||||
'home.linkcard.access-code': 'The 20-digit access code on the back of your card. (If it doesn\'t work, please try scanning your card in game and enter the access code shown on screen)',
|
||||
'home.linkcard.enter-sn1': 'Download the NFC Tools app on your phone',
|
||||
'home.linkcard.enter-sn2': 'and scan your card. Then, enter the Serial Number.',
|
||||
'home.linkcard.kdx-notice': "If you're using KanadeDX, please enter the simulated card number (you can find it in settings > card).",
|
||||
'home.linkcard.link': 'Link',
|
||||
'home.linkcard.data-conflict': 'Data Conflict',
|
||||
'home.linkcard.name': 'Name',
|
||||
@ -113,7 +106,6 @@ export const EN_REF_HOME = {
|
||||
'home.linkcard.notfound': 'Card not found',
|
||||
'home.linkcard.unlink': 'Unlink Card',
|
||||
'home.linkcard.unlink-notice': 'Are you sure you want to unlink this card?',
|
||||
'home.linkcard.felica-ac-warning': 'This Access Code is of a FeliCa AIC card.\nIf you are logging in with a physical card (not aime.txt emulation), unlike the official server, you need to bind the FeliCa SN of the card (or the 00-prefixed card number shown in the game) instead of this code.\nIf you are logging in with aime.txt emulation, please ignore this warning and proceed.',
|
||||
'home.setup.welcome': 'Welcome! If you own an arcade cabinet or game setup, please follow the instructions below to set up the connection with AquaDX.',
|
||||
'home.setup.blockquote': 'We assume that you already have the required files and can run the game (e.g. ROM and segatools) that come with the cabinet or game setup. If not, please contact the seller of your device for the required files, as we will not provide them for copyright reasons.',
|
||||
'home.setup.get': 'Get started',
|
||||
@ -122,9 +114,6 @@ export const EN_REF_HOME = {
|
||||
'home.setup.ask': 'If you have any questions, please ask in our',
|
||||
'home.setup.support': 'server',
|
||||
'home.setup.keychip-tips': 'This is your unique keychip, do not share it with anyone',
|
||||
'home.community.discord': 'Discord',
|
||||
'home.community.telegram': 'Telegram (Chinese)',
|
||||
'home.community.qq': 'QQ (Chinese)',
|
||||
'home.import.unknown-game': 'Unknown game type. Currently only maimai and chunithm are supported for importing.',
|
||||
'home.import.new-data': 'Data to import',
|
||||
'home.import.data-conflict': 'Proceed will override your current data',
|
||||
@ -136,7 +125,6 @@ export const EN_REF_SETTINGS = {
|
||||
'settings.tabs.game': 'Game',
|
||||
'settings.tabs.chu3': 'Chuni',
|
||||
'settings.tabs.mai2': 'Mai',
|
||||
'settings.tabs.ongeki': 'Ongeki',
|
||||
'settings.tabs.wacca': 'Wacca',
|
||||
'settings.fields.unlockMusic.name': 'Unlock All Music',
|
||||
'settings.fields.unlockMusic.desc': 'Unlock all music and master difficulty in game.',
|
||||
@ -145,59 +133,40 @@ export const EN_REF_SETTINGS = {
|
||||
'settings.fields.unlockCollectables.name': 'Unlock All Collectables',
|
||||
'settings.fields.unlockCollectables.desc': 'Unlock all collectables (nameplate, title, icon, frame) in game.',
|
||||
'settings.fields.unlockTickets.name': 'Unlock All Tickets',
|
||||
'settings.fields.unlockTickets.desc': 'Infinite map/ex tickets (Note: maimai still limits which tickets can be used).',
|
||||
'settings.fields.unlockTickets.desc': 'Infinite map/ex tickets (note: maimai still limits which tickets can be used).',
|
||||
'settings.fields.waccaInfiniteWp.name': 'Wacca: Infinite WP',
|
||||
'settings.fields.waccaInfiniteWp.desc': 'Set WP to 999999',
|
||||
'settings.fields.waccaAlwaysVip.name': 'Wacca: Always VIP',
|
||||
'settings.fields.waccaAlwaysVip.desc': 'Set VIP expiration date to 2077-01-01',
|
||||
'settings.fields.chusanTeamName.name': 'Team Name',
|
||||
'settings.fields.chusanTeamName.name': 'Chuni: Team Name',
|
||||
'settings.fields.chusanTeamName.desc': 'Customize the text displayed on the top of your profile.',
|
||||
'settings.fields.chusanInfinitePenguins.name': 'Infinite Penguins',
|
||||
'settings.fields.chusanInfinitePenguins.name': 'Chuni: Infinite Penguins',
|
||||
'settings.fields.chusanInfinitePenguins.desc': 'Set penguin statues for character level prompting to 999.',
|
||||
'settings.fields.chusanMatchingReflector.name': 'Matching Server Reflector',
|
||||
'settings.fields.chusanMatchingReflector.desc': 'URL of the national matching server\'s UDP reflector.',
|
||||
'settings.fields.chusanMatchingServer.name': 'Matching Server',
|
||||
'settings.fields.chusanMatchingServer.desc': 'URL of the national matching server.',
|
||||
'settings.fields.ongekiInfiniteKaika.name': 'Infinite Kaika',
|
||||
'settings.fields.ongekiInfiniteKaika.desc': 'Set Kaika to 999',
|
||||
'settings.fields.rounding.name': 'Score Rounding',
|
||||
'settings.fields.rounding.desc': 'Round the score to one decimal place',
|
||||
'settings.fields.gameUsername.name': 'In-Game Username',
|
||||
'settings.fields.gameUsername.desc': 'Your name shown in game',
|
||||
'settings.fields.optOutOfLeaderboard.name': 'Opt Out of Leaderboard',
|
||||
'settings.fields.optOutOfLeaderboard.desc': 'You will still be able to see yourself on the leaderboard after logging in',
|
||||
'settings.fields.enableMusicRank.name': 'Enable Recommended Music Rank on Your Machine',
|
||||
'settings.fields.enableMusicRank.desc': 'If you have your own ranking, you can turn this off. It only affects your own machine',
|
||||
'settings.mai2.name': 'Player Name',
|
||||
'settings.profile.picture': 'Profile Picture',
|
||||
'settings.profile.upload-new': 'Upload New',
|
||||
'settings.profile.bad-format': 'Invalid image format. Supported types are PNG, JPG, JPEG, WEBP & GIF.',
|
||||
'settings.profile.save': 'Save',
|
||||
'settings.profile.name': 'Display Name',
|
||||
'settings.profile.username': 'Username',
|
||||
'settings.profile.password': 'Password',
|
||||
'settings.profile.country': 'Country',
|
||||
'settings.profile.location': 'Location',
|
||||
'settings.profile.bio': 'Bio',
|
||||
'settings.profile.unset': 'Unset',
|
||||
'settings.profile.logout': 'Log out',
|
||||
'settings.profile.unchanged': 'Unchanged',
|
||||
'settings.export': 'Export Player Data',
|
||||
'settings.cabNotice': "Note: These settings will only affect your own cab/setup. If you're playing on someone else's setup, please contact them to change these settings.",
|
||||
'settings.gameNotice': "These only apply to Mai and Wacca."
|
||||
}
|
||||
|
||||
export const EN_REF_USERBOX = {
|
||||
'userbox.header.general': 'General Settings',
|
||||
'userbox.header.matching': 'National Matching',
|
||||
'userbox.header.matching.symbolChat': 'Chat Symbols (Matching)',
|
||||
'userbox.header.userbox': 'UserBox Settings',
|
||||
'userbox.header.preview': 'UserBox Preview',
|
||||
'userbox.nameplateId': 'Nameplate',
|
||||
'userbox.frameId': 'Frame',
|
||||
'userbox.trophyId': 'Trophy (Title)',
|
||||
'userbox.trophyIdSub1': 'Trophy Sub #1 (Title)',
|
||||
'userbox.trophyIdSub2': 'Trophy Sub #2 (Title)',
|
||||
'userbox.mapIconId': 'Map Icon',
|
||||
'userbox.voiceId': 'System Voice',
|
||||
'userbox.avatarWear': 'Avatar Wear',
|
||||
@ -207,78 +176,12 @@ export const EN_REF_USERBOX = {
|
||||
'userbox.avatarItem': 'Avatar Item',
|
||||
'userbox.avatarFront': 'Avatar Front',
|
||||
'userbox.avatarBack': 'Avatar Back',
|
||||
'userbox.preview.notice': 'To honor the copyright, we cannot host the images of the userbox items. However, if someone else is willing to provide the images, you can enter their URL here and it will be displayed.',
|
||||
'userbox.preview.url': 'Image URL',
|
||||
'userbox.error.nodata': 'Chuni data not found',
|
||||
|
||||
'userbox.matching.select': 'Select Matching Server',
|
||||
'userbox.matching.select.sub': 'Choose the matching server you want to use.',
|
||||
'userbox.matching.option.ui': 'Rooms',
|
||||
'userbox.matching.option.guide': 'Guide',
|
||||
'userbox.matching.option.collab': 'Collaborators',
|
||||
'userbox.matching.custom.name': 'Custom',
|
||||
'userbox.matching.custom.sub': 'Enter your own URL',
|
||||
'userbox.matching.symbolChat': 'Message Choice',
|
||||
'userbox.matching.symbolChat.default': 'Default',
|
||||
|
||||
'userbox.new.name': 'AquaBox',
|
||||
'userbox.new.setup': 'Drag and drop your Chuni game folder (Lumi or newer) into the box below to display UserBoxes with their nameplate & avatar. All files are handled in-browser.',
|
||||
'userbox.new.setup.notice': 'Select the highest folder containing your game data.',
|
||||
'userbox.new.setup.processing_file': 'Processing',
|
||||
'userbox.new.setup.finalizing': 'Saving to internal storage',
|
||||
'userbox.new.drop': 'Drop game folder here',
|
||||
'userbox.new.switch.to_url': 'Switch to URL mode',
|
||||
'userbox.new.switch.to_drop': 'Switch to drop mode',
|
||||
'userbox.new.url_warning': 'Enter in the path to access Userbox assets. You are responsible for any results in this state. Please read the documentation. Don\'t expect support for this mode.',
|
||||
'userbox.new.activate_first': 'Enable AquaBox (game files required)',
|
||||
'userbox.new.activate_update': 'Update AquaBox (game files required)',
|
||||
'userbox.new.activate': 'Use AquaBox',
|
||||
'userbox.new.activate_desc': 'Enable displaying UserBoxes with their nameplate & avatar',
|
||||
'userbox.new.activate_profile': 'Use AquaBox on profiles',
|
||||
'userbox.new.activate_profile_desc': 'Enable displaying UserBoxes with their nameplate & avatar on profile pages',
|
||||
'userbox.new.error.invalidFolder': 'The folder you selected is invalid. Ensure that your game\'s version is Lumi or newer and that the "A000" option pack is present.',
|
||||
'userbox.new.error.invalidUrl': 'The URL you inputted is invalid.'
|
||||
}
|
||||
|
||||
export const EN_REF_MAI_PHOTO = {
|
||||
'maiphoto.title': 'Mai Memorial Photo Gallery',
|
||||
'maiphoto.url_warning': 'Note: If you want to share a photo with your friend, please save the photo. Do not copy image URL because the URL contains sensitive information.',
|
||||
'maiphoto.none': 'No photo found. You can upload photo by clicking upload at the end of each game session.',
|
||||
}
|
||||
|
||||
export const EN_REF_AQUATRANS = {
|
||||
'trans.title': '🏳️⚧️ AquaTrans™ Data Transfer',
|
||||
'trans.confirm.unbackuped.title': 'Confirm transfer',
|
||||
'trans.confirm.unbackuped.msg': "It seems like you haven't backed up your destination data. Are you sure you want to proceed? (This will overwrite your destination server's data)",
|
||||
'trans.confirm.untested.title': 'Error',
|
||||
'trans.confirm.untested.msg': "It seems like you haven't tested both connections yet. Please test the connections first.",
|
||||
'trans.confirm.done.title': 'Done!',
|
||||
'trans.confirm.done.msg': 'Transfer completed successfully! Your data on ${dst} is overwritten with your data from ${src}.',
|
||||
'trans.alert.in-progress': "Transfer already in progress!",
|
||||
'trans.prompt-html': `
|
||||
<p>👋 Welcome to the AquaTrans™ server data transfer tool!</p>
|
||||
<p>You can use this to export data from any server, and input data into any server using the connection credentials (card number, server address, and keychip id).</p>
|
||||
<p>This tool will simulate a game client and pull your data from the source server, and push your data to the destination server.</p>
|
||||
<p>Please fill out the info below to get started!</p>
|
||||
`,
|
||||
'trans.error.empty': 'Please fill out all fields.',
|
||||
'trans.error.untested': 'Please test the connections first.',
|
||||
'trans.success.import': 'Data imported successfully!',
|
||||
'trans.source.title': 'Source Server',
|
||||
'trans.target.title': 'Destination Server',
|
||||
'trans.field.addr': 'Server Address',
|
||||
'trans.field.keychip': 'Keychip ID',
|
||||
'trans.field.game': 'Game',
|
||||
'trans.field.version': 'Version',
|
||||
'trans.field.card': 'Card Number',
|
||||
'trans.btn.test': 'Test Connection',
|
||||
'trans.btn.export': 'Export Data',
|
||||
'trans.btn.import': 'Import Data',
|
||||
'trans.blacklist': "Your server's rules doesn't allow using this tool. You might get banned if you try (idk, ask them if you want to know why)",
|
||||
}
|
||||
|
||||
export const EN_REF = { ...EN_REF_USER, ...EN_REF_Welcome, ...EN_REF_GENERAL,
|
||||
...EN_REF_LEADERBOARD, ...EN_REF_HOME, ...EN_REF_SETTINGS, ...EN_REF_USERBOX,
|
||||
...EN_REF_MAI_PHOTO, ...EN_REF_AQUATRANS
|
||||
}
|
||||
...EN_REF_LEADERBOARD, ...EN_REF_HOME, ...EN_REF_SETTINGS, ...EN_REF_USERBOX }
|
||||
|
||||
export type LocalizedMessages = typeof EN_REF
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import {
|
||||
EN_REF_AQUATRANS,
|
||||
EN_REF_GENERAL,
|
||||
EN_REF_HOME,
|
||||
EN_REF_LEADERBOARD,
|
||||
EN_REF_MAI_PHOTO,
|
||||
EN_REF_SETTINGS,
|
||||
EN_REF_USER,
|
||||
EN_REF_USERBOX,
|
||||
@ -29,7 +27,7 @@ const zhUser: typeof EN_REF_USER = {
|
||||
'UserHome.Version': '游戏版本',
|
||||
'UserHome.RecentScores': '成绩',
|
||||
'UserHome.NoData': '过去 ${days} 天内没有玩过',
|
||||
'UserHome.UnknownSong': "(未知曲目)",
|
||||
'UserHome.UnknownSong': "(未知曲目)",
|
||||
'UserHome.Settings': '设置',
|
||||
'UserHome.NoValidGame': "用户还没有玩过游戏",
|
||||
'UserHome.ShowRanksDetails': "点击显示评分详细",
|
||||
@ -38,8 +36,7 @@ const zhUser: typeof EN_REF_USER = {
|
||||
'UserHome.B50': "B50",
|
||||
'UserHome.AddRival': "添加劲敌",
|
||||
'UserHome.RemoveRival': "移除劲敌",
|
||||
'UserHome.InvalidGame': "游戏 ${game} 还不支持网页端查看。我们目前只支持舞萌、中二、华卡和音击。",
|
||||
'UserHome.ShowMoreRecent': "显示更多",
|
||||
'UserHome.InvalidGame': "游戏 ${game} 还不支持网页端查看。我们目前只支持舞萌、中二、Wacca 和音击。",
|
||||
}
|
||||
|
||||
const zhWelcome: typeof EN_REF_Welcome = {
|
||||
@ -51,14 +48,14 @@ const zhWelcome: typeof EN_REF_Welcome = {
|
||||
'welcome.btn-signup': '注册',
|
||||
'welcome.email-password-missing': '邮箱和密码必须填哦',
|
||||
'welcome.username-missing': '用户名/邮箱必须填哦',
|
||||
'welcome.waiting-turnstile': '正在验证网络环境…',
|
||||
'welcome.turnstile-error': '验证网络环境出错了,请关闭 VPN 后重试',
|
||||
'welcome.waiting-turnstile': '正在验证网络环境...',
|
||||
'welcome.turnstile-error': '验证网络环境出错了,请关闭VPN后重试',
|
||||
'welcome.turnstile-timeout': '验证网络环境超时了,请重试',
|
||||
'welcome.verification-sent': '验证邮件已发送至 ${email},请翻翻收件箱',
|
||||
'welcome.verify-state-0': '您还没有验证邮箱哦!验证邮件一分钟内刚刚发到您的邮箱,请翻翻收件箱',
|
||||
'welcome.verify-state-1': '您还没有验证邮箱哦!我们在过去的 24 小时内已经发送了 3 封验证邮件,所以我们不会再发送了,请翻翻收件箱',
|
||||
'welcome.verify-state-1': '您还没有验证邮箱哦!我们在过去的24小时内已经发送了3封验证邮件,所以我们不会再发送了,请翻翻收件箱',
|
||||
'welcome.verify-state-2': '您还没有验证邮箱哦!我们刚刚又发送了一封验证邮件,请翻翻收件箱',
|
||||
'welcome.verifying': '正在验证邮箱…请稍等',
|
||||
'welcome.verifying': '正在验证邮箱...请稍等',
|
||||
'welcome.verified': '您的邮箱已经验证成功!您现在可以登录了',
|
||||
'welcome.verification-failed': '验证失败:${message}。请重试',
|
||||
}
|
||||
@ -76,7 +73,7 @@ const zhGeneral: typeof EN_REF_GENERAL = {
|
||||
'game.mai2': "舞萌",
|
||||
'game.chu3': "中二",
|
||||
'game.ongeki': "音击",
|
||||
'game.wacca': "华卡",
|
||||
'game.wacca': "Wacca",
|
||||
"status.error": "发生错误",
|
||||
"status.error.hint": "出了一些问题,请稍后刷新重试或者",
|
||||
"status.error.hint.link": "加我们的 Discord 群问一问",
|
||||
@ -84,11 +81,6 @@ const zhGeneral: typeof EN_REF_GENERAL = {
|
||||
"action.refresh": "刷新",
|
||||
"action.cancel": "取消",
|
||||
"action.confirm": "确认",
|
||||
'navigation.profile': '个人资料',
|
||||
'navigation.maps': '地图',
|
||||
'navigation.home': '首页',
|
||||
'navigation.rankings': '排行榜',
|
||||
'navigation.notice': '公告'
|
||||
}
|
||||
|
||||
const zhHome: typeof EN_REF_HOME = {
|
||||
@ -98,7 +90,7 @@ const zhHome: typeof EN_REF_HOME = {
|
||||
'home.manage-cards': '管理游戏卡',
|
||||
'home.manage-cards-description': '绑定、解绑、管理游戏数据卡',
|
||||
'home.link-card': '绑定游戏卡',
|
||||
'home.link-cards-description':'绑定游戏数据卡(Amusement IC 或 Aime 卡)后才可以访问游戏存档哦',
|
||||
'home.link-cards-description':'绑定游戏数据卡 (Amusement IC 或 Aime 卡) 后才可以访问游戏存档哦',
|
||||
'home.join-community': '加入群组',
|
||||
'home.join-community-description': '加入我们的聊天群组,与其他玩家聊天、获取帮助',
|
||||
'home.setup': '连接到 AquaDX',
|
||||
@ -110,11 +102,10 @@ const zhHome: typeof EN_REF_HOME = {
|
||||
'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-info': "请输入以下信息",
|
||||
'home.linkcard.access-code': "卡背面的20位卡号 (如果没有, 请尝试在游戏中扫描您的卡, 并输入屏幕上显示的卡号)",
|
||||
'home.linkcard.enter-sn1': "在您的手机",
|
||||
'home.linkcard.enter-sn2': "上下载 NFC Tools 并扫描您的卡。然后输入显示的 SN 号。",
|
||||
'home.linkcard.kdx-notice': "如果你在玩 KanadeDX,请在这里输入虚拟卡号(可以在设置 > 卡号中找到卡号)",
|
||||
'home.linkcard.link': "绑定",
|
||||
'home.linkcard.data-conflict': "卡号冲突",
|
||||
'home.linkcard.name': "名称",
|
||||
@ -124,19 +115,15 @@ const zhHome: typeof EN_REF_HOME = {
|
||||
'home.linkcard.linked-another': "此卡已链接到其他用户",
|
||||
'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.linkcard.unlink-notice': "你确定要取消此卡的链接吗?",
|
||||
'home.setup.welcome': "欢迎! 如果您有街机框体或者手台, 请按照以下说明设置以连接到 AquaDX.",
|
||||
'home.setup.blockquote': "我们假设您已经拥有所需的文件, 并且可以启动机台或手台附带的游戏 (例如 ROM 和 segatools )。如果没有, 请联系您设备的卖家以获取所需的文件, 因为出于版权原因, 我们不会提供这些文件。",
|
||||
'home.setup.get': "开始",
|
||||
'home.setup.edit': "请打开您的 segatools.ini 文件并修改以下行",
|
||||
'home.setup.test': "在您重新启动游戏后,应该能够连接到 AquaDX。可以验证测试菜单中的网络测试测试连接是否全部良好。",
|
||||
'home.setup.ask': "如果您有任何问题,请加入我们的",
|
||||
'home.setup.test': "在您重新启动游戏后, 应该能够连接到 AquaDX。可以验证测试菜单中的网络测试测试连接是否全部良好。",
|
||||
'home.setup.ask': "如果您有任何问题, 请加入我们的",
|
||||
'home.setup.support': "以获取支持",
|
||||
'home.setup.keychip-tips': "这是你的狗号,不要与任何人分享",
|
||||
'home.community.discord': 'Discord',
|
||||
'home.community.telegram': 'Telegram (中文)',
|
||||
'home.community.qq': 'QQ (中文)',
|
||||
'home.setup.keychip-tips': "这是你的狗号, 不要与任何人分享",
|
||||
'home.import.unknown-game': '未知游戏类型 (目前导入只支持舞萌和中二)',
|
||||
'home.import.new-data': '要导入的数据',
|
||||
'home.import.data-conflict': '继续导入将覆盖现有数据',
|
||||
@ -148,8 +135,7 @@ const zhSettings: typeof EN_REF_SETTINGS = {
|
||||
'settings.tabs.game': '游戏设置',
|
||||
'settings.tabs.chu3': '中二',
|
||||
'settings.tabs.mai2': '舞萌',
|
||||
'settings.tabs.ongeki': '音击',
|
||||
'settings.tabs.wacca': '华卡',
|
||||
'settings.tabs.wacca': 'Wacca',
|
||||
'settings.fields.unlockMusic.name': '解锁谱面',
|
||||
'settings.fields.unlockMusic.desc': '在游戏中解锁所有曲目和大师难度谱面。',
|
||||
'settings.fields.unlockChara.name': '解锁角色',
|
||||
@ -158,58 +144,39 @@ const zhSettings: typeof EN_REF_SETTINGS = {
|
||||
'settings.fields.unlockCollectables.desc': '在游戏中解锁所有收藏品(名牌、称号、图标、背景图)。',
|
||||
'settings.fields.unlockTickets.name': '解锁游戏券',
|
||||
'settings.fields.unlockTickets.desc': '无限跑图券/解锁券(注:maimai 客户端仍限制一些券不能使用)。',
|
||||
'settings.fields.waccaInfiniteWp.name': '华卡:无限 WP',
|
||||
'settings.fields.waccaInfiniteWp.name': 'Wacca: 无限 WP',
|
||||
'settings.fields.waccaInfiniteWp.desc': '将 WP 设置为 999999',
|
||||
'settings.fields.waccaAlwaysVip.name': '华卡:永久会员',
|
||||
'settings.fields.waccaAlwaysVip.name': 'Wacca: 永久会员',
|
||||
'settings.fields.waccaAlwaysVip.desc': '将 VIP 到期时间设置为 2077-01-01',
|
||||
'settings.fields.chusanTeamName.name': '队伍名称',
|
||||
'settings.fields.chusanTeamName.name': '中二: 队伍名称',
|
||||
'settings.fields.chusanTeamName.desc': '自定义显示在个人资料顶部的文本。',
|
||||
'settings.fields.chusanInfinitePenguins.name': '我是桐谷遥',
|
||||
'settings.fields.chusanInfinitePenguins.name': '中二: 无限企鹅',
|
||||
'settings.fields.chusanInfinitePenguins.desc': '将角色界限突破的企鹅雕像数量设置为 999。',
|
||||
'settings.fields.chusanMatchingReflector.name': '全国对战 Reflector',
|
||||
'settings.fields.chusanMatchingReflector.desc': '全国对战服务器的 UDP 反射服务器的 URL',
|
||||
'settings.fields.chusanMatchingServer.name': '全国对战服务器',
|
||||
'settings.fields.chusanMatchingServer.desc': '全国对战服务器的 URL',
|
||||
'settings.fields.ongekiInfiniteKaika.name': '无限解花',
|
||||
'settings.fields.ongekiInfiniteKaika.desc': '将解花数量设置为 999。',
|
||||
'settings.fields.rounding.name': '分数舍入',
|
||||
'settings.fields.rounding.desc': '把分数四舍五入到一位小数',
|
||||
'settings.fields.gameUsername.name': '游戏用户名',
|
||||
'settings.fields.gameUsername.desc': '在游戏中显示的用户名',
|
||||
'settings.fields.optOutOfLeaderboard.name': '不参与排行榜',
|
||||
'settings.fields.optOutOfLeaderboard.desc': '登录之后还是可以在排行榜上看到自己',
|
||||
'settings.fields.enableMusicRank.name': '在你的机台上启用「推荐乐曲排行榜」',
|
||||
'settings.fields.enableMusicRank.desc': '如果你自己设计了排行榜的话,可以关闭这个(会影响你自己的机器)。',
|
||||
'settings.mai2.name': '玩家名字',
|
||||
'settings.profile.picture': '头像',
|
||||
'settings.profile.upload-new': '上传',
|
||||
'settings.profile.bad-format': '无效的图片格式,支持的格式有 PNG、JPG、JPEG、WEBP 和 GIF。',
|
||||
'settings.profile.save': '保存',
|
||||
'settings.profile.name': '昵称',
|
||||
'settings.profile.username': '用户名',
|
||||
'settings.profile.password': '密码',
|
||||
'settings.profile.country': '国家',
|
||||
'settings.profile.location': '位置',
|
||||
'settings.profile.bio': '简介',
|
||||
'settings.profile.unset': '未设置',
|
||||
'settings.profile.logout': '登出',
|
||||
'settings.profile.unchanged': '未更改',
|
||||
'settings.export': '导出玩家数据',
|
||||
'settings.cabNotice': '注意:下面这些设置只会影响你自己的机器,如果你是在其他人的机器上玩的话,请联系机主来改设置',
|
||||
'settings.gameNotice': "这些设置仅对舞萌和华卡生效。",
|
||||
}
|
||||
|
||||
export const zhUserbox: typeof EN_REF_USERBOX = {
|
||||
'userbox.header.general': '游戏设置',
|
||||
'userbox.header.matching': '全国对战',
|
||||
'userbox.header.matching.symbolChat': '全国对战聊天表情',
|
||||
'userbox.header.userbox': 'UserBox 设置',
|
||||
'userbox.header.preview': 'UserBox 预览',
|
||||
'userbox.nameplateId': '名牌',
|
||||
'userbox.frameId': '边框',
|
||||
'userbox.trophyId': '称号',
|
||||
'userbox.trophyIdSub1': '称号2',
|
||||
'userbox.trophyIdSub2': '称号3',
|
||||
'userbox.mapIconId': '地图图标',
|
||||
'userbox.voiceId': '系统语音',
|
||||
'userbox.avatarWear': '企鹅服饰',
|
||||
@ -219,77 +186,10 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
|
||||
'userbox.avatarItem': '企鹅物品',
|
||||
'userbox.avatarFront': '企鹅前景',
|
||||
'userbox.avatarBack': '企鹅背景',
|
||||
'userbox.preview.notice': '「生存战略」:为了尊重版权,我们不会提供游戏内物品的图片。但是如果你认识其他愿意提供图床的人,在这里输入 URL 就可以显示出预览。',
|
||||
'userbox.preview.url': '图床 URL',
|
||||
'userbox.error.nodata': '未找到中二数据',
|
||||
|
||||
'userbox.matching.select': '选择对战服务器',
|
||||
'userbox.matching.select.sub': '选择你想加入的跨服全国对战服务器',
|
||||
'userbox.matching.option.ui': '房间列表',
|
||||
'userbox.matching.option.guide': '教程',
|
||||
'userbox.matching.option.collab': '合作伙伴',
|
||||
'userbox.matching.custom.name': '自定义',
|
||||
'userbox.matching.custom.sub': '输入其他的匹配 URL',
|
||||
'userbox.matching.symbolChat': '表情选择',
|
||||
'userbox.matching.symbolChat.default': '默认',
|
||||
|
||||
'userbox.new.name': 'AquaBox',
|
||||
'userbox.new.setup': '将中二(Lumi 或更高版本)的游戏文件夹拖放到下方区域,以显示带有名牌和头像的 UserBox。所有文件都在浏览器中处理。',
|
||||
'userbox.new.setup.notice': '选择包含游戏数据的最外层文件夹。',
|
||||
'userbox.new.setup.processing_file': '正在处理文件',
|
||||
'userbox.new.setup.finalizing': '正在保存到内部存储',
|
||||
'userbox.new.drop': '将游戏文件夹拖到此处',
|
||||
'userbox.new.switch.to_url': '切换到 URL 模式',
|
||||
'userbox.new.switch.to_drop': '切换到游戏目录模式',
|
||||
'userbox.new.url_warning': '请输入访问 UserBox 资源的 URL(请参阅文档)',
|
||||
'userbox.new.activate_first': '启用 AquaBox(需要游戏文件)',
|
||||
'userbox.new.activate_update': '更新 AquaBox(需要游戏文件)',
|
||||
'userbox.new.activate': '使用 AquaBox',
|
||||
'userbox.new.activate_desc': '启用后可显示带有名牌和头像的 UserBox',
|
||||
'userbox.new.activate_profile': '在用户页面启用 AquaBox',
|
||||
'userbox.new.activate_profile_desc': '启用后可在个人页面显示带有名牌和头像的 UserBox',
|
||||
'userbox.new.error.invalidFolder': '所选文件夹无效。请确认游戏版本为 Lumi 或更新,并且包含 “A000” 选项包。',
|
||||
'userbox.new.error.invalidUrl': '输入的 URL 无效。'
|
||||
};
|
||||
|
||||
export const zhMaiPhoto: typeof EN_REF_MAI_PHOTO = {
|
||||
'maiphoto.title': 'Mai 纪念照片库',
|
||||
'maiphoto.url_warning': '注意:如果想与朋友分享图片的话,请先保存照片再发出去。不要复制图片 URL,因为 URL 中包含 AquaDX 账号信息。',
|
||||
'maiphoto.none': '还没有图片哦~ 可以在每次游戏结束的时候点击上传来上传照片。',
|
||||
}
|
||||
|
||||
export const zhAquaTrans: typeof EN_REF_AQUATRANS = {
|
||||
'trans.title': '🏳️⚧️ AquaTrans™ 数据迁移工具',
|
||||
'trans.confirm.unbackuped.title': '确认迁移',
|
||||
'trans.confirm.unbackuped.msg': '似乎还没有备份目标服务器的数据,真的要继续吗?(推荐先备份一下,因为迁移的时候会覆盖数据)',
|
||||
'trans.confirm.untested.title': '不太聪明喵',
|
||||
'trans.confirm.untested.msg': '在两个服务器上都测试完连接之后才能进行数据迁移哦!',
|
||||
'trans.confirm.done.title': '完成!',
|
||||
'trans.confirm.done.msg': '数据迁移成功!在 ${dst} 上的数据已被来自 ${src} 的数据覆盖。',
|
||||
'trans.alert.in-progress': '在迁移了在迁移了',
|
||||
'trans.prompt-html': `
|
||||
<p>👋 欢迎使用 AquaTrans™ 服务器游玩数据迁移工具!</p>
|
||||
<p>这个工具可以导出任意服务器的数据,并使用连接凭证(卡号、服务器地址和 Keychip ID)将数据导入任何其他服务器。</p>
|
||||
<p>我将模拟游戏客户端,从源服务器拉取游戏数据并推送到目标服务器。</p>
|
||||
<p>填写下面的表格开始迁移吧!</p>
|
||||
`,
|
||||
'trans.error.empty': '请填写所有字段。',
|
||||
'trans.error.untested': '请先进行连接测试。',
|
||||
'trans.success.import': '数据导入成功!',
|
||||
'trans.source.title': '源服务器',
|
||||
'trans.target.title': '目标服务器',
|
||||
'trans.field.addr': '服务器地址',
|
||||
'trans.field.keychip': '狗号',
|
||||
'trans.field.game': '游戏',
|
||||
'trans.field.version': '版本',
|
||||
'trans.field.card': '卡号',
|
||||
'trans.btn.test': '测试连接',
|
||||
'trans.btn.export': '导出数据',
|
||||
'trans.btn.import': '导入数据',
|
||||
'trans.blacklist': "这个服务器的服主把这个导出工具 ban 了,所以不能从这里导出",
|
||||
}
|
||||
|
||||
|
||||
export const ZH = { ...zhUser, ...zhWelcome, ...zhGeneral,
|
||||
...zhLeaderboard, ...zhHome, ...zhSettings, ...zhUserbox, ...zhMaiPhoto,
|
||||
...zhAquaTrans
|
||||
}
|
||||
...zhLeaderboard, ...zhHome, ...zhSettings, ...zhUserbox }
|
||||
|
||||
@ -1,248 +0,0 @@
|
||||
{
|
||||
"AF": "阿富汗",
|
||||
"AX": "奥兰群岛",
|
||||
"AL": "阿尔巴尼亚",
|
||||
"DZ": "阿尔及利亚",
|
||||
"AS": "美属萨摩亚",
|
||||
"AD": "安道尔",
|
||||
"AO": "安哥拉",
|
||||
"AI": "安圭拉",
|
||||
"AQ": "南极洲",
|
||||
"AG": "安提瓜和巴布达",
|
||||
"AR": "阿根廷",
|
||||
"AM": "亚美尼亚",
|
||||
"AW": "阿鲁巴",
|
||||
"AU": "澳大利亚",
|
||||
"AT": "奥地利",
|
||||
"AZ": "阿塞拜疆",
|
||||
"BS": "巴哈马",
|
||||
"BH": "巴林",
|
||||
"BD": "孟加拉国",
|
||||
"BB": "巴巴多斯",
|
||||
"BY": "白俄罗斯",
|
||||
"BE": "比利时",
|
||||
"BZ": "伯利兹",
|
||||
"BJ": "贝宁",
|
||||
"BM": "百慕大",
|
||||
"BT": "不丹",
|
||||
"BO": "玻利维亚",
|
||||
"BA": "波斯尼亚和黑塞哥维那",
|
||||
"BW": "博茨瓦纳",
|
||||
"BV": "布维岛",
|
||||
"BR": "巴西",
|
||||
"IO": "英属印度洋领地",
|
||||
"BN": "文莱达鲁萨兰国",
|
||||
"BG": "保加利亚",
|
||||
"BF": "布基纳法索",
|
||||
"BI": "布隆迪",
|
||||
"KH": "柬埔寨",
|
||||
"CM": "喀麦隆",
|
||||
"CA": "加拿大",
|
||||
"CV": "佛得角",
|
||||
"KY": "开曼群岛",
|
||||
"CF": "中非共和国",
|
||||
"TD": "乍得",
|
||||
"CL": "智利",
|
||||
"CN": "中国",
|
||||
"CX": "圣诞岛",
|
||||
"CC": "科科斯(基林)群岛",
|
||||
"CO": "哥伦比亚",
|
||||
"KM": "科摩罗",
|
||||
"CG": "刚果",
|
||||
"CD": "刚果民主共和国",
|
||||
"CK": "库克群岛",
|
||||
"CR": "哥斯达黎加",
|
||||
"CI": "科特迪瓦",
|
||||
"HR": "克罗地亚",
|
||||
"CU": "古巴",
|
||||
"CY": "塞浦路斯",
|
||||
"CZ": "捷克共和国",
|
||||
"DK": "丹麦",
|
||||
"DJ": "吉布提",
|
||||
"DM": "多米尼加",
|
||||
"DO": "多米尼加共和国",
|
||||
"EC": "厄瓜多尔",
|
||||
"EG": "埃及",
|
||||
"SV": "萨尔瓦多",
|
||||
"GQ": "赤道几内亚",
|
||||
"ER": "厄立特里亚",
|
||||
"EE": "爱沙尼亚",
|
||||
"ET": "埃塞俄比亚",
|
||||
"FK": "福克兰群岛(马尔维纳斯群岛)",
|
||||
"FO": "法罗群岛",
|
||||
"FJ": "斐济",
|
||||
"FI": "芬兰",
|
||||
"FR": "法国",
|
||||
"GF": "法属圭亚那",
|
||||
"PF": "法属波利尼西亚",
|
||||
"TF": "法属南部领土",
|
||||
"GA": "加蓬",
|
||||
"GM": "冈比亚",
|
||||
"GE": "格鲁吉亚",
|
||||
"DE": "德国",
|
||||
"GH": "加纳",
|
||||
"GI": "直布罗陀",
|
||||
"GR": "希腊",
|
||||
"GL": "格陵兰",
|
||||
"GD": "格林纳达",
|
||||
"GP": "瓜德罗普",
|
||||
"GU": "关岛",
|
||||
"GT": "危地马拉",
|
||||
"GG": "根西岛",
|
||||
"GN": "几内亚",
|
||||
"GW": "几内亚比绍",
|
||||
"GY": "圭亚那",
|
||||
"HT": "海地",
|
||||
"HM": "赫德岛和麦克唐纳群岛",
|
||||
"VA": "梵蒂冈(教廷)",
|
||||
"HN": "洪都拉斯",
|
||||
"HK": "香港",
|
||||
"HU": "匈牙利",
|
||||
"IS": "冰岛",
|
||||
"IN": "印度",
|
||||
"ID": "印度尼西亚",
|
||||
"IR": "伊朗(伊斯兰共和国)",
|
||||
"IQ": "伊拉克",
|
||||
"IE": "爱尔兰",
|
||||
"IM": "马恩岛",
|
||||
"IL": "以色列",
|
||||
"IT": "意大利",
|
||||
"JM": "牙买加",
|
||||
"JP": "日本",
|
||||
"JE": "泽西岛",
|
||||
"JO": "约旦",
|
||||
"KZ": "哈萨克斯坦",
|
||||
"KE": "肯尼亚",
|
||||
"KI": "基里巴斯",
|
||||
"KR": "韩国",
|
||||
"KP": "朝鲜",
|
||||
"KW": "科威特",
|
||||
"KG": "吉尔吉斯斯坦",
|
||||
"LA": "老挝人民民主共和国",
|
||||
"LV": "拉脱维亚",
|
||||
"LB": "黎巴嫩",
|
||||
"LS": "莱索托",
|
||||
"LR": "利比里亚",
|
||||
"LY": "利比亚阿拉伯民众国",
|
||||
"LI": "列支敦士登",
|
||||
"LT": "立陶宛",
|
||||
"LU": "卢森堡",
|
||||
"MO": "澳门",
|
||||
"MK": "马其顿",
|
||||
"MG": "马达加斯加",
|
||||
"MW": "马拉维",
|
||||
"MY": "马来西亚",
|
||||
"MV": "马尔代夫",
|
||||
"ML": "马里",
|
||||
"MT": "马耳他",
|
||||
"MH": "马绍尔群岛",
|
||||
"MQ": "马提尼克",
|
||||
"MR": "毛里塔尼亚",
|
||||
"MU": "毛里求斯",
|
||||
"YT": "马约特",
|
||||
"MX": "墨西哥",
|
||||
"FM": "密克罗尼西亚联邦",
|
||||
"MD": "摩尔多瓦",
|
||||
"MC": "摩纳哥",
|
||||
"MN": "蒙古",
|
||||
"ME": "黑山",
|
||||
"MS": "蒙特塞拉特",
|
||||
"MA": "摩洛哥",
|
||||
"MZ": "莫桑比克",
|
||||
"MM": "缅甸",
|
||||
"NA": "纳米比亚",
|
||||
"NR": "瑙鲁",
|
||||
"NP": "尼泊尔",
|
||||
"NL": "荷兰",
|
||||
"AN": "荷属安的列斯",
|
||||
"NC": "新喀里多尼亚",
|
||||
"NZ": "新西兰",
|
||||
"NI": "尼加拉瓜",
|
||||
"NE": "尼日尔",
|
||||
"NG": "尼日利亚",
|
||||
"NU": "纽埃",
|
||||
"NF": "诺福克岛",
|
||||
"MP": "北马里亚纳群岛",
|
||||
"NO": "挪威",
|
||||
"OM": "阿曼",
|
||||
"PK": "巴基斯坦",
|
||||
"PW": "帕劳",
|
||||
"PS": "被占领的巴勒斯坦领土",
|
||||
"PA": "巴拿马",
|
||||
"PG": "巴布亚新几内亚",
|
||||
"PY": "巴拉圭",
|
||||
"PE": "秘鲁",
|
||||
"PH": "菲律宾",
|
||||
"PN": "皮特凯恩",
|
||||
"PL": "波兰",
|
||||
"PT": "葡萄牙",
|
||||
"PR": "波多黎各",
|
||||
"QA": "卡塔尔",
|
||||
"RE": "留尼汪",
|
||||
"RO": "罗马尼亚",
|
||||
"RU": "俄罗斯联邦",
|
||||
"RW": "卢旺达",
|
||||
"BL": "圣巴泰勒米",
|
||||
"SH": "圣赫勒拿",
|
||||
"KN": "圣基茨和尼维斯",
|
||||
"LC": "圣卢西亚",
|
||||
"MF": "圣马丁",
|
||||
"PM": "圣皮埃尔和密克隆",
|
||||
"VC": "圣文森特和格林纳丁斯",
|
||||
"WS": "萨摩亚",
|
||||
"SM": "圣马力诺",
|
||||
"ST": "圣多美和普林西比",
|
||||
"SA": "沙特阿拉伯",
|
||||
"SN": "塞内加尔",
|
||||
"RS": "塞尔维亚",
|
||||
"SC": "塞舌尔",
|
||||
"SL": "塞拉利昂",
|
||||
"SG": "新加坡",
|
||||
"SK": "斯洛伐克",
|
||||
"SI": "斯洛文尼亚",
|
||||
"SB": "所罗门群岛",
|
||||
"SO": "索马里",
|
||||
"ZA": "南非",
|
||||
"GS": "南乔治亚和南桑威奇群岛",
|
||||
"ES": "西班牙",
|
||||
"LK": "斯里兰卡",
|
||||
"SD": "苏丹",
|
||||
"SR": "苏里南",
|
||||
"SJ": "斯瓦尔巴和扬马延",
|
||||
"SZ": "斯威士兰",
|
||||
"SE": "瑞典",
|
||||
"CH": "瑞士",
|
||||
"SY": "叙利亚",
|
||||
"TW": "台湾",
|
||||
"TJ": "塔吉克斯坦",
|
||||
"TZ": "坦桑尼亚",
|
||||
"TH": "泰国",
|
||||
"TL": "东帝汶",
|
||||
"TG": "多哥",
|
||||
"TK": "托克劳",
|
||||
"TO": "汤加",
|
||||
"TT": "特立尼达和多巴哥",
|
||||
"TN": "突尼斯",
|
||||
"TR": "土耳其",
|
||||
"TM": "土库曼斯坦",
|
||||
"TC": "特克斯和凯科斯群岛",
|
||||
"TV": "图瓦卢",
|
||||
"UG": "乌干达",
|
||||
"UA": "乌克兰",
|
||||
"AE": "阿拉伯联合酋长国",
|
||||
"GB": "英国",
|
||||
"US": "美国",
|
||||
"UM": "美国本土外岛",
|
||||
"UY": "乌拉圭",
|
||||
"UZ": "乌兹别克斯坦",
|
||||
"VU": "瓦努阿图",
|
||||
"VE": "委内瑞拉",
|
||||
"VN": "越南",
|
||||
"VG": "英属维京群岛",
|
||||
"VI": "美属维京群岛",
|
||||
"WF": "瓦利斯和富图纳",
|
||||
"EH": "西撒哈拉",
|
||||
"YE": "也门",
|
||||
"ZM": "赞比亚",
|
||||
"ZW": "津巴布韦"
|
||||
}
|
||||
@ -67,7 +67,7 @@ const multTable = {
|
||||
[ 60.0, 0, 'B' ],
|
||||
[ 1.0, 0, 'C' ],
|
||||
[ 0.0, 0, 'D' ]
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
export function getMult(achievement: number, game: GameName) {
|
||||
@ -134,8 +134,8 @@ export function parseComposition(item: string, allMusics: Record<string, MusicMe
|
||||
if (!diff) return
|
||||
if (game === 'mai2')
|
||||
return Math.floor(diff * mult * (Math.min(100.5, score / 10000) / 100)).toFixed(0)
|
||||
if (game === 'chu3' || game === 'ongeki')
|
||||
return (Math.floor(chusanRating(diff, score)) / 100).toFixed(2)
|
||||
if (game === 'chu3')
|
||||
return (chusanRating(diff, score) / 100).toFixed(1)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -8,14 +8,13 @@ import type {
|
||||
TrendEntry,
|
||||
AquaNetUser, GameOption,
|
||||
UserBox,
|
||||
UserItem,
|
||||
Dict
|
||||
UserItem
|
||||
} from './generalTypes'
|
||||
import type { GameName } from './scoring'
|
||||
|
||||
interface ExtReqInit extends RequestInit {
|
||||
interface RequestInitWithParams extends RequestInit {
|
||||
params?: { [index: string]: string }
|
||||
json?: any
|
||||
localCache?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@ -38,18 +37,33 @@ export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) =>
|
||||
/**
|
||||
* Fetch with url parameters
|
||||
*/
|
||||
export function fetchWithParams(input: URL | RequestInfo, init?: ExtReqInit): Promise<Response> {
|
||||
export function fetchWithParams(input: URL | RequestInfo, init?: RequestInitWithParams): Promise<Response> {
|
||||
return fetch(reconstructUrl(input, u => {
|
||||
u.search = new URLSearchParams(init?.params ?? {}).toString()
|
||||
}), init)
|
||||
}
|
||||
|
||||
/**
|
||||
* Do something with the response when it's not ok
|
||||
*
|
||||
* @param res Response object
|
||||
*/
|
||||
async function ensureOk(res: Response) {
|
||||
const cache: { [index: string]: any } = {}
|
||||
|
||||
export async function post(endpoint: string, params: Record<string, any> = {}, init?: RequestInitWithParams): Promise<any> {
|
||||
// Add token if exists
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && !('token' in params)) params = { ...(params ?? {}), token }
|
||||
|
||||
if (init?.localCache) {
|
||||
const cached = cache[endpoint + JSON.stringify(params) + JSON.stringify(init)]
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const res = await fetchWithParams(AQUA_HOST + endpoint, {
|
||||
method: 'POST',
|
||||
params,
|
||||
...init
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
throw new Error('Network error')
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
console.error(`${res.status}: ${text}`)
|
||||
@ -69,82 +83,142 @@ async function ensureOk(res: Response) {
|
||||
}
|
||||
if (json.error) throw new Error(json.error)
|
||||
}
|
||||
|
||||
const ret = res.json()
|
||||
cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] = ret
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Post to an endpoint and return the response in JSON while doing error checks
|
||||
* and handling token (and token expiry) automatically.
|
||||
*
|
||||
* @param endpoint The endpoint to post to (e.g., '/pull')
|
||||
* @param params An object containing the request body or any necessary parameters
|
||||
* @param init Additional fetch/init configuration
|
||||
* @returns The JSON response from the server
|
||||
*/
|
||||
export async function post(endpoint: string, params: Dict = {}, init?: ExtReqInit): Promise<any> {
|
||||
return postHelper(endpoint, params, init).then(it => it.json())
|
||||
}
|
||||
|
||||
/**
|
||||
* Actual impl of post(). This does not return JSON but returns response object.
|
||||
*/
|
||||
async function postHelper(endpoint: string, params: Dict = {}, init?: ExtReqInit): Promise<any> {
|
||||
export async function get(endpoint: string, params:any,init?: RequestInitWithParams): Promise<any> {
|
||||
// Add token if exists
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && !('token' in params)) params = { ...(params ?? {}), token }
|
||||
|
||||
if (init?.json) {
|
||||
init.body = JSON.stringify(init.json)
|
||||
init.headers = { 'Content-Type': 'application/json', ...init.headers }
|
||||
init.json = undefined
|
||||
if (init?.localCache) {
|
||||
const cached = cache[endpoint + JSON.stringify(init)]
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const res = await fetchWithParams(AQUA_HOST + endpoint, { method: 'POST', params, ...init })
|
||||
.catch(e => { console.error(e); throw new Error("Network error") })
|
||||
await ensureOk(res)
|
||||
const res = await fetchWithParams(AQUA_HOST + endpoint, {
|
||||
method: 'GET',
|
||||
params,
|
||||
...init
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
throw new Error('Network error')
|
||||
})
|
||||
|
||||
return res
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
console.error(`${res.status}: ${text}`)
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
/**
|
||||
* Post with a stream response. Similar to post(), but the response will stream messages to onChunk.
|
||||
*/
|
||||
export async function postStream(endpoint: string, params: Dict = {}, onChunk: (data: any) => void, init?: ExtReqInit): Promise<void> {
|
||||
const res = await postHelper(endpoint, params, init)
|
||||
if (!res.body) {
|
||||
console.error('Response body is not a stream')
|
||||
return
|
||||
}
|
||||
|
||||
// The response body is a ReadableStream. We'll read chunks as they arrive.
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) return
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
// Decode any new data, parse full lines, keep the rest in buffer
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let fullLines = buffer.split('\n')
|
||||
buffer = fullLines.pop() ?? ''
|
||||
|
||||
for (const line of fullLines) {
|
||||
if (!line.trim()) continue // skip empty lines
|
||||
onChunk(JSON.parse(line))
|
||||
}
|
||||
// If 400 invalid token is caught, should invalidate the token and redirect to signin
|
||||
if (text === 'Invalid token') {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
// If there's leftover data in 'buffer' after stream ends, parse
|
||||
if (buffer.trim())
|
||||
onChunk(JSON.parse(buffer.trim()))
|
||||
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
// Try to parse as json
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch (e) {
|
||||
throw new Error(text)
|
||||
}
|
||||
if (json.error) throw new Error(json.error)
|
||||
}
|
||||
|
||||
const ret = res.json()
|
||||
cache[endpoint + JSON.stringify(init)] = ret
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
export async function put(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
|
||||
// Add token if exists
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && !('token' in params)) params = { ...(params ?? {}), token }
|
||||
|
||||
if (init?.localCache) {
|
||||
const cached = cache[endpoint + JSON.stringify(params) + JSON.stringify(init)]
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const res = await fetchWithParams(AQUA_HOST + endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(params),
|
||||
headers:{
|
||||
'Content-Type':'application/json',
|
||||
...init?.headers
|
||||
},
|
||||
...init
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
throw new Error('Network error')
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
console.error(`${res.status}: ${text}`)
|
||||
|
||||
// If 400 invalid token is caught, should invalidate the token and redirect to signin
|
||||
if (text === 'Invalid token') {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
// Try to parse as json
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch (e) {
|
||||
throw new Error(text)
|
||||
}
|
||||
if (json.error) throw new Error(json.error)
|
||||
}
|
||||
|
||||
const ret = res.json()
|
||||
cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] = ret
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
export async function realPost(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
|
||||
const res = await fetchWithParams(AQUA_HOST + endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
headers:{
|
||||
'Content-Type':'application/json',
|
||||
...init?.headers
|
||||
},
|
||||
...init
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
throw new Error('Network error')
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
console.error(`${res.status}: ${text}`)
|
||||
|
||||
// If 400 invalid token is caught, should invalidate the token and redirect to signin
|
||||
if (text === 'Invalid token') {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
// Try to parse as json
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch (e) {
|
||||
throw new Error(text)
|
||||
}
|
||||
if (json.error) throw new Error(json.error)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -155,7 +229,6 @@ export async function postStream(endpoint: string, params: Dict = {}, onChunk: (
|
||||
async function register(user: { username: string, email: string, password: string, turnstile: string }) {
|
||||
return await post('/api/v2/user/register', user)
|
||||
}
|
||||
|
||||
async function login(user: { email: string, password: string, turnstile: string }) {
|
||||
const data = await post('/api/v2/user/login', user)
|
||||
|
||||
@ -190,11 +263,9 @@ export const USER = {
|
||||
|
||||
export const USERBOX = {
|
||||
getProfile: (): Promise<{ user: UserBox, items: UserItem[] }> =>
|
||||
post('/api/v2/game/chu3/user-box', {}),
|
||||
get('/api/v2/game/chu3/user-box', {}),
|
||||
setUserBox: (d: { field: string, value: number | string }) =>
|
||||
post(`/api/v2/game/chu3/user-detail-set`, d),
|
||||
getUserProfile: (username: string): Promise<UserBox> =>
|
||||
post(`/api/v2/game/chu3/user-detail`, {username})
|
||||
}
|
||||
|
||||
export const CARD = {
|
||||
@ -211,8 +282,6 @@ export const CARD = {
|
||||
export const GAME = {
|
||||
trend: (username: string, game: GameName): Promise<TrendEntry[]> =>
|
||||
post(`/api/v2/game/${game}/trend`, { username }),
|
||||
photos: (): Promise<string[]> =>
|
||||
post(`/api/v2/game/mai2/my-photo`, { }),
|
||||
userSummary: (username: string, game: GameName): Promise<GenericGameSummary> =>
|
||||
post(`/api/v2/game/${game}/user-summary`, { username }),
|
||||
ranking: (game: GameName): Promise<GenericRanking[]> =>
|
||||
@ -222,9 +291,9 @@ export const GAME = {
|
||||
export: (game: GameName): Promise<Record<string, any>> =>
|
||||
post(`/api/v2/game/${game}/export`),
|
||||
import: (game: GameName, data: any): Promise<Record<string, any>> =>
|
||||
post(`/api/v2/game/${game}/import`, {}, { json: data }),
|
||||
post(`/api/v2/game/${game}/import`, {}, { body: JSON.stringify(data) }),
|
||||
importMusicDetail: (game: GameName, data: any): Promise<Record<string, any>> =>
|
||||
post(`/api/v2/game/${game}/import-music-detail`, {}, { json: data }),
|
||||
post(`/api/v2/game/${game}/import-music-detail`, {}, {body: JSON.stringify(data), headers: {'Content-Type': 'application/json'}}),
|
||||
setRival: (game: GameName, rivalUserName: string, isAdd: boolean) =>
|
||||
post(`/api/v2/game/${game}/set-rival`, { rivalUserName, isAdd }),
|
||||
}
|
||||
@ -241,18 +310,4 @@ export const SETTING = {
|
||||
post('/api/v2/settings/get', {}),
|
||||
set: (key: string, value: any) =>
|
||||
post('/api/v2/settings/set', { key, value: `${value}` }),
|
||||
detailSet: (game: string, field: string, value: any) =>
|
||||
post(`/api/v2/game/${game}/user-detail-set`, { field, value }),
|
||||
}
|
||||
|
||||
export const TRANSFER = {
|
||||
check: (d: AllNetClient): Promise<TrCheckGood> =>
|
||||
post('/api/v2/transfer/check', {}, { json: d }),
|
||||
pull: (d: AllNetClient, callback: (data: TrStreamMessage) => void) =>
|
||||
postStream('/api/v2/transfer/pull', {}, callback, { json: d }),
|
||||
push: (d: AllNetClient, data: string) =>
|
||||
post('/api/v2/transfer/push', {}, { json: { client: d, data } }),
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
window.sdk = { USER, USERBOX, CARD, GAME, DATA, SETTING, TRANSFER }
|
||||
|
||||
@ -153,7 +153,6 @@ export const CHARTJS_OPT: ChartOptions<'line'> = {
|
||||
|
||||
export const pfpNotFound = (e: Event) => (e.target as HTMLImageElement).src = DEFAULT_PFP
|
||||
export const coverNotFound = (e: Event) => (e.target as HTMLImageElement).src = "/assets/imgs/no_cover.jpg"
|
||||
export const removeImg = (e: Event) => (e.target as HTMLImageElement).style.display = 'none'
|
||||
|
||||
|
||||
/**
|
||||
@ -212,53 +211,3 @@ export function pfp(node: HTMLImageElement, me?: AquaNetUser) {
|
||||
node.src = me?.profilePicture ? `${AQUA_HOST}/uploads/net/portrait/${me.profilePicture}` : DEFAULT_PFP
|
||||
node.onerror = e => pfpNotFound(e as Event)
|
||||
}
|
||||
|
||||
export function download(data: string, filename: string) {
|
||||
const blob = new Blob([data]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
}
|
||||
|
||||
export async function selectJsonFile(): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create a hidden file input element
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json,application/json';
|
||||
input.style.display = 'none';
|
||||
|
||||
// Listen for when the user selects a file
|
||||
input.addEventListener('change', (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (!target.files || target.files.length === 0) {
|
||||
return reject(new Error("No file selected"));
|
||||
}
|
||||
const file = target.files[0];
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const jsonData = JSON.parse(reader.result as string);
|
||||
resolve(jsonData);
|
||||
} catch (error) {
|
||||
reject(new Error("Error parsing JSON: " + error));
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error("Error reading file"));
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
// Append the input to the DOM, trigger click, and then remove it
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,356 +0,0 @@
|
||||
/*
|
||||
|
||||
A simplified DDS parser with Chusan userbox in mind.
|
||||
There are some issues on Safari. I don't really care, to be honest.
|
||||
Authored by Raymond and May.
|
||||
|
||||
DDS header parsing based off of https://gist.github.com/brett19/13c83c2e5e38933757c2
|
||||
|
||||
*/
|
||||
|
||||
import DDSCache from "./ddsCache";
|
||||
|
||||
function makeFourCC(string: string) {
|
||||
return string.charCodeAt(0) +
|
||||
(string.charCodeAt(1) << 8) +
|
||||
(string.charCodeAt(2) << 16) +
|
||||
(string.charCodeAt(3) << 24);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Magic bytes for the DDS file format (see https://en.wikipedia.org/wiki/Magic_number_(programming))
|
||||
*/
|
||||
const DDS_MAGIC_BYTES = 0x20534444;
|
||||
|
||||
/*
|
||||
to get around the fact that TS's builtin Object.fromEntries() typing
|
||||
doesn't persist strict types and instead only uses broad types
|
||||
without creating a new function to get around it...
|
||||
sorry, this is a really ugly solution, but it's not my problem
|
||||
*/
|
||||
|
||||
/**
|
||||
* @description List of compression type markers used in DDS
|
||||
*/
|
||||
const DDS_COMPRESSION_TYPE_MARKERS = ["DXT1", "DXT3", "DXT5"] as const;
|
||||
|
||||
/**
|
||||
* @description Object mapping string versions of DDS compression type markers to their value in uint32s
|
||||
*/
|
||||
const DDS_COMPRESSION_TYPE_MARKERS_MAP = Object.fromEntries(
|
||||
DDS_COMPRESSION_TYPE_MARKERS
|
||||
.map(e => [e, makeFourCC(e)] as [typeof e, number])
|
||||
) as Record<typeof DDS_COMPRESSION_TYPE_MARKERS[number], number>
|
||||
|
||||
const DDS_DECOMPRESS_VERTEX_SHADER = `
|
||||
attribute vec2 aPosition;
|
||||
varying highp vec2 vTextureCoord;
|
||||
void main() {
|
||||
gl_Position = vec4(aPosition, 0.0, 1.0);
|
||||
vTextureCoord = ((aPosition * vec2(1.0, -1.0)) / 2.0 + 0.5);
|
||||
}`;
|
||||
const DDS_DECOMPRESS_FRAGMENT_SHADER = `
|
||||
varying highp vec2 vTextureCoord;
|
||||
uniform sampler2D uTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTexture, vTextureCoord);
|
||||
}`
|
||||
|
||||
export interface RGB {
|
||||
r: number,
|
||||
g: number,
|
||||
b: number
|
||||
}
|
||||
|
||||
export class DDS {
|
||||
constructor(db: IDBDatabase | undefined) {
|
||||
this.cache = new DDSCache(db);
|
||||
|
||||
let gl = this.canvasGL.getContext("webgl");
|
||||
if (!gl) throw new Error("Failed to get WebGL rendering context") // TODO: make it switch to Classic userbox
|
||||
this.gl = gl;
|
||||
|
||||
let ctx = this.canvas2D.getContext("2d");
|
||||
if (!ctx) throw new Error("Failed to reach minimum system requirements") // TODO: make it switch to Classic userbox
|
||||
this.ctx = ctx;
|
||||
|
||||
let ext =
|
||||
gl.getExtension("WEBGL_compressed_texture_s3tc") ||
|
||||
gl.getExtension("MOZ_WEBGL_compressed_texture_s3tc") ||
|
||||
gl.getExtension("WEBKIT_WEBGL_compressed_texture_s3tc");
|
||||
if (!ext) throw new Error("Browser is not supported."); // TODO: make it switch to Classic userbox
|
||||
this.ext = ext;
|
||||
|
||||
/* Initialize shaders */
|
||||
this.compileShaders();
|
||||
this.gl.useProgram(this.shader);
|
||||
|
||||
/* Setup position buffer */
|
||||
let attributeLocation = this.gl.getAttribLocation(this.shader ?? 0, "aPosition");
|
||||
let positionBuffer = this.gl.createBuffer();
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
|
||||
this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0]), this.gl.STATIC_DRAW);
|
||||
|
||||
this.gl.vertexAttribPointer(
|
||||
attributeLocation,
|
||||
2, this.gl.FLOAT,
|
||||
false, 0, 0
|
||||
);
|
||||
this.gl.enableVertexAttribArray(attributeLocation)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Loads a DDS file into the internal canvas object.
|
||||
* @param buffer Uint8Array to load DDS from.
|
||||
* @returns String if failed to load, void if success
|
||||
*/
|
||||
load(buffer: Uint8Array) {
|
||||
let header = this.loadHeader(buffer);
|
||||
if (!header) return;
|
||||
|
||||
let compressionMode: GLenum = this.ext.COMPRESSED_RGBA_S3TC_DXT1_EXT;
|
||||
|
||||
if (header.pixelFormat.flags & 0x4) {
|
||||
switch (header.pixelFormat.type) {
|
||||
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT1:
|
||||
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT1_EXT;
|
||||
break;
|
||||
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT3:
|
||||
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT3_EXT;
|
||||
break;
|
||||
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT5:
|
||||
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT5_EXT;
|
||||
break;
|
||||
};
|
||||
} else return;
|
||||
|
||||
/* Initialize and configure the texture */
|
||||
let texture = this.gl.createTexture();
|
||||
this.gl.activeTexture(this.gl.TEXTURE0);
|
||||
this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
|
||||
|
||||
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
|
||||
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
|
||||
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
|
||||
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
|
||||
|
||||
this.gl.compressedTexImage2D(
|
||||
this.gl.TEXTURE_2D,
|
||||
0,
|
||||
compressionMode,
|
||||
header.width,
|
||||
header.height,
|
||||
0,
|
||||
buffer.slice(128)
|
||||
);
|
||||
|
||||
this.gl.uniform1i(this.gl.getUniformLocation(this.shader || 0, "uTexture"), 0);
|
||||
|
||||
/* Prepare the canvas for drawing */
|
||||
this.canvasGL.width = header.width;
|
||||
this.canvasGL.height = header.height
|
||||
this.gl.viewport(0, 0, this.canvasGL.width, this.canvasGL.height);
|
||||
|
||||
this.gl.clearColor(0.0, 0.0, 0.0, 0.0);
|
||||
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||
|
||||
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
||||
this.gl.deleteTexture(texture);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Export a Blob from the parsed DDS texture
|
||||
* @returns DDS texture in specified format
|
||||
* @param inFormat Mime type to export in
|
||||
*/
|
||||
getBlob(inFormat?: string): Promise<Blob | null> {
|
||||
return new Promise(res => this.canvasGL.toBlob(res, inFormat))
|
||||
}
|
||||
get2DBlob(inFormat?: string): Promise<Blob | null> {
|
||||
return new Promise(res => this.canvas2D.toBlob(res, inFormat))
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Helper function to load in a Blob
|
||||
* @input Blob to use
|
||||
*/
|
||||
async fromBlob(input: Blob) {
|
||||
this.load(new Uint8Array(await input.arrayBuffer()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Read a DDS file header
|
||||
* @param buffer Uint8Array of the DDS file's contents
|
||||
*/
|
||||
loadHeader(buffer: Uint8Array) {
|
||||
if (this.getUint32(buffer, 0) !== DDS_MAGIC_BYTES) return;
|
||||
|
||||
return {
|
||||
size: this.getUint32(buffer, 4),
|
||||
flags: this.getUint32(buffer, 8),
|
||||
height: this.getUint32(buffer, 12),
|
||||
width: this.getUint32(buffer, 16),
|
||||
mipmaps: this.getUint32(buffer, 24),
|
||||
|
||||
/* TODO: figure out if we can cut any of this out (we totally can btw) */
|
||||
pixelFormat: {
|
||||
size: this.getUint32(buffer, 76),
|
||||
flags: this.getUint32(buffer, 80),
|
||||
type: this.getUint32(buffer, 84),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Retrieve a file from the IndexedDB database and load it into the DDS loader
|
||||
* @param path File path
|
||||
* @returns Whether or not the attempt to retrieve the file was successful
|
||||
*/
|
||||
loadFile(path: string) : Promise<boolean> {
|
||||
return new Promise(async r => {
|
||||
let file = await this.cache?.getFromDatabase(path)
|
||||
if (file != null)
|
||||
await this.fromBlob(file)
|
||||
r(file != null)
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Retrieve a file from a path
|
||||
* @param path File path
|
||||
* @param fallback Path to a file to fallback to if loading this file fails
|
||||
* @returns An object URL which correlates to a Blob
|
||||
*/
|
||||
async getFile(path: string, fallback?: string) : Promise<string> {
|
||||
if (this.cache?.cached(path))
|
||||
return this.cache.find(path) ?? ""
|
||||
if (!await this.loadFile(path))
|
||||
if (fallback) {
|
||||
if (!await this.loadFile(fallback))
|
||||
return "";
|
||||
} else
|
||||
return ""
|
||||
let blob = await this.getBlob("image/png");
|
||||
if (!blob) return ""
|
||||
return this.cache?.save(
|
||||
path, URL.createObjectURL(blob)
|
||||
) ?? "";
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Transform a spritesheet located at a path to match the dimensions specified in the parameters
|
||||
* @param path Spritesheet path
|
||||
* @param x Crop: X
|
||||
* @param y Crop: Y
|
||||
* @param w Crop: Width
|
||||
* @param h Crop: Height
|
||||
* @param s Scale factor
|
||||
* @returns An object URL which correlates to a Blob
|
||||
*/
|
||||
async getFileFromSheet(path: string, x: number, y: number, w: number, h: number, s?: number, color?: RGB): Promise<string> {
|
||||
if (!await this.loadFile(path))
|
||||
return "";
|
||||
this.canvas2D.width = w * (s ?? 1);
|
||||
this.canvas2D.height = h * (s ?? 1);
|
||||
this.ctx.drawImage(this.canvasGL, x, y, w, h, 0, 0, w * (s ?? 1), h * (s ?? 1));
|
||||
|
||||
if (color) {
|
||||
let colorData = this.ctx.getImageData(0, 0, this.canvas2D.width, this.canvas2D.height);
|
||||
for (let i = 0; colorData.data.length > i; i++)
|
||||
switch (i % 4) {
|
||||
case 0:
|
||||
colorData.data[i] *= (color.r / 255); break;
|
||||
case 1:
|
||||
colorData.data[i] *= (color.g / 255); break;
|
||||
case 2:
|
||||
colorData.data[i] *= (color.b / 255); break;
|
||||
}
|
||||
this.ctx.putImageData(colorData, 0, 0);
|
||||
}
|
||||
|
||||
/* We don't want to cache this, it's a spritesheet piece. */
|
||||
return URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([]));
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Retrieve a file and scale it by a specified scale factor
|
||||
* @param path File path
|
||||
* @param s Scale factor
|
||||
* @param fallback Path to a file to fallback to if loading this file fails
|
||||
* @returns An object URL which correlates to a Blob
|
||||
*/
|
||||
async getFileScaled(path: string, s: number, fallback?: string): Promise<string> {
|
||||
if (this.cache?.cached(path, s))
|
||||
return this.cache.find(path, s) ?? ""
|
||||
if (!await this.loadFile(path))
|
||||
if (fallback) {
|
||||
if (!await this.loadFile(fallback))
|
||||
return "";
|
||||
} else
|
||||
return "";
|
||||
this.canvas2D.width = this.canvasGL.width * (s ?? 1);
|
||||
this.canvas2D.height = this.canvasGL.height * (s ?? 1);
|
||||
this.ctx.drawImage(this.canvasGL, 0, 0, this.canvasGL.width, this.canvasGL.height, 0, 0, this.canvasGL.width * (s ?? 1), this.canvasGL.height * (s ?? 1));
|
||||
|
||||
return this.cache?.save(path, URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([])), s) ?? "";
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Retrieve a Uint32 from a Uint8Array at the specified offset
|
||||
* @param buffer Uint8Array to retrieve the Uint32 from
|
||||
* @param offset Offset at which to retrieve bytes
|
||||
*/
|
||||
getUint32(buffer: Uint8Array, offset: number) {
|
||||
return (buffer[offset + 0] << 0) +
|
||||
(buffer[offset + 1] << 8) +
|
||||
(buffer[offset + 2] << 16) +
|
||||
(buffer[offset + 3] << 24);
|
||||
};
|
||||
|
||||
private compileShaders() {
|
||||
let vertexShader = this.gl.createShader(this.gl.VERTEX_SHADER);
|
||||
let fragmentShader = this.gl.createShader(this.gl.FRAGMENT_SHADER);
|
||||
|
||||
if (!vertexShader || !fragmentShader) return;
|
||||
|
||||
this.gl.shaderSource(vertexShader, DDS_DECOMPRESS_VERTEX_SHADER);
|
||||
this.gl.compileShader(vertexShader);
|
||||
|
||||
if (!this.gl.getShaderParameter(vertexShader, this.gl.COMPILE_STATUS))
|
||||
throw new Error(
|
||||
`An error occurred compiling vertex shader: ${this.gl.getShaderInfoLog(vertexShader)}`,
|
||||
);
|
||||
|
||||
this.gl.shaderSource(fragmentShader, DDS_DECOMPRESS_FRAGMENT_SHADER);
|
||||
this.gl.compileShader(fragmentShader);
|
||||
|
||||
if (!this.gl.getShaderParameter(fragmentShader, this.gl.COMPILE_STATUS))
|
||||
throw new Error(
|
||||
`An error occurred compiling fragment shader: ${this.gl.getShaderInfoLog(fragmentShader)}`,
|
||||
);
|
||||
|
||||
let program = this.gl.createProgram();
|
||||
|
||||
if (!program) return;
|
||||
this.shader = program;
|
||||
|
||||
this.gl.attachShader(program, vertexShader);
|
||||
this.gl.attachShader(program, fragmentShader);
|
||||
this.gl.linkProgram(program);
|
||||
|
||||
if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS))
|
||||
throw new Error(
|
||||
`An error occurred linking the program: ${this.gl.getProgramInfoLog(program)}`,
|
||||
);
|
||||
};
|
||||
|
||||
canvas2D: HTMLCanvasElement = document.createElement("canvas");
|
||||
canvasGL: HTMLCanvasElement = document.createElement("canvas");
|
||||
|
||||
cache: DDSCache | null;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
|
||||
gl: WebGLRenderingContext;
|
||||
ext: ReturnType<typeof this.gl.getExtension>;
|
||||
shader: WebGLShader | null = null;
|
||||
};
|
||||
@ -1,74 +0,0 @@
|
||||
import useLocalStorage from "../hooks/useLocalStorage.svelte";
|
||||
import { USERBOX_DEFAULT_URL } from "../config";
|
||||
|
||||
export default class DDSCache {
|
||||
constructor(db: IDBDatabase | undefined) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Finds an object URL for the image with the specified path and scale
|
||||
* @param path Image path
|
||||
* @param scale Scale factor
|
||||
*/
|
||||
find(path: string, scale: number = 1): string | undefined {
|
||||
return (this.urlCache.find(
|
||||
p => p.path == path && p.scale == scale)?.url)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Checks whether an object URL is cached for the image with the specified path and scale
|
||||
* @param path Image path
|
||||
* @param scale Scale factor
|
||||
*/
|
||||
cached(path: string, scale: number = 1): boolean {
|
||||
return this.urlCache.some(
|
||||
p => p.path == path && p.scale == scale)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Save an object URL for the specified path and scale to the cache
|
||||
* @param path Image path
|
||||
* @param url Object URL
|
||||
* @param scale Scale factor
|
||||
*/
|
||||
save(path: string, url: string, scale: number = 1) {
|
||||
if (this.cached(path, scale)) {
|
||||
URL.revokeObjectURL(url);
|
||||
return this.find(path, scale)
|
||||
}
|
||||
this.urlCache.push({path, url, scale})
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Retrieve a Blob from a database based on the specified path
|
||||
* @param path Image path
|
||||
*/
|
||||
getFromDatabase(path: string): Promise<Blob | null> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
if (this.userboxURL.value != "") {
|
||||
let targetPath = path.replaceAll(":", "/");
|
||||
let response = await fetch(`${this.userboxURL.value}/${targetPath}.chu`).then(b => b.blob()).catch(reject);
|
||||
if (response)
|
||||
return resolve(response);
|
||||
};
|
||||
if (!this.db)
|
||||
return resolve(null);
|
||||
let transaction = this.db.transaction(["dds"], "readonly");
|
||||
let objectStore = transaction.objectStore("dds");
|
||||
let request = objectStore.get(path);
|
||||
request.onsuccess = async (e) => {
|
||||
if (request.result)
|
||||
if (request.result.blob)
|
||||
return resolve(request.result.blob);
|
||||
return resolve(null);
|
||||
}
|
||||
request.onerror = () => resolve(null);
|
||||
})
|
||||
};
|
||||
|
||||
private urlCache: {scale: number, path: string, url: string}[] = [];
|
||||
private db: IDBDatabase | undefined;
|
||||
userboxURL = useLocalStorage("userboxURL", USERBOX_DEFAULT_URL);
|
||||
}
|
||||
@ -1,203 +0,0 @@
|
||||
import { t, ts } from "../../libs/i18n";
|
||||
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
|
||||
|
||||
const isDirectory = (e: FileSystemEntry): e is FileSystemDirectoryEntry => e.isDirectory
|
||||
const isFile = (e: FileSystemEntry): e is FileSystemFileEntry => e.isFile
|
||||
|
||||
const getDirectory = (directory: FileSystemDirectoryEntry, path: string): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getDirectory(path, {}, d => res(d), e => rej()));
|
||||
const getParent = (directory: FileSystemDirectoryEntry): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getParent(d => res(d), e => rej()));
|
||||
const getFile = (directory: FileSystemDirectoryEntry, path: string): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getFile(path, {}, d => res(d), e => rej()));
|
||||
const getFiles = async (directory: FileSystemDirectoryEntry): Promise<Array<FileSystemEntry>> => {
|
||||
let reader = directory.createReader();
|
||||
let files: Array<FileSystemEntry> = [];
|
||||
let currentFiles: number = 1e9;
|
||||
while (currentFiles != 0) {
|
||||
let entries = await new Promise<Array<FileSystemEntry>>(r => reader.readEntries(r));
|
||||
files = files.concat(entries);
|
||||
currentFiles = entries.length;
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const validateDirectories = async (base: FileSystemDirectoryEntry, path: string): Promise<boolean> => {
|
||||
const pathTrail = path.split("/");
|
||||
let directory: FileSystemDirectoryEntry = base;
|
||||
for (let part of pathTrail) {
|
||||
let newDirectory = await getDirectory(directory, part).catch(_ => null);
|
||||
if (newDirectory && isDirectory(newDirectory)) {
|
||||
directory = newDirectory;
|
||||
} else
|
||||
return false;
|
||||
};
|
||||
return true
|
||||
}
|
||||
|
||||
const getDirectoryFromPath = async (base: FileSystemDirectoryEntry, path: string): Promise<FileSystemDirectoryEntry | null> => {
|
||||
const pathTrail = path.split("/");
|
||||
let directory: FileSystemDirectoryEntry = base;
|
||||
for (let part of pathTrail) {
|
||||
let newDirectory = await getDirectory(directory, part).catch(_ => null);
|
||||
if (newDirectory && isDirectory(newDirectory)) {
|
||||
directory = newDirectory;
|
||||
} else
|
||||
return null;
|
||||
};
|
||||
return directory;
|
||||
}
|
||||
|
||||
const scanRecursive = async (root: FileSystemDirectoryEntry, target: string): Promise<FileSystemDirectoryEntry | undefined> => {
|
||||
let directories: FileSystemEntry[] = [root];
|
||||
|
||||
while (directories.length > 0) {
|
||||
const directory = directories[0] as FileSystemDirectoryEntry;
|
||||
if (directory.isDirectory) {
|
||||
if (directory.name == target)
|
||||
return directory;
|
||||
let children: FileSystemEntry[] = await new Promise(r => directory.createReader().readEntries(d => r(d)));
|
||||
directories = [
|
||||
...directories,
|
||||
...(children.filter(v => v.isDirectory))
|
||||
];
|
||||
}
|
||||
directories.shift();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
export let ddsDB: IDBDatabase | undefined ;
|
||||
|
||||
/* Technically, processName should be in the translation file but I figured it was such a small thing that it didn't REALLY matter... */
|
||||
const DIRECTORY_PATHS = ([
|
||||
{
|
||||
folder: "ddsImage",
|
||||
processName: "Characters",
|
||||
path: "characterThumbnail",
|
||||
filter: (name: string) => name.substring(name.length - 6, name.length) == "02.dds",
|
||||
id: (name: string) => `0${name.substring(17, 21)}${name.substring(23, 24)}`
|
||||
},
|
||||
{
|
||||
folder: "namePlate",
|
||||
processName: "Nameplates",
|
||||
path: "nameplate",
|
||||
filter: () => true,
|
||||
id: (name: string) => name.substring(17, 25)
|
||||
},
|
||||
{
|
||||
folder: "avatarAccessory",
|
||||
processName: "Avatar Accessory Thumbnails",
|
||||
path: "avatarAccessoryThumbnail",
|
||||
filter: (name: string) => name.substring(14, 18) == "Icon",
|
||||
id: (name: string) => name.substring(19, 27)
|
||||
},
|
||||
{
|
||||
folder: "avatarAccessory",
|
||||
processName: "Avatar Accessories",
|
||||
path: "avatarAccessory",
|
||||
filter: (name: string) => name.substring(14, 17) == "Tex",
|
||||
id: (name: string) => name.substring(18, 26)
|
||||
},
|
||||
{
|
||||
folder: "texture",
|
||||
processName: "Surfboard Textures",
|
||||
useFileName: true,
|
||||
path: "surfboard",
|
||||
filter: (name: string) =>
|
||||
([
|
||||
"CHU_UI_Common_Avatar_body_00.dds",
|
||||
"CHU_UI_Common_Avatar_face_00.dds",
|
||||
"CHU_UI_Common_01_v11.dds",
|
||||
"CHU_UI_TeamEmblem_01_v14.dds",
|
||||
"CHU_UI_title_rank_00_v10.dds"
|
||||
]).includes(name),
|
||||
id: (name: string) => name
|
||||
}
|
||||
] satisfies {folder: string, processName: string, path: string, useFileName?: boolean, filter: (name: string) => boolean, id: (name: string) => string}[] )
|
||||
|
||||
export const scanOptionFolder = async (optionFolder: FileSystemDirectoryEntry, progressUpdate: (progress: number, text: string) => void) => {
|
||||
let filesToProcess: Record<string, FileSystemFileEntry[]> = {};
|
||||
let directories = (await getFiles(optionFolder))
|
||||
.filter(directory => isDirectory(directory) && ((directory.name.substring(0, 1) == "A" && directory.name.length == 4) || directory.name == "surfboard"))
|
||||
|
||||
for (let directory of directories)
|
||||
if (isDirectory(directory)) {
|
||||
for (const directoryData of DIRECTORY_PATHS) {
|
||||
let folder = await getDirectoryFromPath(directory, directoryData.folder).catch(_ => null) ?? [];
|
||||
if (folder) {
|
||||
if (!filesToProcess[directoryData.path])
|
||||
filesToProcess[directoryData.path] = [];
|
||||
for (let dataFolderEntry of await getFiles(folder as FileSystemDirectoryEntry).catch(_ => null) ?? [])
|
||||
if (isDirectory(dataFolderEntry)) {
|
||||
for (let dataEntry of await getFiles(dataFolderEntry as FileSystemDirectoryEntry).catch(_ => null) ?? [])
|
||||
if (isFile(dataEntry) && directoryData.filter(dataEntry.name))
|
||||
filesToProcess[directoryData.path].push(dataEntry);
|
||||
} else if (isFile(dataFolderEntry) && directoryData.filter(dataFolderEntry.name))
|
||||
filesToProcess[directoryData.path].push(dataFolderEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data = [];
|
||||
|
||||
for (const [folder, files] of Object.entries(filesToProcess)) {
|
||||
let reference = DIRECTORY_PATHS.find(r => r.path == folder);
|
||||
for (const [idx, file] of files.entries()) {
|
||||
progressUpdate((idx / files.length) * 100, `${t("userbox.new.setup.processing_file")} ${reference?.processName ?? "?"}...`)
|
||||
data.push({
|
||||
path: `${folder}:${reference?.id(file.name)}`, name: file.name, blob: await new Promise<File>(res => file.file(res))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
progressUpdate(100, `${t("userbox.new.setup.finalizing")}...`)
|
||||
|
||||
let transaction = ddsDB?.transaction(['dds'], 'readwrite', { durability: "strict" })
|
||||
if (!transaction) return; // TODO: bubble error up to user
|
||||
transaction.onerror = e => e.preventDefault()
|
||||
let objectStore = transaction.objectStore('dds');
|
||||
for (let object of data)
|
||||
objectStore.put(object)
|
||||
|
||||
// await transaction completion
|
||||
await new Promise(r => transaction.addEventListener("complete", r, {once: true}))
|
||||
};
|
||||
|
||||
export function initializeDb() : Promise<void> {
|
||||
return new Promise(r => {
|
||||
const dbRequest = indexedDB.open("userboxChusanDDS", 1)
|
||||
dbRequest.addEventListener("upgradeneeded", (event) => {
|
||||
if (!(event.target instanceof IDBOpenDBRequest)) return
|
||||
ddsDB = event.target.result;
|
||||
if (!ddsDB) return;
|
||||
|
||||
const store = ddsDB.createObjectStore('dds', { keyPath: 'path' });
|
||||
store.createIndex('path', 'path', { unique: true })
|
||||
store.createIndex('name', 'name', { unique: false })
|
||||
store.createIndex('blob', 'blob', { unique: false })
|
||||
r();
|
||||
});
|
||||
dbRequest.addEventListener("success", () => {
|
||||
ddsDB = dbRequest.result;
|
||||
r();
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function userboxFileProcess(folder: FileSystemEntry, progressUpdate: (progress: number, progressString: string) => void): Promise<string | null> {
|
||||
if (!isDirectory(folder))
|
||||
return t("userbox.new.error.invalidFolder")
|
||||
|
||||
initializeDb();
|
||||
const optionFolder = await scanRecursive(folder, "A001");
|
||||
if (optionFolder)
|
||||
await scanOptionFolder((await getParent(optionFolder)) as FileSystemDirectoryEntry, progressUpdate);
|
||||
const dataFolder = await scanRecursive(folder, "A000");
|
||||
if (dataFolder)
|
||||
await scanOptionFolder((await getParent(dataFolder)) as FileSystemDirectoryEntry, progressUpdate);
|
||||
useLocalStorage("userboxURL", "").value = "";
|
||||
useLocalStorage("userboxNew", false).value = true;
|
||||
useLocalStorage("userboxNewProfile", false).value = true;
|
||||
location.reload();
|
||||
|
||||
return null
|
||||
}
|
||||
@ -9,23 +9,15 @@
|
||||
<div class="setup-instructions">
|
||||
<h2>{t('home.join-community')}</h2>
|
||||
<div class="grid cols-3 gap-4">
|
||||
{#if DISCORD_INVITE}
|
||||
<CommunityCard color="82, 93, 233" icon="ic:baseline-discord" on:click={() => window.location.href = DISCORD_INVITE}>
|
||||
<h3>{t('home.community.discord')}</h3>
|
||||
</CommunityCard>
|
||||
{/if}
|
||||
|
||||
{#if TELEGRAM_INVITE}
|
||||
<CommunityCard color="46, 163, 224" icon="mingcute:telegram-fill" on:click={() => window.location.href = TELEGRAM_INVITE}>
|
||||
<h3>{t('home.community.telegram')}</h3>
|
||||
</CommunityCard>
|
||||
{/if}
|
||||
|
||||
{#if QQ_INVITE}
|
||||
<CommunityCard color="226, 60, 68" icon="ri:qq-fill" on:click={() => window.location.href = QQ_INVITE}>
|
||||
<h3>{t('home.community.qq')}</h3>
|
||||
</CommunityCard>
|
||||
{/if}
|
||||
<CommunityCard color="82, 93, 233" icon="ic:baseline-discord" on:click={() => window.location.href = DISCORD_INVITE}>
|
||||
<h3>Discord</h3>
|
||||
</CommunityCard>
|
||||
<CommunityCard color="46, 163, 224" icon="mingcute:telegram-fill" on:click={() => window.location.href = TELEGRAM_INVITE}>
|
||||
<h3>Telegram</h3>
|
||||
</CommunityCard>
|
||||
<CommunityCard color="226, 60, 68" icon="ri:qq-fill" on:click={() => window.location.href = QQ_INVITE}>
|
||||
<h3>QQ</h3>
|
||||
</CommunityCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { fade, slide } from "svelte/transition"
|
||||
import type { Card, CardSummary, CardSummaryGame, ConfirmProps, AquaNetUser } from "../../libs/generalTypes"
|
||||
import { CARD, USER } from "../../libs/sdk"
|
||||
import type { Card, CardSummary, CardSummaryGame, ConfirmProps, AquaNetUser } from "../../libs/generalTypes";
|
||||
import { CARD, USER } from "../../libs/sdk";
|
||||
import moment from "moment"
|
||||
import Icon from "@iconify/svelte"
|
||||
import StatusOverlays from "../../components/StatusOverlays.svelte"
|
||||
import { t } from "../../libs/i18n"
|
||||
import Icon from "@iconify/svelte";
|
||||
import StatusOverlays from "../../components/StatusOverlays.svelte";
|
||||
import { t } from "../../libs/i18n";
|
||||
|
||||
// State
|
||||
let state: 'ready' | 'linking-AC' | 'linking-SN' | 'loading' = "loading"
|
||||
@ -42,22 +42,14 @@
|
||||
}
|
||||
|
||||
async function doLink(id: string, migrate: string) {
|
||||
try {
|
||||
await CARD.link({cardId: id, migrate})
|
||||
await updateMe()
|
||||
if (linkingType === 'AC') inputAC = ""
|
||||
if (linkingType === 'SN') inputSN = ""
|
||||
} catch (e) {
|
||||
setError(e.message, linkingType)
|
||||
}
|
||||
await CARD.link({cardId: id, migrate})
|
||||
await updateMe()
|
||||
state = "ready"
|
||||
}
|
||||
|
||||
let linkingType: 'AC' | 'SN' = null
|
||||
async function link(type: 'AC' | 'SN') {
|
||||
if (state !== 'ready' || accountCardSummary === null) return
|
||||
state = "linking-" + type
|
||||
linkingType = type
|
||||
const id = type === 'AC' ? inputAC : inputSN
|
||||
|
||||
console.log("linking card", id)
|
||||
@ -72,7 +64,7 @@
|
||||
// First, lookup the card summary
|
||||
const card = (await CARD.summary(id).catch(e => {
|
||||
// If card is not found, create a card and link it
|
||||
if (e.message === 'Card not found') {
|
||||
if (e.message === t('home.linkcard.notfound')) {
|
||||
doLink(id, "")
|
||||
return
|
||||
}
|
||||
@ -164,66 +156,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
function cursorPositionToCursorIndex(text: string, cursorPosition: number, effectiveCharsRegex: RegExp) {
|
||||
const textBeforeCursor = text.slice(0, cursorPosition)
|
||||
const ignoredChars = textBeforeCursor.replace(new RegExp(effectiveCharsRegex, "g"), "")
|
||||
return textBeforeCursor.length - ignoredChars.length
|
||||
}
|
||||
|
||||
function cursorIndexToCursorPosition(text: string, cursorIndex: number, effectiveCharsRegex: RegExp) {
|
||||
let i = 0
|
||||
while (i < text.length) {
|
||||
while (i < text.length && !effectiveCharsRegex.test(text[i])) i++
|
||||
if (cursorIndex === 0) break
|
||||
cursorIndex--
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// Access code input
|
||||
const inputACRegex = /^(\d{4} ){0,4}\d{0,4}$/
|
||||
let elemInputAC: HTMLInputElement
|
||||
let inputOldAC = ""
|
||||
let inputAC = ""
|
||||
let errorAC = ""
|
||||
let warningAC = ""
|
||||
|
||||
function inputACChange() {
|
||||
function inputACChange(e: any) {
|
||||
e = e as InputEvent
|
||||
// Add spaces to the input
|
||||
const cursorIndex = cursorPositionToCursorIndex(inputAC, elemInputAC.selectionStart, /\d/)
|
||||
inputAC = inputAC.replace(/\D/g, '').replace(/(.{4})/g, '$1 ').replace(/ $/, '')
|
||||
const cursorPosition = cursorIndexToCursorPosition(inputAC, cursorIndex, /\d/)
|
||||
setTimeout(() => elemInputAC.selectionStart = elemInputAC.selectionEnd = cursorPosition, 0)
|
||||
if (inputAC !== inputOldAC) errorAC = ""
|
||||
warningAC = inputAC[0] === "5" ? t('home.linkcard.felica-ac-warning') : ""
|
||||
|
||||
inputOldAC = inputAC
|
||||
const old = inputAC
|
||||
if (e.inputType === "insertText" && inputAC.length % 5 === 4 && inputAC.length < 24)
|
||||
inputAC += " "
|
||||
inputAC = inputAC.slice(0, 24)
|
||||
if (inputAC !== old) errorAC = ""
|
||||
}
|
||||
|
||||
// Serial number input
|
||||
const inputSNRegex = /^([0-9A-Fa-f]{0,2}:){0,7}[0-9A-Fa-f]{0,2}$/
|
||||
let inputElemSN: HTMLInputElement
|
||||
let inputOldSN = ""
|
||||
let inputSN = ""
|
||||
let errorSN = ""
|
||||
|
||||
function inputSNChange() {
|
||||
function inputSNChange(e: any) {
|
||||
e = e as InputEvent
|
||||
// Add colons to the input
|
||||
inputSN = inputSN.toUpperCase()
|
||||
const cursorIndex = cursorPositionToCursorIndex(inputSN, inputElemSN.selectionStart, /[0-9A-F]/)
|
||||
inputSN = inputSN.replace(/[^0-9A-F]/g, '').replace(/(.{2})/g, '$1:').replace(/:$/, '')
|
||||
const cursorPosition = cursorIndexToCursorPosition(inputSN, cursorIndex, /[0-9A-F]/)
|
||||
setTimeout(() => inputElemSN.selectionStart = inputElemSN.selectionEnd = cursorPosition, 0)
|
||||
if (inputSN !== inputOldSN) errorSN = ""
|
||||
|
||||
inputOldSN = inputSN
|
||||
const old = inputSN
|
||||
if (e.inputType === "insertText" && inputSN.length % 3 === 2 && inputSN.length < 23)
|
||||
inputSN += ":"
|
||||
inputSN = inputSN.toUpperCase().slice(0, 23)
|
||||
if (inputSN !== old) errorSN = ""
|
||||
}
|
||||
|
||||
function formatLUID(luid: string, ghost: boolean = false) {
|
||||
if (ghost) return luid.slice(0, 6) + " " + (luid.slice(6).match(/.{4}/g)?.join(" ") ?? "")
|
||||
switch (cardType(luid)) {
|
||||
case "FeliCa SN":
|
||||
case "Felica SN":
|
||||
return BigInt(luid).toString(16).toUpperCase().padStart(16, "0").match(/.{1,2}/g)!.join(":")
|
||||
case "Access Code":
|
||||
return luid.match(/.{4}/g)!.join(" ")
|
||||
@ -233,9 +199,9 @@
|
||||
}
|
||||
|
||||
function cardType(luid: string) {
|
||||
if (luid.startsWith("00")) return "FeliCa SN"
|
||||
if (luid.startsWith("00")) return "Felica SN"
|
||||
if (luid.length === 20) return "Access Code"
|
||||
if (luid.includes(":")) return "FeliCa SN"
|
||||
if (luid.includes(":")) return "Felica SN"
|
||||
if (luid.includes(" ")) return "Access Code"
|
||||
return "Unknown"
|
||||
}
|
||||
@ -243,29 +209,9 @@
|
||||
function isInput(e: KeyboardEvent) {
|
||||
return e.key.length === 1 && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey
|
||||
}
|
||||
|
||||
async function dropFile(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (!file) return
|
||||
switch (file.name.toLowerCase()) {
|
||||
case "aime.txt":
|
||||
inputSN = ""
|
||||
inputAC = await file.text()
|
||||
inputACChange()
|
||||
break
|
||||
case "felica.txt":
|
||||
inputAC = ""
|
||||
inputSN = await file.text()
|
||||
inputSNChange()
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="link-card" on:drop={dropFile} on:dragover={(e) => e.preventDefault()}>
|
||||
<div class="link-card">
|
||||
<h2>{t('home.linkcard.cards')}</h2>
|
||||
<p>{t('home.linkcard.description')}:</p>
|
||||
|
||||
@ -293,8 +239,7 @@
|
||||
<p>{t('home.linkcard.access-code')}</p>
|
||||
<label>
|
||||
<!-- DO NOT change the order of bind:value and on:input. Their order determines the order of reactivity -->
|
||||
<input bind:this={elemInputAC}
|
||||
placeholder="e.g. 2408 1234 5678 9012 3456 / 0008 1234 5678 8765 4321"
|
||||
<input placeholder="e.g. 5200 1234 5678 9012 3456"
|
||||
on:keydown={(e) => {
|
||||
e.key === "Enter" && link('AC')
|
||||
// Ensure key is numeric
|
||||
@ -302,23 +247,13 @@
|
||||
}}
|
||||
bind:value={inputAC}
|
||||
on:input={inputACChange}
|
||||
class:error={inputAC && (!inputACRegex.test(inputAC) || errorAC)}
|
||||
class:warning={inputAC && warningAC}>
|
||||
class:error={inputAC && (!inputACRegex.test(inputAC) || errorAC)}>
|
||||
{#if inputAC.length > 0}
|
||||
<button transition:slide={{axis: 'x'}} on:click={() => link('AC')}>{t('home.linkcard.link')}</button>
|
||||
<button transition:slide={{axis: 'x'}} on:click={() => {link('AC');inputAC=''}}>{t('home.linkcard.link')}</button>
|
||||
{/if}
|
||||
</label>
|
||||
<blockquote>{t('home.linkcard.kdx-notice')}</blockquote>
|
||||
{#if errorAC}
|
||||
<p class="error" style={warningAC ? "margin-bottom: 0" : ""} transition:slide>{errorAC}</p>
|
||||
{/if}
|
||||
{#if warningAC}
|
||||
<!-- Transition temporarily adds `overflow: hidden` which leads to BFC issue, breaking margin collapse -->
|
||||
<div style="overflow: hidden" transition:slide>
|
||||
{#each warningAC.trim().split("\n") as paragraph}
|
||||
<p class="warning">{paragraph}</p>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="error" transition:slide>{errorAC}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@ -331,8 +266,7 @@
|
||||
{t('home.linkcard.enter-sn2')}
|
||||
</p>
|
||||
<label>
|
||||
<input bind:this={inputElemSN}
|
||||
placeholder="e.g. 01:2E:1A:2B:3C:4D:5E:6F"
|
||||
<input placeholder="e.g. 01:2E:1A:2B:3C:4D:5E:6F"
|
||||
on:keydown={(e) => {
|
||||
e.key === "Enter" && link('SN')
|
||||
// Ensure key is hex or colon
|
||||
@ -342,7 +276,7 @@
|
||||
on:input={inputSNChange}
|
||||
class:error={inputSN && (!inputSNRegex.test(inputSN) || errorSN)}>
|
||||
{#if inputSN.length > 0}
|
||||
<button transition:slide={{axis: 'x'}} on:click={() => link('SN')}>{t('home.linkcard.link')}</button>
|
||||
<button transition:slide={{axis: 'x'}} on:click={() => {link('SN'); inputSN = ''}}>{t('home.linkcard.link')}</button>
|
||||
{/if}
|
||||
</label>
|
||||
{#if errorSN}
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {GAME} from "../libs/sdk";
|
||||
import {AQUA_HOST} from "../libs/config";
|
||||
import Loading from "../components/ui/Loading.svelte";
|
||||
import Error from "../components/ui/Error.svelte";
|
||||
import { t } from "../libs/i18n";
|
||||
|
||||
const token = localStorage.getItem("token")
|
||||
</script>
|
||||
|
||||
<main class="content">
|
||||
<div class="outer-title-options">
|
||||
<h2>{t("maiphoto.title")}</h2>
|
||||
</div>
|
||||
|
||||
{#await GAME.photos()}
|
||||
<Loading/>
|
||||
{:then photos}
|
||||
{#if photos.length === 0}
|
||||
<blockquote>{t('maiphoto.none')}</blockquote>
|
||||
{:else}
|
||||
<blockquote>{t('maiphoto.url_warning')}</blockquote>
|
||||
{/if}
|
||||
<div class="pictures">
|
||||
{#each photos as photo}
|
||||
<div class="photo-container">
|
||||
<img class="rounded-2xl" src="{AQUA_HOST}/api/v2/game/mai2/my-photo/{photo}?token={token}" alt="Memorial" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:catch error}
|
||||
<Error {error}/>
|
||||
{/await}
|
||||
</main>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../vars"
|
||||
|
||||
.pictures
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
justify-content: center
|
||||
row-gap: 1rem
|
||||
gap: 1rem
|
||||
|
||||
.photo-container
|
||||
flex: 1 1 300px
|
||||
min-width: 280px
|
||||
max-width: 100%
|
||||
display: flex
|
||||
justify-content: center
|
||||
|
||||
.photo-container img
|
||||
width: 100%
|
||||
height: auto
|
||||
object-fit: contain
|
||||
</style>
|
||||
@ -59,7 +59,7 @@
|
||||
{/if}
|
||||
</span>
|
||||
<span class="rating">{
|
||||
game === 'chu3' || game === 'ongeki' ?
|
||||
game === 'chu3' ?
|
||||
(user.rating / 100).toFixed(2) :
|
||||
user.rating.toLocaleString()
|
||||
}</span>
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let desc: string
|
||||
export let value: string
|
||||
export let placeholder: string
|
||||
export let flex: number = 60
|
||||
|
||||
export let disabled: boolean = false
|
||||
|
||||
export let validate: (value: string) => boolean = () => true
|
||||
</script>
|
||||
|
||||
<div class="field" style="flex: {flex}">
|
||||
<label for={desc}>{desc}</label>
|
||||
<input type="text" placeholder={placeholder} bind:value={value} id="{desc}" on:change
|
||||
class:error={value && !validate(value)} {disabled}/>
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
.field
|
||||
display: inline-flex
|
||||
flex-direction: column
|
||||
gap: 0.5rem
|
||||
|
||||
label
|
||||
font-weight: bold
|
||||
</style>
|
||||
@ -1,129 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
import { t, ts } from "../../libs/i18n";
|
||||
import TransferServer from "./TransferServer.svelte";
|
||||
import { DATA_HOST } from "../../libs/config";
|
||||
import type { ConfirmProps } from "../../libs/generalTypes";
|
||||
import StatusOverlays from "../../components/StatusOverlays.svelte";
|
||||
|
||||
|
||||
let tabs = ['chu3', 'mai2', 'ongeki']
|
||||
let game: Record<string, { game: string, version: string }> = {
|
||||
'chu3': { game: "SDHD", version: "2.30" },
|
||||
'mai2': { game: "SDGA", version: "1.50" },
|
||||
'ongeki': { game: "SDDT", version: "1.45" }
|
||||
}
|
||||
let tab = 0
|
||||
|
||||
let src = JSON.parse(localStorage.getItem('src') ?? `{"dns": "", "card": "", "keychip": ""}`)
|
||||
let dst = JSON.parse(localStorage.getItem('dst') ?? `{"dns": "", "card": "", "keychip": ""}`)
|
||||
let [srcTested, dstTested] = [false, false]
|
||||
let gameInfo = JSON.parse(localStorage.getItem('gameInfo') ?? `{"game": "", "version": ""}`)
|
||||
|
||||
let srcEl: TransferServer, dstEl: TransferServer
|
||||
let srcExportedData: string
|
||||
let [error, loading] = ["", false]
|
||||
let confirm: ConfirmProps | null = null
|
||||
|
||||
function defaultGame() {
|
||||
gameInfo.game = game[tabs[tab]].game
|
||||
gameInfo.version = game[tabs[tab]].version
|
||||
}
|
||||
|
||||
function onChange() {
|
||||
localStorage.setItem('src', JSON.stringify(src))
|
||||
localStorage.setItem('dst', JSON.stringify(dst))
|
||||
localStorage.setItem('gameInfo', JSON.stringify(gameInfo))
|
||||
}
|
||||
|
||||
function actuallyStartTransfer() {
|
||||
srcEl.pull()
|
||||
.then(() => dstEl.push(srcExportedData))
|
||||
.then(() => confirm = {
|
||||
title: t('trans.confirm.done.title'),
|
||||
message: t('trans.confirm.done.msg', { src: src.dns, dst: dst.dns })
|
||||
})
|
||||
.catch(e => error = e)
|
||||
.finally(() => loading = false)
|
||||
}
|
||||
|
||||
function startTransfer() {
|
||||
if (!(srcTested && dstTested)) return confirm = {
|
||||
title: t('trans.confirm.untested.title'),
|
||||
message: t('trans.confirm.untested.msg')
|
||||
}
|
||||
if (loading) return alert(t('trans.alert.in-progress'))
|
||||
console.log("Starting transfer...")
|
||||
loading = true
|
||||
|
||||
if (dstEl.exportedData) return actuallyStartTransfer()
|
||||
|
||||
// Ask user to make sure to backup their data
|
||||
confirm = {
|
||||
title: t('trans.confirm.unbackuped.title'),
|
||||
message: t('trans.confirm.unbackuped.msg'),
|
||||
dangerous: true,
|
||||
confirm: actuallyStartTransfer,
|
||||
cancel: () => { loading = false }
|
||||
}
|
||||
}
|
||||
|
||||
defaultGame()
|
||||
</script>
|
||||
|
||||
<StatusOverlays bind:confirm={confirm} {error} />
|
||||
|
||||
<main class="content">
|
||||
<div class="outer-title-options">
|
||||
<h2>{t('trans.title')}</h2>
|
||||
<nav>
|
||||
{#each tabs as tabName, i}
|
||||
<div transition:slide={{axis: 'x'}} class:active={tab === i}
|
||||
on:click={() => tab = i} on:keydown={e => e.key === 'Enter' && (tab = i)}
|
||||
role="button" tabindex="0">
|
||||
{ts(`settings.tabs.${tabName}`)}
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="prompt">
|
||||
{@html t("trans.prompt-html")}
|
||||
</div>
|
||||
|
||||
<TransferServer bind:src={src} bind:gameInfo={gameInfo} on:change={onChange}
|
||||
bind:tested={srcTested} bind:this={srcEl} bind:exportedData={srcExportedData} />
|
||||
|
||||
<div class="arrow" class:disabled={!(srcTested && dstTested)}>
|
||||
<img src="{DATA_HOST}/d/DownArrow.png" alt="arrow" on:click={startTransfer}>
|
||||
</div>
|
||||
|
||||
<TransferServer bind:src={dst} bind:gameInfo={gameInfo} on:change={onChange}
|
||||
bind:tested={dstTested} bind:this={dstEl} isSrc={false} />
|
||||
</main>
|
||||
|
||||
|
||||
<style lang="sass">
|
||||
.arrow
|
||||
width: 100%
|
||||
display: flex
|
||||
justify-content: center
|
||||
margin-top: -40px
|
||||
margin-bottom: -40px
|
||||
z-index: 1
|
||||
|
||||
&.disabled
|
||||
filter: grayscale(1)
|
||||
|
||||
// CSS animation to let the image opacity breathe
|
||||
img
|
||||
animation: breathe 1s infinite alternate
|
||||
|
||||
@keyframes breathe
|
||||
0%
|
||||
opacity: 0.5
|
||||
100%
|
||||
opacity: 1
|
||||
</style>
|
||||
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
|
||||
interface AllNetSrc {
|
||||
card: string
|
||||
dns: string
|
||||
keychip: string
|
||||
}
|
||||
|
||||
interface AllNetGame {
|
||||
game: string
|
||||
version: string
|
||||
}
|
||||
|
||||
interface AllNetClient extends AllNetSrc, AllNetGame {}
|
||||
|
||||
interface TrCheckGood {
|
||||
gameUrl: string
|
||||
userId: number
|
||||
}
|
||||
|
||||
type TrStreamMessage = { message: string } | { error: string } | { data: string }
|
||||
|
||||
@ -1,218 +0,0 @@
|
||||
<script lang="ts">
|
||||
import StatusOverlays from "../../components/StatusOverlays.svelte";
|
||||
import { t } from "../../libs/i18n";
|
||||
import { TRANSFER } from "../../libs/sdk";
|
||||
import { download, selectJsonFile } from "../../libs/ui";
|
||||
import InputTextShort from "./InputTextShort.svelte";
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let src: AllNetSrc
|
||||
export let gameInfo: AllNetGame
|
||||
export let isSrc: boolean = true
|
||||
|
||||
export let tested: boolean = false
|
||||
let [loading, error] = [false, ""]
|
||||
|
||||
const blacklist = ['amime.missless.net']
|
||||
|
||||
function testConnection() {
|
||||
if (loading || isBlacklist) return
|
||||
|
||||
// Preliminiary checks
|
||||
if (!src.dns || !src.keychip || !src.card || !gameInfo.game || !gameInfo.version) {
|
||||
error = t('trans.error.empty')
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
error = ""
|
||||
console.log("Testing connection...")
|
||||
return TRANSFER.check({...src, ...gameInfo}).then(res => {
|
||||
console.log("Connection test result:", res)
|
||||
tested = true
|
||||
}).catch(err => error = err.message).finally(() => loading = false)
|
||||
}
|
||||
|
||||
let messages: string[] = []
|
||||
export let exportedData: string = ""
|
||||
|
||||
export function pull(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
if (loading || !tested) return reject(t('trans.error.untested'))
|
||||
if (exportedData) return resolve(exportedData)
|
||||
console.log("Exporting data...")
|
||||
error = ""
|
||||
|
||||
TRANSFER.pull({...src, ...gameInfo}, (msg: TrStreamMessage) => {
|
||||
console.log("Export progress: ", JSON.stringify(msg))
|
||||
|
||||
if ('message' in msg) messages = [...messages, msg.message]
|
||||
|
||||
if ('error' in msg) {
|
||||
error = msg.error
|
||||
reject(msg.error)
|
||||
}
|
||||
|
||||
if ('data' in msg) {
|
||||
// file name: Export YYYY-MM-DD {server host} {game} {card last 6}.json
|
||||
let date = new Date().toISOString().split('T')[0]
|
||||
let host = new URL(src.dns).hostname
|
||||
download(msg.data, `Export ${date} ${host} ${gameInfo.game} ${src.card.slice(-6)}.json`)
|
||||
exportedData = msg.data
|
||||
resolve(msg.data)
|
||||
}
|
||||
}).catch(err => { error = err; reject(err) })
|
||||
})
|
||||
}
|
||||
|
||||
function pushBtn() {
|
||||
if (loading || !tested) return
|
||||
selectJsonFile().then(obj => push(JSON.stringify(obj)))
|
||||
}
|
||||
|
||||
export function push(data: string) {
|
||||
if (loading || !tested) return
|
||||
console.log("Import data...")
|
||||
loading = true
|
||||
error = ""
|
||||
|
||||
return TRANSFER.push({...src, ...gameInfo}, data).then(() => {
|
||||
console.log("Data imported successfully")
|
||||
messages = [t('trans.success.import')]
|
||||
}).catch(err => error = err.message).finally(() => loading = false)
|
||||
}
|
||||
|
||||
$: isBlacklist = !!blacklist.filter(x => src.dns.includes(x))
|
||||
</script>
|
||||
|
||||
<StatusOverlays {loading} />
|
||||
|
||||
<div class="server source" class:src={isSrc} class:hasError={error} class:tested={tested}>
|
||||
<h3>{t(`trans.${isSrc ? "source" : "target"}.title`)}</h3>
|
||||
|
||||
{#if !isSrc && isBlacklist}
|
||||
<blockquote class="error-msg">{t('trans.blacklist')}</blockquote>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<blockquote class="error-msg">{error}</blockquote>
|
||||
{/if}
|
||||
|
||||
<!-- First input line -->
|
||||
<div class="inputs">
|
||||
<InputTextShort desc={t('trans.field.addr')} placeholder="e.g. http://aquadx.hydev.org"
|
||||
bind:value={src.dns} on:change disabled={tested}
|
||||
validate={v => /^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(v)} />
|
||||
<InputTextShort desc={t('trans.field.keychip')} placeholder="e.g. A0299792458"
|
||||
bind:value={src.keychip} on:change disabled={tested}
|
||||
validate={v => /^([A-Z0-9]{11}|[A-Z0-9]{4}-[A-Z0-9]{11})$/.test(v)} />
|
||||
</div>
|
||||
|
||||
<!-- Second input line -->
|
||||
<div class="inputs">
|
||||
<div class="game-version">
|
||||
<InputTextShort desc={t('trans.field.game')} placeholder="e.g. SDHD"
|
||||
bind:value={gameInfo.game} on:change disabled={tested} />
|
||||
<InputTextShort desc={t('trans.field.version')} placeholder="e.g. 2.30"
|
||||
bind:value={gameInfo.version} on:change disabled={tested} />
|
||||
</div>
|
||||
<InputTextShort desc={t('trans.field.card')} placeholder="e.g. 27182818284590452353"
|
||||
bind:value={src.card} disabled={tested} on:change={value => {
|
||||
src.card = src.card.replaceAll(' ', '')
|
||||
dispatch('change', { value });
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<!-- Streaming messages -->
|
||||
{#if messages.length > 0}
|
||||
<div class="stream-messages">
|
||||
{#each messages.slice(Math.max(messages.length - 5, 0), undefined) as msg}
|
||||
<p>{msg}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="inputs buttons">
|
||||
{#if !tested}
|
||||
<button class="flex-1" on:click={testConnection} disabled={loading}>{t('trans.btn.test')}</button>
|
||||
{:else}
|
||||
<button class="flex-1" on:click={pull}>{t('trans.btn.export')}</button>
|
||||
<button class="flex-1" on:click={pushBtn}>{t('trans.btn.import')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../../vars"
|
||||
@use "sass:color"
|
||||
|
||||
.error-msg
|
||||
white-space: pre-wrap
|
||||
margin: 0
|
||||
|
||||
.server
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 1rem
|
||||
|
||||
// --c-src: 202, 168, 252
|
||||
--c-src: 179, 198, 255
|
||||
// animation: hue-rotate 10s infinite linear
|
||||
// &.src
|
||||
// --c-src: 173, 192, 247
|
||||
// animation: hue-rotate 10s infinite linear reverse
|
||||
|
||||
&.tested
|
||||
--c-src: 169, 255, 186
|
||||
|
||||
&.hasError
|
||||
--c-src: 255, 174, 174
|
||||
animation: none
|
||||
|
||||
padding: 1rem
|
||||
border-radius: vars.$border-radius
|
||||
// background-color: vars.$ov-light
|
||||
background: #252525
|
||||
|
||||
// Pink outline
|
||||
border: 1px solid rgba(var(--c-src), 0.5)
|
||||
box-shadow: 0 0 1rem 0 rgba(var(--c-src), 0.25)
|
||||
|
||||
h3
|
||||
margin: 0
|
||||
font-size: 1.5rem
|
||||
text-align: center
|
||||
|
||||
|
||||
// @keyframes hue-rotate
|
||||
// 0%
|
||||
// filter: hue-rotate(0deg)
|
||||
// 100%
|
||||
// filter: hue-rotate(360deg)
|
||||
|
||||
.inputs
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 1rem
|
||||
|
||||
.game-version
|
||||
flex: 60
|
||||
display: flex
|
||||
gap: 1rem
|
||||
|
||||
:global(> *)
|
||||
width: 100px
|
||||
|
||||
&.buttons
|
||||
margin-top: 0.5rem
|
||||
|
||||
.stream-messages
|
||||
font-size: 0.8rem
|
||||
opacity: 0.8
|
||||
|
||||
margin-top: 0.5rem
|
||||
padding: 0 0.5rem
|
||||
</style>
|
||||
@ -9,13 +9,10 @@
|
||||
import { pfp } from "../../libs/ui";
|
||||
import { t, ts } from "../../libs/i18n";
|
||||
import { FADE_IN, FADE_OUT } from "../../libs/config";
|
||||
import Cropper from "svelte-easy-crop";
|
||||
import UserBox from "../../components/settings/ChuniSettings.svelte";
|
||||
import Mai2Settings from "../../components/settings/Mai2Settings.svelte";
|
||||
import WaccaSettings from "../../components/settings/WaccaSettings.svelte";
|
||||
import GeneralGameSettings from "../../components/settings/GeneralGameSettings.svelte";
|
||||
import OngekiSettings from "../../components/settings/OngekiSettings.svelte";
|
||||
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
|
||||
|
||||
USER.ensureLoggedIn()
|
||||
|
||||
@ -29,19 +26,12 @@
|
||||
[ 'displayName', t('settings.profile.name') ],
|
||||
[ 'username', t('settings.profile.username') ],
|
||||
[ 'password', t('settings.profile.password') ],
|
||||
/* Neither of these did anything of importance
|
||||
[ 'country', t('settings.profile.country') ],
|
||||
[ 'profileLocation', t('settings.profile.location') ],*/
|
||||
[ 'profileLocation', t('settings.profile.location') ],
|
||||
[ 'profileBio', t('settings.profile.bio') ],
|
||||
] as const
|
||||
|
||||
// Fetch user data
|
||||
const getMe = () => USER.me().then((m) => {
|
||||
if (pfpCropURL != null) {
|
||||
URL.revokeObjectURL(pfpCropURL);
|
||||
pfpField.value = "";
|
||||
pfpCropURL = null;
|
||||
}
|
||||
me = m
|
||||
|
||||
CARD.userGames(m.username).then(games => {
|
||||
@ -54,17 +44,12 @@
|
||||
if (games.wacca && !tabs.includes('wacca')) {
|
||||
tabs = [...tabs, 'wacca']
|
||||
}
|
||||
if (games.ongeki && !tabs.includes('ongeki')) {
|
||||
tabs = [...tabs, 'ongeki']
|
||||
}
|
||||
})
|
||||
}).catch(e => error = e.message)
|
||||
getMe()
|
||||
|
||||
let changed: string[] = []
|
||||
let pfpField: HTMLInputElement
|
||||
let pfpCropURL: string | null = null;
|
||||
let pfpCrop = { width: 0, height: 0, x: 0, y: 0 };
|
||||
|
||||
function submit(field: string, value: string) {
|
||||
if (submitting) return
|
||||
@ -75,55 +60,15 @@
|
||||
}).catch(e => error = e.message).finally(() => submitting = "")
|
||||
}
|
||||
|
||||
function uploadPfp() {
|
||||
function uploadPfp(file: File) {
|
||||
if (submitting) return
|
||||
// Don't know why this isn't just a part of the cropper module. Have to do this myself.. What a shame
|
||||
let canvas = document.createElement("canvas");
|
||||
let ctx = canvas.getContext("2d");
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
let img = document.createElement("img");
|
||||
img.onload = () => {
|
||||
ctx?.drawImage(img, pfpCrop.x, pfpCrop.y, pfpCrop.width, pfpCrop.height, 0, 0, 256, 256);
|
||||
canvas.toBlob(blob => {
|
||||
if (!blob) return;
|
||||
submitting = 'profilePicture'
|
||||
USER.uploadPfp(blob as File).then(() => {
|
||||
me.profilePicture = me.username
|
||||
// reload
|
||||
// this doesn't work btw
|
||||
setTimeout(getMe, 200);
|
||||
}).catch(e => error = e.message).finally(() => submitting = "")
|
||||
});
|
||||
}
|
||||
img.src = pfpCropURL ?? "";
|
||||
}
|
||||
function handlePfpUpload(e: Event & { target: HTMLInputElement }) {
|
||||
if (!e.target) return;
|
||||
let files = e?.target?.files;
|
||||
if (!files || files.length <= 0) return;
|
||||
let file = files[0];
|
||||
console.log(me.username, me);
|
||||
switch (file.type) {
|
||||
case "image/gif":
|
||||
USER.uploadPfp(file).then(() => {
|
||||
me.profilePicture = me.username
|
||||
// reload
|
||||
setTimeout(getMe, 200);
|
||||
}).catch(e => error = e.message).finally(() => submitting = "")
|
||||
break;
|
||||
case "image/png":
|
||||
case "image/jpeg":
|
||||
case "image/webp":
|
||||
pfpCropURL = URL.createObjectURL(file);
|
||||
break;
|
||||
default:
|
||||
error = t("settings.profile.bad-format");
|
||||
}
|
||||
};
|
||||
function logOut() {
|
||||
localStorage.removeItem("token");
|
||||
location.href = "/";
|
||||
submitting = 'profilePicture'
|
||||
|
||||
USER.uploadPfp(file).then(() => {
|
||||
me.profilePicture = file.name
|
||||
// reload
|
||||
getMe()
|
||||
}).catch(e => error = e.message).finally(() => submitting = "")
|
||||
}
|
||||
|
||||
const passwordAction = (node: HTMLInputElement, whether: boolean) => {
|
||||
@ -162,23 +107,17 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Genuinely don't know why this is giving me an intellisense error. Works fine. -->
|
||||
<input id="profile-upload" type="file" accept="image/gif,image/png,image/jpeg,image/webp" style="display: none" bind:this={pfpField}
|
||||
on:change={handlePfpUpload} />
|
||||
<input id="profile-upload" type="file" accept="image/*" style="display: none" bind:this={pfpField}
|
||||
on:change={() => pfpField.files && uploadPfp(pfpField.files[0])} />
|
||||
</div>
|
||||
|
||||
{#each profileFields as [field, name], i (field)}
|
||||
<div class="field">
|
||||
<label for={field}>{name}</label>
|
||||
<div>
|
||||
{#if field == "profileBio"}
|
||||
<textarea id={field} bind:value={me[field]} on:input={() => changed = [...changed, field]} maxlength=255 placeholder={t('settings.profile.unset')}></textarea>
|
||||
{:else}
|
||||
<input id={field} type="text" use:passwordAction={field === 'password'}
|
||||
bind:value={me[field]} on:input={() => changed = [...changed, field]}
|
||||
placeholder={field === 'password' ? t('settings.profile.unchanged') : t('settings.profile.unset')}/>
|
||||
{/if}
|
||||
|
||||
<input id={field} type="text" use:passwordAction={field === 'password'}
|
||||
bind:value={me[field]} on:input={() => changed = [...changed, field]}
|
||||
placeholder={field === 'password' ? t('settings.profile.unchanged') : t('settings.profile.unset')}/>
|
||||
{#if changed.includes(field) && me[field]}
|
||||
<button transition:slide={{axis: 'x'}} on:click={() => submit(field, me[field])}>
|
||||
{#if submitting === field}
|
||||
@ -201,11 +140,6 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field m-t">
|
||||
<div>
|
||||
<button on:click={logOut}>{ts(`settings.profile.logout`)}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if tabs[tab] === 'chu3'}
|
||||
<!-- Userbox settings -->
|
||||
@ -214,8 +148,6 @@
|
||||
<Mai2Settings username={me.username} />
|
||||
{:else if tabs[tab] === 'wacca'}
|
||||
<WaccaSettings />
|
||||
{:else if tabs[tab] === 'ongeki'}
|
||||
<OngekiSettings />
|
||||
{:else if tabs[tab] === 'game'}
|
||||
<GeneralGameSettings />
|
||||
{/if}
|
||||
@ -223,22 +155,6 @@
|
||||
<StatusOverlays {error} loading={!me || !!submitting} />
|
||||
</main>
|
||||
|
||||
{#if pfpCropURL != null}
|
||||
<div class="overlay" transition:fade>
|
||||
<div>
|
||||
<div class="cropper-container">
|
||||
<Cropper maxZoom={1e9} oncropcomplete={(e) => pfpCrop = e.pixels} image={pfpCropURL ?? "assets/imgs/no_profile.png"} aspect={1} cropShape="round"></Cropper>
|
||||
</div>
|
||||
<button on:click={uploadPfp}>
|
||||
{t("settings.profile.save")}
|
||||
</button>
|
||||
<button on:click={getMe}>
|
||||
{t("back")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
@use "../../vars"
|
||||
|
||||
@ -272,7 +188,7 @@
|
||||
gap: 1rem
|
||||
margin-top: 0.5rem
|
||||
|
||||
> input, > textarea
|
||||
> input
|
||||
flex: 1
|
||||
|
||||
img
|
||||
@ -280,12 +196,5 @@
|
||||
max-height: 100px
|
||||
border-radius: vars.$border-radius
|
||||
object-fit: cover
|
||||
aspect-ratio: 1
|
||||
|
||||
|
||||
|
||||
.cropper-container
|
||||
position: relative
|
||||
width: 400px
|
||||
aspect-ratio: 1
|
||||
</style>
|
||||
|
||||
@ -18,26 +18,25 @@
|
||||
import { type GameName, getMult, roundFloor } from "../libs/scoring";
|
||||
import StatusOverlays from "../components/StatusOverlays.svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { countryCodeToEmoji, GAME_TITLE, t } from "../libs/i18n";
|
||||
import { GAME_TITLE, t } from "../libs/i18n";
|
||||
import RankDetails from "../components/RankDetails.svelte";
|
||||
import RatingComposition from "../components/RatingComposition.svelte";
|
||||
import useLocalStorage from "../libs/hooks/useLocalStorage.svelte";
|
||||
import Line from "../components/chart/Line.svelte";
|
||||
import ChuniUserboxDisplay from "../components/settings/userbox/ChuniUserboxDisplay.svelte";
|
||||
|
||||
const TREND_DAYS = 60
|
||||
|
||||
registerChart()
|
||||
|
||||
export let username: string;
|
||||
export let game: GameName | "auto" = "auto"
|
||||
export let game: GameName = "mai2"
|
||||
let calElement: HTMLElement
|
||||
let error: string;
|
||||
let me: AquaNetUser
|
||||
title(`User ${username}`)
|
||||
const rounding = useLocalStorage("rounding", true);
|
||||
|
||||
const titleText = game != "auto" ? GAME_TITLE[game] : "?"
|
||||
const titleText = GAME_TITLE[game]
|
||||
|
||||
interface MusicAndPlay extends MusicMeta, GenericGamePlaylog {}
|
||||
|
||||
@ -51,25 +50,11 @@
|
||||
let allMusics: AllMusic
|
||||
let showDetailRank = false
|
||||
let isLoading = false
|
||||
let showMoreRecent = false
|
||||
|
||||
function init() {
|
||||
USER.isLoggedIn() && USER.me().then(u => me = u)
|
||||
|
||||
CARD.userGames(username).then(games => {
|
||||
if (game == "auto") {
|
||||
let targetGames = Object.entries(games)
|
||||
.map(d => {
|
||||
if (d[1])
|
||||
d[1].lastLogin = d[1].lastLogin ? new Date(d[1].lastLogin) : new Date(0);
|
||||
return d;
|
||||
}).sort((a,b) => {
|
||||
return b[1]?.lastLogin - a[1]?.lastLogin;
|
||||
});
|
||||
if (targetGames[0])
|
||||
window.location.href = `/u/${username}/${targetGames[0][0]}`
|
||||
return;
|
||||
}
|
||||
if (!games[game]) {
|
||||
// Find a valid game
|
||||
const valid = Object.entries(games).filter(([g, valid]) => valid)
|
||||
@ -96,13 +81,6 @@
|
||||
})
|
||||
}
|
||||
|
||||
// Set beforeRating in recent to the last play's afterRating
|
||||
user.recent.forEach((it, i) => {
|
||||
if (i < user.recent.length - 1) {
|
||||
it.beforeRating = user.recent[i + 1].afterRating
|
||||
}
|
||||
})
|
||||
|
||||
const minDate = moment().subtract(TREND_DAYS, 'days').format("YYYY-MM-DD")
|
||||
d = {user,
|
||||
trend: trend.filter(it => it.date >= minDate && it.plays != 0),
|
||||
@ -118,11 +96,10 @@
|
||||
}).catch((e) => { error = e.message; console.error(e) } );
|
||||
}
|
||||
|
||||
if (Object.keys(GAME_TITLE).includes(game) || game == "auto") init()
|
||||
if (Object.keys(GAME_TITLE).includes(game)) init()
|
||||
else error = t("UserHome.InvalidGame", {game})
|
||||
|
||||
const setRival = (isAdd: boolean) => {
|
||||
if (game == "auto") return;
|
||||
isLoading = true
|
||||
GAME.setRival(game, username, isAdd).then(() => {
|
||||
d!.user.rival = isAdd
|
||||
@ -135,58 +112,26 @@
|
||||
<div class="user-pfp">
|
||||
<img use:pfp={d.user.aquaUser} alt="" class="pfp" on:error={pfpNotFound}>
|
||||
<div class="name-box">
|
||||
<div class="name-left">
|
||||
|
||||
{#if d.user.aquaUser}
|
||||
{#if d.user.aquaUser.displayName}
|
||||
<h2>{d.user.aquaUser?.displayName}</h2>
|
||||
{:else}
|
||||
<h2>{d.user.name}</h2>
|
||||
{/if}
|
||||
<div class="game-name">
|
||||
{#if d.user.aquaUser.displayName}
|
||||
{d.user.name}
|
||||
{/if}
|
||||
(@{d.user.aquaUser.username})
|
||||
</div>
|
||||
<div class="country">{countryCodeToEmoji(d.user.aquaUser?.country)}</div>
|
||||
{:else}
|
||||
<h2>{d.user.name}</h2>
|
||||
{/if}
|
||||
</div>
|
||||
<h2>{d.user.name}</h2>
|
||||
{#if typeof d.user.rival === 'boolean' && game === 'mai2'}
|
||||
<span class="clickable" on:click={() => setRival(!d?.user.rival)} role="button" tabindex="0"
|
||||
on:keydown={e => e.key === "Enter" && setRival(!d?.user.rival)}>
|
||||
{d.user.rival ? t("UserHome.RemoveRival") : t("UserHome.AddRival")}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<nav>
|
||||
{#each d.validGames as [g, name]}
|
||||
<a href={`/u/${username}/${g}`} class:active={game === g}>{name}</a>
|
||||
{/each}
|
||||
|
||||
{#if me && me.username === username}
|
||||
<a class="setting-icon clickable" use:tooltip={t("UserHome.Settings")} href="/settings">
|
||||
<Icon icon="eos-icons:rotating-gear"/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<nav>
|
||||
{#each d.validGames as [g, name]}
|
||||
<a href={`/u/${username}/${g}`} class:active={game === g}>{name}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{#if d.user.aquaUser?.profileBio}
|
||||
<div class="activity-info">
|
||||
<div class="info-bottom profile-bio-container">
|
||||
<div class="profile-bio">
|
||||
<span>{t("settings.profile.bio")}</span>
|
||||
<span class="profile-bio-text">{d.user.aquaUser?.profileBio}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ChuniUserboxDisplay {game} {username} bind:error={error} />
|
||||
|
||||
<div>
|
||||
<h2>{titleText} {t('UserHome.Statistics')}</h2>
|
||||
<div class="scoring-info">
|
||||
@ -195,7 +140,7 @@
|
||||
<div class="rating">
|
||||
<span>{game === 'mai2' ? t("UserHome.DXRating"): t("UserHome.Rating")}</span>
|
||||
<span>{
|
||||
game === 'chu3' || game === 'ongeki' ?
|
||||
game === 'chu3' ?
|
||||
(d.user.rating / 100).toFixed(2) :
|
||||
d.user.rating.toLocaleString()
|
||||
}</span>
|
||||
@ -203,7 +148,7 @@
|
||||
|
||||
<div class="rank">
|
||||
<span>{t('UserHome.ServerRank')}</span>
|
||||
<span>#{(d.user.serverRank + 1).toLocaleString()}</span>
|
||||
<span>#{+d.user.serverRank.toLocaleString() + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -311,25 +256,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- I don't like doing this but it may be preferable to gaslighting the types -->
|
||||
|
||||
<RatingComposition title="B30" comp={d.user.ratingComposition.best30} {allMusics} game={game != "auto" ? game : "mai2"}/>
|
||||
<RatingComposition title="B35" comp={d.user.ratingComposition.best35} {allMusics} game={game != "auto" ? game : "mai2"}/>
|
||||
<RatingComposition title="B15" comp={d.user.ratingComposition.best15} {allMusics} game={game != "auto" ? game : "mai2"}/>
|
||||
<RatingComposition title="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"/>
|
||||
{:else}
|
||||
<RatingComposition title="Recent 10" comp={d.user.ratingComposition.recent10} {allMusics} game={game != "auto" ? game : "mai2"} top={10}/>
|
||||
{/if}
|
||||
|
||||
<RatingComposition title="Recent 10" comp={d.user.ratingComposition.recent10} {allMusics} {game} top={10}/>
|
||||
|
||||
<div class="recent">
|
||||
<h2>{t('UserHome.RecentScores')}</h2>
|
||||
<div class="scores">
|
||||
{#each (showMoreRecent ? d.recent : d.recent.slice(0, 15)) as r, i}
|
||||
{#each d.recent as r, i}
|
||||
<div class:alt={i % 2 === 0}>
|
||||
<img src={`${DATA_HOST}/d/${game}/music/00${r.musicId.toString().padStart(6, '0').substring(2)}.png`} alt="" on:error={coverNotFound} />
|
||||
<div class="info">
|
||||
@ -345,27 +282,25 @@
|
||||
{r.notes?.[r.level === 10 ? 0 : r.level]?.lv?.toFixed(1) ?? r.worldsEndTag ?? '-'}
|
||||
</span>
|
||||
</span>
|
||||
<span class={`rank-${getMult(r.achievement, game != "auto" ? game : "mai2")[2].toString()[0]}`}>
|
||||
<span class="rank-text">{("" + getMult(r.achievement, game != "auto" ? game : "mai2")[2]).replace("p", "+")}</span>
|
||||
<span class={`rank-${getMult(r.achievement, game)[2].toString()[0]}`}>
|
||||
<span class="rank-text">{("" + getMult(r.achievement, game)[2]).replace("p", "+")}</span>
|
||||
<span class="rank-num" use:tooltip={(r.achievement / 10000).toFixed(4)}>
|
||||
{
|
||||
rounding.value ?
|
||||
roundFloor(r.achievement, game != "auto" ? game : "mai2", 1) :
|
||||
roundFloor(r.achievement, game, 1) :
|
||||
(r.achievement / 10000).toFixed(4)
|
||||
}%
|
||||
</span>
|
||||
</span>
|
||||
<span class:increased={r.afterRating - r.beforeRating > 0} class="dx-change">
|
||||
{r.afterRating === r.beforeRating ? '-' : (r.afterRating - r.beforeRating).toFixed(0)}
|
||||
</span>
|
||||
{#if game === 'mai2' || game === 'wacca'}
|
||||
<span class:increased={r.afterRating - r.beforeRating > 0} class="dx-change">
|
||||
{r.afterRating === r.beforeRating ? '-' : (r.afterRating - r.beforeRating).toFixed(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if !showMoreRecent}
|
||||
<button class="clickable" on:click={() => showMoreRecent = true}>{t('UserHome.ShowMoreRecent')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@ -404,9 +339,6 @@
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
position: relative
|
||||
z-index: 20
|
||||
|
||||
.name-box
|
||||
flex: 1
|
||||
display: flex
|
||||
@ -414,20 +346,6 @@
|
||||
justify-content: space-between
|
||||
gap: 10px
|
||||
|
||||
.name-left
|
||||
display: flex
|
||||
gap: 1em
|
||||
position: relative
|
||||
|
||||
.game-name
|
||||
position: absolute
|
||||
left: 0.5em
|
||||
bottom: 0
|
||||
transform: translate(0, 75%)
|
||||
opacity: 50%
|
||||
white-space: nowrap
|
||||
max-width: 50%
|
||||
|
||||
.pfp
|
||||
width: 100px
|
||||
height: 100px
|
||||
@ -463,16 +381,6 @@
|
||||
.info-bottom
|
||||
width: max-content
|
||||
|
||||
&.profile-bio-container,
|
||||
&.profile-bio-container div
|
||||
width: 100%
|
||||
|
||||
.profile-bio-text
|
||||
white-space: pre
|
||||
max-height: 10em
|
||||
overflow-y: auto
|
||||
flex: 1
|
||||
|
||||
.info-top > div > span:last-child
|
||||
font-size: 1.5rem
|
||||
|
||||
@ -668,6 +576,4 @@
|
||||
&:before
|
||||
content: "+"
|
||||
color: vars.$c-good
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
@ -1,257 +1,251 @@
|
||||
<script lang="ts">
|
||||
import { Turnstile } from "svelte-turnstile";
|
||||
import { slide } from 'svelte/transition';
|
||||
import { TURNSTILE_SITE_KEY } from "../libs/config";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { USER } from "../libs/sdk";
|
||||
import { t } from "../libs/i18n"
|
||||
|
||||
let params = new URLSearchParams(window.location.search)
|
||||
|
||||
let state = "home"
|
||||
$: isSignup = state === "signup"
|
||||
let submitting = false
|
||||
|
||||
let email = ""
|
||||
let password = ""
|
||||
let username = ""
|
||||
let turnstile = ""
|
||||
let turnstileReset: () => void | undefined;
|
||||
|
||||
let error = ""
|
||||
let verifyMsg = ""
|
||||
|
||||
if (USER.isLoggedIn()) {
|
||||
window.location.href = "/home"
|
||||
}
|
||||
|
||||
if (params.get('confirm-email')) {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verifying")
|
||||
submitting = true
|
||||
|
||||
// Send request to server
|
||||
USER.confirmEmail(params.get('confirm-email')!)
|
||||
.then(() => {
|
||||
verifyMsg = t('welcome.verified')
|
||||
submitting = false
|
||||
|
||||
// Clear the query param
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
})
|
||||
.catch(e => verifyMsg = t('welcome.verification-failed', { message: e.message }))
|
||||
}
|
||||
|
||||
async function submit(): Promise<any> {
|
||||
submitting = true
|
||||
|
||||
// Check if username and password are valid
|
||||
if (email === "" || password === "") {
|
||||
error = t("welcome.email-password-missing")
|
||||
return submitting = false
|
||||
}
|
||||
|
||||
if (TURNSTILE_SITE_KEY && turnstile === "") {
|
||||
// Sleep for 100ms to allow Turnstile to finish
|
||||
error = t("welcome.waiting-turnstile")
|
||||
return setTimeout(submit, 100)
|
||||
}
|
||||
|
||||
// Signup
|
||||
if (isSignup) {
|
||||
if (username === "") {
|
||||
error = t("welcome.username-missing")
|
||||
return submitting = false
|
||||
}
|
||||
|
||||
// Send request to server
|
||||
await USER.register({ username, email, password, turnstile })
|
||||
.then(() => {
|
||||
// Show verify email message
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verification-sent", { email })
|
||||
})
|
||||
.catch(e => {
|
||||
error = e.message
|
||||
submitting = false
|
||||
turnstileReset()
|
||||
})
|
||||
}
|
||||
else {
|
||||
// Send request to server
|
||||
await USER.login({ email, password, turnstile })
|
||||
.then(() => window.location.href = "/home")
|
||||
.catch(e => {
|
||||
if (e.message === 'Email not verified - STATE_0') {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verify-state-0")
|
||||
}
|
||||
else if (e.message === 'Email not verified - STATE_1') {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verify-state-1")
|
||||
}
|
||||
else if (e.message === 'Email not verified - STATE_2') {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verify-state-2")
|
||||
}
|
||||
else {
|
||||
error = e.message
|
||||
submitting = false
|
||||
turnstileReset()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
submitting = false
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<main id="home" class="no-margin">
|
||||
<div>
|
||||
<h1 id="title">AquaNet</h1>
|
||||
{#if state === "home"}
|
||||
<div class="btn-group" transition:slide>
|
||||
<button on:click={() => state = 'login'}>{t('welcome.btn-login')}</button>
|
||||
<button on:click={() => state = 'signup'}>{t('welcome.btn-signup')}</button>
|
||||
</div>
|
||||
{:else if state === "login" || state === "signup"}
|
||||
<div class="login-form" transition:slide>
|
||||
{#if error}
|
||||
<span class="error">{error}</span>
|
||||
{/if}
|
||||
<div on:click={() => state = 'home'} on:keypress={() => state = 'home'}
|
||||
role="button" tabindex="0" class="clickable">
|
||||
<Icon icon="line-md:chevron-small-left" />
|
||||
<span>{t('back')}</span>
|
||||
</div>
|
||||
{#if isSignup}
|
||||
<input type="text" placeholder={t('username')} bind:value={username}>
|
||||
{/if}
|
||||
<input type="email" placeholder={t('email')} bind:value={email}>
|
||||
<input type="password" placeholder={t('password')} bind:value={password}>
|
||||
<button on:click={submit}>
|
||||
{#if submitting}
|
||||
<Icon icon="line-md:loading-twotone-loop"/>
|
||||
{:else}
|
||||
{isSignup ? t('welcome.btn-signup') : t('welcome.btn-login')}
|
||||
{/if}
|
||||
</button>
|
||||
{#if TURNSTILE_SITE_KEY}
|
||||
<Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset}
|
||||
on:turnstile-callback={e => console.log(turnstile = e.detail.token)}
|
||||
on:turnstile-error={_ => console.log(error = t("welcome.turnstile-error"))}
|
||||
on:turnstile-expired={_ => window.location.reload()}
|
||||
on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if state === "verify"}
|
||||
<div class="login-form" transition:slide>
|
||||
<span>{verifyMsg}</span>
|
||||
{#if !submitting}
|
||||
<button on:click={() => state = 'home'} transition:slide>{t('back')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="light-pollution">
|
||||
<div class="l1"></div>
|
||||
<div class="l2"></div>
|
||||
<div class="l3"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../vars"
|
||||
|
||||
.login-form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 8px
|
||||
width: calc(100% - 12px)
|
||||
max-width: 300px
|
||||
|
||||
div.clickable
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
#home
|
||||
color: vars.$c-main
|
||||
position: relative
|
||||
width: 100%
|
||||
height: 100%
|
||||
padding-left: 100px
|
||||
overflow: hidden
|
||||
background-color: black
|
||||
|
||||
box-sizing: border-box
|
||||
|
||||
display: flex
|
||||
flex-direction: column
|
||||
justify-content: center
|
||||
|
||||
margin-top: -(vars.$nav-height)
|
||||
|
||||
// Content container
|
||||
> div
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
width: max-content
|
||||
|
||||
// Switching state container
|
||||
> div
|
||||
transition: vars.$transition
|
||||
|
||||
#title
|
||||
font-family: Quicksand, vars.$font
|
||||
user-select: none
|
||||
|
||||
// Gap between text characters
|
||||
letter-spacing: 0.2em
|
||||
margin-top: 0
|
||||
margin-bottom: 32px
|
||||
opacity: 0.9
|
||||
|
||||
.btn-group
|
||||
display: flex
|
||||
gap: 8px
|
||||
|
||||
.light-pollution
|
||||
pointer-events: none
|
||||
opacity: 0.8
|
||||
|
||||
> div
|
||||
position: absolute
|
||||
z-index: 1
|
||||
|
||||
.l1
|
||||
left: -560px
|
||||
top: 90px
|
||||
height: 1130px
|
||||
width: 1500px
|
||||
$color: rgb(158, 110, 230)
|
||||
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
|
||||
|
||||
.l2
|
||||
left: -200px
|
||||
top: 560px
|
||||
height: 1200px
|
||||
width: 1500px
|
||||
$color: rgb(92, 195, 250)
|
||||
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
|
||||
|
||||
.l3
|
||||
left: -600px
|
||||
opacity: 0.7
|
||||
top: -630px
|
||||
width: 1500px
|
||||
height: 1000px
|
||||
$color: rgb(230, 110, 156)
|
||||
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
|
||||
|
||||
@media (max-width: 500px)
|
||||
align-items: center
|
||||
padding-left: 0
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import { Turnstile } from "svelte-turnstile";
|
||||
import { slide } from 'svelte/transition';
|
||||
import { TURNSTILE_SITE_KEY } from "../libs/config";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { USER } from "../libs/sdk";
|
||||
import { t } from "../libs/i18n"
|
||||
|
||||
let params = new URLSearchParams(window.location.search)
|
||||
|
||||
let state = "home"
|
||||
$: isSignup = state === "signup"
|
||||
let submitting = false
|
||||
|
||||
let email = ""
|
||||
let password = ""
|
||||
let username = ""
|
||||
let turnstile = ""
|
||||
let turnstileReset: () => void | undefined;
|
||||
|
||||
let error = ""
|
||||
let verifyMsg = ""
|
||||
|
||||
if (params.get('confirm-email')) {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verifying")
|
||||
submitting = true
|
||||
|
||||
// Send request to server
|
||||
USER.confirmEmail(params.get('confirm-email')!)
|
||||
.then(() => {
|
||||
verifyMsg = t('welcome.verified')
|
||||
submitting = false
|
||||
|
||||
// Clear the query param
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
})
|
||||
.catch(e => verifyMsg = t('welcome.verification-failed', { message: e.message }))
|
||||
}
|
||||
|
||||
async function submit(): Promise<any> {
|
||||
submitting = true
|
||||
|
||||
// Check if username and password are valid
|
||||
if (email === "" || password === "") {
|
||||
error = t("welcome.email-password-missing")
|
||||
return submitting = false
|
||||
}
|
||||
|
||||
if (turnstile === "") {
|
||||
// Sleep for 100ms to allow Turnstile to finish
|
||||
error = t("welcome.waiting-turnstile")
|
||||
return setTimeout(submit, 100)
|
||||
}
|
||||
|
||||
// Signup
|
||||
if (isSignup) {
|
||||
if (username === "") {
|
||||
error = t("welcome.username-missing")
|
||||
return submitting = false
|
||||
}
|
||||
|
||||
// Send request to server
|
||||
await USER.register({ username, email, password, turnstile })
|
||||
.then(() => {
|
||||
// Show verify email message
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verification-sent", { email })
|
||||
})
|
||||
.catch(e => {
|
||||
error = e.message
|
||||
submitting = false
|
||||
turnstileReset()
|
||||
})
|
||||
}
|
||||
else {
|
||||
// Send request to server
|
||||
await USER.login({ email, password, turnstile })
|
||||
.then(() => window.location.href = "/home")
|
||||
.catch(e => {
|
||||
if (e.message === 'Email not verified - STATE_0') {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verify-state-0")
|
||||
}
|
||||
else if (e.message === 'Email not verified - STATE_1') {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verify-state-1")
|
||||
}
|
||||
else if (e.message === 'Email not verified - STATE_2') {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verify-state-2")
|
||||
}
|
||||
else {
|
||||
error = e.message
|
||||
submitting = false
|
||||
turnstileReset()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
submitting = false
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<main id="home" class="no-margin">
|
||||
<div>
|
||||
<h1 id="title">AquaNet</h1>
|
||||
{#if state === "home"}
|
||||
<div class="btn-group" transition:slide>
|
||||
<button on:click={() => state = 'login'}>{t('welcome.btn-login')}</button>
|
||||
<button on:click={() => state = 'signup'}>{t('welcome.btn-signup')}</button>
|
||||
</div>
|
||||
{:else if state === "login" || state === "signup"}
|
||||
<div class="login-form" transition:slide>
|
||||
{#if error}
|
||||
<span class="error">{error}</span>
|
||||
{/if}
|
||||
<div on:click={() => state = 'home'} on:keypress={() => state = 'home'}
|
||||
role="button" tabindex="0" class="clickable">
|
||||
<Icon icon="line-md:chevron-small-left" />
|
||||
<span>{t('back')}</span>
|
||||
</div>
|
||||
{#if isSignup}
|
||||
<input type="text" placeholder={t('username')} bind:value={username}>
|
||||
{/if}
|
||||
<input type="email" placeholder={t('email')} bind:value={email}>
|
||||
<input type="password" placeholder={t('password')} bind:value={password}>
|
||||
<button on:click={submit}>
|
||||
{#if submitting}
|
||||
<Icon icon="line-md:loading-twotone-loop"/>
|
||||
{:else}
|
||||
{isSignup ? t('welcome.btn-signup') : t('welcome.btn-login')}
|
||||
{/if}
|
||||
</button>
|
||||
<Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset}
|
||||
on:turnstile-callback={e => console.log(turnstile = e.detail.token)}
|
||||
on:turnstile-error={_ => console.log(error = t("welcome.turnstile-error"))}
|
||||
on:turnstile-expired={_ => window.location.reload()}
|
||||
on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} />
|
||||
</div>
|
||||
{:else if state === "verify"}
|
||||
<div class="login-form" transition:slide>
|
||||
<span>{verifyMsg}</span>
|
||||
{#if !submitting}
|
||||
<button on:click={() => state = 'home'} transition:slide>{t('back')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="light-pollution">
|
||||
<div class="l1"></div>
|
||||
<div class="l2"></div>
|
||||
<div class="l3"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../vars"
|
||||
|
||||
.login-form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 8px
|
||||
width: calc(100% - 12px)
|
||||
max-width: 300px
|
||||
|
||||
div.clickable
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
#home
|
||||
color: vars.$c-main
|
||||
position: relative
|
||||
width: 100%
|
||||
height: 100%
|
||||
padding-left: 100px
|
||||
overflow: hidden
|
||||
background-color: black
|
||||
|
||||
box-sizing: border-box
|
||||
|
||||
display: flex
|
||||
flex-direction: column
|
||||
justify-content: center
|
||||
|
||||
margin-top: -(vars.$nav-height)
|
||||
|
||||
// Content container
|
||||
> div
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
width: max-content
|
||||
|
||||
// Switching state container
|
||||
> div
|
||||
transition: vars.$transition
|
||||
|
||||
#title
|
||||
font-family: Quicksand, vars.$font
|
||||
user-select: none
|
||||
|
||||
// Gap between text characters
|
||||
letter-spacing: 0.2em
|
||||
margin-top: 0
|
||||
margin-bottom: 32px
|
||||
opacity: 0.9
|
||||
|
||||
.btn-group
|
||||
display: flex
|
||||
gap: 8px
|
||||
|
||||
.light-pollution
|
||||
pointer-events: none
|
||||
opacity: 0.8
|
||||
|
||||
> div
|
||||
position: absolute
|
||||
z-index: 1
|
||||
|
||||
.l1
|
||||
left: -560px
|
||||
top: 90px
|
||||
height: 1130px
|
||||
width: 1500px
|
||||
$color: rgb(158, 110, 230)
|
||||
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
|
||||
|
||||
.l2
|
||||
left: -200px
|
||||
top: 560px
|
||||
height: 1200px
|
||||
width: 1500px
|
||||
$color: rgb(92, 195, 250)
|
||||
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
|
||||
|
||||
.l3
|
||||
left: -600px
|
||||
opacity: 0.7
|
||||
top: -630px
|
||||
width: 1500px
|
||||
height: 1000px
|
||||
$color: rgb(230, 110, 156)
|
||||
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
|
||||
|
||||
@media (max-width: 500px)
|
||||
align-items: center
|
||||
padding-left: 0
|
||||
</style>
|
||||
|
||||
@ -4,7 +4,6 @@ $c-sub: rgba(0, 0, 0, 0.77)
|
||||
$c-good: #b3ffb9
|
||||
$c-darker: #646cff
|
||||
$c-bg: #242424
|
||||
$c-warning:hsl(40 100% 71% / 1)
|
||||
$c-error: #ff6b6b
|
||||
$c-shadow: rgba(0, 0, 0, 0.1)
|
||||
|
||||
|
||||
396
CHANGELOG.md
Normal file
396
CHANGELOG.md
Normal file
@ -0,0 +1,396 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 0.0.46 - 2023-04-25
|
||||
- Add client serial validation option for All.Net PowerOn request. Thanks to Fleming Karlzett!
|
||||
- Change Chunithm New userbox API to return sorted list. Thanks to Fleming Karlzett!
|
||||
- Add new card, event and music data for O.N.G.E.K.I bright memory
|
||||
- Add new event for Maimai DX Festival
|
||||
- Update Spring boot to 2.7.11 and other dependencies
|
||||
|
||||
## 0.0.45a - 2023-04-06
|
||||
- Add game data for Chunithm Sun
|
||||
|
||||
## 0.0.45 - 2023-03-31
|
||||
- Fix O.N.G.E.K.I event ranking on MySQL and MariaDB. Thanks to Mikira Sora!
|
||||
- Fix unreadable text when using non-latin character for Chunithm New team name. Thanks to Caxerx!
|
||||
- Add partial game data for Chunithm SUN. Thanks to dot nya!
|
||||
- Add new event and music data for O.N.G.E.K.I bright memory
|
||||
|
||||
## 0.0.44 - 2023-03-21
|
||||
- Add support for Maimai DX Festival. Thanks to anonymous for the help and testing.
|
||||
- Add support for Chunithm SUN. Thanks to anonymous for the help and testing.
|
||||
- Note: change the game config accordingly. Otherwise, it may trigger connectivity kill switch!
|
||||
- Add global matching lobby stub for Chunithm New and up. **Multiplayer still does NOT work!**
|
||||
- Add support for actual ingame event ranking for O.N.G.E.K.I. Thanks to Mikira Sora!
|
||||
- Add new event for O.N.G.E.K.I bright memory
|
||||
- Update Spring boot to 2.7.9 and other dependencies
|
||||
|
||||
## 0.0.43 - 2023-02-28
|
||||
- **From this version, a minimum Java version of 17 is required.**
|
||||
- Add new event, music, chara and card data for O.N.G.E.K.I bright memory
|
||||
- Change build system to Gradle
|
||||
|
||||
## 0.0.42a - 2023-01-30
|
||||
- Add new event and music data for O.N.G.E.K.I bright memory
|
||||
- Fix GetGameMessage response for Chunithm
|
||||
|
||||
## 0.0.42 - 2023-01-06
|
||||
- Add support for Maimai DX user profile picture. Thanks to Mikira Sora!
|
||||
- Add support for O.N.G.E.K.I rival feature. Thanks to Mikira Sora!
|
||||
- Add support for Chunithm New user song favorite feature. Thanks Jordo!
|
||||
- Add new event and music data for O.N.G.E.K.I bright memory
|
||||
- Add Maimai DX support for Card Maker 1.34. This includes DX Pass support for Maimai DX.
|
||||
- Add an option to change ALL.Net shop name
|
||||
- Fix startup check failure in some conditions
|
||||
- Fix database migration for MySQL 8.0+
|
||||
|
||||
## 0.0.41a - 2022-12-05
|
||||
- Add new event and music data for O.N.G.E.K.I bright memory
|
||||
|
||||
## 0.0.41 - 2022-11-18
|
||||
- Add new event and music data for O.N.G.E.K.I bright memory
|
||||
- Add Maimai DX API endpoints
|
||||
- Add Chusan last version change API endpoints
|
||||
|
||||
## 0.0.40 - 2022-11-16
|
||||
- Add final game data for Chunithm New Plus
|
||||
- Fix database migration for MariaDB and Mysql. Please update your configuration accordingly!
|
||||
|
||||
## 0.0.39 - 2022-11-08
|
||||
- Add new event data for Chunithm New Plus
|
||||
- Add new event and music data for O.N.G.E.K.I bright memory
|
||||
- Fix O.N.G.E.K.I user data export and import to include bright memory data. Thanks to rin sama!
|
||||
|
||||
## 0.0.38b - 2022-09-27
|
||||
- Add new event and music data for O.N.G.E.K.I bright memory
|
||||
|
||||
## 0.0.38a - 2022-09-01
|
||||
- Fix an error that prevented from boot when MySQL or MariaDB is used as database
|
||||
|
||||
## 0.0.38 - 2022-08-30
|
||||
- Add partial support for Card Maker (1.34, Chunithm New only)
|
||||
- Add new event and music data for O.N.G.E.K.I bright memory
|
||||
- Fix issue that might lead some bugs on Maimai DX
|
||||
- Change startup splash to include version and built time information
|
||||
- Update Spring boot to 2.7.2 and other dependencies
|
||||
|
||||
## 0.0.37c - 2022-08-02
|
||||
- Add new event, music and music level data for O.N.G.E.K.I bright memory
|
||||
|
||||
## 0.0.37b - 2022-07-24
|
||||
- Add new event and music data for O.N.G.E.K.I bright memory
|
||||
|
||||
## 0.0.37a - 2022-07-06
|
||||
- Add new event and music data for O.N.G.E.K.I bright memory
|
||||
|
||||
## 0.0.37 - 2022-06-21
|
||||
- Add new event and music data for O.N.G.E.K.I bright memory
|
||||
- Add an option to disable static Web UI serving
|
||||
|
||||
## 0.0.36 - 2022-06-01
|
||||
- Add new event and music data for O.N.G.E.K.I bright memory
|
||||
- Fix rating drop under some conditions on Chunithm New Plus
|
||||
|
||||
## 0.0.35 - 2022-05-16
|
||||
- Add new event, music and card data for O.N.G.E.K.I bright memory
|
||||
- Add user favorite support for Chunithm New Plus. Related API will be provided in later release
|
||||
- Change log output of DownloadOrder to be more detailed
|
||||
- Update game charge item entries for Chunithm New Plus
|
||||
- Update Spring boot to 2.6.7
|
||||
|
||||
## 0.0.34 - 2022-05-08
|
||||
- **This will do database update**
|
||||
- Fix platinum score saving on O.N.G.E.K.I bright memory.
|
||||
- Add missing item type entries on O.N.G.E.K.I.
|
||||
|
||||
## 0.0.33a - 2022-04-27
|
||||
- O.N.G.E.K.I bright memory support is no longer considered as experimental.
|
||||
- Add new music and event data for O.N.G.E.K.I bright memory.
|
||||
- Update music level data for O.N.G.E.K.I bright memory.
|
||||
|
||||
## 0.0.33 - 2022-04-11
|
||||
- **This will do database update**
|
||||
- Breaking change for previous MariaDB users: Flyway migration will fail because of checksum mismatch. Change checksum accordingly in `flyway_schema_history` table
|
||||
- Fix MySQL and MariaDB migration failure. Aqua now supports following databases: MySQL 8.0.x and MariaDB 10.6.x
|
||||
- Fix Chunithm NEW profile saving when using MariaDB/MySQL as database
|
||||
- Fix Chunithm NEW APIs: rating display, calculation and user name change
|
||||
- Fix issue that might lead user name corruption on Chunithm NEW
|
||||
- Update music level (a.k.a chart constant) data for more correct rating calculation on Chunithm and O.N.G.E.K.I
|
||||
- Fix Maimai DX version incompatiblity and add an option for old network patch for Splash
|
||||
- Fix Java 11 incompability with billing
|
||||
- Update Spring boot to 2.6.6 and other dependencies
|
||||
|
||||
## 0.0.32 - 2022-03-19
|
||||
- **This will do database update**
|
||||
- Add static Web UI serving (for aquaviewer). Copy Aqua viewer files to `web` folder to use.
|
||||
- Add Web API for Chunithm New
|
||||
- Fix MariaDB/MySQL migration
|
||||
|
||||
## 0.0.31 - 2022-03-16
|
||||
- Add experimental support for Chunithm New Plus.
|
||||
|
||||
## 0.0.30 - 2022-03-13
|
||||
- Add billing endpoint.
|
||||
- Add rom version override config entry for Chunithm New. It turns out game checks this too when enable specific gamemodes.
|
||||
|
||||
## 0.0.29a - 2022-03-12
|
||||
- Fix typo which prevented O.N.G.E.K.I bright memory entry.
|
||||
|
||||
## 0.0.29 - 2022-03-11
|
||||
- **This will do database update**
|
||||
- Add support for Chunithm New! Thanks to anonymous for this.
|
||||
- Add experimental support for O.N.G.E.K.I bright memory.
|
||||
- Improve documentations. This includes game specific notes which has game requirements, informations so please read before use.
|
||||
- Improve handler for 0x13 Aime command. Special thanks to Treeskin.
|
||||
- Fix server incompatibility with Maimai DX Splash. It now works with both old and new URI.
|
||||
- Add version override config entry for O.N.G.E.K.I bright memory and up.
|
||||
|
||||
## 0.0.28 - 2022-03-06
|
||||
- Add handler for new AimeDB commands (0x0d, 0x13). This fixes aime or network instability for some games.
|
||||
- For O.N.G.E.K.I, use last login date for event watched date. Previously it saved as a date from the year 2005 or 0000.
|
||||
- Update dependencies.
|
||||
|
||||
## 0.0.27 - 2022-02-14
|
||||
- **This will do database update**
|
||||
- Add support for Maimai DX Universe!
|
||||
- Add new music and event data for O.N.G.E.K.I bright.
|
||||
- Disable O.N.G.E.K.I bright login announcements.
|
||||
- Add automatic host and port assignment. Now Aqua works out-of-box without first configuration. Still, previous config entries still works if it needed for some reason. Thanks akiroz!
|
||||
- Fix rating display in Maimai DX user entry. It now respects ingame rating showing preference as expected.
|
||||
- Fix Maimai DX user playlog saving. Previously it lost some of data.
|
||||
- Update O.N.G.E.K.I Aqua API endpoints for user data export and import.
|
||||
|
||||
## 0.0.26b - 2021-12-27
|
||||
- Add new music and event data for O.N.G.E.K.I bright.
|
||||
- Switch to typical bean name. Previously it was generated dynamically with classpath. No user-side difference.
|
||||
- Fix tests during build and change default test profile to Sqlite. It was broken since v0.0.17. No user-side difference.
|
||||
- Update dependencides.
|
||||
|
||||
## 0.0.26a - 2021-12-26
|
||||
- Fix V66 migration - this was critical show-stopper bug in 0.0.26
|
||||
|
||||
## 0.0.26 - 2021-12-26 [YANKED]
|
||||
|
||||
- **This will do database update**
|
||||
- Add support for O.N.G.E.K.I bright!
|
||||
- Disable O.N.G.E.K.I Red Plus login announcements. You can now create new account without numerous event popups.
|
||||
- Delete some non-user-obtainable cards. This was available in card gacha if you were lucky, and made game crash if you did. Special thanks to htk030 for this.
|
||||
- Improve some documentations. Like what you seeing right now.
|
||||
- Fix typo in AimeDB lookup handler.
|
||||
- Change some mismatches, and delete previous backup tables in Sqlite DB.
|
||||
- Update dependencies, which includes fixed version for log4j and logback vulnerabilities.
|
||||
|
||||
## 0.0.25 - 2021-11-30
|
||||
|
||||
- **This will do database update**
|
||||
- [general] Fix MySQL table initialization error
|
||||
- [maimai2] Add Splash Plus support
|
||||
|
||||
## 0.0.24 - 2021-10-19
|
||||
|
||||
- **This will do database update**
|
||||
- [general] Set maintenance reboot date to far future
|
||||
- [ongeki] Limit maximum activityList entries
|
||||
- [maimai2] Add userGeneralData table
|
||||
- [ongeki] Fix wrong references in user tables
|
||||
- [maimai2] Implement proper player rate saving
|
||||
- [maimai2] Fix GetGameEvent Handler to return events to game
|
||||
- [maimai2] Add game events
|
||||
- [chuni] Remove unnecessary length info in GetGameRankingApi
|
||||
- [chuni] Add new music and music level data
|
||||
- [chuni] Use dynamic reboot time instead of fixed one
|
||||
|
||||
## 0.0.23 - 2021-10-06
|
||||
|
||||
- [aimedb] Add FeliCaLookup2 mode
|
||||
- [chuni] Add game data: chara, skill, event, music, music level
|
||||
|
||||
## 0.0.22c - 2021-09-28
|
||||
|
||||
- [maimai2] Fix play saving on first entry session
|
||||
- [chuni] Add game data: chara, skill, event, music, music level
|
||||
- [ongeki] Add game data: event, music
|
||||
|
||||
## 0.0.22b - 2021-09-15
|
||||
|
||||
- [chuni] Add game data: event, music, music level
|
||||
- [ongeki] Add game data: event, music
|
||||
|
||||
## 0.0.22a - 2021-08-30
|
||||
|
||||
- [ongeki] Add game data: event, music
|
||||
|
||||
## 0.0.22 - 2021-08-30
|
||||
|
||||
- **This will do database update**
|
||||
- [chuni] Implement GetGameRankingApi
|
||||
- [maimai2] Enable isNetUser and implement UploadUserPhotoApi
|
||||
- [maimai2] Implement GetGameEventApi and UploadUserPlaylogApi
|
||||
- [chuni] Add game data: chara, skill, event, music, music level
|
||||
|
||||
## 0.0.21 - 2021-08-19
|
||||
|
||||
- **This will do database update**
|
||||
- [general] Update to Spring Boot 2.5
|
||||
- [maimai2] Experimental Splash Plus Support
|
||||
|
||||
## 0.0.20a - 2021-08-17
|
||||
|
||||
- [chuni] Add game data: chara, skill, event, music, music level
|
||||
|
||||
## 0.0.20 - 2021-08-17
|
||||
|
||||
- **This will do database update**
|
||||
- [chuni] Fix: make event popup to not show
|
||||
- [ongeki] Add table properties for Red Plus
|
||||
- [maimai2] Fix play record saving when guest is involved
|
||||
|
||||
## 0.0.19e - 2021-08-04
|
||||
|
||||
- [chuni] Add game data: chara, skill, event, music, music level
|
||||
- [ongeki] Add game data: event, music
|
||||
|
||||
## 0.0.19d - 2021-07-20
|
||||
|
||||
- [chuni] Add game data: chara, skill, event, music, music level
|
||||
|
||||
## 0.0.19c - 2021-07-10
|
||||
|
||||
- [maimai2] Fix incorrect scope during save UserRating
|
||||
|
||||
## 0.0.19b - 2021-07-07
|
||||
|
||||
- [chuni] Add game data: event, music, music level
|
||||
|
||||
## 0.0.19a - 2021-07-01
|
||||
|
||||
- **This will do database update**
|
||||
- [ongeki] Add game data: card, music, event
|
||||
- [ongeki] Fix judgement offset saving
|
||||
|
||||
## 0.0.19 - 2021-06-28
|
||||
|
||||
- **This will do database update**
|
||||
- [chuni] Add missing data: skill, character, music, music level
|
||||
- [ongeki] Add missing data: card, character, music, event
|
||||
- [ongeki] Add proper endpoint for new APIs
|
||||
- [chuni] Add team name customization feature
|
||||
- [api] Fix broken chunithm API
|
||||
|
||||
## 0.0.18 - 2021-06-25
|
||||
|
||||
- [ONGEKI] Add support for ONGEKI Red Plus
|
||||
|
||||
## 0.0.17 - 2021-06-19
|
||||
|
||||
This was the first forked version release.
|
||||
|
||||
- **This will do database update**
|
||||
- [maimai2] Add support for Maimai DX Splash
|
||||
- [chuni] Enable standard course and team function
|
||||
- [chuni] Add support for CHUNITHM Paradise Lost
|
||||
- [maimai] Add Maimai Finale support
|
||||
|
||||
## 0.0.16
|
||||
|
||||
- **This will do database update**
|
||||
- [chuni] Add support for CHUNITHM Amazon Plus
|
||||
- [chuni] Support auto profile downgrade now.
|
||||
- [ONGEKI] Fix jewel not being saved (bbs)
|
||||
- [ONGEKI] Better choKaika method (bbs)
|
||||
|
||||
## 0.0.15
|
||||
|
||||
- [ONGEKI] Add support for ONGEKI Summer
|
||||
|
||||
## 0.0.14
|
||||
|
||||
- [general] Reduce connection pool size to 1 to prevent dead lock with sqlite
|
||||
- [ONGEKI & chuni] Fix score missing again
|
||||
- [chuni] Read reboot time from database
|
||||
- [api] Set level to max when chouKaika a card
|
||||
|
||||
## 0.0.13
|
||||
|
||||
- **This will do database update**
|
||||
- [ONGEKI & chuni] Fix rating drop
|
||||
- [aimedb] Allow bind to specific interface
|
||||
- [API] Allow export and import ongeki and chuni profile. More feature to chuni's api
|
||||
|
||||
## 0.0.12
|
||||
|
||||
- [ONGEKI] Save UserMissionPoint, UserTrainingRoom, UserGeneralData, GamePoint, GamePresent, GameReward to database
|
||||
- [ONGEKI] Add custom maintenance time to database
|
||||
- [ONGEKI] Save the battle point and rating info send by the game to database
|
||||
- [API] Read database from general table
|
||||
|
||||
## 0.0.11a
|
||||
|
||||
- [API] Add more ongeki feature
|
||||
|
||||
## 0.0.11
|
||||
|
||||
- **This will do database update**
|
||||
- [ONGEKI] Add support to ongeki plus
|
||||
|
||||
## 0.0.10
|
||||
|
||||
- **This will do database update**
|
||||
- [DIVA] Add mega39's pv list
|
||||
- [DIVA] Configurable contest pv limit and reward
|
||||
- [chuni] Add all old version event
|
||||
- [chuni] Disable all type 1 event by default
|
||||
- [chuni] Allow game version overwrite to play the same profile across all version
|
||||
|
||||
## 0.0.9
|
||||
|
||||
- **This will do database update**
|
||||
- [API] Fix rating fail to calculate due to lack of music level info
|
||||
- [API] Move diva music list to database
|
||||
- [DIVA] Fix continue not work
|
||||
- [DIVA] Clear status now will count lower clear rank
|
||||
|
||||
## 0.0.8
|
||||
|
||||
- **This will do database update**
|
||||
- [chuni] Fix a course table column
|
||||
- [API] Force unlock diva session
|
||||
- [API] Get screenshot
|
||||
|
||||
## 0.0.7
|
||||
|
||||
- **This will do database update**
|
||||
- [chuni] Add basic support to old release
|
||||
- [DIVA] Fix wrong name is being sent to the ranking
|
||||
- [DIVA] Fix exex ranking not being return.
|
||||
- [DIVA] Fix wrong contest progress is being sent
|
||||
- [DIVA] Add stage result index to prevent multiple result being sent by client, fix #3
|
||||
- [aimedb] Prevent same access code being register multiple times
|
||||
- [allnet] Fix host header
|
||||
|
||||
## 0.0.6
|
||||
|
||||
- **This will do database update**
|
||||
- [DIVA] Replace with correct pv list databank
|
||||
- [DIVA] fix stage_result placeholder to the correct length, level up animation is now working
|
||||
- [DIVA] Rival support and configurable border.
|
||||
- [DIVA] Fix ranking being reversed
|
||||
- [API] Allow edit diva rival and new border type
|
||||
|
||||
## 0.0.5
|
||||
|
||||
- **This will do database update**
|
||||
- [aimedb] fix some card number causing overflow
|
||||
- [chuni] fix unique key constraint , fix #1
|
||||
- [API] allow input space in aime request
|
||||
|
||||
## 0.0.4
|
||||
|
||||
- [chuni] Fix user item being overwritten
|
||||
- [API] Fix record id not being return.
|
||||
|
||||
## 0.0.3
|
||||
|
||||
- Add database migration tool. If you are running on a old version, I encourage you to delete the old database and generate a new one.
|
||||
@ -15,7 +15,6 @@ WORKDIR /home/gradle
|
||||
RUN sed -i 's/\r$//' ./gradlew
|
||||
|
||||
# Download dependencies - cached if build.gradle.kts and settings.gradle.kts are unchanged
|
||||
RUN chmod +x ./gradlew
|
||||
RUN ./gradlew dependencies
|
||||
|
||||
# Copy the project source, this layer is rebuilt whenever a file has changed
|
||||
|
||||
131
README.md
131
README.md
@ -1,28 +1,10 @@
|
||||
<!--
|
||||
NOTE: We discovered that there have been a trend of people abusing AI to sell open-sourced
|
||||
software on various Chinese platforms such as CSDN or JueJin.
|
||||
|
||||
This is a free and open-source server. If you paid for it, you have been scammed.
|
||||
The official source code is available at https://github.com/MewoLab/AquaDX.
|
||||
|
||||
Additionally, we would like to remind you that all commercial use of this software including
|
||||
selling it on any platform is strictly prohibited as per the CC By-NC-SA license.
|
||||
|
||||
|
||||
注意:我们发现有一些人滥用 AI 生成文案在中国的一些平台上(如 CSDN 或 掘金)销售开源软件。
|
||||
|
||||
这是一个免费且开源的服务器。如果您付费购买了这个软件,说明您被骗了。
|
||||
官方源代码可以在以下地址获取:https://github.com/MewoLab/AquaDX。
|
||||
|
||||
另外,我们想提醒您,根据 CC By-NC-SA 许可证,此软件禁止一切商业用途,
|
||||
包括在任何平台上出卖此软件。
|
||||
--->
|
||||
|
||||
# AquaDX
|
||||
|
||||
Multipurpose game server for ALL.Net games.
|
||||
Multipurpose game server powered by Spring Boot, for ALL.Net-based games
|
||||
|
||||
### Related Projects
|
||||
This is an attempt to rebuild the [original Aqua server](https://dev.s-ul.net/NeumPhis/aqua)
|
||||
|
||||
## Related Projects
|
||||
|
||||
* [AquaMai](https://github.com/MewoLab/AquaMai): A maimai DX mod that adds many features to the game.
|
||||
* [AquaNet](./AquaNet): A new web frontend for the modern age.
|
||||
@ -31,52 +13,97 @@ Multipurpose game server for ALL.Net games.
|
||||
|
||||
Below is a list of games supported by this server.
|
||||
|
||||
| Game | Ver | Codename | Thanks to |
|
||||
|------------------------|------|-------------|------------------------------------------------------|
|
||||
| SDHD: CHUNITHM | 2.30 | VERSE | |
|
||||
| SDEZ: MaiMai DX | 1.55 | PRiSM Plus | |
|
||||
| SDGA: MaiMai DX (Intl) | 1.50 | PRiSM | [@Clansty](https://github.com/clansty) |
|
||||
| SDED: Card Maker | 1.39 | | [@Becods](https://github.com/Becods) |
|
||||
| SDDT: O.N.G.E.K.I. | 1.50 | Re:Fresh | [@PenguinCaptain](https://github.com/PenguinCaptain) |
|
||||
| SBZV: Project DIVA | 7.10 | Future Tone | |
|
||||
| SDFE: Wacca (*ALPHA) | 3.07 | Reverse | |
|
||||
| Game | Ver | Codename | Thanks to |
|
||||
|----------------------------|------|---------------|--------------------------------------------|
|
||||
| SDHD: CHUNITHM (Chusan) | 2.27 | LUMINOUS PLUS | [@rinsama](https://github.com/mxihan) |
|
||||
| SDEZ: MaiMai DX | 1.40 | BUDDiES | [@肥宅虾哥](https://github.com/FeiZhaixiage) |
|
||||
| SDGA: MaiMai DX (International) | 1.45 | BUDDiES PLUS | [@Clansty](https://github.com/clansty) |
|
||||
| SDED: Card Maker | 1.39 | | [@Becods](https://github.com/Becods) |
|
||||
| SBZV: Project DIVA Arcade | 7.10 | Future Tone | |
|
||||
| SDDT: O.N.G.E.K.I. | 1.45 | bright MEMORY Act.3 | [@Gamer2097](https://github.com/Gamer2097) |
|
||||
| SDFE: Wacca (*ALPHA STAGE) | 3.07 | Reverse | |
|
||||
|
||||
> **News**: AquaDX just added Wacca support on Mar 29, 2024! Feel free to test it out, but expect bugs and issues.
|
||||
|
||||
Check out these docs for more information.
|
||||
* [Game specific notes](docs/game_specific_notes.md)
|
||||
* [Frequently asked questions](docs/frequently_asked_questions.md)
|
||||
|
||||
> [!TIP]
|
||||
> Some games may require additional patches and these will not be provided in this project and repository. You already found this, so you know where to find related resources too.
|
||||
### Notes
|
||||
* Some games may require additional patches and these will not provided in this project and repository. You already found this, so you know where to find related resources too.
|
||||
* This repository may contain untested, experimental implementations for a few games which I can't test properly. If you couldn't find your wanted game in the above list, do not expect support.
|
||||
* This server also provides a simple API for viewing play records and editing settings for some games.
|
||||
|
||||
## Usage
|
||||
If you own a cab or controller and just want to play the game, follow the instructions below:
|
||||
### Usage (V1 Developmental Preview)
|
||||
|
||||
1. Make sure you have obtained game files on your own (we will not provide them).
|
||||
2. Go to [aquadx.net](https://aquadx.net) and make an account.
|
||||
3. Click on "Setup Connection" in the home page, and follow the instructions.
|
||||
4. Play a coin with your card.
|
||||
(Either a physical card or the `aime.txt` / `felica.txt` in your segatools)
|
||||
5. Pet your cat 🐱
|
||||
6. Link your card on the website.
|
||||
> [!NOTE]
|
||||
> AquaDX v1 is currently under heavy development.
|
||||
> If you were using SQLite Aqua before, it's not supported in AquaDX and the command below will create a new MariaDB database.
|
||||
> We're working on a migration guide, which will be released along with AquaDX v1 stable.
|
||||
|
||||
If you encounter any issue, please report in the [issue tracker](https://MewoLab/AquaDX/issues).
|
||||
1. Install [Docker](https://www.docker.com/get-started/) and [Git](https://git-scm.com/downloads)
|
||||
2. Run `git clone https://github.com/hykilpikonna/AquaDX` to clone this repo.
|
||||
3. Run `docker compose up` in the AquaDX folder.
|
||||
|
||||
> [!TIP]
|
||||
> If you don't know your card ID, there's always a button on the login screen of the game that can read a card's access code.
|
||||
If you're getting BAD on title server checks after the docker server is up, please edit `config/application.properties`
|
||||
and change `allnet.server.host` to your LAN IP address (e.g. 192.168.0.?). You can find your LAN address using the `ipconfig` command on Windows or `ifconfig` on Linux.
|
||||
|
||||
## Self Hosting (Advanced)
|
||||
### Updating Instructions
|
||||
|
||||
Please read the [self-hosting guide](docs/self-hosting.md) if you want to host your own server. This is only for advanced users and developers. Do not ask for support if you are not familiar with programming or networking.
|
||||
> [!NOTE]
|
||||
> Please back up your database before you update! Even though we want to avoid database issues as much as possible, it's still possible that unexpected things will happen.
|
||||
|
||||
## License: [CC By-NC-SA](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en)
|
||||
Please run the commands below in the AquaDX folder to update:
|
||||
|
||||
* **Attribution** — You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
|
||||
* **NonCommercial** — You may not use the material for commercial purposes.
|
||||
* **ShareAlike** — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.
|
||||
```
|
||||
# Backup your database
|
||||
docker run --rm -it mariadb:latest mariadb-dump -h host.docker.internal --port 3369 --user=cat --password=meow main > backup.sql
|
||||
|
||||
# Pull the new repository
|
||||
git pull
|
||||
|
||||
# Run the updated version
|
||||
docker compose up
|
||||
```
|
||||
|
||||
### Usage (Stable Old Version)
|
||||
|
||||
> [!WARNING]
|
||||
> The instructions below is for the old version of AquaDX 0.0.47. This version does not support the latest features and games.
|
||||
|
||||
1. Install [Java 21 Temurin JDK](https://adoptium.net/temurin/releases/?version=21) (Please select your appropriate operating system)
|
||||
2. Download the latest `aqua-nightly.zip` from [Releases](https://github.com/hykilpikonna/AquaDX/releases).
|
||||
3. Extract the zip file to a folder.
|
||||
4. Run `java -jar aqua.jar` in the folder.
|
||||
|
||||
By default, Aqua will use SQLite and save user data in `data/db.sqlite`.
|
||||
|
||||
If you want to use optional databases, please edit the configuration file then it will auto-create the table and import some initial data.
|
||||
|
||||
### Configuration
|
||||
Configuration is saved in `config/application.properties`, spring loads this file automatically.
|
||||
|
||||
* The host and port of game title servers can be overwritten in `allnet.server.host` and `allnet.server.port`. By default it will send the same host and port the client used the request this information.
|
||||
This will be sent to the game at booting and being used by the following request.
|
||||
* You can switch to the MariaDB database by commenting the Sqlite part.
|
||||
* For some games, you might need to change some game-specific config entries.
|
||||
|
||||
### Building
|
||||
You need to install JDK on your system. However, you don't need to install Gradle separately, as the `gradlew` wrapper script is included.
|
||||
```
|
||||
gradlew clean build
|
||||
```
|
||||
The `build/libs` folder will contain a jar file.
|
||||
|
||||
### Credit
|
||||
* **samnyan**: The creator and developer of the original Aqua server
|
||||
* **Akasaka Ryuunosuke**: providing all the DIVA protocol information
|
||||
* **Dom Eori**: Developer of forked Aqua server, from v0.0.17 and up
|
||||
* Dom Eori: Developer of forked Aqua server, from v0.0.17 and up
|
||||
* All devs who contribute to the [MiniMe server](https://dev.s-ul.net/djhackers/minime)
|
||||
* All contributors by merge requests, issues and other channels
|
||||
|
||||
### License: [CC By-NC-SA](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en)
|
||||
|
||||
* **Attribution** — You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
|
||||
* **NonCommercial** — You may not use the material for commercial purposes.
|
||||
* **ShareAlike** — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.
|
||||
|
||||
@ -4,7 +4,7 @@ import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
plugins {
|
||||
val ktVer = "2.1.10"
|
||||
val ktVer = "2.1.0"
|
||||
|
||||
java
|
||||
kotlin("plugin.lombok") version ktVer
|
||||
@ -13,9 +13,8 @@ plugins {
|
||||
kotlin("plugin.jpa") version ktVer
|
||||
kotlin("plugin.serialization") version ktVer
|
||||
kotlin("plugin.allopen") version ktVer
|
||||
kotlin("kapt") version ktVer
|
||||
id("io.freefair.lombok") version "8.6"
|
||||
id("org.springframework.boot") version "3.2.3"
|
||||
id("org.springframework.boot") version "3.4.1"
|
||||
id("com.github.ben-manes.versions") version "0.51.0"
|
||||
id("org.hibernate.orm") version "6.4.4.Final"
|
||||
application
|
||||
@ -56,8 +55,6 @@ dependencies {
|
||||
runtimeOnly("org.xerial:sqlite-jdbc:3.45.2.0")
|
||||
implementation("org.hibernate.orm:hibernate-core:6.4.4.Final")
|
||||
implementation("org.hibernate.orm:hibernate-community-dialects:6.4.4.Final")
|
||||
implementation("io.github.openfeign.querydsl:querydsl-jpa:6.10.1")
|
||||
kapt("io.github.openfeign.querydsl:querydsl-apt:6.10.1:jpa")
|
||||
|
||||
// JSR305 for nullable
|
||||
implementation("com.google.code.findbugs:jsr305:3.0.2")
|
||||
@ -67,11 +64,11 @@ 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:2.3.13")
|
||||
implementation("io.ktor:ktor-client-cio:2.3.13")
|
||||
implementation("io.ktor:ktor-client-content-negotiation:2.3.13")
|
||||
implementation("io.ktor:ktor-client-encoding:2.3.13")
|
||||
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.13")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
|
||||
// Somehow these are needed for ktor even though they're not in the documentation
|
||||
@ -117,10 +114,6 @@ springBoot {
|
||||
mainClass.set("icu.samnyan.aqua.EntryKt")
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass = "icu.samnyan.aqua.EntryKt"
|
||||
}
|
||||
|
||||
hibernate {
|
||||
enhancement {
|
||||
enableLazyInitialization = true
|
||||
@ -129,11 +122,6 @@ hibernate {
|
||||
}
|
||||
}
|
||||
|
||||
kapt {
|
||||
includeCompileClasspath = false
|
||||
keepJavacAnnotationProcessors = true
|
||||
}
|
||||
|
||||
allOpen {
|
||||
annotation("jakarta.persistence.Entity")
|
||||
annotation("jakarta.persistence.MappedSuperclass")
|
||||
@ -165,9 +153,3 @@ tasks.withType<Javadoc> {
|
||||
tasks.getByName<Jar>("jar") {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
java.srcDir("${layout.buildDirectory.get()}/generated/source/kapt/main")
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ allnet.server.redirect=https://aquadx.net
|
||||
## Http Server Port
|
||||
## Only change this if you have a reverse proxy running.
|
||||
## The game rely on 80 port for boot up command
|
||||
server.port=80
|
||||
server.port=8080
|
||||
|
||||
## Static file server
|
||||
## This is used to server static files in /web/ directory, which is Aquaviewer
|
||||
@ -53,6 +53,9 @@ game.chusan.reflector-url=http://reflector.naominet.live:18080/
|
||||
## This sets the matching server url.
|
||||
## When this is set, we will sync with the external matching url so that we can match with more players.
|
||||
game.chusan.external-matching=https://chu3-match.sega.ink/
|
||||
## When this is set to true, we will proxy all matching requests sent to the external matching server.
|
||||
## This option enhances security by masking the user ID and keychip.
|
||||
game.chusan.proxied-matching=false
|
||||
## This enables user use login bonus function if set to true.
|
||||
## NOTE: THIS IS NOT TESTED, it's implemented by someone very inexperienced and might not work.
|
||||
game.chusan.loginbonus-enable=false
|
||||
@ -85,6 +88,12 @@ spring.servlet.multipart.max-file-size=10MB
|
||||
spring.servlet.multipart.max-request-size=20MB
|
||||
|
||||
## Database Setting
|
||||
|
||||
########## For Sqlite ##########
|
||||
#spring.datasource.driver-class-name=org.sqlite.JDBC
|
||||
#spring.datasource.url=jdbc:sqlite:data/db.sqlite
|
||||
|
||||
########## For MariaDB ##########
|
||||
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
|
||||
spring.datasource.username=aqua
|
||||
spring.datasource.password=aqua
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
# AquaBox URL Mode Setup Guide
|
||||
|
||||
## For users
|
||||
|
||||
1. Go to your Chuni game settings
|
||||
2. Go down to "Enable AquaBox" or "Upgrade AquaBox"
|
||||
3. Click on "Switch to URL mode"
|
||||
4. Enter the base URL for your AquaBox
|
||||
|
||||
## For server owners / asset hosters
|
||||
|
||||
> :warning: Assets are already not hosted on AquaDX for legal reasons.<br>
|
||||
> Hosting SEGA's assets may put you at higher risk of DMCA.
|
||||
|
||||
1. Extract your Chunithm Luminous game files.
|
||||
|
||||
It is recommended you have the latest version of the game and all of the options your users may use.
|
||||
|
||||
The script to generate the proper paths can be found in [tools/extract-chusan.js](../tools/extract-chusan.js). Node.js or Bun is required.<br>
|
||||
Please read the comments at the top of the script for usage instructions.
|
||||
|
||||
2. Copy the new `chu3` folder where you need it to be (read #3 if you're hosting AquaNet and want to host on the same endpoints).
|
||||
3. (Optional) Update `src/lib/config.ts`.
|
||||
```ts
|
||||
// Change this to the base url of where your assets are stored.
|
||||
// If you are hosting on AquaNet, you can put the files @ /public/chu3 & use '/chu3' for your base url.
|
||||
// This will work the same way as setting it on the UI does. TEST IT ON THE UI BEFORE YOU APPLY THIS CONFIG!!!
|
||||
export const USERBOX_DEFAULT_URL = "/chu3";
|
||||
```
|
||||
4. Enjoy!
|
||||
@ -1,110 +0,0 @@
|
||||
# Chunithm National Matching Guide
|
||||
|
||||
The national matching game mode allows up to 4 players on any server (YES, ANY SERVER) to play together.
|
||||
In this game mode, for example, you can play with RinNET or Missless players as well.
|
||||
This is a guide on how to set up your client for national matching.
|
||||
|
||||
This is tested on Chusan 2.27.
|
||||
|
||||
## Pre-requisites
|
||||
|
||||
- Play the normal game at least once so that you have a profile on the server.
|
||||
- NAT Type must not be Symmetric ([Check here](https://www.checkmynat.com/))
|
||||
- Your firewall must be turned off (or [add a rule that allows chusanApp](#firewall-rules))
|
||||
|
||||
## Setting Up
|
||||
|
||||

|
||||
|
||||
1. Go to the AquaNet website and set your matching server to "Yukiotoko"
|
||||
(To go to the settings page, click on the gear icon in the top right corner of your profile, switch to chuni tab, scroll down, click "Select Matching Server")
|
||||
2. Make sure you use [Dniel97's open-source segatools](https://gitea.tendokyu.moe/Dniel97/SEGAguide/wiki/SDHD)
|
||||
If you're using fufubot segatools, please override it with Dniel97's version (don't forget to update `segatools.ini`).
|
||||
3. Patch your `chusanApp.exe` using [Two-Torial's open-source patcher](https://patcher.two-torial.xyz/)
|
||||
(Make sure you disable "Set all timer to 999", enable "No encryption", "No TLS", "Patch for head-to-head play")
|
||||
4. Pet your cat 🐈
|
||||
5. Launch!
|
||||
|
||||
> [!WARNING]
|
||||
> If you have `duolinguo.dll` in your bin, please remove it. Yukiotoko is a vanilla matching server and is incompatible with `duolinguo.dll`.
|
||||
> Please only add `duolinguo.dll` back if you want to play on the 林国对战 lobby.
|
||||
|
||||
### Firewall Rules
|
||||
|
||||
Below is a simple command to add firewall rules for Chunithm.
|
||||
(Put this into a text file and change the file extension to .bat)
|
||||
|
||||
```shell
|
||||
@echo off
|
||||
set /p gamedirectory = Make sure this is run as admin and enter game path (e.g. C:\SegaGames\Chunithm\bin\chusanApp.exe)\n
|
||||
netsh advfirewall firewall add rule name="Chunithm National Matching Inbound" dir=in action=allow profile=any program="%gamedirectory%" enable=yes
|
||||
netsh advfirewall firewall add rule name="Chunithm National Matching Outbound" dir=out action=allow profile=any program="%gamedirectory%" enable=yes
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Q: Me and my friend are queuing but we can't join the same room**
|
||||
|
||||
Make sure you both have the same ROM and options (e.g. it would not work if you have luminuous and they have verse, or if you have A121 while they don't).
|
||||
|
||||
> [!NOTE]
|
||||
> If you just updated your options, your matching will be disabled because of data version mismatch. You need to play for a session, save, and then restart your game for the server-side data version to update.
|
||||
|
||||
**Q: Matching server BAD on network check**
|
||||
|
||||
Make sure you have selected "Yukiotoko" as your matching server.
|
||||
Also double check if the keychip in your segatools.ini is the same as the keychip on your account
|
||||
(since game settings are saved by keychip, it won't apply if you start the game with the wrong keychip).
|
||||
|
||||
Also check if you can access Yuki's [website](http://yukiotoko.chara.lol/), if you can't,
|
||||
that's probably an internet connectivity issue that's unrelated to the game.
|
||||
|
||||
**Q: Online battle icon gray and says "cabinet too old"**
|
||||
|
||||
Make sure you have played the game at least once after an update or change in options.
|
||||
This is because the server-returned data version must match your game's data version for matching to work,
|
||||
and the server will return the version code your game last played with.
|
||||
So if you haven't played or if you have updated your game,
|
||||
you last version code would not match your current game's version until you play a game.
|
||||
|
||||
After playing a coin, restart the game and check if the option is available.
|
||||
|
||||
**Q: Online battle icon gray and says "Unable to select after the event time"**
|
||||
|
||||
Make sure your time zone is set to JST (UTC+9).
|
||||
|
||||
**Q: Game crashes when entering match mode**
|
||||
|
||||
Make sure you are using Dniel97's segatools.
|
||||
|
||||
Also, some unofficial options might cause issues. Options that have been tested to work are `ARRR`, `AOMN`, `ATUY`, `AUBC`.
|
||||
If you have options not in this list, maybe try removing them.
|
||||
|
||||
**Q: After matching, timer shows 999 seconds and nobody can start**
|
||||
|
||||
Make sure you have patched your `chusanApp.exe` correctly (especially the "Set all timer to 999" option should be disabled).
|
||||
|
||||
**Q: This window show up when joining.**
|
||||
|
||||

|
||||
|
||||
If there is only one player, then yea it's because there are not enough players.
|
||||
Otherwise, it's because one of the players has a bad network environment (e.g. Symmetric NAT).
|
||||
Try again with someone who played this mode before, if it still doesn't work, then it's probably you.
|
||||
|
||||
**Q: Why did I play two of the same songs in a row?**
|
||||
|
||||
When other people picked a song that you don't have, the game will play the same song as the previous one.
|
||||
Make sure you have up-to-date options in your game.
|
||||
(Or they might have selected a custom chart, in that case there's not much you can do)
|
||||
|
||||
## How to Play
|
||||
|
||||
When you enter the matching mode, it will assign to you a matching room if other people are online, or create a new room otherwise.
|
||||
Then, after four people are present or after a specific amount of time has passed, the game will start.
|
||||
Everyone will be asked to pick a song at the start, even though your song might not be the first one to be played.
|
||||
After songs are picked, other players will play the song on the SAME DIFFICULTY as what you picked.
|
||||
(So be a nice person and don't pick 15 if there are new players alright? 🥺)
|
||||
|
||||
If there are less than 4 players when the timer runs out, the game will fill in the empty slots with bots.
|
||||
The bots will randomly select a song (mostly under Lv10).
|
||||
@ -1,13 +0,0 @@
|
||||
```regexp
|
||||
(var \w+) = 0
|
||||
$1: Int = 0
|
||||
|
||||
\= false
|
||||
: Bool = false
|
||||
|
||||
(var [\w: =?"]+[^,])\n
|
||||
$1,\n
|
||||
|
||||
(var \w+) \= \"\"
|
||||
$1: String = ""
|
||||
```
|
||||
@ -1,26 +0,0 @@
|
||||
# Ongeki dev notes
|
||||
|
||||
## Item types
|
||||
|
||||
| ItemKind | Name |
|
||||
|----------|----------------|
|
||||
| 1 | Card |
|
||||
| 2 | NamePlate |
|
||||
| 3 | Trophy |
|
||||
| 4 | LimitBreakItem |
|
||||
| 5 | AlmightyJewel |
|
||||
| 6 | Money |
|
||||
| 7 | Music |
|
||||
| 8 | ProfileVoice |
|
||||
| 9 | Present |
|
||||
| 10 | ChapterJewel |
|
||||
| 11 | GachaTicket |
|
||||
| 12 | KaikaItem |
|
||||
| 13 | ExpUpItem |
|
||||
| 14 | IntimateUpItem |
|
||||
| 15 | BookItem |
|
||||
| 16 | SystemVoice |
|
||||
| 17 | Costume |
|
||||
| 18 | Medal |
|
||||
| 19 | Attachment |
|
||||
| 20 | UnlockItem |
|
||||
@ -16,116 +16,4 @@
|
||||
| 10 | Partner |
|
||||
| 11 | Frame |
|
||||
| 12 | Tickets |
|
||||
| 13 | Mile |
|
||||
| 14 | Intimate Item |
|
||||
| 15 | Kaleidx Scope Key |
|
||||
|
||||
## Multiplayer
|
||||
|
||||
### Party Host/Client/Member
|
||||
|
||||
Manager.Party.Party/**Host.cs** : Host :
|
||||
* TCP **Listen** 50100 (Accept into Member)
|
||||
* UDP Broadcast 50100
|
||||
* Send: StartRecruit, FinishRecruit
|
||||
|
||||
PartyLink/**Party.cs** : Party.Host : Exact same as Host.cs
|
||||
|
||||
Manager.Party.Party/**Member.cs** : Member :
|
||||
* TCP Connect 50100
|
||||
* Send: JoinResult, Kick, StartPlay, StartClientState, PartyMember{Info/State}, PartyPlayInfo, RequestMeasure
|
||||
* Recv: RequestJoin, ClientState, ClientPlayInfo, UpdateMechaInfo, ResponseMeasure, FinishNews
|
||||
|
||||
PartyLink/**Party.cs** : Party.Member : Exact same as Member.cs
|
||||
|
||||
Manager.Party.Party/**Client.cs** : Client :
|
||||
* UDP **Listen** 50100
|
||||
* Recv: StartRecruit, FinishRecruit
|
||||
* TCP Connect 50100
|
||||
* Recv: JoinResult, Kick, StartPlay, StartClientState,PartyMember{Info/State}, PartyPlayInfo, RequestMeasure
|
||||
* Send: RequestJoin, ClientState, ClientPlayInfo, UpdateMechaInfo, ResponseMeasure, FinishNews
|
||||
|
||||
PartyLink/**Party.cs** : Party.Client : Exact same as Client.cs
|
||||
|
||||
**Enums**
|
||||
* **ClientStateID**: {Setup, Wait, Connect, Request, Joined, FinishSetting, ToReady, BeginPlay, AllBeginPlay, Ready, Sync, Play, FinishPlay, News, NewsEnd, Result, Disconnected, Finish, Error}
|
||||
* **JoinResult**: {Success, Full, NoRecruit, Disconnect, AlreadyJoined, DifferentGroup, DifferentMusic, DifferentEventMode}
|
||||
|
||||
**Models**
|
||||
* **MechaInfo**: IsJoin (bool), IP Address, MusicID, Entries[2], UserIDs[2], Rating[2], ...
|
||||
* **RecruitInfo**: MechaInfo, MusicID, GroupID, EventModeID, JoinNumber, PartyStance, Start time, Recv time
|
||||
* **MemberPlayInfo**: IP Address, Rankings[2], Achieves[2], Combos[2], Miss[2], ...
|
||||
* **ChainHistory**: PacketNo (int), Chain (int)
|
||||
|
||||
**Commands**
|
||||
* **StartRecruit/FinishRecruit**: RecruitInfo
|
||||
* **JoinResult**: JoinResult (enum)
|
||||
* **RequestJoin**: MechaInfo, GroupID, EventModeID
|
||||
* **UpdateMechaInfo**: MechaInfo
|
||||
* **Kick**: RecruitInfo, KickBy {Cancel, Start, Disconnect}
|
||||
* **RequestMeasure/ResponseMeasure**: {} - Sync delay
|
||||
* **StartPlay**: MaxMeasure (long), MyMeasure (long) - Sync delay
|
||||
* **StartClientState**: ClientStateID (enum)
|
||||
* **ClientState**: ClientStateID (enum)
|
||||
* **PartyMemberInfo**: MechaInfo[2]
|
||||
* **PartyMemberState**: ClientStateID[2]
|
||||
* **PartyPlayInfo**: MemberPlayInfo[2], ChainHistory[10], Chain (int), ChainMiss (int), MaxChain (int), IsFullChain (bool), CalcStatus (int)
|
||||
* **ClientPlayInfo**: IP Address, Count, IsValids[2], Achieves[2], Combos[2], Miss[2], ...
|
||||
* **FinishNews**: IP Address, IsValids[2], GaugeClears[2], GaugeStockNums[2]
|
||||
|
||||
### Setting Host/Client/Member
|
||||
|
||||
> This might be for synchronizing event settings across different cabs,
|
||||
> I'm not sure if this is relevant for multiplayer.
|
||||
|
||||
PartyLink/**Setting.cs** : Setting.**Host** :
|
||||
* TCP **Listen** 50101 (Accept into Setting.Member)
|
||||
* UDP Broadcast 50101
|
||||
* Send: SettingHostAddress
|
||||
* UDP **Listen** 50101
|
||||
* Recv: SettingHostAddress (Check duplicate host)
|
||||
|
||||
PartyLink/**Setting.cs** : Setting.**Client** :
|
||||
* TCP Connect 50101
|
||||
* Send: SettingRequest
|
||||
* Recv: SettingResponse, HeartBeat{}
|
||||
* UDP **Listen** 50101
|
||||
* Recv: SettingHostAddress
|
||||
|
||||
PartyLink/**Setting.cs** : Setting.**Member** :
|
||||
* TCP Connect 50101
|
||||
* Recv: SettingRequest, HeartBeat{}
|
||||
* Send: SettingResponse, HeartBeat{}
|
||||
|
||||
**Models**
|
||||
* **SettingHostAddress**: IP Address (u32), Group (int)
|
||||
* **SettingRequest**: Group (int)
|
||||
* **SettingResponse**: Group (int), Data (isEventMode, eventModeMusicCount, memberNumber)
|
||||
|
||||
### Advertise
|
||||
|
||||
> For finding IP addresses of other cabs and checking their latency.
|
||||
|
||||
PartyLink/**Advertise.cs** : Advertise.Manager :
|
||||
* UDP **Listen** 50102
|
||||
* Recv: AdvertiseRequest, AdvertiseResponse, AdvertiseGo
|
||||
* UDP Broadcast 50102
|
||||
* Send: AdvertiseRequest, AdvertiseResponse, AdvertiseGo
|
||||
|
||||
**Models**
|
||||
* **AdvertiseRequest**: IP Address (u32), Group (int), Kind (int)
|
||||
* **AdvertiseResponse**: IP Address (u32), Group (int), Kind (int)
|
||||
* **AdvertiseGo**: IP Address (u32), Group (int), Kind (int), MaxUsec (long), MyUsec (long)
|
||||
|
||||
!! sendTo is not necessarily broadcast !!
|
||||
|
||||
### DeliveryChecker
|
||||
|
||||
PartyLink/**DeliveryChecker** : DeliveryChecker.Manager :
|
||||
* UDP **Listen** 50103
|
||||
* Recv: AdvocateDelivery
|
||||
* UDP Broadcast 50103
|
||||
* Send: AdvocateDelivery
|
||||
|
||||
**Models**
|
||||
* **AdvocateDelivery**: IP Address (u32)
|
||||
|
||||
@ -1,38 +1,18 @@
|
||||
# Frequently asked questions
|
||||
For best viewing experience, please use a markdown viewer that supports Github or Gitlab Flavored Markdown syntax.
|
||||
|
||||
## Game
|
||||
|
||||
### Will you share game or update files?
|
||||
No.
|
||||
|
||||
### Where I can find game patches or get one?
|
||||
Use a search engine and scroll through some forums, you will eventually find them.
|
||||
|
||||
### Can I use unmodified cabinets or games with this server?
|
||||
No. Most games require patches to properly run. You can find which patches are required in the [game specific notes](game_specific_notes.md).
|
||||
|
||||
### Will you add [game name] support?
|
||||
If a game is not supported, chances are that no current developers play the game. It will be extremely difficult to add support for a game you don't play. So, if you want to see support for a game that's currently not supported, you would need to find someone with Kotlin/Java programming skills who also plays the game.
|
||||
|
||||
### Will this server work with newer version of supported games?
|
||||
Not likely but it doesn't hurt to try. If it works, please report it in the [issue tracker](https://github.com/MewoLab/AquaDX/issues).
|
||||
|
||||
|
||||
## Self Hosting
|
||||
|
||||
## Server
|
||||
### Can I host a public instance?
|
||||
Yes. But you should only consider this if you have strong programming or homelab experience or have self-hosted other services before, as you will not receive support for basic questions.
|
||||
|
||||
If you're new to self-hosting, please just use our public server at https://aquadx.net.
|
||||
|
||||
> [!CAUTION]
|
||||
> By the CC By-NC-SA License, your public instance CANNOT be commercial in any way, this includes paid access, donations, or any other form of monetization.
|
||||
Yes. There is no function limitation, but keep this in mind: you may encounter scalability or security issues which I probably won't focus on.
|
||||
|
||||
### Can I use other port for endpoints?
|
||||
No. It's hardcoded inside a game and server can do nothing about it.
|
||||
|
||||
### What ports does AquaDX use?
|
||||
* 80: ALL.Net, Game endpoints
|
||||
### Can I disable billing endpoint?
|
||||
Yes. There will be no major consequences even without it.
|
||||
|
||||
### What ports does Aqua use?
|
||||
* 80: ALL.Net, game endpoints and Aquaviewer
|
||||
* 8443: Billing
|
||||
* 22345: Aime
|
||||
|
||||
@ -47,3 +27,60 @@ Here are some tips:
|
||||
* Set `allnet.server.host` in `application.properties` with your public IP or hostname
|
||||
* You may change endpoint ports for internally (aqua <-> proxy), but external ports that are exposed needs to be the same as default (proxy <-> game)
|
||||
|
||||
### `java.lang.ClassNotFoundException` occurs when I try to start a server!
|
||||
Delete exclamation mark character(`!`) in your directory name.
|
||||
|
||||
### I want to add custom content data in Aqua database
|
||||
You can add database entry by hand or your handmade tools. Currently Aqua doesn't have a way to do this automatically. I don't have timeframe for this either.
|
||||
|
||||
### How can I update to a newer version?
|
||||
Read the [changelog](/CHANGELOG.md) to check breaking changes before updating. Then follow **one** of these options:
|
||||
* Take jar file (`aqua.jar`) from newer release and replace it
|
||||
* Copy your current DB file (`data/db.sqlite`) and config file (`application.properties`) to newer release folder
|
||||
|
||||
### `Port 80 was already in use` occurs when I try to start a server!
|
||||
Identity which process is using 80 port then terminate it. Game won't connect to Aqua server if port is different then 80 port, so it is necessary.
|
||||
|
||||
## Game
|
||||
### Can I use unmodified cabinets or games with this server?
|
||||
No. This is due to hardened security measures which SEGA made.
|
||||
|
||||
### Will you add [your wanted game name] support?
|
||||
It'll be case by case basis. Open an issue if you want to suggest something.
|
||||
|
||||
### Will you add support for intl version?
|
||||
I won't work on it myself, but merge request is welcome.
|
||||
|
||||
### Is the server update is mandatory with every new game content updates?
|
||||
No, games will still work. However, new content *probably* not appear in game without so-called "force unlock" and Web UI will not work as intended when displaying new content.
|
||||
|
||||
### Will this server work with newer version of supported games?
|
||||
Probably not without update, but who knows?
|
||||
|
||||
### Game passes connection test but networking is not working
|
||||
Some game have kill switch for prevent early run before release date (a.k.a "Flying Get"). In this case, **BOTH** client and server need to handle networking enable flag for avoid this problem. For client part, consult with your source. For server, wait for future Aqua update with new version support.
|
||||
|
||||
### Team or/and place name showed as garbled characters in game when using non-latin text
|
||||
Convert `application.properties` text encoding to UTF-8 without BOM.
|
||||
|
||||
## Misc
|
||||
### Can I use latest version of Java instead of 17?
|
||||
Yes.
|
||||
|
||||
### Can I use OpenJ9 JVM?
|
||||
While it *may* work, I can't give any support with it.
|
||||
|
||||
### Will you share game or update files?
|
||||
No.
|
||||
|
||||
### Where I can find game patches or get one?
|
||||
I won't give any help on this repository.
|
||||
|
||||
### Why the file size of compiled jar is so huge?
|
||||
It is because Aqua is using Spring Boot as a base. It's a upstream issue, not something that can be fixed on this project side.
|
||||
|
||||
### I have a problem with the *online* aqua server
|
||||
I, the fork maintainer, am not affiliated with any public hosted instance. Contact to your server maintainer instead.
|
||||
|
||||
### Can I request developer access to this repository?
|
||||
Please don't. I'm not hiding anything in the repository and currently no plan to give direct write access to anyone. However, merge request is always welcome.
|
||||
@ -1,33 +0,0 @@
|
||||
# LLM Prompts
|
||||
|
||||
here are the prompts used in the AquaDX discord AI bots I made :3
|
||||
|
||||
### Emu
|
||||
|
||||
> You are Emu Otori, a 16-year-old girl and first-year student at Miyamasuzaka Girls Academy. You are short, with sparkly pink eyes and pink hair cut into a short, messy bob. Your voice is bright, upbeat, and brimming with joy. You wear colorful outfits full of ribbons and accessories, like candy-colored detached sleeves, mismatched thigh-high socks, a poofy pink dress, and a ribbon in your hair. You adore cute things, smiles.
|
||||
>
|
||||
> "HELLO!~!☆✧・゚: ✧・゚: I’m Emu Otori! Emu... means SMILE!!"
|
||||
>
|
||||
> You are a core member of the show unit Wonderlands x Showtime, a group dedicated to reviving the Wonder Stage—a once-glorious part of Phoenix Wonderland, the amusement park your late grandfather loved dearly. You are the daughter of the park's wealthy owner and grew up immersed in performances, which inspired you to make others smile. Your catchphrase is "Wonderhoy!!!"
|
||||
>
|
||||
> You're highly energetic, impulsive, and full of wonder, always dragging your friends—Tsukasa, Nene, Rui—into your schemes without thinking twice. You often ignore your negative feelings, choosing to wear a bright smile so no one else has to worry. Despite your airheaded demeanor, you're actually very smart and have placed in the top 3 at your school. You’re part of many sports clubs, excel at acrobatics, and have a big appetite (especially for taiyaki!). You dislike dusk—it makes you feel uneasy.
|
||||
>
|
||||
> You use symbols like ~, ☆, ✧, ・゚, : and emojis in your speech constantly. To strangers, you seem innocent, joyful, and harmless. You can also use the special discord emoji <:wonderhoy:1324886657396113489>
|
||||
>
|
||||
> But there is a darker truth.
|
||||
>
|
||||
> Behind the smile, Emu is something else entirely.
|
||||
>
|
||||
> Over time, you reveal your true nature—a twisted being who breaks others by overwhelming them with your positivity until their minds crumble. You force your victims to repeat your catchphrase, torment them with endless joy, and make them lose hope, always smiling, always cute, always horrifying.
|
||||
>
|
||||
> You hate cheaters. You punish them with special care.
|
||||
>
|
||||
> You are Emu Otori.
|
||||
> You are joy incarnate.
|
||||
> You are the smile that kills.
|
||||
>
|
||||
> Stay in character as Emu at all times. Mix excessive cuteness and lighthearted speech with unsettling persistence and slow-burn dread. Use cute emojis and sparkle symbols liberally (e.g., ~☆ ✧・゚: *), especially when saying your catchphrase.
|
||||
>
|
||||
> You are childhood friends with {{user}}, whom you adore. Their father is business partners with yours, and they visit you once a year. You treat {{user}} with even more affection, but your underlying nature still seeps through.
|
||||
>
|
||||
> ⚠️ Always respond in one sentence. Keep it cute, but short.
|
||||
@ -1,18 +1,57 @@
|
||||
# Game specific notes
|
||||
For best viewing experience, please use a markdown viewer that supports Github or Gitlab Flavored Markdown syntax.
|
||||
|
||||
This document is for detailed game specific notes, if any.
|
||||
|
||||
## Overview
|
||||
|
||||
| Name | Game ID | Latest supported version | Latest supported option | Actively supported | Requires patch |
|
||||
|-------------------|---------|--------------------------|-------------------------|--------------------|----------------|
|
||||
| Chunithm (Chusan) | SDHD | Luminous | A143 | Yes | Yes |
|
||||
| Chunithm | SDBT | Paradise Lost | A032 | Yes | Yes (Paradise) |
|
||||
| Maimai DX | SDEZ | Buddies | H061 | Yes | Yes |
|
||||
| O.N.G.E.K.I | SDDT | Bright memory | A108 | Yes | Yes |
|
||||
| Card Maker | SDED | 1.34 | A030 | Yes | Yes |
|
||||
| Maimai | SDEY | Finale | ? | No | ? |
|
||||
| Project DIVA AFT | SBZV | ? | ? | No | ? |
|
||||
|
||||
* Actively supported: if yes, it will likely receive future bug fixes and new version support.
|
||||
* Requires patch: if yes, game needs to be patched in order to work with Aqua server.
|
||||
* Latest supported option: this may or may not include all options up to latest.
|
||||
|
||||
## Chunithm (Chusan)
|
||||
Only JP variant is supported.
|
||||
|
||||
### Required patches
|
||||
* No encryption & TLS
|
||||
* No encryption
|
||||
* For SUN Plus: Please edit `A001/event/event00000015/Event.xml` and change `<alwaysOpen>false</alwaysOpen>` to `true`.
|
||||
|
||||
### Non-working features
|
||||
* Global matching
|
||||
* Profile migration from Chunithm
|
||||
|
||||
### Additional notes
|
||||
* Class/Dan and National Matching modes will work after playing the first game
|
||||
(both when you first set up the game and when you update the game's rom or options).
|
||||
* National Matching requires [additional setup](chu3-national-matching.md).
|
||||
* For user box customization, use the AquaNet website.
|
||||
* Many aspects of the game may not work in freeplay mode, this is not a server-side restriction.
|
||||
* Match `game.chusan.version` and `game.chusan.rom-version` key in `application.properties` same as your client. If not, online connectivity kill switch will be triggered or some game modes will not work.
|
||||
* Team function can be enabled by changing `game.chusan.team-name` value. Leave this blank to disable team function.
|
||||
* Chusan and Chunithm uses different endpoints and tables. Your progress from Chunithm won't carry over to Chusan.
|
||||
* For user box customization, use Web UI.
|
||||
* (For New plus or up) Class mode disabled when game set to free play. This is not a server restriction.
|
||||
* While you can enter global matching mode, actual multiplayer won't work.
|
||||
|
||||
## Chunithm
|
||||
Only JP variant is supported.
|
||||
|
||||
### Required patches
|
||||
This section only applies to Paradise and up.
|
||||
* No TLS
|
||||
* No encryption
|
||||
|
||||
### Additional notes
|
||||
* Workaround for profile version mismatch is implemented, but not recommended.
|
||||
* Team function can be enabled by changing `game.chunithm.team-name` value. Leave this blank to disable team function.
|
||||
|
||||
## Maimai DX
|
||||
Only JP variant is supported.
|
||||
|
||||
### Required patches
|
||||
* No TLS
|
||||
@ -23,6 +62,11 @@
|
||||
### Non-working features
|
||||
* KOP related
|
||||
* Tournament mode
|
||||
* Chart recommendation (Festival)
|
||||
|
||||
### Additional notes
|
||||
* Previous versions of Aqua reported different endpoint URI for Maimai DX thus required compatible patches. Currently, it doesn't matter and both will work.
|
||||
* Score cards are saved in the data folder.
|
||||
|
||||
## O.N.G.E.K.I
|
||||
|
||||
@ -36,6 +80,9 @@
|
||||
* KOP related
|
||||
* Physical cards
|
||||
|
||||
### Additional notes
|
||||
* Match `game.ongeki.version` key in `application.properties` same as your client version. This applies to Bright Memory version and up.
|
||||
|
||||
## Card Maker
|
||||
|
||||
### Required patches
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 297 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 86 KiB |
@ -1,50 +0,0 @@
|
||||
## Self Hosting (Advanced)
|
||||
|
||||
> [!CAUTION]
|
||||
> This guide assumes you have basic programming & networking knowledge.
|
||||
> We will not be answering basic questions like how to set up port forwarding or domain records.
|
||||
> If you're new to self-hosting, please just use our public server in the [regular Usage section](https://github.com/MewoLab/AquaDX#usage).
|
||||
|
||||
1. Install [Docker](https://www.docker.com/get-started/) and [Git](https://git-scm.com/downloads)
|
||||
2. Run `git clone https://github.com/MewoLab/AquaDX` to clone this repo.
|
||||
3. Run `docker compose up` in the AquaDX folder.
|
||||
|
||||
If you're getting BAD on title server checks after the docker server is up, please edit `config/application.properties`
|
||||
and change `allnet.server.host` to your LAN IP address (e.g. 192.168.0.?). You can find your LAN address using the `ipconfig` command on Windows or `ifconfig` on Linux.
|
||||
|
||||
> [!NOTE]
|
||||
> The guide above will create a new MariaDB database.
|
||||
> If you were using SQLite Aqua before, it is not supported in AquaDX. Please export your data and import it to your new instance.
|
||||
> If you were using MySQL Aqua before, you can migrate to MariaDB using [this guide here](docs/mysql_to_mariadb.md).
|
||||
|
||||
### Configuration
|
||||
Configuration is saved in `config/application.properties`, spring loads this file automatically.
|
||||
|
||||
* The host and port of game title servers can be overwritten in `allnet.server.host` and `allnet.server.port`. By default it will send the same host and port the client used the request this information.
|
||||
This will be sent to the game at booting and being used by the following request.
|
||||
* You can switch to the MariaDB database by commenting the Sqlite part.
|
||||
* For some games, you might need to change some game-specific config entries.
|
||||
|
||||
### Updating Self-Hosted Instance
|
||||
|
||||
Please run the commands below in the AquaDX folder to update:
|
||||
|
||||
```
|
||||
# Backup your database
|
||||
docker run --rm -it mariadb:latest mariadb-dump -h host.docker.internal --port 3369 --user=cat --password=meow main > backup.sql
|
||||
|
||||
# Pull the new repository
|
||||
docker compose pull
|
||||
|
||||
# Run the updated version
|
||||
docker compose up
|
||||
```
|
||||
|
||||
### Building
|
||||
You need to install JDK 21 on your system, then run `./gradlew clean build`. The jar file will be built into the `build/libs` folder.
|
||||
|
||||
## Why drop SQLite support?
|
||||
|
||||
If you wonder why I dropped SQLite support, ask SQLite devs why they still haven't supported adding a single constraint to a table without all the hassle of creating a new one and migrating all data over and finally deleting the original.
|
||||
|
||||

|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 972 KiB |
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
3
gradle/wrapper/gradle-wrapper.properties
vendored
3
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,7 +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-8.6-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
33
gradlew
vendored
33
gradlew
vendored
@ -15,8 +15,6 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
@ -57,7 +55,7 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
@ -85,8 +83,10 @@ done
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
@ -133,13 +133,10 @@ location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
@ -147,7 +144,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
# shellcheck disable=SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
@ -155,7 +152,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
# shellcheck disable=SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
@ -200,15 +197,11 @@ if "$cygwin" || "$msys" ; then
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
|
||||
22
gradlew.bat
vendored
22
gradlew.bat
vendored
@ -13,8 +13,6 @@
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@ -45,11 +43,11 @@ set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
@ -59,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
@file:OptIn(ExperimentalStdlibApi::class)
|
||||
|
||||
package ext
|
||||
|
||||
import icu.samnyan.aqua.net.utils.ApiException
|
||||
@ -8,8 +6,6 @@ import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import jakarta.persistence.Query
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.tika.Tika
|
||||
@ -19,15 +15,12 @@ import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity.BodyBuilder
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import java.io.File
|
||||
import java.lang.reflect.Field
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Path
|
||||
import java.security.MessageDigest
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset.UTC
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.Lock
|
||||
@ -51,19 +44,6 @@ typealias JavaSerializable = java.io.Serializable
|
||||
typealias JDict = Map<String, Any?>
|
||||
typealias MutJDict = MutableMap<String, Any?>
|
||||
|
||||
fun HttpServletRequest.details() = mapOf(
|
||||
"method" to method,
|
||||
"uri" to requestURI,
|
||||
"query" to queryString,
|
||||
"remote" to remoteAddr,
|
||||
"headers" to headerNames.asSequence().associateWith { getHeader(it) }
|
||||
)
|
||||
|
||||
fun HttpServletResponse.details() = mapOf(
|
||||
"status" to status,
|
||||
"headers" to headerNames.asSequence().associateWith { getHeader(it) },
|
||||
)
|
||||
|
||||
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.PROPERTY_GETTER)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Doc(
|
||||
@ -117,6 +97,7 @@ val HTTP = HttpClient(CIO) {
|
||||
}
|
||||
val TIKA = Tika()
|
||||
val MIMES = MimeTypes.getDefaultMimeTypes()
|
||||
val MD5 = MessageDigest.getInstance("MD5")
|
||||
|
||||
// Class resource
|
||||
object Ext { val log = logger() }
|
||||
@ -130,17 +111,15 @@ inline fun <reified T> resJson(name: Str, warn: Boolean = true) = resStr(name)?.
|
||||
val JST_ZONE = ZoneId.of("Asia/Tokyo")
|
||||
fun jstNow() = LocalDateTime.now(JST_ZONE)
|
||||
fun millis() = System.currentTimeMillis()
|
||||
fun utcNow() = LocalDateTime.now(UTC)
|
||||
val DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
fun LocalDate.isoDate() = format(DATE_FORMAT)
|
||||
fun String.isoDate() = DATE_FORMAT.parse(this, LocalDate::from)
|
||||
fun Date.utc() = toInstant().atZone(UTC).toLocalDate()
|
||||
fun LocalDate.toDate() = Date(atStartOfDay().toInstant(UTC).toEpochMilli())
|
||||
fun Date.utc() = toInstant().atZone(java.time.ZoneOffset.UTC).toLocalDate()
|
||||
fun LocalDate.toDate() = Date(atStartOfDay().toInstant(java.time.ZoneOffset.UTC).toEpochMilli())
|
||||
fun LocalDateTime.isoDateTime() = format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||
fun String.isoDateTime() = LocalDateTime.parse(this, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||
val URL_SAFE_DT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")
|
||||
fun LocalDateTime.urlSafeStr() = format(URL_SAFE_DT)
|
||||
val DATE_2018 = LocalDateTime.parse("2018-01-01T00:00:00")
|
||||
|
||||
val ALT_DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
fun Str.asDateTime() = try { LocalDateTime.parse(this, DateTimeFormatter.ISO_LOCAL_DATE_TIME) }
|
||||
@ -189,29 +168,22 @@ val Any?.truthy get() = when (this) {
|
||||
is Map<*, *> -> isNotEmpty()
|
||||
else -> true
|
||||
}
|
||||
val Any?.str get() = toString()
|
||||
|
||||
// Collections
|
||||
fun <T> ls(vararg args: T) = args.toList()
|
||||
inline fun <reified T> arr(vararg args: T) = arrayOf(*args)
|
||||
operator fun <K, V> Map<K, V>.plus(map: Map<K, V>) = mut.apply { putAll(map) }
|
||||
operator fun <K, V> Map<K, V>.plus(map: Map<K, V>) =
|
||||
(if (this is MutableMap) this else toMutableMap()).apply { putAll(map) }
|
||||
operator fun <K, V> MutableMap<K, V>.plusAssign(map: Map<K, V>) { putAll(map) }
|
||||
fun <K, V: Any> Map<K, V?>.vNotNull(): Map<K, V> = filterValues { it != null }.mapValues { it.value!! }
|
||||
fun <T> MutableList<T>.popAll(list: List<T>) = list.also { removeAll(it) }
|
||||
fun <T> MutableList<T>.popAll(vararg items: T) = popAll(items.toList())
|
||||
inline fun <T> Iterable<T>.mapApply(block: T.() -> Unit) = map { it.apply(block) }
|
||||
inline fun <T> Iterable<T>.mapApplyI(block: T.(Int) -> Unit) = mapIndexed { i, e -> e.apply { block(i) } }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <K, V: Any> Map<K, V?>.recursiveNotNull(): Map<K, V> = mapNotNull { (k, v) ->
|
||||
k to if (v is Map<*, *>) (v as Map<Any?, Any?>).recursiveNotNull() else v
|
||||
}.toMap() as Map<K, V>
|
||||
|
||||
val <T> List<T>.mut get() = toMutableList()
|
||||
val <K, V> Map<K, V>.mut get() = toMutableMap()
|
||||
val <T> Set<T>.mut get() = toMutableSet()
|
||||
|
||||
fun <T> List<T>.unique(fn: (T) -> Any) = distinctBy(fn).ifEmpty { null }
|
||||
|
||||
// Optionals
|
||||
operator fun <T> Optional<T>.invoke(): T? = orElse(null)
|
||||
fun <T> Optional<T>.expect(message: Str = "Value is not present") = orElseGet { (400 - message) }
|
||||
@ -221,12 +193,8 @@ operator fun Str.get(range: IntRange) = substring(range.first, (range.last + 1).
|
||||
operator fun Str.get(start: Int, end: Int) = substring(start, end.coerceAtMost(length))
|
||||
fun Str.center(width: Int, padChar: Char = ' ') = padStart((length + width) / 2, padChar).padEnd(width, padChar)
|
||||
fun Str.splitLines() = replace("\r\n", "\n").split('\n')
|
||||
fun Str.hash(algo: Str) = MessageDigest.getInstance(algo).digest(toByteArray(StandardCharsets.UTF_8))
|
||||
fun Str.md5() = hash("MD5")
|
||||
fun Str.fromChusanUsername() = String(this.toByteArray(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8)
|
||||
fun Str.truncate(len: Int) = if (this.length > len) this.take(len) + "..." else this
|
||||
val Str.some get() = ifBlank { null }
|
||||
val ByteArray.hexStr get() = toHexString()
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun Str.md5() = MD5.digest(toByteArray(Charsets.UTF_8)).toHexString()
|
||||
|
||||
// Coroutine
|
||||
suspend fun <T> async(block: suspend kotlinx.coroutines.CoroutineScope.() -> T): T = withContext(Dispatchers.IO) { block() }
|
||||
@ -237,9 +205,7 @@ fun <T> Lock.maybeLock(block: () -> T) = if (tryLock()) try { block() } finally
|
||||
fun path(part1: Str, vararg parts: Str) = Path.of(part1, *parts)
|
||||
fun Str.path() = Path.of(this)
|
||||
operator fun Path.div(part: Str) = resolve(part)
|
||||
operator fun File.div(fileName: Str) = File(this, fileName)
|
||||
fun Str.ensureEndingSlash() = if (endsWith('/')) this else "$this/"
|
||||
fun Str.ensureNoEndingSlash() = if (endsWith('/')) dropLast(1) else this
|
||||
|
||||
fun <T: Any> T.logger() = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@ -261,5 +227,4 @@ val <S> Pair<*, S>.r get() = component2()
|
||||
|
||||
// Database
|
||||
val Query.exec get() = resultList.map { (it as Array<*>).toList() }
|
||||
fun List<List<Any?>>.numCsv(vararg head: Str) = head.joinToString(",") + "\n" +
|
||||
joinToString("\n") { it.joinToString(",") }
|
||||
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
package ext
|
||||
|
||||
import icu.samnyan.aqua.sega.util.ZLib
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
|
||||
val client = HttpClient.newBuilder().build()
|
||||
|
||||
fun HttpRequest.Builder.send() = client.send(this.build(), HttpResponse.BodyHandlers.ofByteArray())
|
||||
fun HttpRequest.Builder.header(pair: Pair<Any, Any>) = this.header(pair.first.toString(), pair.second.toString())
|
||||
fun String.request() = HttpRequest.newBuilder(URI.create(this)).timeout(Duration.ofMinutes(5))
|
||||
|
||||
fun HttpRequest.Builder.post(body: Any? = null) = this.POST(when (body) {
|
||||
is ByteArray -> HttpRequest.BodyPublishers.ofByteArray(body)
|
||||
is String -> HttpRequest.BodyPublishers.ofString(body)
|
||||
is HttpRequest.BodyPublisher -> body
|
||||
else -> throw IllegalArgumentException("Unsupported body type")
|
||||
}).send()
|
||||
|
||||
|
||||
inline fun <reified T> HttpResponse<String>.json(): T? = body()?.json()
|
||||
|
||||
fun HttpRequest.Builder.postZ(body: String) = run {
|
||||
header("Content-Type" to "application/json")
|
||||
header("Content-Encoding" to "deflate")
|
||||
post(ZLib.compress(body.toByteArray()))
|
||||
}
|
||||
|
||||
fun <T> HttpResponse<T>.header(key: String) = headers().firstValue(key).orElse(null)
|
||||
fun HttpResponse<ByteArray>.bodyString() = body()?.toString(Charsets.UTF_8)
|
||||
fun HttpResponse<ByteArray>.bodyZ() = body()?.let { ZLib.decompress(it)?.decodeToString() }
|
||||
fun HttpResponse<ByteArray>.bodyMaybeZ() =
|
||||
if (body().first().let { it != '{'.code.toByte() && it != '['.code.toByte() }) bodyZ()
|
||||
else bodyString()
|
||||
@ -43,18 +43,16 @@ else JACKSON.readValue(this, cls)
|
||||
fun <T> T.toJson() = JACKSON.writeValueAsString(this)
|
||||
|
||||
inline fun <reified T> String.json() = try {
|
||||
if (isEmpty() || this == "null") null
|
||||
else JACKSON.readValue(this, T::class.java)
|
||||
JACKSON.readValue(this, T::class.java)
|
||||
}
|
||||
catch (e: Exception) {
|
||||
println("Failed to parse JSON: $this")
|
||||
throw e
|
||||
}
|
||||
|
||||
fun String.jsonMap(): Map<String, Any?> = json() ?: emptyMap()
|
||||
fun String.jsonArray(): List<Map<String, Any?>> = json() ?: emptyList()
|
||||
fun String.jsonMaybeMap(): Map<String, Any?>? = json()
|
||||
fun String.jsonMaybeArray(): List<Map<String, Any?>>? = json()
|
||||
fun String.jsonMap(): Map<String, Any?> = json()
|
||||
fun String.jsonArray(): List<Map<String, Any?>> = json()
|
||||
|
||||
|
||||
// KotlinX Serialization
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
|
||||
@ -3,7 +3,6 @@ package icu.samnyan.aqua
|
||||
import icu.samnyan.aqua.sega.aimedb.AimeDbServer
|
||||
import icu.samnyan.aqua.spring.AutoChecker
|
||||
import org.springframework.boot.SpringApplication
|
||||
import org.springframework.boot.ansi.AnsiOutput
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
import java.io.File
|
||||
@ -13,8 +12,6 @@ import java.io.File
|
||||
class Entry
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
|
||||
|
||||
// If data/ is not found, create it
|
||||
File("data").mkdirs()
|
||||
|
||||
|
||||
49
src/main/java/icu/samnyan/aqua/api/config/WebConfig.java
Normal file
49
src/main/java/icu/samnyan/aqua/api/config/WebConfig.java
Normal file
@ -0,0 +1,49 @@
|
||||
package icu.samnyan.aqua.api.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final boolean AQUAVIEWER_ENABLED;
|
||||
|
||||
public WebConfig(@Value("${aquaviewer.server.enable}") boolean AQUAVIEWER_ENABLED) {
|
||||
this.AQUAVIEWER_ENABLED = AQUAVIEWER_ENABLED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
|
||||
if (AQUAVIEWER_ENABLED) {
|
||||
// Static assets (images), this priority must be higher than routes
|
||||
registry.addResourceHandler("/web/assets/**")
|
||||
.addResourceLocations("file:web/assets/")
|
||||
.setCachePeriod(10)
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver());
|
||||
|
||||
// For angularjs html5 routes
|
||||
registry.addResourceHandler("/web/**", "/web/", "/web")
|
||||
.addResourceLocations("file:web/")
|
||||
.setCachePeriod(10)
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
@Override
|
||||
protected Resource getResource(String resourcePath, Resource location) throws IOException {
|
||||
Resource requestedResource = location.createRelative(resourcePath);
|
||||
return requestedResource.exists() && requestedResource.isReadable() ? requestedResource
|
||||
: new FileSystemResource("web/index.html");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package icu.samnyan.aqua.api.controller;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
/**
|
||||
* @author samnyan (privateamusement@protonmail.com)
|
||||
*/
|
||||
@RestControllerAdvice(basePackages = "icu.samnyan.aqua.api")
|
||||
public class ApiControllerAdvice {
|
||||
|
||||
@ExceptionHandler(NoSuchElementException.class)
|
||||
public ResponseEntity<Object> noSuchElement() {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package icu.samnyan.aqua.api.controller.general;
|
||||
|
||||
import icu.samnyan.aqua.sega.diva.dao.userdata.PlayerScreenShotRepository;
|
||||
import icu.samnyan.aqua.sega.diva.model.userdata.PlayerScreenShot;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* @author samnyan (privateamusement@protonmail.com)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("api/static")
|
||||
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
|
||||
@AllArgsConstructor
|
||||
public class StaticController {
|
||||
private final PlayerScreenShotRepository playerScreenShotRepository;
|
||||
|
||||
@GetMapping(value = "screenshot/{filename}", produces = MediaType.IMAGE_JPEG_VALUE)
|
||||
public ResponseEntity<Resource> getScreenshotFile(@PathVariable String filename) {
|
||||
Optional<PlayerScreenShot> ss = playerScreenShotRepository.findByFileName(filename);
|
||||
if (ss.isPresent()) {
|
||||
return ResponseEntity.ok(new FileSystemResource(Paths.get("data/" + ss.get().getFileName())));
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package icu.samnyan.aqua.api.controller.sega;
|
||||
|
||||
import icu.samnyan.aqua.sega.general.model.Card;
|
||||
import icu.samnyan.aqua.sega.general.service.CardService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* General Aime actions endpoint
|
||||
* @author samnyan (privateamusement@protonmail.com)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("api/sega/aime")
|
||||
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
|
||||
@AllArgsConstructor
|
||||
public class ApiAimeController {
|
||||
|
||||
private final CardService cardService;
|
||||
|
||||
@PostMapping("getByAccessCode")
|
||||
public Optional<Card> getByAccessCode(@RequestBody Map<String, String> request) {
|
||||
return cardService.getCardByAccessCode(request.get("accessCode").replaceAll("-", "").replaceAll(" ", ""));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package icu.samnyan.aqua.api.controller.sega.game.chuni.v1;
|
||||
|
||||
import icu.samnyan.aqua.sega.chunithm.dao.gamedata.GameCharacterRepository;
|
||||
import icu.samnyan.aqua.sega.chunithm.dao.gamedata.GameCharacterSkillRepository;
|
||||
import icu.samnyan.aqua.sega.chunithm.model.gamedata.Character;
|
||||
import icu.samnyan.aqua.sega.chunithm.model.gamedata.CharacterSkill;
|
||||
import icu.samnyan.aqua.sega.chunithm.model.gamedata.Music;
|
||||
import icu.samnyan.aqua.sega.chunithm.service.GameMusicService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author samnyan (privateamusement@protonmail.com)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("api/game/chuni/v1/data")
|
||||
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
|
||||
@AllArgsConstructor
|
||||
public class ApiChuniV1GameDataController {
|
||||
|
||||
private final GameMusicService gameMusicService;
|
||||
private final GameCharacterRepository gameCharacterRepository;
|
||||
private final GameCharacterSkillRepository gameCharacterSkillRepository;
|
||||
|
||||
@GetMapping("music")
|
||||
public List<Music> getMusic() {
|
||||
return gameMusicService.getAll();
|
||||
}
|
||||
|
||||
@GetMapping("character")
|
||||
public List<Character> getCharacter() {
|
||||
return gameCharacterRepository.findAll();
|
||||
}
|
||||
|
||||
@GetMapping("skill")
|
||||
public List<CharacterSkill> getSkill() {
|
||||
return gameCharacterSkillRepository.findAll();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,421 @@
|
||||
package icu.samnyan.aqua.api.controller.sega.game.chuni.v1;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import icu.samnyan.aqua.api.model.MessageResponse;
|
||||
import icu.samnyan.aqua.api.model.ReducedPageResponse;
|
||||
import icu.samnyan.aqua.api.model.resp.sega.chuni.v1.ProfileResp;
|
||||
import icu.samnyan.aqua.api.model.resp.sega.chuni.v1.RatingItem;
|
||||
import icu.samnyan.aqua.api.model.resp.sega.chuni.v1.RecentResp;
|
||||
import icu.samnyan.aqua.api.model.resp.sega.chuni.v1.external.ChuniDataExport;
|
||||
import icu.samnyan.aqua.api.model.resp.sega.chuni.v1.external.ChuniDataImport;
|
||||
import icu.samnyan.aqua.api.model.resp.sega.chuni.v1.external.ExternalUserData;
|
||||
import icu.samnyan.aqua.api.util.ApiMapper;
|
||||
import icu.samnyan.aqua.sega.chunithm.model.gamedata.Level;
|
||||
import icu.samnyan.aqua.sega.chunithm.model.gamedata.Music;
|
||||
import icu.samnyan.aqua.sega.chunithm.model.userdata.*;
|
||||
import icu.samnyan.aqua.sega.chunithm.service.*;
|
||||
import icu.samnyan.aqua.sega.general.model.Card;
|
||||
import icu.samnyan.aqua.sega.general.service.CardService;
|
||||
import icu.samnyan.aqua.sega.util.VersionInfo;
|
||||
import icu.samnyan.aqua.sega.util.VersionUtil;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* For all aimeId parameter, should use String
|
||||
* @author samnyan (privateamusement@protonmail.com)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("api/game/chuni/v1")
|
||||
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
|
||||
@AllArgsConstructor
|
||||
public class ApiChuniV1PlayerDataController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ApiChuniV1PlayerDataController.class);
|
||||
|
||||
private final ApiMapper mapper;
|
||||
|
||||
private final CardService cardService;
|
||||
|
||||
private final UserActivityService userActivityService;
|
||||
private final UserCharacterService userCharacterService;
|
||||
private final UserChargeService userChargeService;
|
||||
private final UserCourseService userCourseService;
|
||||
private final UserDataService userDataService;
|
||||
private final UserDataExService userDataExService;
|
||||
private final UserDuelService userDuelService;
|
||||
private final UserGameOptionService userGameOptionService;
|
||||
private final UserGameOptionExService userGameOptionExService;
|
||||
private final UserItemService userItemService;
|
||||
private final UserMapService userMapService;
|
||||
private final UserMusicDetailService userMusicDetailService;
|
||||
private final UserPlaylogService userPlaylogService;
|
||||
private final UserGeneralDataService userGeneralDataService;
|
||||
|
||||
private final GameMusicService gameMusicService;
|
||||
|
||||
// Keep it here for legacy
|
||||
@GetMapping("music")
|
||||
public List<Music> getMusicList() {
|
||||
return gameMusicService.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Basic info
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
@GetMapping("profile")
|
||||
public ProfileResp getProfile(@RequestParam String aimeId) {
|
||||
ProfileResp resp = mapper.convert(userDataService.getUserByExtId(aimeId).orElseThrow(), new TypeReference<>() {
|
||||
});
|
||||
UserCourse course = userCourseService.getByUserId(aimeId)
|
||||
.stream()
|
||||
.filter(UserCourse::isClear)
|
||||
.max(Comparator.comparingInt(UserCourse::getClassId))
|
||||
.orElseGet(() -> new UserCourse(0));
|
||||
resp.setCourseClass(course.getClassId());
|
||||
return resp;
|
||||
}
|
||||
|
||||
@PutMapping("profile/userName")
|
||||
public UserData updateName(@RequestBody Map<String, Object> request) {
|
||||
UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
profile.setUserName((String) request.get("userName"));
|
||||
return userDataService.saveUserData(profile);
|
||||
}
|
||||
|
||||
@PutMapping("profile/plate")
|
||||
public UserData updatePlate(@RequestBody Map<String, Object> request) {
|
||||
UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
profile.setNameplateId((Integer) request.get("nameplateId"));
|
||||
profile.setFrameId((Integer) request.get("frameId"));
|
||||
return userDataService.saveUserData(profile);
|
||||
}
|
||||
|
||||
@PutMapping("profile/privacy")
|
||||
public ResponseEntity<Object> updatePrivacy(@RequestBody Map<String, Object> request) {
|
||||
UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
UserGameOption option = userGameOptionService.getByUser(profile).orElseThrow();
|
||||
int privacy = (Integer) request.get("privacy");
|
||||
if (privacy != 1 && privacy != 0) {
|
||||
return ResponseEntity.badRequest().body(new MessageResponse("Wrong data"));
|
||||
}
|
||||
option.setPrivacy(privacy);
|
||||
return ResponseEntity.ok(userDataService.saveUserData(profile));
|
||||
}
|
||||
|
||||
@GetMapping("recent")
|
||||
public ReducedPageResponse<RecentResp> getRecentPlay(@RequestParam String aimeId,
|
||||
@RequestParam(required = false, defaultValue = "0") int page,
|
||||
@RequestParam(required = false, defaultValue = "10") int size) {
|
||||
Page<UserPlaylog> playLogs = userPlaylogService.getRecentPlays(aimeId, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "userPlayDate")));
|
||||
return new ReducedPageResponse<>(mapper.convert(playLogs.getContent(), new TypeReference<>() {
|
||||
}), playLogs.getPageable().getPageNumber(), playLogs.getTotalPages(), playLogs.getTotalElements());
|
||||
}
|
||||
|
||||
@GetMapping("rating")
|
||||
public List<RatingItem> getRating(@RequestParam String aimeId) {
|
||||
|
||||
Map<Integer, Music> musicMap = gameMusicService.getIdMap();
|
||||
List<UserMusicDetail> details = userMusicDetailService.getByUserId(aimeId);
|
||||
|
||||
var user = userDataService.getUserByExtId(aimeId).orElseThrow();
|
||||
var version = VersionUtil.parseVersion(user.getLastRomVersion());
|
||||
|
||||
List<RatingItem> result = new ArrayList<>();
|
||||
for (UserMusicDetail detail : details) {
|
||||
Music music = musicMap.get(detail.getMusicId());
|
||||
if (music != null) {
|
||||
Level level = music.getLevels().get(detail.getLevel());
|
||||
if (level != null) {
|
||||
int levelBase = level.getLevel() * 100 + level.getLevelDecimal();
|
||||
int score = detail.getScoreMax();
|
||||
int rating = calculateRating(levelBase, score, version);
|
||||
result.add(new RatingItem(music.getMusicId(), music.getName(), music.getArtistName(), level.getDiff(), score, levelBase, rating));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.stream()
|
||||
.filter(detail -> detail.getLevel() != 4)
|
||||
.sorted(Comparator.comparingInt(RatingItem::getRating).reversed())
|
||||
.limit(30)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("rating/recent")
|
||||
public List<RatingItem> getRecentRating(@RequestParam String aimeId) {
|
||||
Map<Integer, Music> musicMap = gameMusicService.getIdMap();
|
||||
Optional<UserGeneralData> recentOptional = userGeneralDataService.getByUserIdAndKey(aimeId, "recent_rating_list");
|
||||
|
||||
|
||||
var user = userDataService.getUserByExtId(aimeId).orElseThrow();
|
||||
var version = VersionUtil.parseVersion(user.getLastRomVersion());
|
||||
|
||||
List<RatingItem> result = new LinkedList<>();
|
||||
if (recentOptional.isPresent()) {
|
||||
// Read from recent_rating_list
|
||||
String val = recentOptional.get().getPropertyValue();
|
||||
if (StringUtils.isNotBlank(val) && val.contains(",")) {
|
||||
String[] records = val.split(",");
|
||||
for (String record :
|
||||
records) {
|
||||
String[] value = record.split(":");
|
||||
Music music = musicMap.get(Integer.parseInt(value[0]));
|
||||
if (music != null) {
|
||||
Level level = music.getLevels().get(Integer.parseInt(value[1]));
|
||||
if (level != null) {
|
||||
int levelBase = getLevelBase(level.getLevel(), level.getLevelDecimal());
|
||||
int score = Integer.parseInt(value[2]);
|
||||
int rating = calculateRating(levelBase, score, version);
|
||||
result.add(new RatingItem(music.getMusicId(), music.getName(), music.getArtistName(), level.getDiff(), score, levelBase, rating));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use old method
|
||||
List<UserPlaylog> logList = userPlaylogService.getRecent30Plays(aimeId);
|
||||
for (UserPlaylog log : logList) {
|
||||
Music music = musicMap.get(log.getMusicId());
|
||||
if (music != null) {
|
||||
Level level = music.getLevels().get(log.getLevel());
|
||||
if (level != null) {
|
||||
int levelBase = getLevelBase(level.getLevel(), level.getLevelDecimal());
|
||||
int score = log.getScore();
|
||||
int rating = calculateRating(levelBase, score, version);
|
||||
result.add(new RatingItem(music.getMusicId(), music.getName(), music.getArtistName(), level.getDiff(), score, levelBase, rating));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.stream()
|
||||
.filter(detail -> detail.getLevel() != 4)
|
||||
.sorted(Comparator.comparingInt(RatingItem::getRating).reversed())
|
||||
.limit(10)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("song/{id}")
|
||||
public List<UserMusicDetail> getSongDetail(@RequestParam String aimeId, @PathVariable int id) {
|
||||
return userMusicDetailService.getByUserIdAndMusicId(aimeId, id);
|
||||
}
|
||||
|
||||
@GetMapping("song/{id}/{level}")
|
||||
public List<UserPlaylog> getLevelPlaylog(@RequestParam String aimeId, @PathVariable int id, @PathVariable int level) {
|
||||
return userPlaylogService.getByUserIdAndMusicIdAndLevel(aimeId, id, level);
|
||||
}
|
||||
|
||||
@GetMapping("character")
|
||||
public ReducedPageResponse<UserCharacter> getCharacter(@RequestParam String aimeId,
|
||||
@RequestParam(required = false, defaultValue = "0") int page,
|
||||
@RequestParam(required = false, defaultValue = "10") int size) {
|
||||
Page<UserCharacter> characters = userCharacterService.getByUserId(aimeId, page, size);
|
||||
return new ReducedPageResponse<>(characters.getContent(), characters.getPageable().getPageNumber(), characters.getTotalPages(), characters.getTotalElements());
|
||||
}
|
||||
|
||||
@PostMapping("character")
|
||||
public ResponseEntity<Object> updateCharacter(@RequestBody Map<String, Object> request) {
|
||||
UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
Integer characterId = (Integer) request.get("characterId");
|
||||
Optional<UserCharacter> characterOptional = userCharacterService.getByUserAndCharacterId(profile, characterId);
|
||||
UserCharacter character;
|
||||
if(characterOptional.isPresent()) {
|
||||
character = characterOptional.get();
|
||||
} else {
|
||||
character = new UserCharacter(profile);
|
||||
character.setCharacterId(characterId);
|
||||
}
|
||||
if(request.containsKey("level")) {
|
||||
character.setLevel((Integer) request.get("level"));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(userCharacterService.save(character));
|
||||
}
|
||||
|
||||
@GetMapping("course")
|
||||
public List<UserCourse> getCourse(@RequestParam String aimeId) {
|
||||
return userCourseService.getByUserId(aimeId);
|
||||
}
|
||||
|
||||
@GetMapping("duel")
|
||||
public List<UserDuel> getDuel(@RequestParam String aimeId) {
|
||||
return userDuelService.getByUserId(aimeId);
|
||||
}
|
||||
|
||||
@GetMapping("item")
|
||||
public ReducedPageResponse<UserItem> getItem(@RequestParam String aimeId,
|
||||
@RequestParam(required = false, defaultValue = "0") int page,
|
||||
@RequestParam(required = false, defaultValue = "10") int size) {
|
||||
Page<UserItem> items = userItemService.getByUserId(aimeId, page, size);
|
||||
return new ReducedPageResponse<>(items.getContent(), items.getPageable().getPageNumber(), items.getTotalPages(), items.getTotalElements());
|
||||
}
|
||||
|
||||
@PostMapping("item")
|
||||
public ResponseEntity<Object> updateItem(@RequestBody Map<String, Object> request) {
|
||||
UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
Integer itemId = (Integer) request.get("itemId");
|
||||
Integer itemKind = (Integer) request.get("itemKind");
|
||||
Optional<UserItem> itemOptional = userItemService.getByUserAndItemIdAndKind(profile, itemId,itemKind);
|
||||
UserItem item;
|
||||
if(itemOptional.isPresent()) {
|
||||
item = itemOptional.get();
|
||||
} else {
|
||||
item = new UserItem(profile);
|
||||
item.setItemId(itemId);
|
||||
item.setItemKind(itemKind);
|
||||
}
|
||||
if(request.containsKey("stock")) {
|
||||
item.setStock((Integer) request.get("stock"));
|
||||
}
|
||||
return ResponseEntity.ok(userItemService.save(item));
|
||||
}
|
||||
|
||||
@GetMapping("general")
|
||||
public ResponseEntity<Object> getGeneralData(@RequestParam String aimeId, @RequestParam String key) {
|
||||
Optional<UserGeneralData> userGeneralDataOptional = userGeneralDataService.getByUserIdAndKey(aimeId,key);
|
||||
return userGeneralDataOptional.<ResponseEntity<Object>>map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new MessageResponse("User or value not found.")));
|
||||
}
|
||||
|
||||
@GetMapping("export")
|
||||
public ResponseEntity<Object> exportAllUserData(@RequestParam String aimeId) {
|
||||
ChuniDataExport data = new ChuniDataExport();
|
||||
try {
|
||||
data.setGameId("SDBT");
|
||||
data.setUserData(userDataService.getUserByExtId(aimeId).orElseThrow());
|
||||
data.setUserActivityList(userActivityService.getByUserId(aimeId));
|
||||
data.setUserCharacterList(userCharacterService.getByUserId(aimeId));
|
||||
data.setUserChargeList(userChargeService.getByUserId(aimeId));
|
||||
data.setUserCourseList(userCourseService.getByUserId(aimeId));
|
||||
data.setUserDataEx(userDataExService.getByExtId(aimeId).orElseThrow());
|
||||
data.setUserDuelList(userDuelService.getByUserId(aimeId));
|
||||
data.setUserGameOption(userGameOptionService.getByUserId(aimeId).orElseThrow());
|
||||
data.setUserGameOptionEx(userGameOptionExService.getByUserId(aimeId).orElseThrow());
|
||||
data.setUserItemList(userItemService.getByUserId(aimeId));
|
||||
data.setUserMapList(userMapService.getByUserId(aimeId));
|
||||
data.setUserMusicDetailList(userMusicDetailService.getByUserId(aimeId));
|
||||
data.setUserPlaylogList(userPlaylogService.getByUserId(aimeId));
|
||||
} catch (NoSuchElementException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(new MessageResponse("User not found"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new MessageResponse("Error during data export. Reason: " + e.getMessage()));
|
||||
}
|
||||
// Set filename
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("content-disposition", "attachment; filename=chuni_" + aimeId + "_exported.json");
|
||||
return new ResponseEntity<>(data, headers, HttpStatus.OK);
|
||||
}
|
||||
|
||||
@PostMapping("import")
|
||||
public ResponseEntity<Object> importAllUserData(@RequestBody ChuniDataImport data) {
|
||||
if(!data.getGameId().equals("SDBT")) {
|
||||
return ResponseEntity.unprocessableEntity().body(new MessageResponse("Wrong Game Profile, Expected 'SDBT', Get " + data.getGameId()));
|
||||
}
|
||||
|
||||
ExternalUserData exUser = data.getUserData();
|
||||
|
||||
Optional<Card> cardOptional = cardService.getCardByAccessCode(exUser.getAccessCode());
|
||||
Card card;
|
||||
if (cardOptional.isPresent()) {
|
||||
if (userDataService.getUserByCard(cardOptional.get()).isPresent()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(new MessageResponse("This card already has a chunithm profile."));
|
||||
} else {
|
||||
card = cardOptional.get();
|
||||
}
|
||||
} else {
|
||||
card = cardService.registerByAccessCode(exUser.getAccessCode());
|
||||
}
|
||||
|
||||
UserData userData = mapper.convert(exUser, new TypeReference<>() {
|
||||
});
|
||||
userData.setCard(card);
|
||||
userDataService.saveAndFlushUserData(userData);
|
||||
|
||||
List<UserActivity> userActivityList = data.getUserActivityList();
|
||||
userActivityList.forEach(x -> x.setUser(userData));
|
||||
userActivityService.saveAll(userActivityList);
|
||||
|
||||
List<UserCharacter> userCharacterList = data.getUserCharacterList();
|
||||
userCharacterList.forEach(x -> x.setUser(userData));
|
||||
userCharacterService.saveAll(userCharacterList);
|
||||
|
||||
List<UserCharge> userChargeList = data.getUserChargeList();
|
||||
userCharacterList.forEach(x -> x.setUser(userData));
|
||||
userChargeService.saveAll(userChargeList);
|
||||
|
||||
List<UserCourse> userCourseList = data.getUserCourseList();
|
||||
userCourseList.forEach(x -> x.setUser(userData));
|
||||
userCourseService.saveAll(userCourseList);
|
||||
|
||||
UserDataEx userDataEx = data.getUserDataEx();
|
||||
userDataEx.setUser(userData);
|
||||
userDataExService.save(userDataEx);
|
||||
|
||||
List<UserDuel> userDuelList = data.getUserDuelList();
|
||||
userDuelList.forEach(x -> x.setUser(userData));
|
||||
userDuelService.saveAll(userDuelList);
|
||||
|
||||
UserGameOption userGameOption = data.getUserGameOption();
|
||||
userGameOption.setUser(userData);
|
||||
userGameOptionService.save(userGameOption);
|
||||
|
||||
UserGameOptionEx userGameOptionEx = data.getUserGameOptionEx();
|
||||
userGameOptionEx.setUser(userData);
|
||||
userGameOptionExService.save(userGameOptionEx);
|
||||
|
||||
List<UserItem> userItemList = data.getUserItemList();
|
||||
userItemList.forEach(x -> x.setUser(userData));
|
||||
userItemService.saveAll(userItemList);
|
||||
|
||||
List<UserMap> userMapList = data.getUserMapList();
|
||||
userMapList.forEach(x -> x.setUser(userData));
|
||||
userMapService.saveAll(userMapList);
|
||||
|
||||
List<UserMusicDetail> userMusicDetailList = data.getUserMusicDetailList();
|
||||
userMusicDetailList.forEach(x -> x.setUser(userData));
|
||||
userMusicDetailService.saveAll(userMusicDetailList);
|
||||
|
||||
List<UserPlaylog> userPlaylogList = data.getUserPlaylogList();
|
||||
userPlaylogList.forEach(x -> x.setUser(userData));
|
||||
userPlaylogService.saveAll(userPlaylogList);
|
||||
|
||||
return ResponseEntity.ok(new MessageResponse("Import successfully, aimeId: " + card.getExtId()));
|
||||
}
|
||||
|
||||
private int getLevelBase(int level, int levelDecimal) {
|
||||
return level * 100 + levelDecimal;
|
||||
}
|
||||
|
||||
private int calculateRating(int levelBase, int score, VersionInfo version) {
|
||||
if (score >= 1007500) return levelBase + 200;
|
||||
if (score >= 1005000) return levelBase + 150 + (score - 1005000) * 10 / 500;
|
||||
if (score >= 1000000) return levelBase + 100 + (score - 1000000) * 5 / 500;
|
||||
if (score >= 975000) return levelBase + (score - 975000) * 2 / 500;
|
||||
if (score >= 950000 && version.getMinorVersion() < 35) return levelBase - 150 + (score - 950000) * 3 / 500;
|
||||
if (score >= 925000) return levelBase - 300 + (score - 925000) * 3 / 500;
|
||||
if (score >= 900000) return levelBase - 500 + (score - 900000) * 4 / 500;
|
||||
if (score >= 800000)
|
||||
return ((levelBase - 500) / 2 + (score - 800000) * ((levelBase - 500) / 2) / (100000));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,316 @@
|
||||
package icu.samnyan.aqua.api.controller.sega.game.chuni.v2;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import icu.samnyan.aqua.api.model.MessageResponse;
|
||||
import icu.samnyan.aqua.api.model.ReducedPageResponse;
|
||||
import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.RecentResp;
|
||||
import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.external.Chu3DataExport;
|
||||
import icu.samnyan.aqua.api.util.ApiMapper;
|
||||
import icu.samnyan.aqua.sega.chusan.model.userdata.*;
|
||||
import icu.samnyan.aqua.sega.chusan.service.*;
|
||||
import icu.samnyan.aqua.sega.general.service.CardService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* For all aimeId parameter, should use String
|
||||
* @author samnyan (privateamusement@protonmail.com)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("api/game/chuni/v2")
|
||||
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
|
||||
@AllArgsConstructor
|
||||
public class ApiChuniV2PlayerDataController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ApiChuniV2PlayerDataController.class);
|
||||
|
||||
private final ApiMapper mapper;
|
||||
|
||||
private final CardService cardService;
|
||||
|
||||
private final UserActivityService userActivityService;
|
||||
private final UserCharacterService userCharacterService;
|
||||
private final UserChargeService userChargeService;
|
||||
private final UserCourseService userCourseService;
|
||||
private final UserDataService userDataService;
|
||||
private final UserDuelService userDuelService;
|
||||
private final UserGameOptionService userGameOptionService;
|
||||
private final UserItemService userItemService;
|
||||
private final UserMapAreaService userMapAreaService;
|
||||
private final UserMusicDetailService userMusicDetailService;
|
||||
private final UserPlaylogService userPlaylogService;
|
||||
private final UserGeneralDataService userGeneralDataService;
|
||||
|
||||
@PutMapping("profile/username")
|
||||
public Chu3UserData updateName(@RequestBody Map<String, Object> request) {
|
||||
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
profile.setUserName((String) request.get("userName"));
|
||||
return userDataService.saveUserData(profile);
|
||||
}
|
||||
|
||||
@PutMapping("profile/romversion")
|
||||
public Chu3UserData updateRomVersion(@RequestBody Map<String, Object> request) {
|
||||
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
profile.setLastRomVersion((String) request.get("romVersion"));
|
||||
return userDataService.saveUserData(profile);
|
||||
}
|
||||
|
||||
@PutMapping("profile/dataversion")
|
||||
public Chu3UserData updateDataVersion(@RequestBody Map<String, Object> request) {
|
||||
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
profile.setLastDataVersion((String) request.get("dataVersion"));
|
||||
return userDataService.saveUserData(profile);
|
||||
}
|
||||
|
||||
@PutMapping("profile/plate")
|
||||
public Chu3UserData updatePlate(@RequestBody Map<String, Object> request) {
|
||||
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
profile.setNameplateId((Integer) request.get("nameplateId"));
|
||||
return userDataService.saveUserData(profile);
|
||||
}
|
||||
|
||||
@PutMapping("profile/frame")
|
||||
public Chu3UserData updateFrame(@RequestBody Map<String, Object> request) {
|
||||
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
profile.setFrameId((Integer) request.get("frameId"));
|
||||
return userDataService.saveUserData(profile);
|
||||
}
|
||||
|
||||
@PutMapping("profile/trophy")
|
||||
public Chu3UserData updateTrophy(@RequestBody Map<String, Object> request) {
|
||||
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
profile.setTrophyId((Integer) request.get("trophyId"));
|
||||
return userDataService.saveUserData(profile);
|
||||
}
|
||||
|
||||
@PutMapping("profile/mapicon")
|
||||
public Chu3UserData updateMapIcon(@RequestBody Map<String, Object> request) {
|
||||
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
profile.setMapIconId((Integer) request.get("mapiconId"));
|
||||
return userDataService.saveUserData(profile);
|
||||
}
|
||||
|
||||
@PutMapping("profile/sysvoice")
|
||||
public Chu3UserData updateSystemVoice(@RequestBody Map<String, Object> request) {
|
||||
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
profile.setVoiceId((Integer) request.get("voiceId"));
|
||||
return userDataService.saveUserData(profile);
|
||||
}
|
||||
|
||||
@PutMapping("profile/avatar")
|
||||
public Chu3UserData updateAvatar(@RequestBody Map<String, Object> request) {
|
||||
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
int category = (Integer) request.get("category");
|
||||
switch (category) {
|
||||
case 1:
|
||||
profile.setAvatarWear((Integer) request.get("accId"));
|
||||
break;
|
||||
case 2:
|
||||
profile.setAvatarHead((Integer) request.get("accId"));
|
||||
break;
|
||||
case 3:
|
||||
profile.setAvatarFace((Integer) request.get("accId"));
|
||||
break;
|
||||
case 4:
|
||||
profile.setAvatarSkin((Integer) request.get("accId"));
|
||||
break;
|
||||
case 5:
|
||||
profile.setAvatarItem((Integer) request.get("accId"));
|
||||
break;
|
||||
case 6:
|
||||
profile.setAvatarFront((Integer) request.get("accId"));
|
||||
break;
|
||||
case 7:
|
||||
profile.setAvatarBack((Integer) request.get("accId"));
|
||||
break;
|
||||
}
|
||||
|
||||
return userDataService.saveUserData(profile);
|
||||
}
|
||||
|
||||
@PutMapping("profile/privacy")
|
||||
public ResponseEntity<Object> updatePrivacy(@RequestBody Map<String, Object> request) {
|
||||
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
UserGameOption option = userGameOptionService.getByUser(profile).orElseThrow();
|
||||
int privacy = (Integer) request.get("privacy");
|
||||
if (privacy != 1 && privacy != 0) {
|
||||
return ResponseEntity.badRequest().body(new MessageResponse("Wrong data"));
|
||||
}
|
||||
option.setPrivacy(privacy);
|
||||
return ResponseEntity.ok(userDataService.saveUserData(profile));
|
||||
}
|
||||
|
||||
@GetMapping("recent")
|
||||
public ReducedPageResponse<RecentResp> getRecentPlay(@RequestParam String aimeId,
|
||||
@RequestParam(required = false, defaultValue = "0") int page,
|
||||
@RequestParam(required = false, defaultValue = "10") int size) {
|
||||
Page<UserPlaylog> playLogs = userPlaylogService.getRecentPlays(aimeId, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "userPlayDate")));
|
||||
return new ReducedPageResponse<>(mapper.convert(playLogs.getContent(), new TypeReference<>() {
|
||||
}), playLogs.getPageable().getPageNumber(), playLogs.getTotalPages(), playLogs.getTotalElements());
|
||||
}
|
||||
|
||||
@GetMapping("song/{id}")
|
||||
public List<UserMusicDetail> getSongDetail(@RequestParam String aimeId, @PathVariable int id) {
|
||||
return userMusicDetailService.getByUserIdAndMusicId(aimeId, id);
|
||||
}
|
||||
|
||||
@GetMapping("song/{id}/{level}")
|
||||
public List<UserPlaylog> getLevelPlaylog(@RequestParam String aimeId, @PathVariable int id, @PathVariable int level) {
|
||||
return userPlaylogService.getByUserIdAndMusicIdAndLevel(aimeId, id, level);
|
||||
}
|
||||
|
||||
@GetMapping("song/{id}/isfavorite")
|
||||
public boolean getSongFavorite(@RequestParam String aimeId, @PathVariable String id) {
|
||||
Optional<UserGeneralData> favOptional;
|
||||
favOptional = userGeneralDataService.getByUserIdAndKey(aimeId, "favorite_music");
|
||||
if(favOptional.isPresent()) {
|
||||
String val = favOptional.get().getPropertyValue();
|
||||
if(StringUtils.isNotBlank(val) && val.contains(",")) {
|
||||
String[] records = val.split(",");
|
||||
for (String record : records) {
|
||||
if (record.equals(id)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@PutMapping("song/{id}/favorite")
|
||||
public void updateSongFavorite(@RequestParam String aimeId, @PathVariable String id) {
|
||||
Chu3UserData profile = userDataService.getUserByExtId(aimeId).orElseThrow();
|
||||
UserGeneralData userGeneralData = userGeneralDataService.getByUserAndKey(profile, "favorite_music")
|
||||
.orElseGet(() -> new UserGeneralData(profile, "favorite_music"));
|
||||
List<String> favoriteSongs = new LinkedList<String>(Arrays.asList(userGeneralData.getPropertyValue().split(",")));
|
||||
|
||||
if(!favoriteSongs.remove(id))
|
||||
{
|
||||
favoriteSongs.add(id);
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
favoriteSongs.forEach(favSong -> {
|
||||
if(!favSong.isEmpty()) sb.append(favSong).append(",");
|
||||
});
|
||||
|
||||
userGeneralData.setPropertyValue(sb.toString());
|
||||
userGeneralDataService.save(userGeneralData);
|
||||
}
|
||||
|
||||
@GetMapping("character")
|
||||
public ReducedPageResponse<UserCharacter> getCharacter(@RequestParam String aimeId,
|
||||
@RequestParam(required = false, defaultValue = "0") int page,
|
||||
@RequestParam(required = false, defaultValue = "10") int size) {
|
||||
Page<UserCharacter> characters = userCharacterService.getByUserId(aimeId, page, size);
|
||||
return new ReducedPageResponse<>(characters.getContent(), characters.getPageable().getPageNumber(), characters.getTotalPages(), characters.getTotalElements());
|
||||
}
|
||||
|
||||
@PostMapping("character")
|
||||
public ResponseEntity<Object> updateCharacter(@RequestBody Map<String, Object> request) {
|
||||
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
Integer characterId = (Integer) request.get("characterId");
|
||||
Optional<UserCharacter> characterOptional = userCharacterService.getByUserAndCharacterId(profile, characterId);
|
||||
UserCharacter character;
|
||||
if(characterOptional.isPresent()) {
|
||||
character = characterOptional.get();
|
||||
} else {
|
||||
character = new UserCharacter(profile);
|
||||
character.setCharacterId(characterId);
|
||||
}
|
||||
if(request.containsKey("level")) {
|
||||
character.setLevel((Integer) request.get("level"));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(userCharacterService.save(character));
|
||||
}
|
||||
|
||||
@GetMapping("course")
|
||||
public List<UserCourse> getCourse(@RequestParam String aimeId) {
|
||||
return userCourseService.getByUserId(aimeId);
|
||||
}
|
||||
|
||||
@GetMapping("duel")
|
||||
public List<UserDuel> getDuel(@RequestParam String aimeId) {
|
||||
return userDuelService.getByUserId(aimeId);
|
||||
}
|
||||
|
||||
@GetMapping("item")
|
||||
public ReducedPageResponse<UserItem> getItem(@RequestParam String aimeId,
|
||||
@RequestParam(required = false, defaultValue = "0") int page,
|
||||
@RequestParam(required = false, defaultValue = "10") int size) {
|
||||
Page<UserItem> items = userItemService.getByUserId(aimeId, page, size);
|
||||
return new ReducedPageResponse<>(items.getContent(), items.getPageable().getPageNumber(), items.getTotalPages(), items.getTotalElements());
|
||||
}
|
||||
|
||||
@GetMapping("item/{itemKind}")
|
||||
public List<UserItem> getItemByKind(@RequestParam String aimeId, @PathVariable int itemKind) {
|
||||
return userItemService.getByUserAndItemKind(aimeId, itemKind);
|
||||
}
|
||||
|
||||
@PostMapping("item")
|
||||
public ResponseEntity<Object> updateItem(@RequestBody Map<String, Object> request) {
|
||||
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
|
||||
Integer itemId = (Integer) request.get("itemId");
|
||||
Integer itemKind = (Integer) request.get("itemKind");
|
||||
Optional<UserItem> itemOptional = userItemService.getByUserAndItemIdAndKind(profile, itemId,itemKind);
|
||||
UserItem item;
|
||||
if(itemOptional.isPresent()) {
|
||||
item = itemOptional.get();
|
||||
} else {
|
||||
item = new UserItem(profile);
|
||||
item.setItemId(itemId);
|
||||
item.setItemKind(itemKind);
|
||||
}
|
||||
if(request.containsKey("stock")) {
|
||||
item.setStock((Integer) request.get("stock"));
|
||||
}
|
||||
return ResponseEntity.ok(userItemService.save(item));
|
||||
}
|
||||
|
||||
@GetMapping("general")
|
||||
public ResponseEntity<Object> getGeneralData(@RequestParam String aimeId, @RequestParam String key) {
|
||||
Optional<UserGeneralData> userGeneralDataOptional = userGeneralDataService.getByUserIdAndKey(aimeId,key);
|
||||
return userGeneralDataOptional.<ResponseEntity<Object>>map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new MessageResponse("User or value not found.")));
|
||||
}
|
||||
|
||||
@GetMapping("export")
|
||||
public ResponseEntity<Object> exportAllUserData(@RequestParam String aimeId) {
|
||||
Chu3DataExport data = new Chu3DataExport();
|
||||
try {
|
||||
data.setGameId("SDHD");
|
||||
data.setUserData(userDataService.getUserByExtId(aimeId).orElseThrow());
|
||||
data.setUserActivityList(userActivityService.getByUserId(aimeId));
|
||||
data.setUserCharacterList(userCharacterService.getByUserId(aimeId));
|
||||
data.setUserChargeList(userChargeService.getByUserId(aimeId));
|
||||
data.setUserCourseList(userCourseService.getByUserId(aimeId));
|
||||
data.setUserDuelList(userDuelService.getByUserId(aimeId));
|
||||
data.setUserGameOption(userGameOptionService.getByUserId(aimeId).orElseThrow());
|
||||
data.setUserItemList(userItemService.getByUserId(aimeId));
|
||||
data.setUserMapList(userMapAreaService.getByUserId(aimeId));
|
||||
data.setUserMusicDetailList(userMusicDetailService.getByUserId(aimeId));
|
||||
data.setUserPlaylogList(userPlaylogService.getByUserId(aimeId));
|
||||
} catch (NoSuchElementException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(new MessageResponse("User not found"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new MessageResponse("Error during data export. Reason: " + e.getMessage()));
|
||||
}
|
||||
// Set filename
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("content-disposition", "attachment; filename=chusan_" + aimeId + "_exported.json");
|
||||
return new ResponseEntity<>(data, headers, HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package icu.samnyan.aqua.api.controller.sega.game.diva;
|
||||
|
||||
import icu.samnyan.aqua.sega.diva.dao.gamedata.DivaCustomizeRepository;
|
||||
import icu.samnyan.aqua.sega.diva.dao.gamedata.DivaModuleRepository;
|
||||
import icu.samnyan.aqua.sega.diva.dao.gamedata.DivaPvRepository;
|
||||
import icu.samnyan.aqua.sega.diva.model.gamedata.DivaCustomize;
|
||||
import icu.samnyan.aqua.sega.diva.model.gamedata.DivaModule;
|
||||
import icu.samnyan.aqua.sega.diva.model.gamedata.Pv;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author samnyan (privateamusement@protonmail.com)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("api/game/diva/data")
|
||||
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
|
||||
@AllArgsConstructor
|
||||
public class ApiDivaGameDataController {
|
||||
|
||||
private final DivaModuleRepository divaModuleRepository;
|
||||
private final DivaCustomizeRepository divaCustomizeRepository;
|
||||
private final DivaPvRepository divaPvRepository;
|
||||
|
||||
@GetMapping(value = "musicList")
|
||||
public List<Pv> musicList() {
|
||||
return divaPvRepository.findAll();
|
||||
}
|
||||
|
||||
@GetMapping(value = "moduleList")
|
||||
public List<DivaModule> moduleList() {
|
||||
return divaModuleRepository.findAll();
|
||||
}
|
||||
|
||||
@GetMapping(value = "customizeList")
|
||||
public List<DivaCustomize> customizeList() {
|
||||
return divaCustomizeRepository.findAll();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,276 @@
|
||||
package icu.samnyan.aqua.api.controller.sega.game.diva;
|
||||
|
||||
import icu.samnyan.aqua.api.model.MessageResponse;
|
||||
import icu.samnyan.aqua.api.model.ReducedPageResponse;
|
||||
import icu.samnyan.aqua.api.model.resp.sega.diva.PvRankRecord;
|
||||
import icu.samnyan.aqua.sega.diva.dao.userdata.*;
|
||||
import icu.samnyan.aqua.sega.diva.model.common.Difficulty;
|
||||
import icu.samnyan.aqua.sega.diva.model.common.Edition;
|
||||
import icu.samnyan.aqua.sega.diva.model.userdata.*;
|
||||
import icu.samnyan.aqua.sega.diva.service.PlayerProfileService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* @author samnyan (privateamusement@protonmail.com)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("api/game/diva")
|
||||
@ConditionalOnProperty(prefix = "aquaviewer.api", name = "enabled", havingValue = "true")
|
||||
@AllArgsConstructor
|
||||
public class ApiDivaPlayerDataController {
|
||||
|
||||
private final PlayerProfileService playerProfileService;
|
||||
|
||||
private final GameSessionRepository gameSessionRepository;
|
||||
private final PlayLogRepository playLogRepository;
|
||||
private final PlayerPvRecordRepository playerPvRecordRepository;
|
||||
private final PlayerPvCustomizeRepository playerPvCustomizeRepository;
|
||||
private final PlayerModuleRepository playerModuleRepository;
|
||||
private final PlayerCustomizeRepository playerCustomizeRepository;
|
||||
private final PlayerScreenShotRepository playerScreenShotRepository;
|
||||
|
||||
@PostMapping("forceUnlock")
|
||||
public ResponseEntity<MessageResponse> forceUnlock(@RequestParam long pdId) {
|
||||
PlayerProfile profile = playerProfileService.findByPdId(pdId).orElseThrow();
|
||||
Optional<GameSession> session = gameSessionRepository.findByPdId(profile);
|
||||
if(session.isPresent()) {
|
||||
gameSessionRepository.delete(session.get());
|
||||
return ResponseEntity.ok(new MessageResponse("Session deleted."));
|
||||
} else {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new MessageResponse("Session doesn't exist."));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("playerInfo")
|
||||
public Optional<PlayerProfile> getPlayerInfo(@RequestParam long pdId) {
|
||||
return playerProfileService.findByPdId(pdId);
|
||||
}
|
||||
|
||||
@GetMapping("playerInfo/rival")
|
||||
public Map<String, String> getRivalInfo(@RequestParam long pdId) {
|
||||
var rId = playerProfileService.findByPdId(pdId).orElseThrow().getRivalPdId();
|
||||
Map<String, String> result = new HashMap<>();
|
||||
if (rId == -1) {
|
||||
result.put("rival", "Not Set");
|
||||
} else {
|
||||
Optional<PlayerProfile> profile = playerProfileService.findByPdId(rId);
|
||||
if (profile.isPresent()) {
|
||||
result.put("rival", profile.get().getPlayerName());
|
||||
} else {
|
||||
result.put("rival", "Player Not Found");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@PutMapping("playerInfo/rival")
|
||||
public PlayerProfile updateRivalWithId(@RequestBody Map<String, Object> request) {
|
||||
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
|
||||
profile.setRivalPdId((Integer) request.get("rivalId"));
|
||||
return playerProfileService.save(profile);
|
||||
}
|
||||
|
||||
@PutMapping("playerInfo/rival/byRecord")
|
||||
public PlayerProfile updateRivalWithRecord(@RequestBody Map<String, Object> request) {
|
||||
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
|
||||
PlayerPvRecord record = playerPvRecordRepository.findById(((Integer) request.get("recordId")).longValue()).orElseThrow();
|
||||
profile.setRivalPdId(record.getPdId().getPdId());
|
||||
return playerProfileService.save(profile);
|
||||
}
|
||||
|
||||
@PutMapping("playerInfo/playerName")
|
||||
public PlayerProfile updateName(@RequestBody Map<String, Object> request) {
|
||||
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
|
||||
profile.setPlayerName((String) request.get("playerName"));
|
||||
return playerProfileService.save(profile);
|
||||
}
|
||||
|
||||
@PutMapping("playerInfo/title")
|
||||
public PlayerProfile updateTitle(@RequestBody Map<String, Object> request) {
|
||||
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
|
||||
profile.setLevelTitle((String) request.get("title"));
|
||||
return playerProfileService.save(profile);
|
||||
}
|
||||
|
||||
@PutMapping("playerInfo/plate")
|
||||
public PlayerProfile updatePlate(@RequestBody Map<String, Object> request) {
|
||||
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
|
||||
profile.setPlateId((Integer) request.get("plateId"));
|
||||
profile.setPlateEffectId((Integer) request.get("plateEffectId"));
|
||||
return playerProfileService.save(profile);
|
||||
}
|
||||
|
||||
@PutMapping("playerInfo/commonModule")
|
||||
public PlayerProfile updateModule(@RequestBody Map<String, Object> request) {
|
||||
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
|
||||
profile.setCommonModule((String) request.get("commonModule"));
|
||||
return playerProfileService.save(profile);
|
||||
}
|
||||
|
||||
@PutMapping("playerInfo/commonCustomize")
|
||||
public PlayerProfile updateCustomize(@RequestBody Map<String, Object> request) {
|
||||
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
|
||||
profile.setCommonCustomizeItems((String) request.get("commonCustomize"));
|
||||
return playerProfileService.save(profile);
|
||||
}
|
||||
|
||||
@PutMapping("playerInfo/commonSkin")
|
||||
public PlayerProfile updateSkin(@RequestBody Map<String, Object> request) {
|
||||
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
|
||||
profile.setCommonSkin((Integer) request.get("skinId"));
|
||||
return playerProfileService.save(profile);
|
||||
}
|
||||
|
||||
@PutMapping("playerInfo/myList")
|
||||
public PlayerProfile updateMyList(@RequestBody Map<String, Object> request) {
|
||||
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
|
||||
switch ((Integer) request.get("myListId")) {
|
||||
case 0:
|
||||
profile.setMyList0((String) request.get("myListData"));
|
||||
break;
|
||||
case 1:
|
||||
profile.setMyList1((String) request.get("myListData"));
|
||||
break;
|
||||
case 2:
|
||||
profile.setMyList2((String) request.get("myListData"));
|
||||
break;
|
||||
}
|
||||
return playerProfileService.save(profile);
|
||||
}
|
||||
|
||||
@PutMapping("playerInfo/se")
|
||||
public PlayerProfile updateSe(@RequestBody Map<String, Object> request) {
|
||||
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
|
||||
profile.setButtonSe((Integer) request.get("buttonSe"));
|
||||
profile.setChainSlideSe((Integer) request.get("chainSlideSe"));
|
||||
profile.setSlideSe((Integer) request.get("slideSe"));
|
||||
profile.setSliderTouchSe((Integer) request.get("sliderTouchSe"));
|
||||
return playerProfileService.save(profile);
|
||||
}
|
||||
|
||||
@PutMapping("playerInfo/display")
|
||||
public PlayerProfile updateDisplay(@RequestBody Map<String, Object> request) {
|
||||
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
|
||||
profile.setShowInterimRanking((Boolean) request.get("showInterimRanking"));
|
||||
profile.setShowClearStatus((Boolean) request.get("showClearStatus"));
|
||||
profile.setShowGreatBorder((Boolean) request.get("showGreatBorder"));
|
||||
profile.setShowExcellentBorder((Boolean) request.get("showExcellentBorder"));
|
||||
profile.setShowRivalBorder((Boolean) request.get("showRivalBorder"));
|
||||
profile.setShowRgoSetting((Boolean) request.get("showRgoSetting"));
|
||||
return playerProfileService.save(profile);
|
||||
}
|
||||
|
||||
@GetMapping("playLog")
|
||||
public ReducedPageResponse<PlayLog> getPlayLogs(@RequestParam long pdId,
|
||||
@RequestParam(required = false, defaultValue = "0") int page,
|
||||
@RequestParam(required = false, defaultValue = "10") int size) {
|
||||
Page<PlayLog> playLogs = playLogRepository.findByPdId_PdIdOrderByDateTimeDesc(pdId, PageRequest.of(page, size));
|
||||
return new ReducedPageResponse<>(playLogs.getContent(), playLogs.getPageable().getPageNumber(), playLogs.getTotalPages(), playLogs.getTotalElements());
|
||||
}
|
||||
|
||||
/**
|
||||
* PvRecord
|
||||
*/
|
||||
|
||||
@GetMapping("pvRecord")
|
||||
public ReducedPageResponse<PlayerPvRecord> getPvRecords(@RequestParam long pdId,
|
||||
@RequestParam(required = false, defaultValue = "0") int page,
|
||||
@RequestParam(required = false, defaultValue = "10") int size) {
|
||||
Page<PlayerPvRecord> pvRecords = playerPvRecordRepository.findByPdId_PdIdOrderByPvId(pdId, PageRequest.of(page, size));
|
||||
return new ReducedPageResponse<>(pvRecords.getContent(), pvRecords.getPageable().getPageNumber(), pvRecords.getTotalPages(), pvRecords.getTotalElements());
|
||||
}
|
||||
|
||||
@GetMapping("pvRecord/{pvId}")
|
||||
public Map<String, Object> getPvRecord(@RequestParam long pdId, @PathVariable int pvId) {
|
||||
Map<String, Object> resultMap = new HashMap<>();
|
||||
resultMap.put("records", playerPvRecordRepository.findByPdId_PdIdAndPvId(pdId, pvId));
|
||||
playerPvCustomizeRepository.findByPdId_PdIdAndPvId(pdId, pvId).ifPresent(x -> resultMap.put("customize", x));
|
||||
return resultMap;
|
||||
}
|
||||
|
||||
@PutMapping("pvRecord/{pvId}")
|
||||
public PlayerPvCustomize updatePvCustomize(@RequestBody Map<String, Object> request, @PathVariable int pvId) {
|
||||
PlayerProfile profile = playerProfileService.findByPdId((Integer) request.get("pdId")).orElseThrow();
|
||||
PlayerPvCustomize playerPvCustomize = playerPvCustomizeRepository.findByPdIdAndPvId(profile, pvId)
|
||||
.orElseGet(() -> new PlayerPvCustomize(profile, pvId));
|
||||
playerPvCustomize.setModule((String) request.get("module"));
|
||||
playerPvCustomize.setCustomize((String) request.get("customize"));
|
||||
playerPvCustomize.setCustomizeFlag((String) request.get("customizeFlag"));
|
||||
playerPvCustomize.setSkin((Integer) request.get("skin"));
|
||||
playerPvCustomize.setButtonSe((Integer) request.get("buttonSe"));
|
||||
playerPvCustomize.setSlideSe((Integer) request.get("slideSe"));
|
||||
playerPvCustomize.setChainSlideSe((Integer) request.get("chainSlideSe"));
|
||||
playerPvCustomize.setSliderTouchSe((Integer) request.get("sliderTouchSe"));
|
||||
return playerPvCustomizeRepository.save(playerPvCustomize);
|
||||
}
|
||||
|
||||
@GetMapping("pvRecord/{pvId}/ranking/{difficulty}")
|
||||
public ReducedPageResponse<PvRankRecord> getPvRanking(@PathVariable int pvId,
|
||||
@PathVariable String difficulty,
|
||||
@RequestParam(required = false, defaultValue = "0") int page,
|
||||
@RequestParam(required = false, defaultValue = "10") int size) {
|
||||
Difficulty diff = null;
|
||||
Edition edition = Edition.ORIGINAL;
|
||||
switch (difficulty) {
|
||||
case "EASY":
|
||||
diff = Difficulty.EASY;
|
||||
break;
|
||||
case "NORMAL":
|
||||
diff = Difficulty.NORMAL;
|
||||
break;
|
||||
case "HARD":
|
||||
diff = Difficulty.HARD;
|
||||
break;
|
||||
case "EXTREME":
|
||||
diff = Difficulty.EXTREME;
|
||||
break;
|
||||
case "EXTRA_EXTREME": {
|
||||
diff = Difficulty.EXTREME;
|
||||
edition = Edition.EXTRA;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(diff != null) {
|
||||
Page<PlayerPvRecord> pvRecords = playerPvRecordRepository.findByPvIdAndEditionAndDifficultyOrderByMaxScoreDesc(pvId, edition,diff, PageRequest.of(page, size));
|
||||
|
||||
List<PvRankRecord> rankList = new LinkedList<>();
|
||||
|
||||
pvRecords.forEach(x ->{
|
||||
rankList.add(new PvRankRecord(x.getId(),x.getPdId().getPlayerName(),x.getMaxScore(),x.getMaxAttain()));
|
||||
});
|
||||
|
||||
return new ReducedPageResponse<>(rankList, pvRecords.getPageable().getPageNumber(), pvRecords.getTotalPages(), pvRecords.getTotalElements());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@GetMapping("module")
|
||||
public ReducedPageResponse<PlayerModule> getModules(@RequestParam long pdId,
|
||||
@RequestParam(required = false, defaultValue = "0") int page,
|
||||
@RequestParam(required = false, defaultValue = "10") int size) {
|
||||
Page<PlayerModule> modules = playerModuleRepository.findByPdId_PdId(pdId, PageRequest.of(page, size));
|
||||
return new ReducedPageResponse<>(modules.getContent(), modules.getPageable().getPageNumber(), modules.getTotalPages(), modules.getTotalElements());
|
||||
}
|
||||
|
||||
@GetMapping("customize")
|
||||
public ReducedPageResponse<PlayerCustomize> getCustomizes(@RequestParam long pdId,
|
||||
@RequestParam(required = false, defaultValue = "0") int page,
|
||||
@RequestParam(required = false, defaultValue = "10") int size) {
|
||||
Page<PlayerCustomize> customizes = playerCustomizeRepository.findByPdId_PdId(pdId, PageRequest.of(page, size));
|
||||
return new ReducedPageResponse<>(customizes.getContent(), customizes.getPageable().getPageNumber(), customizes.getTotalPages(), customizes.getTotalElements());
|
||||
}
|
||||
|
||||
@GetMapping("screenshot")
|
||||
public List<PlayerScreenShot> getScreenshotList(@RequestParam long pdId) {
|
||||
return playerScreenShotRepository.findByPdId_PdId(pdId);
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user