Compare commits

..

65 Commits

Author SHA1 Message Date
Rosalina c38abbf9cb Merge remote-tracking branch 'upstream/main' into splashcat-exporter 2023-12-10 02:42:02 -05:00
spacemeowx2 4a0cda32ab feat: update `NSOAPP_VERSION` 2023-12-07 14:47:08 +08:00
Rosalina ca88b0e490
cleanup splashcat exporter slightly 2023-12-03 18:05:48 -05:00
Rosalina 0a90da9b21
better readme 2023-12-03 18:05:25 -05:00
Rosalina 6dd721bbab
add link_url to stat.ink body 2023-12-03 17:54:59 -05:00
Rosalina 5ac9362df9
better cli args 2023-12-03 17:54:35 -05:00
Rosalina 07ac720d73
prompt for api key 2023-12-03 17:53:13 -05:00
Rosalina 0352665076
Merge remote-tracking branch 'upstream/main' into splashcat-exporter 2023-12-03 17:51:18 -05:00
imspace 454f294045 feat: add 6.0.0 special 2023-12-02 21:39:22 +08:00
imspace 209e5e75ed Revert "fix: use old NSO app version"
This reverts commit 02c1c92191.
2023-12-02 21:04:03 +08:00
Rosalina 7de96fc85f
Merge remote-tracking branch 'upstream/main' into splashcat-exporter 2023-12-01 23:31:44 -05:00
spacemeowx2 02c1c92191 fix: use old NSO app version 2023-12-01 13:46:36 +08:00
spacemeowx2 6a4fd3cceb chore: update VersionData 2023-11-30 13:44:06 +08:00
spacemeowx2 a7b0783f89 chore: update NSOAPP_VERSION 2023-11-30 13:27:55 +08:00
spacemeowx2 bfb7d79609 ci: update pnpm version 2023-11-30 13:19:35 +08:00
spacemeowx2 e4be0f2fe3 chore: update S3SI_VERSION 2023-11-30 12:56:53 +08:00
spacemeowx2 af0ea16ecc chore update `WEB_VIEW_VERSION` 2023-11-30 12:55:31 +08:00
Rosalina 929232e273
Merge remote-tracking branch 'upstream/main' into splashcat-exporter 2023-11-12 21:42:05 -05:00
spacemeowx2 addb535d96 chore: update `NSOAPP_VERSION` 2023-11-09 15:57:05 +08:00
Rosalina daad8f5a94
Merge remote-tracking branch 'upstream/main' into splashcat-exporter 2023-11-09 01:05:29 -05:00
spacemeowx2 94c33bae8f feat: support random primary ability 2023-10-23 20:00:44 +08:00
spacemeowx2 f236a523f7 chore: update `WEB_VIEW_VERSION` 2023-10-17 15:15:51 +08:00
imspace b2555783bb chore: bump version 0.4.9 2023-09-15 15:13:05 +08:00
imspace 0cfe618f2f feat: add species and crown_type
https://github.com/fetus-hina/stat.ink/issues/1227
2023-09-15 15:13:05 +08:00
spacemeowx2 5e36f6c33d feat: add stat.ink types 2023-09-15 15:13:05 +08:00
Rosalina 1f5e994a25
Merge remote-tracking branch 'upstream/main' into splashcat-exporter 2023-09-02 16:06:37 -04:00
spacemeowx2 41d71073dc chore: update S3SI_VERSION 2023-09-01 15:10:36 +08:00
spacemeowx2 5b7d320267 chore: update `WEB_VIEW_VERSION` and queries (0.4.8) 2023-08-31 17:11:59 +08:00
spacemeowx2 a770901759 feat: update VersionData 2023-08-31 17:11:59 +08:00
Rosalina b9fa4fdda7
Merge remote-tracking branch 'upstream/main' into splashcat-exporter 2023-08-28 13:40:13 -04:00
imspace 7a2dedfbe5 chore: update VERSION 2023-08-25 13:54:16 +08:00
spacemeowx2 cad2edeaf5 chore: update `WEB_VIEW_VERSION` (0.4.7) 2023-08-22 16:09:19 +08:00
Rosalina 97ebd5ef80
push mongodb sender 2023-07-27 02:15:16 -04:00
Rosalina b86fcfc64d
Merge remote-tracking branch 'upstream/main' into splashcat-exporter 2023-07-26 14:56:36 -04:00
spacemeowx2 6582ab408b chore: bump version (0.4.6) 2023-07-26 16:47:25 +08:00
spacemeowx2 348cf6045a chore: update WEB_VIEW_VERSION and Queries 2023-07-26 16:45:39 +08:00
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
31 changed files with 1858 additions and 647 deletions

