3 Commits
tmp ... master

Author SHA1 Message Date
Azalea Gui
02dc142eea [+] Data convert script: Add ongeki 2024-02-27 18:09:06 -05:00
Azalea Gui
5973b3bfe5 [F] Fix level calculation 2024-02-26 20:06:32 -05:00
Azalea Gui
f4e3be8d15 [+] Add chusan support for data convert 2024-02-26 19:36:54 -05:00
949 changed files with 69023 additions and 16252 deletions

BIN
.github/workflows/DATA vendored Normal file

Binary file not shown.

26
.github/workflows/aquamai.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: AquaMai Build
on:
push:
branches: [ master ]
jobs:
build:
runs-on: windows-latest
defaults:
run:
working-directory: ./AquaMai
steps:
- uses: actions/checkout@v4
- name: Setup MSBuild Path
uses: microsoft/setup-msbuild@v1.0.2
- name: Decrypt DLL
run: gpg -d --batch --passphrase "${{ secrets.DLL_PASSPHRASE }}" -o .\Libs\Assembly-CSharp.dll ..\.github\workflows\DATA
- name: Build with MSBuild
run: msbuild.exe .\AquaMai.csproj

6
.gitignore vendored
View File

@@ -75,8 +75,4 @@ gradle-app.setting
### Gradle Patch ### ### Gradle Patch ###
# Java heap dump # Java heap dump
*.hprof *.hprof
.jpb
src/main/resources/meta/*/*.json
*.log.*.gz
*.salive

View File

@@ -266,10 +266,8 @@
<Compile Include="Cheat\TicketUnlock.cs" /> <Compile Include="Cheat\TicketUnlock.cs" />
<Compile Include="Config.cs" /> <Compile Include="Config.cs" />
<Compile Include="Fix\FixCharaCrash.cs" /> <Compile Include="Fix\FixCharaCrash.cs" />
<Compile Include="Performance\ImproveLoadSpeed.cs" />
<Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Main.cs" /> <Compile Include="Main.cs" />
<Compile Include="UX\CustomVersionString.cs" />
<Compile Include="UX\SinglePlayer.cs" /> <Compile Include="UX\SinglePlayer.cs" />
<Compile Include="UX\SkipWarningScreen.cs" /> <Compile Include="UX\SkipWarningScreen.cs" />
<Compile Include="UX\SkipToMusicSelection.cs" /> <Compile Include="UX\SkipToMusicSelection.cs" />

View File

@@ -13,11 +13,4 @@ SkipWarningScreen=true
# Single player: Show 1P only, at the center of the screen # Single player: Show 1P only, at the center of the screen
SinglePlayer=true SinglePlayer=true
# !!EXPERIMENTAL!! Skip from the card-scanning screen directly to music selection screen # !!EXPERIMENTAL!! Skip from the card-scanning screen directly to music selection screen
SkipToMusicSelection=false SkipToMusicSelection=false
# Set the version string displayed at the top-right corner of the screen
CustomVersionString=""
[Performance]
# Disable some useless checks and delays to speed up the game boot process
# !! Known issue: The game may crash if DX Pass scanning is enabled
ImproveLoadSpeed=false

View File

@@ -7,7 +7,6 @@ namespace AquaMai
{ {
public UXConfig UX { get; set; } public UXConfig UX { get; set; }
public CheatConfig Cheat { get; set; } public CheatConfig Cheat { get; set; }
public PerformanceConfig Performance { get; set; }
public class CheatConfig public class CheatConfig
{ {
@@ -19,12 +18,6 @@ namespace AquaMai
public bool SkipWarningScreen { get; set; } public bool SkipWarningScreen { get; set; }
public bool SinglePlayer { get; set; } public bool SinglePlayer { get; set; }
public bool SkipToMusicSelection { get; set; } public bool SkipToMusicSelection { get; set; }
public string CustomVersionString { get; set; }
}
public class PerformanceConfig
{
public bool ImproveLoadSpeed { get; set; }
} }
} }
} }

View File

@@ -1,6 +1,5 @@
using System; using System;
using AquaMai.Fix; using AquaMai.Fix;
using AquaMai.UX;
using MelonLoader; using MelonLoader;
using Tomlet; using Tomlet;
@@ -78,7 +77,6 @@ namespace AquaMai
// Fixes that does not have side effects // Fixes that does not have side effects
// These don't need to be configurable // These don't need to be configurable
Patch(typeof(FixCharaCrash)); Patch(typeof(FixCharaCrash));
Patch(typeof(CustomVersionString));
MelonLogger.Msg("Loaded!"); MelonLogger.Msg("Loaded!");
} }

View File

@@ -1,63 +0,0 @@
using System.Diagnostics;
using HarmonyLib;
using MAI2.Util;
using Manager;
using Process;
namespace AquaMai.Performance
{
public class ImproveLoadSpeed
{
[HarmonyPrefix]
[HarmonyPatch(typeof(PowerOnProcess), "OnUpdate")]
public static bool PrePowerOnUpdate(PowerOnProcess __instance)
{
var traverse = Traverse.Create(__instance);
var state = traverse.Field("_state").GetValue<byte>();
switch (state)
{
case 3:
traverse.Field("_state").SetValue((byte)4);
break;
case 5:
case 6:
case 7:
traverse.Field("_state").SetValue((byte)8);
break;
case 9:
traverse.Field("_state").SetValue((byte)10);
break;
}
return true;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(StartupProcess), "OnUpdate")]
public static bool PreStartupUpdate(StartupProcess __instance)
{
var traverse = Traverse.Create(__instance);
var state = traverse.Field("_state").GetValue<byte>();
switch (state)
{
case 0:
traverse.Field("_state").SetValue((byte)1);
break;
case 2:
// AimeReader maybe typeof AimeReaderManager or ChimeReaderManager, must build with correct Assembly-CSharp.dll in Libs folder
if(SingletonStateMachine<AmManager, AmManager.EState>.Instance.AimeReader.GetType().FullName == "Manager.AimeReaderManager")
traverse.Field("_state").SetValue((byte)3);
break;
case 4:
traverse.Field("_state").SetValue((byte)5);
break;
case 8:
var timer = traverse.Field("timer").GetValue<Stopwatch>();
Traverse.Create(timer).Field("elapsed").SetValue(2 * 10000000L);
break;
}
return true;
}
}
}

View File

@@ -1,24 +0,0 @@
using HarmonyLib;
namespace AquaMai.UX
{
public class CustomVersionString
{
/*
* Patch displayVersionString Property Getter
*/
[HarmonyPrefix]
[HarmonyPatch(typeof(MAI2System.Config), "displayVersionString", MethodType.Getter)]
public static bool GetDisplayVersionString(ref string __result)
{
if (string.IsNullOrEmpty(AquaMai.AppConfig.UX.CustomVersionString))
{
return true;
}
__result = AquaMai.AppConfig.UX.CustomVersionString;
// Return false to block the original method
return false;
}
}
}

View File

@@ -1,20 +0,0 @@
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
# Matches multiple files: .ts, .json, .svelte .sass
[*.{json,svelte,ts,sass}]
indent_style = space
indent_size = 2
# Markdown files (e.g., README.md) often use a line length of 80 characters
[*.md]
max_line_length = 80
trim_trailing_whitespace = false

43
AquaNet/docs/nginx.conf Normal file
View File

@@ -0,0 +1,43 @@
# Data server for Aqua frontend
server
{
listen 443 ssl;
listen [::]:443 ssl;
server_name aqua-data.example.com;
# / should redirect to the actual website aquadx.hydev.org
location / {
return 301 https://example.com;
}
# /maimai should be a file server on /etc/nginx/aqua-data/maimai
# These are generated using:
# cd Package/Sinmai_Data/StreamingAssets/A000
# mkdir -p /etc/nginx/aqua-data/maimai
# python3 AquaDX/tools/data_convert.py .. /etc/nginx/aqua-data/maimai/meta
# rm -rf MovieData SoundData
# (Open AssetRipper and open folder Package/Sinmai_Data)
# (Export all assets to /tmp/maimai)
# cd /tmp/maimai/ExportedProject/Assets
# find -name "*.meta" -delete -print
# find -name "*.asset" -delete -print
# cp -r assetbundle Texture2D Resources/common/sprites /etc/nginx/aqua-data/maimai
# rm -rf /tmp/maimai
location /maimai {
root /etc/nginx/aqua-data;
# Specify UTF-8 encoding
charset utf-8;
# CORS allow all
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
autoindex on;
}
ssl_certificate /dev/null;
ssl_certificate_key /dev/null;
}

View File

