Compare commits

..

29 Commits

Author SHA1 Message Date
Rosalina 186ef7dcca
Merge remote-tracking branch 'upstream/main' into splashcat-exporter 2023-07-26 00:17:21 -04:00
Rosalina 22f511d843
add anarchy data 2023-07-26 00:16:09 -04:00
spacemeowx2 0baad9c04b chore: update NSOAPP_VERSION 2023-07-24 16:11:38 +08:00
spacemeowx2 56c75385fa chore: deno.lock 2023-07-17 15:36:19 +08:00
spacemeowx2 417a52138d fix: skip updateState if history if empty (#81) 2023-07-17 15:33:32 +08:00
Rosalina a10d97d91d
send challenge data 2023-07-08 14:13:13 -04:00
Rosalina 97a3e45926
fix a stupid 2023-07-06 20:25:36 -04:00
spacemeowx2 5867740de3 feat(gui): using latest tauri config, remove hacky way 2023-07-07 01:18:22 +08:00
spacemeowx2 8707feac01 fix(gui): breaking change by daisyui 2023-07-07 01:18:22 +08:00
spacemeowx2 740259e156 chore(gui): update README 2023-07-07 01:18:22 +08:00
spacemeowx2 2702e6cdf3 chore: upgrade deps 2023-07-07 01:18:22 +08:00
Rosalina be1c8cdc00
oops i managed to fuck up that logic 2023-07-03 00:05:30 -04:00
Rosalina 6c7f206626
small fixes 2023-07-02 21:41:41 -04:00
Rosalina 3ce8ae8d8e
Merge remote-tracking branch 'upstream/main' into dumb-splashcat-thing 2023-07-01 00:25:14 -04:00
Rosalina ff95f1a4b0
MEOW MEOW 2023-07-01 00:24:31 -04:00
Rosalina 8180d35cc5
more jank 2023-07-01 00:22:36 -04:00
imspace 1bc0d3eefc fix: list method is not auto 2023-06-15 19:22:00 +08:00
Rosalina edda191f56
Merge remote-tracking branch 'upstream/main' into dumb-splashcat-thing 2023-06-14 22:20:48 -04:00
spacemeowx2 a67bb4814d chore: update `NSOAPP_VERSION` 2023-06-13 21:47:39 +08:00
Rosalina 2941efd869
bleh. 2023-06-12 14:16:13 -04:00
spacemeowx2 6e5c2e05f3 chore: update README 2023-06-13 01:42:39 +08:00
spacemeowx2 40cfd13e6c feat: send Anarchy (Open) Power 2023-06-12 17:05:46 +08:00
spacemeowx2 6d044a15ae chore: bump version(0.4.3) 2023-06-06 22:40:52 +08:00
spacemeowx2 63ea9347da feat: implement auto list-method 2023-06-06 22:40:52 +08:00
spacemeowx2 a5f35c78c9 style: remove one line 2023-06-06 22:40:52 +08:00
spacemeowx2 91f528a3be feat: add fetch from all modes 2023-06-06 22:40:52 +08:00
spacemeowx2 8a96cb321c feat: add list-method opt and its query 2023-06-06 22:40:52 +08:00
spacemeowx2 0517bda98d fix: don't print token (oops) 2023-06-05 19:15:27 +08:00
spacemeowx2 cabfa8f8c0 fix: `coral_user_id` is string 2023-06-05 17:06:42 +08:00
28 changed files with 1713 additions and 598 deletions

View File

@ -1,3 +1,20 @@
## 0.4.5
fix: list method is not auto
## 0.4.4
feat: send Anarchy (Open) Power
## 0.4.3
feat: add `list-method` option
([#73](https://github.com/spacemeowx2/s3si.ts/issues/73))
## 0.4.2
fix: `coral_user_id` is string
## 0.4.1 ## 0.4.1
feat: add support for Challenges feat: add support for Challenges

View File

@ -20,12 +20,16 @@ Options:
--exporter <exporter>, -e Exporter list to use (default: stat.ink) --exporter <exporter>, -e Exporter list to use (default: stat.ink)
Multiple exporters can be separated by commas Multiple exporters can be separated by commas
(e.g. "stat.ink,file") (e.g. "stat.ink,file")
--list-method When set to "latest", the latest 50 matches will be obtained.
When set to "all", matches of all modes will be obtained with a maximum of 250 matches (5 modes x 50 matches).
When set to "auto", the latest 50 matches will be obtained. If 50 matches have not been uploaded yet, matches will be obtained from the list of all modes.
"auto" is the default setting.
--no-progress, -n Disable progress bar --no-progress, -n Disable progress bar
--monitor, -m Monitor mode --monitor, -m Monitor mode
--skip-mode <mode>, -s Skip mode (default: null) --skip-mode <mode>, -s Skip mode (default: null)
("vs", "coop") ("vs", "coop")
--with-summary Include summary in the output --with-summary Include summary in the output
--help Show this help message and exit`, --help Show this help message and exit
``` ```
3. If it's your first time running this, follow the instructions to login to 3. If it's your first time running this, follow the instructions to login to

View File

@ -16,6 +16,7 @@
"https://deno.land/std@0.160.0/bytes/mod.ts": "b2e342fd3669176a27a4e15061e9d588b89c1aaf5008ab71766e23669565d179", "https://deno.land/std@0.160.0/bytes/mod.ts": "b2e342fd3669176a27a4e15061e9d588b89c1aaf5008ab71766e23669565d179",
"https://deno.land/std@0.160.0/encoding/base64.ts": "c57868ca7fa2fbe919f57f88a623ad34e3d970d675bdc1ff3a9d02bba7409db2", "https://deno.land/std@0.160.0/encoding/base64.ts": "c57868ca7fa2fbe919f57f88a623ad34e3d970d675bdc1ff3a9d02bba7409db2",
"https://deno.land/std@0.160.0/flags/mod.ts": "686b6b36e14b00f11c9e26cecf439021158436a6e34f60eeb0d927f0b169ae20", "https://deno.land/std@0.160.0/flags/mod.ts": "686b6b36e14b00f11c9e26cecf439021158436a6e34f60eeb0d927f0b169ae20",
"https://deno.land/std@0.160.0/fmt/colors.ts": "9e36a716611dcd2e4865adea9c4bec916b5c60caad4cdcdc630d4974e6bb8bd4",
"https://deno.land/std@0.160.0/io/buffer.ts": "fae02290f52301c4e0188670e730cd902f9307fb732d79c4aa14ebdc82497289", "https://deno.land/std@0.160.0/io/buffer.ts": "fae02290f52301c4e0188670e730cd902f9307fb732d79c4aa14ebdc82497289",
"https://deno.land/std@0.160.0/io/mod.ts": "6e781ebafd5cdccf9ab4afa1f499b08c513602d023cb08ceebc58758501f78bd", "https://deno.land/std@0.160.0/io/mod.ts": "6e781ebafd5cdccf9ab4afa1f499b08c513602d023cb08ceebc58758501f78bd",
"https://deno.land/std@0.160.0/io/readers.ts": "45847ad404afd2f605eae1cff193f223462bc55eeb9ae313c2f3db28aada0fd6", "https://deno.land/std@0.160.0/io/readers.ts": "45847ad404afd2f605eae1cff193f223462bc55eeb9ae313c2f3db28aada0fd6",
@ -32,6 +33,9 @@
"https://deno.land/std@0.160.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", "https://deno.land/std@0.160.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9",
"https://deno.land/std@0.160.0/path/win32.ts": "ee8826dce087d31c5c81cd414714e677eb68febc40308de87a2ce4b40e10fb8d", "https://deno.land/std@0.160.0/path/win32.ts": "ee8826dce087d31c5c81cd414714e677eb68febc40308de87a2ce4b40e10fb8d",
"https://deno.land/std@0.160.0/streams/conversion.ts": "328afbedee0a7e0c330ac4c7b4c1af569ee53974f970230f6a78f545b93abb9b", "https://deno.land/std@0.160.0/streams/conversion.ts": "328afbedee0a7e0c330ac4c7b4c1af569ee53974f970230f6a78f545b93abb9b",
"https://deno.land/std@0.160.0/testing/_diff.ts": "a23e7fc2b4d8daa3e158fa06856bedf5334ce2a2831e8bf9e509717f455adb2c",
"https://deno.land/std@0.160.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832",
"https://deno.land/std@0.160.0/testing/asserts.ts": "1e340c589853e82e0807629ba31a43c84ebdcdeca910c4a9705715dfdb0f5ce8",
"https://deno.land/std@0.160.0/uuid/_common.ts": "76e1fdfb03aecf733f7b3a5edc900f5734f2433b359fdb1535f8de72873bdb3f", "https://deno.land/std@0.160.0/uuid/_common.ts": "76e1fdfb03aecf733f7b3a5edc900f5734f2433b359fdb1535f8de72873bdb3f",
"https://deno.land/std@0.160.0/uuid/mod.ts": "e57ba10200d75f2b17570f13eba19faa6734b1be2da5091e2c01039df41274a5", "https://deno.land/std@0.160.0/uuid/mod.ts": "e57ba10200d75f2b17570f13eba19faa6734b1be2da5091e2c01039df41274a5",
"https://deno.land/std@0.160.0/uuid/v1.ts": "7123410ef9ce980a4f2e54a586ccde5ed7063f6f119a70d86eebd92f8e100295", "https://deno.land/std@0.160.0/uuid/v1.ts": "7123410ef9ce980a4f2e54a586ccde5ed7063f6f119a70d86eebd92f8e100295",

View File

@ -1,7 +1,7 @@
# Tauri + React + Typescript # s3si.ts GUI
This template should help get you started developing with Tauri, React and Typescript in Vite. ## Development
## Recommended IDE Setup ```
pnpm tauri dev
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) ```

View File

@ -11,31 +11,32 @@
"lint": "eslint --max-warnings=0 src" "lint": "eslint --max-warnings=0 src"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^1.3.0", "@tauri-apps/api": "^1.4.0",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"daisyui": "^2.52.0", "daisyui": "^3.1.7",
"i18next": "^22.5.0", "i18next": "^23.2.6",
"i18next-browser-languagedetector": "^7.0.2", "i18next-browser-languagedetector": "^7.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^12.3.1", "react-i18next": "^13.0.1",
"react-icons": "^4.9.0", "react-icons": "^4.10.1",
"react-router-dom": "^6.11.2", "react-router-dom": "^6.14.1",
"react-use": "^17.4.0" "react-use": "^17.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^1.3.1", "@tauri-apps/cli": "^1.4.0",
"@types/node": "^20.2.5", "@types/node": "^20.3.3",
"@types/react": "^18.0.15", "@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^4.0.0", "@typescript-eslint/parser": "^5.61.0",
"@vitejs/plugin-react": "^4.0.1",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"eslint": "^8.41.0", "eslint": "^8.44.0",
"eslint-config-react-app": "^7.0.1", "eslint-config-react-app": "^7.0.1",
"i18next-http-backend": "^2.2.1", "i18next-http-backend": "^2.2.1",
"postcss": "^8.4.24", "postcss": "^8.4.24",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"typescript": "^5.0.4", "typescript": "^5.1.6",
"vite": "^4.3.9", "vite": "^4.3.9",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^4.2.0" "vite-tsconfig-paths": "^4.2.0"

File diff suppressed because it is too large Load Diff

645
gui/src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,14 +10,21 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies] [build-dependencies]
tauri-build = { version = "1.2", features = [] } tauri-build = { version = "1.4.0", features = [] }
[dependencies] [dependencies]
tauri = { version = "1.2", features = ["fs-all", "path-all", "process-relaunch", "shell-execute", "shell-open", "shell-sidecar", "window-all"] } tauri = { version = "1.4.1", features = [
serde = { version = "1.0", features = ["derive"] } "fs-all",
serde_json = "1.0" "path-all",
tokio = { version = "1.0", features = ["time"] } "process-relaunch",
urlencoding = "2.1.2" "shell-execute",
"shell-open",
"shell-sidecar",
"window-all",
] }
serde = { version = "1.0.164", features = ["derive"] }
serde_json = "1.0.97"
tokio = { version = "1.28.2", features = ["time"] }
[features] [features]
# this feature is used for production builds or when `devPath` points to the filesystem # this feature is used for production builds or when `devPath` points to the filesystem

View File

@ -15,14 +15,12 @@ function onSelectUserClick(e) {
} }
e.preventDefault(); e.preventDefault();
// very hacky way... // a little official way...
window.ipc.postMessage(JSON.stringify({ window.__TAURI_INVOKE__({
"cmd":"tauri",
"callback":0,
"error":0,
"__tauriModule":"Event", "__tauriModule":"Event",
"cmd": "tauri",
"message":{"cmd":"emit","event":"login","payload":{"url":element.href}} "message":{"cmd":"emit","event":"login","payload":{"url":element.href}}
})) })
} }
function detectAndInject() { function detectAndInject() {
const element = document.getElementById('authorize-switch-approval-link'); const element = document.getElementById('authorize-switch-approval-link');
@ -74,12 +72,7 @@ document.addEventListener("DOMContentLoaded", () => {{
#[tauri::command] #[tauri::command]
async fn open_login_window(app: tauri::AppHandle, url: String) -> Option<String> { async fn open_login_window(app: tauri::AppHandle, url: String) -> Option<String> {
let encoded = urlencoding::encode(&url); let window = WindowBuilder::new(&app, "login", tauri::WindowUrl::App(url.into()))
let window = WindowBuilder::new(
&app,
"login",
tauri::WindowUrl::App(format!("/redirect?url={encoded}").into()),
)
.title("Login") .title("Login")
.center() .center()
.inner_size(1040.0, 960.0) .inner_size(1040.0, 960.0)

View File

@ -1,4 +1,5 @@
{ {
"$schema": "https://github.com/tauri-apps/tauri/raw/tauri-v1.4.1/core/tauri-config-schema/schema.json",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build", "beforeBuildCommand": "pnpm build",
@ -8,7 +9,7 @@
}, },
"package": { "package": {
"productName": "s3si-ts", "productName": "s3si-ts",
"version": "0.4.1" "version": "0.4.5"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {
@ -69,7 +70,16 @@
] ]
}, },
"security": { "security": {
"csp": null "csp": null,
"dangerousRemoteDomainIpcAccess": [
{
"windows": [
"login"
],
"domain": "accounts.nintendo.com",
"enableTauriAPI": true
}
]
}, },
"updater": { "updater": {
"active": false, "active": false,

View File

@ -4,7 +4,6 @@ import { Layout } from "components/Layout";
import { Home } from "pages/Home"; import { Home } from "pages/Home";
import { Settings } from "pages/Settings"; import { Settings } from "pages/Settings";
import { Guide } from 'pages/Guide'; import { Guide } from 'pages/Guide';
import { RedirectLogin } from 'pages/RedirectLogin';
import { useShowWindow } from 'hooks/useShowWindow'; import { useShowWindow } from 'hooks/useShowWindow';
function App() { function App() {
@ -15,7 +14,6 @@ function App() {
<Route index element={<Home />} /> <Route index element={<Home />} />
<Route path='/settings' element={<Settings />} /> <Route path='/settings' element={<Settings />} />
<Route path='/guide' element={<Guide />} /> <Route path='/guide' element={<Guide />} />
<Route path='/redirect' element={<RedirectLogin />} />
</Route> </Route>
</Routes> </Routes>
); );

View File

@ -51,10 +51,10 @@ export const OpenSplatnet: React.FC<OpenSplatnetProps> = ({ children }) => {
</> </>
} }
const btnLoading = loading || doing;
return <> return <>
<button className={classNames('btn', { <button className={classNames('btn w-full', {
'btn-disabled': !result?.profile.state.loginState?.sessionToken, 'btn-disabled': !result?.profile.state.loginState?.sessionToken,
'loading': loading || doing, })} onClick={onClick} disabled={btnLoading}>{btnLoading ? <span className='loading' /> : children}</button>
})} onClick={onClick}>{children}</button>
</> </>
} }

View File

@ -61,11 +61,11 @@ export const RunPanel: React.FC<RunPanelProps> = () => {
<Checkbox disabled={disabled || loading} value={exportCoop} onChange={setExportCoop}>{t('导出打工数据')}</Checkbox> <Checkbox disabled={disabled || loading} value={exportCoop} onChange={setExportCoop}>{t('导出打工数据')}</Checkbox>
<button <button
onClick={onClick} onClick={onClick}
className={classNames('btn w-full', { className={classNames('btn btn-primary w-full', {
'btn-disabled': disabled || (!exportBattle && !exportCoop), 'btn-disabled': disabled || (!exportBattle && !exportCoop),
'loading': loading,
})} })}
>{t('导出')}</button> disabled={loading}
>{loading ? <span className='loading' /> : t('导出')}</button>
</div> </div>
</> </>
} }

View File

@ -2,40 +2,17 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
body { body {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
} }
#root { #root {
width: 100vw;
height: 100vh; height: 100vh;
overflow: hidden overflow: hidden
} }
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
}
/* custom classes */ /* custom classes */
.flex-auto-all > * { .flex-auto-all > * {

View File

@ -16,7 +16,7 @@ export const Home: React.FC = () => {
<Link to='/settings' className='btn'>{t('设置')}</Link> <Link to='/settings' className='btn'>{t('设置')}</Link>
<div className='flex gap-2 flex-auto-all'> <div className='flex gap-2 flex-auto-all'>
<OpenSplatnet>{t('打开鱿鱼圈3')}</OpenSplatnet> <OpenSplatnet>{t('打开鱿鱼圈3')}</OpenSplatnet>
<a className='btn' href={STAT_INK} target='_blank' rel='noreferrer'>{t('前往 stat.ink')}</a> <a className='btn w-full' href={STAT_INK} target='_blank' rel='noreferrer'>{t('前往 stat.ink')}</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,25 +0,0 @@
import { Loading } from 'components/Loading';
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-use';
export const RedirectLogin: React.FC = () => {
const { t } = useTranslation();
const state = useLocation();
useEffect(() => {
const search = state.search ?? '';
const index = search.indexOf('url=');
if (index === -1) {
return;
}
const url = decodeURIComponent(search.substring(index + 4));
window.location.href = url;
}, [state])
return <div className='h-full flex justify-center items-center'>
<span className='flex justify-center items-center gap-1'><Loading className='align-middle' />{t('正在跳转到登录页面...')}</span>
</div>
}

View File

@ -4,7 +4,7 @@ import { flags } from "./deps.ts";
const parseArgs = (args: string[]) => { const parseArgs = (args: string[]) => {
const parsed = flags.parse(args, { const parsed = flags.parse(args, {
string: ["profilePath", "exporter", "skipMode"], string: ["profilePath", "exporter", "skipMode", "listMethod"],
boolean: ["help", "noProgress", "monitor", "withSummary"], boolean: ["help", "noProgress", "monitor", "withSummary"],
alias: { alias: {
"help": "h", "help": "h",
@ -15,6 +15,7 @@ const parseArgs = (args: string[]) => {
"skipMode": ["s", "skip-mode"], "skipMode": ["s", "skip-mode"],
"withSummary": "with-summary", "withSummary": "with-summary",
"withStages": "with-stages", "withStages": "with-stages",
"listMethod": "list-method",
}, },
}); });
return parsed; return parsed;
@ -30,6 +31,10 @@ Options:
--exporter <exporter>, -e Exporter list to use (default: stat.ink) --exporter <exporter>, -e Exporter list to use (default: stat.ink)
Multiple exporters can be separated by commas Multiple exporters can be separated by commas
(e.g. "stat.ink,file,mongodb") (e.g. "stat.ink,file,mongodb")
--list-method When set to "latest", the latest 50 matches will be obtained.
When set to "all", matches of all modes will be obtained with a maximum of 250 matches (5 modes x 50 matches).
When set to "auto", the latest 50 matches will be obtained. If 50 matches have not been uploaded yet, matches will be obtained from the list of all modes.
"auto" is the default setting.
--no-progress, -n Disable progress bar --no-progress, -n Disable progress bar
--monitor, -m Monitor mode --monitor, -m Monitor mode
--skip-mode <mode>, -s Skip mode (default: null) --skip-mode <mode>, -s Skip mode (default: null)

View File

@ -87,7 +87,7 @@
}, },
"npm": { "npm": {
"specifiers": { "specifiers": {
"mongodb": "mongodb@5.1.0", "mongodb": "mongodb@5.5.0",
"splatnet3-types": "splatnet3-types@0.2.20230227204004" "splatnet3-types": "splatnet3-types@0.2.20230227204004"
}, },
"packages": { "packages": {
@ -110,6 +110,10 @@
"integrity": "sha512-y09gBGusgHtinMon/GVbv1J6FrXhnr/+6hqLlSmEFzkz6PodqF6TxjyvfvY3AfO+oG1mgUtbC86xSbOlwvM62Q==", "integrity": "sha512-y09gBGusgHtinMon/GVbv1J6FrXhnr/+6hqLlSmEFzkz6PodqF6TxjyvfvY3AfO+oG1mgUtbC86xSbOlwvM62Q==",
"dependencies": {} "dependencies": {}
}, },
"bson@5.3.0": {
"integrity": "sha512-ukmCZMneMlaC5ebPHXIkP8YJzNl5DC41N5MAIvKDqLggdao342t4McltoJBQfQya/nHBWAcSsYRqlXPoQkTJag==",
"dependencies": {}
},
"ip@2.0.0": { "ip@2.0.0": {
"integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
"dependencies": {} "dependencies": {}
@ -134,6 +138,15 @@
"socks": "socks@2.7.1" "socks": "socks@2.7.1"
} }
}, },
"mongodb@5.5.0": {
"integrity": "sha512-XgrkUgAAdfnZKQfk5AsYL8j7O99WHd4YXPxYxnh8dZxD+ekYWFRA3JktUsBnfg+455Smf75/+asoU/YLwNGoQQ==",
"dependencies": {
"bson": "bson@5.3.0",
"mongodb-connection-string-url": "mongodb-connection-string-url@2.6.0",
"saslprep": "saslprep@1.0.3",
"socks": "socks@2.7.1"
}
},
"punycode@2.3.0": { "punycode@2.3.0": {
"integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==",
"dependencies": {} "dependencies": {}

View File

@ -5,7 +5,7 @@ import {
HistoryGroups, HistoryGroups,
RankParam, RankParam,
} from "./types.ts"; } from "./types.ts";
import { gameId, parseHistoryDetailId } from "./utils.ts"; import { battleTime, gameId } from "./utils.ts";
import { getSeason } from "./VersionData.ts"; import { getSeason } from "./VersionData.ts";
const splusParams = () => { const splusParams = () => {
@ -193,17 +193,6 @@ function addRank(
}; };
} }
const battleTime = (id: string) => {
const { timestamp } = parseHistoryDetailId(id);
const dateStr = timestamp.replace(
/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/,
"$1-$2-$3T$4:$5:$6Z",
);
return new Date(dateStr);
};
type FlattenItem = { type FlattenItem = {
id: string; id: string;
gameId: string; gameId: string;
@ -358,6 +347,10 @@ export class RankTracker {
async updateState( async updateState(
history: HistoryGroups<BattleListNode>["nodes"], history: HistoryGroups<BattleListNode>["nodes"],
) { ) {
if (history.length === 0) {
return;
}
// history order by time. 0 is the oldest. // history order by time. 0 is the oldest.
const flatten: FlattenItem[] = await Promise.all( const flatten: FlattenItem[] = await Promise.all(
history history

View File

@ -1,8 +1,8 @@
import { loginManually } from "./iksm.ts"; import { loginManually } from "./iksm.ts";
import { MultiProgressBar } from "../deps.ts"; import { MultiProgressBar, Mutex } from "../deps.ts";
import { FileStateBackend, Profile, StateBackend } from "./state.ts"; import { FileStateBackend, Profile, StateBackend } from "./state.ts";
import { Splatnet3 } from "./splatnet3.ts"; import { Splatnet3 } from "./splatnet3.ts";
import { BattleListType, Game, GameExporter } from "./types.ts"; import { BattleListType, Game, GameExporter, ListMethod } from "./types.ts";
import { Cache, FileCache } from "./cache.ts"; import { Cache, FileCache } from "./cache.ts";
import { StatInkExporter } from "./exporters/stat.ink.ts"; import { StatInkExporter } from "./exporters/stat.ink.ts";
import { FileExporter } from "./exporters/file.ts"; import { FileExporter } from "./exporters/file.ts";
@ -10,6 +10,7 @@ import { delay, showError } from "./utils.ts";
import { GameFetcher } from "./GameFetcher.ts"; import { GameFetcher } from "./GameFetcher.ts";
import { DEFAULT_ENV, Env } from "./env.ts"; import { DEFAULT_ENV, Env } from "./env.ts";
import { MongoDBExporter } from "./exporters/mongodb.ts"; import { MongoDBExporter } from "./exporters/mongodb.ts";
import { SplashcatExporter } from "./exporters/splashcat.ts";
export type Opts = { export type Opts = {
profilePath: string; profilePath: string;
@ -19,6 +20,7 @@ export type Opts = {
withSummary: boolean; withSummary: boolean;
withStages: boolean; withStages: boolean;
skipMode?: string; skipMode?: string;
listMethod?: string;
cache?: Cache; cache?: Cache;
stateBackend?: StateBackend; stateBackend?: StateBackend;
env: Env; env: Env;
@ -31,6 +33,7 @@ export const DEFAULT_OPTS: Opts = {
monitor: false, monitor: false,
withSummary: false, withSummary: false,
withStages: true, withStages: true,
listMethod: "auto",
env: DEFAULT_ENV, env: DEFAULT_ENV,
}; };
@ -55,6 +58,103 @@ class StepProgress {
} }
} }
interface GameListFetcher {
/**
* Return not exported game list.
* [0] is the latest game.
* @param exporter GameExporter
*/
fetch(exporter: GameExporter): Promise<string[]>;
}
class BattleListFetcher implements GameListFetcher {
protected listMethod: ListMethod;
protected allBattleList?: string[];
protected latestBattleList?: string[];
protected allLock = new Mutex();
protected latestLock = new Mutex();
constructor(
listMethod: string,
protected splatnet: Splatnet3,
) {
if (listMethod === "all") {
this.listMethod = "all";
} else if (listMethod === "latest") {
this.listMethod = "latest";
} else {
this.listMethod = "auto";
}
}
protected getAllBattleList() {
return this.allLock.use(async () => {
if (!this.allBattleList) {
this.allBattleList = await this.splatnet.getAllBattleList();
}
return this.allBattleList;
});
}
protected getLatestBattleList() {
return this.latestLock.use(async () => {
if (!this.latestBattleList) {
this.latestBattleList = await this.splatnet.getBattleList();
}
return this.latestBattleList;
});
}
private async innerFetch(exporter: GameExporter) {
if (this.listMethod === "latest") {
return await exporter.notExported({
type: "VsInfo",
list: await this.getLatestBattleList(),
});
}
if (this.listMethod === "all") {
return await exporter.notExported({
type: "VsInfo",
list: await this.getAllBattleList(),
});
}
if (this.listMethod === "auto") {
const latestList = await exporter.notExported({
type: "VsInfo",
list: await this.getLatestBattleList(),
});
if (latestList.length === 50) {
return await exporter.notExported({
type: "VsInfo",
list: await this.getAllBattleList(),
});
}
return latestList;
}
throw new TypeError(`Unknown listMethod: ${this.listMethod}`);
}
async fetch(exporter: GameExporter) {
return [...await this.innerFetch(exporter)].reverse();
}
}
class CoopListFetcher implements GameListFetcher {
constructor(
protected splatnet: Splatnet3,
) {}
async fetch(exporter: GameExporter) {
return [
...await exporter.notExported({
type: "CoopInfo",
list: await this.splatnet.getBattleList(BattleListType.Coop),
}),
].reverse();
}
}
function progress({ total, currentUrl, done }: StepProgress): Progress { function progress({ total, currentUrl, done }: StepProgress): Progress {
return { return {
total, total,
@ -75,6 +175,12 @@ export class App {
env: opts.env, env: opts.env,
}); });
this.env = opts.env; this.env = opts.env;
if (
opts.listMethod && !["all", "auto", "latest"].includes(opts.listMethod)
) {
throw new TypeError(`Unknown listMethod: ${opts.listMethod}`);
}
} }
getSkipMode(): ("vs" | "coop")[] { getSkipMode(): ("vs" | "coop")[] {
@ -137,6 +243,14 @@ export class App {
); );
} }
if (exporters.includes("splashcat")) {
out.push(new SplashcatExporter({
env: this.env,
uploadMode: this.opts.monitor ? "Monitoring" : "Manual",
splashcatApiKey: this.profile.state.splashcatApiKey!,
}));
}
return out; return out;
} }
exporterProgress(title: string) { exporterProgress(title: string) {
@ -184,8 +298,10 @@ export class App {
if (skipMode.includes("vs") || exporters.length === 0) { if (skipMode.includes("vs") || exporters.length === 0) {
this.env.logger.log("Skip exporting VS games."); this.env.logger.log("Skip exporting VS games.");
} else { } else {
this.env.logger.log("Fetching battle list..."); const gameListFetcher = new BattleListFetcher(
const gameList = await splatnet.getBattleList(); this.opts.listMethod ?? "auto",
splatnet,
);
const { redraw, endBar } = this.exporterProgress("Export vs games"); const { redraw, endBar } = this.exporterProgress("Export vs games");
const fetcher = new GameFetcher({ const fetcher = new GameFetcher({
@ -204,7 +320,7 @@ export class App {
type: "VsInfo", type: "VsInfo",
fetcher, fetcher,
exporter: e, exporter: e,
gameList, gameListFetcher,
stepProgress: stats[e.name], stepProgress: stats[e.name],
onStep: () => { onStep: () => {
redraw(e.name, progress(stats[e.name])); redraw(e.name, progress(stats[e.name]));
@ -238,10 +354,7 @@ export class App {
if (skipMode.includes("coop") || exporters.length === 0) { if (skipMode.includes("coop") || exporters.length === 0) {
this.env.logger.log("Skip exporting coop games."); this.env.logger.log("Skip exporting coop games.");
} else { } else {
this.env.logger.log("Fetching coop battle list..."); const gameListFetcher = new CoopListFetcher(splatnet);
const coopBattleList = await splatnet.getBattleList(
BattleListType.Coop,
);
const { redraw, endBar } = this.exporterProgress("Export coop games"); const { redraw, endBar } = this.exporterProgress("Export coop games");
const fetcher = new GameFetcher({ const fetcher = new GameFetcher({
@ -258,7 +371,7 @@ export class App {
type: "CoopInfo", type: "CoopInfo",
fetcher, fetcher,
exporter: e, exporter: e,
gameList: coopBattleList, gameListFetcher,
stepProgress: stats[e.name], stepProgress: stats[e.name],
onStep: () => { onStep: () => {
redraw(e.name, progress(stats[e.name])); redraw(e.name, progress(stats[e.name]));
@ -392,30 +505,24 @@ export class App {
* @param gameList ID list of games, sorted by date, newest first * @param gameList ID list of games, sorted by date, newest first
* @param onStep Callback function called when a game is exported * @param onStep Callback function called when a game is exported
*/ */
async exportGameList({ private async exportGameList({
type, type,
fetcher, fetcher,
exporter, exporter,
gameList, gameListFetcher,
stepProgress, stepProgress,
onStep, onStep,
}: { }: {
type: Game["type"]; type: Game["type"];
exporter: GameExporter; exporter: GameExporter;
fetcher: GameFetcher; fetcher: GameFetcher;
gameList: string[]; gameListFetcher: GameListFetcher;
stepProgress: StepProgress; stepProgress: StepProgress;
onStep: () => void; onStep: () => void;
}): Promise<StepProgress> { }): Promise<StepProgress> {
onStep?.(); onStep?.();
const workQueue = [ const workQueue = await gameListFetcher.fetch(exporter);
...await exporter.notExported({
type,
list: gameList,
}),
]
.reverse();
const step = async (id: string) => { const step = async (id: string) => {
const detail = await fetcher.fetch(type, id); const detail = await fetcher.fetch(type, id);

View File

@ -2,9 +2,9 @@ import type { StatInkPostBody, VsHistoryDetail } from "./types.ts";
export const AGENT_NAME = "splashcat / s3si.ts"; export const AGENT_NAME = "splashcat / s3si.ts";
export const AGENT_VERSION = "1.1.1"; export const AGENT_VERSION = "1.1.1";
export const S3SI_VERSION = "0.4.1"; export const S3SI_VERSION = "0.4.5";
export const COMBINED_VERSION = `${AGENT_VERSION}/${S3SI_VERSION}`; export const COMBINED_VERSION = `${AGENT_VERSION}/${S3SI_VERSION}`;
export const NSOAPP_VERSION = "2.5.1"; export const NSOAPP_VERSION = "2.6.0";
export const WEB_VIEW_VERSION = "4.0.0-d5178440"; export const WEB_VIEW_VERSION = "4.0.0-d5178440";
export enum Queries { export enum Queries {
HomeQuery = "7dcc64ea27a08e70919893a0d3f70871", HomeQuery = "7dcc64ea27a08e70919893a0d3f70871",
@ -12,6 +12,7 @@ export enum Queries {
RegularBattleHistoriesQuery = "3baef04b095ad8975ea679d722bc17de", RegularBattleHistoriesQuery = "3baef04b095ad8975ea679d722bc17de",
BankaraBattleHistoriesQuery = "0438ea6978ae8bd77c5d1250f4f84803", BankaraBattleHistoriesQuery = "0438ea6978ae8bd77c5d1250f4f84803",
XBattleHistoriesQuery = "6796e3cd5dc3ebd51864dc709d899fc5", XBattleHistoriesQuery = "6796e3cd5dc3ebd51864dc709d899fc5",
EventBattleHistoriesQuery = "9744fcf676441873c7c8a51285b6aa4d",
PrivateBattleHistoriesQuery = "8e5ae78b194264a6c230e262d069bd28", PrivateBattleHistoriesQuery = "8e5ae78b194264a6c230e262d069bd28",
VsHistoryDetailQuery = "9ee0099fbe3d8db2a838a75cf42856dd", VsHistoryDetailQuery = "9ee0099fbe3d8db2a838a75cf42856dd",
CoopHistoryQuery = "91b917becd2fa415890f5b47e15ffb15", CoopHistoryQuery = "91b917becd2fa415890f5b47e15ffb15",

View File

@ -0,0 +1,146 @@
/**
* A battle to be uploaded to Splashcat. Any SplatNet 3 strings should use en-US locale.
* Splashcat will translate strings into the user's language.
*/
export interface SplashcatBattle {
anarchy?: Anarchy;
/**
* The en-US string for the award. Splashcat will translate this into the user's language
* and manage the award's rank.
*/
awards: string[];
challenge?: Challenge;
duration: number;
judgement: SplashcatBattleJudgement;
knockout?: Knockout;
playedTime: Date;
splatfest?: Splatfest;
/**
* base64 decoded and split by `:` to get the last section
*/
splatnetId: string;
teams: Team[];
vsMode: VsMode;
vsRule: VsRule;
vsStageId: number;
xBattle?: XBattle;
[property: string]: any;
}
export interface Anarchy {
mode?: AnarchyMode;
pointChange?: number;
points?: number;
power?: number;
rank?: Rank;
sPlusNumber?: number;
[property: string]: any;
}
export type AnarchyMode = "SERIES" | "OPEN";
export type Rank = "C-" | "C" | "C+" | "B-" | "B" | "B+" | "A-" | "A" | "A+" | "S" | "S+";
export interface Challenge {
/**
* base64 decoded and split by `-` to get the last section
*/
id?: string;
power?: number;
[property: string]: any;
}
export type SplashcatBattleJudgement = "WIN" | "LOSE" | "DRAW" | "EXEMPTED_LOSE" | "DEEMED_LOSE";
export type Knockout = "NEITHER" | "WIN" | "LOSE";
export interface Splatfest {
cloutMultiplier?: CloutMultiplier;
mode?: SplatfestMode;
power?: number;
[property: string]: any;
}
export type CloutMultiplier = "NONE" | "DECUPLE" | "DRAGON" | "DOUBLE_DRAGON";
export type SplatfestMode = "OPEN" | "PRO";
export interface Team {
color: Color;
festStreakWinCount?: number;
festTeamName?: string;
festUniformBonusRate?: number;
festUniformName?: string;
isMyTeam: boolean;
judgement?: TeamJudgement;
noroshi?: number;
order: number;
paintRatio?: number;
players?: Player[];
score?: number;
tricolorRole?: TricolorRole;
[property: string]: any;
}
export interface Color {
a: number;
b: number;
g: number;
r: number;
[property: string]: any;
}
export type TeamJudgement = "WIN" | "LOSE" | "DRAW";
export interface Player {
assists?: number;
/**
* Array of badge IDs. Use JSON `null` for empty slots.
*/
badges: Array<number | null>;
clothingGear: Gear;
deaths?: number;
disconnected: boolean;
headGear: Gear;
isMe: boolean;
/**
* Should report the same way that SplatNet 3 does (kills + assists)
*/
kills?: number;
name: string;
nameId?: string;
noroshiTry?: number;
nplnId: string;
paint: number;
shoesGear: Gear;
specials?: number;
species: Species;
splashtagBackgroundId: number;
title: string;
weaponId: number;
[property: string]: any;
}
/**
* A piece of gear. Use en-US locale for name and all abilities.
*/
export interface Gear {
name?: string;
primaryAbility?: string;
secondaryAbilities?: string[];
[property: string]: any;
}
export type Species = "INKLING" | "OCTOLING";
export type TricolorRole = "ATTACK1" | "ATTACK2" | "DEFENSE";
export type VsMode = "BANKARA" | "X_MATCH" | "REGULAR" | "FEST" | "PRIVATE" | "CHALLENGE";
export type VsRule = "AREA" | "TURF_WAR" | "TRI_COLOR" | "LOFT" | "CLAM" | "GOAL";
export interface XBattle {
xPower?: number;
xRank?: number;
[property: string]: any;
}

295
src/exporters/splashcat.ts Normal file
View File

@ -0,0 +1,295 @@
import {
USERAGENT,
} from "../constant.ts";
import {
Color,
ExportResult,
Game,
GameExporter,
Nameplate,
PlayerGear,
StatInkPostBody,
VsHistoryDetail,
VsInfo,
VsPlayer,
VsTeam,
} from "../types.ts";
import { base64, msgpack, Mutex } from "../../deps.ts";
import { APIError } from "../APIError.ts";
import {
b64Number,
gameId,
parseHistoryDetailId,
} from "../utils.ts";
import { Env } from "../env.ts";
import { Gear, Player, SplashcatBattle, Team, TeamJudgement } from "./splashcat-types.ts";
class SplashcatAPI {
splashcatApiBase = "https://splashcat.ink";
FETCH_LOCK = new Mutex();
cache: Record<string, unknown> = {};
constructor(private splashcatApiKey: string, private env: Env) {}
requestHeaders() {
return {
"User-Agent": USERAGENT,
"Authorization": `Bearer ${this.splashcatApiKey}`,
};
}
async uuidList(): Promise<string[]> {
const fetch = this.env.newFetcher();
const response = await fetch.get({
url: `${this.splashcatApiBase}/battles/api/recent/`,
headers: this.requestHeaders(),
});
const uuidResult: Record<string, unknown> = await response.json();
return uuidResult.battle_ids as string[];
}
async postBattle(body: unknown) {
const fetch = this.env.newFetcher();
const resp = await fetch.post({
url: `${this.splashcatApiBase}/battles/api/upload/`,
headers: {
...this.requestHeaders(),
"Content-Type": "application/x-msgpack",
},
body: msgpack.encode(body),
});
const json: unknown = {}//await resp.json().catch(() => ({}));
console.log(json)
// read the body again as text
const text = await resp.text();
console.log(text);
if (resp.status !== 200 && resp.status !== 201) {
throw new APIError({
response: resp,
message: "Failed to export battle",
json,
});
}
if (json.error) {
throw new APIError({
response: resp,
message: "Failed to export battle",
json,
});
}
return json;
}
}
export class SplashcatExporter implements GameExporter {
name = "Splashcat";
private api: SplashcatAPI;
private uploadMode: string;
constructor(
{ splashcatApiKey, uploadMode, env }: {
splashcatApiKey: string;
uploadMode: string;
env: Env;
},
) {
this.api = new SplashcatAPI(splashcatApiKey, env);
this.uploadMode = uploadMode;
}
async exportGame(game: Game): Promise<ExportResult> {
if (game.type === "VsInfo") {
const body = await this.mapBattle(game);
const resp = await this.api.postBattle(body);
console.log(resp);
return {
status: "success",
url: undefined,
};
} else {
return {
status: "skip",
reason: "Splashcat API does not support Salmon Run",
}
}
}
static getGameIdOld(id: string) { // very similar to the file exporter
const { uid, timestamp } = parseHistoryDetailId(id);
return `${uid}_${timestamp}Z`;
}
static getGameId(id: string) {
const plainText = new TextDecoder().decode(base64.decode(id));
return plainText.split(':').at(-1);
}
async notExported(
{ type, list }: { list: string[]; type: Game["type"] },
): Promise<string[]> {
if (type !== "VsInfo") return [];
const uuid = await this.api.uuidList();
const out: string[] = [];
for (const id of list) {
const oldGameId = SplashcatExporter.getGameIdOld(id);
const gameId = SplashcatExporter.getGameId(id);
if (
!uuid.includes(oldGameId) && !uuid.includes(gameId)
) {
out.push(id);
}
}
return out;
}
mapPlayer = (
player: VsPlayer,
_index: number,
): Player => {
const result: Player = {
badges: (player.nameplate as Nameplate).badges.map((i) => i ? Number(new TextDecoder().decode(base64.decode(i.id)).split("-")[1]) : null),
splashtagBackgroundId: Number(new TextDecoder().decode(base64.decode((player.nameplate as Nameplate).background.id)).split('-')[1]),
clothingGear: this.mapGear(player.clothingGear),
headGear: this.mapGear(player.headGear),
shoesGear: this.mapGear(player.shoesGear),
disconnected: player.result ? false : true,
isMe: player.isMyself,
name: player.name,
nameId: player.nameId ?? "",
nplnId: new TextDecoder().decode(base64.decode(player.id)).split(":").at(-1),
paint: player.paint,
species: player.species,
weaponId: Number(new TextDecoder().decode(base64.decode(player.weapon.id)).split("-")[1]),
assists: player.result?.assist,
deaths: player.result?.death,
kills: player.result?.kill,
specials: player.result?.special,
noroshiTry: player.result?.noroshiTry ?? undefined,
title: player.byname,
}
return result;
};
async mapBattle(
{
groupInfo,
challengeProgress,
bankaraMatchChallenge,
listNode,
detail: vsDetail,
rankBeforeState,
rankState,
}: VsInfo,
): Promise<Record<string, unknown>> {
const {
knockout,
vsRule: { rule },
myTeam,
otherTeams,
bankaraMatch,
festMatch,
playedTime,
} = vsDetail;
const self = vsDetail.myTeam.players.find((i) => i.isMyself);
if (!self) {
throw new Error("Self not found");
}
if (otherTeams.length === 0) {
throw new Error(`Other teams is empty`);
}
let anarchyMode: "OPEN" | "SERIES" | undefined;
if (vsDetail.bankaraMatch?.mode) {
anarchyMode = vsDetail.bankaraMatch.mode === "OPEN" ? "OPEN" : "SERIES"
}
const rank = rankState?.rank.substring(0, 2) ?? undefined;
const sPlusNumber = rankState?.rank.substring(2) ?? undefined;
const result: SplashcatBattle = {
splatnetId: await SplashcatExporter.getGameId(vsDetail.id),
duration: vsDetail.duration,
judgement: vsDetail.judgement,
playedTime: new Date(vsDetail.playedTime).toISOString(),
vsMode: vsDetail.vsMode.mode === "LEAGUE" ? "CHALLENGE" : vsDetail.vsMode.mode,
vsRule: vsDetail.vsRule.rule,
vsStageId: Number(new TextDecoder().decode(base64.decode(vsDetail.vsStage.id)).split("-")[1]),
anarchy: vsDetail.vsMode.mode === "BANKARA" ? {
mode: anarchyMode,
pointChange: vsDetail.bankaraMatch?.earnedUdemaePoint ?? undefined,
power: vsDetail.bankaraMatch?.bankaraPower?.power ?? undefined,
points: rankState?.rankPoint ?? undefined,
rank,
sPlusNumber: sPlusNumber ? Number(sPlusNumber) : undefined,
} : undefined,
knockout: vsDetail.knockout ?? undefined,
splatfest: vsDetail.vsMode.mode === "FEST" ? {
cloutMultiplier: vsDetail.festMatch?.dragonMatchType === "NORMAL" ? "NONE" : (vsDetail.festMatch?.dragonMatchType ?? undefined),
power: vsDetail.festMatch?.myFestPower ?? undefined,
} : undefined,
xBattle: vsDetail.vsMode.mode === "X_MATCH" ? {
xPower: vsDetail.xMatch?.lastXPower ?? undefined,
} : undefined,
challenge: vsDetail.vsMode.mode === "LEAGUE" ? {
id: new TextDecoder().decode(base64.decode(vsDetail.leagueMatch?.leagueMatchEvent?.id!)).split("-")[1],
power: vsDetail.leagueMatch?.myLeaguePower ?? undefined,
} : undefined,
teams: [],
awards: vsDetail.awards.map((i) => i.name),
};
const teams: VsTeam[] = [vsDetail.myTeam, ...vsDetail.otherTeams];
for (const team of teams) {
const players = team.players.map(this.mapPlayer);
const teamResult: Team = {
players,
color: team.color,
isMyTeam: team.players.find((i) => i.isMyself) !== undefined,
judgement: team.judgement as TeamJudgement,
order: team.order as number,
festStreakWinCount: team.festStreakWinCount as unknown as number ?? undefined,
festTeamName: team.festTeamName ?? undefined,
festUniformBonusRate: team.festUniformBonusRate as unknown as number ?? undefined,
festUniformName: team.festUniformName as unknown as string ?? undefined,
noroshi: team.result?.noroshi ?? undefined,
paintRatio: team.result?.paintRatio ?? undefined,
score: team.result?.score ?? undefined,
tricolorRole: team.tricolorRole ?? undefined,
}
result.teams.push(teamResult);
}
return {
battle: result,
data_type: "splashcat"
}
}
mapColor(color: Color): string | undefined {
const float2hex = (i: number) =>
Math.round(i * 255).toString(16).padStart(2, "0");
// rgba
const nums = [color.r, color.g, color.b, color.a];
return nums.map(float2hex).join("");
}
mapGear(gear: PlayerGear): Gear {
return {
name: gear.name,
primaryAbility: gear.primaryGearPower.name,
secondaryAbilities: gear.additionalGearPowers.map((i) => i.name),
}
}
}

View File

@ -588,6 +588,8 @@ export class StatInkExporter implements GameExporter {
} }
} }
result.bankara_power_after = vsDetail.bankaraMatch?.bankaraPower?.power;
if (rankBeforeState && rankState) { if (rankBeforeState && rankState) {
result.rank_before_exp = rankBeforeState.rankPoint; result.rank_before_exp = rankBeforeState.rankPoint;
result.rank_after_exp = rankState.rankPoint; result.rank_after_exp = rankState.rankPoint;

View File

@ -213,20 +213,20 @@ export async function getGToken(
const idToken2: string = respJson?.result?.webApiServerCredential const idToken2: string = respJson?.result?.webApiServerCredential
?.accessToken; ?.accessToken;
const coralUserId: number = respJson?.result?.user?.id; const coralUserId: string = respJson?.result?.user?.id?.toString();
if (!idToken2 || !coralUserId) { if (!idToken2 || !coralUserId) {
throw new APIError({ throw new APIError({
response: resp, response: resp,
json: respJson, json: respJson,
message: message:
`No idToken2 or coralUserId found. Please try again later. ('${idToken2}', '${coralUserId}')`, `No idToken2 or coralUserId found. Please try again later. (${idToken2.length}, ${coralUserId.length})`,
}); });
} }
return [idToken2, coralUserId] as const; return [idToken2, coralUserId] as const;
}; };
const getGToken = async (idToken: string, coralUserId: number) => { const getGToken = async (idToken: string, coralUserId: string) => {
const { f, request_id: requestId, timestamp } = await callImink({ const { f, request_id: requestId, timestamp } = await callImink({
step: 2, step: 2,
idToken, idToken,
@ -414,7 +414,7 @@ async function callImink(
step: number; step: number;
idToken: string; idToken: string;
userId: string; userId: string;
coralUserId?: number; coralUserId?: string;
env: Env; env: Env;
}, },
): Promise<IminkResponse> { ): Promise<IminkResponse> {

View File

@ -15,7 +15,7 @@ import {
} from "./types.ts"; } from "./types.ts";
import { DEFAULT_ENV, Env } from "./env.ts"; import { DEFAULT_ENV, Env } from "./env.ts";
import { getBulletToken, getGToken } from "./iksm.ts"; import { getBulletToken, getGToken } from "./iksm.ts";
import { parseHistoryDetailId } from "./utils.ts"; import { battleTime, parseHistoryDetailId } from "./utils.ts";
export class Splatnet3 { export class Splatnet3 {
protected profile: Profile; protected profile: Profile;
@ -137,6 +137,12 @@ export class Splatnet3 {
[BattleListType.Bankara]: () => [BattleListType.Bankara]: () =>
this.request(Queries.BankaraBattleHistoriesQuery) this.request(Queries.BankaraBattleHistoriesQuery)
.then((r) => getIdsFromGroups(r.bankaraBattleHistories)), .then((r) => getIdsFromGroups(r.bankaraBattleHistories)),
[BattleListType.XBattle]: () =>
this.request(Queries.XBattleHistoriesQuery)
.then((r) => getIdsFromGroups(r.xBattleHistories)),
[BattleListType.Event]: () =>
this.request(Queries.EventBattleHistoriesQuery)
.then((r) => getIdsFromGroups(r.eventBattleHistories)),
[BattleListType.Private]: () => [BattleListType.Private]: () =>
this.request(Queries.PrivateBattleHistoriesQuery) this.request(Queries.PrivateBattleHistoriesQuery)
.then((r) => getIdsFromGroups(r.privateBattleHistories)), .then((r) => getIdsFromGroups(r.privateBattleHistories)),
@ -168,6 +174,29 @@ export class Splatnet3 {
return await this.BATTLE_LIST_TYPE_MAP[battleListType](); return await this.BATTLE_LIST_TYPE_MAP[battleListType]();
} }
// Get all id from all battle list, sort by time, [0] is the latest
async getAllBattleList() {
const ALL_TYPE: BattleListType[] = [
BattleListType.Regular,
BattleListType.Bankara,
BattleListType.XBattle,
BattleListType.Event,
BattleListType.Private,
];
const ids: string[] = [];
for (const type of ALL_TYPE) {
ids.push(...await this.getBattleList(type));
}
const timeMap = new Map<string, Date>(
ids.map((id) => [id, battleTime(id)] as const),
);
return ids.sort((a, b) =>
timeMap.get(b)!.getTime() - timeMap.get(a)!.getTime()
);
}
getBattleDetail( getBattleDetail(
id: string, id: string,
) { ) {

View File

@ -9,6 +9,7 @@ export type VarsMap = {
[Queries.RegularBattleHistoriesQuery]: []; [Queries.RegularBattleHistoriesQuery]: [];
[Queries.BankaraBattleHistoriesQuery]: []; [Queries.BankaraBattleHistoriesQuery]: [];
[Queries.XBattleHistoriesQuery]: []; [Queries.XBattleHistoriesQuery]: [];
[Queries.EventBattleHistoriesQuery]: [];
[Queries.PrivateBattleHistoriesQuery]: []; [Queries.PrivateBattleHistoriesQuery]: [];
[Queries.VsHistoryDetailQuery]: [{ [Queries.VsHistoryDetailQuery]: [{
vsResultId: string; vsResultId: string;
@ -130,6 +131,7 @@ export type PlayerWeapon = {
}; };
}; };
export type VsPlayer = { export type VsPlayer = {
[x: string]: Nameplate;
id: string; id: string;
nameId: string | null; nameId: string | null;
name: string; name: string;
@ -158,6 +160,11 @@ export type Color = {
r: number; r: number;
}; };
export type VsTeam = { export type VsTeam = {
festUniformName: undefined;
festUniformBonusRate: unknown;
festStreakWinCount: undefined;
order: unknown;
judgement: string;
players: VsPlayer[]; players: VsPlayer[];
color: Color; color: Color;
tricolorRole: null | "DEFENSE" | "ATTACK1" | "ATTACK2"; tricolorRole: null | "DEFENSE" | "ATTACK1" | "ATTACK2";
@ -165,6 +172,7 @@ export type VsTeam = {
result: null | { result: null | {
paintRatio: null | number; paintRatio: null | number;
score: null | number; score: null | number;
noroshi: null | number;
}; };
}; };
export type VsRule = export type VsRule =
@ -237,6 +245,9 @@ export type VsHistoryDetail = {
bankaraMatch: { bankaraMatch: {
earnedUdemaePoint: null | number; earnedUdemaePoint: null | number;
mode: "OPEN" | "CHALLENGE"; mode: "OPEN" | "CHALLENGE";
bankaraPower?: null | {
power?: null | number;
};
} | null; } | null;
festMatch: { festMatch: {
dragonMatchType: "NORMAL" | "DECUPLE" | "DRAGON" | "DOUBLE_DRAGON"; dragonMatchType: "NORMAL" | "DECUPLE" | "DRAGON" | "DOUBLE_DRAGON";
@ -420,6 +431,11 @@ export type RespMap = {
}; };
[Queries.BankaraBattleHistoriesQuery]: BankaraBattleHistories; [Queries.BankaraBattleHistoriesQuery]: BankaraBattleHistories;
[Queries.XBattleHistoriesQuery]: XBattleHistories; [Queries.XBattleHistoriesQuery]: XBattleHistories;
[Queries.EventBattleHistoriesQuery]: {
eventBattleHistories: {
historyGroups: HistoryGroups<BattleListNode>;
};
};
[Queries.PrivateBattleHistoriesQuery]: { [Queries.PrivateBattleHistoriesQuery]: {
privateBattleHistories: { privateBattleHistories: {
historyGroups: HistoryGroups<BattleListNode>; historyGroups: HistoryGroups<BattleListNode>;
@ -607,10 +623,14 @@ export enum BattleListType {
Latest, Latest,
Regular, Regular,
Bankara, Bankara,
Event,
XBattle,
Private, Private,
Coop, Coop,
} }
export type ListMethod = "latest" | "all" | "auto";
export type StatInkUuidList = { export type StatInkUuidList = {
status: number; status: number;
code: number; code: number;
@ -815,6 +835,8 @@ export type StatInkPostBody = {
challenge_lose?: number; challenge_lose?: number;
x_power_before?: number | null; x_power_before?: number | null;
x_power_after?: number | null; x_power_after?: number | null;
bankara_power_before?: number | null;
bankara_power_after?: number | null;
fest_power?: number; // Splatfest Power (Pro) fest_power?: number; // Splatfest Power (Pro)
fest_dragon?: fest_dragon?:
| "10x" | "10x"

View File

@ -188,3 +188,14 @@ export function urlSimplify(url: string): { pathname: string } | string {
return url; return url;
} }
} }
export const battleTime = (id: string) => {
const { timestamp } = parseHistoryDetailId(id);
const dateStr = timestamp.replace(
/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/,
"$1-$2-$3T$4:$5:$6Z",
);
return new Date(dateStr);
};