View File

@ -29,7 +29,7 @@ jobs:
- uses: pnpm/action-setup@v2
with:
version: 7.29.1
version: 8.11.0
- name: Sync node version and setup cache
uses: actions/setup-node@v3

View File

@ -1,3 +1,53 @@
## 0.4.12
feat: add 6.0.0 special
## 0.4.11
chore: update `WEB_VIEW_VERSION` and queries
## 0.4.10
feat: support random primary ability
## 0.4.9
feat: add species and crown_type
## 0.4.8
chore: update `WEB_VIEW_VERSION` and queries
feat: update VersionData
## 0.4.7
chore: update `WEB_VIEW_VERSION`
## 0.4.6
chore: update constants
fix: skip updateState if history if empty
([#81](https://github.com/spacemeowx2/s3si.ts/issues/81))
## 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
feat: add support for Challenges

View File

@ -3,7 +3,7 @@
[![Build status](https://github.com/spacemeowx2/s3si.ts/workflows/Build/badge.svg)](https://github.com/spacemeowx2/s3si.ts/actions/workflows/ci.yaml)
[![Constant check status](https://github.com/spacemeowx2/s3si.ts/workflows/Constant%20Check/badge.svg)](https://github.com/spacemeowx2/s3si.ts/actions/workflows/constant-check.yaml)
Export your battles from SplatNet to stat.ink.
Export your battles from SplatNet to stat.ink and Splashcat.
If you have used s3s, please see [here](#migrate-from-s3s).
@ -17,15 +17,19 @@ If you have used s3s, please see [here](#migrate-from-s3s).
```
Options:
--profile-path <path>, -p Path to config file (default: ./profile.json)
--exporter <exporter>, -e Exporter list to use (default: stat.ink)
--exporter <exporter>, -e Exporter list to use (default: stat.ink,splashcat)
Multiple exporters can be separated by commas
(e.g. "stat.ink,file")
(e.g. "stat.ink,file,splashcat")
--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
--monitor, -m Monitor mode
--skip-mode <mode>, -s Skip mode (default: null)
("vs", "coop")
--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
@ -34,6 +38,10 @@ Options:
- If you want to use a different profile, use `-p` to specify the path to the
profile file.
### Splashcat Notes
Splashcat requires battles uploaded to use `en-US` (set with `userLang`). Splashcat will localize battle results into the user's language when displayed.
### Track your rank
- Run
@ -67,7 +75,8 @@ Options:
// userLang will effect the language of the exported games to stat.ink
"userLang": "zh-CN",
"userCountry": "JP",
"statInkApiKey": "..."
"statInkApiKey": "...",
"splashcatApiKey": "..."
}
```
@ -91,3 +100,4 @@ Then run `s3si.ts`, and it will work without login prompt.
- https://github.com/frozenpandaman/s3s
- https://github.com/fetus-hina/stat.ink
- forked from https://github.com/spacemeowx2/s3si.ts

View File

@ -16,6 +16,7 @@
"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/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/mod.ts": "6e781ebafd5cdccf9ab4afa1f499b08c513602d023cb08ceebc58758501f78bd",
"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/win32.ts": "ee8826dce087d31c5c81cd414714e677eb68febc40308de87a2ce4b40e10fb8d",
"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/mod.ts": "e57ba10200d75f2b17570f13eba19faa6734b1be2da5091e2c01039df41274a5",
"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
- [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)
```
pnpm tauri dev
```

View File

@ -11,31 +11,32 @@
"lint": "eslint --max-warnings=0 src"
},
"dependencies": {
"@tauri-apps/api": "^1.3.0",
"@tauri-apps/api": "^1.4.0",
"classnames": "^2.3.2",
"daisyui": "^2.52.0",
"i18next": "^22.5.0",
"i18next-browser-languagedetector": "^7.0.2",
"daisyui": "^3.1.7",
"i18next": "^23.2.6",
"i18next-browser-languagedetector": "^7.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^12.3.1",
"react-icons": "^4.9.0",
"react-router-dom": "^6.11.2",
"react-i18next": "^13.0.1",
"react-icons": "^4.10.1",
"react-router-dom": "^6.14.1",
"react-use": "^17.4.0"
},
"devDependencies": {
"@tauri-apps/cli": "^1.3.1",
"@types/node": "^20.2.5",
"@tauri-apps/cli": "^1.4.0",
"@types/node": "^20.3.3",
"@types/react": "^18.0.15",
"@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",
"eslint": "^8.41.0",
"eslint": "^8.44.0",
"eslint-config-react-app": "^7.0.1",
"i18next-http-backend": "^2.2.1",
"postcss": "^8.4.24",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.4",
"typescript": "^5.1.6",
"vite": "^4.3.9",
"vite-plugin-eslint": "^1.8.1",
"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
[build-dependencies]
tauri-build = { version = "1.2", features = [] }
tauri-build = { version = "1.4.0", features = [] }
[dependencies]
tauri = { version = "1.2", features = ["fs-all", "path-all", "process-relaunch", "shell-execute", "shell-open", "shell-sidecar", "window-all"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["time"] }
urlencoding = "2.1.2"
tauri = { version = "1.4.1", features = [
"fs-all",
"path-all",
"process-relaunch",
"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]
# 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();
// very hacky way...
window.ipc.postMessage(JSON.stringify({
"cmd":"tauri",
"callback":0,
"error":0,
// a little official way...
window.__TAURI_INVOKE__({
"__tauriModule":"Event",
"cmd": "tauri",
"message":{"cmd":"emit","event":"login","payload":{"url":element.href}}
}))
})
}
function detectAndInject() {
const element = document.getElementById('authorize-switch-approval-link');
@ -74,12 +72,7 @@ document.addEventListener("DOMContentLoaded", () => {{
#[tauri::command]
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(format!("/redirect?url={encoded}").into()),
)
let window = WindowBuilder::new(&app, "login", tauri::WindowUrl::App(url.into()))
.title("Login")
.center()
.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": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
@ -8,7 +9,7 @@
},
"package": {
"productName": "s3si-ts",
"version": "0.4.1"
"version": "0.4.12"
},
"tauri": {
"allowlist": {
@ -69,7 +70,16 @@
]
},
"security": {
"csp": null
"csp": null,
"dangerousRemoteDomainIpcAccess": [
{
"windows": [
"login"
],
"domain": "accounts.nintendo.com",
"enableTauriAPI": true
}
]
},
"updater": {
"active": false,

View File

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

View File

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

View File

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

View File

@ -2,40 +2,17 @@
@tailwind components;
@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 {
width: 100vw;
height: 100vh;
}
#root {
width: 100vw;
height: 100vh;
overflow: hidden
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
}
/* custom classes */
.flex-auto-all > * {

View File

@ -16,7 +16,7 @@ export const Home: React.FC = () => {
<Link to='/settings' className='btn'>{t('设置')}</Link>
<div className='flex gap-2 flex-auto-all'>
<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>

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 parsed = flags.parse(args, {
string: ["profilePath", "exporter", "skipMode"],
string: ["profilePath", "exporter", "skipMode", "listMethod"],
boolean: ["help", "noProgress", "monitor", "withSummary"],
alias: {
"help": "h",
@ -15,6 +15,7 @@ const parseArgs = (args: string[]) => {
"skipMode": ["s", "skip-mode"],
"withSummary": "with-summary",
"withStages": "with-stages",
"listMethod": "list-method",
},
});
return parsed;
@ -29,7 +30,11 @@ Options:
--profile-path <path>, -p Path to config file (default: ./profile.json)
--exporter <exporter>, -e Exporter list to use (default: stat.ink)
Multiple exporters can be separated by commas
(e.g. "stat.ink,file,mongodb")
(e.g. "stat.ink,file,mongodb,splashcat")
--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
--monitor, -m Monitor mode
--skip-mode <mode>, -s Skip mode (default: null)

View File

@ -87,7 +87,7 @@
},
"npm": {
"specifiers": {
"mongodb": "mongodb@5.1.0",
"mongodb": "mongodb@5.5.0",
"splatnet3-types": "splatnet3-types@0.2.20230227204004"
},
"packages": {
@ -110,6 +110,10 @@
"integrity": "sha512-y09gBGusgHtinMon/GVbv1J6FrXhnr/+6hqLlSmEFzkz6PodqF6TxjyvfvY3AfO+oG1mgUtbC86xSbOlwvM62Q==",
"dependencies": {}
},
"bson@5.3.0": {
"integrity": "sha512-ukmCZMneMlaC5ebPHXIkP8YJzNl5DC41N5MAIvKDqLggdao342t4McltoJBQfQya/nHBWAcSsYRqlXPoQkTJag==",
"dependencies": {}
},
"ip@2.0.0": {
"integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
"dependencies": {}
@ -134,6 +138,15 @@
"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": {
"integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==",
"dependencies": {}

View File

@ -1,6 +1,7 @@
import { MongoDB } from "../deps.ts";
import { DEFAULT_ENV } from "../src/env.ts";
import { MongoDBExporter } from "../src/exporters/mongodb.ts";
import { SplashcatExporter } from "../src/exporters/splashcat.ts";
import { FileStateBackend, Profile } from "../src/state.ts";
const env = DEFAULT_ENV;
@ -13,6 +14,12 @@ if (!profile.state.mongoDbUri) {
Deno.exit(1);
}
const splashcatClient = new SplashcatExporter({
env,
splashcatApiKey: profile.state.splashcatApiKey!,
uploadMode: "manual",
});
const mongoDbClient = new MongoDB.MongoClient(profile.state.mongoDbUri);
const battlesCollection = mongoDbClient.db("splashcat").collection("battles");
@ -27,32 +34,30 @@ let count = 0;
const erroredBattles = [];
for await (const doc of cursor) {
const { splatNetData, _id } = doc;
const { data, splatNetData, _id } = doc;
// start time for performance tracking, needs to be very accurate
const startTime = new Date();
splatNetData.playedTime = splatNetData.playedTime.toISOString();
const response = await fetch("http://127.0.0.1:8000/battles/api/upload/", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${profile.state.splashcatApiKey}`,
},
body: JSON.stringify({
"data_type": "splatnet3",
"battle": splatNetData,
})
})
if (!response.ok) {
console.error(`Failed to upload ${splatNetData.id}`);
erroredBattles.push({
id: doc.gameId,
error: await response.text(),
try {
if (data) {
await splashcatClient.exportGame(data);
} else {
await splashcatClient.exportGame({
type: "VsInfo",
detail: splatNetData,
bankaraMatchChallenge: null,
challengeProgress: null,
groupInfo: null,
listNode: null,
rankBeforeState: null,
rankState: null,
});
}
} catch (e) {
console.log("Failed to export game", e);
erroredBattles.push(e.toString());
}
// end time for performance tracking, needs to be very accurate
const endTime = new Date();

View File

@ -5,7 +5,7 @@ import {
HistoryGroups,
RankParam,
} from "./types.ts";
import { gameId, parseHistoryDetailId } from "./utils.ts";
import { battleTime, gameId } from "./utils.ts";
import { getSeason } from "./VersionData.ts";
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 = {
id: string;
gameId: string;
@ -358,6 +347,10 @@ export class RankTracker {
async updateState(
history: HistoryGroups<BattleListNode>["nodes"],
) {
if (history.length === 0) {
return;
}
// history order by time. 0 is the oldest.
const flatten: FlattenItem[] = await Promise.all(
history

View File

@ -30,6 +30,18 @@ export const SEASONS: Season[] = [
start: new Date("2023-06-01T00:00:00+00:00"),
end: new Date("2023-09-01T00:00:00+00:00"),
},
{
id: "season202309",
name: "Drizzle Season 2023",
start: new Date("2023-09-01T00:00:00+00:00"),
end: new Date("2023-12-01T00:00:00+00:00"),
},
{
id: "season202312",
name: "Chill Season 2023",
start: new Date("2023-12-01T00:00:00+00:00"),
end: new Date("2024-03-01T00:00:00+00:00"),
},
];
export const getSeason = (date: Date): Season | undefined => {

View File

@ -1,8 +1,8 @@
import { loginManually } from "./iksm.ts";
import { MultiProgressBar } from "../deps.ts";
import { MultiProgressBar, Mutex } from "../deps.ts";
import { FileStateBackend, Profile, StateBackend } from "./state.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 { StatInkExporter } from "./exporters/stat.ink.ts";
import { FileExporter } from "./exporters/file.ts";
@ -10,6 +10,7 @@ import { delay, showError } from "./utils.ts";
import { GameFetcher } from "./GameFetcher.ts";
import { DEFAULT_ENV, Env } from "./env.ts";
import { MongoDBExporter } from "./exporters/mongodb.ts";
import { SplashcatExporter } from "./exporters/splashcat.ts";
export type Opts = {
profilePath: string;
@ -19,6 +20,7 @@ export type Opts = {
withSummary: boolean;
withStages: boolean;
skipMode?: string;
listMethod?: string;
cache?: Cache;
stateBackend?: StateBackend;
env: Env;
@ -26,11 +28,12 @@ export type Opts = {
export const DEFAULT_OPTS: Opts = {
profilePath: "./profile.json",
exporter: "stat.ink",
exporter: "stat.ink,splashcat",
noProgress: false,
monitor: false,
withSummary: false,
withStages: true,
listMethod: "auto",
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 {
return {
total,
@ -75,6 +175,12 @@ export class App {
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")[] {
@ -137,6 +243,27 @@ export class App {
);
}
if (exporters.includes("splashcat")) {
if (!state.splashcatApiKey) {
const key = (await this.env.prompts.prompt(
"Splashcat API key is not set. Please enter below.",
)).trim();
if (!key) {
this.env.logger.error("API key is required.");
Deno.exit(1);
}
await this.profile.writeState({
...state,
splashcatApiKey: key,
});
}
out.push(new SplashcatExporter({
env: this.env,
uploadMode: this.opts.monitor ? "Monitoring" : "Manual",
splashcatApiKey: this.profile.state.splashcatApiKey!,
}));
}
return out;
}
exporterProgress(title: string) {
@ -184,8 +311,10 @@ export class App {
if (skipMode.includes("vs") || exporters.length === 0) {
this.env.logger.log("Skip exporting VS games.");
} else {
this.env.logger.log("Fetching battle list...");
const gameList = await splatnet.getBattleList();
const gameListFetcher = new BattleListFetcher(
this.opts.listMethod ?? "auto",
splatnet,
);
const { redraw, endBar } = this.exporterProgress("Export vs games");
const fetcher = new GameFetcher({
@ -204,7 +333,7 @@ export class App {
type: "VsInfo",
fetcher,
exporter: e,
gameList,
gameListFetcher,
stepProgress: stats[e.name],
onStep: () => {
redraw(e.name, progress(stats[e.name]));
@ -238,10 +367,7 @@ export class App {
if (skipMode.includes("coop") || exporters.length === 0) {
this.env.logger.log("Skip exporting coop games.");
} else {
this.env.logger.log("Fetching coop battle list...");
const coopBattleList = await splatnet.getBattleList(
BattleListType.Coop,
);
const gameListFetcher = new CoopListFetcher(splatnet);
const { redraw, endBar } = this.exporterProgress("Export coop games");
const fetcher = new GameFetcher({
@ -258,7 +384,7 @@ export class App {
type: "CoopInfo",
fetcher,
exporter: e,
gameList: coopBattleList,
gameListFetcher,
stepProgress: stats[e.name],
onStep: () => {
redraw(e.name, progress(stats[e.name]));
@ -392,30 +518,24 @@ export class App {
* @param gameList ID list of games, sorted by date, newest first
* @param onStep Callback function called when a game is exported
*/
async exportGameList({
private async exportGameList({
type,
fetcher,
exporter,
gameList,
gameListFetcher,
stepProgress,
onStep,
}: {
type: Game["type"];
exporter: GameExporter;
fetcher: GameFetcher;
gameList: string[];
gameListFetcher: GameListFetcher;
stepProgress: StepProgress;
onStep: () => void;
}): Promise<StepProgress> {
onStep?.();
const workQueue = [
...await exporter.notExported({
type,
list: gameList,
}),
]
.reverse();
const workQueue = await gameListFetcher.fetch(exporter);
const step = async (id: string) => {
const detail = await fetcher.fetch(type, id);

View File

@ -2,26 +2,41 @@ import type { StatInkPostBody, VsHistoryDetail } from "./types.ts";
export const AGENT_NAME = "splashcat / s3si.ts";
export const AGENT_VERSION = "1.1.1";
export const S3SI_VERSION = "0.4.1";
export const S3SI_VERSION = "0.4.12";
export const COMBINED_VERSION = `${AGENT_VERSION}/${S3SI_VERSION}`;
export const NSOAPP_VERSION = "2.5.1";
export const WEB_VIEW_VERSION = "4.0.0-d5178440";
export const NSOAPP_VERSION = "2.8.1";
export const WEB_VIEW_VERSION = "6.0.0-e135295b";
export enum Queries {
HomeQuery = "7dcc64ea27a08e70919893a0d3f70871",
LatestBattleHistoriesQuery = "0d90c7576f1916469b2ae69f64292c02",
RegularBattleHistoriesQuery = "3baef04b095ad8975ea679d722bc17de",
BankaraBattleHistoriesQuery = "0438ea6978ae8bd77c5d1250f4f84803",
XBattleHistoriesQuery = "6796e3cd5dc3ebd51864dc709d899fc5",
PrivateBattleHistoriesQuery = "8e5ae78b194264a6c230e262d069bd28",
VsHistoryDetailQuery = "9ee0099fbe3d8db2a838a75cf42856dd",
CoopHistoryQuery = "91b917becd2fa415890f5b47e15ffb15",
CoopHistoryDetailQuery = "379f0d9b78b531be53044bcac031b34b",
HomeQuery =
"51fc56bbf006caf37728914aa8bc0e2c86a80cf195b4d4027d6822a3623098a8",
LatestBattleHistoriesQuery =
"b24d22fd6cb251c515c2b90044039698aa27bc1fab15801d83014d919cd45780",
RegularBattleHistoriesQuery =
"2fe6ea7a2de1d6a888b7bd3dbeb6acc8e3246f055ca39b80c4531bbcd0727bba",
BankaraBattleHistoriesQuery =
"9863ea4744730743268e2940396e21b891104ed40e2286789f05100b45a0b0fd",
XBattleHistoriesQuery =
"eb5996a12705c2e94813a62e05c0dc419aad2811b8d49d53e5732290105559cb",
EventBattleHistoriesQuery =
"e47f9aac5599f75c842335ef0ab8f4c640e8bf2afe588a3b1d4b480ee79198ac",
PrivateBattleHistoriesQuery =
"fef94f39b9eeac6b2fac4de43bc0442c16a9f2df95f4d367dd8a79d7c5ed5ce7",
VsHistoryDetailQuery =
"f893e1ddcfb8a4fd645fd75ced173f18b2750e5cfba41d2669b9814f6ceaec46",
CoopHistoryQuery =
"0f8c33970a425683bb1bdecca50a0ca4fb3c3641c0b2a1237aedfde9c0cb2b8f",
CoopHistoryDetailQuery =
"42262d241291d7324649e21413b29da88c0314387d8fdf5f6637a2d9d29954ae",
myOutfitCommonDataFilteringConditionQuery =
"d02ab22c9dccc440076055c8baa0fa7a",
myOutfitCommonDataEquipmentsQuery = "d29cd0c2b5e6bac90dd5b817914832f8",
HistoryRecordQuery = "d9246baf077b2a29b5f7aac321810a77",
ConfigureAnalyticsQuery = "f8ae00773cc412a50dd41a6d9a159ddd",
StageRecordQuery = "f08a932d533845dde86e674e03bbb7d3",
"ac20c44a952131cb0c9d00eda7bc1a84c1a99546f0f1fc170212d5a6bb51a426",
myOutfitCommonDataEquipmentsQuery =
"45a4c343d973864f7bb9e9efac404182be1d48cf2181619505e9b7cd3b56a6e8",
HistoryRecordQuery =
"0a62c0152f27c4218cf6c87523377521c2cff76a4ef0373f2da3300079bf0388",
ConfigureAnalyticsQuery =
"2a9302bdd09a13f8b344642d4ed483b9464f20889ac17401e993dfa5c2bb3607",
StageRecordQuery =
"c8b31c491355b4d889306a22bd9003ac68f8ce31b2d5345017cdd30a2c8056f3",
}
export const S3SI_LINK = "https://forgejo.catgirlin.space/catgirl/s3si.ts";
@ -94,6 +109,10 @@ export const SPLATNET3_STATINK_MAP: {
"sameride",
"380e541b5bc5e49d77ff1a616f1343aeba01d500fee36aaddf8f09d74bd3d3bc":
"tripletornado",
"8a7ee88a06407f4be1595ef8af4d2d2ac22bbf213a622cd19bbfaf4d0f36bcd7":
"teioika",
"a75eac34675bc0d4bd9ca9977cf22472848f89e28e08ee986b4461a3f2af28fc":
"ultra_chakuchi",
},
WATER_LEVEL_MAP: {
0: "low",

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;
}

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

@ -0,0 +1,285 @@
import {
USERAGENT,
} from "../constant.ts";
import {
Color,
ExportResult,
Game,
GameExporter,
Nameplate,
PlayerGear,
VsInfo,
VsPlayer,
VsTeam,
} from "../types.ts";
import { base64, msgpack, Mutex } from "../../deps.ts";
import { APIError } from "../APIError.ts";
import {
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 = await resp.json();
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 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

@ -366,7 +366,7 @@ export class StatInkExporter implements GameExporter {
{ primaryGearPower, additionalGearPowers }: PlayerGear,
): StatInkGear => {
const primary = mapAbility(primaryGearPower);
if (!primary) {
if (!primary && !this.isRandom(primaryGearPower.image)) {
throw new Error("Unknown ability: " + primaryGearPower.name);
}
return {
@ -394,6 +394,8 @@ export class StatInkExporter implements GameExporter {
inked: player.paint,
gears: await this.mapGears(player),
crown: player.crown ? "yes" : "no",
crown_type: undefined,
species: player.species === "INKLING" ? "inkling" : "octoling",
disconnected: player.result ? "no" : "yes",
};
if (player.result) {
@ -404,6 +406,13 @@ export class StatInkExporter implements GameExporter {
result.signal = player.result.noroshiTry ?? undefined;
result.special = player.result.special;
}
if (player.crown) {
result.crown_type = "x";
} else if (player.festDragonCert === "DRAGON") {
result.crown_type = "100x";
} else if (player.festDragonCert === "DOUBLE_DRAGON") {
result.crown_type = "333x";
}
return result;
};
async mapBattle(
@ -588,6 +597,8 @@ export class StatInkExporter implements GameExporter {
}
}
result.bankara_power_after = vsDetail.bankaraMatch?.bankaraPower?.power;
if (rankBeforeState && rankState) {
result.rank_before_exp = rankBeforeState.rankPoint;
result.rank_after_exp = rankState.rankPoint;
@ -619,16 +630,18 @@ export class StatInkExporter implements GameExporter {
}
isRandom(image: Image | null): boolean {
// question mark
const RANDOM_FILENAME =
"473fffb2442075078d8bb7125744905abdeae651b6a5b7453ae295582e45f7d1";
const RANDOM_FILENAME = [
"473fffb2442075078d8bb7125744905abdeae651b6a5b7453ae295582e45f7d1",
"dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91",
];
// file exporter will replace url to { pathname: string } | string
const url = image?.url as ReturnType<typeof urlSimplify> | undefined | null;
if (typeof url === "string") {
return url.includes(RANDOM_FILENAME);
return RANDOM_FILENAME.some((i) => url.includes(i));
} else if (url === undefined || url === null) {
return false;
} else {
return url.pathname.includes(RANDOM_FILENAME);
return RANDOM_FILENAME.some((i) => url.pathname.includes(i));
}
}
async mapCoopWeapon(
@ -698,6 +711,7 @@ export class StatInkExporter implements GameExporter {
rescued: rescuedCount,
defeat_boss: defeatEnemyCount,
disconnected: disconnected ? "yes" : "no",
species: player.species === "INKLING" ? "inkling" : "octoling",
};
}
mapKing(id?: string) {

View File

@ -213,20 +213,20 @@ export async function getGToken(
const idToken2: string = respJson?.result?.webApiServerCredential
?.accessToken;
const coralUserId: number = respJson?.result?.user?.id;
const coralUserId: string = respJson?.result?.user?.id?.toString();
if (!idToken2 || !coralUserId) {
throw new APIError({
response: resp,
json: respJson,
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;
};
const getGToken = async (idToken: string, coralUserId: number) => {
const getGToken = async (idToken: string, coralUserId: string) => {
const { f, request_id: requestId, timestamp } = await callImink({
step: 2,
idToken,
@ -414,7 +414,7 @@ async function callImink(
step: number;
idToken: string;
userId: string;
coralUserId?: number;
coralUserId?: string;
env: Env;
},
): Promise<IminkResponse> {

View File

@ -15,7 +15,7 @@ import {
} from "./types.ts";
import { DEFAULT_ENV, Env } from "./env.ts";
import { getBulletToken, getGToken } from "./iksm.ts";
import { parseHistoryDetailId } from "./utils.ts";
import { battleTime, parseHistoryDetailId } from "./utils.ts";
export class Splatnet3 {
protected profile: Profile;
@ -137,6 +137,12 @@ export class Splatnet3 {
[BattleListType.Bankara]: () =>
this.request(Queries.BankaraBattleHistoriesQuery)
.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]: () =>
this.request(Queries.PrivateBattleHistoriesQuery)
.then((r) => getIdsFromGroups(r.privateBattleHistories)),
@ -168,6 +174,29 @@ export class Splatnet3 {
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(
id: string,
) {

View File

@ -9,6 +9,7 @@ export type VarsMap = {
[Queries.RegularBattleHistoriesQuery]: [];
[Queries.BankaraBattleHistoriesQuery]: [];
[Queries.XBattleHistoriesQuery]: [];
[Queries.EventBattleHistoriesQuery]: [];
[Queries.PrivateBattleHistoriesQuery]: [];
[Queries.VsHistoryDetailQuery]: [{
vsResultId: string;
@ -130,6 +131,7 @@ export type PlayerWeapon = {
};
};
export type VsPlayer = {
[x: string]: Nameplate;
id: string;
nameId: string | null;
name: string;
@ -146,6 +148,7 @@ export type VsPlayer = {
} | null;
paint: number;
crown: boolean;
festDragonCert: "NONE" | "DRAGON" | "DOUBLE_DRAGON";
headGear: PlayerGear;
clothingGear: PlayerGear;
@ -158,6 +161,11 @@ export type Color = {
r: number;
};
export type VsTeam = {
festUniformName: undefined;
festUniformBonusRate: unknown;
festStreakWinCount: undefined;
order: unknown;
judgement: string;
players: VsPlayer[];
color: Color;
tricolorRole: null | "DEFENSE" | "ATTACK1" | "ATTACK2";
@ -165,6 +173,7 @@ export type VsTeam = {
result: null | {
paintRatio: null | number;
score: null | number;
noroshi: null | number;
};
};
export type VsRule =
@ -237,6 +246,9 @@ export type VsHistoryDetail = {
bankaraMatch: {
earnedUdemaePoint: null | number;
mode: "OPEN" | "CHALLENGE";
bankaraPower?: null | {
power?: null | number;
};
} | null;
festMatch: {
dragonMatchType: "NORMAL" | "DECUPLE" | "DRAGON" | "DOUBLE_DRAGON";
@ -268,6 +280,8 @@ export type CoopHistoryPlayerResult = {
name: string;
id: string;
};
isMyself: boolean;
species: "INKLING" | "OCTOLING";
};
weapons: { name: string; image: Image | null }[];
specialWeapon: null | {
@ -420,6 +434,11 @@ export type RespMap = {
};
[Queries.BankaraBattleHistoriesQuery]: BankaraBattleHistories;
[Queries.XBattleHistoriesQuery]: XBattleHistories;
[Queries.EventBattleHistoriesQuery]: {
eventBattleHistories: {
historyGroups: HistoryGroups<BattleListNode>;
};
};
[Queries.PrivateBattleHistoriesQuery]: {
privateBattleHistories: {
historyGroups: HistoryGroups<BattleListNode>;
@ -607,10 +626,14 @@ export enum BattleListType {
Latest,
Regular,
Bankara,
Event,
XBattle,
Private,
Coop,
}
export type ListMethod = "latest" | "all" | "auto";
export type StatInkUuidList = {
status: number;
code: number;
@ -630,7 +653,7 @@ export type StatInkWeapon = {
}[];
export type StatInkGear = {
primary_ability: string;
primary_ability: string | null;
secondary_abilities: (string | null)[];
};
@ -656,7 +679,9 @@ export type StatInkPlayer = {
special?: number;
gears?: StatInkGears;
crown?: "yes" | "no";
crown_type?: "x" | "100x" | "333x";
disconnected: "yes" | "no";
species: "inkling" | "octoling";
};
export type StatInkStage = {
@ -706,6 +731,7 @@ export type StatInkCoopPlayer = {
rescued: number;
defeat_boss: number;
disconnected: "yes" | "no";
species: "inkling" | "octoling";
};
export type StatInkCoopBoss = {
@ -759,6 +785,7 @@ export type StatInkCoopPostBody = {
};
export type StatInkPostBody = {
link_url?: string;
test?: "yes" | "no";
uuid: string;
lobby:
@ -815,6 +842,8 @@ export type StatInkPostBody = {
challenge_lose?: number;
x_power_before?: number | null;
x_power_after?: number | null;
bankara_power_before?: number | null;
bankara_power_after?: number | null;
fest_power?: number; // Splatfest Power (Pro)
fest_dragon?:
| "10x"

View File

@ -188,3 +188,14 @@ export function urlSimplify(url: string): { pathname: string } | string {
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);
};