@@ -15,10 +15,6 @@
<meta name="msapplication-TileColor" content="#ffffff"> <meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-config" content="/assets/icons/browserconfig.xml"> <meta name="msapplication-config" content="/assets/icons/browserconfig.xml">
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
<!-- Font CSS -->
<link rel="stylesheet" href="/assets/fonts/Quicksand.400.css" />
<link rel="stylesheet" href="/assets/fonts/Quicksand.500.css" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -1,7 +1,7 @@
{ {
"name": "aqua-net", "name": "aqua-net",
"private": true, "private": true,
"version": "1.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -12,28 +12,23 @@
}, },
"devDependencies": { "devDependencies": {
"@iconify/svelte": "^3.1.6", "@iconify/svelte": "^3.1.6",
"@iconify/tools": "^4.0.2",
"@sveltejs/vite-plugin-svelte": "^3.0.1", "@sveltejs/vite-plugin-svelte": "^3.0.1",
"@tsconfig/svelte": "^5.0.2", "@tsconfig/svelte": "^5.0.2",
"chartjs-adapter-moment": "^1.0.1", "chartjs-adapter-moment": "^1.0.1",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-plugin-svelte": "^2.35.1", "eslint-plugin-svelte": "^2.35.1",
"sass": "^1.71.0", "sass": "^1.70.0",
"shiki": "^1.1.7", "svelte": "^4.2.10",
"svelte": "^4.2.11",
"svelte-check": "^3.6.4", "svelte-check": "^3.6.4",
"svelte-routing": "^2.12.0", "svelte-routing": "^2.12.0",
"svelte-turnstile": "^0.5.0",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"typescript-eslint": "^7.0.2", "typescript-eslint": "^7.0.1",
"vite": "^5.1.3" "vite": "^5.1.1"
}, },
"dependencies": { "dependencies": {
"cal-heatmap": "^4.2.4", "cal-heatmap": "^4.2.4",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"lxgw-wenkai-lite-webfont": "^1.7.0",
"modern-normalize": "^2.0.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"svelte-chartjs": "^3.1.5" "svelte-chartjs": "^3.1.5"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,27 +0,0 @@
/* vietnamese */
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(Quicksand.400.vietnamese.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(Quicksand.400.latin-ext.woff2) format('woff2');
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(Quicksand.400.latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@@ -1,27 +0,0 @@
/* vietnamese */
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(Quicksand.500.vietnamese.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(Quicksand.500.latin-ext.woff2) format('woff2');
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(Quicksand.400.latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

View File

@@ -1,62 +1,24 @@
<script lang="ts"> <script lang="ts">
import { Route, Router } from "svelte-routing"; import { Router, Route } from "svelte-routing";
import Welcome from "./pages/Welcome.svelte"; import Home from "./pages/Home.svelte";
import MaimaiRating from "./pages/MaimaiRating.svelte"; import MaimaiRating from "./pages/MaimaiRating.svelte";
import UserHome from "./pages/UserHome.svelte"; import UserHome from "./pages/UserHome.svelte";
import Home from "./pages/Home.svelte"; import Icon from '@iconify/svelte';
import Ranking from "./pages/Ranking.svelte";
import { USER } from "./libs/sdk";
import type { AquaNetUser } from "./libs/generalTypes";
import Settings from "./pages/User/Settings.svelte";
import { pfp } from "./libs/ui"
console.log(`%c
┏━┓ ┳━┓━┓┏━
┣━┫┏━┓┓ ┏┏━┓┃ ┃ ┣┫
┛ ┗┗━┫┗━┻┗━┻┻━┛━┛┗━
┗ v${APP_VERSION}`, `
background: linear-gradient(-45deg, rgba(18,194,233,1) 0%, rgba(196,113,237,1) 50%, rgba(246,79,89,1) 100%);
font-size: 2em;
font-family: Monospace;
unicode-bidi: isolate;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;`)
export let url = ""; export let url = "";
let me: AquaNetUser
if (USER.isLoggedIn()) USER.me().then(m => me = m).catch(e => console.error(e))
let path = window.location.pathname;
</script> </script>
<nav> <nav>
{#if path !== "/"} <div>home</div>
<a class="logo" href={USER.isLoggedIn() ? "/home" : "/"}> <div>maps</div>
<img src="/assets/icons/android-chrome-192x192.png" alt="AquaDX"/> <div>rankings</div>
<span>AquaNet</span> <div><Icon icon="tabler:search" /></div>
</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}">
<img alt="profile" class="pfp" use:pfp={me}/>
</a>
{/if}
</nav> </nav>
<Router {url}> <Router {url}>
<Route path="/" component={Welcome} /> <Route path="/"><Home /></Route>
<Route path="/home" component={Home} /> <Route path="/u/:userId" component={UserHome}></Route>
<Route path="/ranking" component={Ranking} /> <Route path="/u/:userId/mai/rating" component={MaimaiRating}></Route>
<Route path="/ranking/:game" component={Ranking} />
<Route path="/u/:username" component={UserHome} />
<Route path="/u/:username/:game" component={UserHome} />
<Route path="/u/:username/:game/rating" component={MaimaiRating} />
<Route path="/settings" component={Settings} />
</Router> </Router>
<style lang="sass"> <style lang="sass">
@@ -74,30 +36,21 @@
z-index: 10 z-index: 10
position: relative position: relative
img > div
width: 1.5rem cursor: pointer
height: 1.5rem transition: all 0.2s ease
border-radius: 50% text-decoration: underline 1px solid transparent
object-fit: cover text-underline-offset: 0.1em
.pfp
width: 2rem
height: 2rem
.logo
display: flex display: flex
align-items: center align-items: center
gap: 8px
font-weight: bold
color: $c-main
letter-spacing: 0.2em
flex: 1
@media (max-width: $w-mobile) &:hover
> span color: $c-main
display: none text-decoration-color: $c-main
text-underline-offset: 0.5em
@media (max-width: $w-mobile) @media (max-width: $w-mobile)
justify-content: center justify-content: center
</style> </style>

View File

@@ -1,5 +1,6 @@
@import "vars" @import "vars"
@import 'lxgw-wenkai-lite-webfont/style.css' @import url('https://fonts.googleapis.com/css2?family=Quicksand&display=swap')
@import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@500&display=swap')
html html
height: 100% height: 100%
@@ -24,7 +25,7 @@ body
a a
font-weight: 500 font-weight: 500
color: $c-main color: $c-darker
text-decoration: inherit text-decoration: inherit
a:hover a:hover
@@ -36,22 +37,7 @@ h1
.card .card
display: flex padding: 2em
flex-direction: column
border-radius: $border-radius
padding: 12px 16px
background: $ov-light
blockquote
$c1: rgba(255, 149, 149, 0.05)
$c2: rgba(255, 152, 152, 0.12)
background: repeating-linear-gradient(45deg, $c1, $c1 10px, $c2 10px, $c2 20px)
padding: 10px 20px 10px 20px
margin: 16px 0
border-left: solid #ff7c7c 3px
border-radius: $border-radius
#app #app
@@ -61,260 +47,29 @@ blockquote
main:not(.no-margin) main:not(.no-margin)
max-width: 1280px max-width: 1280px
//width: 100%
margin: 0 auto margin: 0 auto
padding-bottom: 100px padding-bottom: 100px
button button
border-radius: $border-radius border-radius: 8px
border: 1px solid transparent border: 1px solid transparent
padding: 0.6em 1.2em padding: 0.6em 1.2em
font-size: 1em font-size: 1em
font-weight: 500 font-weight: 500
font-family: inherit font-family: inherit
background-color: $ov-lighter background-color: #1a1a1a
opacity: 0.9 opacity: 0.9
cursor: pointer cursor: pointer
transition: $transition transition: all 0.25s
button:hover button:hover
border-color: $c-main border: 1px solid $c-main
button:focus, button:focus-visible button:focus, button:focus-visible
color: $c-main color: $c-main
outline: none outline: none
button.error
color: unset
&:hover
border-color: $c-error
color: $c-error
//background: $c-error
//border-color: transparent
button.icon
padding: 0.6em
font-size: 1.2em
border-radius: 50px
@extend .flex-center
.level-0
//--lv-color: #6ED43E
--lv-color: 110, 212, 62
.level-1
//--lv-color: #F7B807
--lv-color: 247, 184, 7
.level-2
//--lv-color: #FF828D
--lv-color: 255, 130, 141
.level-3
//--lv-color: #A051DC
--lv-color: 160, 81, 220
.level-4
//--lv-color: #c299e7
--lv-color: 194, 153, 231
.error
color: $c-error
input
border-radius: $border-radius
border: 1px solid transparent
padding: 0.6em 1.2em
font-size: 1em
font-weight: 500
font-family: inherit
background-color: $ov-lighter
transition: $transition
box-sizing: border-box
input[type="checkbox"]
width: 1.2em
height: 1.2em
margin: 0
padding: 0
border: 1px solid $c-main
background-color: $ov-lighter
appearance: none
cursor: pointer
flex-shrink: 0
&:checked
background-color: $c-main
border-color: $c-main
label
cursor: pointer
input:focus, input:focus-visible
border: 1px solid $c-main
outline: none
input.error
border: 1px solid $c-error
.flex-center
display: flex
justify-content: center
align-items: center
.inline-flex-center
display: inline-flex
justify-content: center
align-items: center
.clickable
cursor: pointer
user-select: none
// Content containers
.content-main
display: flex
flex-direction: column
gap: 20px
margin: 100px auto 0
padding: 32px 32px 128px
min-height: 100%
max-width: $w-max
background-color: darken($c-bg, 3%)
border-radius: 16px 16px 0 0
@media (max-width: #{$w-max + (64px) * 2})
margin: 100px 32px 0
@media (max-width: $w-mobile)
margin: 100px 0 0
.fw-block
margin-left: -32px
margin-right: -32px
padding: 12px 32px
background-color: $ov-darker
// Inner shadow
box-shadow: inset 0 10px 10px -2px $c-shadow, inset 0 -10px 10px -2px $c-shadow
> h2.outer-title, > .outer-title-options
margin-top: -5rem
margin-bottom: 1rem
@media (max-width: $w-mobile)
text-align: center
> .outer-title-options
display: flex
justify-content: space-between
align-items: center
nav
display: flex
flex-direction: row
gap: 10px
top: 4px
@media (max-width: $w-mobile)
flex-direction: column
> h2, > .outer-title-options > h2
margin: 0
main.content
@extend .content-main
// Not used. still need a lot of work
.content-popup
position: absolute
inset: 0
> div
@extend .content-main
position: absolute
inset: 0
top: 100px
background: rgba(darken($c-bg, 3%), 0.9)
backdrop-filter: blur(5px)
box-shadow: 0 0 10px 6px rgba(black, 0.4)
max-width: calc($w-max + 20px)
@media (max-width: #{$w-max + (64px) * 2})
margin: 100px 22px 0
@media (max-width: $w-mobile)
margin: 100px 0 0
// Overlay
.overlay
position: fixed
inset: 0
background-color: rgba(0, 0, 0, 0.5)
display: flex
justify-content: center
align-items: center
z-index: 1000
backdrop-filter: blur(5px)
h2, p
user-select: none
margin: 0
> div
background-color: $c-bg
padding: 2rem
border-radius: $border-radius
display: flex
flex-direction: column
gap: 1rem
max-width: 400px
.no-margin
margin: 0
nav
> div, > a
cursor: pointer
transition: $transition
text-decoration: underline 1px solid transparent
text-underline-offset: 0.1em
display: flex
align-items: center
color: unset
font-weight: unset
&:hover
color: $c-main
text-decoration-color: $c-main
text-underline-offset: 0.5em
&.active
color: $c-main
.hide-scrollbar
&::-webkit-scrollbar
display: none
-ms-overflow-style: none
scrollbar-width: none
.aqua-tooltip
box-shadow: 0 0 5px 0 $c-shadow
border-radius: $border-radius
position: absolute
padding: 4px 8px
background: $ov-lighter
backdrop-filter: blur(5px)

View File

@@ -1,76 +0,0 @@
<!-- Svelte 4.2.11 -->
<script lang="ts">
import Icon from "@iconify/svelte";
export let color: string = '179, 198, 255'
export let icon: string
// Manually positioned icons
const iconPos = [
[1, 0.5, 2],
[6, 2, 1.5],
[-0.5, 4.5, 1.3],
[5, -0.5],
[3.5, 4.5],
[9.5, 0.3, 1.2],
[12.5, 2.5, 0.8],
[10, 4.4, 0.8],
]
</script>
<div class="action-card" style="--card-color: {color}" on:click role="button" tabindex="0" on:keydown>
<slot/>
<div class="icons">
{#each iconPos as [x, y, size], i}
<Icon icon={icon} style={`top: ${y}rem; right: ${x}rem; font-size: ${size || 1}em`} />
{/each}
</div>
</div>
<style lang="sass">
@import '../vars'
.action-card
overflow: hidden
padding: 1rem
border-radius: $border-radius
box-shadow: 0 5px 5px 1px $c-shadow
transition: all 0.2s ease
cursor: pointer
position: relative
background: linear-gradient(45deg, transparent 20%, rgba(var(--card-color), 0.5) 100%)
outline: 1px solid transparent
filter: drop-shadow(0 0 12px rgba(var(--card-color), 0))
&:hover
box-shadow: 0 0 0.5rem 0.2rem $c-shadow
transform: translateY(-3px)
// Drop shadow glow
filter: drop-shadow(0 0 12px rgba(var(--card-color), 0.5))
outline-color: rgba(var(--card-color), 0.5)
span
font-size: 1.2rem
display: block
margin-bottom: 0.5rem
.icons
position: absolute
inset: 0
color: rgba(var(--card-color), 0.5)
font-size: 2rem
transition: all 0.2s ease
z-index: -1
mask-image: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.5) 70%, white 100%)
opacity: 0.8
@media (max-width: $w-mobile)
opacity: 0.6
:global(> svg)
position: absolute
rotate: 20deg
</style>

View File

@@ -1,153 +0,0 @@
<!-- Svelte 4.2.11 -->
<script lang="ts">
import { slide } from "svelte/transition";
import { DATA_HOST } from "../libs/config";
import { t } from "../libs/i18n";
import { type GameName, getMult } from "../libs/scoring";
export let g: string
export let meta: MusicMeta
export let game: GameName
import { coverNotFound } from "../libs/ui";
import { DATA } from "../libs/sdk";
import type { MusicMeta } from "../libs/generalTypes";
import { parse } from "svelte/compiler";
let mapData = g.split(":").map(Number)
let mult = getMult(mapData[3], game)
let mapRank = parseFloat(meta.notes?.[mapData[1] === 10 ? 0 : mapData[1]]?.lv?.toFixed(1) ?? mapData[1] ?? '0')
</script>
<div class="map-detail-container" transition:slide>
<div class="scores">
<div>
<img src={`${DATA_HOST}/d/mai2/music/00${mapData[0].toString().padStart(6, '0').substring(2)}.png`} alt="" on:error={coverNotFound} />
<div class="info">
<div class="first-line">
<div class="song-title">{meta.name ?? t("UserHome.UnknownSong")}</div>
<span class={`lv level-${mapData[1] === 10 ? 3 : mapData[1]}`}>
{ mapRank }
</span>
</div>
<div class="second-line">
<span class={`rank-${getMult(mapData[3], game)[2].toString()[0]}`}>
<span class="rank-text">{("" + getMult(mapData[3], game)[2]).replace("p", "+")}</span>
<span class="rank-num">{(mapData[3] / 10000).toFixed(2)}%</span>
</span>
{#if game === 'mai2'}
<span class:increased={true} class="dx-change">
{ (mapData[3] / 1000000 * mapRank * Number(mult[1])).toFixed(0) }
</span>
{/if}
</div>
</div>
</div>
</div>
</div>
<style lang="sass">
@import "../vars"
$gap: 20px
.map-detail-container
background-color: rgb(35,35,35)
border-radius: $border-radius
overflow: hidden
.scores
display: flex
flex-direction: column
flex-wrap: wrap
gap: $gap
// Image and song info
> div
display: flex
align-items: center
gap: 12px
max-width: 100%
box-sizing: border-box
img
width: 50px
height: 50px
border-radius: $border-radius
object-fit: cover
// Song info and score
> div.info
flex: 1
display: flex
justify-content: space-between
overflow: hidden
flex-direction: column
.first-line
display: flex
flex-direction: row
// Limit song name to one line
.song-title
flex: 1
min-width: 0
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
// Make song score and rank not wrap
> div:last-child
white-space: nowrap
@media (max-width: $w-mobile)
flex-direction: column
gap: 0
.rank-text
text-align: left
.rank-S
// Gold green gradient on text
background: $grad-special
-webkit-background-clip: text
color: transparent
.rank-A
color: #ff8a8a
.rank-B
color: #6ba6ff
.lv
width: 30px
text-align: center
background: rgba(var(--lv-color), 0.6)
padding: 0 6px
border-radius: 0 $border-radius 0 $border-radius
// Inset shadow, like it's a paper below this card with a cut
box-shadow: inset 0 0 10px rgba(0,0,0,0.5)
span
display: inline-block
text-align: left
.second-line
display: flex
justify-content: space-between
align-items: center
// Vertical table-like alignment
span.rank-text
min-width: 40px
span.rank-num
min-width: 60px
span.dx-change
min-width: 50px
span.increased
&:before
content: "+"
color: $c-good

View File

@@ -1,64 +0,0 @@
<!-- Svelte 4.2.11 -->
<script lang="ts">
import { slide } from "svelte/transition";
import { t } from "../libs/i18n";
import type { GenericGameSummary } from "../libs/generalTypes";
export let g: GenericGameSummary
const detail = Object.entries(g.detailedRanks).toSorted((a, b) => +b[0] - +a[0])
</script>
<div class="rank-detail-container fw-block" transition:slide>
<div>
<h2>{t("UserHome.RankDetail.Title")}</h2>
<table>
<!-- rankDetails: { Level : { Rank : Count } } -->
<!-- Rows are levels, columns are ranks -->
<!-- Headers -->
<tr>
<th>{t("UserHome.RankDetail.Level")}</th>
{#each Object.values(g.ranks) as rankMap}<th>{rankMap.name}</th>{/each}
</tr>
<!-- Data -->
{#each detail as [level, rankMap]}
<tr>
<td>{level}</td>
{#each Object.values(rankMap) as count}<td>{count}</td>{/each}
</tr>
{/each}
</table>
</div>
</div>
<style lang="sass">
@import "../vars"
.rank-detail-container
> div
margin: 1em auto
max-width: 500px
table
width: 100%
border-collapse: collapse
table-layout: fixed
th:not(:first-child)
background: $grad-special
-webkit-background-clip: text
-webkit-text-fill-color: transparent
background-clip: text
color: $c-main
padding: 0.5em
th, td
padding: 0.5em
text-align: center
&:first-child
color: $c-main
</style>

View File

@@ -1,87 +0,0 @@
<!-- 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>

View File

@@ -1,15 +1,5 @@
export const AQUA_HOST = 'https://aquadx.net/aqua' const aqua_host = 'https://aquanet.example.com/aqua'
export const DATA_HOST = 'https://aquadx.net' const data_host = 'https://aquanet.example.com'
// This will be displayed for users to connect from the client export { aqua_host, data_host }
export const AQUA_CONNECTION = 'aquadx.hydev.org'
export const TURNSTILE_SITE_KEY = '0x4AAAAAAASGA2KQEIelo9P9'
export const DISCORD_INVITE = 'https://discord.gg/FNgveqFF7s'
// UI
export const FADE_OUT = { duration: 200 }
export const FADE_IN = { delay: 400 }
export const DEFAULT_PFP = "/assets/imgs/no_profile.png"

View File

@@ -2,121 +2,4 @@ export interface TrendEntry {
date: string date: string
rating: number rating: number
plays: number plays: number
} }
export interface Card {
luid: string
registerTime: string
accessTime: string
linked: boolean
ghost: boolean
}
export interface AquaNetUser {
username: string
email: string
displayName: string
country: string
lastLogin: number
regTime: number
profileLocation: string
profileBio: string
profilePicture: string
emailConfirmed: boolean
ghostCard: Card
cards: Card[]
computedName: string,
}
export interface CardSummaryGame {
name: string
rating: number
lastLogin: string
}
export interface CardSummary {
mai2: CardSummaryGame | null
chu3: CardSummaryGame | null
ongeki: CardSummaryGame | null
diva: CardSummaryGame | null
}
export interface ConfirmProps {
title: string
message: string
confirm: () => void
cancel?: () => void
dangerous?: boolean
}
export interface GenericGamePlaylog {
musicId: number
level: number
playDate: string
achievement: number
maxCombo: number
totalCombo: number
afterRating: number
beforeRating: number
}
export interface GenericRanking {
name: string
username: string
rank: number
accuracy: number
rating: number
fullCombo: number
allPerfect: number
lastSeen: string
}
export interface RankCount {
name: string
count: number
}
export interface GenericGameSummary {
name: string
iconId: number
aquaUser?: AquaNetUser
serverRank: number
accuracy: number
rating: number
ratingHighest: number
ranks: RankCount[]
detailedRanks: { [key: number]: { [key: string]: number } }
maxCombo: number
fullCombo: number
allPerfect: number
totalScore: number
plays: number
totalPlayTime: number
joined: string
lastSeen: string
lastVersion: string
ratingComposition: { [key: string]: any }
recent: GenericGamePlaylog[]
}
export interface MusicMeta {
name: string,
composer: string,
bpm: number,
ver: number,
notes: {
lv: number
designer: string
lv_id: number
notes: number
}[]
}
export type AllMusic = { [key: string]: MusicMeta }
export interface GameOption {
key: string
value: any
type: "Boolean"
}

View File

@@ -1,52 +0,0 @@
import { EN_REF, type LocalizedMessages } from "./i18n/en_ref";
import { ZH } from "./i18n/zh";
import type { GameName } from "./scoring";
type Lang = 'en' | 'zh'
const msgs: Record<Lang, LocalizedMessages> = {
en: EN_REF,
zh: ZH
}
let lang: Lang = 'en'
// Infer language from browser
if (navigator.language.startsWith('zh')) {
lang = 'zh'
}
export function ts(key: string, variables?: { [index: string]: any }) {
return t(key as keyof LocalizedMessages, variables)
}
/**
* Load the translation for the given key
*
* TODO: Check for translation completion on build
*
* @param key
* @param variables
*/
export function t(key: keyof LocalizedMessages, variables?: { [index: string]: any }) {
// Check if the key exists
let msg = msgs[lang][key]
if (!msg) {
// Check if the key exists in English
if (!(msg = msgs.en[key])) {
msg = key
console.error(`ERROR!! Missing translation reference entry (English) for ${key}`)
}
else console.warn(`Missing translation for ${key} in ${lang}`)
}
// Replace variables
if (variables) {
return msg.replace(/\${(.*?)}/g, (_: string, v: string | number) => variables[v] + "")
}
return msg
}
Object.assign(window, { t })
export const GAME_TITLE: { [key in GameName]: string } =
{chu3: t("game.chu3"), mai2: t("game.mai2"), ongeki: t("game.ongeki"), wacca: t("game.wacca")}

View File

@@ -1,109 +0,0 @@
export const EN_REF_USER = {
'UserHome.ServerRank': 'Server Rank',
'UserHome.DXRating': 'DX Rating',
'UserHome.Rating': 'Rating',
'UserHome.Statistics': 'Statistics',
'UserHome.Accuracy': 'Accuracy',
'UserHome.MaxCombo': 'Max Combo',
'UserHome.FullCombo': 'Full Combo',
'UserHome.AllPerfect': 'All Perfect',
'UserHome.DXScore': 'DX Score',
'UserHome.Score': 'Score',
'UserHome.PlayActivity': ' Play Activity',
'UserHome.Plays': 'Plays',
'UserHome.PlayTime': 'Play Time',
'UserHome.FirstSeen': 'First Seen',
'UserHome.LastSeen': 'Last Seen',
'UserHome.Version': 'Last Version',
'UserHome.RecentScores': 'Recent Scores',
'UserHome.NoData': 'No data in the past ${days} days',
'UserHome.UnknownSong': "(unknown song)",
'UserHome.Settings': 'Settings',
'UserHome.NoValidGame': "The user hasn't played any game yet.",
'UserHome.ShowRanksDetails': "Click to show details",
'UserHome.RankDetail.Title': 'Achievement Details',
'UserHome.RankDetail.Level': "Level",
'UserHome.B50': "B50",
}
export const EN_REF_Welcome = {
'back': 'Back',
'email': 'Email',
'password': 'Password',
'username': 'Username',
'welcome.btn-login': 'Log in',
'welcome.btn-signup': 'Sign up',
'welcome.email-password-missing': 'Email and password are required',
'welcome.username-missing': 'Username/email is required',
'welcome.waiting-turnstile': 'Waiting for Turnstile to verify your network environment...',
'welcome.turnstile-error': 'Error verifying your network environment. Please turn off your VPN and try again.',
'welcome.turnstile-timeout': 'Network verification timed out. Please try again.',
'welcome.verification-sent': 'A verification email has been sent to ${email}. Please check your inbox!',
'welcome.verify-state-0': 'You haven\'t verified your email. A verification email had been sent to your inbox less than a minute ago. Please check your inbox!',
'welcome.verify-state-1': 'You haven\'t verified your email. We\'ve already sent 3 emails over the last 24 hours so we\'ll not send another one. Please check your inbox!',
'welcome.verify-state-2': 'You haven\'t verified your email. We just sent you another verification email. Please check your inbox!',
'welcome.verifying': 'Verifying your email... please wait.',
'welcome.verified': 'Your email has been verified! You can now log in now.',
'welcome.verification-failed': 'Verification failed: ${message}. Please try again.',
}
export const EN_REF_LEADERBOARD = {
'Leaderboard.Title': 'Server Leaderboard',
'Leaderboard.Rank': 'Rank',
'Leaderboard.Rating': 'Rating',
'Leaderboard.Accuracy': 'Accuracy',
'Leaderboard.FC': 'FC',
'Leaderboard.AP': 'AP',
}
export const EN_REF_GENERAL = {
'game.mai2': "Mai",
'game.chu3': "Chuni",
'game.ongeki': "Ongeki",
'game.wacca': "Wacca",
'status.error': "Error",
'status.error.hint': 'Something went wrong, please try again later or ',
'status.error.hint.link': 'join our discord for support.',
'status.detail': 'Detail: ${detail}',
'action.refresh': 'Refresh',
'action.cancel': 'Cancel',
'action.confirm': 'Confirm',
}
export const EN_REF_HOME = {
'home.nav.portal': 'Portal',
'home.nav.link-card': 'Link Card',
'home.nav.game-setup': 'Game Setup',
'home.manage-cards': 'Manage Cards',
'home.manage-cards-description': 'Link, unlink, and manage your cards.',
'home.link-card': 'Link Card',
'home.link-cards-description': 'Link your Amusement IC / Aime card to play games.',
'home.join-discord': 'Join Discord',
'home.join-discord-description': 'Join our Discord server to chat with other players and get help.',
'home.setup': 'Setup Connection',
'home.setup-description': 'If you own a cab or arcade setup, begin setting up the connection.',
}
export const EN_REF_SETTINGS = {
'settings.title': 'Settings',
'settings.tabs.profile': 'Profile',
'settings.tabs.game': 'Game',
'settings.fields.unlockMusic.name': 'Unlock All Music',
'settings.fields.unlockMusic.desc': 'Unlock all music and master difficulty in game.',
'settings.fields.unlockChara.name': 'Unlock All Characters',
'settings.fields.unlockChara.desc': 'Unlock all characters, voices, and partners in game.',
'settings.fields.unlockCollectables.name': 'Unlock All Collectables',
'settings.fields.unlockCollectables.desc': 'Unlock all collectables (nameplate, title, icon, frame) in game. ' +
'This setting is not relevant in chusan because in-game user box is disabled.',
'settings.fields.unlockTickets.name': 'Unlock All Tickets',
'settings.fields.unlockTickets.desc': 'Infinite map/ex tickets (note: maimai still limits which tickets can be used).',
'settings.fields.waccaInfiniteWp.name': 'Wacca: Infinite WP',
'settings.fields.waccaInfiniteWp.desc': 'Set WP to 999999',
'settings.fields.waccaAlwaysVip.name': 'Wacca: Always VIP',
'settings.fields.waccaAlwaysVip.desc': 'Set VIP expiration date to 2077-01-01',
}
export const EN_REF = { ...EN_REF_USER, ...EN_REF_Welcome, ...EN_REF_GENERAL,
...EN_REF_LEADERBOARD, ...EN_REF_HOME, ...EN_REF_SETTINGS }
export type LocalizedMessages = typeof EN_REF

View File

@@ -1,115 +0,0 @@
import {
EN_REF_GENERAL,
EN_REF_HOME,
EN_REF_LEADERBOARD,
EN_REF_SETTINGS,
EN_REF_USER,
type EN_REF_Welcome
} from "./en_ref";
const zhUser: typeof EN_REF_USER = {
'UserHome.ServerRank': '服务器排名',
'UserHome.DXRating': 'DX B50',
'UserHome.Rating': '评分',
'UserHome.Statistics': '统计数据',
'UserHome.Accuracy': '准确率',
'UserHome.MaxCombo': '最大连击',
'UserHome.FullCombo': '全连曲目',
'UserHome.AllPerfect': '完美曲目',
'UserHome.DXScore': 'DX 总分',
'UserHome.Score': '总分',
'UserHome.PlayActivity': '游戏活动',
'UserHome.Plays': '出勤次数',
'UserHome.PlayTime': '游玩时间',
'UserHome.FirstSeen': '发现新大陆',
'UserHome.LastSeen': '上次出勤',
'UserHome.Version': '游戏版本',
'UserHome.RecentScores': '成绩',
'UserHome.NoData': '过去 ${days} 天内没有玩过',
'UserHome.UnknownSong': "(未知曲目)",
'UserHome.Settings': '设置',
'UserHome.NoValidGame': "用户还没有玩过游戏",
'UserHome.ShowRanksDetails': "点击显示评分详细",
'UserHome.RankDetail.Title': '评分详细',
'UserHome.RankDetail.Level': "等级",
'UserHome.B50': "B50",
}
const zhWelcome: typeof EN_REF_Welcome = {
'back': '返回',
'email': '邮箱',
'password': '密码',
'username': '用户名',
'welcome.btn-login': '登录',
'welcome.btn-signup': '注册',
'welcome.email-password-missing': '邮箱和密码必须填哦',
'welcome.username-missing': '用户名/邮箱必须填哦',
'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-2': '您还没有验证邮箱哦!我们刚刚又发送了一封验证邮件,请翻翻收件箱',
'welcome.verifying': '正在验证邮箱...请稍等',
'welcome.verified': '您的邮箱已经验证成功!您现在可以登录了',
'welcome.verification-failed': '验证失败:${message}。请重试',
}
const zhLeaderboard: typeof EN_REF_LEADERBOARD = {
'Leaderboard.Title': '排行榜',
'Leaderboard.Rank': '排名',
'Leaderboard.Rating': '评分',
'Leaderboard.Accuracy': '准确率',
'Leaderboard.FC': 'FC',
'Leaderboard.AP': 'AP',
}
const zhGeneral: typeof EN_REF_GENERAL = {
'game.mai2': "舞萌",
'game.chu3': "中二",
'game.ongeki': "音击",
'game.wacca': "Wacca",
"status.error": "发生错误",
"status.error.hint": "出了一些问题,请稍后刷新重试或者",
"status.error.hint.link": "加我们的 Discord 群问一问",
"status.detail": "详细信息:${detail}",
"action.refresh": "刷新",
"action.cancel": "取消",
"action.confirm": "确认",
}
const zhHome: typeof EN_REF_HOME = {
'home.nav.portal': "主页",
'home.nav.link-card': "绑卡",
'home.nav.game-setup': "连接设置",
'home.manage-cards': '管理游戏卡',
'home.manage-cards-description': '绑定、解绑、管理游戏数据卡',
'home.link-card': '绑定游戏卡',
'home.link-cards-description':'绑定游戏数据卡 (Amusement IC 或 Aime 卡) 后才可以访问游戏存档哦',
'home.join-discord': '加入 Discord',
'home.join-discord-description': '加入我们的 Discord 群,与其他玩家聊天、获取帮助',
'home.setup': '连接私服',
'home.setup-description': '如果您有街机框体或者手台,点击这里设置服务器的连接',
}
const zhSettings: typeof EN_REF_SETTINGS = {
'settings.title': '用户设置',
'settings.tabs.profile': '个人资料',
'settings.tabs.game': '游戏设置',
'settings.fields.unlockMusic.name': '解锁谱面',
'settings.fields.unlockMusic.desc': '在游戏中解锁所有曲目和大师难度谱面。',
'settings.fields.unlockChara.name': '解锁角色',
'settings.fields.unlockChara.desc': '在游戏中解锁所有角色、语音和伙伴。',
'settings.fields.unlockCollectables.name': '解锁收藏品',
'settings.fields.unlockCollectables.desc': '在游戏中解锁所有收藏品(名牌、称号、图标、背景图),此设置对中二不适用。',
'settings.fields.unlockTickets.name': '解锁游戏券',
'settings.fields.unlockTickets.desc': '无限跑图券/解锁券maimai 客户端仍限制一些券不能使用)。',
'settings.fields.waccaInfiniteWp.name': 'Wacca: 无限 WP',
'settings.fields.waccaInfiniteWp.desc': '将 WP 设置为 999999',
'settings.fields.waccaAlwaysVip.name': 'Wacca: 永久会员',
'settings.fields.waccaAlwaysVip.desc': '将 VIP 到期时间设置为 2077-01-01',
}
export const ZH = { ...zhUser, ...zhWelcome, ...zhGeneral,
...zhLeaderboard, ...zhHome, ...zhSettings }

View File

@@ -1,13 +1,51 @@
import { AQUA_HOST, DATA_HOST } from './config' import { aqua_host, data_host } from './config'
import type { TrendEntry } from './generalTypes'
import type { MaimaiUserSummaryEntry } from './maimaiTypes'
const multTable = [
[ 100.5, 22.4, 'SSSp' ],
[ 100, 21.6, 'SSS' ],
[ 99.5, 21.1, 'SSp' ],
[ 99, 20.8, 'SS' ],
[ 98, 20.3, 'Sp' ],
[ 97, 20, 'S' ],
[ 94, 16.8, 'AAA' ],
[ 90, 15.2, 'AA' ],
[ 80, 13.6, 'A' ]
]
export function getMult(achievement: number) {
achievement /= 10000
for (let i = 0; i < multTable.length; i++) {
if (achievement >= (multTable[i][0] as number)) return multTable[i]
}
return [ 0, 0, 0 ]
}
export async function getMaimai(endpoint: string, params: any) { export async function getMaimai(endpoint: string, params: any) {
return await fetch(`${AQUA_HOST}/Maimai2Servlet/${endpoint}`, { return await fetch(`${aqua_host}/Maimai2Servlet/${endpoint}`, {
method: 'POST', method: 'POST',
body: JSON.stringify(params) body: JSON.stringify(params)
}).then(res => res.json()) }).then(res => res.json())
} }
export async function getMaimaiAllMusic(): Promise<{ [key: string]: any }> { export async function getMaimaiAllMusic(): Promise<{ [key: string]: any }> {
return fetch(`${DATA_HOST}/maimai/meta/00/all-music.json`).then(it => it.json()) return fetch(`${data_host}/maimai/meta/00/all-music.json`).then(it => it.json())
} }
export async function getMaimaiApi(endpoint: string, params: any) {
const url = new URL(`${aqua_host}/api/game/maimai2new/${endpoint}`)
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]))
return await fetch(url).then(res => res.json())
}
export async function getMaimaiTrend(userId: number): Promise<TrendEntry[]> {
return await getMaimaiApi('trend', { userId })
}
export async function getMaimaiUser(userId: number): Promise<MaimaiUserSummaryEntry> {
return await getMaimaiApi('user-summary', { userId })
}

View File

@@ -1,5 +1,3 @@
import type { MusicMeta } from "./generalTypes";
export interface Rating { export interface Rating {
musicId: number musicId: number
level: number level: number
@@ -7,11 +5,24 @@ export interface Rating {
} }
export interface ParsedRating extends Rating { export interface ParsedRating extends Rating {
music: MusicMeta, music: MaimaiMusic,
calc: number, calc: number,
rank: string rank: string
} }
export interface MaimaiMusic {
name: string,
composer: string,
bpm: number,
ver: number,
note: {
lv: number
designer: string
lv_id: number
notes: number
}
}
export interface MaimaiUserSummaryEntry { export interface MaimaiUserSummaryEntry {
name: string name: string
iconId: number iconId: number

View File

@@ -1,68 +0,0 @@
export type GameName = 'mai2' | 'chu3' | 'ongeki' | 'wacca'
const multTable = {
'mai2': [
[ 100.5, 22.4, 'SSSp' ],
[ 100.0, 21.6, 'SSS' ],
[ 99.5, 21.1, 'SSp' ],
[ 99, 20.8, 'SS' ],
[ 98, 20.3, 'Sp' ],
[ 97, 20, 'S' ],
[ 94, 16.8, 'AAA' ],
[ 90, 15.2, 'AA' ],
[ 80, 13.6, 'A' ]
],
// TODO: Fill in multipliers for Chunithm and Ongeki
'chu3': [
[ 100.75, 0, 'SSS' ],
[ 100.0, 0, 'SS' ],
[ 97.5, 0, 'S' ],
[ 95.0, 0, 'AAA' ],
[ 92.5, 0, 'AA' ],
[ 90.0, 0, 'A' ],
[ 80.0, 0, 'BBB' ],
[ 70.0, 0, 'BB' ],
[ 60.0, 0, 'B' ],
[ 50.0, 0, 'C' ],
[ 0.0, 0, 'D' ]
],
'ongeki': [
[ 100.75, 0, 'SSS+' ],
[ 100.0, 0, 'SSS' ],
[ 99.0, 0, 'SS' ],
[ 97.0, 0, 'S' ],
[ 94.0, 0, 'AAA' ],
[ 90.0, 0, 'AA' ],
[ 85.0, 0, 'A' ],
[ 80.0, 0, 'BBB' ],
[ 75.0, 0, 'BB' ],
[ 70.0, 0, 'B' ],
[ 50.0, 0, 'C' ],
[ 0.0, 0, 'D' ]
],
'wacca': [
[ 100.0, 0, 'AP' ],
[ 98.0, 0, 'SSS' ],
[ 95.0, 0, 'SS' ],
[ 90.0, 0, 'S' ],
[ 85.0, 0, 'AAA' ],
[ 80.0, 0, 'AA' ],
[ 70.0, 0, 'A' ],
[ 60.0, 0, 'B' ],
[ 1.0, 0, 'C' ],
[ 0.0, 0, 'D' ]
]
}
export function getMult(achievement: number, game: GameName) {
achievement /= 10000
const mt = multTable[game]
for (let i = 0; i < mt.length; i++) {
if (achievement >= (mt[i][0] as number)) return mt[i]
}
return [ 0, 0, 0 ]
}

View File

@@ -1,162 +0,0 @@
import { AQUA_HOST, DATA_HOST } from "./config";
import type {
AllMusic,
Card,
CardSummary,
GenericGameSummary,
GenericRanking,
TrendEntry,
AquaNetUser, GameOption
} from "./generalTypes";
import type { GameName } from "./scoring";
interface RequestInitWithParams extends RequestInit {
params?: { [index: string]: string }
localCache?: boolean
}
/**
* Modify a fetch url
*
* @param input Fetch url input
* @param callback Callback for modification
*/
export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) => URL | void): RequestInfo | URL {
let u = new URL((input instanceof Request) ? input.url : input);
const result = callback(u)
if (result) u = result
if (input instanceof Request) {
// @ts-ignore
return { url: u, ...input }
}
return u
}
/**
* Fetch with url parameters
*/
export function fetchWithParams(input: URL | RequestInfo, init?: RequestInitWithParams): Promise<Response> {
return fetch(reconstructUrl(input, u => {
u.search = new URLSearchParams(init?.params ?? {}).toString()
}), init)
}
let cache: { [index: string]: any } = {}
export async function post(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
}
let 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}`)
// 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
}
/**
* aqua.net.UserRegistrar
*
* @param user
*/
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)
// Put token into local storage
localStorage.setItem('token', data.token)
}
const isLoggedIn = () => !!localStorage.getItem('token')
const ensureLoggedIn = () => !isLoggedIn() && (window.location.href = '/')
export const USER = {
register,
login,
confirmEmail: (token: string) =>
post('/api/v2/user/confirm-email', { token }),
me: (): Promise<AquaNetUser> => {
ensureLoggedIn()
return post('/api/v2/user/me', {})
},
keychip: (): Promise<string> =>
post('/api/v2/user/keychip', {}).then(it => it.keychip),
setting: (key: string, value: string) =>
post('/api/v2/user/setting', { key: key === 'password' ? 'pwHash' : key, value }),
uploadPfp: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return post('/api/v2/user/upload-pfp', { }, { method: 'POST', body: formData })
},
isLoggedIn,
ensureLoggedIn,
}
export const CARD = {
summary: (cardId: string): Promise<{card: Card, summary: CardSummary}> =>
post('/api/v2/card/summary', { cardId }),
link: (props: { cardId: string, migrate: string }) =>
post('/api/v2/card/link', props),
unlink: (cardId: string) =>
post('/api/v2/card/unlink', { cardId }),
userGames: (username: string): Promise<CardSummary> =>
post('/api/v2/card/user-games', { username }),
}
export const GAME = {
trend: (username: string, game: GameName): Promise<TrendEntry[]> =>
post(`/api/v2/game/${game}/trend`, { username }),
userSummary: (username: string, game: GameName): Promise<GenericGameSummary> =>
post(`/api/v2/game/${game}/user-summary`, { username }),
ranking: (game: GameName): Promise<GenericRanking[]> =>
post(`/api/v2/game/${game}/ranking`, { }),
}
export const DATA = {
allMusic: (game: GameName): Promise<AllMusic> =>
fetch(`${DATA_HOST}/d/${game}/00/all-music.json`).then(it => it.json())
}
export const SETTING = {
get: (): Promise<GameOption[]> =>
post('/api/v2/settings/get', {}),
set: (key: string, value: any) =>
post('/api/v2/settings/set', { key, value: `${value}` }),
}

View File

@@ -1,22 +1,18 @@
import { import {
CategoryScale,
Chart as ChartJS, Chart as ChartJS,
type ChartOptions,
Legend,
LinearScale,
LineElement,
PointElement,
TimeScale,
Title, Title,
Tooltip, Tooltip,
Legend,
LineElement,
LinearScale,
PointElement,
CategoryScale, TimeScale, type ChartOptions, type LineOptions,
} from 'chart.js' } from 'chart.js'
import moment from 'moment/moment' import moment from 'moment/moment'
// @ts-expect-error Cal-heatmap does not have proper types // @ts-expect-error Cal-heatmap does not have proper types
import CalHeatmap from 'cal-heatmap' import CalHeatmap from 'cal-heatmap'
// @ts-expect-error Cal-heatmap does not have proper types // @ts-expect-error Cal-heatmap does not have proper types
import CalTooltip from 'cal-heatmap/plugins/Tooltip' import CalTooltip from 'cal-heatmap/plugins/Tooltip'
import { AQUA_HOST, DEFAULT_PFP } from "./config";
import type { AquaNetUser } from "./generalTypes";
export function title(t: string) { export function title(t: string) {
document.title = `AquaNet - ${t}` document.title = `AquaNet - ${t}`
@@ -35,7 +31,7 @@ export function registerChart() {
) )
} }
export function renderCal(el: HTMLElement, d: { date: any, value: any }[]): Promise<any> { export function renderCal(el: HTMLElement, d: {date: any, value: any}[]) {
const cal = new CalHeatmap() const cal = new CalHeatmap()
return cal.paint({ return cal.paint({
itemSelector: el, itemSelector: el,
@@ -59,15 +55,12 @@ export function renderCal(el: HTMLElement, d: { date: any, value: any }[]): Prom
date: { start: moment().subtract(1, 'year').add(1, 'month').toDate() }, date: { start: moment().subtract(1, 'year').add(1, 'month').toDate() },
theme: 'dark', theme: 'dark',
}, [ }, [
[ CalTooltip, { [ CalTooltip, { text: (_: Date, v: number, d: any) =>
text: (_: Date, v: number, d: any) => `${v ?? 'No'} songs played on ${d.format('MMMM D, YYYY')}` }]
`${v ?? 'No'} songs played on ${d.format('MMMM D, YYYY')}`
} ]
]) ])
} }
const now = moment()
export const CHARTJS_OPT: ChartOptions<'line'> = { export const CHARTJS_OPT: ChartOptions<'line'> = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -92,73 +85,16 @@ export const CHARTJS_OPT: ChartOptions<'line'> = {
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false, intersect: false
callbacks: {
title: (tooltipItems) => {
const date = tooltipItems[0].parsed.x;
const diff = now.diff(date, 'days')
return diff ? `${diff} days ago` : 'Today'
}
}
} }
}, },
} }
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"
/** /**
* use:tooltip * Usage: clazz({a: false, b: true}) -> "b"
*
* @param obj HashMap<string, boolean>
*/ */
export function tooltip(element: HTMLElement, params: { text: string } | string) { export function clazz(obj: { [key: string]: boolean }) {
// Create div if not exists return Object.keys(obj).filter(k => obj[k]).join(' ')
if (!document.querySelector('.aqua-tooltip')) {
const div = document.createElement('div')
div.classList.add('aqua-tooltip')
// Initially hidden
div.style.display = 'none'
document.body.appendChild(div)
}
let isFocus = false
let div: HTMLDivElement = document.querySelector('.aqua-tooltip')!
const p = typeof params === 'string' ? { text: params } : params
function updatePosition(event: MouseEvent) {
div.style.top = `${event.pageY + 10}px`;
div.style.left = `${event.pageX - div.clientWidth / 2 + 5}px`;
}
function mouseOver(event: MouseEvent) {
if (isFocus) return
div.textContent = p.text;
div.style.display = ''
updatePosition(event);
isFocus = true;
}
function mouseLeave() {
isFocus = false
div.style.display = 'none'
}
element.addEventListener('mouseover', mouseOver);
element.addEventListener('mouseleave', mouseLeave);
element.addEventListener('mousemove', updatePosition);
return {
destroy() {
element.removeEventListener('mouseover', mouseOver);
element.removeEventListener('mouseleave', mouseLeave);
element.removeEventListener('mousemove', updatePosition);
}
}
}
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)
} }

View File

@@ -1,90 +1,83 @@
<script lang="ts"> <main id="home" class="no-margin">
import { fade } from "svelte/transition"; <h1>AquaNet</h1>
import LinkCard from "./Home/LinkCard.svelte"; <div class="btn-group">
import SetupInstructions from "./Home/SetupInstructions.svelte"; <button>Login</button>
import { DISCORD_INVITE, FADE_IN, FADE_OUT } from "../libs/config"; <button>Sign Up</button>
import { USER } from "../libs/sdk.js"; </div>
import type { AquaNetUser } from "../libs/generalTypes";
import StatusOverlays from "../components/StatusOverlays.svelte";
import ActionCard from "../components/ActionCard.svelte";
import { t } from "../libs/i18n";
USER.ensureLoggedIn(); <div class="light-pollution">
<div class="l1"></div>
let me: AquaNetUser <div class="l2"></div>
let error = "" <div class="l3"></div>
</div>
let tab = 0;
let tabs = [t('home.nav.portal'), t('home.nav.link-card'), t('home.nav.game-setup')]
USER.me().then((m) => me = m).catch(e => error = e.message)
</script>
<main class="content">
<!-- <h2 class="outer-title">&nbsp;</h2>-->
<nav class="tabs">
{#each tabs as t, i}
<div class="clickable"
class:active={tab === i}
on:click={() => tab = i}
on:keydown={(e) => e.key === "Enter" && (tab = i)}
role="button" tabindex={i}>{t}
</div>
{/each}
</nav>
{#if tab === 0}
<div out:fade={FADE_OUT} in:fade={FADE_IN} class="action-cards">
<ActionCard color="255, 192, 203" icon="solar:card-bold-duotone" on:click={() => tab = 1}>
{#if me && me.cards.length > 1}
<h3>{t('home.manage-cards')}</h3>
<span>{t('home.manage-cards-description')}</span>
{:else if me}
<h3>{t('home.link-card')}</h3>
<span>{t('home.link-cards-description')}</span>
{/if}
</ActionCard>
<ActionCard color="82, 93, 233" icon="ic:baseline-discord" on:click={() => window.location.href = DISCORD_INVITE}>
<h3>{t('home.join-discord')}</h3>
<span>{t('home.join-discord-description')}</span>
</ActionCard>
<ActionCard on:click={() => tab = 2} icon="uil:link-alt">
<h3>{t('home.setup')}</h3>
<span>{t('home.setup-description')}</span>
</ActionCard>
</div>
{:else if tab === 1}
<div out:fade={FADE_OUT} in:fade={FADE_IN}>
<LinkCard/>
</div>
{:else if tab === 2}
<div out:fade={FADE_OUT} in:fade={FADE_IN}>
<SetupInstructions/>
</div>
{/if}
</main> </main>
<StatusOverlays {error} loading={!me}/>
<style lang="sass"> <style lang="sass">
@import "../vars" @import "../vars"
.tabs #home
display: flex color: $c-main
gap: 1rem position: relative
width: 100%
height: 100%
padding-left: 100px
overflow: hidden
div box-sizing: border-box
&.active
color: $c-main
h3
font-size: 1.3rem
margin: 0
.action-cards
display: flex display: flex
flex-direction: column flex-direction: column
gap: 1rem justify-content: center
</style>
margin-top: -$nav-height
> h1
font-family: Quicksand, $font
user-select: none
// Gap between text characters
letter-spacing: 0.2em
margin-top: 0
opacity: 0.9
.btn-group
display: flex
gap: 8px
.light-pollution
pointer-events: none
opacity: 0.6
> 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($color, 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($color, 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($color, 0) 100%)
@media (max-width: 500px)
align-items: center
padding-left: 0
</style>

View File

@@ -1,376 +0,0 @@
<!-- Svelte 4.2.11 -->
<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 moment from "moment"
import Icon from "@iconify/svelte";
import StatusOverlays from "../../components/StatusOverlays.svelte";
// State
let state: 'ready' | 'linking-AC' | 'linking-SN' | 'loading' = "loading"
let showConfirm: ConfirmProps | null = null
let error: string = ""
let me: AquaNetUser | null = null
let accountCardSummary: CardSummary | null = null
// Fetch data for current user
const updateMe = () => USER.me().then(m => {
me = m
m.cards.sort((a, b) => a.registerTime < b.registerTime ? 1 : -1)
CARD.summary(m.ghostCard.luid).then(s => accountCardSummary = s.summary)
// Always put the ghost card at the top
m.cards.sort((a, b) => a.ghost ? -1 : 1)
state = "ready"
}).catch(e => error = e.message)
updateMe()
// Data conflict overlay
let conflictCardID: string = ""
let conflictSummary: CardSummary | null = null
let conflictGame: string = ""
let conflictNew: CardSummaryGame | null = null
let conflictOld: CardSummaryGame | null = null
let conflictToMigrate: string[] = []
function setError(msg: string, type: 'AC' | 'SN') {
type === 'AC' ? errorAC = msg : errorSN = msg
}
async function doLink(id: string, migrate: string) {
await CARD.link({cardId: id, migrate})
await updateMe()
state = "ready"
}
async function link(type: 'AC' | 'SN') {
if (state !== 'ready' || accountCardSummary === null) return
state = "linking-" + type
const id = type === 'AC' ? inputAC : inputSN
console.log("linking card", id)
// Check if this card is already linked in the account
if (me?.cards?.some(c => formatLUID(c.luid, c.ghost).toLowerCase() === id.toLowerCase())) {
setError("This card is already linked to your account", type)
state = "ready"
return
}
// 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") {
doLink(id, "")
return
}
setError(e.message, type)
state = "ready"
return
}))!
const summary = card.summary
// Check if it's already linked
if (card.card.linked) {
setError("This card is already linked to another account", type)
state = "ready"
return
}
// If all games in summary are null or doesn't conflict with the ghost card,
// we can link the card directly
if (Object.keys(summary).every(k => summary[k as keyof CardSummary] === null
|| accountCardSummary!![k as keyof CardSummary] === null)) {
console.log("linking card directly")
await doLink(id, Object.keys(summary).filter(k => summary[k as keyof CardSummary] !== null).join(","))
}
// For each conflicting game, ask the user if they want to migrate the data
else {
conflictSummary = summary
conflictCardID = id
await linkConflictContinue(null)
}
}
async function linkConflictContinue(choose: "old" | "new" | null) {
if (accountCardSummary === null || conflictSummary === null) return
console.log("linking card with migration")
if (choose) {
// If old is chosen, nothing needs to be migrated
// If new is chosen, we need to migrate the data
if (choose === "new") {
conflictToMigrate.push(conflictGame)
}
// Continue to the next card
conflictSummary[conflictGame as keyof CardSummary] = null
}
let isConflict = false
for (const k in conflictSummary) {
conflictNew = conflictSummary[k as keyof CardSummary]
conflictOld = accountCardSummary[k as keyof CardSummary]
conflictGame = k
if (!conflictNew || !conflictOld) continue
isConflict = true
break
}
// If there are no longer conflicts, we can link the card
if (!isConflict) {
await doLink(conflictCardID, conflictToMigrate.join(","))
// Reset the conflict state
linkConflictCancel()
}
}
function linkConflictCancel() {
state = "ready"
conflictSummary = null
conflictCardID = ""
conflictGame = ""
conflictNew = null
conflictOld = null
conflictToMigrate = []
}
async function unlink(card: Card) {
showConfirm = {
title: "Unlink Card",
message: "Are you sure you want to unlink this card?",
confirm: async () => {
await CARD.unlink(card.luid)
await updateMe()
showConfirm = null
},
cancel: () => showConfirm = null,
dangerous: true
}
}
// Access code input
const inputACRegex = /^(\d{4} ){0,4}\d{0,4}$/
let inputAC = ""
let errorAC = ""
function inputACChange(e: any) {
e = e as InputEvent
// Add spaces to the input
const old = inputAC
if (e.inputType === "insertText" && inputAC.length % 5 === 4 && inputAC.length < 24)
inputAC += " "
inputAC = inputAC.slice(0, 24)
if (inputAC !== old) errorAC = ""
}
// Serial number input
const inputSNRegex = /^([0-9A-Fa-f]{0,2}:){0,7}[0-9A-Fa-f]{0,2}$/
let inputSN = ""
let errorSN = ""
function inputSNChange(e: any) {
e = e as InputEvent
// Add colons to the input
const old = inputSN
if (e.inputType === "insertText" && inputSN.length % 3 === 2 && inputSN.length < 23)
inputSN += ":"
inputSN = inputSN.toUpperCase().slice(0, 23)
if (inputSN !== old) errorSN = ""
}
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":
return BigInt(luid).toString(16).toUpperCase().padStart(16, "0").match(/.{1,2}/g)!.join(":")
case "Access Code":
return luid.match(/.{4}/g)!.join(" ")
default:
return luid
}
}
function cardType(luid: string) {
if (luid.startsWith("00")) return "Felica SN"
if (luid.length === 20) return "Access Code"
if (luid.includes(":")) return "Felica SN"
if (luid.includes(" ")) return "Access Code"
return "Unknown"
}
function isInput(e: KeyboardEvent) {
return e.key.length === 1 && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey
}
</script>
<div class="link-card">
<h2>Your Cards</h2>
<p>Here are the cards you have linked to your account:</p>
{#if me}
<div class="existing-cards" transition:slide>
{#each me.cards as card (card.luid)}
<div class:ghost={card.ghost} class='existing card' transition:fade|global>
<span class="type">{card.ghost ? "Account Card" : cardType(card.luid)}</span>
<span class="register">Registered: {moment(card.registerTime).format("YYYY MMM DD")}</span>
<span class="last">Last used: {moment(card.accessTime).format("YYYY MMM DD")}</span>
<div/>
<span class="id">{formatLUID(card.luid, card.ghost)}</span>
{#if !card.ghost}
<button class="icon error" on:click={() => unlink(card)}><Icon icon="tabler:trash-x-filled"/></button>
{/if}
</div>
{/each}
</div>
{/if}
<h2>Link Card</h2>
<p>Please enter the following information:</p>
{#if !inputSN}
<div out:slide={{ duration: 250 }}>
<p>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)</p>
<label>
<!-- DO NOT change the order of bind:value and on:input. Their order determines the order of reactivity -->
<input placeholder="e.g. 5200 1234 5678 9012 3456"
on:keydown={(e) => {
e.key === "Enter" && link('AC')
// Ensure key is numeric
if (isInput(e) && !/[\d ]/.test(e.key)) e.preventDefault()
}}
bind:value={inputAC}
on:input={inputACChange}
class:error={inputAC && (!inputACRegex.test(inputAC) || errorAC)}>
{#if inputAC.length > 0}
<button transition:slide={{axis: 'x'}} on:click={() => {link('AC');inputAC=''}}>Link</button>
{/if}
</label>
{#if errorAC}
<p class="error" transition:slide>{errorAC}</p>
{/if}
</div>
{/if}
{#if !inputAC}
<div out:slide={{ duration: 250 }}>
<p>Download the NFC Tools app on your phone
(<a href="https://play.google.com/store/apps/details?id=com.wakdev.wdnfc">Android</a> /
<a href="https://apps.apple.com/us/app/nfc-tools/id1252962749">Apple</a>) and scan your card.
Then, enter the Serial Number.
</p>
<label>
<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
if (isInput(e) && !/[0-9A-Fa-f:]/.test(e.key)) e.preventDefault()
}}
bind:value={inputSN}
on:input={inputSNChange}
class:error={inputSN && (!inputSNRegex.test(inputSN) || errorSN)}>
{#if inputSN.length > 0}
<button transition:slide={{axis: 'x'}} on:click={() => {link('SN'); inputSN = ''}}>Link</button>
{/if}
</label>
{#if errorSN}
<p class="error" transition:slide>{errorSN}</p>
{/if}
</div>
{/if}
{#if conflictOld && conflictNew && me}
<div class="overlay" transition:fade>
<div>
<h2>Data Conflict</h2>
<p>The card contains data for {conflictGame}, which is already present on your account.
Please choose the data you would like to keep</p>
<div class="conflict-cards">
<div class="old card clickable" on:click={() => linkConflictContinue('old')}
role="button" tabindex="0" on:keydown={e => e.key === "Enter" && linkConflictContinue('old')}>
<span class="type">Account Card</span>
<span>Name: {conflictOld.name}</span>
<span>Rating: {conflictOld.rating}</span>
<span>Last Login: {moment(conflictOld.lastLogin).format("YYYY MMM DD")}</span>
<span class="id">{formatLUID(me.ghostCard.luid, true)}</span>
</div>
<div class="new card clickable" on:click={() => linkConflictContinue('new')}
role="button" tabindex="0" on:keydown={e => e.key === "Enter" && linkConflictContinue('new')}>
<span class="type">{cardType(conflictCardID)}</span>
<span>Name: {conflictNew.name}</span>
<span>Rating: {conflictNew.rating}</span>
<span>Last Login: {moment(conflictNew.lastLogin).format("YYYY MMM DD")}</span>
<span class="id">{conflictCardID}</span>
</div>
</div>
<button class="error" on:click={linkConflictCancel}>Cancel</button>
</div>
</div>
{/if}
<StatusOverlays bind:confirm={showConfirm} bind:error={error} loading={!me} />
</div>
<style lang="sass">
@import "../../vars"
.link-card
input
width: 100%
label
display: flex
button
margin-left: 1rem
.existing-cards, .conflict-cards
display: grid
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr))
gap: 1rem
.existing.card
min-height: 90px
position: relative
overflow: hidden
*
white-space: nowrap
&.ghost
background: rgba($c-darker, 0.8)
.register, .last
opacity: 0.7
span:not(.type)
font-size: 0.8rem
> div
flex: 1
button
position: absolute
right: 10px
bottom: 10px
.conflict-cards
.card
transition: $transition
.card:hover
background: $c-darker
span:not(.type)
font-size: 0.8rem
.id
opacity: 0.7
</style>

View File

@@ -1,105 +0,0 @@
<!-- Svelte 4.2.11 -->
<script lang="ts">
import { fade, slide } from "svelte/transition";
import { USER } from "../../libs/sdk";
import type { AquaNetUser } from "../../libs/generalTypes";
import { codeToHtml } from 'shiki'
import { AQUA_CONNECTION, DISCORD_INVITE, FADE_IN, FADE_OUT } from "../../libs/config";
let user: AquaNetUser
let keychip: string;
let keychipCode: string;
let getStartedRequesting = false;
USER.me().then((u) => {
user = u;
});
function getStarted() {
if (getStartedRequesting) return;
getStartedRequesting = true;
USER.keychip().then(k => {
getStartedRequesting = false;
keychip = k;
codeToHtml(`
[dns]
default=${AQUA_CONNECTION}
[keychip]
enable=1
; This is your unique keychip, do not share it with anyone
id=${keychip.slice(0, 4)}-${keychip.slice(4)}1337`.trim(), {
lang: 'ini',
theme: 'rose-pine',
}).then((html) => {
keychipCode = html;
});
});
}
</script>
<div class="setup-instructions">
<h2>Setup Connection</h2>
<p>
Welcome! If you own an arcade cabinet or game setup,
please follow the instructions below to set up the connection with AquaDX.
</p>
<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.
</blockquote>
{#if user}
<div transition:slide>
{#if !keychip && !keychipCode}
<div class="no-margin" out:fade={FADE_OUT}>
<button class="emp" on:click={getStarted}>Get started</button>
</div>
{:else}
<div class="no-margin" in:fade={FADE_IN}>
<p>
Please edit your segatools.ini file and modify the following lines:
</p>
<div class="code">
{@html keychipCode}
</div>
<p>
Then, after you restart the game, you should be able to connect to AquaDX. Please verify that the network tests are all GOOD in the test menu.
</p>
<p>
If you have any questions, please ask in our <a href={DISCORD_INVITE}>Discord server</a>.
</p>
</div>
{/if}
</div>
{:else}
<p>Loading...</p>
{/if}
</div>
<style lang="sass">
.code
overflow-x: auto
:global(pre.shiki)
background-color: transparent !important
:global(code)
counter-reset: step
counter-increment: step 0
:global(code .line::before)
content: counter(step)
counter-increment: step
width: 1rem
margin-right: 1.5rem
display: inline-block
text-align: right
color: rgba(115,138,148,.4)
</style>

View File

@@ -1,13 +1,10 @@
<script lang="ts"> <script lang="ts">
import { DATA_HOST } from "../libs/config"; import {data_host} from "../libs/config";
import { getMaimai, getMaimaiAllMusic } from "../libs/maimai"; import {getMaimaiAllMusic, getMaimai, getMult} from "../libs/maimai";
import type { ParsedRating, Rating } from "../libs/maimaiTypes"; import type {ParsedRating, Rating} from "../libs/maimaiTypes";
import { getMult } from "../libs/scoring";
import StatusOverlays from "../components/StatusOverlays.svelte";
export let userId: any export let userId: any
userId = +userId userId = +userId
let error: string | null;
if (!userId) console.error("No user ID provided") if (!userId) console.error("No user ID provided")
@@ -27,7 +24,7 @@
old: parseRating(data.userRating.ratingList), old: parseRating(data.userRating.ratingList),
new: parseRating(data.userRating.newRatingList) new: parseRating(data.userRating.newRatingList)
} }
}).catch((e) => error = e.message) })
function parseRating(arr: Rating[]) { function parseRating(arr: Rating[]) {
return arr.map(x => { return arr.map(x => {
@@ -39,7 +36,7 @@
} }
music.note = music.notes[x.level] music.note = music.notes[x.level]
const mult = getMult(x.achievement, 'mai2') const mult = getMult(x.achievement)
return { return {
...x, ...x,
music: music, music: music,
@@ -74,19 +71,19 @@
{#each section.data as rating} {#each section.data as rating}
<div class="level-{rating.level}"> <div class="level-{rating.level}">
<img class="cover" <img class="cover"
src={`${DATA_HOST}/maimai/assetbundle/jacket_s/00${rating.musicId.toString().padStart(6, '0').substring(2)}.png`} src={`${data_host}/maimai/assetbundle/jacket_s/00${rating.musicId.toString().padStart(6, '0').substring(2)}.png`}
alt=""> alt="">
<div class="detail"> <div class="detail">
<span class="name">{rating.music.name}</span> <span class="name">{rating.music.name}</span>
<span class="rating"> <span class="rating">
<span>{(rating.achievement / 10000).toFixed(2)}%</span> <span>{(rating.achievement / 10000).toFixed(2)}%</span>
<img class="rank" src={`${DATA_HOST}/maimai/sprites/rankimage/UI_GAM_Rank_${rating.rank}.png`} alt=""> <img class="rank" src={`${data_host}/maimai/sprites/rankimage/UI_GAM_Rank_${rating.rank}.png`} alt="">
</span> </span>
<span>{rating.calc.toFixed(1)}</span> <span>{rating.calc.toFixed(1)}</span>
</div> </div>
<img class="ver" <img class="ver"
src={`${DATA_HOST}/maimai/sprites/tab/title/UI_CMN_TabTitle_MaimaiTitle_Ver${rating.music.ver.toString().substring(0, 3)}.png`} src={`${data_host}/maimai/sprites/tab/title/UI_CMN_TabTitle_MaimaiTitle_Ver${rating.music.ver.toString().substring(0, 3)}.png`}
alt=""> alt="">
<div class="lv">{rating.music.note.lv}</div> <div class="lv">{rating.music.note.lv}</div>
</div> </div>
@@ -94,8 +91,6 @@
</div> </div>
{/each} {/each}
{/if} {/if}
<StatusOverlays error={error} loading={!parsedRatings} />
</main> </main>
<style lang="sass"> <style lang="sass">
@@ -177,7 +172,7 @@
bottom: 0 bottom: 0
right: 0 right: 0
padding: 5px 10px padding: 5px 10px
background: rgb(var(--lv-color)) background: var(--lv-color)
// Top left border radius // Top left border radius
border-radius: 10px 0 border-radius: 10px 0
@@ -201,4 +196,4 @@
img.ver img.ver
height: 45px height: 45px
left: -20px left: -20px
</style> </style>

View File

@@ -1,121 +0,0 @@
<script lang="ts">
import { title } from "../libs/ui";
import { GAME } from "../libs/sdk";
import type { GenericRanking } from "../libs/generalTypes";
import StatusOverlays from "../components/StatusOverlays.svelte";
import type { GameName } from "../libs/scoring";
import { GAME_TITLE } from "../libs/i18n";
import { t } from "../libs/i18n";
export let game: GameName = 'mai2';
title(`Ranking`);
let d: { users: GenericRanking[] };
let error: string | null;
Promise.all([GAME.ranking(game)])
.then(([users]) => {
console.log(users)
d = { users };
})
.catch((e) => error = e.message);
</script>
<main class="content leaderboard">
<div class="outer-title-options">
<h2>{t("Leaderboard.Title")}</h2>
<nav>
{#each Object.entries(GAME_TITLE) as [k, v]}
<a href="/ranking/{k}" class:active={k === game}>{v}</a>
{/each}
</nav>
</div>
{#if d}
<div class="leaderboard-container">
<div class="lb-user">
<span class="rank">{t("Leaderboard.Rank")}</span>
<span class="name"></span>
<span class="rating">{t("Leaderboard.Rating")}</span>
<span class="accuracy">{t("Leaderboard.Accuracy")}</span>
<span class="fc">{t("Leaderboard.FC")}</span>
<span class="ap">{t("Leaderboard.AP")}</span>
</div>
{#each d.users as user, i (user.rank)}
<div class="lb-user" class:alternate={i % 2 === 1}>
<span class="rank">#{user.rank}</span>
<span class="name">
{#if user.username !== ""}
<a href="/u/{user.username}/{game}" class:registered={!(/user\d+/.test(user.username))}>{user.name}</a>
{:else}
<span>{user.name}</span>
{/if}
</span>
<span class="rating">{user.rating.toLocaleString()}</span>
<span class="accuracy">{(+user.accuracy).toFixed(2)}%</span>
<span class="fc">{user.fullCombo}</span>
<span class="ap">{user.allPerfect}</span>
</div>
{/each}
</div>
{/if}
<StatusOverlays error={error} loading={!d} />
</main>
<style lang="sass">
@import "../vars"
.leaderboard-container
display: flex
flex-direction: column
.lb-user
display: flex
align-items: center
justify-content: space-between
width: 100%
gap: 12px
border-radius: $border-radius
padding: 6px 12px
box-sizing: border-box
> *:not(.name)
text-align: center
.name
min-width: 100px
flex: 1
> a
color: unset
.registered
background: $grad-special
color: transparent
-webkit-background-clip: text
background-clip: text
.accuracy, .rating
width: 15%
min-width: 45px
.rating
font-weight: bold
color: white
.fc, .ap
width: 5%
min-width: 20px
@media (max-width: $w-mobile)
font-size: 0.9rem
.accuracy
display: none
&.alternate
background-color: $ov-light
</style>

View File

@@ -1,195 +0,0 @@
<!-- Svelte 4.2.11 -->
<script lang="ts">
import { slide, fade } from "svelte/transition";
import type { AquaNetUser, GameOption } from "../../libs/generalTypes";
import { SETTING, USER } from "../../libs/sdk";
import StatusOverlays from "../../components/StatusOverlays.svelte";
import Icon from "@iconify/svelte";
import { pfp } from "../../libs/ui";
import { t, ts } from "../../libs/i18n";
import { FADE_IN, FADE_OUT } from "../../libs/config";
USER.ensureLoggedIn()
let me: AquaNetUser;
let error: string;
let submitting = ""
let tab = 0
const tabs = [ 'profile', 'game' ]
const profileFields = [
[ 'displayName', "Display Name" ],
[ 'username', "Username" ],
[ 'password', "Password" ],
[ 'profileLocation', "Location" ],
[ 'profileBio', "Bio" ],
]
let gameFields: GameOption[] = []
// Fetch user data
const getMe = () => Promise.all([USER.me(), SETTING.get()]).then(([m, s]) => {
gameFields = s
me = m
values = profileFields.map(([field]) => me[field as keyof AquaNetUser])
}).catch(e => error = e.message)
getMe()
let values = Array(profileFields.length).fill('')
let changed: string[] = []
let pfpField: HTMLInputElement
function submit(field: string, value: string) {
if (submitting) return
submitting = field
USER.setting(field, value).then(() => {
changed = changed.filter(c => c !== field)
}).catch(e => error = e.message).finally(() => submitting = "")
}
function submitGameOption(field: string, value: any) {
if (submitting) return
submitting = field
SETTING.set(field, value).catch(e => error = e.message).finally(() => submitting = "")
}
function uploadPfp(file: File) {
if (submitting) return
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) => {
if (whether) node.type = 'password'
}
</script>
<main class="content">
<div class="outer-title-options">
<h2>{t('settings.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>
{#if tab === 0}
<!-- Tab 0: Profile settings -->
<div out:fade={FADE_OUT} in:fade={FADE_IN} class="fields">
<div class="field">
<label for="profile-upload">Profile Picture</label>
<div>
{#if me && me.profilePicture}
<div on:click={() => pfpField.click()} on:keydown={e => e.key === 'Enter' && pfpField.click()}
role="button" tabindex="0" class="clickable">
<img use:pfp={me} alt="Profile" />
</div>
{:else}
<button on:click={() => pfpField.click()}>
Upload New
</button>
{/if}
</div>
<input id="profile-upload" type="file" accept="image/*" style="display: none" bind:this={pfpField}
on:change={() => pfpField.files && uploadPfp(pfpField.files[0])} />
</div>
{#each profileFields as [field, name], i (field)}
<div class="field">
<label for={field}>{name}</label>
<div>
<input id={field} type="text" use:passwordAction={field === 'password'}
bind:value={values[i]} on:input={() => changed = [...changed, field]}
placeholder={field === 'password' ? 'Unchanged' : 'Unset'}/>
{#if changed.includes(field) && values[i]}
<button transition:slide={{axis: 'x'}} on:click={() => submit(field, values[i])}>
{#if submitting === field}
<Icon icon="line-md:loading-twotone-loop" />
{:else}
Save
{/if}
</button>
{/if}
</div>
</div>
{/each}
</div>
{:else if tab === 1}
<!-- Tab 1: Game settings -->
<div out:fade={FADE_OUT} in:fade={FADE_IN} class="fields">
{#each gameFields as field}
<div class="field">
{#if field.type === "Boolean"}
<div class="bool">
<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>
</div>
{/if}
</div>
{/each}
</div>
{/if}
<StatusOverlays {error} loading={!me || !!submitting} />
</main>
<style lang="sass">
@import "../../vars"
.fields
display: flex
flex-direction: column
gap: 12px
.bool
display: flex
align-items: center
gap: 1rem
label
display: flex
flex-direction: column
.desc
opacity: 0.6
.field
display: flex
flex-direction: column
label
max-width: max-content
> div:not(.bool)
display: flex
align-items: center
gap: 1rem
margin-top: 0.5rem
> input
flex: 1
img
max-width: 100px
max-height: 100px
border-radius: $border-radius
object-fit: cover
</style>

View File

@@ -1,279 +1,190 @@
<script lang="ts"> <script lang="ts">
import { CHARTJS_OPT, coverNotFound, pfpNotFound, registerChart, renderCal, title, tooltip, pfp } from "../libs/ui"; import {CHARTJS_OPT, clazz, registerChart, renderCal, title} from "../libs/ui";
import type { import {getMaimaiAllMusic, getMaimaiTrend, getMaimaiUser, getMult} from "../libs/maimai";
GenericGamePlaylog, import type {MaimaiMusic, MaimaiUserPlaylog, MaimaiUserSummaryEntry} from "../libs/maimaiTypes";
GenericGameSummary, import type {TrendEntry} from "../libs/generalTypes";
MusicMeta, import {data_host} from "../libs/config";
TrendEntry,
AquaNetUser,
AllMusic
} from "../libs/generalTypes";
import { DATA_HOST } from "../libs/config";
import 'cal-heatmap/cal-heatmap.css'; import 'cal-heatmap/cal-heatmap.css';
import { Line } from 'svelte-chartjs'; import { Line } from 'svelte-chartjs';
import moment from "moment"; import moment from "moment";
import 'chartjs-adapter-moment'; import 'chartjs-adapter-moment';
import { CARD, DATA, GAME, USER } from "../libs/sdk";
import { type GameName, getMult } from "../libs/scoring";
import StatusOverlays from "../components/StatusOverlays.svelte";
import Icon from "@iconify/svelte";
import { GAME_TITLE, t } from "../libs/i18n";
import RankDetails from "../components/RankDetails.svelte";
import MapDetails from "../components/MapDetails.svelte";
const TREND_DAYS = 60
registerChart() registerChart()
export let username: string; export let userId: any;
export let game: GameName = "mai2" userId = +userId
let calElement: HTMLElement let calElement: HTMLElement
let error: string;
let me: AquaNetUser
title(`User ${username}`)
const titleText = GAME_TITLE[game] title(`User ${userId}`)
interface MusicAndPlay extends MusicMeta, GenericGamePlaylog {} interface MusicAndPlay extends MaimaiMusic, MaimaiUserPlaylog {}
let d: { let d: {
user: GenericGameSummary, user: MaimaiUserSummaryEntry,
trend: TrendEntry[] trend: TrendEntry[]
recent: MusicAndPlay[], recent: MusicAndPlay[]
validGames: [ string, string ][], } | null = null
} | null
let allMusics: AllMusic Promise.all([
let showDetailRank = false getMaimaiUser(userId),
USER.isLoggedIn() && USER.me().then(u => me = u) getMaimaiTrend(userId),
getMaimaiAllMusic()
]).then(([user, trend, music]) => {
console.log(user)
console.log(trend)
console.log(music)
// Sort recent by date
user.recent.sort((a, b) => b.userPlayDate < a.userPlayDate ? -1 : 1)
CARD.userGames(username).then(games => { d = {user, trend, recent: user.recent.map(it => {return {...music[it.musicId], ...it}})}
if (!games[game]) { localStorage.setItem("tmp-user-details", JSON.stringify(d))
// Find a valid game renderCal(calElement, trend.map(it => {return {date: it.date, value: it.plays}}))
const valid = Object.entries(games).filter(([g, valid]) => valid) })
if (!valid || !valid[0]) return error = t("UserHome.NoValidGame")
window.location.href = `/u/${username}/${valid[0][0]}`
}
Promise.all([
GAME.userSummary(username, game),
GAME.trend(username, game),
DATA.allMusic(game),
]).then(([user, trend, music]) => {
console.log(user)
console.log(trend)
console.log(games)
// If game is wacca, divide all ratings by 10
if (game === 'wacca') {
user.rating /= 10
trend.forEach(it => it.rating /= 10)
user.recent.forEach(it => {
it.beforeRating /= 10
it.afterRating /= 10
})
}
const minDate = moment().subtract(TREND_DAYS, 'days').format("YYYY-MM-DD")
d = {user,
trend: trend.filter(it => it.date >= minDate && it.plays != 0),
recent: user.recent.map(it => {return {...music[it.musicId], ...it}}),
validGames: Object.entries(GAME_TITLE).filter(g => games[g[0] as GameName])
}
allMusics = music
renderCal(calElement, trend.map(it => {return {date: it.date, value: it.plays}})).then(() => {
// Scroll to the rightmost
calElement.scrollLeft = calElement.scrollWidth - calElement.clientWidth
})
}).catch((e) => error = e.message);
}).catch((e) => { error = e.message; console.error(e) } );
</script> </script>
<main id="user-home" class="content"> <main id="user-home">
{#if d} {#if d !== null}
<div class="user-pfp"> <div class="user-pfp">
<img use:pfp={d.user.aquaUser} alt="" class="pfp" on:error={pfpNotFound}> <img src={`${data_host}/maimai/assetbundle/icon/${d.user.iconId.toString().padStart(6, "0")}.png`} alt="" class="pfp">
<div class="name-box"> <h2>{d.user.name}</h2>
<h2>{d.user.name}</h2>
{#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> </div>
<div> <div>
<h2>{titleText} {t('UserHome.Statistics')}</h2> <h2>Rating Statistics</h2>
<div class="scoring-info"> <div class="scoring-info">
<div class="chart"> <div class="chart">
<div class="info-top"> <div class="info-top">
<div class="rating"> <div class="rating">
<span>{game === 'mai2' ? t("UserHome.DXRating"): t("UserHome.Rating")}</span> <span>DX Rating</span>
<span>{d.user.rating.toLocaleString()}</span> <span>{d.user.rating.toLocaleString()}</span>
</div> </div>
<div class="rank"> <div class="rank">
<span>{t('UserHome.ServerRank')}</span> <span>Server Rank</span>
<span>#{+d.user.serverRank.toLocaleString() + 1}</span> <span>#{d.user.serverRank.toLocaleString()}</span>
</div> </div>
</div> </div>
<div class="trend"> <div class="trend">
<!-- ChartJS cannot be fully responsive unless there is a parent div that's independent from its size and helps it determine its size --> <!-- ChartJS cannot be fully responsive unless there is a parent div that's independent from its size and helps it determine its size -->
<div class="chartjs-box-reference"> <div class="chartjs-box-reference">
{#if d.trend.length <= 1} <Line data={{
<div class="no-data">{t("UserHome.NoData", { days: TREND_DAYS })}</div> datasets: [
{:else} {
<Line data={{ label: 'Rating',
datasets: [ data: d.trend.map(it => {return {x: Date.parse(it.date), y: it.rating}}),
{ borderColor: '#646cff',
label: 'Rating', tension: 0.1,
data: d.trend.map(it => {return {x: Date.parse(it.date), y: it.rating}}),
borderColor: '#646cff',
tension: 0.1,
// TODO: Set X axis span to 3 months // TODO: Set X axis span to 3 months
} }
] ]
}} options={CHARTJS_OPT} /> }} options={CHARTJS_OPT} />
{/if}
</div> </div>
</div> </div>
{#if Object.entries(d.user.detailedRanks).length > 0} <div class="info-bottom">
<div class="info-bottom clickable" use:tooltip={t("UserHome.ShowRanksDetails")} {#each d.user.ranks as r}
on:click={() => showDetailRank = !showDetailRank} role="button" tabindex="0" <div>
on:keydown={e => e.key === "Enter" && (showDetailRank = !showDetailRank)}> <span>{r.name}</span>
{#each d.user.ranks as r} <span>{r.count}</span>
<div><span>{r.name}</span><span>{r.count}</span></div> </div>
{/each} {/each}
</div> </div>
{:else}
<div class="info-bottom">
{#each d.user.ranks as r}
<div><span>{r.name}</span><span>{r.count}</span></div>
{/each}
</div>
{/if}
</div> </div>
<div class="other-info"> <div class="other-info">
<div class="accuracy"> <div class="accuracy">
<span>{t('UserHome.Accuracy')}</span> <span>Accuracy</span>
<span>{(d.user.accuracy).toFixed(2)}%</span> <span>{(d.user.accuracy / 10000).toFixed(2)}%</span>
</div> </div>
<div class="max-combo"> <div class="max-combo">
<span>{t("UserHome.MaxCombo")}</span> <span>Max Combo</span>
<span>{d.user.maxCombo}</span> <span>{d.user.maxCombo}</span>
</div> </div>
<div class="full-combo"> <div class="full-combo">
<span>{t("UserHome.FullCombo")}</span> <span>Full Combo</span>
<span>{d.user.fullCombo}</span> <span>{d.user.fullCombo}</span>
</div> </div>
<div class="all-perfect"> <div class="all-perfect">
<span>{t("UserHome.AllPerfect")}</span> <span>All Perfect</span>
<span>{d.user.allPerfect}</span> <span>{d.user.allPerfect}</span>
</div> </div>
<div class="total-dx-score"> <div class="total-dx-score">
<span>{game === 'mai2' ? t('UserHome.DXScore') : t("UserHome.Score")}</span> <span>DX Score</span>
<span>{d.user.totalScore.toLocaleString()}</span> <span>{d.user.totalDxScore.toLocaleString()}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{#if showDetailRank}<RankDetails g={d.user}/>{/if}
<div> <div>
<h2>{t('UserHome.PlayActivity')}</h2> <h2>Play Activity</h2>
<div class="activity-info"> <div class="activity-info">
<div class="hide-scrollbar" id="cal-heatmap" bind:this={calElement} /> <div id="cal-heatmap" bind:this={calElement} />
<div class="info-bottom"> <div class="info-bottom">
<div class="plays"> <div class="plays">
<span>{t("UserHome.Plays")}</span> <span>Plays</span>
<span>{d.user.plays}</span> <span>{d.user.plays}</span>
</div> </div>
<div class="time"> <div class="time">
<span>{t('UserHome.PlayTime')}</span> <span>Play Time</span>
<span>{(d.user.totalPlayTime / 60).toFixed(1)} hr</span> <span>{(d.user.totalPlayTime / 60 / 60).toFixed(1)} hr</span>
</div> </div>
<div class="first-play"> <div class="first-play">
<span>{t('UserHome.FirstSeen')}</span> <span>First Seen</span>
<span>{moment(d.user.joined).format("YYYY-MM-DD")}</span> <span>{moment(d.user.joined).format("YYYY-MM-DD")}</span>
</div> </div>
<div class="last-play"> <div class="last-play">
<span>{t('UserHome.LastSeen')}</span> <span>Last Seen</span>
<span>{moment(d.user.lastSeen).format("YYYY-MM-DD")}</span> <span>{moment(d.user.lastSeen).format("YYYY-MM-DD")}</span>
</div> </div>
<div class="last-version"> <div class="last-version">
<span>{t('UserHome.Version')}</span> <span>Last Version</span>
<span>{d.user.lastVersion}</span> <span>{d.user.lastVersion}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{#if d.user.ratingComposition.best35}
<div>
<h2>B35</h2>
<div class="b35">
{#each d.user.ratingComposition.best35.split(",") as map}
<div>
<MapDetails g={map} meta={allMusics[map.split(":")[0]]} game={game}/>
</div>
{/each}
</div>
</div>
{/if}
<div class="recent"> <div class="recent">
<h2>{t('UserHome.RecentScores')}</h2> <h2>Recent Scores</h2>
<div class="scores"> <div class="scores">
{#each d.recent as r, i} {#each d.recent as r, i}
<div class:alt={i % 2 === 0}> <div class={clazz({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} /> <img src={`${data_host}/maimai/assetbundle/jacket_s/00${r.musicId.toString().padStart(6, '0').substring(2)}.png`} alt="">
<div class="info"> <div class="info">
<div>{r.name ?? t("UserHome.UnknownSong")}</div>
<div> <div>
<span class={`lv level-${r.level === 10 ? 3 : r.level}`}> <span class="name">{r.name}</span>
{ r.notes?.[r.level === 10 ? 0 : r.level]?.lv?.toFixed(1) ?? '-' ?? '0'} </div>
</span> <div>
<span class={`rank-${getMult(r.achievement, game)[2].toString()[0]}`}> <span class={`lv level-${r.level}`}>{r.notes[r.level].lv}</span>
<span class="rank-text">{("" + getMult(r.achievement, game)[2]).replace("p", "+")}</span> <span class={"rank-" + ("" + getMult(r.achievement)[2])[0]}>
<span class="rank-text">{("" + getMult(r.achievement)[2]).replace("p", "+")}</span>
<span class="rank-num">{(r.achievement / 10000).toFixed(2)}%</span> <span class="rank-num">{(r.achievement / 10000).toFixed(2)}%</span>
</span> </span>
{#if game === 'mai2' || game === 'wacca'} <span class={"dx-change " + clazz({increased: r.afterDeluxRating - r.beforeDeluxRating > 0})}>
<span class:increased={r.afterRating - r.beforeRating > 0} class="dx-change"> {r.afterDeluxRating - r.beforeDeluxRating}
{(r.afterRating - r.beforeRating).toFixed(0)} </span>
</span>
{/if}
</div> </div>
</div> </div>
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
{:else}
<p>Loading...</p>
{/if} {/if}
<StatusOverlays {error} loading={!d} />
</main> </main>
<style lang="sass"> <style lang="sass">
@@ -282,56 +193,41 @@
$gap: 20px $gap: 20px
#user-home #user-home
display: flex
flex-direction: column
gap: $gap
margin: 100px auto 0
padding: 0 32px 32px
min-height: 100%
max-width: $w-max
background-color: rgba(black, 0.2)
border-radius: 16px 16px 0 0
@media (max-width: #{$w-max + (64px) * 2})
margin: 100px 32px 0
padding: 0 32px 16px
@media (max-width: $w-mobile)
margin: 100px 0 0
padding: 0 32px 16px
.user-pfp .user-pfp
display: flex display: flex
align-items: flex-end align-items: flex-end
gap: $gap gap: $gap
margin-top: -72px margin-top: -40px
position: relative
h2 h2
font-size: 2rem font-size: 2rem
margin: 0 margin: 0
white-space: nowrap
nav
position: absolute
display: flex
flex-direction: row
gap: 10px
top: 4px
right: 0
.setting-icon
font-size: 1.5rem
color: $c-main
cursor: pointer
display: flex
align-items: center
.name-box
flex: 1
display: flex
align-items: center
justify-content: space-between
gap: 10px
.pfp .pfp
width: 100px width: 100px
height: 100px height: 100px
border-radius: $border-radius border-radius: 5px
object-fit: cover object-fit: cover
@media (max-width: $w-mobile)
.user-pfp
margin-top: -68px
h2
font-size: 1.5rem
.pfp
width: 80px
height: 80px
.info-bottom, .info-top, .other-info .info-bottom, .info-top, .other-info
display: flex display: flex
gap: $gap gap: $gap
@@ -348,17 +244,9 @@ $gap: 20px
letter-spacing: 0.1em letter-spacing: 0.1em
color: $c-main color: $c-main
.info-bottom
width: max-content
.info-top > div > span:last-child .info-top > div > span:last-child
font-size: 1.5rem font-size: 1.5rem
.info-bottom
max-width: 100%
flex-wrap: wrap
row-gap: 0.5rem
.scoring-info .scoring-info
display: flex display: flex
gap: $gap gap: $gap
@@ -385,13 +273,6 @@ $gap: 20px
> .chartjs-box-reference > .chartjs-box-reference
position: absolute position: absolute
inset: 0 inset: 0
display: flex
align-items: center
justify-content: center
.no-data
opacity: 0.5
user-select: none
@media (max-width: $w-mobile) @media (max-width: $w-mobile)
flex-direction: column flex-direction: column
@@ -426,7 +307,6 @@ $gap: 20px
.info-bottom .info-bottom
flex-direction: column flex-direction: column
gap: 0 gap: 0
width: 100%
> div > div
flex-direction: row flex-direction: row
@@ -442,7 +322,7 @@ $gap: 20px
> div.alt > div.alt
background-color: rgba(white, 0.03) background-color: rgba(white, 0.03)
border-radius: $border-radius border-radius: 10px
// Image and song info // Image and song info
> div > div
@@ -456,27 +336,22 @@ $gap: 20px
img img
width: 50px width: 50px
height: 50px height: 50px
border-radius: $border-radius border-radius: 10px
object-fit: cover object-fit: cover
// Song info and score // Song info and score
> div.info > div
flex: 1 flex: 1
display: flex display: flex
justify-content: space-between justify-content: space-between
overflow: hidden
// Limit song name to one line // Limit song name to one line
> div:first-child overflow: hidden
flex: 1 .name
min-width: 0
overflow: hidden overflow: hidden
overflow-wrap: anywhere
white-space: nowrap
text-overflow: ellipsis text-overflow: ellipsis
white-space: nowrap
// Make song score and rank not wrap
> div:last-child
white-space: nowrap
@media (max-width: $w-mobile) @media (max-width: $w-mobile)
flex-direction: column flex-direction: column
@@ -485,18 +360,9 @@ $gap: 20px
.rank-text .rank-text
text-align: left text-align: left
// Score and rank should be space-between on mobile
> div:last-child
display: flex
justify-content: space-between
gap: 10px
.lv
margin-right: auto
.rank-S .rank-S
// Gold green gradient on text // Gold green gradient on text
background: $grad-special background: linear-gradient(90deg, #ffee94, #ffb798, #ffa3e5, #ebff94)
-webkit-background-clip: text -webkit-background-clip: text
color: transparent color: transparent
@@ -507,11 +373,10 @@ $gap: 20px
color: #6ba6ff color: #6ba6ff
.lv .lv
min-width: 30px background: var(--lv-color)
text-align: center
background: rgba(var(--lv-color), 0.6)
padding: 0 6px padding: 0 6px
border-radius: $border-radius border-radius: 10px
opacity: 0.8
margin-right: 10px margin-right: 10px
span span
@@ -520,7 +385,7 @@ $gap: 20px
// Vertical table-like alignment // Vertical table-like alignment
span.rank-text span.rank-text
min-width: 40px min-width: 30px
span.rank-num span.rank-num
min-width: 60px min-width: 60px
span.dx-change span.dx-change
@@ -530,11 +395,4 @@ $gap: 20px
&:before &:before
content: "+" content: "+"
color: $c-good color: $c-good
</style>
.b35
display: grid
// 3 columns
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr))
gap: $gap
</style>

View File

@@ -1,251 +0,0 @@
<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">
@import "../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: $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: -$nav-height
// Content container
> div
display: flex
flex-direction: column
align-items: flex-start
width: max-content
// Switching state container
> div
transition: $transition
#title
font-family: Quicksand, $font
user-select: none
// Gap between text characters
letter-spacing: 0.2em
margin-top: 0
margin-bottom: 32px
opacity: 0.9
.btn-group
display: flex
gap: 8px
.light-pollution
pointer-events: none
opacity: 0.8
> div
position: absolute
z-index: 1
.l1
left: -560px
top: 90px
height: 1130px
width: 1500px
$color: rgb(158, 110, 230)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
.l2
left: -200px
top: 560px
height: 1200px
width: 1500px
$color: rgb(92, 195, 250)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
.l3
left: -600px
opacity: 0.7
top: -630px
width: 1500px
height: 1000px
$color: rgb(230, 110, 156)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
@media (max-width: 500px)
align-items: center
padding-left: 0
</style>

View File

@@ -1,23 +1,18 @@
$font: Quicksand, Inter, LXGW Wenkai, Microsoft YaHei, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Avenir, Helvetica, Arial, sans-serif $font: Quicksand, Inter, system-ui, Avenir, Helvetica, Arial, sans-serif
$c-main: #b3c6ff $c-main: #b3c6ff
$c-sub: rgba(0, 0, 0, 0.77)
$c-good: #b3ffb9 $c-good: #b3ffb9
$c-darker: #646cff $c-darker: #646cff
$c-bg: #242424 $c-bg: #242424
$c-error: #ff6b6b
$c-shadow: rgba(0, 0, 0, 0.1)
$ov-light: rgba(white, 0.04)
$ov-lighter: rgba(white, 0.08)
$ov-dark: rgba(black, 0.1)
$ov-darker: rgba(black, 0.18)
$nav-height: 4rem $nav-height: 4rem
$w-mobile: 560px $w-mobile: 560px
$w-max: 900px $w-max: 900px
$grad-special: linear-gradient(90deg, #ffee94, #ffb798, #ffa3e5, #ebff94) .level-0
--lv-color: #6ED43E
$border-radius: 12px .level-1
--lv-color: #F7B807
$transition: all 0.25s .level-2
--lv-color: #FF828D
.level-3
--lv-color: #A051DC

View File

@@ -1,3 +1,2 @@
/// <reference types="svelte" /> /// <reference types="svelte" />
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const APP_VERSION: string;

View File

@@ -4,7 +4,4 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],
define: {
APP_VERSION: JSON.stringify(process.env.npm_package_version),
},
}) })

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
# Use a multi-stage build to keep the image size small # Use a multi-stage build to keep the image size small
# Start with a Gradle image for building the project # Start with a Gradle image for building the project
FROM gradle:jdk21-alpine as builder FROM gradle:jdk11 as builder
# Copy the Gradle wrapper and configuration files separately to leverage Docker cache # Copy the Gradle wrapper and configuration files separately to leverage Docker cache
COPY --chown=gradle:gradle gradlew /home/gradle/ COPY --chown=gradle:gradle gradlew /home/gradle/
@@ -20,13 +20,13 @@ COPY --chown=gradle:gradle src /home/gradle/src
RUN ./gradlew build -x test RUN ./gradlew build -x test
# Start with a fresh image for the runtime # Start with a fresh image for the runtime
FROM eclipse-temurin:21-jre-alpine FROM openjdk:11-jre-slim
# Set the deployment directory # Set the deployment directory
WORKDIR /app WORKDIR /app
# Copy only the built JAR from the builder image # Copy only the built JAR from the builder image
COPY --from=builder /home/gradle/build/libs/AquaDX-*.jar /app/ COPY --from=builder /home/gradle/build/libs/aqua-?.?.??.jar /app/
# The command to run the application # The command to run the application
CMD java -jar AquaDX-*.jar CMD java -jar aqua-*.jar

439
LICENSE
View File

@@ -1,439 +0,0 @@
TL;DR: https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en
Attribution-NonCommercial-ShareAlike 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial-ShareAlike 4.0 International Public License
("Public License"). To the extent this Public License may be
interpreted as a contract, You are granted the Licensed Rights in
consideration of Your acceptance of these terms and conditions, and the
Licensor grants You such rights in consideration of benefits the
Licensor receives from making the Licensed Material available under
these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. BY-NC-SA Compatible License means a license listed at
creativecommons.org/compatiblelicenses, approved by Creative
Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
e. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name
of a Creative Commons Public License. The License Elements of this
Public License are Attribution, NonCommercial, and ShareAlike.
h. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
i. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
k. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
l. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
m. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
n. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce, reproduce, and Share Adapted Material for
NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. Additional offer from the Licensor -- Adapted Material.
Every recipient of Adapted Material from You
automatically receives an offer from the Licensor to
exercise the Licensed Rights in the Adapted Material
under the conditions of the Adapter's License You apply.
c. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
b. ShareAlike.
In addition to the conditions in Section 3(a), if You Share
Adapted Material You produce, the following conditions also apply.
1. The Adapter's License You apply must be a Creative Commons
license with the same License Elements, this version or
later, or a BY-NC-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the
Adapter's License You apply. You may satisfy this condition
in any reasonable manner based on the medium, means, and
context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms
or conditions on, or apply any Effective Technological
Measures to, Adapted Material that restrict exercise of the
rights granted under the Adapter's License You apply.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material,
including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.

View File

@@ -13,16 +13,13 @@ This is an attempt to rebuild the [original Aqua server](https://dev.s-ul.net/Ne
Below is a list of games supported by this server. Below is a list of games supported by this server.
| Game | Ver | Codename | Thanks to | | Game | Ver | Codename | Thanks to |
|----------------------------|------|---------------|--------------------------------------------| |---------------------------|------|---------------|--------------------------------------------|
| SDHD: CHUNITHM (Chusan) | 2.20 | LUMINOUS | [@rinsama](https://github.com/mxihan) | | SDHD: CHUNITHM (Chusan) | 2.16 | SUN Plus | [@rinsama](https://github.com/mxihan) |
| SDEZ: MaiMai DX | 1.40 | BUDDiES | [@肥宅虾哥](https://github.com/FeiZhaixiage) | | SDEZ: MaiMai DX | 1.40 | BUDDiES | [@肥宅虾哥](https://github.com/FeiZhaixiage) |
| SDED: Card Maker | 1.34 | | | | SDED: Card Maker | 1.34 | | |
| SBZV: Project DIVA Arcade | 7.10 | Future Tone | | | SBZV: Project DIVA Arcade | 7.10 | Future Tone | |
| SDDT: O.N.G.E.K.I. | 1.39 | bright MEMORY | [@Gamer2097](https://github.com/Gamer2097) | | SDDT: O.N.G.E.K.I. | 1.39 | bright MEMORY | [@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. Check out these docs for more information.
* [Game specific notes](docs/game_specific_notes.md) * [Game specific notes](docs/game_specific_notes.md)
@@ -35,7 +32,7 @@ Check out these docs for more information.
### Usage ### Usage
1. Install [Java 21 Temurin JDK](https://adoptium.net/temurin/releases/?version=21) (Please select your appropriate operating system) 1. Install [Java 17 JDK](https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html)
2. Download the latest `aqua-nightly.zip` from [Releases](https://github.com/hykilpikonna/AquaDX/releases). 2. Download the latest `aqua-nightly.zip` from [Releases](https://github.com/hykilpikonna/AquaDX/releases).
3. Extract the zip file to a folder. 3. Extract the zip file to a folder.
4. Run `java -jar aqua.jar` in the folder. 4. Run `java -jar aqua.jar` in the folder.
@@ -49,7 +46,7 @@ Configuration is saved in `config/application.properties`, spring loads this fil
* 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. * 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. 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. * You can switch to the MariaDB (or MySQL) database by commenting the Sqlite part.
* For some games, you might need to change some game-specific config entries. * For some games, you might need to change some game-specific config entries.
### Building ### Building
@@ -65,9 +62,3 @@ The `build/libs` folder will contain a jar file.
* 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 devs who contribute to the [MiniMe server](https://dev.s-ul.net/djhackers/minime)
* All contributors by merge requests, issues and other channels * 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.

View File

@@ -1,20 +1,13 @@
import java.time.Instant import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.ZoneId
plugins { plugins {
val ktVer = "2.0.0-Beta5"
java java
kotlin("plugin.lombok") version ktVer kotlin("plugin.lombok") version "1.9.22"
kotlin("jvm") version ktVer kotlin("jvm") version "1.9.22"
kotlin("plugin.spring") version ktVer id("io.freefair.lombok") version "8.1.0"
kotlin("plugin.serialization") version ktVer id("org.springframework.boot") version "2.7.11"
id("io.freefair.lombok") version "8.6"
id("org.springframework.boot") version "3.2.3"
id("com.github.ben-manes.versions") version "0.51.0"
application
} }
apply(plugin = "io.spring.dependency-management") apply(plugin = "io.spring.dependency-management")
@@ -33,79 +26,32 @@ dependencies {
} }
implementation("org.springframework.boot:spring-boot-starter-jetty") implementation("org.springframework.boot:spring-boot-starter-jetty")
implementation("io.netty:netty-all") implementation("io.netty:netty-all")
implementation("org.apache.commons:commons-lang3:3.14.0") implementation("org.apache.commons:commons-lang3")
implementation("org.apache.httpcomponents.client5:httpclient5") implementation("org.apache.httpcomponents:httpclient")
implementation("org.flywaydb:flyway-core:10.10.0") implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-mysql:10.10.0") implementation("org.flywaydb:flyway-mysql")
testImplementation("org.springframework.boot:spring-boot-starter-test") { testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(group = "org.junit.vintage", module = "junit-vintage-engine") exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
} }
testImplementation("org.springframework.security:spring-security-test") testImplementation("org.springframework.security:spring-security-test")
implementation("net.logstash.logback:logstash-logback-encoder:7.4")
// Database // Database
runtimeOnly("org.mariadb.jdbc:mariadb-java-client:3.3.3") runtimeOnly("com.mysql:mysql-connector-j:8.3.0")
runtimeOnly("org.xerial:sqlite-jdbc:3.45.2.0") runtimeOnly("org.mariadb.jdbc:mariadb-java-client:3.3.2")
implementation("org.hibernate.orm:hibernate-core:6.4.4.Final") runtimeOnly("org.xerial:sqlite-jdbc:3.45.1.0")
implementation("org.hibernate.orm:hibernate-community-dialects:6.4.4.Final") implementation("com.github.gwenn:sqlite-dialect:0.1.4")
// JSR305 for nullable // JSR305 for nullable
implementation("com.google.code.findbugs:jsr305:3.0.2") implementation("com.google.code.findbugs:jsr305:3.0.2")
// ============================= // Others
// AquaNet Specific Dependencies implementation("commons-fileupload:commons-fileupload:1.5")
// =============================
// Network
implementation("io.ktor:ktor-client-core:2.3.8")
implementation("io.ktor:ktor-client-cio:2.3.8")
implementation("io.ktor:ktor-client-content-negotiation:2.3.8")
implementation("io.ktor:ktor-client-encoding:2.3.8")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.8")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// Somehow these are needed for ktor even though they're not in the documentation
runtimeOnly("org.reactivestreams:reactive-streams:1.0.4")
runtimeOnly("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.0")
// Email
implementation("org.simplejavamail:simple-java-mail:8.6.3")
implementation("org.simplejavamail:spring-module:8.6.3")
// GeoIP
implementation("com.maxmind.geoip2:geoip2:4.2.0")
// JWT Authentication
implementation("io.jsonwebtoken:jjwt-api:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5")
// Content validation
implementation("org.apache.tika:tika-core:2.9.1")
// Import: DateTime Parsing
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0")
// Serialization
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// Testing
testImplementation("io.kotest:kotest-runner-junit5-jvm:5.8.1")
testImplementation("io.kotest:kotest-assertions-core")
} }
group = "icu.samnya" group = "icu.samnya"
version = "1.0.0" version = "0.0.47"
description = "AquaDX Arcade Server" description = "Aqua Server"
java.sourceCompatibility = JavaVersion.VERSION_21 java.sourceCompatibility = JavaVersion.VERSION_17
kotlin {
jvmToolchain(21)
}
springBoot {
mainClass.set("icu.samnyan.aqua.EntryKt")
}
val buildTime: String by extra(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(ZoneId.of("UTC")).format(Instant.now())) val buildTime: String by extra(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(ZoneId.of("UTC")).format(Instant.now()))
@@ -116,19 +62,13 @@ tasks.processResources {
} }
tasks.test { tasks.test {
enabled = false
useJUnitPlatform() useJUnitPlatform()
jvmArgs("-Dkotest.assertions.collection.print.size=100")
} }
tasks.withType<JavaCompile> { tasks.withType<JavaCompile>() {
options.encoding = "UTF-8" options.encoding = "UTF-8"
} }
tasks.withType<Javadoc> { tasks.withType<Javadoc>() {
options.encoding = "UTF-8" options.encoding = "UTF-8"
} }
tasks.getByName<Jar>("jar") {
enabled = false
}

View File

@@ -11,20 +11,14 @@ billing.server.port=8443
## Server host & port return to client when boot up. ## Server host & port return to client when boot up.
## By default the same address and port from the client connection is returned. ## By default the same address and port from the client connection is returned.
## Please notice most games won't work with localhost or 127.0.0.1 ## Please notice most games won't work with localhost or 127.0.0.1
#allnet.server.host= #allnet.server.host=localhost
#allnet.server.port= #allnet.server.port=80
## This is for some games that use shop name for in-game functions. ## This is for some games that use shop name for in-game functions.
## Specify the place name here if you need it. By default it is empty. ## Specify the place name here if you need it. By default it is empty.
allnet.server.place-name=AquaDX #allnet.server.place-name=
## This enables client serial validation during power on request using keychip table in database. ## This enables client serial validation during power on request using keychip table in database.
## Only enable this if you know what you are doing. ## Only enable this if you know what you are doing.
allnet.server.check-keychip=false #allnet.server.check-keychip=false
## Interval between keychip session clean up checks in ms. Default is 1 day.
allnet.server.keychip-ses-clean-interval=86400000
## Token that haven't been used for this amount of time will be removed from the database. Default is 2 days.
allnet.server.keychip-ses-expire=172800000
## Redirect url when someone open this game server in a browser.
allnet.server.redirect=https://aquadx.net
## Http Server Port ## Http Server Port
## Only change this if you have a reverse proxy running. ## Only change this if you have a reverse proxy running.
@@ -60,16 +54,17 @@ game.ongeki.version=1.05.00
game.ongeki.rival.rivals-max-count=10 game.ongeki.rival.rivals-max-count=10
## Maimai DX ## Maimai DX
## Set this true if you are using old version of Splash network patch and have no other choice.
## This is a dirty workaround. If enabled, you probably won't able to play other versions.
game.maimai2.splash-old-patch=false
## Allow users take photo as their avatar/portrait photo. ## Allow users take photo as their avatar/portrait photo.
game.maimai2.userPhoto.enable=true game.maimai2.userPhoto.enable=true
## Specify folder path that user portrait photo and its (.json) data save to.
game.maimai2.userPhoto.picSavePath=data/userPhoto
## When uploading user portraits, limit the divMaxLength parameter. 1 divLength is about equal to the file size of 10kb. ## When uploading user portraits, limit the divMaxLength parameter. 1 divLength is about equal to the file size of 10kb.
## The default value is 32 (320kb), and the minimum value is 1 (10kb) ## The default value is 32 (320kb), and the minimum value is 1 (10kb)
game.maimai2.userPhoto.divMaxLength=32 game.maimai2.userPhoto.divMaxLength=32
## User upload saving paths
paths.mai2-plays=data/upload/mai2/plays
paths.mai2-portrait=data/upload/mai2/portrait
paths.aqua-net-portrait=data/upload/net/portrait
## Logging ## Logging
spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-file-size=10MB
@@ -80,53 +75,22 @@ spring.servlet.multipart.max-request-size=20MB
########## For Sqlite ########## ########## For Sqlite ##########
spring.datasource.driver-class-name=org.sqlite.JDBC spring.datasource.driver-class-name=org.sqlite.JDBC
spring.datasource.url=jdbc:sqlite:data/db.sqlite spring.datasource.url=jdbc:sqlite:data/db.sqlite
spring.jpa.properties.hibernate.dialect=org.sqlite.hibernate.dialect.SQLiteDialect
########## For MariaDB ########## ########## For MariaDB ##########
#spring.datasource.driver-class-name=org.mariadb.jdbc.Driver #spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
#spring.datasource.username= #spring.datasource.username=
#spring.datasource.password= #spring.datasource.password=
#spring.datasource.url=jdbc:mariadb://localhost:3306/insert_db_name_here?allowPublicKeyRetrieval=true&useSSL=false #spring.datasource.url=jdbc:mariadb://localhost:3306/insert_db_name_here?allowPublicKeyRetrieval=true&useSSL=false
#spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDB10Dialect
#spring.datasource.hikari.maximum-pool-size=10 #spring.datasource.hikari.maximum-pool-size=10
################################ ########## For MySQL ##########
## AquaNet Settings #spring.datasource.driver-class-name=com.mysql.jdbc.Driver
################################ #spring.datasource.username=
#spring.datasource.password=
#spring.datasource.url=jdbc:mysql://localhost:3306/insert_db_name_here?allowPublicKeyRetrieval=true&useSSL=false
#spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
#spring.datasource.hikari.maximum-pool-size=10
## Link card limit ## You can add any Spring Boot properties below
aqua-net.link-card-limit=10
## CloudFlare Turnstile Captcha
## This enables captcha for user registration.
aqua-net.turnstile.enable=false
aqua-net.turnstile.secret=1x0000000000000000000000000000000AA
## Email Settings
aqua-net.email.enable=false
aqua-net.email.senderName=AquaDX
aqua-net.email.senderAddr=you@example.com
aqua-net.email.webHost=aquadx.net
simplejavamail.javaxmail.debug=false
simplejavamail.smtp.host=smtp.production.host
simplejavamail.smtp.port=443
simplejavamail.transportstrategy=SMTPS
simplejavamail.smtp.username=<username>
simplejavamail.smtp.password=<password>
#simplejavamail.proxy.username=<proxy_username>
#simplejavamail.proxy.password=<proxy_password>
## GeoIP Settings (Powered by MaxMind GeoLite2)
aqua-net.geoip.path=data/GeoLite2-Country.mmdb
aqua-net.geoip.ip-header=CF-Connecting-IP
## JWT Authentication Settings
aqua-net.jwt.secret="Open Sesame!"
## Disable debug pages
server.error.whitelabel.enabled=false
## Adapter for Frontier (For testing only, please keep this disabled)
aqua-net.frontier.enabled=false
aqua-net.frontier.ftk=0x00
## OpenAI Settings (For content moderation)
aqua-net.openai.api-key=sk-1234567890abcdef1234567890abcdef

View File

@@ -4,31 +4,23 @@ services:
app: app:
build: . build: .
ports: ports:
- "80:80" - "8080:8080" # Replace with your application's port
- "8443:8443"
- "22345:22345"
environment: environment:
- SPRING_DATASOURCE_URL=jdbc:mariadb://db:3306/main - DB_HOST=db
- SPRING_DATASOURCE_USERNAME=cat - DB_PORT=3306
- SPRING_DATASOURCE_PASSWORD=meow - DB_USER=root
- SPRING_DATASOURCE_DRIVER_CLASS_NAME=org.mariadb.jdbc.Driver - DB_PASSWORD=mysecret
depends_on: depends_on:
- db - db
volumes:
- ./config:/app/config
- ./data:/app/data
db: db:
image: mariadb:latest image: mariadb:latest
environment: environment:
MYSQL_ROOT_PASSWORD: meow MYSQL_ROOT_PASSWORD: mysecret
MYSQL_DATABASE: main MYSQL_DATABASE: myappdb
MYSQL_USER: cat MYSQL_USER: myappuser
MYSQL_PASSWORD: meow MYSQL_PASSWORD: myapppassword
ports: ports:
- "127.0.0.1:3369:3306" - "3306:3306"
# There is an unfixed bug in Windows WSL2 that prevents volumes from working volumes:
# properly with mariadb. Please uncomment this on Linux / MacOS. - ./db:/var/lib/mysql
# Check https://stackoverflow.com/questions/76711550/i-get-an-error-on-mariadb-allways-i-start-my-app
# volumes:
# - ./data/db:/var/lib/mysql

View File

@@ -1,93 +0,0 @@
### CardController : /api/v2/card
Located at: [icu.samnyan.aqua.net.CardController](icu/samnyan/aqua/net/CardController.kt)
**/card/default-game** : Get the default game for the card.
* username: String
* **Returns**: Game ID
**/card/link** : Bind a card to the user. This action will migrate selected data from the card to the user's ghost card.
* token: String
* cardId: String
* migrate: String
* **Returns**: Success message
**/card/summary** : Get a summary of the card, including the user's name, rating, and last login date.
* cardId: String
* **Returns**: Summary of the card
**/card/unlink** : Unbind a card from the user. No data will be migrated during this action.
* token: String
* cardId: String
* **Returns**: Success message
### Frontier : /api/v2/frontier
Located at: [icu.samnyan.aqua.net.Frontier](icu/samnyan/aqua/net/Frontier.kt)
**/frontier/lookup-card** : Lookup a card by access code
* ftk: String
* accessCode: String
* **Returns**: Card information
**/frontier/register-card** : Register a new card by access code
* ftk: String
* accessCode: String
* **Returns**: Card information
### UserRegistrar : /api/v2/user
Located at: [icu.samnyan.aqua.net.UserRegistrar](icu/samnyan/aqua/net/UserRegistrar.kt)
**/user/confirm-email** : Confirm email address with a token sent through email to the user.
* token: String
* **Returns**: Success message
**/user/me** : Get the information of the current logged-in user.
* token: String
* **Returns**: User information
**/user/login** : Login with email/username and password. This will also check if the email is verified and send another confirmation
* email: String
* password: String
* turnstile: String
* **Returns**: JWT token
**/user/register** : Register a new user. This will also create a ghost card for the user and send a confirmation email.
* username: String
* email: String
* password: String
* turnstile: String
* **Returns**: Success message
**/user/setting** : Validate and set a user setting field.
* token: String
* key: String
* value: String
* **Returns**: Success message
**/user/keychip** : Get a Keychip ID so that the user can connect to the server.
* token: String
* **Returns**: Success message
**/user/upload-pfp** : Upload a profile picture for the user.
* token: String
* file: MultipartFile
* **Returns**: Success message

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ This document is for detailed game specific notes, if any.
|-------------------|---------|--------------------------|-------------------------|--------------------|----------------| |-------------------|---------|--------------------------|-------------------------|--------------------|----------------|
| Chunithm (Chusan) | SDHD | Sun | A152 | Yes | Yes | | Chunithm (Chusan) | SDHD | Sun | A152 | Yes | Yes |
| Chunithm | SDBT | Paradise Lost | A032 | Yes | Yes (Paradise) | | Chunithm | SDBT | Paradise Lost | A032 | Yes | Yes (Paradise) |
| Maimai DX | SDEZ | Buddies | H061 | Yes | Yes | | Maimai DX | SDEZ | Festival | F061 | Yes | Yes |
| O.N.G.E.K.I | SDDT | Bright memory | A108 | Yes | Yes | | O.N.G.E.K.I | SDDT | Bright memory | A108 | Yes | Yes |
| Card Maker | SDED | 1.34 | A030 | Yes | Yes | | Card Maker | SDED | 1.34 | A030 | Yes | Yes |
| Maimai | SDEY | Finale | ? | No | ? | | Maimai | SDEY | Finale | ? | No | ? |

View File

@@ -1,83 +0,0 @@
## Migrating MySQL to MariaDB
Since AquaDX 1.0.0, MySQL will no longer be supported.
If you were using Aqua / AquaDX <= 0.0.47 with a MySQL database before this upgrade, please follow the instructions below to migrate your data to MariaDB.
### 1. Run a MariaDB Server
To migrate, you first need to run a MariaDB server using your preferred method.
If you don't have a preference, we recommend running it using Docker Compose.
Below is an example `docker-compose.yml` configuration to run a MariaDB server.
```yaml
version: '3.1'
services:
mariadb:
image: mariadb
restart: always
environment:
MYSQL_ROOT_PASSWORD: example
MYSQL_DATABASE: aqua
MYSQL_USER: aqua
MYSQL_PASSWORD: aqua
ports:
- '127.0.0.1:3306:3306'
volumes:
- ./db:/var/lib/mysql
```
### 2. Export Data from MySQL
Run the following command to export your data from MySQL. Please fill in the fields in the curly braces with your MySQL database details.
To prevent charset encoding issues, please run the following command on Linux / macOS.
For some reason Windows is still not using UTF-8 by default in 2024.
```bash
mysqldump --user={username} --password={password} --host={host} --port={port} {database} > aqua.sql
```
Now `aqua.sql` should be created. Please open `aqua.sql` and check if `utf8mb4_0900_ai_ci` exists.
If it does, you're running a higher version of MySQL, please replace `utf8mb4_0900_ai_ci` with `utf8mb4_general_ci` in the `aqua.sql` file.
```bash
# A command to replace the strings as mentioned above (Linux / macOS only).
sed -i 's/utf8mb4_0900_ai_ci/utf8mb4_general_ci/g' aqua.sql
```
### 3. Import Data to MariaDB
Run the following command to import your data to MariaDB. Please fill in the fields in the curly braces with your MariaDB database details.
First, login to your MariaDB server.
```bash
mysql --user={username} --password={password} --host={host} --port={port} {database}
```
Then, import the data.
```sql
source aqua.sql;
```
Finally, we need to fix a flyway checksum. Aqua uses Flyway to manage database schema migrations. Most migrations were identical, but one migration used different case for MySQL and MariaDB, so we need to fix its checksum.
Run the following SQL query to fix the checksum.
```sql
UPDATE main.flyway_schema_history t
SET t.checksum = 357127209
WHERE t.installed_rank = 144;
```
### 5. Update AquaDX Configuration
Finally, update your AquaDX configuration `application.properties` to use MariaDB instead of MySQL.

View File

@@ -2,4 +2,4 @@
* This file was generated by the Gradle 'init' task. * This file was generated by the Gradle 'init' task.
*/ */
rootProject.name = "AquaDX" rootProject.name = "aqua"

View File

@@ -1,184 +1,7 @@
package ext package ext
import icu.samnyan.aqua.net.utils.ApiException
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.apache.tika.Tika
import org.apache.tika.mime.MimeTypes
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity.BodyBuilder import org.springframework.web.server.ResponseStatusException
import org.springframework.web.bind.annotation.*
import java.lang.reflect.Field
import java.nio.file.Path
import java.security.MessageDigest
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.*
import kotlin.reflect.KCallable
import kotlin.reflect.KClass
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.jvmErasure
typealias RP = RequestParam // Make it easier to throw a ResponseStatusException
typealias RB = RequestBody operator fun HttpStatus.invoke(message: String? = null): Nothing = throw ResponseStatusException(this, message ?: this.reasonPhrase)
typealias RH = RequestHeader
typealias PV = PathVariable
typealias API = RequestMapping
typealias Var<T, V> = KMutableProperty1<T, V>
typealias Str = String
typealias Bool = Boolean
typealias JavaSerializable = java.io.Serializable
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.PROPERTY_GETTER)
@Retention(AnnotationRetention.RUNTIME)
annotation class Doc(
val desc: String,
val ret: String = ""
)
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class SettingField
// Reflection
@Suppress("UNCHECKED_CAST")
fun <T : Any> KClass<T>.vars() = memberProperties.mapNotNull { it as? Var<T, Any> }
fun <T : Any> KClass<T>.varsMap() = vars().associateBy { it.name }
fun <T : Any> KClass<T>.getters() = java.methods.filter { it.name.startsWith("get") }
fun <T : Any> KClass<T>.gettersMap() = getters().associateBy { it.name.removePrefix("get").decapitalize() }
infix fun KCallable<*>.returns(type: KClass<*>) = returnType.jvmErasure.isSubclassOf(type)
@Suppress("UNCHECKED_CAST")
fun <C, T: Any> Var<C, T>.setCast(obj: C, value: String) = set(obj, when (returnType.classifier) {
String::class -> value
Int::class -> value.toInt()
Boolean::class -> value.toBoolean()
else -> 400 - "Invalid field type $returnType"
} as T)
inline fun <reified T: Any> Field.gets(obj: Any): T? = get(obj)?.let { it as T }
// HTTP
operator fun HttpStatus.invoke(message: String? = null): Nothing = throw ApiException(value(), message ?: this.reasonPhrase)
operator fun Int.minus(message: String): Nothing {
ApiException.log.info("> Error $this: $message")
throw ApiException(this, message)
}
fun <R> parsing(block: () -> R) = try { block() }
catch (e: ApiException) { throw e }
catch (e: Exception) { 400 - e.message.toString() }
fun BodyBuilder.headers(vararg pairs: Pair<String, String>) = headers(HttpHeaders().apply { pairs.forEach { (k, v) -> set(k, v) } })
// Email validation
// https://www.baeldung.com/java-email-validation-regex
val emailRegex = "^(?=.{1,64}@)[\\p{L}0-9_-]+(\\.[\\p{L}0-9_-]+)*@[^-][\\p{L}0-9-]+(\\.[\\p{L}0-9-]+)*(\\.[\\p{L}]{2,})$".toRegex()
fun Str.isValidEmail(): Bool = emailRegex.matches(this)
// Global Tools
val HTTP = HttpClient(CIO) {
install(ContentNegotiation) {
json(JSON)
}
}
val TIKA = Tika()
val MIMES = MimeTypes.getDefaultMimeTypes()
val MD5 = MessageDigest.getInstance("MD5")
// Class resource
object Ext { val log = logger() }
fun res(name: Str) = Ext::class.java.getResourceAsStream(name)
fun resStr(name: Str) = res(name)?.reader()?.readText()
inline fun <reified T> resJson(name: Str, warn: Boolean = true) = resStr(name)?.let {
JSON.decodeFromString<T>(it)
} ?: run { if (warn) Ext.log.warn("Resource $name is not found"); null }
// Date and time
fun millis() = System.currentTimeMillis()
val DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd")
fun LocalDate.isoDate() = format(DATE_FORMAT)
fun String.isoDate() = DATE_FORMAT.parse(this, LocalDate::from)
fun Date.utc() = toInstant().atZone(java.time.ZoneOffset.UTC).toLocalDate()
fun LocalDate.toDate() = Date(atStartOfDay().toInstant(java.time.ZoneOffset.UTC).toEpochMilli())
fun 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 ALT_DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
fun Str.asDateTime() = try { LocalDateTime.parse(this, DateTimeFormatter.ISO_LOCAL_DATE_TIME) }
catch (e: Exception) { try { LocalDateTime.parse(this, ALT_DATETIME_FORMAT) }
catch (e: Exception) { null } }
val Calendar.year get() = get(Calendar.YEAR)
val Calendar.month get() = get(Calendar.MONTH) + 1
val Calendar.day get() = get(Calendar.DAY_OF_MONTH)
fun cal() = Calendar.getInstance()
fun Date.cal() = Calendar.getInstance().apply { time = this@cal }
operator fun Calendar.invoke(field: Int) = get(field)
val Date.sec get() = time / 1000
// Encodings
fun Long.toHex(len: Int = 16): Str = "0x${this.toString(len).padStart(len, '0').uppercase()}"
fun Map<String, Any>.toUrl() = entries.joinToString("&") { (k, v) -> "$k=$v" }
fun Any.long() = when (this) {
is Boolean -> if (this) 1L else 0
is Number -> toLong()
is String -> toLong()
else -> 400 - "Invalid number: $this"
}
fun Any.int() = long().toInt()
operator fun Bool.unaryPlus() = if (this) 1 else 0
// Collections
fun <T> ls(vararg args: T) = args.toList()
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) }
@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>
// Optionals
operator fun <T> Optional<T>.invoke(): T? = orElse(null)
// Strings
operator fun Str.get(range: IntRange) = substring(range.first, (range.last + 1).coerceAtMost(length))
operator fun Str.get(start: Int, end: Int) = substring(start, end.coerceAtMost(length))
fun Str.center(width: Int, padChar: Char = ' ') = padStart((length + width) / 2, padChar).padEnd(width, padChar)
fun Str.splitLines() = replace("\r\n", "\n").split('\n')
@OptIn(ExperimentalStdlibApi::class)
fun Str.md5() = MD5.digest(toByteArray(Charsets.UTF_8)).toHexString()
// Coroutine
suspend fun <T> async(block: suspend kotlinx.coroutines.CoroutineScope.() -> T): T = withContext(Dispatchers.IO) { block() }
// Paths
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)
fun Str.ensureEndingSlash() = if (endsWith('/')) this else "$this/"
fun <T: Any> T.logger() = LoggerFactory.getLogger(this::class.java)
// I hate this ;-;
operator fun <E> List<E>.component6(): E = get(5)
operator fun <E> List<E>.component7(): E = get(6)
operator fun <E> List<E>.component8(): E = get(7)
operator fun <E> List<E>.component9(): E = get(8)
operator fun <E> List<E>.component10(): E = get(9)
operator fun <E> List<E>.component11(): E = get(10)
operator fun <E> List<E>.component12(): E = get(11)
operator fun <E> List<E>.component13(): E = get(12)

View File

@@ -1,74 +0,0 @@
package ext
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.*
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
// Jackson
val ACCEPTABLE_FALSE = setOf("0", "false", "no", "off", "False", "None", "null")
val ACCEPTABLE_TRUE = setOf("1", "true", "yes", "on", "True")
val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, object : JsonDeserializer<Boolean>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) = when(parser.text) {
in ACCEPTABLE_FALSE -> false
in ACCEPTABLE_TRUE -> true
else -> 400 - "Invalid boolean value ${parser.text}"
}
})
val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer<java.time.LocalDateTime>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) =
parser.text.asDateTime() ?: (400 - "Invalid date time value ${parser.text}")
})
val JACKSON = jacksonObjectMapper().apply {
setSerializationInclusion(JsonInclude.Include.NON_NULL)
setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL)
findAndRegisterModules()
registerModule(JSON_FUZZY_BOOLEAN)
registerModule(JSON_DATETIME)
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
setPropertyNamingStrategy(PropertyNamingStrategy.LOWER_CAMEL_CASE);
}
inline fun <reified T> ObjectMapper.parse(str: Str) = readValue(str, T::class.java)
inline fun <reified T> ObjectMapper.parse(map: Map<*, *>) = convertValue(map, T::class.java)
// TODO: https://stackoverflow.com/q/78197784/7346633
fun <T> Str.parseJackson(cls: Class<T>) = if (contains("null")) {
val map = JACKSON.parse<MutableMap<String, Any>>(this)
JACKSON.convertValue(map.recursiveNotNull(), cls)
}
else JACKSON.readValue(this, cls)
fun <T> T.toJson() = JACKSON.writeValueAsString(this)
inline fun <reified T> String.json() = try {
JACKSON.readValue(this, T::class.java)
}
catch (e: Exception) {
println("Failed to parse JSON: $this")
throw e
}
fun String.jsonMap(): Map<String, Any?> = json()
fun String.jsonArray(): List<Map<String, Any?>> = json()
// KotlinX Serialization
@OptIn(ExperimentalSerializationApi::class)
val JSON = Json {
ignoreUnknownKeys = true
isLenient = true
namingStrategy = JsonNamingStrategy.SnakeCase
explicitNulls = false
coerceInputValues = true
}
// Bean for default jackson object mapper
//@Configuration
//class JacksonConfig {
// @Bean
// fun objectMapper(): ObjectMapper {
// return JACKSON
// }
//}

View File

@@ -0,0 +1,22 @@
package icu.samnyan.aqua;
import icu.samnyan.aqua.sega.aimedb.AimeDbServer;
import icu.samnyan.aqua.spring.util.AutoChecker;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
public class AquaServerApplication {
public static void main(String[] args) throws Exception {
ConfigurableApplicationContext ctx = SpringApplication.run(AquaServerApplication.class, args);
final AimeDbServer aimeDbServer = ctx.getBean(AimeDbServer.class);
aimeDbServer.start();
final AutoChecker checker = ctx.getBean(AutoChecker.class);
checker.check();
}
}

View File

@@ -1,29 +0,0 @@
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.autoconfigure.SpringBootApplication
import org.springframework.scheduling.annotation.EnableScheduling
import java.io.File
@SpringBootApplication
@EnableScheduling
class Entry
fun main(args: Array<String>) {
// If data/ is not found, create it
File("data").mkdirs()
// Run the application
val ctx = SpringApplication.run(Entry::class.java, *args)
// Start the AimeDbServer
val aimeDbServer = ctx.getBean(AimeDbServer::class.java)
aimeDbServer.start()
// Start the AutoChecker
val checker = ctx.getBean(AutoChecker::class.java)
checker.check()
}

View File

@@ -1,5 +1,7 @@
package icu.samnyan.aqua.api.config; package icu.samnyan.aqua.api.config;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.FileSystemResource;
@@ -8,8 +10,6 @@ import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver; import org.springframework.web.servlet.resource.PathResourceResolver;
import java.io.IOException;
@Configuration @Configuration
public class WebConfig implements WebMvcConfigurer { public class WebConfig implements WebMvcConfigurer {

View File

@@ -2,7 +2,6 @@ package icu.samnyan.aqua.api.controller.general;
import icu.samnyan.aqua.sega.diva.dao.userdata.PlayerScreenShotRepository; import icu.samnyan.aqua.sega.diva.dao.userdata.PlayerScreenShotRepository;
import icu.samnyan.aqua.sega.diva.model.userdata.PlayerScreenShot; import icu.samnyan.aqua.sega.diva.model.userdata.PlayerScreenShot;
import lombok.AllArgsConstructor;
import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -20,10 +19,14 @@ import java.util.Optional;
*/ */
@RestController @RestController
@RequestMapping("api/static") @RequestMapping("api/static")
@AllArgsConstructor
public class StaticController { public class StaticController {
private final PlayerScreenShotRepository playerScreenShotRepository; private final PlayerScreenShotRepository playerScreenShotRepository;
public StaticController(PlayerScreenShotRepository playerScreenShotRepository) {
this.playerScreenShotRepository = playerScreenShotRepository;
}
@GetMapping(value = "screenshot/{filename}", produces = MediaType.IMAGE_JPEG_VALUE) @GetMapping(value = "screenshot/{filename}", produces = MediaType.IMAGE_JPEG_VALUE)
public ResponseEntity<Resource> getScreenshotFile(@PathVariable String filename) { public ResponseEntity<Resource> getScreenshotFile(@PathVariable String filename) {
Optional<PlayerScreenShot> ss = playerScreenShotRepository.findByFileName(filename); Optional<PlayerScreenShot> ss = playerScreenShotRepository.findByFileName(filename);

View File

@@ -2,7 +2,6 @@ package icu.samnyan.aqua.api.controller.sega;
import icu.samnyan.aqua.sega.general.model.Card; import icu.samnyan.aqua.sega.general.model.Card;
import icu.samnyan.aqua.sega.general.service.CardService; import icu.samnyan.aqua.sega.general.service.CardService;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -17,11 +16,14 @@ import java.util.Optional;
*/ */
@RestController @RestController
@RequestMapping("api/sega/aime") @RequestMapping("api/sega/aime")
@AllArgsConstructor
public class ApiAimeController { public class ApiAimeController {
private final CardService cardService; private final CardService cardService;
public ApiAimeController(CardService cardService) {
this.cardService = cardService;
}
@PostMapping("getByAccessCode") @PostMapping("getByAccessCode")
public Optional<Card> getByAccessCode(@RequestBody Map<String, String> request) { public Optional<Card> getByAccessCode(@RequestBody Map<String, String> request) {
return cardService.getCardByAccessCode(request.get("accessCode").replaceAll("-", "").replaceAll(" ", "")); return cardService.getCardByAccessCode(request.get("accessCode").replaceAll("-", "").replaceAll(" ", ""));

View File

@@ -6,10 +6,7 @@ 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.CharacterSkill;
import icu.samnyan.aqua.sega.chunithm.model.gamedata.Music; import icu.samnyan.aqua.sega.chunithm.model.gamedata.Music;
import icu.samnyan.aqua.sega.chunithm.service.GameMusicService; import icu.samnyan.aqua.sega.chunithm.service.GameMusicService;
import lombok.AllArgsConstructor; import org.springframework.web.bind.annotation.*;
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; import java.util.List;
@@ -18,13 +15,18 @@ import java.util.List;
*/ */
@RestController @RestController
@RequestMapping("api/game/chuni/v1/data") @RequestMapping("api/game/chuni/v1/data")
@AllArgsConstructor
public class ApiChuniV1GameDataController { public class ApiChuniV1GameDataController {
private final GameMusicService gameMusicService; private final GameMusicService gameMusicService;
private final GameCharacterRepository gameCharacterRepository; private final GameCharacterRepository gameCharacterRepository;
private final GameCharacterSkillRepository gameCharacterSkillRepository; private final GameCharacterSkillRepository gameCharacterSkillRepository;
public ApiChuniV1GameDataController(GameMusicService gameMusicService, GameCharacterRepository gameCharacterRepository, GameCharacterSkillRepository gameCharacterSkillRepository) {
this.gameMusicService = gameMusicService;
this.gameCharacterRepository = gameCharacterRepository;
this.gameCharacterSkillRepository = gameCharacterSkillRepository;
}
@GetMapping("music") @GetMapping("music")
public List<Music> getMusic() { public List<Music> getMusic() {
return gameMusicService.getAll(); return gameMusicService.getAll();
@@ -39,4 +41,15 @@ public class ApiChuniV1GameDataController {
public List<CharacterSkill> getSkill() { public List<CharacterSkill> getSkill() {
return gameCharacterSkillRepository.findAll(); return gameCharacterSkillRepository.findAll();
} }
// @PostMapping("character")
// public List<Character> importCharacter(@RequestBody List<Character> req) {
// return gameCharacterRepository.saveAll(req);
// }
//
// @PostMapping("skill")
// public List<CharacterSkill> importSkill(@RequestBody List<CharacterSkill> req) {
// return gameCharacterSkillRepository.saveAll(req);
// }
} }

View File

@@ -18,7 +18,6 @@ import icu.samnyan.aqua.sega.general.model.Card;
import icu.samnyan.aqua.sega.general.service.CardService; import icu.samnyan.aqua.sega.general.service.CardService;
import icu.samnyan.aqua.sega.util.VersionInfo; import icu.samnyan.aqua.sega.util.VersionInfo;
import icu.samnyan.aqua.sega.util.VersionUtil; import icu.samnyan.aqua.sega.util.VersionUtil;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -40,7 +39,6 @@ import java.util.stream.Collectors;
*/ */
@RestController @RestController
@RequestMapping("api/game/chuni/v1") @RequestMapping("api/game/chuni/v1")
@AllArgsConstructor
public class ApiChuniV1PlayerDataController { public class ApiChuniV1PlayerDataController {
private static final Logger logger = LoggerFactory.getLogger(ApiChuniV1PlayerDataController.class); private static final Logger logger = LoggerFactory.getLogger(ApiChuniV1PlayerDataController.class);
@@ -66,6 +64,28 @@ public class ApiChuniV1PlayerDataController {
private final GameMusicService gameMusicService; private final GameMusicService gameMusicService;
@Autowired
public ApiChuniV1PlayerDataController(ApiMapper mapper, CardService cardService, UserActivityService userActivityService, UserCharacterService userCharacterService, UserChargeService userChargeService, UserDataService userDataService, UserDataExService userDataExService, UserGameOptionExService userGameOptionExService, UserMapService userMapService, UserPlaylogService userPlaylogService, UserMusicDetailService userMusicDetailService, UserCourseService userCourseService, UserDuelService userDuelService, UserGameOptionService userGameOptionService, UserItemService userItemService, UserGeneralDataService userGeneralDataService, GameMusicService gameMusicService) {
this.mapper = mapper;
this.cardService = cardService;
this.userActivityService = userActivityService;
this.userCharacterService = userCharacterService;
this.userChargeService = userChargeService;
this.userDataService = userDataService;
this.userDataExService = userDataExService;
this.userGameOptionExService = userGameOptionExService;
this.userMapService = userMapService;
this.userPlaylogService = userPlaylogService;
this.userMusicDetailService = userMusicDetailService;
this.userCourseService = userCourseService;
this.userDuelService = userDuelService;
this.userGameOptionService = userGameOptionService;
this.userItemService = userItemService;
this.userGeneralDataService = userGeneralDataService;
this.gameMusicService = gameMusicService;
}
// Keep it here for legacy // Keep it here for legacy
@GetMapping("music") @GetMapping("music")
public List<Music> getMusicList() { public List<Music> getMusicList() {

View File

@@ -1,12 +1,22 @@
package icu.samnyan.aqua.api.controller.sega.game.chuni.v2; package icu.samnyan.aqua.api.controller.sega.game.chuni.v2;
import icu.samnyan.aqua.sega.chusan.dao.gamedata.GameAvatarAccRepository;
import icu.samnyan.aqua.sega.chusan.model.*; import icu.samnyan.aqua.sega.chusan.dao.gamedata.GameCharacterRepository;
import icu.samnyan.aqua.sega.chusan.dao.gamedata.GameFrameRepository;
import icu.samnyan.aqua.sega.chusan.dao.gamedata.GameMapIconRepository;
import icu.samnyan.aqua.sega.chusan.dao.gamedata.GameMusicRepository;
import icu.samnyan.aqua.sega.chusan.dao.gamedata.GameTrophyRepository;
import icu.samnyan.aqua.sega.chusan.dao.gamedata.GameNamePlateRepository;
import icu.samnyan.aqua.sega.chusan.dao.gamedata.GameSystemVoiceRepository;
import icu.samnyan.aqua.sega.chusan.model.gamedata.AvatarAcc;
import icu.samnyan.aqua.sega.chusan.model.gamedata.Character; import icu.samnyan.aqua.sega.chusan.model.gamedata.Character;
import icu.samnyan.aqua.sega.chusan.model.gamedata.*; import icu.samnyan.aqua.sega.chusan.model.gamedata.Frame;
import lombok.AllArgsConstructor; import icu.samnyan.aqua.sega.chusan.model.gamedata.MapIcon;
import org.springframework.web.bind.annotation.GetMapping; import icu.samnyan.aqua.sega.chusan.model.gamedata.Music;
import org.springframework.web.bind.annotation.RequestMapping; import icu.samnyan.aqua.sega.chusan.model.gamedata.NamePlate;
import org.springframework.web.bind.annotation.RestController; import icu.samnyan.aqua.sega.chusan.model.gamedata.SystemVoice;
import icu.samnyan.aqua.sega.chusan.model.gamedata.Trophy;
import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@@ -15,17 +25,27 @@ import java.util.List;
*/ */
@RestController @RestController
@RequestMapping("api/game/chuni/v2/data") @RequestMapping("api/game/chuni/v2/data")
@AllArgsConstructor
public class ApiChuniV2GameDataController { public class ApiChuniV2GameDataController {
private final Chu3GameMusicRepo gameMusicRepository; private final GameMusicRepository gameMusicRepository;
private final Chu3GameCharacterRepo gameCharacterRepository; private final GameCharacterRepository gameCharacterRepository;
private final Chu3GameTrophyRepo gameTrophyRepository; private final GameTrophyRepository gameTrophyRepository;
private final Chu3GameNamePlateRepo gameNamePlateRepository; private final GameNamePlateRepository gameNamePlateRepository;
private final Chu3GameSystemVoiceRepo gameSystemVoiceRepository; private final GameSystemVoiceRepository gameSystemVoiceRepository;
private final Chu3GameMapIconRepo gameMapIconRepository; private final GameMapIconRepository gameMapIconRepository;
private final Chu3GameFrameRepo gameFrameRepository; private final GameFrameRepository gameFrameRepository;
private final Chu3GameAvatarAccRepo gameAvatarAccRepository; private final GameAvatarAccRepository gameAvatarAccRepository;
public ApiChuniV2GameDataController(GameMusicRepository gameMusicRepository, GameCharacterRepository gameCharacterRepository, GameTrophyRepository gameTrophyRepository, GameNamePlateRepository gameNamePlateRepository, GameSystemVoiceRepository gameSystemVoiceRepository, GameMapIconRepository gameMapIconRepository, GameFrameRepository gameFrameRepository, GameAvatarAccRepository gameAvatarAccRepository) {
this.gameMusicRepository = gameMusicRepository;
this.gameCharacterRepository = gameCharacterRepository;
this.gameTrophyRepository = gameTrophyRepository;
this.gameNamePlateRepository = gameNamePlateRepository;
this.gameSystemVoiceRepository = gameSystemVoiceRepository;
this.gameMapIconRepository = gameMapIconRepository;
this.gameFrameRepository = gameFrameRepository;
this.gameAvatarAccRepository = gameAvatarAccRepository;
}
@GetMapping("music") @GetMapping("music")
public List<Music> getMusic() { public List<Music> getMusic() {

View File

@@ -6,10 +6,11 @@ import icu.samnyan.aqua.api.model.ReducedPageResponse;
import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.ProfileResp; import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.ProfileResp;
import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.RatingItem; import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.RatingItem;
import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.RecentResp; 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.model.resp.sega.chuni.v2.external.ChuniDataExport;
import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.external.ChuniDataImport; import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.external.ChuniDataImport;
import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.external.ExternalUserData; import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.external.ExternalUserData;
import icu.samnyan.aqua.api.util.ApiMapper; import icu.samnyan.aqua.api.util.ApiMapper;
import icu.samnyan.aqua.sega.chunithm.handler.impl.GetUserFavoriteMusicHandler;
import icu.samnyan.aqua.sega.chusan.model.gamedata.Level; import icu.samnyan.aqua.sega.chusan.model.gamedata.Level;
import icu.samnyan.aqua.sega.chusan.model.gamedata.Music; import icu.samnyan.aqua.sega.chusan.model.gamedata.Music;
import icu.samnyan.aqua.sega.chusan.model.userdata.*; import icu.samnyan.aqua.sega.chusan.model.userdata.*;
@@ -18,10 +19,10 @@ import icu.samnyan.aqua.sega.general.model.Card;
import icu.samnyan.aqua.sega.general.service.CardService; import icu.samnyan.aqua.sega.general.service.CardService;
import icu.samnyan.aqua.sega.util.VersionInfo; import icu.samnyan.aqua.sega.util.VersionInfo;
import icu.samnyan.aqua.sega.util.VersionUtil; import icu.samnyan.aqua.sega.util.VersionUtil;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -39,7 +40,6 @@ import java.util.stream.Collectors;
*/ */
@RestController @RestController
@RequestMapping("api/game/chuni/v2") @RequestMapping("api/game/chuni/v2")
@AllArgsConstructor
public class ApiChuniV2PlayerDataController { public class ApiChuniV2PlayerDataController {
private static final Logger logger = LoggerFactory.getLogger(ApiChuniV2PlayerDataController.class); private static final Logger logger = LoggerFactory.getLogger(ApiChuniV2PlayerDataController.class);
@@ -62,6 +62,33 @@ public class ApiChuniV2PlayerDataController {
private final UserGeneralDataService userGeneralDataService; private final UserGeneralDataService userGeneralDataService;
private final GameMusicService gameMusicService; private final GameMusicService gameMusicService;
@Autowired
public ApiChuniV2PlayerDataController(ApiMapper mapper, CardService cardService, UserActivityService userActivityService, UserCharacterService userCharacterService, UserChargeService userChargeService, UserDataService userDataService, UserMapAreaService userMapAreaService, UserPlaylogService userPlaylogService, UserMusicDetailService userMusicDetailService, UserCourseService userCourseService, UserDuelService userDuelService, UserGameOptionService userGameOptionService, UserItemService userItemService, UserGeneralDataService userGeneralDataService, GameMusicService gameMusicService) {
this.mapper = mapper;
this.cardService = cardService;
this.userActivityService = userActivityService;
this.userCharacterService = userCharacterService;
this.userChargeService = userChargeService;
this.userDataService = userDataService;
this.userMapAreaService = userMapAreaService;
this.userPlaylogService = userPlaylogService;
this.userMusicDetailService = userMusicDetailService;
this.userCourseService = userCourseService;
this.userDuelService = userDuelService;
this.userGameOptionService = userGameOptionService;
this.userItemService = userItemService;
this.userGeneralDataService = userGeneralDataService;
this.gameMusicService = gameMusicService;
}
/*
// Keep it here for legacy
@GetMapping("music")
public List<Music> getMusicList() {
return gameMusicService.getAll();
}
*/
/** /**
* Get Basic info * Get Basic info
* *
@@ -81,64 +108,64 @@ public class ApiChuniV2PlayerDataController {
} }
@PutMapping("profile/username") @PutMapping("profile/username")
public Chu3UserData updateName(@RequestBody Map<String, Object> request) { public UserData updateName(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow(); UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setUserName((String) request.get("userName")); profile.setUserName((String) request.get("userName"));
return userDataService.saveUserData(profile); return userDataService.saveUserData(profile);
} }
@PutMapping("profile/romversion") @PutMapping("profile/romversion")
public Chu3UserData updateRomVersion(@RequestBody Map<String, Object> request) { public UserData updateRomVersion(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow(); UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setLastRomVersion((String) request.get("romVersion")); profile.setLastRomVersion((String) request.get("romVersion"));
return userDataService.saveUserData(profile); return userDataService.saveUserData(profile);
} }
@PutMapping("profile/dataversion") @PutMapping("profile/dataversion")
public Chu3UserData updateDataVersion(@RequestBody Map<String, Object> request) { public UserData updateDataVersion(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow(); UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setLastDataVersion((String) request.get("dataVersion")); profile.setLastDataVersion((String) request.get("dataVersion"));
return userDataService.saveUserData(profile); return userDataService.saveUserData(profile);
} }
@PutMapping("profile/plate") @PutMapping("profile/plate")
public Chu3UserData updatePlate(@RequestBody Map<String, Object> request) { public UserData updatePlate(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow(); UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setNameplateId((Integer) request.get("nameplateId")); profile.setNameplateId((Integer) request.get("nameplateId"));
return userDataService.saveUserData(profile); return userDataService.saveUserData(profile);
} }
@PutMapping("profile/frame") @PutMapping("profile/frame")
public Chu3UserData updateFrame(@RequestBody Map<String, Object> request) { public UserData updateFrame(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow(); UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setFrameId((Integer) request.get("frameId")); profile.setFrameId((Integer) request.get("frameId"));
return userDataService.saveUserData(profile); return userDataService.saveUserData(profile);
} }
@PutMapping("profile/trophy") @PutMapping("profile/trophy")
public Chu3UserData updateTrophy(@RequestBody Map<String, Object> request) { public UserData updateTrophy(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow(); UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setTrophyId((Integer) request.get("trophyId")); profile.setTrophyId((Integer) request.get("trophyId"));
return userDataService.saveUserData(profile); return userDataService.saveUserData(profile);
} }
@PutMapping("profile/mapicon") @PutMapping("profile/mapicon")
public Chu3UserData updateMapIcon(@RequestBody Map<String, Object> request) { public UserData updateMapIcon(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow(); UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setMapIconId((Integer) request.get("mapiconId")); profile.setMapIconId((Integer) request.get("mapiconId"));
return userDataService.saveUserData(profile); return userDataService.saveUserData(profile);
} }
@PutMapping("profile/sysvoice") @PutMapping("profile/sysvoice")
public Chu3UserData updateSystemVoice(@RequestBody Map<String, Object> request) { public UserData updateSystemVoice(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow(); UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
profile.setVoiceId((Integer) request.get("voiceId")); profile.setVoiceId((Integer) request.get("voiceId"));
return userDataService.saveUserData(profile); return userDataService.saveUserData(profile);
} }
@PutMapping("profile/avatar") @PutMapping("profile/avatar")
public Chu3UserData updateAvatar(@RequestBody Map<String, Object> request) { public UserData updateAvatar(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow(); UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
int category = (Integer) request.get("category"); int category = (Integer) request.get("category");
switch (category) { switch (category) {
case 1: case 1:
@@ -169,7 +196,7 @@ public class ApiChuniV2PlayerDataController {
@PutMapping("profile/privacy") @PutMapping("profile/privacy")
public ResponseEntity<Object> updatePrivacy(@RequestBody Map<String, Object> request) { public ResponseEntity<Object> updatePrivacy(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow(); UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
UserGameOption option = userGameOptionService.getByUser(profile).orElseThrow(); UserGameOption option = userGameOptionService.getByUser(profile).orElseThrow();
int privacy = (Integer) request.get("privacy"); int privacy = (Integer) request.get("privacy");
if (privacy != 1 && privacy != 0) { if (privacy != 1 && privacy != 0) {
@@ -300,7 +327,7 @@ public class ApiChuniV2PlayerDataController {
@PutMapping("song/{id}/favorite") @PutMapping("song/{id}/favorite")
public void updateSongFavorite(@RequestParam String aimeId, @PathVariable String id) { public void updateSongFavorite(@RequestParam String aimeId, @PathVariable String id) {
Chu3UserData profile = userDataService.getUserByExtId(aimeId).orElseThrow(); UserData profile = userDataService.getUserByExtId(aimeId).orElseThrow();
UserGeneralData userGeneralData = userGeneralDataService.getByUserAndKey(profile, "favorite_music") UserGeneralData userGeneralData = userGeneralDataService.getByUserAndKey(profile, "favorite_music")
.orElseGet(() -> new UserGeneralData(profile, "favorite_music")); .orElseGet(() -> new UserGeneralData(profile, "favorite_music"));
List<String> favoriteSongs = new LinkedList<String>(Arrays.asList(userGeneralData.getPropertyValue().split(","))); List<String> favoriteSongs = new LinkedList<String>(Arrays.asList(userGeneralData.getPropertyValue().split(",")));
@@ -329,7 +356,7 @@ public class ApiChuniV2PlayerDataController {
@PostMapping("character") @PostMapping("character")
public ResponseEntity<Object> updateCharacter(@RequestBody Map<String, Object> request) { public ResponseEntity<Object> updateCharacter(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow(); UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
Integer characterId = (Integer) request.get("characterId"); Integer characterId = (Integer) request.get("characterId");
Optional<UserCharacter> characterOptional = userCharacterService.getByUserAndCharacterId(profile, characterId); Optional<UserCharacter> characterOptional = userCharacterService.getByUserAndCharacterId(profile, characterId);
UserCharacter character; UserCharacter character;
@@ -371,7 +398,7 @@ public class ApiChuniV2PlayerDataController {
@PostMapping("item") @PostMapping("item")
public ResponseEntity<Object> updateItem(@RequestBody Map<String, Object> request) { public ResponseEntity<Object> updateItem(@RequestBody Map<String, Object> request) {
Chu3UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow(); UserData profile = userDataService.getUserByExtId((String) request.get("aimeId")).orElseThrow();
Integer itemId = (Integer) request.get("itemId"); Integer itemId = (Integer) request.get("itemId");
Integer itemKind = (Integer) request.get("itemKind"); Integer itemKind = (Integer) request.get("itemKind");
Optional<UserItem> itemOptional = userItemService.getByUserAndItemIdAndKind(profile, itemId,itemKind); Optional<UserItem> itemOptional = userItemService.getByUserAndItemIdAndKind(profile, itemId,itemKind);
@@ -398,7 +425,7 @@ public class ApiChuniV2PlayerDataController {
@GetMapping("export") @GetMapping("export")
public ResponseEntity<Object> exportAllUserData(@RequestParam String aimeId) { public ResponseEntity<Object> exportAllUserData(@RequestParam String aimeId) {
Chu3DataExport data = new Chu3DataExport(); ChuniDataExport data = new ChuniDataExport();
try { try {
data.setGameId("SDHD"); data.setGameId("SDHD");
data.setUserData(userDataService.getUserByExtId(aimeId).orElseThrow()); data.setUserData(userDataService.getUserByExtId(aimeId).orElseThrow());
@@ -446,7 +473,7 @@ public class ApiChuniV2PlayerDataController {
card = cardService.registerByAccessCode(exUser.getAccessCode()); card = cardService.registerByAccessCode(exUser.getAccessCode());
} }
Chu3UserData userData = mapper.convert(exUser, new TypeReference<>() { UserData userData = mapper.convert(exUser, new TypeReference<>() {
}); });
userData.setCard(card); userData.setCard(card);
userDataService.saveAndFlushUserData(userData); userDataService.saveAndFlushUserData(userData);
@@ -479,7 +506,7 @@ public class ApiChuniV2PlayerDataController {
userItemList.forEach(x -> x.setUser(userData)); userItemList.forEach(x -> x.setUser(userData));
userItemService.saveAll(userItemList); userItemService.saveAll(userItemList);
List<UserMap> userMapList = data.getUserMapList(); List<UserMapArea> userMapList = data.getUserMapList();
userMapList.forEach(x -> x.setUser(userData)); userMapList.forEach(x -> x.setUser(userData));
userMapAreaService.saveAll(userMapList); userMapAreaService.saveAll(userMapList);

View File

@@ -6,11 +6,15 @@ 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.DivaCustomize;
import icu.samnyan.aqua.sega.diva.model.gamedata.DivaModule; import icu.samnyan.aqua.sega.diva.model.gamedata.DivaModule;
import icu.samnyan.aqua.sega.diva.model.gamedata.Pv; import icu.samnyan.aqua.sega.diva.model.gamedata.Pv;
import lombok.AllArgsConstructor; import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.nio.file.Paths;
import java.util.List; import java.util.List;
/** /**
@@ -18,13 +22,18 @@ import java.util.List;
*/ */
@RestController @RestController
@RequestMapping("api/game/diva/data") @RequestMapping("api/game/diva/data")
@AllArgsConstructor
public class ApiDivaGameDataController { public class ApiDivaGameDataController {
private final DivaModuleRepository divaModuleRepository; private final DivaModuleRepository divaModuleRepository;
private final DivaCustomizeRepository divaCustomizeRepository; private final DivaCustomizeRepository divaCustomizeRepository;
private final DivaPvRepository divaPvRepository; private final DivaPvRepository divaPvRepository;
public ApiDivaGameDataController(DivaModuleRepository divaModuleRepository, DivaCustomizeRepository divaCustomizeRepository, DivaPvRepository divaPvRepository) {
this.divaModuleRepository = divaModuleRepository;
this.divaCustomizeRepository = divaCustomizeRepository;
this.divaPvRepository = divaPvRepository;
}
@GetMapping(value = "musicList") @GetMapping(value = "musicList")
public List<Pv> musicList() { public List<Pv> musicList() {
return divaPvRepository.findAll(); return divaPvRepository.findAll();

View File

@@ -8,7 +8,6 @@ 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.common.Edition;
import icu.samnyan.aqua.sega.diva.model.userdata.*; import icu.samnyan.aqua.sega.diva.model.userdata.*;
import icu.samnyan.aqua.sega.diva.service.PlayerProfileService; import icu.samnyan.aqua.sega.diva.service.PlayerProfileService;
import lombok.AllArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -22,7 +21,6 @@ import java.util.*;
*/ */
@RestController @RestController
@RequestMapping("api/game/diva") @RequestMapping("api/game/diva")
@AllArgsConstructor
public class ApiDivaPlayerDataController { public class ApiDivaPlayerDataController {
private final PlayerProfileService playerProfileService; private final PlayerProfileService playerProfileService;
@@ -35,8 +33,19 @@ public class ApiDivaPlayerDataController {
private final PlayerCustomizeRepository playerCustomizeRepository; private final PlayerCustomizeRepository playerCustomizeRepository;
private final PlayerScreenShotRepository playerScreenShotRepository; private final PlayerScreenShotRepository playerScreenShotRepository;
public ApiDivaPlayerDataController(PlayerProfileService playerProfileService, GameSessionRepository gameSessionRepository, PlayLogRepository playLogRepository, PlayerPvRecordRepository playerPvRecordRepository, PlayerPvCustomizeRepository playerPvCustomizeRepository, PlayerModuleRepository playerModuleRepository, PlayerCustomizeRepository playerCustomizeRepository, PlayerScreenShotRepository playerScreenShotRepository) {
this.playerProfileService = playerProfileService;
this.gameSessionRepository = gameSessionRepository;
this.playLogRepository = playLogRepository;
this.playerPvRecordRepository = playerPvRecordRepository;
this.playerPvCustomizeRepository = playerPvCustomizeRepository;
this.playerModuleRepository = playerModuleRepository;
this.playerCustomizeRepository = playerCustomizeRepository;
this.playerScreenShotRepository = playerScreenShotRepository;
}
@PostMapping("forceUnlock") @PostMapping("forceUnlock")
public ResponseEntity<MessageResponse> forceUnlock(@RequestParam long pdId) { public ResponseEntity<MessageResponse> forceUnlock(@RequestParam int pdId) {
PlayerProfile profile = playerProfileService.findByPdId(pdId).orElseThrow(); PlayerProfile profile = playerProfileService.findByPdId(pdId).orElseThrow();
Optional<GameSession> session = gameSessionRepository.findByPdId(profile); Optional<GameSession> session = gameSessionRepository.findByPdId(profile);
if(session.isPresent()) { if(session.isPresent()) {
@@ -48,13 +57,13 @@ public class ApiDivaPlayerDataController {
} }
@GetMapping("playerInfo") @GetMapping("playerInfo")
public Optional<PlayerProfile> getPlayerInfo(@RequestParam long pdId) { public Optional<PlayerProfile> getPlayerInfo(@RequestParam int pdId) {
return playerProfileService.findByPdId(pdId); return playerProfileService.findByPdId(pdId);
} }
@GetMapping("playerInfo/rival") @GetMapping("playerInfo/rival")
public Map<String, String> getRivalInfo(@RequestParam long pdId) { public Map<String, String> getRivalInfo(@RequestParam int pdId) {
var rId = playerProfileService.findByPdId(pdId).orElseThrow().getRivalPdId(); int rId = playerProfileService.findByPdId(pdId).orElseThrow().getRivalPdId();
Map<String, String> result = new HashMap<>(); Map<String, String> result = new HashMap<>();
if (rId == -1) { if (rId == -1) {
result.put("rival", "Not Set"); result.put("rival", "Not Set");
@@ -167,7 +176,7 @@ public class ApiDivaPlayerDataController {
} }
@GetMapping("playLog") @GetMapping("playLog")
public ReducedPageResponse<PlayLog> getPlayLogs(@RequestParam long pdId, public ReducedPageResponse<PlayLog> getPlayLogs(@RequestParam int pdId,
@RequestParam(required = false, defaultValue = "0") int page, @RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) { @RequestParam(required = false, defaultValue = "10") int size) {
Page<PlayLog> playLogs = playLogRepository.findByPdId_PdIdOrderByDateTimeDesc(pdId, PageRequest.of(page, size)); Page<PlayLog> playLogs = playLogRepository.findByPdId_PdIdOrderByDateTimeDesc(pdId, PageRequest.of(page, size));
@@ -179,7 +188,7 @@ public class ApiDivaPlayerDataController {
*/ */
@GetMapping("pvRecord") @GetMapping("pvRecord")
public ReducedPageResponse<PlayerPvRecord> getPvRecords(@RequestParam long pdId, public ReducedPageResponse<PlayerPvRecord> getPvRecords(@RequestParam int pdId,
@RequestParam(required = false, defaultValue = "0") int page, @RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) { @RequestParam(required = false, defaultValue = "10") int size) {
Page<PlayerPvRecord> pvRecords = playerPvRecordRepository.findByPdId_PdIdOrderByPvId(pdId, PageRequest.of(page, size)); Page<PlayerPvRecord> pvRecords = playerPvRecordRepository.findByPdId_PdIdOrderByPvId(pdId, PageRequest.of(page, size));
@@ -187,7 +196,7 @@ public class ApiDivaPlayerDataController {
} }
@GetMapping("pvRecord/{pvId}") @GetMapping("pvRecord/{pvId}")
public Map<String, Object> getPvRecord(@RequestParam long pdId, @PathVariable int pvId) { public Map<String, Object> getPvRecord(@RequestParam int pdId, @PathVariable int pvId) {
Map<String, Object> resultMap = new HashMap<>(); Map<String, Object> resultMap = new HashMap<>();
resultMap.put("records", playerPvRecordRepository.findByPdId_PdIdAndPvId(pdId, pvId)); resultMap.put("records", playerPvRecordRepository.findByPdId_PdIdAndPvId(pdId, pvId));
playerPvCustomizeRepository.findByPdId_PdIdAndPvId(pdId, pvId).ifPresent(x -> resultMap.put("customize", x)); playerPvCustomizeRepository.findByPdId_PdIdAndPvId(pdId, pvId).ifPresent(x -> resultMap.put("customize", x));
@@ -251,7 +260,7 @@ public class ApiDivaPlayerDataController {
} }
@GetMapping("module") @GetMapping("module")
public ReducedPageResponse<PlayerModule> getModules(@RequestParam long pdId, public ReducedPageResponse<PlayerModule> getModules(@RequestParam int pdId,
@RequestParam(required = false, defaultValue = "0") int page, @RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) { @RequestParam(required = false, defaultValue = "10") int size) {
Page<PlayerModule> modules = playerModuleRepository.findByPdId_PdId(pdId, PageRequest.of(page, size)); Page<PlayerModule> modules = playerModuleRepository.findByPdId_PdId(pdId, PageRequest.of(page, size));
@@ -259,7 +268,7 @@ public class ApiDivaPlayerDataController {
} }
@GetMapping("customize") @GetMapping("customize")
public ReducedPageResponse<PlayerCustomize> getCustomizes(@RequestParam long pdId, public ReducedPageResponse<PlayerCustomize> getCustomizes(@RequestParam int pdId,
@RequestParam(required = false, defaultValue = "0") int page, @RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) { @RequestParam(required = false, defaultValue = "10") int size) {
Page<PlayerCustomize> customizes = playerCustomizeRepository.findByPdId_PdId(pdId, PageRequest.of(page, size)); Page<PlayerCustomize> customizes = playerCustomizeRepository.findByPdId_PdId(pdId, PageRequest.of(page, size));
@@ -267,7 +276,7 @@ public class ApiDivaPlayerDataController {
} }
@GetMapping("screenshot") @GetMapping("screenshot")
public List<PlayerScreenShot> getScreenshotList(@RequestParam long pdId) { public List<PlayerScreenShot> getScreenshotList(@RequestParam int pdId) {
return playerScreenShotRepository.findByPdId_PdId(pdId); return playerScreenShotRepository.findByPdId_PdId(pdId);
} }

View File

@@ -2,19 +2,19 @@ package icu.samnyan.aqua.api.controller.sega.game.maimai2;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import icu.samnyan.aqua.api.model.MessageResponse; import icu.samnyan.aqua.api.model.MessageResponse;
import icu.samnyan.aqua.api.model.ReducedPageResponse; import icu.samnyan.aqua.api.model.ReducedPageResponse;
import icu.samnyan.aqua.api.model.resp.sega.maimai2.PhotoResp;
import icu.samnyan.aqua.api.model.resp.sega.maimai2.ProfileResp; import icu.samnyan.aqua.api.model.resp.sega.maimai2.ProfileResp;
import icu.samnyan.aqua.api.model.resp.sega.maimai2.PhotoResp;
import icu.samnyan.aqua.api.model.resp.sega.maimai2.external.ExternalUserData; import icu.samnyan.aqua.api.model.resp.sega.maimai2.external.ExternalUserData;
import icu.samnyan.aqua.api.model.resp.sega.maimai2.external.Maimai2DataExport; import icu.samnyan.aqua.api.model.resp.sega.maimai2.external.Maimai2DataExport;
import icu.samnyan.aqua.api.model.resp.sega.maimai2.external.Maimai2DataImport; import icu.samnyan.aqua.api.model.resp.sega.maimai2.external.Maimai2DataImport;
import icu.samnyan.aqua.api.util.ApiMapper; import icu.samnyan.aqua.api.util.ApiMapper;
import icu.samnyan.aqua.sega.general.model.Card; import icu.samnyan.aqua.sega.general.model.Card;
import icu.samnyan.aqua.sega.general.service.CardService; import icu.samnyan.aqua.sega.general.service.CardService;
import icu.samnyan.aqua.sega.maimai2.model.*; import icu.samnyan.aqua.sega.maimai2.dao.userdata.*;
import icu.samnyan.aqua.sega.maimai2.model.userdata.*; import icu.samnyan.aqua.sega.maimai2.model.userdata.*;
import lombok.AllArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
@@ -24,9 +24,9 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.nio.file.Files; import java.io.IOException;
import java.nio.file.Path; import java.nio.file.*;
import java.nio.file.Paths; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -36,30 +36,59 @@ import java.util.stream.Stream;
*/ */
@RestController @RestController
@RequestMapping("api/game/maimai2") @RequestMapping("api/game/maimai2")
@AllArgsConstructor
public class ApiMaimai2PlayerDataController { public class ApiMaimai2PlayerDataController {
private final ApiMapper mapper; private final ApiMapper mapper;
private static DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0");
private final CardService cardService; private final CardService cardService;
private final Mai2UserActRepo userActRepository; private final UserActRepository userActRepository;
private final Mai2UserCharacterRepo userCharacterRepository; private final UserCharacterRepository userCharacterRepository;
private final Mai2UserDataRepo userDataRepository; private final UserDataRepository userDataRepository;
private final Mai2UserItemRepo userItemRepository; private final UserItemRepository userItemRepository;
private final Mai2UserLoginBonusRepo userLoginBonusRepository; private final UserLoginBonusRepository userLoginBonusRepository;
private final Mai2UserMusicDetailRepo userMusicDetailRepository; private final UserMusicDetailRepository userMusicDetailRepository;
private final Mai2UserOptionRepo userOptionRepository; private final UserOptionRepository userOptionRepository;
private final Mai2UserPlaylogRepo userPlaylogRepository; private final UserPlaylogRepository userPlaylogRepository;
private final Mai2UserGeneralDataRepo userGeneralDataRepository; private final UserGeneralDataRepository userGeneralDataRepository;
private final Mai2MapEncountNpcRepo mapEncountNpcRepository; private final MapEncountNpcRepository mapEncountNpcRepository;
private final Mai2UserChargeRepo userChargeRepository; private final UserChargeRepository userChargeRepository;
private final Mai2UserCourseRepo userCourseRepository; private final UserCourseRepository userCourseRepository;
private final Mai2UserExtendRepo userExtendRepository; private final UserExtendRepository userExtendRepository;
private final Mai2UserFavoriteRepo userFavoriteRepository; private final UserFavoriteRepository userFavoriteRepository;
private final Mai2UserFriendSeasonRankingRepo userFriendSeasonRankingRepository; private final UserFriendSeasonRankingRepository userFriendSeasonRankingRepository;
private final Mai2UserMapRepo userMapRepository; private final UserMapRepository userMapRepository;
private final Mai2UserUdemaeRepo userUdemaeRepository; private final UserUdemaeRepository userUdemaeRepository;
public ApiMaimai2PlayerDataController(ApiMapper mapper, CardService cardService, UserActRepository userActRepository,
UserCharacterRepository userCharacterRepository, UserDataRepository userDataRepository, UserItemRepository userItemRepository,
UserLoginBonusRepository userLoginBonusRepository, UserMusicDetailRepository userMusicDetailRepository, UserOptionRepository userOptionRepository,
UserPlaylogRepository userPlaylogRepository, UserGeneralDataRepository userGeneralDataRepository, MapEncountNpcRepository mapEncountNpcRepository,
UserChargeRepository userChargeRepository, UserCourseRepository userCourseRepository, UserExtendRepository userExtendRepository,
UserFavoriteRepository userFavoriteRepository, UserFriendSeasonRankingRepository userFriendSeasonRankingRepository, UserMapRepository userMapRepository,
UserUdemaeRepository userUdemaeRepository) {
this.mapper = mapper;
this.cardService = cardService;
this.userActRepository = userActRepository;
this.userCharacterRepository = userCharacterRepository;
this.userDataRepository = userDataRepository;
this.userItemRepository = userItemRepository;
this.userLoginBonusRepository = userLoginBonusRepository;
this.userMusicDetailRepository = userMusicDetailRepository;
this.userOptionRepository = userOptionRepository;
this.userPlaylogRepository = userPlaylogRepository;
this.userGeneralDataRepository = userGeneralDataRepository;
this.mapEncountNpcRepository = mapEncountNpcRepository;
this.userChargeRepository = userChargeRepository;
this.userCourseRepository = userCourseRepository;
this.userExtendRepository = userExtendRepository;
this.userFavoriteRepository = userFavoriteRepository;
this.userFriendSeasonRankingRepository = userFriendSeasonRankingRepository;
this.userMapRepository = userMapRepository;
this.userUdemaeRepository = userUdemaeRepository;
}
@GetMapping("config/userPhoto/divMaxLength") @GetMapping("config/userPhoto/divMaxLength")
public long getConfigUserPhotoDivMaxLength(@Value("${game.maimai2.userPhoto.divMaxLength:32}") long divMaxLength) { public long getConfigUserPhotoDivMaxLength(@Value("${game.maimai2.userPhoto.divMaxLength:32}") long divMaxLength) {
@@ -83,10 +112,10 @@ public class ApiMaimai2PlayerDataController {
.map(Path::getFileName) .map(Path::getFileName)
.map(Path::toString) .map(Path::toString)
.sorted(Comparator.reverseOrder()) .sorted(Comparator.reverseOrder())
.toList(); .collect(Collectors.toList());
Photo.setTotalImage(matchedFiles.size()); Photo.setTotalImage(matchedFiles.size());
Photo.setImageIndex(imageIndex); Photo.setImageIndex(imageIndex);
if(matchedFiles.size() > imageIndex) { if(matchedFiles.size() > imageIndex){
byte[] targetImageContent = Files.readAllBytes(Paths.get("data/" + matchedFiles.get(imageIndex))); byte[] targetImageContent = Files.readAllBytes(Paths.get("data/" + matchedFiles.get(imageIndex)));
String divData = Base64.getEncoder().encodeToString(targetImageContent); String divData = Base64.getEncoder().encodeToString(targetImageContent);
Photo.setDivData(divData); Photo.setDivData(divData);
@@ -100,71 +129,71 @@ public class ApiMaimai2PlayerDataController {
@GetMapping("profile") @GetMapping("profile")
public ProfileResp getProfile(@RequestParam long aimeId) { public ProfileResp getProfile(@RequestParam long aimeId) {
return mapper.convert(userDataRepository.findByCardExtId(aimeId).orElseThrow(), new TypeReference<>() { return mapper.convert(userDataRepository.findByCard_ExtId(aimeId).orElseThrow(), new TypeReference<>() {
}); });
} }
@PostMapping("profile/username") @PostMapping("profile/username")
public Mai2UserDetail updateName(@RequestBody Map<String, Object> request) { public UserDetail updateName(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow(); UserDetail profile = userDataRepository.findByCard_ExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
profile.setUserName((String) request.get("userName")); profile.setUserName((String) request.get("userName"));
return userDataRepository.save(profile); return userDataRepository.save(profile);
} }
@PostMapping("profile/icon") @PostMapping("profile/icon")
public Mai2UserDetail updateIcon(@RequestBody Map<String, Object> request) { public UserDetail updateIcon(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow(); UserDetail profile = userDataRepository.findByCard_ExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
profile.setIconId((Integer) request.get("iconId")); profile.setIconId((Integer) request.get("iconId"));
return userDataRepository.save(profile); return userDataRepository.save(profile);
} }
@PostMapping("profile/plate") @PostMapping("profile/plate")
public Mai2UserDetail updatePlate(@RequestBody Map<String, Object> request) { public UserDetail updatePlate(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow(); UserDetail profile = userDataRepository.findByCard_ExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
profile.setPlateId((Integer) request.get("plateId")); profile.setPlateId((Integer) request.get("plateId"));
return userDataRepository.save(profile); return userDataRepository.save(profile);
} }
@PostMapping("profile/frame") @PostMapping("profile/frame")
public Mai2UserDetail updateFrame(@RequestBody Map<String, Object> request) { public UserDetail updateFrame(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow(); UserDetail profile = userDataRepository.findByCard_ExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
profile.setFrameId((Integer) request.get("frameId")); profile.setFrameId((Integer) request.get("frameId"));
return userDataRepository.save(profile); return userDataRepository.save(profile);
} }
@PostMapping("profile/title") @PostMapping("profile/title")
public Mai2UserDetail updateTrophy(@RequestBody Map<String, Object> request) { public UserDetail updateTrophy(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow(); UserDetail profile = userDataRepository.findByCard_ExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
profile.setTitleId((Integer) request.get("titleId")); profile.setTitleId((Integer) request.get("titleId"));
return userDataRepository.save(profile); return userDataRepository.save(profile);
} }
@PostMapping("profile/partner") @PostMapping("profile/partner")
public Mai2UserDetail updatePartner(@RequestBody Map<String, Object> request) { public UserDetail updatePartner(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow(); UserDetail profile = userDataRepository.findByCard_ExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
profile.setPartnerId((Integer) request.get("partnerId")); profile.setPartnerId((Integer) request.get("partnerId"));
return userDataRepository.save(profile); return userDataRepository.save(profile);
} }
@GetMapping("character") @GetMapping("character")
public ReducedPageResponse<Mai2UserCharacter> getCharacter(@RequestParam long aimeId, public ReducedPageResponse<UserCharacter> getCharacter(@RequestParam long aimeId,
@RequestParam(required = false, defaultValue = "0") int page, @RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) { @RequestParam(required = false, defaultValue = "10") int size) {
Page<Mai2UserCharacter> characters = userCharacterRepository.findByUser_Card_ExtId(aimeId, PageRequest.of(page, size)); Page<UserCharacter> characters = userCharacterRepository.findByUser_Card_ExtId(aimeId, PageRequest.of(page, size));
return new ReducedPageResponse<>(characters.getContent(), characters.getPageable().getPageNumber(), characters.getTotalPages(), characters.getTotalElements()); return new ReducedPageResponse<>(characters.getContent(), characters.getPageable().getPageNumber(), characters.getTotalPages(), characters.getTotalElements());
} }
@GetMapping("activity") @GetMapping("activity")
public List<Mai2UserAct> getActivities(@RequestParam long aimeId) { public List<UserAct> getActivities(@RequestParam long aimeId) {
return userActRepository.findByUser_Card_ExtId(aimeId); return userActRepository.findByUser_Card_ExtId(aimeId);
} }
@GetMapping("item") @GetMapping("item")
public ReducedPageResponse<Mai2UserItem> getItem(@RequestParam long aimeId, public ReducedPageResponse<UserItem> getItem(@RequestParam long aimeId,
@RequestParam(required = false, defaultValue = "0") int page, @RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size, @RequestParam(required = false, defaultValue = "10") int size,
@RequestParam(required = false, defaultValue = "0") int ItemKind) { @RequestParam(required = false, defaultValue = "0") int ItemKind) {
Page<Mai2UserItem> items; Page<UserItem> items;
if(ItemKind == 0){ if(ItemKind == 0){
items = userItemRepository.findByUser_Card_ExtId(aimeId, PageRequest.of(page, size)); items = userItemRepository.findByUser_Card_ExtId(aimeId, PageRequest.of(page, size));
} }
@@ -176,7 +205,7 @@ public class ApiMaimai2PlayerDataController {
@PostMapping("item") @PostMapping("item")
public ResponseEntity<Object> updateItem(@RequestBody Map<String, Object> request) { public ResponseEntity<Object> updateItem(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow(); UserDetail profile = userDataRepository.findByCard_ExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
Integer itemKind = (Integer) request.get("itemKind"); Integer itemKind = (Integer) request.get("itemKind");
Integer itemId = (Integer) request.get("itemId"); Integer itemId = (Integer) request.get("itemId");
int stock = 1; int stock = 1;
@@ -184,14 +213,13 @@ public class ApiMaimai2PlayerDataController {
stock = (Integer) request.get("stock"); stock = (Integer) request.get("stock");
} }
Optional<Mai2UserItem> userItemOptional = userItemRepository.findByUserAndItemKindAndItemId(profile, itemKind, itemId); Optional<UserItem> userItemOptional = userItemRepository.findByUserAndItemKindAndItemId(profile, itemKind, itemId);
Mai2UserItem userItem; UserItem userItem;
if (userItemOptional.isPresent()) { if (userItemOptional.isPresent()) {
userItem = userItemOptional.get(); userItem = userItemOptional.get();
} else { } else {
userItem = new Mai2UserItem(); userItem = new UserItem(profile);
userItem.setUser(profile);
userItem.setItemId(itemId); userItem.setItemId(itemId);
userItem.setItemKind(itemKind); userItem.setItemKind(itemKind);
} }
@@ -201,34 +229,34 @@ public class ApiMaimai2PlayerDataController {
} }
@GetMapping("recent") @GetMapping("recent")
public ReducedPageResponse<Mai2UserPlaylog> getRecent(@RequestParam long aimeId, public ReducedPageResponse<UserPlaylog> getRecent(@RequestParam long aimeId,
@RequestParam(required = false, defaultValue = "0") int page, @RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "10") int size) { @RequestParam(required = false, defaultValue = "10") int size) {
Page<Mai2UserPlaylog> playlogs = userPlaylogRepository.findByUser_Card_ExtId(aimeId, PageRequest.of(page, size, Sort.Direction.DESC, "id")); Page<UserPlaylog> playlogs = userPlaylogRepository.findByUser_Card_ExtId(aimeId, PageRequest.of(page, size, Sort.Direction.DESC, "id"));
return new ReducedPageResponse<>(playlogs.getContent(), playlogs.getPageable().getPageNumber(), playlogs.getTotalPages(), playlogs.getTotalElements()); return new ReducedPageResponse<>(playlogs.getContent(), playlogs.getPageable().getPageNumber(), playlogs.getTotalPages(), playlogs.getTotalElements());
} }
@GetMapping("song/{id}") @GetMapping("song/{id}")
public List<Mai2UserMusicDetail> getSongDetail(@RequestParam long aimeId, @PathVariable int id) { public List<UserMusicDetail> getSongDetail(@RequestParam long aimeId, @PathVariable int id) {
return userMusicDetailRepository.findByUser_Card_ExtIdAndMusicId(aimeId, id); return userMusicDetailRepository.findByUser_Card_ExtIdAndMusicId(aimeId, id);
} }
@GetMapping("song/{id}/{level}") @GetMapping("song/{id}/{level}")
public List<Mai2UserPlaylog> getLevelPlaylog(@RequestParam long aimeId, @PathVariable int id, @PathVariable int level) { public List<UserPlaylog> getLevelPlaylog(@RequestParam long aimeId, @PathVariable int id, @PathVariable int level) {
return userPlaylogRepository.findByUser_Card_ExtIdAndMusicIdAndLevel(aimeId, id, level); return userPlaylogRepository.findByUser_Card_ExtIdAndMusicIdAndLevel(aimeId, id, level);
} }
@GetMapping("options") @GetMapping("options")
public Mai2UserOption getOptions(@RequestParam long aimeId) { public UserOption getOptions(@RequestParam long aimeId) {
return userOptionRepository.findSingleByUser_Card_ExtId(aimeId).orElseThrow(); return userOptionRepository.findByUser_Card_ExtId(aimeId).orElseThrow();
} }
@PostMapping("options") @PostMapping("options")
public ResponseEntity<Object> updateOptions(@RequestBody Map<String, Object> request) { public ResponseEntity<Object> updateOptions(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow(); UserDetail profile = userDataRepository.findByCard_ExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
ObjectMapper objectMapper = new ObjectMapper(); ObjectMapper objectMapper = new ObjectMapper();
Mai2UserOption userOption = objectMapper.convertValue(request.get("options"), Mai2UserOption.class); UserOption userOption = objectMapper.convertValue(request.get("options"), UserOption.class);
userOption.setUser(profile); userOption.setUser(profile);
userOptionRepository.deleteByUser(profile); userOptionRepository.deleteByUser(profile);
userOptionRepository.flush(); userOptionRepository.flush();
@@ -237,26 +265,24 @@ public class ApiMaimai2PlayerDataController {
@GetMapping("general") @GetMapping("general")
public ResponseEntity<Object> getGeneralData(@RequestParam long aimeId, @RequestParam String key) { public ResponseEntity<Object> getGeneralData(@RequestParam long aimeId, @RequestParam String key) {
Optional<Mai2UserGeneralData> userGeneralDataOptional = userGeneralDataRepository.findByUser_Card_ExtIdAndPropertyKey(aimeId, key); Optional<UserGeneralData> userGeneralDataOptional = userGeneralDataRepository.findByUser_Card_ExtIdAndPropertyKey(aimeId, key);
return userGeneralDataOptional.<ResponseEntity<Object>>map(ResponseEntity::ok) return userGeneralDataOptional.<ResponseEntity<Object>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new MessageResponse("User or value not found."))); .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new MessageResponse("User or value not found.")));
} }
@PostMapping("general") @PostMapping("general")
public ResponseEntity<Object> setGeneralData(@RequestBody Map<String, Object> request) { public ResponseEntity<Object> setGeneralData(@RequestBody Map<String, Object> request) {
Mai2UserDetail profile = userDataRepository.findByCardExtId(((Number) request.get("aimeId")).longValue()).orElseThrow(); UserDetail profile = userDataRepository.findByCard_ExtId(((Number) request.get("aimeId")).longValue()).orElseThrow();
String key = (String) request.get("key"); String key = (String) request.get("key");
String value = (String) request.get("value"); String value = (String) request.get("value");
Optional<Mai2UserGeneralData> userGeneralDataOptional = userGeneralDataRepository.findByUserAndPropertyKey(profile, key); Optional<UserGeneralData> userGeneralDataOptional = userGeneralDataRepository.findByUserAndPropertyKey(profile, key);
Mai2UserGeneralData userGeneralData; UserGeneralData userGeneralData;
if (userGeneralDataOptional.isPresent()) { if (userGeneralDataOptional.isPresent()) {
userGeneralData = userGeneralDataOptional.get(); userGeneralData = userGeneralDataOptional.get();
} }
else { else {
userGeneralData = new Mai2UserGeneralData(); userGeneralData = new UserGeneralData(profile, key);
userGeneralData.setUser(profile);
userGeneralData.setPropertyKey(key);
} }
userGeneralData.setPropertyValue(value); userGeneralData.setPropertyValue(value);
@@ -268,16 +294,16 @@ public class ApiMaimai2PlayerDataController {
Maimai2DataExport data = new Maimai2DataExport(); Maimai2DataExport data = new Maimai2DataExport();
try { try {
data.setGameId("SDEZ"); data.setGameId("SDEZ");
data.setUserData(userDataRepository.findByCardExtId(aimeId).orElseThrow()); data.setUserData(userDataRepository.findByCard_ExtId(aimeId).orElseThrow());
data.setUserExtend(userExtendRepository.findSingleByUser_Card_ExtId(aimeId).orElseThrow()); data.setUserExtend(userExtendRepository.findByUser_Card_ExtId(aimeId).orElseThrow());
data.setUserOption(userOptionRepository.findSingleByUser_Card_ExtId(aimeId).orElseThrow()); data.setUserOption(userOptionRepository.findByUser_Card_ExtId(aimeId).orElseThrow());
data.setUserUdemae(userUdemaeRepository.findSingleByUser_Card_ExtId(aimeId).orElseThrow()); data.setUserUdemae(userUdemaeRepository.findByUser_Card_ExtId(aimeId).orElseThrow());
data.setUserCharacterList(userCharacterRepository.findByUser_Card_ExtId(aimeId)); data.setUserCharacterList(userCharacterRepository.findByUser_Card_ExtId(aimeId));
data.setUserGeneralDataList(userGeneralDataRepository.findByUser_Card_ExtId(aimeId)); data.setUserGeneralDataList(userGeneralDataRepository.findByUser_Card_ExtId(aimeId));
data.setUserItemList(userItemRepository.findByUser_Card_ExtId(aimeId)); data.setUserItemList(userItemRepository.findByUser_Card_ExtId(aimeId));
data.setUserLoginBonusList(userLoginBonusRepository.findByUser_Card_ExtId(aimeId)); data.setUserLoginBonusList(userLoginBonusRepository.findByUser_Card_ExtId(aimeId));
data.setUserMusicDetailList(userMusicDetailRepository.findByUser_Card_ExtId(aimeId)); data.setUserMusicDetailList(userMusicDetailRepository.findByUser_Card_ExtId(aimeId));
data.setUserPlaylogList(userPlaylogRepository.findByUserCardExtId(aimeId)); data.setUserPlaylogList(userPlaylogRepository.findByUser_Card_ExtId(aimeId));
data.setMapEncountNpcList(mapEncountNpcRepository.findByUser_Card_ExtId(aimeId)); data.setMapEncountNpcList(mapEncountNpcRepository.findByUser_Card_ExtId(aimeId));
data.setUserActList(userActRepository.findByUser_Card_ExtId(aimeId)); data.setUserActList(userActRepository.findByUser_Card_ExtId(aimeId));
data.setUserChargeList(userChargeRepository.findByUser_Card_ExtId(aimeId)); data.setUserChargeList(userChargeRepository.findByUser_Card_ExtId(aimeId));
@@ -310,7 +336,7 @@ public class ApiMaimai2PlayerDataController {
Card card; Card card;
if (cardOptional.isPresent()) { if (cardOptional.isPresent()) {
card = cardOptional.get(); card = cardOptional.get();
Optional<Mai2UserDetail> existUserData = Optional.ofNullable(userDataRepository.findByCard(cardOptional.get())); Optional<UserDetail> existUserData = userDataRepository.findByCard(cardOptional.get());
if (existUserData.isPresent()) { if (existUserData.isPresent()) {
// return ResponseEntity.status(HttpStatus.BAD_REQUEST) // return ResponseEntity.status(HttpStatus.BAD_REQUEST)
// .body(new MessageResponse("This card already has a maimai2 profile.")); // .body(new MessageResponse("This card already has a maimai2 profile."));
@@ -357,7 +383,7 @@ public class ApiMaimai2PlayerDataController {
card = cardService.registerByAccessCode(exUser.getAccessCode()); card = cardService.registerByAccessCode(exUser.getAccessCode());
} }
Mai2UserDetail userData = mapper.convert(exUser, new TypeReference<>() { UserDetail userData = mapper.convert(exUser, new TypeReference<>() {
}); });
userData.setCard(card); userData.setCard(card);
userDataRepository.saveAndFlush(userData); userDataRepository.saveAndFlush(userData);
@@ -376,15 +402,15 @@ public class ApiMaimai2PlayerDataController {
userChargeRepository.saveAll(data.getUserChargeList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList())); userChargeRepository.saveAll(data.getUserChargeList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
userCourseRepository.saveAll(data.getUserCourseList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList())); userCourseRepository.saveAll(data.getUserCourseList().stream().peek(x -> x.setUser(userData)).collect(Collectors.toList()));
Mai2UserExtend userExtend = data.getUserExtend(); UserExtend userExtend = data.getUserExtend();
userExtend.setUser(userData); userExtend.setUser(userData);
userExtendRepository.save(userExtend); userExtendRepository.save(userExtend);
Mai2UserOption userOption = data.getUserOption(); UserOption userOption = data.getUserOption();
userOption.setUser(userData); userOption.setUser(userData);
userOptionRepository.save(userOption); userOptionRepository.save(userOption);
Mai2UserUdemae userUdemae = data.getUserUdemae(); UserUdemae userUdemae = data.getUserUdemae();
userUdemae.setUser(userData); userUdemae.setUser(userData);
userUdemaeRepository.save(userUdemae); userUdemaeRepository.save(userUdemae);

View File

@@ -0,0 +1,88 @@
package icu.samnyan.aqua.api.controller.sega.game.maimai2
import ext.*
import icu.samnyan.aqua.sega.maimai2.dao.userdata.UserDataRepository
import icu.samnyan.aqua.sega.maimai2.dao.userdata.UserGeneralDataRepository
import icu.samnyan.aqua.sega.maimai2.dao.userdata.UserPlaylogRepository
import org.springframework.http.HttpStatus.*
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import kotlin.jvm.optionals.getOrNull
@RestController
@RequestMapping("api/game/maimai2new")
class Maimai2New(
private val userPlaylogRepository: UserPlaylogRepository,
private val userDataRepository: UserDataRepository,
private val userGeneralDataRepository: UserGeneralDataRepository
)
{
data class TrendOut(val date: String, val rating: Int, val plays: Int)
@GetMapping("trend")
fun trend(@RequestParam userId: Long): List<TrendOut> {
// O(n log n) sort
val d = userPlaylogRepository.findByUser_Card_ExtId(userId).sortedBy { it.playDate }.toList()
// Precompute the play counts for each date in O(n)
val playCounts = d.groupingBy { it.playDate }.eachCount()
// Use the precomputed play counts
return d.distinctBy { it.playDate }
.map { TrendOut(it.playDate, it.afterRating, playCounts[it.playDate] ?: 0) }
.sortedBy { it.date }
}
private val shownRanks = listOf(
100.5 to "SSS+",
100.0 to "SSS",
99.5 to "SS+",
99.0 to "SS",
98.0 to "S+",
97.0 to "S").map { (k, v) -> (k * 10000).toInt() to v }
@GetMapping("user-summary")
fun userSummary(@RequestParam userId: Long): Map<String, Any> {
// Summary values: total plays, player rating, server-wide ranking
// number of each rank, max combo, number of full combo, number of all perfect
val user = userDataRepository.findByCard_ExtId(userId).getOrNull() ?: NOT_FOUND()
val plays = userPlaylogRepository.findByUser_Card_ExtId(userId)
val extra = userGeneralDataRepository.findByUser_Card_ExtId(userId)
.associate { it.propertyKey to it.propertyValue }
// O(6n) ranks algorithm: Loop through the entire list of plays,
// count the number of each rank
val ranks = shownRanks.associate { (_, v) -> v to 0 }.toMutableMap()
plays.forEach {
shownRanks.find { (s, _) -> it.achievement > s }?.let { (_, v) -> ranks[v] = ranks[v]!! + 1 }
}
return mapOf(
"name" to user.userName,
"iconId" to user.iconId,
"serverRank" to userDataRepository.getRanking(user.playerRating),
"accuracy" to plays.sumOf { it.achievement } / plays.size,
"rating" to user.playerRating,
"ratingHighest" to user.highestRating,
"ranks" to ranks.map { (k, v) -> mapOf("name" to k, "count" to v) },
"maxCombo" to plays.maxOf { it.maxCombo },
"fullCombo" to plays.count { it.totalCombo == it.maxCombo },
"allPerfect" to plays.count { it.achievement == 1010000 },
"totalDxScore" to user.totalDeluxscore,
"plays" to plays.size,
"totalPlayTime" to plays.count() * 3, // TODO: Make a more accurate estimate
"joined" to user.firstPlayDate,
"lastSeen" to user.lastPlayDate,
"lastVersion" to user.lastRomVersion,
"best35" to (extra["recent_rating"] ?: ""),
"best15" to (extra["recent_rating_new"] ?: ""),
"recent" to plays.sortedBy { it.playDate }.takeLast(10)
)
}
}

View File

@@ -2,7 +2,6 @@ package icu.samnyan.aqua.api.controller.sega.game.ongeki;
import icu.samnyan.aqua.sega.ongeki.dao.gamedata.*; import icu.samnyan.aqua.sega.ongeki.dao.gamedata.*;
import icu.samnyan.aqua.sega.ongeki.model.gamedata.*; import icu.samnyan.aqua.sega.ongeki.model.gamedata.*;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@@ -12,7 +11,6 @@ import java.util.List;
*/ */
@RestController @RestController
@RequestMapping("api/game/ongeki/data") @RequestMapping("api/game/ongeki/data")
@AllArgsConstructor
public class ApiOngekiGameDataController { public class ApiOngekiGameDataController {
private final GameCardRepository gameCardRepository; private final GameCardRepository gameCardRepository;
@@ -21,6 +19,14 @@ public class ApiOngekiGameDataController {
private final GameMusicRepository gameMusicRepository; private final GameMusicRepository gameMusicRepository;
private final GameSkillRepository gameSkillRepository; private final GameSkillRepository gameSkillRepository;
public ApiOngekiGameDataController(GameCardRepository gameCardRepository, GameCharaRepository gameCharaRepository, GameEventRepository gameEventRepository, GameMusicRepository gameMusicRepository, GameSkillRepository gameSkillRepository) {
this.gameCardRepository = gameCardRepository;
this.gameCharaRepository = gameCharaRepository;
this.gameEventRepository = gameEventRepository;
this.gameMusicRepository = gameMusicRepository;
this.gameSkillRepository = gameSkillRepository;
}
@GetMapping("cardList") @GetMapping("cardList")
public List<GameCard> getCardList() { public List<GameCard> getCardList() {
return gameCardRepository.findAll(); return gameCardRepository.findAll();

View File

@@ -16,7 +16,6 @@ import icu.samnyan.aqua.sega.ongeki.dao.userdata.*;
import icu.samnyan.aqua.sega.ongeki.model.gamedata.GameCard; import icu.samnyan.aqua.sega.ongeki.model.gamedata.GameCard;
import icu.samnyan.aqua.sega.ongeki.model.response.data.UserRivalData; import icu.samnyan.aqua.sega.ongeki.model.response.data.UserRivalData;
import icu.samnyan.aqua.sega.ongeki.model.userdata.*; import icu.samnyan.aqua.sega.ongeki.model.userdata.*;
import lombok.AllArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
@@ -36,7 +35,6 @@ import java.util.stream.Collectors;
*/ */
@RestController @RestController
@RequestMapping("api/game/ongeki") @RequestMapping("api/game/ongeki")
@AllArgsConstructor
public class ApiOngekiPlayerDataController { public class ApiOngekiPlayerDataController {
private final ApiMapper mapper; private final ApiMapper mapper;
@@ -78,6 +76,38 @@ public class ApiOngekiPlayerDataController {
private final GameCardRepository gameCardRepository; private final GameCardRepository gameCardRepository;
public ApiOngekiPlayerDataController(ApiMapper mapper, CardService cardService, UserRivalDataRepository userRivalDataRepository, UserActivityRepository userActivityRepository, UserCardRepository userCardRepository, UserChapterRepository userChapterRepository, UserCharacterRepository userCharacterRepository, UserDataRepository userDataRepository, UserDeckRepository userDeckRepository, UserEventPointRepository userEventPointRepository, UserItemRepository userItemRepository, UserLoginBonusRepository userLoginBonusRepository, UserMissionPointRepository userMissionPointRepository, UserMusicDetailRepository userMusicDetailRepository, UserMusicItemRepository userMusicItemRepository, UserOptionRepository userOptionRepository, UserPlaylogRepository userPlaylogRepository, UserStoryRepository userStoryRepository, UserTrainingRoomRepository userTrainingRoomRepository, UserGeneralDataRepository userGeneralDataRepository, GameCardRepository gameCardRepository, UserTradeItemRepository userTradeItemRepository, UserEventMusicRepository userEventMusicRepository, UserTechEventRepository userTechEventRepository, UserKopRepository userKopRepository, UserMemoryChapterRepository userMemoryChapterRepository, UserScenarioRepository userScenarioRepository, UserBossRepository userBossRepository, UserTechCountRepository userTechCountRepository) {
this.mapper = mapper;
this.cardService = cardService;
this.userActivityRepository = userActivityRepository;
this.userCardRepository = userCardRepository;
this.userChapterRepository = userChapterRepository;
this.userCharacterRepository = userCharacterRepository;
this.userDataRepository = userDataRepository;
this.userDeckRepository = userDeckRepository;
this.userEventPointRepository = userEventPointRepository;
this.userItemRepository = userItemRepository;
this.userLoginBonusRepository = userLoginBonusRepository;
this.userMissionPointRepository = userMissionPointRepository;
this.userMusicDetailRepository = userMusicDetailRepository;
this.userMusicItemRepository = userMusicItemRepository;
this.userOptionRepository = userOptionRepository;
this.userPlaylogRepository = userPlaylogRepository;
this.userStoryRepository = userStoryRepository;
this.userTrainingRoomRepository = userTrainingRoomRepository;
this.userGeneralDataRepository = userGeneralDataRepository;
this.gameCardRepository = gameCardRepository;
this.userTradeItemRepository = userTradeItemRepository;
this.userEventMusicRepository = userEventMusicRepository;
this.userTechEventRepository = userTechEventRepository;
this.userKopRepository = userKopRepository;
this.userMemoryChapterRepository = userMemoryChapterRepository;
this.userScenarioRepository = userScenarioRepository;
this.userBossRepository = userBossRepository;
this.userTechCountRepository = userTechCountRepository;
this.userRivalDataRepository = userRivalDataRepository;
}
@GetMapping("profile") @GetMapping("profile")
public ProfileResp getProfile(@RequestParam long aimeId) { public ProfileResp getProfile(@RequestParam long aimeId) {
return mapper.convert(userDataRepository.findByCard_ExtId(aimeId).orElseThrow(), new TypeReference<>() { return mapper.convert(userDataRepository.findByCard_ExtId(aimeId).orElseThrow(), new TypeReference<>() {
@@ -318,7 +348,7 @@ public class ApiOngekiPlayerDataController {
var rivalDataList = userDataRepository.findByCard_ExtIdIn(rivalUserIds) var rivalDataList = userDataRepository.findByCard_ExtIdIn(rivalUserIds)
.stream() .stream()
.map(x -> new UserRivalData(x.getCard().getExtId(), x.getUserName())) .map(x -> new UserRivalData(x.getCard().getExtId().longValue(), x.getUserName()))
.collect(Collectors.toList()); .collect(Collectors.toList());
return rivalDataList; return rivalDataList;
@@ -425,7 +455,7 @@ public class ApiOngekiPlayerDataController {
Card card; Card card;
if (cardOptional.isPresent()) { if (cardOptional.isPresent()) {
card = cardOptional.get(); card = cardOptional.get();
Optional<UserData> existUserData = Optional.ofNullable(userDataRepository.findByCard(cardOptional.get())); Optional<UserData> existUserData = userDataRepository.findByCard(cardOptional.get());
if (existUserData.isPresent()) { if (existUserData.isPresent()) {
// return ResponseEntity.status(HttpStatus.BAD_REQUEST) // return ResponseEntity.status(HttpStatus.BAD_REQUEST)
// .body(new MessageResponse("This card already has a ongeki profile.")); // .body(new MessageResponse("This card already has a ongeki profile."));

View File

@@ -7,7 +7,6 @@ import icu.samnyan.aqua.sega.chunithm.model.userdata.UserMusicDetail;
import icu.samnyan.aqua.sega.chunithm.service.GameMusicService; import icu.samnyan.aqua.sega.chunithm.service.GameMusicService;
import icu.samnyan.aqua.sega.chunithm.service.UserDataService; import icu.samnyan.aqua.sega.chunithm.service.UserDataService;
import icu.samnyan.aqua.sega.chunithm.service.UserMusicDetailService; import icu.samnyan.aqua.sega.chunithm.service.UserMusicDetailService;
import lombok.AllArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -25,7 +24,6 @@ import java.util.Optional;
*/ */
@RestController @RestController
@RequestMapping("api/manage/chuni/v1") @RequestMapping("api/manage/chuni/v1")
@AllArgsConstructor
public class ApiChuniV1ManageController { public class ApiChuniV1ManageController {
private static final Logger logger = LoggerFactory.getLogger(ApiChuniV1ManageController.class); private static final Logger logger = LoggerFactory.getLogger(ApiChuniV1ManageController.class);
@@ -36,6 +34,12 @@ public class ApiChuniV1ManageController {
private final GameMusicService gameMusicService; private final GameMusicService gameMusicService;
public ApiChuniV1ManageController(UserDataService userDataService, UserMusicDetailService userMusicDetailService, GameMusicService gameMusicService) {
this.userDataService = userDataService;
this.userMusicDetailService = userMusicDetailService;
this.gameMusicService = gameMusicService;
}
/** /**
* A request to fill fake score to all chart. only use for testing * A request to fill fake score to all chart. only use for testing
* @param aimeId The internal id of a card * @param aimeId The internal id of a card

View File

@@ -10,7 +10,6 @@ import icu.samnyan.aqua.sega.diva.model.common.Edition;
import icu.samnyan.aqua.sega.diva.model.gamedata.*; import icu.samnyan.aqua.sega.diva.model.gamedata.*;
import icu.samnyan.aqua.sega.general.dao.PropertyEntryRepository; import icu.samnyan.aqua.sega.general.dao.PropertyEntryRepository;
import icu.samnyan.aqua.sega.general.model.PropertyEntry; import icu.samnyan.aqua.sega.general.model.PropertyEntry;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.ArrayList; import java.util.ArrayList;
@@ -22,7 +21,6 @@ import java.util.Optional;
*/ */
@RestController @RestController
@RequestMapping("api/manage/diva/") @RequestMapping("api/manage/diva/")
@AllArgsConstructor
public class ApiDivaManageController { public class ApiDivaManageController {
private final PvEntryRepository pvEntryRepository; private final PvEntryRepository pvEntryRepository;
@@ -31,6 +29,17 @@ public class ApiDivaManageController {
private final FestaRepository festaRepository; private final FestaRepository festaRepository;
private final ContestRepository contestRepository; private final ContestRepository contestRepository;
private final PropertyEntryRepository propertyEntryRepository; private final PropertyEntryRepository propertyEntryRepository;
private final DivaPvRepository divaPvRepository;
public ApiDivaManageController(PvEntryRepository pvEntryRepository, DivaModuleRepository moduleRepository, DivaCustomizeRepository customizeRepository, FestaRepository festaRepository, ContestRepository contestRepository, PropertyEntryRepository propertyEntryRepository, DivaPvRepository divaPvRepository) {
this.pvEntryRepository = pvEntryRepository;
this.moduleRepository = moduleRepository;
this.customizeRepository = customizeRepository;
this.festaRepository = festaRepository;
this.contestRepository = contestRepository;
this.propertyEntryRepository = propertyEntryRepository;
this.divaPvRepository = divaPvRepository;
}
@PostMapping("pvList") @PostMapping("pvList")
public List<PvEntry> updatePvList(@RequestBody PvListRequest request) { public List<PvEntry> updatePvList(@RequestBody PvListRequest request) {

View File

@@ -1,5 +1,7 @@
package icu.samnyan.aqua.api.model; package icu.samnyan.aqua.api.model;
import icu.samnyan.aqua.api.model.MessageResponse;
public class ObjectMessageResponse<T> extends MessageResponse { public class ObjectMessageResponse<T> extends MessageResponse {
private T data; private T data;

View File

@@ -1,23 +0,0 @@
package icu.samnyan.aqua.api.model.resp.sega.chuni.v2.external
import icu.samnyan.aqua.net.games.IExportClass
import icu.samnyan.aqua.sega.chusan.model.userdata.*
data class Chu3DataExport(
override var gameId: String = "SDHD",
override var userData: Chu3UserData,
var userGameOption: UserGameOption,
var userActivityList: List<UserActivity>,
var userCharacterList: List<UserCharacter>,
var userChargeList: List<UserCharge>,
var userCourseList: List<UserCourse>,
var userDuelList: List<UserDuel>,
var userItemList: List<UserItem>,
var userMapList: List<UserMap>,
var userMusicDetailList: List<UserMusicDetail>,
var userPlaylogList: List<UserPlaylog>,
): IExportClass<Chu3UserData> {
constructor() : this("SDHD",
Chu3UserData(), UserGameOption(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList())
}

View File

@@ -0,0 +1,30 @@
package icu.samnyan.aqua.api.model.resp.sega.chuni.v2.external;
import icu.samnyan.aqua.sega.chusan.model.userdata.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* This class is use for exporting chusan profile
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ChuniDataExport {
private String gameId = "SDHD";
private UserData userData;
private List<UserActivity> userActivityList;
private List<UserCharacter> userCharacterList;
private List<UserCharge> userChargeList;
private List<UserCourse> userCourseList;
private List<UserDuel> userDuelList;
private UserGameOption userGameOption;
private List<UserItem> userItemList;
private List<UserMapArea> userMapList;
private List<UserMusicDetail> userMusicDetailList;
private List<UserPlaylog> userPlaylogList;
}

View File

@@ -24,7 +24,7 @@ public class ChuniDataImport {
private List<UserDuel> userDuelList; private List<UserDuel> userDuelList;
private UserGameOption userGameOption; private UserGameOption userGameOption;
private List<UserItem> userItemList; private List<UserItem> userItemList;
private List<UserMap> userMapList; private List<UserMapArea> userMapList;
private List<UserMusicDetail> userMusicDetailList; private List<UserMusicDetail> userMusicDetailList;
private List<UserPlaylog> userPlaylogList; private List<UserPlaylog> userPlaylogList;
} }

View File

@@ -7,7 +7,7 @@ import lombok.Data;
*/ */
@Data @Data
public class PlayerInfo { public class PlayerInfo {
private long pdId; private int pdId;
private String playerName; private String playerName;
private int vocaloidPoints; private int vocaloidPoints;
} }

View File

@@ -1,11 +1,11 @@
package icu.samnyan.aqua.api.model.resp.sega.maimai2; package icu.samnyan.aqua.api.model.resp.sega.maimai2;
import java.util.List;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.List;
/** /**
* @author samnyan (privateamusement@protonmail.com) * @author samnyan (privateamusement@protonmail.com)
*/ */

View File

@@ -0,0 +1,37 @@
package icu.samnyan.aqua.api.model.resp.sega.maimai2.external;
import icu.samnyan.aqua.sega.maimai2.model.userdata.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Maimai2DataExport {
private String gameId = "SDEZ";
private UserDetail userData;
private UserExtend userExtend;
private UserOption userOption;
private List<MapEncountNpc> mapEncountNpcList;
private List<UserAct> userActList;
private List<UserCharacter> userCharacterList;
private List<UserCharge> userChargeList;
private List<UserCourse> userCourseList;
private List<UserFavorite> userFavoriteList;
private List<UserFriendSeasonRanking> userFriendSeasonRankingList;
private List<UserGeneralData> userGeneralDataList;
private List<UserGhost> userGhostList;
private List<UserItem> userItemList;
private List<UserLoginBonus> userLoginBonusList;
private List<UserMap> userMapList;
private List<UserMusicDetail> userMusicDetailList;
private List<UserPlaylog> userPlaylogList;
private List<UserRate> userRateList;
private UserUdemae userUdemae;
}

View File

@@ -1,30 +0,0 @@
package icu.samnyan.aqua.api.model.resp.sega.maimai2.external
import icu.samnyan.aqua.net.games.IExportClass
import icu.samnyan.aqua.sega.maimai2.model.userdata.*
data class Maimai2DataExport(
override var userData: Mai2UserDetail,
var userExtend: Mai2UserExtend,
var userOption: Mai2UserOption,
var userUdemae: Mai2UserUdemae,
var mapEncountNpcList: List<Mai2MapEncountNpc>,
var userActList: List<Mai2UserAct>,
var userCharacterList: List<Mai2UserCharacter>,
var userChargeList: List<Mai2UserCharge>,
var userCourseList: List<Mai2UserCourse>,
var userFavoriteList: List<Mai2UserFavorite>,
var userFriendSeasonRankingList: List<Mai2UserFriendSeasonRanking>,
var userGeneralDataList: List<Mai2UserGeneralData>,
var userItemList: List<Mai2UserItem>,
var userLoginBonusList: List<Mai2UserLoginBonus>,
var userMapList: List<Mai2UserMap>,
var userMusicDetailList: List<Mai2UserMusicDetail>,
var userPlaylogList: List<Mai2UserPlaylog>,
override var gameId: String = "SDEZ",
): IExportClass<Mai2UserDetail> {
constructor() : this(Mai2UserDetail(), Mai2UserExtend(), Mai2UserOption(), Mai2UserUdemae(),
mutableListOf(), mutableListOf(), mutableListOf(), mutableListOf(), mutableListOf(), mutableListOf(),
mutableListOf(), mutableListOf(), mutableListOf(), mutableListOf(), mutableListOf(), mutableListOf(),
mutableListOf())
}

View File

@@ -16,22 +16,22 @@ import java.util.List;
public class Maimai2DataImport { public class Maimai2DataImport {
private String gameId; private String gameId;
private ExternalUserData userData; private ExternalUserData userData;
private Mai2UserExtend userExtend; private UserExtend userExtend;
private Mai2UserOption userOption; private UserOption userOption;
private List<Mai2MapEncountNpc> mapEncountNpcList; private List<MapEncountNpc> mapEncountNpcList;
private List<Mai2UserAct> userActList; private List<UserAct> userActList;
private List<Mai2UserCharacter> userCharacterList; private List<UserCharacter> userCharacterList;
private List<Mai2UserCharge> userChargeList; private List<UserCharge> userChargeList;
private List<Mai2UserCourse> userCourseList; private List<UserCourse> userCourseList;
private List<Mai2UserFavorite> userFavoriteList; private List<UserFavorite> userFavoriteList;
private List<Mai2UserFriendSeasonRanking> userFriendSeasonRankingList; private List<UserFriendSeasonRanking> userFriendSeasonRankingList;
private List<Mai2UserGeneralData> userGeneralDataList; private List<UserGeneralData> userGeneralDataList;
private List<Mai2UserGhost> userGhostList; private List<UserGhost> userGhostList;
private List<Mai2UserItem> userItemList; private List<UserItem> userItemList;
private List<Mai2UserLoginBonus> userLoginBonusList; private List<UserLoginBonus> userLoginBonusList;
private List<Mai2UserMap> userMapList; private List<UserMap> userMapList;
private List<Mai2UserMusicDetail> userMusicDetailList; private List<UserMusicDetail> userMusicDetailList;
private List<Mai2UserPlaylog> userPlaylogList; private List<UserPlaylog> userPlaylogList;
private List<Mai2UserRate> userRateList; private List<UserRate> userRateList;
private Mai2UserUdemae userUdemae; private UserUdemae userUdemae;
} }

View File

@@ -1,9 +1,15 @@
package icu.samnyan.aqua.api.model.resp.sega.ongeki.external; package icu.samnyan.aqua.api.model.resp.sega.ongeki.external;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import icu.samnyan.aqua.sega.general.model.Card;
import icu.samnyan.aqua.sega.util.jackson.AccessCodeSerializer;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.io.Serializable; import java.io.Serializable;
/** /**

View File

@@ -6,6 +6,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* @author samnyan (privateamusement@protonmail.com) * @author samnyan (privateamusement@protonmail.com)

View File

@@ -6,6 +6,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* @author samnyan (privateamusement@protonmail.com) * @author samnyan (privateamusement@protonmail.com)

View File

@@ -1,210 +0,0 @@
package icu.samnyan.aqua.net
import ext.*
import icu.samnyan.aqua.net.components.JWT
import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.net.games.GenericUserDataRepo
import icu.samnyan.aqua.net.games.IUserData
import icu.samnyan.aqua.net.utils.AquaNetProps
import icu.samnyan.aqua.net.utils.SUCCESS
import icu.samnyan.aqua.sega.chusan.model.Chu3UserDataRepo
import icu.samnyan.aqua.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.general.model.Card
import icu.samnyan.aqua.sega.general.service.CardService
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo
import icu.samnyan.aqua.sega.wacca.model.db.WcUserRepo
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.web.bind.annotation.RestController
import kotlin.jvm.optionals.getOrNull
@RestController
@API("/api/v2/card")
class CardController(
val jwt: JWT,
val us: AquaUserServices,
val cardService: CardService,
val cardGameService: CardGameService,
val cardRepository: CardRepository,
val props: AquaNetProps
) {
@API("/summary")
@Doc("Get a summary of the card, including the user's name, rating, and last login date.", "Summary of the card")
suspend fun summary(@RP cardId: Str): Any
{
// DO NOT CHANGE THIS ERROR MESSAGE - The frontend uses it to detect if the card is not found
val card = cardService.tryLookup(cardId) ?: (404 - "Card not found")
// Lookup data for each game
return mapOf(
"card" to card,
"summary" to cardGameService.getSummary(card),
)
}
@API("/user-games")
@Doc("Get the game summary of the user, including the user's name, rating, and last login date.", "Summary of the user")
suspend fun userGames(@RP username: Str) = us.cardByName(username) { card -> cardGameService.getSummary(card) }
/**
* Bind a card to the user. This action will migrate selected data from the card to the user's ghost card.
*
* Non-migrated data will not be lost, but will be inaccessible from the card until the card is unbound.
*
* @param token JWT token
* @param cardId Card ID
* @param migrate Things to migrate, stored as a comma-separated list of game IDs (e.g. "maimai2,chusan")
*/
@API("/link")
@Doc("Bind a card to the user. This action will migrate selected data from the card to the user's ghost card.", "Success message")
suspend fun link(@RP token: Str, @RP cardId: Str, @RP migrate: Str) = jwt.auth(token) { u ->
// Check if the user's card limit is reached
if (u.cards.size >= props.linkCardLimit) 400 - "Card limit reached"
// Try to look up the card
val card = cardService.tryLookup(cardId)
// If no card is found, create a new card
if (card == null) {
// Ensure the format of the card ID is correct
val id = cardService.sanitizeCardId(cardId)
// Create a new card
cardService.registerByAccessCode(id, u)
return SUCCESS
}
// If card is already bound
if (card.aquaUser != null) 400 - "Card already bound to another user"
// Bind the card
card.aquaUser = u
async { cardRepository.save(card) }
// Migrate selected data to the new user
val games = migrate.split(',')
cardGameService.migrate(card, games)
SUCCESS
}
@API("/unlink")
@Doc("Unbind a card from the user. No data will be migrated during this action.", "Success message")
suspend fun unlink(@RP token: Str, @RP cardId: Str) = jwt.auth(token) { u ->
// Try to look up the card
val card = cardService.tryLookup(cardId) ?: (404 - "Card not found")
// If the card is not bound to the user
if (card.aquaUser != u) 400 - "Card not linked to user"
// Ghost cards cannot be unlinked
if (card.isGhost) 400 - "Account virtual cards cannot be unlinked"
// Unbind the card
card.aquaUser = null
async { cardRepository.save(card) }
SUCCESS
}
@API("/default-game")
@Doc("Get the default game for the card.", "Game ID")
suspend fun defaultGame(@RP username: Str) = us.cardByName(username) { card ->
mapOf("game" to cardGameService.getSummary(card).filterValues { it != null }.keys.firstOrNull())
}
}
/**
* Migrate data from the card to the user's ghost card
*
* Assumption: The card is already linked to the user.
*/
suspend fun <T : IUserData> migrateCard(repo: GenericUserDataRepo<T>, card: Card): Bool
{
// Check if data already exists in the user's ghost card
async { repo.findByCard(card.aquaUser!!.ghostCard) }?.let {
// Unbind the data from the card
it.card = null
async { repo.save(it) }
}
// Migrate data from the card to the user's ghost card
// An easy migration is to change the UserData card field to the user's ghost card
val data = async { repo.findByCard(card) } ?: return false
data.card = card.aquaUser!!.ghostCard
async { repo.save(data) }
return true
}
suspend fun getSummaryFor(repo: GenericUserDataRepo<*>, card: Card): Map<Str, Any>?
{
val data = async { repo.findByCard(card) } ?: return null
return mapOf(
"name" to data.userName,
"rating" to data.playerRating,
"lastLogin" to data.lastPlayDate,
)
}
@Service
class CardGameService(
val maimai2: Mai2UserDataRepo,
val chusan: Chu3UserDataRepo,
val wacca: WcUserRepo,
val ongeki: icu.samnyan.aqua.sega.ongeki.dao.userdata.UserDataRepository,
val diva: icu.samnyan.aqua.sega.diva.dao.userdata.PlayerProfileRepository,
val safety: AquaNetSafetyService,
val cardRepo: CardRepository
) {
companion object {
val log = logger()
}
suspend fun migrate(crd: Card, games: List<String>) = async {
// Migrate data from the card to the user's ghost card
// An easy migration is to change the UserData card field to the user's ghost card
games.forEach { game ->
when (game) {
"mai2" -> migrateCard(maimai2, crd)
"chu3" -> migrateCard(chusan, crd)
"ongeki" -> migrateCard(ongeki, crd)
"wacca" -> migrateCard(wacca, crd)
// TODO: diva
// "diva" -> diva.findByPdId(card.extId.toInt()).getOrNull()?.let {
// it.pdId = card.aquaUser!!.ghostCard
// }
}
}
}
suspend fun getSummary(card: Card) = async { mapOf(
"mai2" to getSummaryFor(maimai2, card),
"chu3" to getSummaryFor(chusan, card),
"ongeki" to getSummaryFor(ongeki, card),
"wacca" to getSummaryFor(wacca, card),
"diva" to diva.findByPdId(card.extId).getOrNull()?.let {
mapOf(
"name" to it.playerName,
"rating" to it.level,
)
},
) }
// Every hour
@Scheduled(fixedDelay = 3600000)
suspend fun autoBan() {
log.info("Running auto-ban")
// Ban any players with unacceptable names
for (repo in listOf(maimai2, chusan, wacca, ongeki)) {
repo.findAll().filter { it.card != null && !it.card!!.rankingBanned }.forEach { data ->
if (!safety.isSafe(data.userName)) {
log.info("Banning user ${data.userName} ${data.card!!.id}")
data.card!!.rankingBanned = true
async { cardRepo.save(data.card!!) }
}
}
}
}
}

View File

@@ -1,55 +0,0 @@
package icu.samnyan.aqua.net
import ext.*
import icu.samnyan.aqua.sega.general.service.CardService
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Configuration
import org.springframework.web.bind.annotation.RestController
@Configuration
@ConfigurationProperties(prefix = "aqua-net.frontier")
class FrontierProps {
var enabled: Boolean = false
var ftk: String = ""
}
@RestController
@ConditionalOnProperty("aqua-net.frontier.enabled", havingValue = "true")
@API("/api/v2/frontier")
class Frontier(
val cardService: CardService,
val props: FrontierProps
) {
fun Str.checkFtk() {
if (this != props.ftk) 403 - "Invalid FTK"
}
@API("/register-card")
@Doc("Register a new card by access code", "Card information")
suspend fun registerCard(@RP ftk: Str, @RP accessCode: Str): Any {
ftk.checkFtk()
if (accessCode.length != 20) 400 - "Invalid access code"
if (!accessCode.startsWith("9900")) 400 - "Frontier access code must start with 9900"
if (async { cardService.cardRepo.findByLuid(accessCode) }.isPresent) 400 - "Card already registered"
val card = async { cardService.registerByAccessCode(accessCode) }
return mapOf(
"card" to card,
"id" to card.extId // Expose hidden ID
)
}
@API("/lookup-card")
@Doc("Lookup a card by access code", "Card information")
suspend fun lookupCard(@RP ftk: Str, @RP accessCode: Str): Any {
ftk.checkFtk()
val card = cardService.tryLookup(accessCode) ?: (404 - "Card not found")
return mapOf(
"card" to card,
"id" to card.extId // Expose hidden ID
)
}
}

View File

@@ -1,74 +0,0 @@
package icu.samnyan.aqua.net
import ext.HTTP
import ext.async
import ext.toJson
import icu.samnyan.aqua.net.games.BaseEntity
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import jakarta.persistence.Entity
import kotlinx.serialization.Serializable
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Configuration
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Service
import java.text.Normalizer
@Configuration
@ConfigurationProperties(prefix = "aqua-net.openai")
class OpenAIConfig {
var apiKey: String = ""
}
@Entity
class AquaNetSafety : BaseEntity() {
var content: String = ""
var safe: Boolean = false
}
interface AquaNetSafetyRepo : JpaRepository<AquaNetSafety, Long> {
fun findByContent(content: String): AquaNetSafety?
}
@Serializable
data class OpenAIResp<T>(
val id: String,
val model: String,
val results: List<T>
)
@Serializable
data class OpenAIMod(
val flagged: Boolean,
val categories: Map<String, Boolean>,
val categoryScores: Map<String, Double>,
)
@Service
class AquaNetSafetyService(
val safety: AquaNetSafetyRepo,
val openAIConfig: OpenAIConfig
) {
suspend fun isSafe(rawContent: String): Boolean {
// NFKC normalize
val content = Normalizer.normalize(rawContent, Normalizer.Form.NFKC)
if (content.isBlank()) return true
async { safety.findByContent(content) }?.let { return it.safe }
// Query OpenAI
HTTP.post("https://api.openai.com/v1/moderations") {
header("Authorization", "Bearer ${openAIConfig.apiKey}")
header("Content-Type", "application/json")
setBody(mapOf("input" to content).toJson())
}.let {
if (!it.status.isSuccess()) return true
val body = it.body<OpenAIResp<OpenAIMod>>()
return AquaNetSafety().apply {
this.content = content
this.safe = !body.results.first().flagged
}.also { safety.save(it) }.safe
}
}
}

View File

@@ -1,45 +0,0 @@
package icu.samnyan.aqua.net
import ext.*
import icu.samnyan.aqua.net.db.AquaGameOptions
import icu.samnyan.aqua.net.db.AquaGameOptionsRepo
import icu.samnyan.aqua.net.db.AquaNetUserRepo
import icu.samnyan.aqua.net.db.AquaUserServices
import org.springframework.web.bind.annotation.RestController
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.jvm.jvmErasure
@RestController
@API("/api/v2/settings")
class SettingsApi(
val us: AquaUserServices,
val userRepo: AquaNetUserRepo,
val goRepo: AquaGameOptionsRepo
) {
// Get all params with SettingField annotation
val fields = AquaGameOptions::class.vars()
.mapNotNull { it.findAnnotation<SettingField>()?.let { an -> it to an } }
val fieldMap = fields.associate { (f, _) -> f.name to f }
val fieldDesc = fields.map { (f, _) -> mapOf(
"key" to f.name, "type" to f.returnType.jvmErasure.simpleName
) }
@API("get")
@Doc("Get the game options of the logged in user")
fun getSettings(@RP token: String) = us.jwt.auth(token) { u ->
val go = u.gameOptions ?: AquaGameOptions()
fieldDesc.map { it + ("value" to fieldMap[it["key"]]!!.get(go)) }
}
@API("set")
@Doc("Set a field in the game options")
fun setField(@RP token: String, @RP key: String, @RP value: String) = us.jwt.auth(token) { u ->
val field = fieldMap[key] ?: (400 - "Invalid field $key")
val options = u.gameOptions ?: AquaGameOptions().also {
userRepo.save(u.apply { gameOptions = it })
}
// Check field type
field.setCast(options, value)
goRepo.save(options)
}
}

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